234234 border-radius : 0.25rem ;
235235 }
236236
237+ .hidden {
238+ display : none;
239+ }
240+
241+ .stack {
242+ display : flex;
243+ flex-direction : column;
244+ gap : 0.75rem ;
245+ }
246+
247+ .device-list {
248+ margin-top : 0.75rem ;
249+ padding : 0.875rem 1rem ;
250+ background : rgba (17 , 24 , 39 , 0.75 );
251+ border : 1px solid var (--border );
252+ border-radius : 1rem ;
253+ font-size : 0.9rem ;
254+ color : var (--text-sec );
255+ }
256+
257+ .device-list ul {
258+ margin : 0.5rem 0 0 ;
259+ padding-left : 1.1rem ;
260+ }
261+
262+ .device-list li + li {
263+ margin-top : 0.35rem ;
264+ }
265+
266+ .device-list code {
267+ font-family : ui-monospace, monospace;
268+ font-size : 0.8rem ;
269+ background : rgba (255 , 255 , 255 , 0.08 );
270+ padding : 0.0625rem 0.3rem ;
271+ border-radius : 0.25rem ;
272+ }
273+
237274 /* ── Range slider ───────────────────────────────────────────── */
238275 .range-row {
239276 display : flex;
@@ -497,19 +534,27 @@ <h1>Setup</h1>
497534 < div class ="col-right ">
498535 < div class ="field ">
499536 < label class ="field-label " for ="audioSink "> Audio Device</ label >
500- < input
501- type ="text "
502- id ="audioSink "
503- name ="audioSink "
504- value ="auto "
505- placeholder ="auto "
506- autocomplete ="off "
507- spellcheck ="false "
508- />
537+ < div class ="stack ">
538+ < select id ="audioSinkPreset " name ="audioSinkPreset ">
539+ < option value ="auto "> System default (auto)</ option >
540+ < option value ="__custom__ "> Custom ALSA device</ option >
541+ </ select >
542+ < input
543+ type ="text "
544+ id ="audioSink "
545+ name ="audioSink "
546+ value ="auto "
547+ placeholder ="hw:0,0 "
548+ autocomplete ="off "
549+ spellcheck ="false "
550+ class ="hidden "
551+ />
552+ </ div >
509553 < p class ="hint ">
510554 ALSA device string. < code > auto</ code > uses the system default. Examples:
511555 < code > hw:0,0</ code > (analog), < code > hw:0,3</ code > (HDMI).
512556 </ p >
557+ < div class ="device-list hidden " id ="audioDeviceList "> </ div >
513558 </ div >
514559
515560 < div class ="field ">
@@ -561,6 +606,73 @@ <h1>Setup</h1>
561606 const statusEl = document . getElementById ( 'status' )
562607 const volumeSlider = document . getElementById ( 'audioVolume' )
563608 const volumeDisplay = document . getElementById ( 'volumeDisplay' )
609+ const audioSinkPreset = document . getElementById ( 'audioSinkPreset' )
610+ const audioSinkInput = document . getElementById ( 'audioSink' )
611+ const audioDeviceList = document . getElementById ( 'audioDeviceList' )
612+
613+ function setAudioSinkMode ( mode , value = '' ) {
614+ const isCustom = mode === '__custom__'
615+ audioSinkPreset . value = isCustom ? '__custom__' : mode
616+ audioSinkInput . classList . toggle ( 'hidden' , ! isCustom )
617+ if ( isCustom ) {
618+ audioSinkInput . value = value
619+ } else {
620+ audioSinkInput . value = mode
621+ }
622+ }
623+
624+ function populateAudioDevices ( devices , selectedValue ) {
625+ const previous = new Set (
626+ [ ...audioSinkPreset . querySelectorAll ( 'option[data-device-option="true"]' ) ] . map (
627+ ( option ) => option . value
628+ )
629+ )
630+
631+ for ( const value of previous ) {
632+ const option = audioSinkPreset . querySelector ( `option[value="${ CSS . escape ( value ) } "]` )
633+ option ?. remove ( )
634+ }
635+
636+ if ( Array . isArray ( devices ) && devices . length > 0 ) {
637+ for ( const device of devices ) {
638+ const option = document . createElement ( 'option' )
639+ option . value = device . value
640+ option . dataset . deviceOption = 'true'
641+ option . textContent = `${ device . description } (${ device . value } )`
642+ audioSinkPreset . insertBefore ( option , audioSinkPreset . lastElementChild )
643+ }
644+
645+ audioDeviceList . className = 'device-list'
646+ audioDeviceList . replaceChildren ( )
647+
648+ const heading = document . createElement ( 'strong' )
649+ heading . textContent = 'Detected playback devices'
650+
651+ const list = document . createElement ( 'ul' )
652+ for ( const device of devices ) {
653+ const item = document . createElement ( 'li' )
654+ const code = document . createElement ( 'code' )
655+ code . textContent = device . value
656+ item . append ( code , ` ${ device . description } ` )
657+ list . appendChild ( item )
658+ }
659+
660+ audioDeviceList . append ( heading , list )
661+ } else {
662+ audioDeviceList . className = 'device-list'
663+ audioDeviceList . textContent =
664+ 'No ALSA playback devices were detected yet. You can still enter a manual ALSA device string.'
665+ }
666+
667+ const knownValues = new Set ( [ 'auto' , ...devices . map ( ( device ) => device . value ) ] )
668+ if ( selectedValue && knownValues . has ( selectedValue ) ) {
669+ setAudioSinkMode ( selectedValue )
670+ } else if ( selectedValue && selectedValue !== 'auto' ) {
671+ setAudioSinkMode ( '__custom__' , selectedValue )
672+ } else {
673+ setAudioSinkMode ( 'auto' )
674+ }
675+ }
564676
565677 function updateSlider ( slider ) {
566678 const pct = slider . value + '%'
@@ -586,7 +698,7 @@ <h1>Setup</h1>
586698 const sel = document . getElementById ( 'lang' )
587699 if ( [ ...sel . options ] . some ( ( o ) => o . value === cfg . lang ) ) sel . value = cfg . lang
588700 }
589- if ( cfg . audioSink ) document . getElementById ( 'audioSink' ) . value = cfg . audioSink
701+ populateAudioDevices ( cfg . audioDevices || [ ] , cfg . audioSink || 'auto' )
590702 if ( cfg . audioVolume != null ) {
591703 volumeSlider . value = cfg . audioVolume
592704 updateSlider ( volumeSlider )
@@ -618,6 +730,17 @@ <h1>Setup</h1>
618730
619731 urlHost . addEventListener ( 'input' , validateUrl )
620732 urlProtocol . addEventListener ( 'change' , validateUrl )
733+ audioSinkPreset . addEventListener ( 'change' , ( ) => {
734+ if ( audioSinkPreset . value === '__custom__' ) {
735+ setAudioSinkMode (
736+ '__custom__' ,
737+ audioSinkInput . value === 'auto' ? '' : audioSinkInput . value
738+ )
739+ audioSinkInput . focus ( )
740+ return
741+ }
742+ setAudioSinkMode ( audioSinkPreset . value )
743+ } )
621744
622745 document . getElementById ( 'setup-form' ) . addEventListener ( 'submit' , async ( e ) => {
623746 e . preventDefault ( )
@@ -631,7 +754,7 @@ <h1>Setup</h1>
631754 url : buildUrl ( ) ,
632755 allowedOrigins : document . getElementById ( 'allowedOrigins' ) . value . trim ( ) ,
633756 lang : document . getElementById ( 'lang' ) . value ,
634- audioSink : document . getElementById ( 'audioSink' ) . value . trim ( ) || 'auto' ,
757+ audioSink : audioSinkInput . value . trim ( ) || 'auto' ,
635758 audioVolume : String ( volumeSlider . value ) ,
636759 daemon : document . getElementById ( 'daemon' ) . checked ? 'true' : 'false' ,
637760 }
0 commit comments