@@ -77,6 +77,7 @@ const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postproce
const csAudioMulti = new CardSection ( 'audio-multi' , { titleKey : 'audio_source.group.multichannel' , gridClass : 'templates-grid' , addCardOnclick : "showAudioSourceModal('multichannel')" , keyAttr : 'data-id' } ) ;
const csAudioMono = new CardSection ( 'audio-mono' , { titleKey : 'audio_source.group.mono' , gridClass : 'templates-grid' , addCardOnclick : "showAudioSourceModal('mono')" , keyAttr : 'data-id' } ) ;
const csStaticStreams = new CardSection ( 'static-streams' , { titleKey : 'streams.group.static_image' , gridClass : 'templates-grid' , addCardOnclick : "showAddStreamModal('static_image')" , keyAttr : 'data-stream-id' } ) ;
const csVideoStreams = new CardSection ( 'video-streams' , { titleKey : 'streams.group.video' , gridClass : 'templates-grid' , addCardOnclick : "showAddStreamModal('video')" , keyAttr : 'data-stream-id' } ) ;
const csAudioTemplates = new CardSection ( 'audio-templates' , { titleKey : 'audio_template.title' , gridClass : 'templates-grid' , addCardOnclick : "showAddAudioTemplateModal()" , keyAttr : 'data-audio-template-id' } ) ;
const csColorStrips = new CardSection ( 'color-strips' , { titleKey : 'targets.section.color_strips' , gridClass : 'templates-grid' , addCardOnclick : "showCSSEditor()" , keyAttr : 'data-css-id' } ) ;
const csValueSources = new CardSection ( 'value-sources' , { titleKey : 'value_source.group.title' , gridClass : 'templates-grid' , addCardOnclick : "showValueSourceModal()" , keyAttr : 'data-id' } ) ;
@@ -1250,6 +1251,7 @@ const _streamSectionMap = {
raw : [ csRawStreams ] ,
raw _templates : [ csRawTemplates ] ,
static _image : [ csStaticStreams ] ,
video : [ csVideoStreams ] ,
processed : [ csProcStreams ] ,
proc _templates : [ csProcTemplates ] ,
css _processing : [ csCSPTemplates ] ,
@@ -1307,6 +1309,15 @@ function renderPictureSourcesList(streams) {
detailsHtml = ` <div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title=" ${ escapeHtml ( src ) } "> ${ ICON _WEB } ${ escapeHtml ( src ) } </span>
</div> ` ;
} else if ( stream . stream _type === 'video' ) {
const url = stream . url || '' ;
const shortUrl = url . length > 40 ? url . slice ( 0 , 37 ) + '...' : url ;
detailsHtml = ` <div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title=" ${ escapeHtml ( url ) } "> ${ ICON _WEB } ${ escapeHtml ( shortUrl ) } </span>
<span class="stream-card-prop" title=" ${ t ( 'streams.target_fps' ) } "> ${ ICON _FPS } ${ stream . target _fps ? ? 30 } </span>
${ stream . loop !== false ? ` <span class="stream-card-prop">↻</span> ` : '' }
${ stream . playback _speed && stream . playback _speed !== 1.0 ? ` <span class="stream-card-prop"> ${ stream . playback _speed } × </span>` : '' }
</div> ` ;
}
return wrapCard ( {
@@ -1427,6 +1438,7 @@ function renderPictureSourcesList(streams) {
const rawStreams = streams . filter ( s => s . stream _type === 'raw' ) ;
const processedStreams = streams . filter ( s => s . stream _type === 'processed' ) ;
const staticImageStreams = streams . filter ( s => s . stream _type === 'static_image' ) ;
const videoStreams = streams . filter ( s => s . stream _type === 'video' ) ;
const multichannelSources = _cachedAudioSources . filter ( s => s . source _type === 'multichannel' ) ;
const monoSources = _cachedAudioSources . filter ( s => s . source _type === 'mono' ) ;
@@ -1445,6 +1457,7 @@ function renderPictureSourcesList(streams) {
{ key : 'raw' , icon : getPictureSourceIcon ( 'raw' ) , titleKey : 'streams.group.raw' , count : rawStreams . length } ,
{ key : 'raw_templates' , icon : ICON _CAPTURE _TEMPLATE , titleKey : 'streams.group.raw_templates' , count : _cachedCaptureTemplates . length } ,
{ key : 'static_image' , icon : getPictureSourceIcon ( 'static_image' ) , titleKey : 'streams.group.static_image' , count : staticImageStreams . length } ,
{ key : 'video' , icon : getPictureSourceIcon ( 'video' ) , titleKey : 'streams.group.video' , count : videoStreams . length } ,
{ key : 'processed' , icon : getPictureSourceIcon ( 'processed' ) , titleKey : 'streams.group.processed' , count : processedStreams . length } ,
{ key : 'proc_templates' , icon : ICON _PP _TEMPLATE , titleKey : 'streams.group.proc_templates' , count : _cachedPPTemplates . length } ,
{ key : 'css_processing' , icon : ICON _CSPT , titleKey : 'streams.group.css_processing' , count : csptTemplates . length } ,
@@ -1467,6 +1480,10 @@ function renderPictureSourcesList(streams) {
key : 'static_image' , icon : getPictureSourceIcon ( 'static_image' ) , titleKey : 'streams.group.static_image' ,
count : staticImageStreams . length ,
} ,
{
key : 'video' , icon : getPictureSourceIcon ( 'video' ) , titleKey : 'streams.group.video' ,
count : videoStreams . length ,
} ,
{
key : 'processing_group' , icon : getPictureSourceIcon ( 'processed' ) , titleKey : 'tree.group.processing' ,
children : [
@@ -1590,6 +1607,7 @@ function renderPictureSourcesList(streams) {
const monoItems = csAudioMono . applySortOrder ( monoSources . map ( s => ( { key : s . id , html : renderAudioSourceCard ( s ) } ) ) ) ;
const audioTemplateItems = csAudioTemplates . applySortOrder ( _cachedAudioTemplates . map ( t => ( { key : t . id , html : renderAudioTemplateCard ( t ) } ) ) ) ;
const staticItems = csStaticStreams . applySortOrder ( staticImageStreams . map ( s => ( { key : s . id , html : renderStreamCard ( s ) } ) ) ) ;
const videoItems = csVideoStreams . applySortOrder ( videoStreams . map ( s => ( { key : s . id , html : renderStreamCard ( s ) } ) ) ) ;
const colorStripItems = csColorStrips . applySortOrder ( colorStrips . map ( s => ( { key : s . id , html : createColorStripCard ( s , pictureSourceMap , audioSourceMap ) } ) ) ) ;
const valueItems = csValueSources . applySortOrder ( _cachedValueSources . map ( s => ( { key : s . id , html : createValueSourceCard ( s ) } ) ) ) ;
const syncClockItems = csSyncClocks . applySortOrder ( _cachedSyncClocks . map ( s => ( { key : s . id , html : createSyncClockCard ( s ) } ) ) ) ;
@@ -1601,6 +1619,7 @@ function renderPictureSourcesList(streams) {
raw : rawStreams . length ,
raw _templates : _cachedCaptureTemplates . length ,
static _image : staticImageStreams . length ,
video : videoStreams . length ,
processed : processedStreams . length ,
proc _templates : _cachedPPTemplates . length ,
css _processing : csptTemplates . length ,
@@ -1619,6 +1638,7 @@ function renderPictureSourcesList(streams) {
csAudioMono . reconcile ( monoItems ) ;
csAudioTemplates . reconcile ( audioTemplateItems ) ;
csStaticStreams . reconcile ( staticItems ) ;
csVideoStreams . reconcile ( videoItems ) ;
csValueSources . reconcile ( valueItems ) ;
csSyncClocks . reconcile ( syncClockItems ) ;
} else {
@@ -1634,12 +1654,13 @@ function renderPictureSourcesList(streams) {
else if ( tab . key === 'audio' ) panelContent = csAudioMulti . render ( multiItems ) + csAudioMono . render ( monoItems ) + csAudioTemplates . render ( audioTemplateItems ) ;
else if ( tab . key === 'value' ) panelContent = csValueSources . render ( valueItems ) ;
else if ( tab . key === 'sync' ) panelContent = csSyncClocks . render ( syncClockItems ) ;
else if ( tab . key === 'video' ) panelContent = csVideoStreams . render ( videoItems ) ;
else panelContent = csStaticStreams . render ( staticItems ) ;
return ` <div class="stream-tab-panel ${ tab . key === activeTab ? ' active' : '' } " id="stream-tab- ${ tab . key } "> ${ panelContent } </div> ` ;
} ) . join ( '' ) ;
container . innerHTML = panels ;
CardSection . bindAll ( [ csRawStreams , csRawTemplates , csProcStreams , csProcTemplates , csCSPTemplates , csColorStrips , csAudioMulti , csAudioMono , csAudioTemplates , csStaticStreams , csValueSources , csSyncClocks ] ) ;
CardSection . bindAll ( [ csRawStreams , csRawTemplates , csProcStreams , csProcTemplates , csCSPTemplates , csColorStrips , csAudioMulti , csAudioMono , csAudioTemplates , csStaticStreams , csVideoStreams , csValueSources , csSyncClocks ] ) ;
// Render tree sidebar with expand/collapse buttons
_streamsTree . setExtraHtml ( ` <button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title=" ${ t ( 'section.expand_all' ) } ">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title=" ${ t ( 'section.collapse_all' ) } ">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title=" ${ t ( 'tour.restart' ) } "> ${ ICON _HELP } </button> ` ) ;
@@ -1647,6 +1668,7 @@ function renderPictureSourcesList(streams) {
_streamsTree . observeSections ( 'streams-list' , {
'raw-streams' : 'raw' , 'raw-templates' : 'raw_templates' ,
'static-streams' : 'static_image' ,
'video-streams' : 'video' ,
'proc-streams' : 'processed' , 'proc-templates' : 'proc_templates' ,
'css-proc-templates' : 'css_processing' ,
'color-strips' : 'color_strip' ,
@@ -1662,6 +1684,7 @@ export function onStreamTypeChange() {
document . getElementById ( 'stream-raw-fields' ) . style . display = streamType === 'raw' ? '' : 'none' ;
document . getElementById ( 'stream-processed-fields' ) . style . display = streamType === 'processed' ? '' : 'none' ;
document . getElementById ( 'stream-static-image-fields' ) . style . display = streamType === 'static_image' ? '' : 'none' ;
document . getElementById ( 'stream-video-fields' ) . style . display = streamType === 'video' ? '' : 'none' ;
}
export function onStreamDisplaySelected ( displayIndex , display ) {
@@ -1705,7 +1728,7 @@ function _autoGenerateStreamName() {
export async function showAddStreamModal ( presetType , cloneData = null ) {
const streamType = ( cloneData && cloneData . stream _type ) || presetType || 'raw' ;
const titleKeys = { raw : 'streams.add.raw' , processed : 'streams.add.processed' , static _image : 'streams.add.static_image' } ;
const titleKeys = { raw : 'streams.add.raw' , processed : 'streams.add.processed' , static _image : 'streams.add.static_image' , video : 'streams.add.video' } ;
document . getElementById ( 'stream-modal-title' ) . innerHTML = ` ${ getPictureSourceIcon ( streamType ) } ${ t ( titleKeys [ streamType ] || 'streams.add' ) } ` ;
document . getElementById ( 'stream-form' ) . reset ( ) ;
document . getElementById ( 'stream-id' ) . value = '' ;
@@ -1754,6 +1777,16 @@ export async function showAddStreamModal(presetType, cloneData = null) {
} else if ( streamType === 'static_image' ) {
document . getElementById ( 'stream-image-source' ) . value = cloneData . image _source || '' ;
if ( cloneData . image _source ) validateStaticImage ( ) ;
} else if ( streamType === 'video' ) {
document . getElementById ( 'stream-video-url' ) . value = cloneData . url || '' ;
document . getElementById ( 'stream-video-loop' ) . checked = cloneData . loop !== false ;
document . getElementById ( 'stream-video-speed' ) . value = cloneData . playback _speed || 1.0 ;
const cloneSpeedLabel = document . getElementById ( 'stream-video-speed-value' ) ;
if ( cloneSpeedLabel ) cloneSpeedLabel . textContent = cloneData . playback _speed || 1.0 ;
document . getElementById ( 'stream-video-fps' ) . value = cloneData . target _fps || 30 ;
document . getElementById ( 'stream-video-start' ) . value = cloneData . start _time || '' ;
document . getElementById ( 'stream-video-end' ) . value = cloneData . end _time || '' ;
document . getElementById ( 'stream-video-resolution' ) . value = cloneData . resolution _limit || '' ;
}
}
@@ -1780,7 +1813,7 @@ export async function editStream(streamId) {
if ( ! response . ok ) throw new Error ( ` Failed to load stream: ${ response . status } ` ) ;
const stream = await response . json ( ) ;
const editTitleKeys = { raw : 'streams.edit.raw' , processed : 'streams.edit.processed' , static _image : 'streams.edit.static_image' } ;
const editTitleKeys = { raw : 'streams.edit.raw' , processed : 'streams.edit.processed' , static _image : 'streams.edit.static_image' , video : 'streams.edit.video' } ;
document . getElementById ( 'stream-modal-title' ) . innerHTML = ` ${ getPictureSourceIcon ( stream . stream _type ) } ${ t ( editTitleKeys [ stream . stream _type ] || 'streams.edit' ) } ` ;
document . getElementById ( 'stream-id' ) . value = streamId ;
document . getElementById ( 'stream-name' ) . value = stream . name ;
@@ -1814,6 +1847,16 @@ export async function editStream(streamId) {
} else if ( stream . stream _type === 'static_image' ) {
document . getElementById ( 'stream-image-source' ) . value = stream . image _source || '' ;
if ( stream . image _source ) validateStaticImage ( ) ;
} else if ( stream . stream _type === 'video' ) {
document . getElementById ( 'stream-video-url' ) . value = stream . url || '' ;
document . getElementById ( 'stream-video-loop' ) . checked = stream . loop !== false ;
document . getElementById ( 'stream-video-speed' ) . value = stream . playback _speed || 1.0 ;
const speedLabel = document . getElementById ( 'stream-video-speed-value' ) ;
if ( speedLabel ) speedLabel . textContent = stream . playback _speed || 1.0 ;
document . getElementById ( 'stream-video-fps' ) . value = stream . target _fps || 30 ;
document . getElementById ( 'stream-video-start' ) . value = stream . start _time || '' ;
document . getElementById ( 'stream-video-end' ) . value = stream . end _time || '' ;
document . getElementById ( 'stream-video-resolution' ) . value = stream . resolution _limit || '' ;
}
_showStreamModalLoading ( false ) ;
@@ -1993,6 +2036,19 @@ export async function saveStream() {
const imageSource = document . getElementById ( 'stream-image-source' ) . value . trim ( ) ;
if ( ! imageSource ) { showToast ( t ( 'streams.error.required' ) , 'error' ) ; return ; }
payload . image _source = imageSource ;
} else if ( streamType === 'video' ) {
const url = document . getElementById ( 'stream-video-url' ) . value . trim ( ) ;
if ( ! url ) { showToast ( t ( 'streams.error.required' ) , 'error' ) ; return ; }
payload . url = url ;
payload . loop = document . getElementById ( 'stream-video-loop' ) . checked ;
payload . playback _speed = parseFloat ( document . getElementById ( 'stream-video-speed' ) . value ) || 1.0 ;
payload . target _fps = parseInt ( document . getElementById ( 'stream-video-fps' ) . value ) || 30 ;
const startTime = parseFloat ( document . getElementById ( 'stream-video-start' ) . value ) ;
if ( ! isNaN ( startTime ) && startTime > 0 ) payload . start _time = startTime ;
const endTime = parseFloat ( document . getElementById ( 'stream-video-end' ) . value ) ;
if ( ! isNaN ( endTime ) && endTime > 0 ) payload . end _time = endTime ;
const resLimit = parseInt ( document . getElementById ( 'stream-video-resolution' ) . value ) ;
if ( ! isNaN ( resLimit ) && resLimit > 0 ) payload . resolution _limit = resLimit ;
}
try {