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

@@ -10,9 +10,19 @@ router = APIRouter()
@router.get("/api/v1/audio-devices", tags=["Audio"])
async def list_audio_devices(_auth: AuthRequired):
"""List available audio input/output devices for audio-reactive sources."""
"""List available audio input/output devices for audio-reactive sources.
Returns a deduped flat list (backward compat) plus a ``by_engine`` dict
with per-engine device lists (no cross-engine dedup) so the frontend can
filter by the selected audio template's engine type.
"""
try:
devices = AudioCaptureManager.enumerate_devices()
return {"devices": devices, "count": len(devices)}
by_engine = AudioCaptureManager.enumerate_devices_by_engine()
return {
"devices": devices,
"count": len(devices),
"by_engine": by_engine,
}
except Exception as e:
return {"devices": [], "count": 0, "error": str(e)}
return {"devices": [], "count": 0, "by_engine": {}, "error": str(e)}

View File

@@ -292,3 +292,31 @@ class AudioCaptureManager:
except Exception as e:
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
return result
@staticmethod
def enumerate_devices_by_engine() -> Dict[str, List[dict]]:
"""List available audio devices grouped by engine type.
Unlike enumerate_devices(), does NOT deduplicate across engines.
Each engine's devices are returned with their engine-specific indices.
"""
result: Dict[str, List[dict]] = {}
for engine_type, engine_class in AudioEngineRegistry.get_all_engines().items():
try:
if not engine_class.is_available():
continue
devices = []
for dev in engine_class.enumerate_devices():
devices.append({
"index": dev.index,
"name": dev.name,
"is_input": dev.is_input,
"is_loopback": dev.is_loopback,
"channels": dev.channels,
"default_samplerate": dev.default_samplerate,
"engine_type": engine_type,
})
result[engine_type] = devices
except Exception as e:
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
return result

View File

@@ -63,8 +63,8 @@
}
@keyframes tutorial-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.6); }
50% { box-shadow: 0 0 0 6px rgba(76, 175, 80, 0); }
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary-color) 60%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary-color) 0%, transparent); }
}
.tutorial-tooltip {

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;

View File

@@ -222,8 +222,10 @@
"tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.",
"tour.profiles": "Profiles — group targets and automate switching with time, audio, or value conditions.",
"tour.settings": "Settings — backup and restore configuration, manage auto-backups.",
"tour.api": "API Docs — interactive REST API documentation powered by Swagger.",
"tour.search": "Search — quickly find and navigate to any entity with Ctrl+K.",
"tour.theme": "Theme — switch between dark and light mode.",
"tour.accent": "Accent color — customize the UI accent color to your preference.",
"tour.language": "Language — choose your preferred interface language.",
"tour.restart": "Restart tutorial",
"tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.",

View File

@@ -222,8 +222,10 @@
"tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.",
"tour.profiles": "Профили — группируйте цели и автоматизируйте переключение по расписанию, звуку или значениям.",
"tour.settings": "Настройки — резервное копирование и восстановление конфигурации.",
"tour.api": "API Документация — интерактивная документация REST API на базе Swagger.",
"tour.search": "Поиск — быстрый поиск и переход к любому объекту по Ctrl+K.",
"tour.theme": "Тема — переключение между тёмной и светлой темой.",
"tour.accent": "Цвет акцента — настройте цвет интерфейса по своему вкусу.",
"tour.language": "Язык — выберите предпочитаемый язык интерфейса.",
"tour.restart": "Запустить тур заново",
"tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.",

View File

@@ -222,8 +222,10 @@
"tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。",
"tour.profiles": "配置文件 — 将目标分组,并通过时间、音频或数值条件自动切换。",
"tour.settings": "设置 — 备份和恢复配置,管理自动备份。",
"tour.api": "API 文档 — 基于 Swagger 的交互式 REST API 文档。",
"tour.search": "搜索 — 使用 Ctrl+K 快速查找并导航到任意实体。",
"tour.theme": "主题 — 在深色和浅色模式之间切换。",
"tour.accent": "主题色 — 自定义界面的强调颜色。",
"tour.language": "语言 — 选择您偏好的界面语言。",
"tour.restart": "重新开始导览",
"tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。",