Skip to content

Commit 438ff95

Browse files
feat: implement audio device discovery and update onboarding UI for audio settings
1 parent 8efd809 commit 438ff95

5 files changed

Lines changed: 312 additions & 13 deletions

File tree

electron-kiosk/audio_devices.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict'
2+
3+
const { execFileSync } = require('node:child_process')
4+
5+
function parseAplayDevices(output) {
6+
if (!output) return []
7+
8+
const devices = []
9+
let currentCard = null
10+
11+
for (const rawLine of String(output).split(/\r?\n/)) {
12+
const line = rawLine.trim()
13+
if (!line) continue
14+
15+
const cardMatch = rawLine.match(
16+
/^card\s+(\d+):\s*([^\[]+)\[(.*?)\],\s*device\s+(\d+):\s*(.+?)\s*\[(.*?)\]/
17+
)
18+
if (cardMatch) {
19+
const [, cardIndex, cardIdRaw, cardLabel, deviceIndex, deviceIdRaw, deviceLabel] = cardMatch
20+
devices.push({
21+
value: `hw:${cardIndex},${deviceIndex}`,
22+
cardIndex: Number(cardIndex),
23+
deviceIndex: Number(deviceIndex),
24+
cardId: cardIdRaw.trim(),
25+
cardLabel: cardLabel.trim(),
26+
deviceId: deviceIdRaw.trim(),
27+
deviceLabel: deviceLabel.trim(),
28+
description: `${cardLabel.trim()} / ${deviceLabel.trim()}`,
29+
})
30+
currentCard = null
31+
continue
32+
}
33+
34+
const compactCardMatch = rawLine.match(/^card\s+(\d+):\s*([^\[]+)\[(.*?)\],?\s*$/)
35+
if (compactCardMatch) {
36+
const [, cardIndex, cardIdRaw, cardLabel] = compactCardMatch
37+
currentCard = {
38+
cardIndex: Number(cardIndex),
39+
cardId: cardIdRaw.trim(),
40+
cardLabel: cardLabel.trim(),
41+
}
42+
continue
43+
}
44+
45+
const deviceMatch = rawLine.match(/^\s*device\s+(\d+):\s*(.+?)\s*\[(.*?)\]/)
46+
if (deviceMatch && currentCard) {
47+
const [, deviceIndex, deviceIdRaw, deviceLabel] = deviceMatch
48+
devices.push({
49+
value: `hw:${currentCard.cardIndex},${deviceIndex}`,
50+
cardIndex: currentCard.cardIndex,
51+
deviceIndex: Number(deviceIndex),
52+
cardId: currentCard.cardId,
53+
cardLabel: currentCard.cardLabel,
54+
deviceId: deviceIdRaw.trim(),
55+
deviceLabel: deviceLabel.trim(),
56+
description: `${currentCard.cardLabel} / ${deviceLabel.trim()}`,
57+
})
58+
}
59+
}
60+
61+
return devices
62+
}
63+
64+
function listAudioDevices() {
65+
try {
66+
const output = execFileSync('aplay', ['-l'], { encoding: 'utf8' })
67+
return {
68+
devices: parseAplayDevices(output),
69+
rawOutput: output.trim(),
70+
error: null,
71+
}
72+
} catch (error) {
73+
return {
74+
devices: [],
75+
rawOutput: '',
76+
error: String(error.message || error),
77+
}
78+
}
79+
}
80+
81+
module.exports = {
82+
listAudioDevices,
83+
parseAplayDevices,
84+
}

electron-kiosk/main.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const { app, BrowserWindow, session, ipcMain } = require('electron')
44
const path = require('node:path')
55
const { execFileSync } = require('node:child_process')
6+
const { listAudioDevices } = require('./audio_devices')
67

78
const CONSOLE_LEVEL_TO_METHOD = {
89
0: 'info',
@@ -121,7 +122,15 @@ function isOnboardingFrame(event) {
121122
function registerIpcHandlers() {
122123
ipcMain.handle('get-config', (event) => {
123124
if (!isOnboardingFrame(event)) return {}
124-
if (!process.env.SNAP) return {}
125+
const audioInventory = listAudioDevices()
126+
127+
if (!process.env.SNAP) {
128+
return {
129+
audioDevices: audioInventory.devices,
130+
audioDevicesRaw: audioInventory.rawOutput,
131+
audioDevicesError: audioInventory.error,
132+
}
133+
}
125134
try {
126135
return {
127136
url: snapctlGetSilent('url'),
@@ -130,9 +139,16 @@ function registerIpcHandlers() {
130139
audioSink: snapctlGetSilent('audio-sink'),
131140
audioVolume: snapctlGetSilent('audio-volume'),
132141
daemon: snapctlGetSilent('daemon'),
142+
audioDevices: audioInventory.devices,
143+
audioDevicesRaw: audioInventory.rawOutput,
144+
audioDevicesError: audioInventory.error,
133145
}
134146
} catch {
135-
return {}
147+
return {
148+
audioDevices: audioInventory.devices,
149+
audioDevicesRaw: audioInventory.rawOutput,
150+
audioDevicesError: audioInventory.error,
151+
}
136152
}
137153
})
138154

electron-kiosk/onboarding.html

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,43 @@
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
}

electron-kiosk/wrapper/wrapper.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ echo "Launching Kiosk App with config:
2222
- daemon = $DAEMON
2323
- allowed-origins = $ALLOWED_ORIGINS"
2424

25+
if command -v aplay >/dev/null 2>&1; then
26+
AUDIO_DEVICE_LIST="$(aplay -l 2>/dev/null || true)"
27+
if [ -n "$AUDIO_DEVICE_LIST" ]; then
28+
echo "Audio: available ALSA playback devices:"
29+
printf '%s\n' "$AUDIO_DEVICE_LIST"
30+
else
31+
echo "Audio: no ALSA playback devices reported by aplay -l"
32+
fi
33+
else
34+
echo "Audio: aplay not available inside snap runtime"
35+
fi
36+
2537
# Optional language override from snap config: snap set sbf-kiosk lang=cs-CZ
2638
if [ -n "$KIOSK_LANG" ]; then
2739
EXTRAOPTS="$EXTRAOPTS --lang=$KIOSK_LANG"

0 commit comments

Comments
 (0)