feat: show running PulseAudio apps in a dropdown for easier knob targeting

- Add GET /api/apps endpoint that queries pulsectl for active sink inputs
  and returns deduplicated app names (application.name + process.binary)
- Add fetchRunningApps() API helper (returns [] on any failure, non-fatal)
- Fetch running apps on page load; expose a Refresh button in the Knobs
  section header to re-query without a full page reload
- KnobCard app_volume: wire target input to an HTML5 <datalist> so the
  browser shows a native autocomplete dropdown of running apps
- KnobCard group_volume: add a select + Add button beneath the textarea so
  users can pick a running app and append it without typing
- CSS: style the app-picker row and the compact refresh button in h2
main
Sean Doran 2026-02-28 17:29:52 -05:00
parent 8963b494c3
commit 88216187d4
No known key found for this signature in database
GPG Key ID: A9D7D25CD95E8579
5 changed files with 147 additions and 13 deletions

View File

@ -114,6 +114,30 @@ async def save_config(request: Request) -> dict[str, bool]:
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 ────────────────────────────────────────────────────────────────
def _preset_path(name: str) -> Path:

View File

@ -20,6 +20,8 @@ export default function App() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [loading, setLoading] = useState(true);
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 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
useEffect(() => {
void (async () => {
@ -52,8 +63,9 @@ export default function App() {
setLoading(false);
}
await loadPresets();
await loadApps();
})();
}, [addToast, loadPresets]);
}, [addToast, loadPresets, loadApps]);
const isDirty = config !== null && JSON.stringify(config) !== JSON.stringify(saved);
@ -128,13 +140,24 @@ export default function App() {
/>
<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">
{KNOB_INDICES.map((i) => (
<KnobCard
key={i}
index={Number(i)}
knob={config.knobs[i] ?? { action: 'sink_volume', target: 'default' }}
runningApps={runningApps}
onChange={(knob) =>
patchConfig('knobs', { ...config.knobs, [i]: knob })
}

View File

@ -56,3 +56,13 @@ export async function deletePreset(name: string): Promise<void> {
});
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

@ -18,11 +18,13 @@ const DEFAULT_LED: LedConfig = {
interface Props {
index: number;
knob: KnobConfig;
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 [pickedApp, setPickedApp] = useState('');
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 (
<div className="knob-card">
<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>
@ -94,10 +131,18 @@ export function KnobCard({ index, knob, onChange }: Props) {
<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}>
{runningApps.map((app) => (
<option key={app} value={app} />
))}
</datalist>
)}
</div>
)}

View File

@ -354,3 +354,35 @@ main {
main { padding: 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;
}