Merge pull request #8 from sean351/feat/device-dropdowns

feat: replace free-text target fields with device/app dropdowns
main
Sean 2026-03-06 20:47:57 -05:00 committed by GitHub
commit 989fc4ca40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 356 additions and 172 deletions

View File

@ -138,6 +138,60 @@ def list_running_apps() -> list[str]:
return [] return []
# ── Audio devices API ─────────────────────────────────────────────────────────
@app.get("/api/sinks")
def list_sinks() -> list[dict]:
"""Return active PulseAudio/PipeWire output devices (sinks).
Each entry has ``name`` (the PulseAudio sink name used as a config target)
and ``description`` (the human-readable label). ``"default"`` is always
prepended as the first entry. Falls back to ``[]`` on any error.
"""
try:
import pulsectl
with pulsectl.Pulse("turnup-ui-sinks") as pulse:
sinks = pulse.sink_list()
default_name = pulse.server_info().default_sink_name
result = [{"name": "default", "description": "Default output device"}]
for s in sorted(sinks, key=lambda x: x.description.lower()):
result.append({
"name": s.name,
"description": s.description,
"is_default": s.name == default_name,
})
return result
except Exception:
return []
@app.get("/api/sources")
def list_sources() -> list[dict]:
"""Return active PulseAudio/PipeWire input devices (sources), excluding monitors.
Each entry has ``name`` and ``description``. Monitor sources (loopbacks of
sink outputs) are excluded because users typically don't want to control
them with a physical knob. Falls back to ``[]`` on any error.
"""
try:
import pulsectl
with pulsectl.Pulse("turnup-ui-sources") as pulse:
sources = pulse.source_list()
default_name = pulse.server_info().default_source_name
result = [{"name": "default", "description": "Default input device"}]
for s in sorted(sources, key=lambda x: x.description.lower()):
if s.name.endswith(".monitor"):
continue
result.append({
"name": s.name,
"description": s.description,
"is_default": s.name == default_name,
})
return result
except Exception:
return []
# ── Presets API ──────────────────────────────────────────────────────────────── # ── Presets API ────────────────────────────────────────────────────────────────
def _preset_path(name: str) -> Path: def _preset_path(name: str) -> Path:

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { Config, ToastItem } from './types'; import type { Config, AudioDevice, ToastItem } from './types';
import * as api from './api'; import * as api from './api';
import { ToastContainer } from './components/Toast'; import { ToastContainer } from './components/Toast';
import { Connection } from './components/Connection'; import { Connection } from './components/Connection';
@ -21,6 +21,8 @@ export default function App() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [runningApps, setRunningApps] = useState<string[]>([]); const [runningApps, setRunningApps] = useState<string[]>([]);
const [sinks, setSinks] = useState<AudioDevice[]>([]);
const [sources, setSources] = useState<AudioDevice[]>([]);
const [appsLoading, setAppsLoading] = useState(false); const [appsLoading, setAppsLoading] = useState(false);
const addToast = useCallback((message: string, type: ToastItem['type'] = 'success') => { const addToast = useCallback((message: string, type: ToastItem['type'] = 'success') => {
@ -44,7 +46,14 @@ export default function App() {
const loadApps = useCallback(async () => { const loadApps = useCallback(async () => {
setAppsLoading(true); setAppsLoading(true);
try { try {
setRunningApps(await api.fetchRunningApps()); const [apps, sinkList, sourceList] = await Promise.all([
api.fetchRunningApps(),
api.fetchSinks(),
api.fetchSources(),
]);
setRunningApps(apps);
setSinks(sinkList);
setSources(sourceList);
} finally { } finally {
setAppsLoading(false); setAppsLoading(false);
} }
@ -158,6 +167,8 @@ export default function App() {
index={Number(i)} index={Number(i)}
knob={config.knobs[i] ?? { action: 'sink_volume', target: 'default' }} knob={config.knobs[i] ?? { action: 'sink_volume', target: 'default' }}
runningApps={runningApps} runningApps={runningApps}
sinks={sinks}
sources={sources}
onChange={(knob) => onChange={(knob) =>
patchConfig('knobs', { ...config.knobs, [i]: knob }) patchConfig('knobs', { ...config.knobs, [i]: knob })
} }

View File

@ -1,28 +1,50 @@
/** /**
* Tests for KnobCard running-apps dropdown feature. * Tests for KnobCard target/targets dropdown feature.
* *
* Covers: * Covers:
* app_volume datalist rendered when runningApps provided * sink_volume dropdown renders active sinks
* app_volume no datalist when runningApps is empty / not passed * sink_volume dropdown disabled when sinks is empty / not passed
* app_volume picking a suggestion calls onChange with the chosen value * sink_volume selecting a device calls onChange correctly
* group_volume app-picker select rendered when runningApps provided * source_volume dropdown renders active sources
* group_volume no app-picker when runningApps is empty / not passed * source_volume dropdown disabled when sources is empty / not passed
* group_volume Add button is disabled until an app is selected * app_volume dropdown rendered with running apps
* group_volume clicking Add appends the app to targets * app_volume dropdown disabled when runningApps is empty / not passed
* group_volume clicking Add does NOT add a duplicate app * app_volume selecting an option calls onChange with the chosen value
* group_volume multi-select listbox rendered with running apps
* group_volume listbox disabled when runningApps is empty / not passed
* group_volume selecting a single app fires onChange correctly
* group_volume selecting multiple apps fires onChange with all selected
* group_volume existing targets are pre-selected in the listbox
*/ */
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { KnobCard } from '../components/KnobCard'; import { KnobCard } from '../components/KnobCard';
import type { KnobConfig } from '../types'; import type { KnobConfig, AudioDevice } from '../types';
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
const SINKS: AudioDevice[] = [
{ name: 'default', description: 'Default output device' },
{ name: 'alsa_output.pci.analog-stereo', description: 'Built-in Audio Analog Stereo', is_default: true },
{ name: 'alsa_output.usb-headphones.analog-stereo', description: 'USB Headphones' },
];
const SOURCES: AudioDevice[] = [
{ name: 'default', description: 'Default input device' },
{ name: 'alsa_input.pci.analog-stereo', description: 'Built-in Microphone', is_default: true },
];
interface RenderOptions {
runningApps?: string[];
sinks?: AudioDevice[];
sources?: AudioDevice[];
onChange?: ReturnType<typeof vi.fn>;
}
function renderCard( function renderCard(
overrides: Partial<KnobConfig> = {}, overrides: Partial<KnobConfig> = {},
runningApps?: string[], { runningApps, sinks, sources, onChange = vi.fn() }: RenderOptions = {},
onChange = vi.fn(),
) { ) {
const knob: KnobConfig = { const knob: KnobConfig = {
action: 'app_volume', action: 'app_volume',
@ -32,53 +54,134 @@ function renderCard(
return { return {
onChange, onChange,
...render( ...render(
<KnobCard index={1} knob={knob} runningApps={runningApps} onChange={onChange} />, <KnobCard
index={1}
knob={knob}
runningApps={runningApps}
sinks={sinks}
sources={sources}
onChange={onChange}
/>,
), ),
}; };
} }
// ── sink_volume tests ─────────────────────────────────────────────────────────
describe('KnobCard — sink_volume', () => {
it('renders a dropdown with the supplied sinks', () => {
renderCard({ action: 'sink_volume', target: 'default' }, { sinks: SINKS });
const select = screen.getByRole<HTMLSelectElement>('combobox', { name: /target/i });
expect(select).toBeInTheDocument();
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(SINKS.map((s) => s.name));
});
it('shows device descriptions as option labels', () => {
renderCard({ action: 'sink_volume', target: 'default' }, { sinks: SINKS });
expect(screen.getByText(/Built-in Audio Analog Stereo/)).toBeInTheDocument();
expect(screen.getByText(/USB Headphones/)).toBeInTheDocument();
});
it('marks the default device in the label', () => {
renderCard({ action: 'sink_volume', target: 'default' }, { sinks: SINKS });
expect(screen.getByText(/Built-in Audio Analog Stereo.*\(default\)/)).toBeInTheDocument();
});
it('disables the dropdown when sinks is empty', () => {
renderCard({ action: 'sink_volume' }, { sinks: [] });
expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled();
});
it('disables the dropdown when sinks is not provided', () => {
renderCard({ action: 'sink_volume' });
expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled();
});
it('calls onChange with the selected sink name', () => {
const onChange = vi.fn();
renderCard({ action: 'sink_volume', target: 'default' }, { sinks: SINKS, onChange });
fireEvent.change(screen.getByRole('combobox', { name: /target/i }), {
target: { value: 'alsa_output.usb-headphones.analog-stereo' },
});
const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
expect(lastCall.target).toBe('alsa_output.usb-headphones.analog-stereo');
});
});
// ── source_volume tests ───────────────────────────────────────────────────────
describe('KnobCard — source_volume', () => {
it('renders a dropdown with the supplied sources', () => {
renderCard({ action: 'source_volume', target: 'default' }, { sources: SOURCES });
const select = screen.getByRole<HTMLSelectElement>('combobox', { name: /target/i });
expect(select).toBeInTheDocument();
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(SOURCES.map((s) => s.name));
});
it('disables the dropdown when sources is empty', () => {
renderCard({ action: 'source_volume' }, { sources: [] });
expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled();
});
it('disables the dropdown when sources is not provided', () => {
renderCard({ action: 'source_volume' });
expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled();
});
it('calls onChange with the selected source name', () => {
const onChange = vi.fn();
renderCard({ action: 'source_volume', target: 'default' }, { sources: SOURCES, onChange });
fireEvent.change(screen.getByRole('combobox', { name: /target/i }), {
target: { value: 'alsa_input.pci.analog-stereo' },
});
const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
expect(lastCall.target).toBe('alsa_input.pci.analog-stereo');
});
});
// ── app_volume tests ────────────────────────────────────────────────────────── // ── app_volume tests ──────────────────────────────────────────────────────────
describe('KnobCard — app_volume', () => { describe('KnobCard — app_volume', () => {
it('renders a datalist with the supplied running apps', () => { it('renders a dropdown with the supplied running apps', () => {
renderCard({ action: 'app_volume' }, ['spotify', 'vlc', 'brave']); renderCard({ action: 'app_volume' }, { runningApps: ['spotify', 'vlc', 'brave'] });
// An input with a list attribute is promoted to combobox by jsdom; query const select = screen.getByRole<HTMLSelectElement>('combobox', { name: /target/i });
// by label text instead to avoid coupling to the ARIA role inference. expect(select).toBeInTheDocument();
const input = screen.getByLabelText<HTMLInputElement>('Target'); const options = Array.from(select.options).slice(1); // skip placeholder
const datalistId = input.getAttribute('list'); expect(options.map((o) => o.value)).toEqual(['spotify', 'vlc', 'brave']);
expect(datalistId).toBeTruthy();
const datalist = document.getElementById(datalistId!);
expect(datalist).toBeInTheDocument();
expect(datalist!.querySelectorAll('option')).toHaveLength(3);
const values = Array.from(datalist!.querySelectorAll('option')).map(
(o) => (o as HTMLOptionElement).value,
);
expect(values).toEqual(['spotify', 'vlc', 'brave']);
}); });
it('does not attach a datalist when runningApps is empty', () => { it('disables the dropdown when runningApps is empty', () => {
renderCard({ action: 'app_volume' }, []); renderCard({ action: 'app_volume' }, { runningApps: [] });
const input = screen.getByLabelText<HTMLInputElement>('Target'); expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled();
expect(input.getAttribute('list')).toBeNull();
}); });
it('does not attach a datalist when runningApps is not provided', () => { it('disables the dropdown when runningApps is not provided', () => {
renderCard({ action: 'app_volume' }, undefined); renderCard({ action: 'app_volume' });
const input = screen.getByLabelText<HTMLInputElement>('Target'); expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled();
expect(input.getAttribute('list')).toBeNull();
}); });
it('calls onChange with the typed/selected value when the input changes', () => { it('calls onChange with the selected value when the dropdown changes', () => {
const onChange = vi.fn(); const onChange = vi.fn();
renderCard({ action: 'app_volume', target: 'default' }, ['spotify'], onChange); renderCard({ action: 'app_volume', target: 'default' }, { runningApps: ['spotify', 'brave'], onChange });
// fireEvent.change is appropriate for a controlled input: we simulate fireEvent.change(screen.getByRole('combobox', { name: /target/i }), {
// the browser firing a change event with the full desired value.
fireEvent.change(screen.getByLabelText('Target'), {
target: { value: 'brave' }, target: { value: 'brave' },
}); });
@ -90,84 +193,67 @@ describe('KnobCard — app_volume', () => {
// ── group_volume tests ──────────────────────────────────────────────────────── // ── group_volume tests ────────────────────────────────────────────────────────
describe('KnobCard — group_volume', () => { describe('KnobCard — group_volume', () => {
it('renders the app-picker select with running apps', () => { it('renders a multi-select listbox with running apps', () => {
renderCard({ action: 'group_volume', targets: [] }, ['vlc', 'brave']); renderCard({ action: 'group_volume', targets: [] }, { runningApps: ['vlc', 'brave'] });
// The placeholder option + 2 apps const select = screen.getByRole<HTMLSelectElement>('listbox', { name: /targets/i });
const select = screen.getByRole<HTMLSelectElement>('combobox', {
name: /pick a running app/i,
});
expect(select).toBeInTheDocument(); expect(select).toBeInTheDocument();
const options = Array.from(select.options).slice(1); // skip placeholder const options = Array.from(select.options);
expect(options.map((o) => o.value)).toEqual(['vlc', 'brave']); expect(options.map((o) => o.value)).toEqual(['vlc', 'brave']);
}); });
it('does not render the app-picker when runningApps is empty', () => { it('renders the listbox (disabled) when runningApps is empty', () => {
renderCard({ action: 'group_volume', targets: [] }, []); renderCard({ action: 'group_volume', targets: [] }, { runningApps: [] });
expect( expect(screen.getByRole('listbox', { name: /targets/i })).toBeDisabled();
screen.queryByRole('combobox', { name: /pick a running app/i }),
).not.toBeInTheDocument();
}); });
it('does not render the app-picker when runningApps is not provided', () => { it('renders the listbox (disabled) when runningApps is not provided', () => {
renderCard({ action: 'group_volume', targets: [] }, undefined); renderCard({ action: 'group_volume', targets: [] });
expect( expect(screen.getByRole('listbox', { name: /targets/i })).toBeDisabled();
screen.queryByRole('combobox', { name: /pick a running app/i }),
).not.toBeInTheDocument();
}); });
it('Add button is disabled until an app is selected from the dropdown', () => { it('selecting a single app calls onChange with that target', async () => {
renderCard({ action: 'group_volume', targets: [] }, ['spotify']);
const addBtn = screen.getByRole('button', { name: /add/i });
expect(addBtn).toBeDisabled();
});
it('Add button becomes enabled after selecting an app', async () => {
renderCard({ action: 'group_volume', targets: [] }, ['spotify']);
const select = screen.getByRole('combobox', { name: /pick a running app/i });
await userEvent.selectOptions(select, 'spotify');
expect(screen.getByRole('button', { name: /add/i })).toBeEnabled();
});
it('clicking Add appends the selected app to targets', async () => {
const onChange = vi.fn(); const onChange = vi.fn();
renderCard({ action: 'group_volume', targets: ['vlc'] }, ['spotify', 'brave'], onChange); renderCard({ action: 'group_volume', targets: [] }, { runningApps: ['spotify', 'vlc'], onChange });
const select = screen.getByRole('combobox', { name: /pick a running app/i }); const select = screen.getByRole('listbox', { name: /targets/i });
await userEvent.selectOptions(select, 'brave'); await userEvent.selectOptions(select, 'spotify');
await userEvent.click(screen.getByRole('button', { name: /add/i }));
const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig; const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
expect(lastCall.targets).toEqual(['vlc', 'brave']); expect(lastCall.targets).toEqual(['spotify']);
}); });
it('clicking Add does not add a duplicate app', async () => { it('selecting multiple apps calls onChange with all selected targets', () => {
const onChange = vi.fn(); const onChange = vi.fn();
renderCard({ action: 'group_volume', targets: ['brave'] }, ['spotify', 'brave'], onChange); renderCard({ action: 'group_volume', targets: [] }, { runningApps: ['spotify', 'vlc', 'brave'], onChange });
const select = screen.getByRole('combobox', { name: /pick a running app/i }); const select = screen.getByRole<HTMLSelectElement>('listbox', { name: /targets/i });
await userEvent.selectOptions(select, 'brave');
await userEvent.click(screen.getByRole('button', { name: /add/i }));
const lastCall = onChange.mock.calls.at(-1); // jsdom doesn't support setting selectedOptions via fireEvent, so mark
// onChange should not have been called at all (duplicate guard) // individual options as selected and dispatch the change event manually.
expect(lastCall).toBeUndefined(); Array.from(select.options).forEach((opt) => {
opt.selected = opt.value === 'spotify' || opt.value === 'brave';
});
fireEvent.change(select);
const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
expect(lastCall.targets).toEqual(expect.arrayContaining(['spotify', 'brave']));
expect(lastCall.targets).toHaveLength(2);
}); });
it('resets the picker select back to placeholder after a successful Add', async () => { it('pre-selects options that are already in targets', () => {
renderCard({ action: 'group_volume', targets: [] }, ['spotify']); renderCard(
{ action: 'group_volume', targets: ['vlc', 'brave'] },
{ runningApps: ['spotify', 'vlc', 'brave'] },
);
const select = screen.getByRole<HTMLSelectElement>('combobox', { const select = screen.getByRole<HTMLSelectElement>('listbox', { name: /targets/i });
name: /pick a running app/i, const selected = Array.from(select.options)
}); .filter((o) => o.selected)
await userEvent.selectOptions(select, 'spotify'); .map((o) => o.value);
await userEvent.click(screen.getByRole('button', { name: /add/i })); expect(selected).toEqual(expect.arrayContaining(['vlc', 'brave']));
expect(selected).toHaveLength(2);
expect(select.value).toBe('');
}); });
}); });

View File

@ -1,4 +1,4 @@
import type { Config } from './types'; import type { Config, AudioDevice } from './types';
async function checkResponse(r: Response): Promise<void> { async function checkResponse(r: Response): Promise<void> {
if (!r.ok) { if (!r.ok) {
@ -66,3 +66,23 @@ export async function fetchRunningApps(): Promise<string[]> {
return []; return [];
} }
} }
export async function fetchSinks(): Promise<AudioDevice[]> {
try {
const r = await fetch('/api/sinks');
if (!r.ok) return [];
return r.json();
} catch {
return [];
}
}
export async function fetchSources(): Promise<AudioDevice[]> {
try {
const r = await fetch('/api/sources');
if (!r.ok) return [];
return r.json();
} catch {
return [];
}
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import type { KnobConfig, KnobAction, LedConfig, LedMode } from '../types'; import type { KnobConfig, KnobAction, LedConfig, LedMode, AudioDevice } from '../types';
import { ColorPicker } from './ColorPicker'; import { ColorPicker } from './ColorPicker';
const ACTIONS: { value: KnobAction; label: string }[] = [ const ACTIONS: { value: KnobAction; label: string }[] = [
@ -19,12 +19,13 @@ interface Props {
index: number; index: number;
knob: KnobConfig; knob: KnobConfig;
runningApps?: string[]; runningApps?: string[];
sinks?: AudioDevice[];
sources?: AudioDevice[];
onChange: (knob: KnobConfig) => void; onChange: (knob: KnobConfig) => void;
} }
export function KnobCard({ index, knob, runningApps = [], onChange }: Props) { export function KnobCard({ index, knob, runningApps = [], sinks = [], sources = [], onChange }: Props) {
const [ledOpen, setLedOpen] = useState(!!knob.led); const [ledOpen, setLedOpen] = useState(!!knob.led);
const [pickedApp, setPickedApp] = useState('');
const update = (patch: Partial<KnobConfig>) => onChange({ ...knob, ...patch }); const update = (patch: Partial<KnobConfig>) => onChange({ ...knob, ...patch });
@ -47,19 +48,6 @@ export function KnobCard({ index, knob, runningApps = [], onChange }: Props) {
} }
}; };
const datalistId = `knob-${index}-apps`;
// Add the selected running app to the group_volume targets list
const addPickedApp = () => {
const app = pickedApp.trim();
if (!app) return;
const current = knob.targets ?? [];
if (!current.includes(app)) {
update({ targets: [...current, app] });
}
setPickedApp('');
};
return ( return (
<div className="knob-card"> <div className="knob-card">
<div className="card-title"> <div className="card-title">
@ -91,57 +79,86 @@ export function KnobCard({ index, knob, runningApps = [], onChange }: Props) {
{/* Target / Targets */} {/* Target / Targets */}
{knob.action === 'group_volume' ? ( {knob.action === 'group_volume' ? (
<div> <div>
<label htmlFor={`knob-${index}-targets`}>Targets (comma-separated)</label> <label htmlFor={`knob-${index}-targets`}>
<textarea Targets
{runningApps.length > 0 && (
<span className="label-hint"> (ctrl/shift to select multiple)</span>
)}
</label>
<select
id={`knob-${index}-targets`} id={`knob-${index}-targets`}
value={(knob.targets ?? []).join(', ')} multiple
placeholder="spotify, vlc, brave" size={Math.max(3, Math.min(runningApps.length, 6))}
value={knob.targets ?? []}
onChange={(e) => onChange={(e) =>
update({ update({
targets: e.target.value.split(',').map((s) => s.trim()).filter(Boolean), targets: Array.from(e.target.selectedOptions).map((o) => o.value),
}) })
} }
/> disabled={runningApps.length === 0}
{runningApps.length > 0 && ( aria-label="Targets"
<div className="app-picker"> >
<select {runningApps.length === 0 && (
value={pickedApp} <option value="" disabled> no apps detected </option>
onChange={(e) => setPickedApp(e.target.value)} )}
aria-label="Pick a running app" {runningApps.map((app) => (
> <option key={app} value={app}>{app}</option>
<option value=""> running apps </option> ))}
{runningApps.map((app) => ( </select>
<option key={app} value={app}>{app}</option>
))}
</select>
<button
type="button"
className="btn-secondary"
onClick={addPickedApp}
disabled={!pickedApp}
>
Add
</button>
</div>
)}
</div> </div>
) : ( ) : (
<div> <div>
<label htmlFor={`knob-${index}-target`}>Target</label> <label htmlFor={`knob-${index}-target`}>Target</label>
<input {knob.action === 'sink_volume' ? (
type="text" <select
id={`knob-${index}-target`} id={`knob-${index}-target`}
list={runningApps.length > 0 ? datalistId : undefined} value={knob.target ?? 'default'}
value={knob.target ?? 'default'} onChange={(e) => update({ target: e.target.value })}
placeholder="default" disabled={sinks.length === 0}
onChange={(e) => update({ target: e.target.value })} aria-label="Target"
/> >
{runningApps.length > 0 && ( {sinks.length === 0
<datalist id={datalistId}> ? <option value="default"> no devices detected </option>
: sinks.map((d) => (
<option key={d.name} value={d.name}>
{d.description}{d.is_default ? ' (default)' : ''}
</option>
))
}
</select>
) : knob.action === 'source_volume' ? (
<select
id={`knob-${index}-target`}
value={knob.target ?? 'default'}
onChange={(e) => update({ target: e.target.value })}
disabled={sources.length === 0}
aria-label="Target"
>
{sources.length === 0
? <option value="default"> no devices detected </option>
: sources.map((d) => (
<option key={d.name} value={d.name}>
{d.description}{d.is_default ? ' (default)' : ''}
</option>
))
}
</select>
) : (
/* app_volume */
<select
id={`knob-${index}-target`}
value={knob.target ?? 'default'}
onChange={(e) => update({ target: e.target.value })}
disabled={runningApps.length === 0}
aria-label="Target"
>
<option value="default">
{runningApps.length === 0 ? '— no apps detected —' : '— select an app —'}
</option>
{runningApps.map((app) => ( {runningApps.map((app) => (
<option key={app} value={app} /> <option key={app} value={app}>{app}</option>
))} ))}
</datalist> </select>
)} )}
</div> </div>
)} )}

View File

@ -370,19 +370,9 @@ main {
text-transform: none; text-transform: none;
} }
/* ── App picker (group_volume) ─────────────────────────────────────────────── */ /* ── label hint ────────────────────────────────────────────────────────────── */
.app-picker { .label-hint {
display: flex; font-size: 0.75rem;
gap: 6px; color: var(--muted, #8b949e);
margin-top: 6px; font-weight: normal;
}
.app-picker select {
flex: 1;
min-width: 0;
}
.app-picker .btn-secondary {
flex-shrink: 0;
padding: 7px 12px;
} }

View File

@ -28,6 +28,12 @@ export interface Config {
buttons: Record<string, ButtonConfig>; buttons: Record<string, ButtonConfig>;
} }
export interface AudioDevice {
name: string;
description: string;
is_default?: boolean;
}
export interface ToastItem { export interface ToastItem {
id: number; id: number;
message: string; message: string;