Dotfiles/desktopenvs/hyprlua/audio-panel/ui/card_base.py

177 lines
7.0 KiB
Python

"""Shared chrome for the Applications/Output/Input tabs' cards: icon+title, a
live peak bar (backend/peaklevel.py), a volume meter that becomes an
independent left/right pair via a toggle, and a mute button. Subclasses
(ApplicationCard, DeviceCard) bolt on their own routing controls (output-
device dropdown, profile dropdown, default-device button) via
`_build_extra()`/`_update_extra()`/`_peak_target()`.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from ui.meter import VolumeMeter
from ui.peakbar import PeakBar
def is_stereo(item: dict) -> bool:
return set(item.get("channels") or []) == {"front-left", "front-right"}
def avg_percent(item: dict) -> int:
vals = list((item.get("volume_percent") or {}).values())
return round(sum(vals) / len(vals)) if vals else 0
class MeterCard(Gtk.Box):
def __init__(self, backend, peaks, kind: str, item: dict) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.add_css_class("sb-card")
self._backend = backend
self._peaks = peaks
self._kind = kind
self._id = item["id"]
self._stereo = is_stereo(item)
self._channels: list[str] = item.get("channels") or []
vp = item.get("volume_percent") or {}
self._last_left = vp.get("front-left", avg_percent(item))
self._last_right = vp.get("front-right", self._last_left)
self._peak_unwatch = None
self._item = item
head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
head.add_css_class("sb-card-head")
self._icon = Gtk.Image.new_from_icon_name(item.get("icon") or "audio-card")
self._icon.set_pixel_size(28)
head.append(self._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(16)
head.append(self._name_lbl)
self.append(head)
self._peakbar = PeakBar()
self.append(self._peakbar.widget)
self._meter_stack = Gtk.Stack()
self._meter_stack.add_css_class("sb-meter-stack")
self._single = VolumeMeter("", avg_percent(item), self._on_single_change)
self._meter_stack.add_named(self._single, "single")
if self._stereo:
lr_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
self._left = VolumeMeter("L", self._last_left, self._on_left_change)
self._right = VolumeMeter("R", self._last_right, self._on_right_change)
lr_box.append(self._left)
lr_box.append(self._right)
self._meter_stack.add_named(lr_box, "split")
else:
self._left = None
self._right = None
self.append(self._meter_stack)
controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
controls.add_css_class("sb-card-controls")
self._mute_btn = Gtk.ToggleButton(label="Mute")
self._mute_btn.add_css_class("sb-mute-btn")
self._mute_btn.connect("toggled", self._on_mute_toggled)
controls.append(self._mute_btn)
if self._stereo:
self._lr_toggle = Gtk.ToggleButton(label="L/R")
self._lr_toggle.add_css_class("sb-lr-toggle")
self._lr_toggle.set_tooltip_text("Independent left/right volume")
self._lr_toggle.connect("toggled", self._on_lr_toggled)
controls.append(self._lr_toggle)
self.append(controls)
extra = self._build_extra(item)
if extra is not None:
self.append(extra)
self._apply_item(item)
# -- subclass hooks -----------------------------------------------------------
def _build_extra(self, item: dict):
"""Return an extra widget (routing/profile/default controls), or None."""
return None
def _update_extra(self, item: dict, *ctx) -> None:
pass
def _peak_target(self, item: dict) -> list[str] | None:
"""argv tail for `parec` identifying what to tap, or None to skip live
metering for this item."""
return None
# -- value plumbing -------------------------------------------------------------
def _apply_item(self, item: dict) -> None:
self._item = item
self._channels = item.get("channels") or []
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)
icon_name = item.get("icon")
if icon_name:
self._icon.set_from_icon_name(icon_name)
vp = item.get("volume_percent") or {}
left = vp.get("front-left", avg_percent(item))
right = vp.get("front-right", left)
self._last_left, self._last_right = left, right
self._single.set_value_quiet(avg_percent(item))
if self._left is not None and self._right is not None:
self._left.set_value_quiet(left)
self._right.set_value_quiet(right)
muted = bool(item.get("mute"))
self._mute_btn.handler_block_by_func(self._on_mute_toggled)
self._mute_btn.set_active(muted)
self._mute_btn.handler_unblock_by_func(self._on_mute_toggled)
self._mute_btn.set_label("Muted" if muted else "Mute")
self._single.set_muted(muted)
if self._left is not None and self._right is not None:
self._left.set_muted(muted)
self._right.set_muted(muted)
def update(self, item: dict, *ctx) -> None:
self._apply_item(item)
self._update_extra(item, *ctx)
# -- handlers -------------------------------------------------------------------
def _on_single_change(self, pct: int) -> None:
n = max(1, len(self._channels))
self._backend.set_volume(self._kind, self._id, [pct] * n)
def _on_left_change(self, pct: int) -> None:
self._last_left = pct
self._backend.set_volume(self._kind, self._id, [self._last_left, self._last_right])
def _on_right_change(self, pct: int) -> None:
self._last_right = pct
self._backend.set_volume(self._kind, self._id, [self._last_left, self._last_right])
def _on_mute_toggled(self, btn: Gtk.ToggleButton) -> None:
self._backend.set_mute(self._kind, self._id, btn.get_active())
def _on_lr_toggled(self, btn: Gtk.ToggleButton) -> None:
self._meter_stack.set_visible_child_name("split" if btn.get_active() else "single")
# -- live peak lifecycle, driven by ui/scroll_row.py's activate/deactivate ------
def start_peak(self) -> None:
if self._peak_unwatch is not None:
return
target = self._peak_target(self._item)
if target is None:
return
key = f"{self._kind}:{self._id}"
self._peak_unwatch = self._peaks.watch(key, target, self._peakbar.set_level)
def stop_peak(self) -> None:
if self._peak_unwatch is not None:
self._peak_unwatch()
self._peak_unwatch = None
self._peakbar.set_level(0.0)