diff --git a/desktopenvs/hyprdrive/supersonic-booster/main.py b/desktopenvs/hyprdrive/supersonic-booster/main.py index 19d24ae..0133510 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/main.py +++ b/desktopenvs/hyprdrive/supersonic-booster/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""supersonic-booster — PipeWire mixer panel for hyprdrive. Four tabs: -Applications / Output Devices / Input Devices / General Settings, each a -horizontally-scrolling row of cards (see window.py, ui/*_tab.py). +"""supersonic-booster — PipeWire mixer panel for hyprdrive. Five tabs: +Applications / Input Devices / Output Devices / Cards / General Settings, +each a horizontally-scrolling row of cards (see window.py, ui/*_tab.py). Single-instance, same pattern as astro-menu/horizon-dock/transmitter-panel's main.py: the first launch builds the (hidden) window and holds; later diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/cards_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/cards_tab.py new file mode 100644 index 0000000..7370609 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/cards_tab.py @@ -0,0 +1,36 @@ +"""Cards tab: horizontally-scrolling row of every sound card (pactl cards) — +onboard audio, USB headsets, HDMI, Bluetooth devices — each with just its +profile dropdown (pro-audio vs stereo duplex etc.). See ui/profile_card.py +for why this is its own tab rather than living on individual device cards.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.profile_card import ProfileCard +from ui.scroll_row import ScrollRow + + +class CardsTab(Gtk.Box): + def __init__(self, backend) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._row = ScrollRow(self._make_card, empty_text="No sound cards found") + self.append(self._row) + + def _make_card(self, item: dict) -> ProfileCard: + return ProfileCard(self._backend, item) + + def refresh(self) -> None: + self._backend.list_cards(self._row.sync) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py b/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py index dbf3f78..3a4c990 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py @@ -1,10 +1,12 @@ """DeviceCard — one card in the Output Devices / Input Devices tabs: MeterCard's -usual icon+name/peak/meter/mute, plus a profile dropdown (pro-audio vs the -plain stereo duplex profile, etc. — whatever the owning card advertises via -`pactl list cards`) and a "Set Default" button. Shared between both tabs via -the `kind` parameter ("sink" for outputs, "source" for inputs) — the only -difference between an output and an input device, as far as pactl's verbs go, -is which noun you say. +usual icon+name/peak/meter/mute, plus a "Set Default" button. Shared between +both tabs via the `kind` parameter ("sink" for outputs, "source" for inputs) — +the only difference between an output and an input device, as far as pactl's +verbs go, is which noun you say. + +Profile switching used to live here too, but that's a *card*-level setting +(one physical card can back a device in both this tab and the other one at +once), not a per-device one — see ui/profile_card.py and the Cards tab. """ from __future__ import annotations @@ -21,80 +23,22 @@ _MONITOR_SUFFIX = ".monitor" class DeviceCard(MeterCard): def __init__(self, backend, peaks, kind: str, item: dict, - cards_by_id: dict[int, dict], default_name: str, - set_default) -> None: - self._cards_by_id = cards_by_id + default_name: str, set_default) -> None: self._default_name = default_name self._set_default = set_default - self._profile_names: list[str] = [] - self._profile_guard = False super().__init__(backend, peaks, kind, item) def _build_extra(self, item: dict): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - - profile_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - profile_label = Gtk.Label(label="Profile", xalign=0.0) - profile_label.add_css_class("sb-field-label") - profile_col.append(profile_label) - self._profile_dropdown = Gtk.DropDown() - self._profile_dropdown.add_css_class("sb-device-dropdown") - self._profile_dropdown.connect("notify::selected", self._on_profile_selected) - profile_col.append(self._profile_dropdown) - box.append(profile_col) - self._default_btn = Gtk.ToggleButton(label="Set Default") self._default_btn.add_css_class("sb-default-btn") self._default_btn.connect("toggled", self._on_default_toggled) - box.append(self._default_btn) - - self._sync_profile(item, self._cards_by_id) self._sync_default(item, self._default_name) - return box + return self._default_btn - def _update_extra(self, item: dict, cards_by_id: dict[int, dict], default_name: str) -> None: - self._cards_by_id = cards_by_id + def _update_extra(self, item: dict, default_name: str) -> None: self._default_name = default_name - self._sync_profile(item, cards_by_id) self._sync_default(item, default_name) - # -- profile dropdown ----------------------------------------------------------- - def _owning_card(self, item: dict, cards_by_id: dict[int, dict]) -> dict | None: - card_id = item.get("card") - return cards_by_id.get(card_id) if card_id is not None else None - - def _sync_profile(self, item: dict, cards_by_id: dict[int, dict]) -> None: - card = self._owning_card(item, cards_by_id) - profiles = card.get("profiles") if card else [] - if not profiles: - self._profile_dropdown.set_sensitive(False) - return - self._profile_dropdown.set_sensitive(True) - names = [p["name"] for p in profiles] - if names != self._profile_names: - self._profile_names = names - self._profile_guard = True - self._profile_dropdown.set_model( - Gtk.StringList.new([p["description"] for p in profiles])) - self._profile_guard = False - active = card.get("active_profile") if card else None - idx = names.index(active) if active in names else None - if idx is not None and self._profile_dropdown.get_selected() != idx: - self._profile_guard = True - self._profile_dropdown.set_selected(idx) - self._profile_guard = False - - def _on_profile_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: - if self._profile_guard: - return - card = self._owning_card(self._item, self._cards_by_id) - if card is None: - return - idx = dropdown.get_selected() - if 0 <= idx < len(self._profile_names): - self._backend.set_card_profile(card["id"], self._profile_names[idx]) - - # -- default device --------------------------------------------------------------- def _sync_default(self, item: dict, default_name: str) -> None: is_default = item.get("name") == default_name self._default_btn.handler_block_by_func(self._on_default_toggled) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py index abbd9d1..26dd6df 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py @@ -1,6 +1,6 @@ """Input Devices tab: same shape as Output Devices (see output_tab.py) but for recording devices (pactl sources) — mic gain meter/L-R pair, mute, live peak -bar, profile dropdown, "Set Default".""" +bar, "Set Default". Profile switching lives on the Cards tab instead.""" from __future__ import annotations @@ -23,21 +23,17 @@ class InputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No input devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "source", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "source", item, default_name, self._backend.set_default_source) def refresh(self) -> None: def got_defaults(_sink_name: str, source_name: str) -> None: self._default_name = source_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sources(sources: list[dict]) -> None: - self._row.sync(sources, cards_by_id, self._default_name) - self._backend.list_sources(got_sources) - self._backend.list_cards(got_cards) + def got_sources(sources: list[dict]) -> None: + self._row.sync(sources, self._default_name) + self._backend.list_sources(got_sources) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py index 330ae63..dae52d6 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py @@ -1,7 +1,7 @@ """Output Devices tab: horizontally-scrolling row of every playback device (pactl sinks), each with its own volume meter/L-R pair, mute, live peak bar, -profile dropdown (pro-audio vs stereo duplex etc.) and a "Set Default" button. -""" +and a "Set Default" button. Profile switching lives on the Cards tab instead +(see ui/profile_card.py for why).""" from __future__ import annotations @@ -24,21 +24,17 @@ class OutputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No output devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "sink", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "sink", item, default_name, self._backend.set_default_sink) def refresh(self) -> None: def got_defaults(sink_name: str, _source_name: str) -> None: self._default_name = sink_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sinks(sinks: list[dict]) -> None: - self._row.sync(sinks, cards_by_id, self._default_name) - self._backend.list_sinks(got_sinks) - self._backend.list_cards(got_cards) + def got_sinks(sinks: list[dict]) -> None: + self._row.sync(sinks, self._default_name) + self._backend.list_sinks(got_sinks) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/profile_card.py b/desktopenvs/hyprdrive/supersonic-booster/ui/profile_card.py new file mode 100644 index 0000000..fb702dc --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/profile_card.py @@ -0,0 +1,89 @@ +"""ProfileCard — one card in the Cards tab: a sound *card* (the physical or +virtual device behind one or more sinks/sources — onboard audio, a USB +headset, HDMI, a Bluetooth device) and its profile dropdown. + +Profile is a card-level setting, not a per-device one — pavucontrol puts it +in its own "Configuration" tab for the same reason: a single card can back +multiple sinks/sources at once, and switching its profile can make some of +them appear or disappear entirely (e.g. "Analog Stereo Duplex" exposes both +an output and an input from the same card; "Analog Stereo Output" drops the +input side; "Off" drops both; "Pro Audio" breaks the card into raw individual +ports instead of one grouped stereo pair). Output/Input Devices used to carry +their own copy of this dropdown per-device, which was confusing when one +card backed a device in both tabs — this replaces that.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class ProfileCard(Gtk.Box): + def __init__(self, backend, item: dict) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add_css_class("sb-card") + self._backend = backend + self._id = item["id"] + self._profile_names: list[str] = [] + self._guard = False + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + head.add_css_class("sb-card-head") + icon = Gtk.Image.new_from_icon_name("audio-card") + icon.set_pixel_size(28) + head.append(icon) + self._name_lbl = Gtk.Label(xalign=0.0) + self._name_lbl.add_css_class("sb-card-title") + self._name_lbl.set_ellipsize(3) # Pango.EllipsizeMode.END + self._name_lbl.set_max_width_chars(20) + head.append(self._name_lbl) + self.append(head) + + label = Gtk.Label(label="Profile", xalign=0.0) + label.add_css_class("sb-field-label") + self.append(label) + self._dropdown = Gtk.DropDown() + self._dropdown.add_css_class("sb-device-dropdown") + self._dropdown.connect("notify::selected", self._on_selected) + self.append(self._dropdown) + + self.update(item) + + def update(self, item: dict) -> None: + self._item = item + name = item.get("description") or item.get("name") or f"#{item.get('id')}" + self._name_lbl.set_label(name) + self._name_lbl.set_tooltip_text(name) + + profiles = item.get("profiles") or [] + names = [p["name"] for p in profiles] + if names != self._profile_names: + self._profile_names = names + self._guard = True + self._dropdown.set_model(Gtk.StringList.new([p["description"] for p in profiles])) + self._guard = False + self._dropdown.set_sensitive(bool(profiles)) + + active = item.get("active_profile") + idx = names.index(active) if active in names else None + if idx is not None and self._dropdown.get_selected() != idx: + self._guard = True + self._dropdown.set_selected(idx) + self._guard = False + + def _on_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._profile_names): + self._backend.set_card_profile(self._id, self._profile_names[idx]) + + # ScrollRow's diff-sync calls these on every card unconditionally (see + # scroll_row.py) — cards have no live audio to meter, so these are no-ops. + def start_peak(self) -> None: + pass + + def stop_peak(self) -> None: + pass diff --git a/desktopenvs/hyprdrive/supersonic-booster/window.py b/desktopenvs/hyprdrive/supersonic-booster/window.py index 4dac709..e7abbb8 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/window.py +++ b/desktopenvs/hyprdrive/supersonic-booster/window.py @@ -14,7 +14,7 @@ whichever bar/build you're running against. Gtk.Window (layer TOP, anchored TOP+RIGHT, height/width = content) └ Gtk.Overlay - main : .sb-panel (title, tab switcher, Gtk.Stack of the four tabs) + main : .sb-panel (title, tab switcher, Gtk.Stack of the five tabs) over : hologram overlay over : ✕ close button (top-right) @@ -38,6 +38,7 @@ from backend.peaklevel import PeakMonitorManager from backend.pipewire import AudioBackend from lib.hologram import HologramOverlay from ui.applications_tab import ApplicationsTab +from ui.cards_tab import CardsTab from ui.general_tab import GeneralTab from ui.input_tab import InputTab from ui.output_tab import OutputTab @@ -79,18 +80,21 @@ class SupersonicWindow(Gtk.ApplicationWindow): self._applications_tab = ApplicationsTab(self._backend, self._peaks) self._output_tab = OutputTab(self._backend, self._peaks) self._input_tab = InputTab(self._backend, self._peaks) + self._cards_tab = CardsTab(self._backend) self._general_tab = GeneralTab(self._backend, on_hologram_changed=self._on_hologram_setting_changed) self._tabs = { "applications": self._applications_tab, "output": self._output_tab, "input": self._input_tab, + "cards": self._cards_tab, "general": self._general_tab, } tab_order = [ ("applications", "Applications", self._applications_tab), ("input", "Input Devices", self._input_tab), ("output", "Output Devices", self._output_tab), + ("cards", "Cards", self._cards_tab), ("general", "General Settings", self._general_tab), ] for name, label, widget in tab_order: diff --git a/desktopenvs/hyprlua/audio-panel/main.py b/desktopenvs/hyprlua/audio-panel/main.py index 829abfd..5aa3e9a 100644 --- a/desktopenvs/hyprlua/audio-panel/main.py +++ b/desktopenvs/hyprlua/audio-panel/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""audio-panel — PipeWire mixer panel for hyprlua. Four tabs: -Applications / Output Devices / Input Devices / General Settings, each a -horizontally-scrolling row of cards (see window.py, ui/*_tab.py). +"""audio-panel — PipeWire mixer panel for hyprlua. Five tabs: +Applications / Input Devices / Output Devices / Cards / General Settings, +each a horizontally-scrolling row of cards (see window.py, ui/*_tab.py). Single-instance, same pattern as astro-menu/horizon-dock/transmitter-panel's main.py: the first launch builds the (hidden) window and holds; later diff --git a/desktopenvs/hyprlua/audio-panel/ui/cards_tab.py b/desktopenvs/hyprlua/audio-panel/ui/cards_tab.py new file mode 100644 index 0000000..7370609 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/cards_tab.py @@ -0,0 +1,36 @@ +"""Cards tab: horizontally-scrolling row of every sound card (pactl cards) — +onboard audio, USB headsets, HDMI, Bluetooth devices — each with just its +profile dropdown (pro-audio vs stereo duplex etc.). See ui/profile_card.py +for why this is its own tab rather than living on individual device cards.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.profile_card import ProfileCard +from ui.scroll_row import ScrollRow + + +class CardsTab(Gtk.Box): + def __init__(self, backend) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._row = ScrollRow(self._make_card, empty_text="No sound cards found") + self.append(self._row) + + def _make_card(self, item: dict) -> ProfileCard: + return ProfileCard(self._backend, item) + + def refresh(self) -> None: + self._backend.list_cards(self._row.sync) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprlua/audio-panel/ui/device_card.py b/desktopenvs/hyprlua/audio-panel/ui/device_card.py index dbf3f78..3a4c990 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/device_card.py +++ b/desktopenvs/hyprlua/audio-panel/ui/device_card.py @@ -1,10 +1,12 @@ """DeviceCard — one card in the Output Devices / Input Devices tabs: MeterCard's -usual icon+name/peak/meter/mute, plus a profile dropdown (pro-audio vs the -plain stereo duplex profile, etc. — whatever the owning card advertises via -`pactl list cards`) and a "Set Default" button. Shared between both tabs via -the `kind` parameter ("sink" for outputs, "source" for inputs) — the only -difference between an output and an input device, as far as pactl's verbs go, -is which noun you say. +usual icon+name/peak/meter/mute, plus a "Set Default" button. Shared between +both tabs via the `kind` parameter ("sink" for outputs, "source" for inputs) — +the only difference between an output and an input device, as far as pactl's +verbs go, is which noun you say. + +Profile switching used to live here too, but that's a *card*-level setting +(one physical card can back a device in both this tab and the other one at +once), not a per-device one — see ui/profile_card.py and the Cards tab. """ from __future__ import annotations @@ -21,80 +23,22 @@ _MONITOR_SUFFIX = ".monitor" class DeviceCard(MeterCard): def __init__(self, backend, peaks, kind: str, item: dict, - cards_by_id: dict[int, dict], default_name: str, - set_default) -> None: - self._cards_by_id = cards_by_id + default_name: str, set_default) -> None: self._default_name = default_name self._set_default = set_default - self._profile_names: list[str] = [] - self._profile_guard = False super().__init__(backend, peaks, kind, item) def _build_extra(self, item: dict): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - - profile_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - profile_label = Gtk.Label(label="Profile", xalign=0.0) - profile_label.add_css_class("sb-field-label") - profile_col.append(profile_label) - self._profile_dropdown = Gtk.DropDown() - self._profile_dropdown.add_css_class("sb-device-dropdown") - self._profile_dropdown.connect("notify::selected", self._on_profile_selected) - profile_col.append(self._profile_dropdown) - box.append(profile_col) - self._default_btn = Gtk.ToggleButton(label="Set Default") self._default_btn.add_css_class("sb-default-btn") self._default_btn.connect("toggled", self._on_default_toggled) - box.append(self._default_btn) - - self._sync_profile(item, self._cards_by_id) self._sync_default(item, self._default_name) - return box + return self._default_btn - def _update_extra(self, item: dict, cards_by_id: dict[int, dict], default_name: str) -> None: - self._cards_by_id = cards_by_id + def _update_extra(self, item: dict, default_name: str) -> None: self._default_name = default_name - self._sync_profile(item, cards_by_id) self._sync_default(item, default_name) - # -- profile dropdown ----------------------------------------------------------- - def _owning_card(self, item: dict, cards_by_id: dict[int, dict]) -> dict | None: - card_id = item.get("card") - return cards_by_id.get(card_id) if card_id is not None else None - - def _sync_profile(self, item: dict, cards_by_id: dict[int, dict]) -> None: - card = self._owning_card(item, cards_by_id) - profiles = card.get("profiles") if card else [] - if not profiles: - self._profile_dropdown.set_sensitive(False) - return - self._profile_dropdown.set_sensitive(True) - names = [p["name"] for p in profiles] - if names != self._profile_names: - self._profile_names = names - self._profile_guard = True - self._profile_dropdown.set_model( - Gtk.StringList.new([p["description"] for p in profiles])) - self._profile_guard = False - active = card.get("active_profile") if card else None - idx = names.index(active) if active in names else None - if idx is not None and self._profile_dropdown.get_selected() != idx: - self._profile_guard = True - self._profile_dropdown.set_selected(idx) - self._profile_guard = False - - def _on_profile_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: - if self._profile_guard: - return - card = self._owning_card(self._item, self._cards_by_id) - if card is None: - return - idx = dropdown.get_selected() - if 0 <= idx < len(self._profile_names): - self._backend.set_card_profile(card["id"], self._profile_names[idx]) - - # -- default device --------------------------------------------------------------- def _sync_default(self, item: dict, default_name: str) -> None: is_default = item.get("name") == default_name self._default_btn.handler_block_by_func(self._on_default_toggled) diff --git a/desktopenvs/hyprlua/audio-panel/ui/input_tab.py b/desktopenvs/hyprlua/audio-panel/ui/input_tab.py index abbd9d1..26dd6df 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/input_tab.py +++ b/desktopenvs/hyprlua/audio-panel/ui/input_tab.py @@ -1,6 +1,6 @@ """Input Devices tab: same shape as Output Devices (see output_tab.py) but for recording devices (pactl sources) — mic gain meter/L-R pair, mute, live peak -bar, profile dropdown, "Set Default".""" +bar, "Set Default". Profile switching lives on the Cards tab instead.""" from __future__ import annotations @@ -23,21 +23,17 @@ class InputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No input devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "source", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "source", item, default_name, self._backend.set_default_source) def refresh(self) -> None: def got_defaults(_sink_name: str, source_name: str) -> None: self._default_name = source_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sources(sources: list[dict]) -> None: - self._row.sync(sources, cards_by_id, self._default_name) - self._backend.list_sources(got_sources) - self._backend.list_cards(got_cards) + def got_sources(sources: list[dict]) -> None: + self._row.sync(sources, self._default_name) + self._backend.list_sources(got_sources) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprlua/audio-panel/ui/output_tab.py b/desktopenvs/hyprlua/audio-panel/ui/output_tab.py index 330ae63..dae52d6 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/output_tab.py +++ b/desktopenvs/hyprlua/audio-panel/ui/output_tab.py @@ -1,7 +1,7 @@ """Output Devices tab: horizontally-scrolling row of every playback device (pactl sinks), each with its own volume meter/L-R pair, mute, live peak bar, -profile dropdown (pro-audio vs stereo duplex etc.) and a "Set Default" button. -""" +and a "Set Default" button. Profile switching lives on the Cards tab instead +(see ui/profile_card.py for why).""" from __future__ import annotations @@ -24,21 +24,17 @@ class OutputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No output devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "sink", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "sink", item, default_name, self._backend.set_default_sink) def refresh(self) -> None: def got_defaults(sink_name: str, _source_name: str) -> None: self._default_name = sink_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sinks(sinks: list[dict]) -> None: - self._row.sync(sinks, cards_by_id, self._default_name) - self._backend.list_sinks(got_sinks) - self._backend.list_cards(got_cards) + def got_sinks(sinks: list[dict]) -> None: + self._row.sync(sinks, self._default_name) + self._backend.list_sinks(got_sinks) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprlua/audio-panel/ui/profile_card.py b/desktopenvs/hyprlua/audio-panel/ui/profile_card.py new file mode 100644 index 0000000..fb702dc --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/profile_card.py @@ -0,0 +1,89 @@ +"""ProfileCard — one card in the Cards tab: a sound *card* (the physical or +virtual device behind one or more sinks/sources — onboard audio, a USB +headset, HDMI, a Bluetooth device) and its profile dropdown. + +Profile is a card-level setting, not a per-device one — pavucontrol puts it +in its own "Configuration" tab for the same reason: a single card can back +multiple sinks/sources at once, and switching its profile can make some of +them appear or disappear entirely (e.g. "Analog Stereo Duplex" exposes both +an output and an input from the same card; "Analog Stereo Output" drops the +input side; "Off" drops both; "Pro Audio" breaks the card into raw individual +ports instead of one grouped stereo pair). Output/Input Devices used to carry +their own copy of this dropdown per-device, which was confusing when one +card backed a device in both tabs — this replaces that.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class ProfileCard(Gtk.Box): + def __init__(self, backend, item: dict) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add_css_class("sb-card") + self._backend = backend + self._id = item["id"] + self._profile_names: list[str] = [] + self._guard = False + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + head.add_css_class("sb-card-head") + icon = Gtk.Image.new_from_icon_name("audio-card") + icon.set_pixel_size(28) + head.append(icon) + self._name_lbl = Gtk.Label(xalign=0.0) + self._name_lbl.add_css_class("sb-card-title") + self._name_lbl.set_ellipsize(3) # Pango.EllipsizeMode.END + self._name_lbl.set_max_width_chars(20) + head.append(self._name_lbl) + self.append(head) + + label = Gtk.Label(label="Profile", xalign=0.0) + label.add_css_class("sb-field-label") + self.append(label) + self._dropdown = Gtk.DropDown() + self._dropdown.add_css_class("sb-device-dropdown") + self._dropdown.connect("notify::selected", self._on_selected) + self.append(self._dropdown) + + self.update(item) + + def update(self, item: dict) -> None: + self._item = item + name = item.get("description") or item.get("name") or f"#{item.get('id')}" + self._name_lbl.set_label(name) + self._name_lbl.set_tooltip_text(name) + + profiles = item.get("profiles") or [] + names = [p["name"] for p in profiles] + if names != self._profile_names: + self._profile_names = names + self._guard = True + self._dropdown.set_model(Gtk.StringList.new([p["description"] for p in profiles])) + self._guard = False + self._dropdown.set_sensitive(bool(profiles)) + + active = item.get("active_profile") + idx = names.index(active) if active in names else None + if idx is not None and self._dropdown.get_selected() != idx: + self._guard = True + self._dropdown.set_selected(idx) + self._guard = False + + def _on_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._profile_names): + self._backend.set_card_profile(self._id, self._profile_names[idx]) + + # ScrollRow's diff-sync calls these on every card unconditionally (see + # scroll_row.py) — cards have no live audio to meter, so these are no-ops. + def start_peak(self) -> None: + pass + + def stop_peak(self) -> None: + pass diff --git a/desktopenvs/hyprlua/audio-panel/window.py b/desktopenvs/hyprlua/audio-panel/window.py index 173b39e..be39b6c 100644 --- a/desktopenvs/hyprlua/audio-panel/window.py +++ b/desktopenvs/hyprlua/audio-panel/window.py @@ -14,7 +14,7 @@ whichever bar/build you're running against. Gtk.Window (layer TOP, anchored TOP+RIGHT, height/width = content) └ Gtk.Overlay - main : .sb-panel (title, tab switcher, Gtk.Stack of the four tabs) + main : .sb-panel (title, tab switcher, Gtk.Stack of the five tabs) over : hologram overlay over : ✕ close button (top-right) @@ -38,6 +38,7 @@ from backend.peaklevel import PeakMonitorManager from backend.pipewire import AudioBackend from lib.hologram import HologramOverlay from ui.applications_tab import ApplicationsTab +from ui.cards_tab import CardsTab from ui.general_tab import GeneralTab from ui.input_tab import InputTab from ui.output_tab import OutputTab @@ -79,18 +80,21 @@ class AudioPanelWindow(Gtk.ApplicationWindow): self._applications_tab = ApplicationsTab(self._backend, self._peaks) self._output_tab = OutputTab(self._backend, self._peaks) self._input_tab = InputTab(self._backend, self._peaks) + self._cards_tab = CardsTab(self._backend) self._general_tab = GeneralTab(self._backend, on_hologram_changed=self._on_hologram_setting_changed) self._tabs = { "applications": self._applications_tab, "output": self._output_tab, "input": self._input_tab, + "cards": self._cards_tab, "general": self._general_tab, } tab_order = [ ("applications", "Applications", self._applications_tab), ("input", "Input Devices", self._input_tab), ("output", "Output Devices", self._output_tab), + ("cards", "Cards", self._cards_tab), ("general", "General Settings", self._general_tab), ] for name, label, widget in tab_order: