"""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)