diff --git a/src/turnup/ui/server.py b/src/turnup/ui/server.py index fed2c6b..fae50a9 100644 --- a/src/turnup/ui/server.py +++ b/src/turnup/ui/server.py @@ -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: diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d6e191c..fb438c6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -14,12 +14,14 @@ const BTN_INDICES = ['0', '1', '2', '3', '4'] as const; let toastSeq = 0; export default function App() { - const [config, setConfig] = useState(null); - const [saved, setSaved] = useState(null); - const [presets, setPresets] = useState([]); - const [toasts, setToasts] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + const [config, setConfig] = useState(null); + const [saved, setSaved] = useState(null); + const [presets, setPresets] = useState([]); + const [toasts, setToasts] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [runningApps, setRunningApps] = useState([]); + 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() { />
-

Knobs

+

+ Knobs + +

{KNOB_INDICES.map((i) => ( patchConfig('knobs', { ...config.knobs, [i]: knob }) } diff --git a/ui/src/api.ts b/ui/src/api.ts index b378005..1536a60 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -56,3 +56,13 @@ export async function deletePreset(name: string): Promise { }); await checkResponse(r); } + +export async function fetchRunningApps(): Promise { + try { + const r = await fetch('/api/apps'); + if (!r.ok) return []; + return r.json(); + } catch { + return []; + } +} diff --git a/ui/src/components/KnobCard.tsx b/ui/src/components/KnobCard.tsx index fa52ff1..d68e7fe 100644 --- a/ui/src/components/KnobCard.tsx +++ b/ui/src/components/KnobCard.tsx @@ -16,13 +16,15 @@ const DEFAULT_LED: LedConfig = { }; interface Props { - index: number; - knob: KnobConfig; - onChange: (knob: KnobConfig) => void; + index: number; + knob: KnobConfig; + runningApps?: string[]; + onChange: (knob: KnobConfig) => void; } -export function KnobCard({ index, knob, onChange }: Props) { - const [ledOpen, setLedOpen] = useState(!!knob.led); +export function KnobCard({ index, knob, runningApps = [], onChange }: Props) { + const [ledOpen, setLedOpen] = useState(!!knob.led); + const [pickedApp, setPickedApp] = useState(''); const update = (patch: Partial) => 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 (
@@ -87,6 +102,28 @@ export function KnobCard({ index, knob, onChange }: Props) { }) } /> + {runningApps.length > 0 && ( +
+ + +
+ )}
) : (
@@ -94,10 +131,18 @@ export function KnobCard({ index, knob, onChange }: Props) { 0 ? datalistId : undefined} value={knob.target ?? 'default'} placeholder="default" onChange={(e) => update({ target: e.target.value })} /> + {runningApps.length > 0 && ( + + {runningApps.map((app) => ( + + )}
)} diff --git a/ui/src/index.css b/ui/src/index.css index 5015fda..6b6b3db 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; +}