Filter audio devices by engine type and update tutorial steps

- Add enumerate_devices_by_engine() returning per-engine device lists
  without cross-engine dedup so frontend can filter correctly
- API /audio-devices now includes by_engine dict alongside flat list
- Frontend caches per-engine data, filters device dropdown by selected
  template's engine_type, refreshes on template change
- Reorder getting-started tutorial: add API docs and accent color steps
- Fix tutorial trigger button focus outline persisting on step 2
- Use accent color variable for tutorial pulse ring animation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 21:43:37 +03:00
parent efb05eba77
commit 49c985e5c5
8 changed files with 97 additions and 18 deletions

View File

@@ -60,6 +60,7 @@ export async function showAudioSourceModal(sourceType, editData) {
if (editData.source_type === 'multichannel') {
_loadAudioTemplates(editData.audio_template_id);
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback);
} else {
@@ -72,6 +73,7 @@ export async function showAudioSourceModal(sourceType, editData) {
if (sourceType === 'multichannel') {
_loadAudioTemplates();
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
await _loadAudioDevices();
} else {
_loadMultichannelSources();
@@ -191,25 +193,54 @@ export async function deleteAudioSource(sourceId) {
// ── Helpers ───────────────────────────────────────────────────
let _cachedDevicesByEngine = {};
async function _loadAudioDevices() {
const select = document.getElementById('audio-source-device');
if (!select) return;
try {
const resp = await fetchWithAuth('/audio-devices');
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
const devices = data.devices || [];
select.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
select.innerHTML = '<option value="-1:1">Default</option>';
}
_cachedDevicesByEngine = data.by_engine || {};
} catch {
_cachedDevicesByEngine = {};
}
_filterDevicesBySelectedTemplate();
}
function _filterDevicesBySelectedTemplate() {
const select = document.getElementById('audio-source-device');
if (!select) return;
const prevOption = select.options[select.selectedIndex];
const prevName = prevOption ? prevOption.textContent : '';
const templateId = (document.getElementById('audio-source-audio-template') || {}).value;
const templates = _cachedAudioTemplates || [];
const template = templates.find(t => t.id === templateId);
const engineType = template ? template.engine_type : null;
let devices = [];
if (engineType && _cachedDevicesByEngine[engineType]) {
devices = _cachedDevicesByEngine[engineType];
} else {
for (const devList of Object.values(_cachedDevicesByEngine)) {
devices = devices.concat(devList);
}
}
select.innerHTML = devices.map(d => {
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(d.name)}</option>`;
}).join('');
if (devices.length === 0) {
select.innerHTML = '<option value="-1:1">Default</option>';
}
if (prevName) {
const match = Array.from(select.options).find(o => o.textContent === prevName);
if (match) select.value = match.value;
}
}
function _selectAudioDevice(deviceIndex, isLoopback) {

View File

@@ -24,12 +24,14 @@ const TOUR_KEY = 'tour_completed';
const gettingStartedSteps = [
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
{ selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', position: 'bottom' },
{ selector: '#tab-btn-profiles', textKey: 'tour.profiles', position: 'bottom' },
{ selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' },
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: '#tab-btn-profiles', textKey: 'tour.profiles', position: 'bottom' },
{ selector: '[onclick*="openSettingsModal"]', textKey: 'tour.settings', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
{ selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' },
{ selector: '#cp-wrap-accent', textKey: 'tour.accent', position: 'bottom' },
{ selector: '[onclick*="openSettingsModal"]', textKey: 'tour.settings', position: 'bottom' },
{ selector: '#locale-select', textKey: 'tour.language', position: 'bottom' }
];
@@ -83,6 +85,8 @@ const deviceTutorialSteps = [
export function startTutorial(config) {
closeTutorial();
// Remove focus from the trigger button so its outline doesn't persist
if (document.activeElement) document.activeElement.blur();
const overlay = document.getElementById(config.overlayId);
if (!overlay) return;