Merge pull request #8 from sean351/feat/device-dropdowns
feat: replace free-text target fields with device/app dropdownsmain
commit
989fc4ca40
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue