diff --git a/src/turnup/ui/server.py b/src/turnup/ui/server.py index fae50a9..6a6cfd0 100644 --- a/src/turnup/ui/server.py +++ b/src/turnup/ui/server.py @@ -138,6 +138,60 @@ def list_running_apps() -> list[str]: 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 ──────────────────────────────────────────────────────────────── def _preset_path(name: str) -> Path: diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fb438c6..4ccf422 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,5 @@ 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 { ToastContainer } from './components/Toast'; import { Connection } from './components/Connection'; @@ -21,6 +21,8 @@ export default function App() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [runningApps, setRunningApps] = useState([]); + const [sinks, setSinks] = useState([]); + const [sources, setSources] = useState([]); const [appsLoading, setAppsLoading] = useState(false); const addToast = useCallback((message: string, type: ToastItem['type'] = 'success') => { @@ -44,7 +46,14 @@ export default function App() { const loadApps = useCallback(async () => { setAppsLoading(true); 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 { setAppsLoading(false); } @@ -158,6 +167,8 @@ export default function App() { index={Number(i)} knob={config.knobs[i] ?? { action: 'sink_volume', target: 'default' }} runningApps={runningApps} + sinks={sinks} + sources={sources} onChange={(knob) => patchConfig('knobs', { ...config.knobs, [i]: knob }) } diff --git a/ui/src/__tests__/KnobCard.test.tsx b/ui/src/__tests__/KnobCard.test.tsx index a312e3c..0c52a43 100644 --- a/ui/src/__tests__/KnobCard.test.tsx +++ b/ui/src/__tests__/KnobCard.test.tsx @@ -1,28 +1,50 @@ /** - * Tests for KnobCard — running-apps dropdown feature. + * Tests for KnobCard — target/targets dropdown feature. * * Covers: - * app_volume — datalist rendered when runningApps provided - * app_volume — no datalist when runningApps is empty / not passed - * app_volume — picking a suggestion calls onChange with the chosen value - * group_volume — app-picker select rendered when runningApps provided - * group_volume — no app-picker when runningApps is empty / not passed - * group_volume — Add button is disabled until an app is selected - * group_volume — clicking Add appends the app to targets - * group_volume — clicking Add does NOT add a duplicate app + * sink_volume — dropdown renders active sinks + * sink_volume — dropdown disabled when sinks is empty / not passed + * sink_volume — selecting a device calls onChange correctly + * source_volume — dropdown renders active sources + * source_volume — dropdown disabled when sources is empty / not passed + * app_volume — dropdown rendered with running apps + * app_volume — dropdown disabled when runningApps is empty / not passed + * 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 userEvent from '@testing-library/user-event'; import { KnobCard } from '../components/KnobCard'; -import type { KnobConfig } from '../types'; +import type { KnobConfig, AudioDevice } from '../types'; // ── 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; +} + function renderCard( overrides: Partial = {}, - runningApps?: string[], - onChange = vi.fn(), + { runningApps, sinks, sources, onChange = vi.fn() }: RenderOptions = {}, ) { const knob: KnobConfig = { action: 'app_volume', @@ -32,53 +54,134 @@ function renderCard( return { onChange, ...render( - , + , ), }; } +// ── 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('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('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 ────────────────────────────────────────────────────────── describe('KnobCard — app_volume', () => { - it('renders a datalist with the supplied running apps', () => { - renderCard({ action: 'app_volume' }, ['spotify', 'vlc', 'brave']); + it('renders a dropdown with the supplied running apps', () => { + renderCard({ action: 'app_volume' }, { runningApps: ['spotify', 'vlc', 'brave'] }); - // An input with a list attribute is promoted to combobox by jsdom; query - // by label text instead to avoid coupling to the ARIA role inference. - const input = screen.getByLabelText('Target'); - const datalistId = input.getAttribute('list'); - 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']); + const select = screen.getByRole('combobox', { name: /target/i }); + expect(select).toBeInTheDocument(); + const options = Array.from(select.options).slice(1); // skip placeholder + expect(options.map((o) => o.value)).toEqual(['spotify', 'vlc', 'brave']); }); - it('does not attach a datalist when runningApps is empty', () => { - renderCard({ action: 'app_volume' }, []); + it('disables the dropdown when runningApps is empty', () => { + renderCard({ action: 'app_volume' }, { runningApps: [] }); - const input = screen.getByLabelText('Target'); - expect(input.getAttribute('list')).toBeNull(); + expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled(); }); - it('does not attach a datalist when runningApps is not provided', () => { - renderCard({ action: 'app_volume' }, undefined); + it('disables the dropdown when runningApps is not provided', () => { + renderCard({ action: 'app_volume' }); - const input = screen.getByLabelText('Target'); - expect(input.getAttribute('list')).toBeNull(); + expect(screen.getByRole('combobox', { name: /target/i })).toBeDisabled(); }); - 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(); - 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 - // the browser firing a change event with the full desired value. - fireEvent.change(screen.getByLabelText('Target'), { + fireEvent.change(screen.getByRole('combobox', { name: /target/i }), { target: { value: 'brave' }, }); @@ -90,84 +193,67 @@ describe('KnobCard — app_volume', () => { // ── group_volume tests ──────────────────────────────────────────────────────── describe('KnobCard — group_volume', () => { - it('renders the app-picker select with running apps', () => { - renderCard({ action: 'group_volume', targets: [] }, ['vlc', 'brave']); + it('renders a multi-select listbox with running apps', () => { + renderCard({ action: 'group_volume', targets: [] }, { runningApps: ['vlc', 'brave'] }); - // The placeholder option + 2 apps - const select = screen.getByRole('combobox', { - name: /pick a running app/i, - }); + const select = screen.getByRole('listbox', { name: /targets/i }); 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']); }); - it('does not render the app-picker when runningApps is empty', () => { - renderCard({ action: 'group_volume', targets: [] }, []); + it('renders the listbox (disabled) when runningApps is empty', () => { + renderCard({ action: 'group_volume', targets: [] }, { runningApps: [] }); - expect( - screen.queryByRole('combobox', { name: /pick a running app/i }), - ).not.toBeInTheDocument(); + expect(screen.getByRole('listbox', { name: /targets/i })).toBeDisabled(); }); - it('does not render the app-picker when runningApps is not provided', () => { - renderCard({ action: 'group_volume', targets: [] }, undefined); + it('renders the listbox (disabled) when runningApps is not provided', () => { + renderCard({ action: 'group_volume', targets: [] }); - expect( - screen.queryByRole('combobox', { name: /pick a running app/i }), - ).not.toBeInTheDocument(); + expect(screen.getByRole('listbox', { name: /targets/i })).toBeDisabled(); }); - it('Add button is disabled until an app is selected from the dropdown', () => { - 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 () => { + it('selecting a single app calls onChange with that target', async () => { 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 }); - await userEvent.selectOptions(select, 'brave'); - await userEvent.click(screen.getByRole('button', { name: /add/i })); + const select = screen.getByRole('listbox', { name: /targets/i }); + await userEvent.selectOptions(select, 'spotify'); 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(); - 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 }); - await userEvent.selectOptions(select, 'brave'); - await userEvent.click(screen.getByRole('button', { name: /add/i })); + const select = screen.getByRole('listbox', { name: /targets/i }); - const lastCall = onChange.mock.calls.at(-1); - // onChange should not have been called at all (duplicate guard) - expect(lastCall).toBeUndefined(); + // jsdom doesn't support setting selectedOptions via fireEvent, so mark + // individual options as selected and dispatch the change event manually. + 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 () => { - renderCard({ action: 'group_volume', targets: [] }, ['spotify']); + it('pre-selects options that are already in targets', () => { + renderCard( + { action: 'group_volume', targets: ['vlc', 'brave'] }, + { runningApps: ['spotify', 'vlc', 'brave'] }, + ); - const select = screen.getByRole('combobox', { - name: /pick a running app/i, - }); - await userEvent.selectOptions(select, 'spotify'); - await userEvent.click(screen.getByRole('button', { name: /add/i })); - - expect(select.value).toBe(''); + const select = screen.getByRole('listbox', { name: /targets/i }); + const selected = Array.from(select.options) + .filter((o) => o.selected) + .map((o) => o.value); + expect(selected).toEqual(expect.arrayContaining(['vlc', 'brave'])); + expect(selected).toHaveLength(2); }); }); diff --git a/ui/src/api.ts b/ui/src/api.ts index 1536a60..4b0b4bc 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,4 +1,4 @@ -import type { Config } from './types'; +import type { Config, AudioDevice } from './types'; async function checkResponse(r: Response): Promise { if (!r.ok) { @@ -66,3 +66,23 @@ export async function fetchRunningApps(): Promise { return []; } } + +export async function fetchSinks(): Promise { + try { + const r = await fetch('/api/sinks'); + if (!r.ok) return []; + return r.json(); + } catch { + return []; + } +} + +export async function fetchSources(): Promise { + try { + const r = await fetch('/api/sources'); + if (!r.ok) return []; + return r.json(); + } catch { + return []; + } +} diff --git a/ui/src/components/KnobCard.tsx b/ui/src/components/KnobCard.tsx index d68e7fe..8cb9f4f 100644 --- a/ui/src/components/KnobCard.tsx +++ b/ui/src/components/KnobCard.tsx @@ -1,5 +1,5 @@ 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'; const ACTIONS: { value: KnobAction; label: string }[] = [ @@ -19,12 +19,13 @@ interface Props { index: number; knob: KnobConfig; runningApps?: string[]; + sinks?: AudioDevice[]; + sources?: AudioDevice[]; onChange: (knob: KnobConfig) => void; } -export function KnobCard({ index, knob, runningApps = [], onChange }: Props) { - const [ledOpen, setLedOpen] = useState(!!knob.led); - const [pickedApp, setPickedApp] = useState(''); +export function KnobCard({ index, knob, runningApps = [], sinks = [], sources = [], onChange }: Props) { + const [ledOpen, setLedOpen] = useState(!!knob.led); const update = (patch: Partial) => 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 (
@@ -91,57 +79,86 @@ export function KnobCard({ index, knob, runningApps = [], onChange }: Props) { {/* Target / Targets */} {knob.action === 'group_volume' ? (
- -