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:
@@ -10,9 +10,19 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/api/v1/audio-devices", tags=["Audio"])
|
@router.get("/api/v1/audio-devices", tags=["Audio"])
|
||||||
async def list_audio_devices(_auth: AuthRequired):
|
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:
|
try:
|
||||||
devices = AudioCaptureManager.enumerate_devices()
|
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:
|
except Exception as e:
|
||||||
return {"devices": [], "count": 0, "error": str(e)}
|
return {"devices": [], "count": 0, "by_engine": {}, "error": str(e)}
|
||||||
|
|||||||
@@ -292,3 +292,31 @@ class AudioCaptureManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
|
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
|
||||||
return result
|
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
|
||||||
|
|||||||
@@ -63,8 +63,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tutorial-pulse {
|
@keyframes tutorial-pulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.6); }
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary-color) 60%, transparent); }
|
||||||
50% { box-shadow: 0 0 0 6px rgba(76, 175, 80, 0); }
|
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary-color) 0%, transparent); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-tooltip {
|
.tutorial-tooltip {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export async function showAudioSourceModal(sourceType, editData) {
|
|||||||
|
|
||||||
if (editData.source_type === 'multichannel') {
|
if (editData.source_type === 'multichannel') {
|
||||||
_loadAudioTemplates(editData.audio_template_id);
|
_loadAudioTemplates(editData.audio_template_id);
|
||||||
|
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
|
||||||
await _loadAudioDevices();
|
await _loadAudioDevices();
|
||||||
_selectAudioDevice(editData.device_index, editData.is_loopback);
|
_selectAudioDevice(editData.device_index, editData.is_loopback);
|
||||||
} else {
|
} else {
|
||||||
@@ -72,6 +73,7 @@ export async function showAudioSourceModal(sourceType, editData) {
|
|||||||
|
|
||||||
if (sourceType === 'multichannel') {
|
if (sourceType === 'multichannel') {
|
||||||
_loadAudioTemplates();
|
_loadAudioTemplates();
|
||||||
|
document.getElementById('audio-source-audio-template').onchange = _filterDevicesBySelectedTemplate;
|
||||||
await _loadAudioDevices();
|
await _loadAudioDevices();
|
||||||
} else {
|
} else {
|
||||||
_loadMultichannelSources();
|
_loadMultichannelSources();
|
||||||
@@ -191,24 +193,53 @@ export async function deleteAudioSource(sourceId) {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _cachedDevicesByEngine = {};
|
||||||
|
|
||||||
async function _loadAudioDevices() {
|
async function _loadAudioDevices() {
|
||||||
const select = document.getElementById('audio-source-device');
|
|
||||||
if (!select) return;
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/audio-devices');
|
const resp = await fetchWithAuth('/audio-devices');
|
||||||
if (!resp.ok) throw new Error('fetch failed');
|
if (!resp.ok) throw new Error('fetch failed');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const devices = data.devices || [];
|
_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 => {
|
select.innerHTML = devices.map(d => {
|
||||||
const label = d.name;
|
|
||||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
return `<option value="${val}">${escapeHtml(d.name)}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
select.innerHTML = '<option value="-1:1">Default</option>';
|
select.innerHTML = '<option value="-1:1">Default</option>';
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ const TOUR_KEY = 'tour_completed';
|
|||||||
const gettingStartedSteps = [
|
const gettingStartedSteps = [
|
||||||
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
|
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
|
||||||
{ selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', 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-targets', textKey: 'tour.targets', position: 'bottom' },
|
||||||
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
|
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
|
||||||
{ selector: '#tab-btn-profiles', textKey: 'tour.profiles', position: 'bottom' },
|
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
|
||||||
{ selector: '[onclick*="openSettingsModal"]', textKey: 'tour.settings', position: 'bottom' },
|
|
||||||
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
|
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
|
||||||
{ selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', 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' }
|
{ selector: '#locale-select', textKey: 'tour.language', position: 'bottom' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -83,6 +85,8 @@ const deviceTutorialSteps = [
|
|||||||
|
|
||||||
export function startTutorial(config) {
|
export function startTutorial(config) {
|
||||||
closeTutorial();
|
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);
|
const overlay = document.getElementById(config.overlayId);
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
|
|
||||||
|
|||||||
@@ -222,8 +222,10 @@
|
|||||||
"tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.",
|
"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.profiles": "Profiles — group targets and automate switching with time, audio, or value conditions.",
|
||||||
"tour.settings": "Settings — backup and restore configuration, manage auto-backups.",
|
"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.search": "Search — quickly find and navigate to any entity with Ctrl+K.",
|
||||||
"tour.theme": "Theme — switch between dark and light mode.",
|
"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.language": "Language — choose your preferred interface language.",
|
||||||
"tour.restart": "Restart tutorial",
|
"tour.restart": "Restart tutorial",
|
||||||
"tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.",
|
"tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.",
|
||||||
|
|||||||
@@ -222,8 +222,10 @@
|
|||||||
"tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.",
|
"tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.",
|
||||||
"tour.profiles": "Профили — группируйте цели и автоматизируйте переключение по расписанию, звуку или значениям.",
|
"tour.profiles": "Профили — группируйте цели и автоматизируйте переключение по расписанию, звуку или значениям.",
|
||||||
"tour.settings": "Настройки — резервное копирование и восстановление конфигурации.",
|
"tour.settings": "Настройки — резервное копирование и восстановление конфигурации.",
|
||||||
|
"tour.api": "API Документация — интерактивная документация REST API на базе Swagger.",
|
||||||
"tour.search": "Поиск — быстрый поиск и переход к любому объекту по Ctrl+K.",
|
"tour.search": "Поиск — быстрый поиск и переход к любому объекту по Ctrl+K.",
|
||||||
"tour.theme": "Тема — переключение между тёмной и светлой темой.",
|
"tour.theme": "Тема — переключение между тёмной и светлой темой.",
|
||||||
|
"tour.accent": "Цвет акцента — настройте цвет интерфейса по своему вкусу.",
|
||||||
"tour.language": "Язык — выберите предпочитаемый язык интерфейса.",
|
"tour.language": "Язык — выберите предпочитаемый язык интерфейса.",
|
||||||
"tour.restart": "Запустить тур заново",
|
"tour.restart": "Запустить тур заново",
|
||||||
"tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.",
|
"tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.",
|
||||||
|
|||||||
@@ -222,8 +222,10 @@
|
|||||||
"tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。",
|
"tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。",
|
||||||
"tour.profiles": "配置文件 — 将目标分组,并通过时间、音频或数值条件自动切换。",
|
"tour.profiles": "配置文件 — 将目标分组,并通过时间、音频或数值条件自动切换。",
|
||||||
"tour.settings": "设置 — 备份和恢复配置,管理自动备份。",
|
"tour.settings": "设置 — 备份和恢复配置,管理自动备份。",
|
||||||
|
"tour.api": "API 文档 — 基于 Swagger 的交互式 REST API 文档。",
|
||||||
"tour.search": "搜索 — 使用 Ctrl+K 快速查找并导航到任意实体。",
|
"tour.search": "搜索 — 使用 Ctrl+K 快速查找并导航到任意实体。",
|
||||||
"tour.theme": "主题 — 在深色和浅色模式之间切换。",
|
"tour.theme": "主题 — 在深色和浅色模式之间切换。",
|
||||||
|
"tour.accent": "主题色 — 自定义界面的强调颜色。",
|
||||||
"tour.language": "语言 — 选择您偏好的界面语言。",
|
"tour.language": "语言 — 选择您偏好的界面语言。",
|
||||||
"tour.restart": "重新开始导览",
|
"tour.restart": "重新开始导览",
|
||||||
"tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。",
|
"tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。",
|
||||||
|
|||||||
Reference in New Issue
Block a user