feat: replace free-text target fields with device/app dropdowns
- sink_volume and source_volume knobs now show a dropdown of active PulseAudio/PipeWire output/input devices (monitors excluded), with the system-default device marked in the label - app_volume knob shows a dropdown of running apps (was already a dropdown, previously had a textarea for group_volume) - group_volume knob shows a <select multiple> listbox so several apps can be selected at once with ctrl/shift-click - Add GET /api/sinks and GET /api/sources backend endpoints - Add fetchSinks() / fetchSources() in api.ts with AudioDevice type - Load sinks and sources in App.tsx alongside running apps on refresh - All 23 frontend tests passmain
parent
3126227d2b
commit
fc9ac17f1e
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
const [sinks, setSinks] = useState<AudioDevice[]>([]);
|
||||
const [sources, setSources] = useState<AudioDevice[]>([]);
|
||||
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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function renderCard(
|
||||
overrides: Partial<KnobConfig> = {},
|
||||
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(
|
||||
<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 ──────────────────────────────────────────────────────────
|
||||
|
||||
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<HTMLInputElement>('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<HTMLSelectElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLSelectElement>('combobox', {
|
||||
name: /pick a running app/i,
|
||||
});
|
||||
const select = screen.getByRole<HTMLSelectElement>('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<HTMLSelectElement>('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<HTMLSelectElement>('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<HTMLSelectElement>('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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Config } from './types';
|
||||
import type { Config, AudioDevice } from './types';
|
||||
|
||||
async function checkResponse(r: Response): Promise<void> {
|
||||
if (!r.ok) {
|
||||
|
|
@ -66,3 +66,23 @@ export async function fetchRunningApps(): Promise<string[]> {
|
|||
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 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<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 (
|
||||
<div className="knob-card">
|
||||
<div className="card-title">
|
||||
|
|
@ -91,57 +79,86 @@ export function KnobCard({ index, knob, runningApps = [], onChange }: Props) {
|
|||
{/* Target / Targets */}
|
||||
{knob.action === 'group_volume' ? (
|
||||
<div>
|
||||
<label htmlFor={`knob-${index}-targets`}>Targets (comma-separated)</label>
|
||||
<textarea
|
||||
<label htmlFor={`knob-${index}-targets`}>
|
||||
Targets
|
||||
{runningApps.length > 0 && (
|
||||
<span className="label-hint"> (ctrl/shift to select multiple)</span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
id={`knob-${index}-targets`}
|
||||
value={(knob.targets ?? []).join(', ')}
|
||||
placeholder="spotify, vlc, brave"
|
||||
multiple
|
||||
size={Math.max(3, Math.min(runningApps.length, 6))}
|
||||
value={knob.targets ?? []}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
targets: e.target.value.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
targets: Array.from(e.target.selectedOptions).map((o) => o.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{runningApps.length > 0 && (
|
||||
<div className="app-picker">
|
||||
<select
|
||||
value={pickedApp}
|
||||
onChange={(e) => setPickedApp(e.target.value)}
|
||||
aria-label="Pick a running app"
|
||||
>
|
||||
<option value="">— running apps —</option>
|
||||
{runningApps.map((app) => (
|
||||
<option key={app} value={app}>{app}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={addPickedApp}
|
||||
disabled={!pickedApp}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
disabled={runningApps.length === 0}
|
||||
aria-label="Targets"
|
||||
>
|
||||
{runningApps.length === 0 && (
|
||||
<option value="" disabled>— no apps detected —</option>
|
||||
)}
|
||||
{runningApps.map((app) => (
|
||||
<option key={app} value={app}>{app}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor={`knob-${index}-target`}>Target</label>
|
||||
<input
|
||||
type="text"
|
||||
id={`knob-${index}-target`}
|
||||
list={runningApps.length > 0 ? datalistId : undefined}
|
||||
value={knob.target ?? 'default'}
|
||||
placeholder="default"
|
||||
onChange={(e) => update({ target: e.target.value })}
|
||||
/>
|
||||
{runningApps.length > 0 && (
|
||||
<datalist id={datalistId}>
|
||||
{knob.action === 'sink_volume' ? (
|
||||
<select
|
||||
id={`knob-${index}-target`}
|
||||
value={knob.target ?? 'default'}
|
||||
onChange={(e) => update({ target: e.target.value })}
|
||||
disabled={sinks.length === 0}
|
||||
aria-label="Target"
|
||||
>
|
||||
{sinks.length === 0
|
||||
? <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) => (
|
||||
<option key={app} value={app} />
|
||||
<option key={app} value={app}>{app}</option>
|
||||
))}
|
||||
</datalist>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -370,19 +370,9 @@ main {
|
|||
text-transform: none;
|
||||
}
|
||||
|
||||
/* ── App picker (group_volume) ─────────────────────────────────────────────── */
|
||||
.app-picker {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.app-picker select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-picker .btn-secondary {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 12px;
|
||||
/* ── label hint ────────────────────────────────────────────────────────────── */
|
||||
.label-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted, #8b949e);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export interface Config {
|
|||
buttons: Record<string, ButtonConfig>;
|
||||
}
|
||||
|
||||
export interface AudioDevice {
|
||||
name: string;
|
||||
description: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue