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 h2main
parent
8963b494c3
commit
88216187d4
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export default function App() {
|
||||||
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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ const DEFAULT_LED: LedConfig = {
|
||||||
interface Props {
|
interface Props {
|
||||||
index: number;
|
index: number;
|
||||||
knob: KnobConfig;
|
knob: KnobConfig;
|
||||||
|
runningApps?: string[];
|
||||||
onChange: (knob: KnobConfig) => void;
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue