From ed43cd2ac7ff2274c5515734e3e188da01f1a797 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 29 Jul 2026 16:42:07 +0200 Subject: [PATCH] feat(hyprdrive,hyprlua): mirror output-device dropdown with input-device on Applications tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each app card in the mixer panel's Applications tab now also shows an Input dropdown next to Output, letting you re-route that app's own microphone stream (pactl move-source-output) the same way Output already re-routes its playback stream. Matches an app's sink-input to its source-output via application.process.binary (falling back to name) — the same client tags both when an app plays and records, e.g. Discord. Apps with no matching recording stream get the dropdown disabled rather than hidden, so card layout stays consistent across the row. hyprlua's audio-panel picks this up via regen-audio-panel.sh, unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TkkH9eiCBfpWysUXAD9bDG --- .../supersonic-booster/ui/app_card.py | 98 ++++++++++++++++--- .../supersonic-booster/ui/applications_tab.py | 16 ++- .../hyprlua/audio-panel/ui/app_card.py | 98 ++++++++++++++++--- .../audio-panel/ui/applications_tab.py | 16 ++- 4 files changed, 192 insertions(+), 36 deletions(-) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py b/desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py index a720bc5..ea08f77 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py @@ -1,7 +1,14 @@ """ApplicationCard — one card in the Applications tab: everything MeterCard gives every card (icon+name, live peak bar, volume meter/L-R pair, mute), plus a dropdown to route this one app's playback stream to a different -output device (`pactl move-sink-input`).""" +output device (`pactl move-sink-input`), and — mirroring that — a second +dropdown to route the same app's *microphone* stream (if it has one) to a +different input device (`pactl move-source-output`). The meter/mute/peak +controls above stay tied to the app's playback stream (this tab is keyed by +sink-inputs); the input dropdown is purely a routing control for whichever +source-output shares this app's `application.process.binary` (falling back +to matching by name), disabled when the app isn't recording anything. +""" from __future__ import annotations @@ -14,48 +21,111 @@ from ui.card_base import MeterCard class ApplicationCard(MeterCard): - def __init__(self, backend, peaks, item: dict, sinks: list[dict]) -> None: + def __init__(self, backend, peaks, item: dict, sinks: list[dict], + sources: list[dict], source_outputs: list[dict]) -> None: self._sinks = sinks self._sink_names: list[str] = [] + self._sources = sources + self._source_names: list[str] = [] + self._recording: dict | None = None super().__init__(backend, peaks, "sink-input", item) + self._update_extra(item, sinks, sources, source_outputs) def _build_extra(self, item: dict): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - label = Gtk.Label(label="Output", xalign=0.0) - label.add_css_class("sb-field-label") - box.append(label) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + + out_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + out_label = Gtk.Label(label="Output", xalign=0.0) + out_label.add_css_class("sb-field-label") + out_col.append(out_label) self._sink_dropdown = Gtk.DropDown() self._sink_dropdown.add_css_class("sb-device-dropdown") - self._dropdown_guard = False + self._sink_guard = False self._sink_dropdown.connect("notify::selected", self._on_sink_selected) - box.append(self._sink_dropdown) + out_col.append(self._sink_dropdown) + box.append(out_col) + + in_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + in_label = Gtk.Label(label="Input", xalign=0.0) + in_label.add_css_class("sb-field-label") + in_col.append(in_label) + self._source_dropdown = Gtk.DropDown() + self._source_dropdown.add_css_class("sb-device-dropdown") + self._source_guard = False + self._source_dropdown.connect("notify::selected", self._on_source_selected) + in_col.append(self._source_dropdown) + box.append(in_col) + self._sync_sink_dropdown(item, self._sinks) return box - def _update_extra(self, item: dict, sinks: list[dict]) -> None: + def _update_extra(self, item: dict, sinks: list[dict], sources: list[dict], + source_outputs: list[dict]) -> None: self._sinks = sinks + self._sources = sources + self._recording = self._find_recording(item, source_outputs) self._sync_sink_dropdown(item, sinks) + self._sync_source_dropdown() + # -- output routing (playback stream -> sink) -------------------------------- def _sync_sink_dropdown(self, item: dict, sinks: list[dict]) -> None: names = [s["name"] for s in sinks] if names != self._sink_names: self._sink_names = names - self._dropdown_guard = True + self._sink_guard = True self._sink_dropdown.set_model(Gtk.StringList.new([s["description"] for s in sinks])) - self._dropdown_guard = False + self._sink_guard = False current = item.get("device") idx = next((i for i, s in enumerate(sinks) if s["id"] == current), None) if idx is not None and self._sink_dropdown.get_selected() != idx: - self._dropdown_guard = True + self._sink_guard = True self._sink_dropdown.set_selected(idx) - self._dropdown_guard = False + self._sink_guard = False def _on_sink_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: - if self._dropdown_guard: + if self._sink_guard: return idx = dropdown.get_selected() if 0 <= idx < len(self._sink_names): self._backend.move_sink_input(self._id, self._sink_names[idx]) + # -- input routing (recording stream -> source), mirrors the above ----------- + @staticmethod + def _find_recording(item: dict, source_outputs: list[dict]) -> dict | None: + binary = item.get("binary") + name = item.get("name") + for so in source_outputs: + if binary and so.get("binary") == binary: + return so + if not binary and so.get("name") == name: + return so + return None + + def _sync_source_dropdown(self) -> None: + sources = self._sources + names = [s["name"] for s in sources] + if names != self._source_names: + self._source_names = names + self._source_guard = True + self._source_dropdown.set_model(Gtk.StringList.new([s["description"] for s in sources])) + self._source_guard = False + + self._source_dropdown.set_sensitive(self._recording is not None) + if self._recording is None: + return + current = self._recording.get("device") + idx = next((i for i, s in enumerate(sources) if s["id"] == current), None) + if idx is not None and self._source_dropdown.get_selected() != idx: + self._source_guard = True + self._source_dropdown.set_selected(idx) + self._source_guard = False + + def _on_source_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._source_guard or self._recording is None: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._source_names): + self._backend.move_source_output(self._recording["id"], self._source_names[idx]) + def _peak_target(self, item: dict) -> list[str] | None: return [f"--monitor-stream={item['id']}"] diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py index 3065246..8ce7110 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py @@ -1,6 +1,9 @@ """Applications tab: horizontally-scrolling row of every app currently playing audio (pactl sink-inputs), each with its own volume meter (or -independent L/R pair), mute, live peak bar, and an output-device dropdown.""" +independent L/R pair), mute, live peak bar, an output-device dropdown, and +— for whichever of those apps also happen to be recording (matched against +pactl source-outputs by application binary/name) — an input-device dropdown +mirroring the output one.""" from __future__ import annotations @@ -22,13 +25,18 @@ class ApplicationsTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="Nothing is playing audio right now") self.append(self._row) - def _make_card(self, item: dict, sinks: list[dict]) -> ApplicationCard: - return ApplicationCard(self._backend, self._peaks, item, sinks) + def _make_card(self, item: dict, sinks: list[dict], sources: list[dict], + source_outputs: list[dict]) -> ApplicationCard: + return ApplicationCard(self._backend, self._peaks, item, sinks, sources, source_outputs) def refresh(self) -> None: def got_sinks(sinks: list[dict]) -> None: def got_inputs(inputs: list[dict]) -> None: - self._row.sync(inputs, sinks) + def got_sources(sources: list[dict]) -> None: + def got_source_outputs(source_outputs: list[dict]) -> None: + self._row.sync(inputs, sinks, sources, source_outputs) + self._backend.list_source_outputs(got_source_outputs) + self._backend.list_sources(got_sources) self._backend.list_sink_inputs(got_inputs) self._backend.list_sinks(got_sinks) diff --git a/desktopenvs/hyprlua/audio-panel/ui/app_card.py b/desktopenvs/hyprlua/audio-panel/ui/app_card.py index a720bc5..ea08f77 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/app_card.py +++ b/desktopenvs/hyprlua/audio-panel/ui/app_card.py @@ -1,7 +1,14 @@ """ApplicationCard — one card in the Applications tab: everything MeterCard gives every card (icon+name, live peak bar, volume meter/L-R pair, mute), plus a dropdown to route this one app's playback stream to a different -output device (`pactl move-sink-input`).""" +output device (`pactl move-sink-input`), and — mirroring that — a second +dropdown to route the same app's *microphone* stream (if it has one) to a +different input device (`pactl move-source-output`). The meter/mute/peak +controls above stay tied to the app's playback stream (this tab is keyed by +sink-inputs); the input dropdown is purely a routing control for whichever +source-output shares this app's `application.process.binary` (falling back +to matching by name), disabled when the app isn't recording anything. +""" from __future__ import annotations @@ -14,48 +21,111 @@ from ui.card_base import MeterCard class ApplicationCard(MeterCard): - def __init__(self, backend, peaks, item: dict, sinks: list[dict]) -> None: + def __init__(self, backend, peaks, item: dict, sinks: list[dict], + sources: list[dict], source_outputs: list[dict]) -> None: self._sinks = sinks self._sink_names: list[str] = [] + self._sources = sources + self._source_names: list[str] = [] + self._recording: dict | None = None super().__init__(backend, peaks, "sink-input", item) + self._update_extra(item, sinks, sources, source_outputs) def _build_extra(self, item: dict): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - label = Gtk.Label(label="Output", xalign=0.0) - label.add_css_class("sb-field-label") - box.append(label) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + + out_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + out_label = Gtk.Label(label="Output", xalign=0.0) + out_label.add_css_class("sb-field-label") + out_col.append(out_label) self._sink_dropdown = Gtk.DropDown() self._sink_dropdown.add_css_class("sb-device-dropdown") - self._dropdown_guard = False + self._sink_guard = False self._sink_dropdown.connect("notify::selected", self._on_sink_selected) - box.append(self._sink_dropdown) + out_col.append(self._sink_dropdown) + box.append(out_col) + + in_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + in_label = Gtk.Label(label="Input", xalign=0.0) + in_label.add_css_class("sb-field-label") + in_col.append(in_label) + self._source_dropdown = Gtk.DropDown() + self._source_dropdown.add_css_class("sb-device-dropdown") + self._source_guard = False + self._source_dropdown.connect("notify::selected", self._on_source_selected) + in_col.append(self._source_dropdown) + box.append(in_col) + self._sync_sink_dropdown(item, self._sinks) return box - def _update_extra(self, item: dict, sinks: list[dict]) -> None: + def _update_extra(self, item: dict, sinks: list[dict], sources: list[dict], + source_outputs: list[dict]) -> None: self._sinks = sinks + self._sources = sources + self._recording = self._find_recording(item, source_outputs) self._sync_sink_dropdown(item, sinks) + self._sync_source_dropdown() + # -- output routing (playback stream -> sink) -------------------------------- def _sync_sink_dropdown(self, item: dict, sinks: list[dict]) -> None: names = [s["name"] for s in sinks] if names != self._sink_names: self._sink_names = names - self._dropdown_guard = True + self._sink_guard = True self._sink_dropdown.set_model(Gtk.StringList.new([s["description"] for s in sinks])) - self._dropdown_guard = False + self._sink_guard = False current = item.get("device") idx = next((i for i, s in enumerate(sinks) if s["id"] == current), None) if idx is not None and self._sink_dropdown.get_selected() != idx: - self._dropdown_guard = True + self._sink_guard = True self._sink_dropdown.set_selected(idx) - self._dropdown_guard = False + self._sink_guard = False def _on_sink_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: - if self._dropdown_guard: + if self._sink_guard: return idx = dropdown.get_selected() if 0 <= idx < len(self._sink_names): self._backend.move_sink_input(self._id, self._sink_names[idx]) + # -- input routing (recording stream -> source), mirrors the above ----------- + @staticmethod + def _find_recording(item: dict, source_outputs: list[dict]) -> dict | None: + binary = item.get("binary") + name = item.get("name") + for so in source_outputs: + if binary and so.get("binary") == binary: + return so + if not binary and so.get("name") == name: + return so + return None + + def _sync_source_dropdown(self) -> None: + sources = self._sources + names = [s["name"] for s in sources] + if names != self._source_names: + self._source_names = names + self._source_guard = True + self._source_dropdown.set_model(Gtk.StringList.new([s["description"] for s in sources])) + self._source_guard = False + + self._source_dropdown.set_sensitive(self._recording is not None) + if self._recording is None: + return + current = self._recording.get("device") + idx = next((i for i, s in enumerate(sources) if s["id"] == current), None) + if idx is not None and self._source_dropdown.get_selected() != idx: + self._source_guard = True + self._source_dropdown.set_selected(idx) + self._source_guard = False + + def _on_source_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._source_guard or self._recording is None: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._source_names): + self._backend.move_source_output(self._recording["id"], self._source_names[idx]) + def _peak_target(self, item: dict) -> list[str] | None: return [f"--monitor-stream={item['id']}"] diff --git a/desktopenvs/hyprlua/audio-panel/ui/applications_tab.py b/desktopenvs/hyprlua/audio-panel/ui/applications_tab.py index 3065246..8ce7110 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/applications_tab.py +++ b/desktopenvs/hyprlua/audio-panel/ui/applications_tab.py @@ -1,6 +1,9 @@ """Applications tab: horizontally-scrolling row of every app currently playing audio (pactl sink-inputs), each with its own volume meter (or -independent L/R pair), mute, live peak bar, and an output-device dropdown.""" +independent L/R pair), mute, live peak bar, an output-device dropdown, and +— for whichever of those apps also happen to be recording (matched against +pactl source-outputs by application binary/name) — an input-device dropdown +mirroring the output one.""" from __future__ import annotations @@ -22,13 +25,18 @@ class ApplicationsTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="Nothing is playing audio right now") self.append(self._row) - def _make_card(self, item: dict, sinks: list[dict]) -> ApplicationCard: - return ApplicationCard(self._backend, self._peaks, item, sinks) + def _make_card(self, item: dict, sinks: list[dict], sources: list[dict], + source_outputs: list[dict]) -> ApplicationCard: + return ApplicationCard(self._backend, self._peaks, item, sinks, sources, source_outputs) def refresh(self) -> None: def got_sinks(sinks: list[dict]) -> None: def got_inputs(inputs: list[dict]) -> None: - self._row.sync(inputs, sinks) + def got_sources(sources: list[dict]) -> None: + def got_source_outputs(source_outputs: list[dict]) -> None: + self._row.sync(inputs, sinks, sources, source_outputs) + self._backend.list_source_outputs(got_source_outputs) + self._backend.list_sources(got_sources) self._backend.list_sink_inputs(got_inputs) self._backend.list_sinks(got_sinks)