feat(hyprdrive,hyprlua): add a Cards tab, move profile switching off device cards
Adds a fifth mixer tab, Cards: one row per pactl card (onboard audio, USB headsets, HDMI, Bluetooth) with just its profile dropdown (Analog Stereo Duplex, Pro Audio, Off, etc.). Profile is a card-level setting, not a per-device one — a single card can back a device in both Output and Input Devices at once, and switching its profile can make either side appear or disappear entirely. It used to live on individual DeviceCards in both tabs, which was confusing (same card, edited from two places) and is exactly what pavucontrol avoids by giving profiles their own "Configuration" tab. Pulled it out of DeviceCard/ OutputTab/InputTab (which also drops their now-unneeded list_cards() fetch) and into a new ui/profile_card.py + ui/cards_tab.py. hyprlua's audio-panel picks this up via regen-audio-panel.sh, unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TkkH9eiCBfpWysUXAD9bDGmain
parent
ed43cd2ac7
commit
55d443ecdb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue