Merge pull request #7 from sean351/feat/running-apps-dropdown

feat: show running PulseAudio apps in dropdown for easier knob targeting
main
Sean 2026-02-28 18:06:56 -05:00 committed by GitHub
commit 3126227d2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1736 additions and 17 deletions

View File

@ -114,6 +114,30 @@ async def save_config(request: Request) -> dict[str, bool]:
return {"ok": True} return {"ok": True}
# ── Running apps API ──────────────────────────────────────────────────────────
@app.get("/api/apps")
def list_running_apps() -> list[str]:
"""Return unique app names from currently active PulseAudio/PipeWire sink inputs.
Returns both ``application.name`` and ``application.process.binary`` values
(deduplicated) because the daemon's matching logic accepts either. Falls
back to an empty list if pulsectl is unavailable or no server is reachable.
"""
try:
import pulsectl # optional — only present when [ui] extra is installed
with pulsectl.Pulse("turnup-ui-apps") as pulse:
seen: set[str] = set()
for si in pulse.sink_input_list():
for field in ("application.name", "application.process.binary"):
val = (si.proplist.get(field) or "").strip()
if val:
seen.add(val)
return sorted(seen, key=str.lower)
except Exception:
return []
# ── Presets API ──────────────────────────────────────────────────────────────── # ── Presets API ────────────────────────────────────────────────────────────────
def _preset_path(name: str) -> Path: def _preset_path(name: str) -> Path:

1348
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
@ -15,16 +17,22 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^28.1.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^4.0.18"
} }
} }

View File

@ -14,12 +14,14 @@ const BTN_INDICES = ['0', '1', '2', '3', '4'] as const;
let toastSeq = 0; let toastSeq = 0;
export default function App() { export default function App() {
const [config, setConfig] = useState<Config | null>(null); const [config, setConfig] = useState<Config | null>(null);
const [saved, setSaved] = useState<Config | null>(null); const [saved, setSaved] = useState<Config | null>(null);
const [presets, setPresets] = useState<string[]>([]); const [presets, setPresets] = useState<string[]>([]);
const [toasts, setToasts] = useState<ToastItem[]>([]); const [toasts, setToasts] = useState<ToastItem[]>([]);
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 [appsLoading, setAppsLoading] = useState(false);
const addToast = useCallback((message: string, type: ToastItem['type'] = 'success') => { const addToast = useCallback((message: string, type: ToastItem['type'] = 'success') => {
const id = ++toastSeq; const id = ++toastSeq;
@ -39,6 +41,15 @@ export default function App() {
} }
}, []); }, []);
const loadApps = useCallback(async () => {
setAppsLoading(true);
try {
setRunningApps(await api.fetchRunningApps());
} finally {
setAppsLoading(false);
}
}, []);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@ -52,8 +63,9 @@ export default function App() {
setLoading(false); setLoading(false);
} }
await loadPresets(); await loadPresets();
await loadApps();
})(); })();
}, [addToast, loadPresets]); }, [addToast, loadPresets, loadApps]);
const isDirty = config !== null && JSON.stringify(config) !== JSON.stringify(saved); const isDirty = config !== null && JSON.stringify(config) !== JSON.stringify(saved);
@ -128,13 +140,24 @@ export default function App() {
/> />
<section className="card" id="knobs"> <section className="card" id="knobs">
<h2>Knobs</h2> <h2>
Knobs
<button
className="btn-secondary btn-refresh-apps"
onClick={() => void loadApps()}
disabled={appsLoading}
title="Refresh list of running apps"
>
{appsLoading ? '…' : '↻'} Running apps
</button>
</h2>
<div className="cards-grid"> <div className="cards-grid">
{KNOB_INDICES.map((i) => ( {KNOB_INDICES.map((i) => (
<KnobCard <KnobCard
key={i} key={i}
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}
onChange={(knob) => onChange={(knob) =>
patchConfig('knobs', { ...config.knobs, [i]: knob }) patchConfig('knobs', { ...config.knobs, [i]: knob })
} }

View File

@ -0,0 +1,173 @@
/**
* Tests for KnobCard running-apps 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
*/
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';
// ── helpers ───────────────────────────────────────────────────────────────────
function renderCard(
overrides: Partial<KnobConfig> = {},
runningApps?: string[],
onChange = vi.fn(),
) {
const knob: KnobConfig = {
action: 'app_volume',
target: 'default',
...overrides,
};
return {
onChange,
...render(
<KnobCard index={1} knob={knob} runningApps={runningApps} onChange={onChange} />,
),
};
}
// ── app_volume tests ──────────────────────────────────────────────────────────
describe('KnobCard — app_volume', () => {
it('renders a datalist with the supplied running apps', () => {
renderCard({ action: 'app_volume' }, ['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']);
});
it('does not attach a datalist when runningApps is empty', () => {
renderCard({ action: 'app_volume' }, []);
const input = screen.getByLabelText<HTMLInputElement>('Target');
expect(input.getAttribute('list')).toBeNull();
});
it('does not attach a datalist when runningApps is not provided', () => {
renderCard({ action: 'app_volume' }, undefined);
const input = screen.getByLabelText<HTMLInputElement>('Target');
expect(input.getAttribute('list')).toBeNull();
});
it('calls onChange with the typed/selected value when the input changes', () => {
const onChange = vi.fn();
renderCard({ action: 'app_volume', target: 'default' }, ['spotify'], 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'), {
target: { value: 'brave' },
});
const lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
expect(lastCall.target).toBe('brave');
});
});
// ── group_volume tests ────────────────────────────────────────────────────────
describe('KnobCard — group_volume', () => {
it('renders the app-picker select with running apps', () => {
renderCard({ action: 'group_volume', targets: [] }, ['vlc', 'brave']);
// The placeholder option + 2 apps
const select = screen.getByRole<HTMLSelectElement>('combobox', {
name: /pick a running app/i,
});
expect(select).toBeInTheDocument();
const options = Array.from(select.options).slice(1); // skip placeholder
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: [] }, []);
expect(
screen.queryByRole('combobox', { name: /pick a running app/i }),
).not.toBeInTheDocument();
});
it('does not render the app-picker when runningApps is not provided', () => {
renderCard({ action: 'group_volume', targets: [] }, undefined);
expect(
screen.queryByRole('combobox', { name: /pick a running app/i }),
).not.toBeInTheDocument();
});
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 () => {
const onChange = vi.fn();
renderCard({ action: 'group_volume', targets: ['vlc'] }, ['spotify', '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 lastCall = onChange.mock.calls.at(-1)![0] as KnobConfig;
expect(lastCall.targets).toEqual(['vlc', 'brave']);
});
it('clicking Add does not add a duplicate app', async () => {
const onChange = vi.fn();
renderCard({ action: 'group_volume', targets: ['brave'] }, ['spotify', '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 lastCall = onChange.mock.calls.at(-1);
// onChange should not have been called at all (duplicate guard)
expect(lastCall).toBeUndefined();
});
it('resets the picker select back to placeholder after a successful Add', async () => {
renderCard({ action: 'group_volume', targets: [] }, ['spotify']);
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('');
});
});

View File

@ -0,0 +1,52 @@
/**
* Tests for the fetchRunningApps API helper.
*
* fetchRunningApps wraps GET /api/apps and is intentionally non-throwing:
* - returns the parsed JSON array on success
* - returns [] when the server responds with a non-2xx status
* - returns [] when the network call itself throws (e.g. server not reachable)
*/
import { fetchRunningApps } from '../api';
const mockFetch = vi.fn<typeof fetch>();
vi.stubGlobal('fetch', mockFetch);
afterEach(() => {
mockFetch.mockReset();
});
function makeResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: () => Promise.resolve(body),
} as unknown as Response;
}
describe('fetchRunningApps', () => {
it('returns a sorted list of app names on a successful response', async () => {
mockFetch.mockResolvedValue(makeResponse(['vlc', 'brave', 'spotify']));
const result = await fetchRunningApps();
expect(mockFetch).toHaveBeenCalledWith('/api/apps');
expect(result).toEqual(['vlc', 'brave', 'spotify']);
});
it('returns an empty array when the server responds with a non-ok status', async () => {
mockFetch.mockResolvedValue(makeResponse(null, false, 500));
const result = await fetchRunningApps();
expect(result).toEqual([]);
});
it('returns an empty array when fetch throws (network error)', async () => {
mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
const result = await fetchRunningApps();
expect(result).toEqual([]);
});
});

View File

@ -56,3 +56,13 @@ export async function deletePreset(name: string): Promise<void> {
}); });
await checkResponse(r); await checkResponse(r);
} }
export async function fetchRunningApps(): Promise<string[]> {
try {
const r = await fetch('/api/apps');
if (!r.ok) return [];
return r.json();
} catch {
return [];
}
}

View File

@ -16,13 +16,15 @@ const DEFAULT_LED: LedConfig = {
}; };
interface Props { interface Props {
index: number; index: number;
knob: KnobConfig; knob: KnobConfig;
onChange: (knob: KnobConfig) => void; runningApps?: string[];
onChange: (knob: KnobConfig) => void;
} }
export function KnobCard({ index, knob, onChange }: Props) { export function KnobCard({ index, knob, runningApps = [], 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 });
@ -45,6 +47,19 @@ export function KnobCard({ index, knob, 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">
@ -87,6 +102,28 @@ export function KnobCard({ index, knob, onChange }: Props) {
}) })
} }
/> />
{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>
)}
</div> </div>
) : ( ) : (
<div> <div>
@ -94,10 +131,18 @@ export function KnobCard({ index, knob, onChange }: Props) {
<input <input
type="text" type="text"
id={`knob-${index}-target`} id={`knob-${index}-target`}
list={runningApps.length > 0 ? datalistId : undefined}
value={knob.target ?? 'default'} value={knob.target ?? 'default'}
placeholder="default" placeholder="default"
onChange={(e) => update({ target: e.target.value })} onChange={(e) => update({ target: e.target.value })}
/> />
{runningApps.length > 0 && (
<datalist id={datalistId}>
{runningApps.map((app) => (
<option key={app} value={app} />
))}
</datalist>
)}
</div> </div>
)} )}

View File

@ -354,3 +354,35 @@ main {
main { padding: 16px; } main { padding: 16px; }
#app-header { padding: 10px 16px; } #app-header { padding: 10px 16px; }
} }
/* ── Running-apps refresh button (Knobs section heading) ───────────────────── */
#knobs h2 {
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-refresh-apps {
padding: 3px 10px;
font-size: 11px;
font-weight: 500;
letter-spacing: normal;
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;
}

1
ui/src/test-setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -5,7 +5,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client", "vitest/globals"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */

View File

@ -14,4 +14,9 @@ export default defineConfig({
'/api': 'http://localhost:5173', '/api': 'http://localhost:5173',
}, },
}, },
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
}) })