127 lines
5.3 KiB
Python
127 lines
5.3 KiB
Python
"""VolumeMeter — a single-channel volume control styled as a *vertical* segmented
|
|
meter (a Gtk.Scale dressed up via CSS, see .sb-meter in style/style.css) rather
|
|
than a bare slider, since the user asked for "an audio meter" per channel — a
|
|
mixer column, read bottom-to-top like every hardware fader.
|
|
|
|
The live peak indicator (ui/peakbar.py) is stacked *inside* the same trough via
|
|
a Gtk.Overlay: magenta fill = the volume you selected, the narrower purple
|
|
column inside it = the signal actually reaching the mix right now. The overlay
|
|
child is `can_target=False`, so the scale underneath still gets every click and
|
|
drag across its full width.
|
|
|
|
VolumeMeter (Gtk.Box, vertical)
|
|
├ value label ("100")
|
|
├ Gtk.Overlay ─ main: Gtk.Scale (vertical, inverted)
|
|
│ └ over: PeakBar drawing area (narrow, centred)
|
|
└ channel label ("L"/"R", omitted for the single-channel meter)
|
|
|
|
Two guards keep it usable against a live, event-driven backend:
|
|
- commits are debounced (COMMIT_DEBOUNCE_MS) so dragging doesn't spawn a
|
|
`pactl set-*-volume` per pixel of motion:
|
|
- `set_value_quiet()` (called when a `pactl subscribe` refresh reports the
|
|
authoritative value) is a no-op while the user has the handle pressed, so
|
|
an in-flight drag never gets yanked back by its own not-yet-applied change.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "4.0")
|
|
from gi.repository import GLib, Gtk # noqa: E402
|
|
|
|
from ui.peakbar import PeakBar
|
|
|
|
COMMIT_DEBOUNCE_MS = 80
|
|
METER_HEIGHT = 130
|
|
# half the scale slider's height (.sb-meter slider min-height in style.css): the
|
|
# span the highlight can actually cover is inset by that much at each end, so the
|
|
# peak column drawn inside it has to be inset to match.
|
|
SLIDER_INSET = 5
|
|
|
|
|
|
class VolumeMeter(Gtk.Box):
|
|
def __init__(self, label: str, value: int, on_change: Callable[[int], None],
|
|
muted: bool = False) -> None:
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=3)
|
|
self.add_css_class("sb-meter-row")
|
|
self._on_change = on_change
|
|
self._dragging = False
|
|
self._commit_id: int | None = None
|
|
self._pending: int | None = None
|
|
|
|
self._value_lbl = Gtk.Label(label=str(int(value)))
|
|
self._value_lbl.add_css_class("sb-meter-value")
|
|
self.append(self._value_lbl)
|
|
|
|
self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, 0, 150, 1)
|
|
self.scale.set_value(value)
|
|
self.scale.set_inverted(True) # loud at the top, like a hardware fader
|
|
# value drawn by _value_lbl above instead of by the scale, and no 100%
|
|
# mark: either one would push the trough off-centre inside the widget,
|
|
# and the peak column overlaid on it is centred.
|
|
self.scale.set_draw_value(False)
|
|
self.scale.set_vexpand(True)
|
|
self.scale.set_size_request(-1, METER_HEIGHT)
|
|
self.scale.add_css_class("sb-meter")
|
|
self.scale.connect("value-changed", self._on_value_changed)
|
|
|
|
self._peakbar = PeakBar(inset=SLIDER_INSET)
|
|
trough = Gtk.Overlay()
|
|
trough.set_child(self.scale)
|
|
trough.add_overlay(self._peakbar.widget)
|
|
self.append(trough)
|
|
|
|
# always appended, even unlabelled (single-channel) — an empty label
|
|
# still claims a line, so a lone meter and a split L/R pair keep the
|
|
# same trough height inside the card's Gtk.Stack
|
|
tag = Gtk.Label(label=label)
|
|
tag.add_css_class("sb-meter-label")
|
|
self.append(tag)
|
|
|
|
self.set_muted(muted)
|
|
|
|
click = Gtk.GestureClick()
|
|
click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
|
|
click.connect("pressed", lambda *_a: setattr(self, "_dragging", True))
|
|
click.connect("released", lambda *_a: setattr(self, "_dragging", False))
|
|
self.scale.add_controller(click)
|
|
|
|
def _on_value_changed(self, _scale) -> None:
|
|
self._pending = int(self.scale.get_value())
|
|
self._value_lbl.set_label(str(self._pending))
|
|
if self._commit_id is not None:
|
|
GLib.source_remove(self._commit_id)
|
|
self._commit_id = GLib.timeout_add(COMMIT_DEBOUNCE_MS, self._commit)
|
|
|
|
def _commit(self) -> bool:
|
|
self._commit_id = None
|
|
if self._pending is not None:
|
|
self._on_change(self._pending)
|
|
return False
|
|
|
|
def set_value_quiet(self, value: int) -> None:
|
|
"""Reflect a backend-reported value without re-triggering on_change."""
|
|
if self._dragging or self._commit_id is not None:
|
|
return # user (or our own not-yet-applied commit) owns the value right now
|
|
if int(self.scale.get_value()) != value:
|
|
self.scale.handler_block_by_func(self._on_value_changed)
|
|
self.scale.set_value(value)
|
|
self.scale.handler_unblock_by_func(self._on_value_changed)
|
|
self._value_lbl.set_label(str(int(value)))
|
|
|
|
def set_muted(self, muted: bool) -> None:
|
|
if muted:
|
|
self.scale.add_css_class("sb-meter-muted")
|
|
self.add_css_class("sb-meter-muted")
|
|
else:
|
|
self.scale.remove_css_class("sb-meter-muted")
|
|
self.remove_css_class("sb-meter-muted")
|
|
|
|
def set_peak(self, level: float) -> None:
|
|
"""Feed the inner live-signal column (ui/card_base.py fans one peak
|
|
monitor's level out to every meter of the card)."""
|
|
self._peakbar.set_level(level)
|