From bda4640b548a8e74446fab9da65cb4b286897db0 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 29 Jul 2026 15:41:57 +0200 Subject: [PATCH] feat(hyprdrive,hyprlua): add supersonic-booster/audio-panel PipeWire mixer panel A new layer-shell mixer panel with four tabs (Applications / Input Devices / Output Devices / General Settings): per-app and per-device volume meters with an independent left/right toggle, mute, live parec-based amplitude bars (so a silent stream is visually distinct from a quiet one), output-device routing, card profile switching (pro-audio vs stereo duplex etc.), and default-device pickers. Backed by pactl -f json + pactl subscribe over pipewire-pulse (verified against this box's real pactl; wpctl was dropped for lacking per-channel volume and profile verbs). hyprdrive gets the hologram-styled original (supersonic-booster); hyprlua's plain-themed audio-panel is generated by a new desktopenvs/hyprlua/scripts/regen-audio-panel.sh, extending the existing regen-beacon.sh derivation pattern rather than hand-duplicating the app. Wired into both DEs: Super+Shift+S keybind, autostart, config-updater, apply-theme.sh, and the respective bars (station-bar's volume badge now opens the panel; eww/eww-touch/eww-nobattery's volume slider does too, replacing the old pavucontrol launch). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TkkH9eiCBfpWysUXAD9bDG --- apply-theme.sh | 2 + .../hyprdrive/config-updater/updater.conf | 1 + desktopenvs/hyprdrive/hypr/usr/autostart.lua | 1 + desktopenvs/hyprdrive/hypr/usr/binds.lua | 8 + .../scripts/supersonic-booster-start.sh | 13 + .../hyprdrive/scripts/supersonic-booster.sh | 38 +++ desktopenvs/hyprdrive/station-bar/bar.py | 12 +- .../hyprdrive/supersonic-booster/.gitignore | 2 + .../supersonic-booster/backend/peaklevel.py | 119 +++++++ .../supersonic-booster/backend/pipewire.py | 310 ++++++++++++++++++ .../hyprdrive/supersonic-booster/config.py | 36 ++ .../supersonic-booster/lib/hologram.py | 279 ++++++++++++++++ .../hyprdrive/supersonic-booster/main.py | 94 ++++++ .../hyprdrive/supersonic-booster/paths.py | 22 ++ .../supersonic-booster/style/_colors.css | 8 + .../supersonic-booster/style/style.css | 227 +++++++++++++ .../hyprdrive/supersonic-booster/theme.py | 32 ++ .../supersonic-booster/ui/app_card.py | 61 ++++ .../supersonic-booster/ui/applications_tab.py | 40 +++ .../supersonic-booster/ui/card_base.py | 176 ++++++++++ .../supersonic-booster/ui/device_card.py | 116 +++++++ .../supersonic-booster/ui/general_tab.py | 124 +++++++ .../supersonic-booster/ui/input_tab.py | 48 +++ .../hyprdrive/supersonic-booster/ui/meter.py | 84 +++++ .../supersonic-booster/ui/output_tab.py | 49 +++ .../supersonic-booster/ui/peakbar.py | 25 ++ .../supersonic-booster/ui/scroll_row.py | 66 ++++ .../hyprdrive/supersonic-booster/ui/tabs.py | 48 +++ .../hyprdrive/supersonic-booster/window.py | 189 +++++++++++ .../hyprlua/audio-panel-theme/_colors.css | 8 + .../hyprlua/audio-panel-theme/style.css | 214 ++++++++++++ desktopenvs/hyprlua/audio-panel/.gitignore | 2 + .../hyprlua/audio-panel/backend/peaklevel.py | 119 +++++++ .../hyprlua/audio-panel/backend/pipewire.py | 310 ++++++++++++++++++ desktopenvs/hyprlua/audio-panel/config.py | 36 ++ .../hyprlua/audio-panel/lib/hologram.py | 279 ++++++++++++++++ desktopenvs/hyprlua/audio-panel/main.py | 94 ++++++ desktopenvs/hyprlua/audio-panel/paths.py | 22 ++ .../hyprlua/audio-panel/style/_colors.css | 8 + .../hyprlua/audio-panel/style/style.css | 214 ++++++++++++ desktopenvs/hyprlua/audio-panel/theme.py | 32 ++ .../hyprlua/audio-panel/ui/app_card.py | 61 ++++ .../audio-panel/ui/applications_tab.py | 40 +++ .../hyprlua/audio-panel/ui/card_base.py | 176 ++++++++++ .../hyprlua/audio-panel/ui/device_card.py | 116 +++++++ .../hyprlua/audio-panel/ui/general_tab.py | 124 +++++++ .../hyprlua/audio-panel/ui/input_tab.py | 48 +++ desktopenvs/hyprlua/audio-panel/ui/meter.py | 84 +++++ .../hyprlua/audio-panel/ui/output_tab.py | 49 +++ desktopenvs/hyprlua/audio-panel/ui/peakbar.py | 25 ++ .../hyprlua/audio-panel/ui/scroll_row.py | 66 ++++ desktopenvs/hyprlua/audio-panel/ui/tabs.py | 48 +++ desktopenvs/hyprlua/audio-panel/window.py | 189 +++++++++++ .../hyprlua/config-updater/updater.conf | 3 + desktopenvs/hyprlua/eww-nobattery/eww.yuck | 4 +- desktopenvs/hyprlua/eww-touch/eww.yuck | 4 +- desktopenvs/hyprlua/eww/eww.yuck | 6 +- desktopenvs/hyprlua/hypr/usr/autostart.lua | 1 + desktopenvs/hyprlua/hypr/usr/binds.lua | 8 + .../hyprlua/scripts/audio-panel-start.sh | 13 + desktopenvs/hyprlua/scripts/audio-panel.sh | 38 +++ .../hyprlua/scripts/regen-audio-panel.sh | 121 +++++++ 62 files changed, 4786 insertions(+), 6 deletions(-) create mode 100755 desktopenvs/hyprdrive/scripts/supersonic-booster-start.sh create mode 100755 desktopenvs/hyprdrive/scripts/supersonic-booster.sh create mode 100644 desktopenvs/hyprdrive/supersonic-booster/.gitignore create mode 100644 desktopenvs/hyprdrive/supersonic-booster/backend/peaklevel.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/backend/pipewire.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/config.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/lib/hologram.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/main.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/paths.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/style/_colors.css create mode 100644 desktopenvs/hyprdrive/supersonic-booster/style/style.css create mode 100644 desktopenvs/hyprdrive/supersonic-booster/theme.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/general_tab.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/meter.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/scroll_row.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/tabs.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/window.py create mode 100644 desktopenvs/hyprlua/audio-panel-theme/_colors.css create mode 100644 desktopenvs/hyprlua/audio-panel-theme/style.css create mode 100644 desktopenvs/hyprlua/audio-panel/.gitignore create mode 100644 desktopenvs/hyprlua/audio-panel/backend/peaklevel.py create mode 100644 desktopenvs/hyprlua/audio-panel/backend/pipewire.py create mode 100644 desktopenvs/hyprlua/audio-panel/config.py create mode 100644 desktopenvs/hyprlua/audio-panel/lib/hologram.py create mode 100644 desktopenvs/hyprlua/audio-panel/main.py create mode 100644 desktopenvs/hyprlua/audio-panel/paths.py create mode 100644 desktopenvs/hyprlua/audio-panel/style/_colors.css create mode 100644 desktopenvs/hyprlua/audio-panel/style/style.css create mode 100644 desktopenvs/hyprlua/audio-panel/theme.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/app_card.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/applications_tab.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/card_base.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/device_card.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/general_tab.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/input_tab.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/meter.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/output_tab.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/peakbar.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/scroll_row.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/tabs.py create mode 100644 desktopenvs/hyprlua/audio-panel/window.py create mode 100755 desktopenvs/hyprlua/scripts/audio-panel-start.sh create mode 100755 desktopenvs/hyprlua/scripts/audio-panel.sh create mode 100755 desktopenvs/hyprlua/scripts/regen-audio-panel.sh diff --git a/apply-theme.sh b/apply-theme.sh index 347ea75..80e8f5b 100755 --- a/apply-theme.sh +++ b/apply-theme.sh @@ -47,6 +47,8 @@ USER_FILES=( "desktopenvs/hyprdrive/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css" "desktopenvs/hyprdrive/astro-menu/style/_colors.css|$HOME/.config/astro-menu/style/_colors.css" "desktopenvs/hyprdrive/station-bar/style/_colors.css|$HOME/.config/station-bar/style/_colors.css" + "desktopenvs/hyprdrive/supersonic-booster/style/_colors.css|$HOME/.config/supersonic-booster/style/_colors.css" + "desktopenvs/hyprlua/audio-panel/style/_colors.css|$HOME/.config/audio-panel/style/_colors.css" "desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml" "desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh" "desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini" diff --git a/desktopenvs/hyprdrive/config-updater/updater.conf b/desktopenvs/hyprdrive/config-updater/updater.conf index 3c00491..34b5b10 100644 --- a/desktopenvs/hyprdrive/config-updater/updater.conf +++ b/desktopenvs/hyprdrive/config-updater/updater.conf @@ -23,6 +23,7 @@ config mimeapps.list config orbit-menu config scripts config station-bar +config supersonic-booster config transmitter-panel config vicinae config xfce4 diff --git a/desktopenvs/hyprdrive/hypr/usr/autostart.lua b/desktopenvs/hyprdrive/hypr/usr/autostart.lua index a1ff123..d871147 100644 --- a/desktopenvs/hyprdrive/hypr/usr/autostart.lua +++ b/desktopenvs/hyprdrive/hypr/usr/autostart.lua @@ -18,6 +18,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/orbit-menu-start.sh") hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/horizon-dock-start.sh") hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/station-bar-start.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/supersonic-booster-start.sh") hl.exec_cmd("blueman-applet") hl.exec_cmd("blueman-tray") hl.exec_cmd("hypridle") diff --git a/desktopenvs/hyprdrive/hypr/usr/binds.lua b/desktopenvs/hyprdrive/hypr/usr/binds.lua index 5a6e65f..2ecdc9c 100644 --- a/desktopenvs/hyprdrive/hypr/usr/binds.lua +++ b/desktopenvs/hyprdrive/hypr/usr/binds.lua @@ -338,6 +338,14 @@ hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/horizon-dock.sh to hl.bind(mainMod .. " + B", hl.dsp.exec_cmd("~/.config/scripts/station-bar.sh toggle"), { release = true }) hl.bind(mainMod .. " + Z", hl.dsp.exec_cmd("~/.config/scripts/station-bar.sh toggle here"), { release = true }) +-------------------------- +---- SUPERSONIC-BOOSTER -- +-------------------------- + +-- PipeWire mixer panel: Applications / Output / Input / General tabs. +-- Super+S is already the pavucontrol fallback mixer; this is the primary one. +hl.bind(mainMod .. " + SHIFT + S", hl.dsp.exec_cmd("~/.config/scripts/supersonic-booster.sh"), { release = true }) + -------------------- ---- SCREENSHOT ---- -------------------- diff --git a/desktopenvs/hyprdrive/scripts/supersonic-booster-start.sh b/desktopenvs/hyprdrive/scripts/supersonic-booster-start.sh new file mode 100755 index 0000000..8a60963 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/supersonic-booster-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for supersonic-booster, the PipeWire mixer panel. Same +# LD_PRELOAD requirement and rationale as beacon-start.sh/horizon-dock-start.sh/ +# transmitter-panel-start.sh: gtk4-layer-shell must load before +# libwayland-client, which isn't guaranteed under PyGObject. + +APP="${HOME}/.config/supersonic-booster/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprdrive/scripts/supersonic-booster.sh b/desktopenvs/hyprdrive/scripts/supersonic-booster.sh new file mode 100755 index 0000000..c2c11f1 --- /dev/null +++ b/desktopenvs/hyprdrive/scripts/supersonic-booster.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Toggle supersonic-booster (the PipeWire mixer panel). Forwards a verb to the +# resident daemon over D-Bus; if the daemon isn't running yet, starts it first. +# Mirrors horizon-dock.sh/transmitter-panel.sh. +# +# supersonic-booster.sh -> --toggle (default) +# supersonic-booster.sh show -> --show +# supersonic-booster.sh hide -> --hide +# +# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not +# abort the script before it forwards the verb.) + +BUS="eu.abdelbaki.supersonicbooster" +OBJ="/eu/abdelbaki/supersonicbooster" +APP="${HOME}/.config/supersonic-booster/main.py" + +case "${1:-toggle}" in + show) VERB="--show"; ACTION="show" ;; + hide) VERB="--hide"; ACTION="hide" ;; + *) VERB="--toggle"; ACTION="toggle" ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null +fi + +"${HOME}/.config/scripts/supersonic-booster-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +exec python3 "$APP" "$VERB" diff --git a/desktopenvs/hyprdrive/station-bar/bar.py b/desktopenvs/hyprdrive/station-bar/bar.py index 4a0b017..275ddd0 100644 --- a/desktopenvs/hyprdrive/station-bar/bar.py +++ b/desktopenvs/hyprdrive/station-bar/bar.py @@ -429,11 +429,21 @@ class StationBar(Gtk.Window): pod.append(self._tray_box) self._right.append(pod) + # Left-click opens supersonic-booster (the PipeWire mixer panel); + # middle-click keeps the old quick-mute shortcut; scroll still + # nudges the default sink's volume without opening anything. self._volume_btn = Gtk.Button(label=f"{ICON_VOLUME} --%") self._volume_btn.add_css_class("station-badge") self._volume_btn.add_css_class("station-volume") self._volume_btn.set_has_frame(False) - self._volume_btn.connect("clicked", lambda *_a: (volume_source.toggle_mute(), self._refresh_volume())) + self._volume_btn.set_tooltip_text("Click: mixer · Middle-click: mute · Scroll: volume") + self._volume_btn.connect("clicked", lambda *_a: subprocess.Popen( + ["bash", "-c", "$HOME/.config/scripts/supersonic-booster.sh"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + mute_click = Gtk.GestureClick() + mute_click.set_button(2) # middle-click + mute_click.connect("pressed", lambda *_a: (volume_source.toggle_mute(), self._refresh_volume())) + self._volume_btn.add_controller(mute_click) scroll = Gtk.EventControllerScroll() scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) scroll.connect("scroll", self._on_volume_scroll) diff --git a/desktopenvs/hyprdrive/supersonic-booster/.gitignore b/desktopenvs/hyprdrive/supersonic-booster/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprdrive/supersonic-booster/backend/peaklevel.py b/desktopenvs/hyprdrive/supersonic-booster/backend/peaklevel.py new file mode 100644 index 0000000..8090ac4 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/backend/peaklevel.py @@ -0,0 +1,119 @@ +"""Live peak/amplitude metering, independent of the volume *setting* — the +whole point is to make "the app is playing but nothing is reaching the mix" +visually obvious even when volume sliders read 100%, instead of only ever +showing what the volume knob is set to. + +PipeWire/pipewire-pulse has no `pactl` subcommand for this (pactl only reports +configured volume, never signal level), so this shells out to `parec` +(pipewire-pulse's PulseAudio-protocol recorder) in raw-PCM mode and computes +peak amplitude client-side from the byte stream — the same technique +pavucontrol's meters use under the hood. `parec --monitor-stream=` taps a +single application's own stream (Applications tab); `parec --device=` +taps a physical device directly (`.monitor` for outputs, the source +name itself for inputs). + +One `parec` process per distinct target, shared across every card watching +it, and only running while that card is on the currently-visible tab of a +currently-visible panel (see ui/card_base.py's start_peak/stop_peak, driven by +ui/scroll_row.py's activate/deactivate) — a mixer panel comfortably shows a +dozen live meters, but there's no reason to keep any of them decoding audio +while the panel is hidden. +""" + +from __future__ import annotations + +import array +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +CHUNK_BYTES = 512 # ~256 s16le mono samples per read, small enough to feel live +RATE_HZ = 8000 # plenty of resolution for a peak/VU bar, cheap to decode +LATENCY_MSEC = 60 + + +def _peak_from_pcm16(data: bytes) -> float: + usable = len(data) - (len(data) % 2) + if usable <= 0: + return 0.0 + samples = array.array("h") + samples.frombytes(data[:usable]) + peak = max((abs(s) for s in samples), default=0) + return min(1.0, peak / 32768.0) + + +class PeakMonitorManager: + def __init__(self) -> None: + self._monitors: dict[str, dict] = {} + + def watch(self, key: str, target_args: list[str], + on_level: Callable[[float], None]) -> Callable[[], None]: + """target_args is the parec argv tail identifying what to tap, e.g. + ["--device=alsa_output.foo.monitor"] or ["--monitor-stream=42"]. + Returns an unwatch() callback; call it to stop receiving levels.""" + entry = self._monitors.get(key) + if entry is None: + entry = self._start(key, target_args) + entry["callbacks"].add(on_level) + + def unwatch() -> None: + live = self._monitors.get(key) + if live is None: + return + live["callbacks"].discard(on_level) + if not live["callbacks"]: + self._stop(key) + + return unwatch + + def _start(self, key: str, target_args: list[str]) -> dict: + argv = ["parec", "--format=s16le", f"--rate={RATE_HZ}", "--channels=1", + f"--latency-msec={LATENCY_MSEC}", *target_args] + entry = {"proc": None, "stream": None, "callbacks": set()} + self._monitors[key] = entry + try: + proc = Gio.Subprocess.new( + argv, Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + return entry + entry["proc"] = proc + entry["stream"] = proc.get_stdout_pipe() + self._read_next(key) + return entry + + def _read_next(self, key: str) -> None: + entry = self._monitors.get(key) + if entry is None or entry["stream"] is None: + return + entry["stream"].read_bytes_async(CHUNK_BYTES, GLib.PRIORITY_DEFAULT, None, + self._on_bytes, key) + + def _on_bytes(self, stream, result, key: str) -> None: + entry = self._monitors.get(key) + if entry is None: + return + try: + data = stream.read_bytes_finish(result).get_data() or b"" + except GLib.Error: + data = b"" + if not data: + # target went away (app closed, device unplugged) — the owning + # card's next refresh will drop it entirely; just stop quietly. + self._stop(key) + return + level = _peak_from_pcm16(data) + for cb in list(entry["callbacks"]): + cb(level) + self._read_next(key) + + def _stop(self, key: str) -> None: + entry = self._monitors.pop(key, None) + if entry is not None and entry.get("proc") is not None: + entry["proc"].force_exit() + + def stop_all(self) -> None: + for key in list(self._monitors.keys()): + self._stop(key) diff --git a/desktopenvs/hyprdrive/supersonic-booster/backend/pipewire.py b/desktopenvs/hyprdrive/supersonic-booster/backend/pipewire.py new file mode 100644 index 0000000..8a47e8e --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/backend/pipewire.py @@ -0,0 +1,310 @@ +"""Audio backend: talks to PipeWire through pipewire-pulse's `pactl` compat +layer — pipewire-pulse IS the PipeWire project's own pulse-protocol server +(see hyprdrive.sh's package list: pipewire + pipewire-pulse + wireplumber), +not the legacy PulseAudio daemon. `pactl -f json` gives structured per-channel +volume, card profiles and stream routing with no text-scraping, and `pactl +subscribe` gives event-driven refresh instead of polling (same philosophy as +astro-menu's hypr_ipc-style event feeds). wpctl (WirePlumber's own CLI) was +considered and dropped: it has no per-channel volume or card-profile verbs, +both of which the Applications/Output/Input tabs need. + +Every read is async (Gio.Subprocess.communicate_utf8_async, never a blocking +`subprocess.run`) — a hung pactl must not freeze the panel, the same stance +transmitter-panel's history_client.py takes toward beacon's D-Bus calls. +Mutations are fire-and-forget Gio.Subprocess spawns; the resulting `pactl +subscribe` event is what actually refreshes the UI; no mutation waits on its +own result. +""" + +from __future__ import annotations + +import json +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +# Coalesce bursts of subscribe events (e.g. a drag-driven volume change fires +# many 'change' events in a row) into one refresh instead of one per event. +REFRESH_DEBOUNCE_MS = 120 + +_MUTATE_CMD = { + "sink": ("set-sink-volume", "set-sink-mute"), + "source": ("set-source-volume", "set-source-mute"), + "sink-input": ("set-sink-input-volume", "set-sink-input-mute"), + "source-output": ("set-source-output-volume", "set-source-output-mute"), +} + + +def _percent(vol_entry: dict) -> int: + raw = (vol_entry or {}).get("value_percent") or "0%" + try: + return int(str(raw).rstrip("%")) + except ValueError: + return 0 + + +def _clamp(pct: int) -> int: + return max(0, min(150, pct)) + + +def _normalize_device(raw: dict) -> dict: + """Sinks (output devices) and sources (input devices) share this shape.""" + props = raw.get("properties") or {} + volume = raw.get("volume") or {} + # dict insertion order mirrors pactl's own channel_map order (front-left + # before front-right for stereo) — Python's json.loads preserves it. + channels = list(volume.keys()) + return { + "id": raw.get("index"), + "name": raw.get("name") or "", + "description": raw.get("description") or raw.get("name") or "", + "mute": bool(raw.get("mute")), + "channels": channels, + "volume_percent": {ch: _percent(v) for ch, v in volume.items()}, + "card": raw.get("card"), + "icon": props.get("device.icon_name") or "audio-card", + "form_factor": props.get("device.form_factor") or "", + "state": raw.get("state") or "", + } + + +def _normalize_stream(raw: dict) -> dict: + """Sink-inputs (apps playing audio) and source-outputs (apps recording).""" + props = raw.get("properties") or {} + volume = raw.get("volume") or {} + channels = list(volume.keys()) + name = (props.get("application.name") or props.get("media.name") + or props.get("node.name") or f"Stream #{raw.get('index')}") + return { + "id": raw.get("index"), + "name": name, + "icon": props.get("application.icon_name") or props.get("window.icon_name") + or "audio-x-generic", + "mute": bool(raw.get("mute")), + "channels": channels, + "volume_percent": {ch: _percent(v) for ch, v in volume.items()}, + # whichever sink (sink-input) or source (source-output) it's routed to + "device": raw.get("sink") if "sink" in raw else raw.get("source"), + "corked": bool(raw.get("corked", False)), + "binary": props.get("application.process.binary") or "", + } + + +def _normalize_card(raw: dict) -> dict: + props = raw.get("properties") or {} + profiles_raw = raw.get("profiles") or {} + profiles = [] + for name, info in profiles_raw.items(): + if isinstance(info, dict): + profiles.append({ + "name": name, + "description": info.get("description", name), + "available": bool(info.get("available", True)), + }) + else: # defensive: older/other pactl builds may just give a string + profiles.append({"name": name, "description": str(info), "available": True}) + return { + "id": raw.get("index"), + "name": raw.get("name") or "", + "description": props.get("device.description") or raw.get("name") or "", + "profiles": profiles, + "active_profile": raw.get("active_profile") or "", + } + + +class AudioBackend: + def __init__(self, on_changed: Callable[[], None]) -> None: + self._on_changed = on_changed + self._debounce_id: Optional[int] = None + self._sub_proc: Optional[Gio.Subprocess] = None + self._sub_stream: Optional[Gio.DataInputStream] = None + self._start_subscribe() + + # -- live event stream ---------------------------------------------------- + def _start_subscribe(self) -> None: + try: + self._sub_proc = Gio.Subprocess.new( + ["pactl", "subscribe"], Gio.SubprocessFlags.STDOUT_PIPE) + except GLib.Error: + self._sub_proc = None + return + self._sub_stream = Gio.DataInputStream.new(self._sub_proc.get_stdout_pipe()) + self._read_next_event() + + def _read_next_event(self) -> None: + if self._sub_stream is None: + return + self._sub_stream.read_line_async(GLib.PRIORITY_DEFAULT, None, self._on_event_line) + + def _on_event_line(self, stream: Gio.DataInputStream, result: Gio.AsyncResult) -> None: + try: + line, _length = stream.read_line_finish_utf8(result) + except GLib.Error: + line = None + if line is None: + # `pactl subscribe` died (pipewire-pulse restarted, etc.) — respawn + # after a short delay instead of going silent for the panel's life. + self._sub_proc = None + self._sub_stream = None + GLib.timeout_add_seconds(2, self._restart_subscribe) + return + self._schedule_refresh() + self._read_next_event() + + def _restart_subscribe(self) -> bool: + self._start_subscribe() + return False + + def _schedule_refresh(self) -> None: + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = GLib.timeout_add(REFRESH_DEBOUNCE_MS, self._fire_refresh) + + def _fire_refresh(self) -> bool: + self._debounce_id = None + self._on_changed() + return False + + def stop(self) -> None: + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = None + if self._sub_proc is not None: + self._sub_proc.force_exit() + self._sub_proc = None + + # -- reads ------------------------------------------------------------------ + def _list_async(self, kind: str, normalize, callback: Callable[[list[dict]], None]) -> None: + def done(proc, result) -> None: + try: + ok, stdout, _stderr = proc.communicate_utf8_finish(result) + except GLib.Error: + ok, stdout = False, "" + if not ok or not stdout: + callback([]) + return + try: + raw = json.loads(stdout) + except json.JSONDecodeError: + raw = [] + callback([normalize(r) for r in raw]) + + try: + proc = Gio.Subprocess.new( + ["pactl", "-f", "json", "list", kind], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + callback([]) + return + proc.communicate_utf8_async(None, None, done) + + def list_sinks(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("sinks", _normalize_device, callback) + + def list_sources(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("sources", _normalize_device, callback) + + def list_sink_inputs(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("sink-inputs", _normalize_stream, callback) + + def list_source_outputs(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("source-outputs", _normalize_stream, callback) + + def list_cards(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("cards", _normalize_card, callback) + + def get_defaults(self, callback: Callable[[str, str], None]) -> None: + """callback(default_sink_name, default_source_name), read from `pactl info`.""" + def done(proc, result) -> None: + try: + ok, stdout, _stderr = proc.communicate_utf8_finish(result) + except GLib.Error: + ok, stdout = False, "" + sink = source = "" + if ok: + for line in stdout.splitlines(): + if line.startswith("Default Sink:"): + sink = line.split(":", 1)[1].strip() + elif line.startswith("Default Source:"): + source = line.split(":", 1)[1].strip() + callback(sink, source) + + try: + proc = Gio.Subprocess.new( + ["pactl", "info"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + callback("", "") + return + proc.communicate_utf8_async(None, None, done) + + def get_server_info(self, callback: Callable[[dict], None]) -> None: + """callback({"server_name", "server_version", "default_sink", "default_source"}), + read from `pactl info`. `server_name` is what confirms this is really + talking to pipewire-pulse ("PulseAudio (on PipeWire ...)") rather than + a legacy PulseAudio daemon — surfaced in the General tab.""" + empty = {"server_name": "", "server_version": "", "default_sink": "", "default_source": ""} + + def done(proc, result) -> None: + try: + ok, stdout, _stderr = proc.communicate_utf8_finish(result) + except GLib.Error: + ok, stdout = False, "" + info = dict(empty) + if ok: + for line in stdout.splitlines(): + for key, prefix in (("server_name", "Server Name:"), + ("server_version", "Server Version:"), + ("default_sink", "Default Sink:"), + ("default_source", "Default Source:")): + if line.startswith(prefix): + info[key] = line.split(":", 1)[1].strip() + callback(info) + + try: + proc = Gio.Subprocess.new( + ["pactl", "info"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + callback(dict(empty)) + return + proc.communicate_utf8_async(None, None, done) + + # -- mutations (fire-and-forget) -------------------------------------------- + @staticmethod + def _spawn(argv: list[str]) -> None: + try: + Gio.Subprocess.new( + argv, Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + pass + + def set_volume(self, kind: str, ident, percents: list[int]) -> None: + """kind: 'sink' | 'source' | 'sink-input' | 'source-output'. `percents` + is one value per channel in that node's channel order (see the + `channels` list on its normalized dict) — pass a single-item list to + set every channel uniformly, or one value per channel for independent + left/right gain.""" + cmd, _mute_cmd = _MUTATE_CMD[kind] + values = [f"{_clamp(p)}%" for p in percents] + self._spawn(["pactl", cmd, str(ident), *values]) + + def set_mute(self, kind: str, ident, mute: bool) -> None: + _vol_cmd, cmd = _MUTATE_CMD[kind] + self._spawn(["pactl", cmd, str(ident), "1" if mute else "0"]) + + def set_default_sink(self, name: str) -> None: + self._spawn(["pactl", "set-default-sink", name]) + + def set_default_source(self, name: str) -> None: + self._spawn(["pactl", "set-default-source", name]) + + def move_sink_input(self, ident, sink: str) -> None: + self._spawn(["pactl", "move-sink-input", str(ident), sink]) + + def move_source_output(self, ident, source: str) -> None: + self._spawn(["pactl", "move-source-output", str(ident), source]) + + def set_card_profile(self, card_ident, profile: str) -> None: + self._spawn(["pactl", "set-card-profile", str(card_ident), profile]) diff --git a/desktopenvs/hyprdrive/supersonic-booster/config.py b/desktopenvs/hyprdrive/supersonic-booster/config.py new file mode 100644 index 0000000..4a4a524 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/config.py @@ -0,0 +1,36 @@ +"""Tiny user-editable config file: ~/.local/state/supersonic-booster/config.json. + +Read once at startup (main.py); same pattern as the rest of the Cosmonaut Shell +suite's config.py (orbit-menu, horizon-dock, transmitter-panel). +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) + + +def set_hologram_enabled(value: bool) -> None: + data = _load() + data["hologram"] = bool(value) + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n") diff --git a/desktopenvs/hyprdrive/supersonic-booster/lib/hologram.py b/desktopenvs/hyprdrive/supersonic-booster/lib/hologram.py new file mode 100644 index 0000000..fb1b93c --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/lib/hologram.py @@ -0,0 +1,279 @@ +"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as +the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu / +horizon-dock / beacon / transmitter-panel lib/hologram.py), reused here so the +supersonic-booster popup reads as one more orbit of the same look: scanline +grid + a slow vertical sweep + drifting noise specks, and a 'materialise out +of static' intro when the panel opens. + +One overlay covers the whole panel (same usage as astro-menu's window.py, +`fade_widget=self.root`), not a per-row overlay — window.py runs a single +frame-clock tick and feeds it via .tick(dt). +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.18 + SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 34.0 + NOISE_COUNT = 26 + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 34.0 # smooth vertical fade-out + INTRO_DURATION = 0.9 # brisk 'materialise out of static' when the panel opens + INTRO_STATIC = 900 # static specks at the very start of the intro + OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on close + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to a shape + # widget whose opacity is ramped 0->1 during the intro so the content + # genuinely fades in, rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None + self._outro_t: float | None = None + self._outro_done = None + # Wall-clock safety net: the frame-clock tick only advances while the + # compositor sends frame callbacks; if those stall the intro could freeze + # with content stuck at opacity 0. This timeout force-resolves it anyway. + self._intro_deadline_id: int | None = None + self._mask_cache: tuple | None = None + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from the panel underneath + self.widget.add_css_class("sb-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._finish_intro() + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def _finish_intro(self) -> None: + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = GLib.timeout_add( + int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) + + def _on_intro_deadline(self) -> bool: + self._intro_deadline_id = None + if self._intro_t is not None: + self._finish_intro() + return False # one-shot + + def start_outro(self, on_done) -> None: + """Reverse of the intro (dissolving back into static), then on_done.""" + if not self.enabled: + on_done() + return + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) + cr.clip() + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.09) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/desktopenvs/hyprdrive/supersonic-booster/main.py b/desktopenvs/hyprdrive/supersonic-booster/main.py new file mode 100644 index 0000000..19d24ae --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/main.py @@ -0,0 +1,94 @@ +#!/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). + +Single-instance, same pattern as astro-menu/horizon-dock/transmitter-panel's +main.py: the first launch builds the (hidden) window and holds; later +invocations forward their verb over D-Bus via scripts/supersonic-booster.sh +instead of spawning a second python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --show show + main.py --hide hide + main.py --toggle whichever of the above applies +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# supersonic-booster-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering +# requirement ahead of libwayland-client). Drop it once resident so it isn't +# inherited by anything this process launches — same rationale as the rest of +# the suite's main.py. +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from paths import APP_ID # noqa: E402 +from window import SupersonicWindow # noqa: E402 + + +class SupersonicBoosterApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: SupersonicWindow | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = SupersonicWindow(self) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def guarded(fn): + def wrapper(*_a) -> None: + assert self.window is not None + fn(self.window) + return wrapper + + add("show", guarded(lambda w: w.show_panel())) + add("hide", guarded(lambda w: w.hide_panel())) + add("toggle", guarded(lambda w: w.toggle())) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--show": + self.window.show_panel() + elif verb == "--hide": + self.window.hide_panel() + elif verb == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("supersonic-booster") + return SupersonicBoosterApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprdrive/supersonic-booster/paths.py b/desktopenvs/hyprdrive/supersonic-booster/paths.py new file mode 100644 index 0000000..1983d12 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/paths.py @@ -0,0 +1,22 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does +# `rm -rf ~/.config/supersonic-booster` on every dotfiles sync (see orbit-menu/ +# horizon-dock/paths.py for the same rationale), which would wipe a hand-edited +# config on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "supersonic-booster" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.supersonicbooster" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/desktopenvs/hyprdrive/supersonic-booster/style/_colors.css b/desktopenvs/hyprdrive/supersonic-booster/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprdrive/supersonic-booster/style/style.css b/desktopenvs/hyprdrive/supersonic-booster/style/style.css new file mode 100644 index 0000000..29dc0be --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/style/style.css @@ -0,0 +1,227 @@ +/* supersonic-booster — PipeWire mixer panel. Matches the astro-menu/beacon/ + * transmitter-panel idiom: emitted-magenta text, violet holo-glass fills, + * glow-violet/accent frames, Agave Nerd Font Mono, rounded panels. The + * scanline/sweep/noise depth is Cairo-drawn on top by lib/hologram.py. */ + +@define-color text #EB00A6; /* emitted magenta, same override the rest of the suite uses */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 11pt; +} + +/* Blank the structural nodes so the CyberQueer theme's `* { background-color: + * #1a1a1a }` doesn't fill the surface/gaps with an opaque slab — the panel + * asserts its own glass fill below (same trick as beacon/transmitter-panel). */ +window, +window.background, +.sb-window, +.sb-scroll-row, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +/* the holo-glass panel */ +.sb-panel { + background-color: alpha(@violet, 0.40); + border: 2px solid #8A5CFF; + border-radius: 16px; + padding: 14px 16px; + box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35); +} + +.sb-header { margin: 0 64px 4px 0; } +.sb-title { + color: @text; + font-weight: bold; + font-size: 14pt; + letter-spacing: 1px; +} + +/* ---- tab switcher ------------------------------------------------------- */ +.sb-tabbar { + margin-bottom: 4px; +} +.sb-tab { + color: @text; + background-color: alpha(@violet, 0.35); + border: 2px solid #8A5CFF; + border-radius: 18px; + padding: 4px 14px; + min-height: 26px; + transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease; +} +.sb-tab:hover { + border-color: @accent; + color: @accent; +} +.sb-tab:checked { + background-color: alpha(@accent, 0.55); + border-color: @accent; + color: @bg; + box-shadow: 0 0 12px 1px alpha(@accent, 0.5); +} + +.sb-tab-page { + min-height: 260px; +} + +/* ---- horizontal scroll row of cards --------------------------------------- */ +.sb-scroll-row { padding: 4px 2px 10px 2px; } +.sb-row { padding: 2px; } +.sb-empty { + color: @text; + opacity: 0.55; + padding: 40px 20px; +} + +/* ---- cards -------------------------------------------------------------- */ +.sb-card { + background-color: alpha(@violet, 0.30); + border: 2px solid #8A5CFF; + border-radius: 14px; + padding: 10px 12px; + min-width: 170px; +} +.sb-card-head { margin-bottom: 2px; } +.sb-card-title { + color: @text; + font-weight: bold; + font-size: 10.5pt; +} +.sb-field-label { + color: @text; + opacity: 0.65; + font-size: 8.5pt; + letter-spacing: 0.5px; +} + +/* ---- live peak bar -------------------------------------------------------- */ +.sb-peakbar { min-height: 6px; } +.sb-peakbar trough { + background-color: alpha(@bg, 0.6); + border-radius: 4px; + min-height: 6px; +} +.sb-peakbar block { + border-radius: 4px; + min-width: 2px; +} +.sb-peakbar block.filled { + background-color: #34E27A; /* signal present — distinct from the violet/accent UI chrome */ +} +.sb-peakbar block.empty { + background-color: transparent; +} + +/* ---- volume meter (Gtk.Scale) --------------------------------------------- */ +.sb-meter-row { margin: 2px 0; } +.sb-meter-label { + color: @text; + opacity: 0.75; + font-size: 9pt; + min-width: 12px; +} +.sb-meter trough { + background-color: alpha(@bg, 0.55); + border-radius: 6px; + min-height: 10px; +} +.sb-meter highlight { + background-color: @accent; + border-radius: 6px; +} +.sb-meter slider { + background-color: @text; + border-radius: 50%; + min-width: 14px; + min-height: 14px; +} +.sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.25); } +.sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); } + +/* ---- controls (mute / L-R toggle / default) -------------------------------- */ +.sb-card-controls { margin-top: 2px; } +.sb-mute-btn, +.sb-lr-toggle, +.sb-default-btn { + color: @text; + background-color: alpha(@violet, 0.4); + border: 2px solid #8A5CFF; + border-radius: 16px; + padding: 2px 10px; + min-height: 24px; + font-size: 9pt; + transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease; +} +.sb-mute-btn:hover, +.sb-lr-toggle:hover, +.sb-default-btn:hover { + border-color: @accent; + color: @accent; +} +.sb-mute-btn:checked { + background-color: alpha(@danger, 0.55); + border-color: @danger; + color: @bg; +} +.sb-lr-toggle:checked { + background-color: alpha(@accent, 0.55); + border-color: @accent; + color: @bg; +} +.sb-default-btn:checked { + background-color: alpha(#34E27A, 0.4); + border-color: #34E27A; + color: @bg; +} +.sb-default-btn:disabled { + opacity: 0.85; +} + +/* ---- dropdowns ------------------------------------------------------------ */ +.sb-device-dropdown { + color: @text; + background-color: alpha(@bg, 0.5); + border: 2px solid #8A5CFF; + border-radius: 10px; + padding: 2px 6px; + min-height: 26px; + font-size: 9pt; +} +.sb-device-dropdown:hover { border-color: @accent; } + +/* ---- general settings tab -------------------------------------------------- */ +.sb-general { padding: 6px 4px; } +.sb-general-field { margin-bottom: 4px; } +.sb-server-info { + color: @text; + opacity: 0.6; + font-size: 9pt; + margin-top: 8px; +} + +/* floating panel-level close button (top-right) — copied verbatim from + * astro-menu/style/style.css's .close-btn idiom. */ +.close-btn { + color: @text; background: alpha(@violet, 0.4); + border: none; border-radius: 20px; + min-width: 34px; min-height: 34px; + margin: 16px 20px; + transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease; +} +.close-btn:hover { + background: @accent; + color: @bg; + box-shadow: 0 0 14px 2px alpha(@accent, 0.55); +} + +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; min-height: 6px; } +scrollbar slider:hover { background: @accent; } + +.sb-hologram { background: transparent; } diff --git a/desktopenvs/hyprdrive/supersonic-booster/theme.py b/desktopenvs/hyprdrive/supersonic-booster/theme.py new file mode 100644 index 0000000..a4972c6 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/theme.py @@ -0,0 +1,32 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as the rest +of the Cosmonaut Shell suite's theme.py. _colors.css (generated from +~/Dotfiles/colors.conf by apply-theme.sh) defines the CyberQueer @define-color +names; style.css consumes them. + +Priority is USER+1 for the same reason as the other apps: the CyberQueer GTK +theme at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above +APPLICATION (600), and would beat our transparent structural containers +otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py b/desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py new file mode 100644 index 0000000..a720bc5 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/app_card.py @@ -0,0 +1,61 @@ +"""ApplicationCard — one card in the Applications tab: everything MeterCard +gives every card (icon+name, live peak bar, volume meter/L-R pair, mute), +plus a dropdown to route this one app's playback stream to a different +output device (`pactl move-sink-input`).""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.card_base import MeterCard + + +class ApplicationCard(MeterCard): + def __init__(self, backend, peaks, item: dict, sinks: list[dict]) -> None: + self._sinks = sinks + self._sink_names: list[str] = [] + super().__init__(backend, peaks, "sink-input", item) + + def _build_extra(self, item: dict): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + label = Gtk.Label(label="Output", xalign=0.0) + label.add_css_class("sb-field-label") + box.append(label) + self._sink_dropdown = Gtk.DropDown() + self._sink_dropdown.add_css_class("sb-device-dropdown") + self._dropdown_guard = False + self._sink_dropdown.connect("notify::selected", self._on_sink_selected) + box.append(self._sink_dropdown) + self._sync_sink_dropdown(item, self._sinks) + return box + + def _update_extra(self, item: dict, sinks: list[dict]) -> None: + self._sinks = sinks + self._sync_sink_dropdown(item, sinks) + + def _sync_sink_dropdown(self, item: dict, sinks: list[dict]) -> None: + names = [s["name"] for s in sinks] + if names != self._sink_names: + self._sink_names = names + self._dropdown_guard = True + self._sink_dropdown.set_model(Gtk.StringList.new([s["description"] for s in sinks])) + self._dropdown_guard = False + current = item.get("device") + idx = next((i for i, s in enumerate(sinks) if s["id"] == current), None) + if idx is not None and self._sink_dropdown.get_selected() != idx: + self._dropdown_guard = True + self._sink_dropdown.set_selected(idx) + self._dropdown_guard = False + + def _on_sink_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._dropdown_guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._sink_names): + self._backend.move_sink_input(self._id, self._sink_names[idx]) + + def _peak_target(self, item: dict) -> list[str] | None: + return [f"--monitor-stream={item['id']}"] diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py new file mode 100644 index 0000000..3065246 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/applications_tab.py @@ -0,0 +1,40 @@ +"""Applications tab: horizontally-scrolling row of every app currently +playing audio (pactl sink-inputs), each with its own volume meter (or +independent L/R pair), mute, live peak bar, and an output-device dropdown.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.app_card import ApplicationCard +from ui.scroll_row import ScrollRow + + +class ApplicationsTab(Gtk.Box): + def __init__(self, backend, peaks) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._peaks = peaks + self._row = ScrollRow(self._make_card, empty_text="Nothing is playing audio right now") + self.append(self._row) + + def _make_card(self, item: dict, sinks: list[dict]) -> ApplicationCard: + return ApplicationCard(self._backend, self._peaks, item, sinks) + + def refresh(self) -> None: + def got_sinks(sinks: list[dict]) -> None: + def got_inputs(inputs: list[dict]) -> None: + self._row.sync(inputs, sinks) + self._backend.list_sink_inputs(got_inputs) + self._backend.list_sinks(got_sinks) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py b/desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py new file mode 100644 index 0000000..96087c8 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py @@ -0,0 +1,176 @@ +"""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) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py b/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py new file mode 100644 index 0000000..dbf3f78 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py @@ -0,0 +1,116 @@ +"""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. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.card_base import MeterCard + +_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 + 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 + + def _update_extra(self, item: dict, cards_by_id: dict[int, dict], default_name: str) -> None: + self._cards_by_id = cards_by_id + 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) + self._default_btn.set_active(is_default) + self._default_btn.handler_unblock_by_func(self._on_default_toggled) + self._default_btn.set_label("Default" if is_default else "Set Default") + self._default_btn.set_sensitive(not is_default) + + def _on_default_toggled(self, btn: Gtk.ToggleButton) -> None: + if btn.get_active(): + self._set_default(self._item.get("name")) + + def _peak_target(self, item: dict) -> list[str] | None: + name = item.get("name") + if not name: + return None + if self._kind == "sink": + return [f"--device={name}{_MONITOR_SUFFIX}"] + return [f"--device={name}"] diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/general_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/general_tab.py new file mode 100644 index 0000000..8d0fd29 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/general_tab.py @@ -0,0 +1,124 @@ +"""General Settings tab: system-wide defaults (default output/input device) +and this panel's own display settings, plus a read-only line confirming +what audio server pactl is actually talking to (pipewire-pulse vs a legacy +PulseAudio daemon) — the whole point of the "build for the future" ask.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +import config + + +class GeneralTab(Gtk.Box): + def __init__(self, backend, on_hologram_changed=None) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=16) + self.add_css_class("sb-tab-page") + self.add_css_class("sb-general") + self._backend = backend + self._on_hologram_changed = on_hologram_changed + self._sink_names: list[str] = [] + self._source_names: list[str] = [] + self._guard = False + + self.append(self._field("Default Output Device", self._build_output_dropdown())) + self.append(self._field("Default Input Device", self._build_input_dropdown())) + + if on_hologram_changed is not None: + self.append(self._build_hologram_row()) + + self._server_lbl = Gtk.Label(xalign=0.0) + self._server_lbl.add_css_class("sb-server-info") + self.append(self._server_lbl) + + @staticmethod + def _field(title: str, widget: Gtk.Widget) -> Gtk.Widget: + col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + col.add_css_class("sb-general-field") + label = Gtk.Label(label=title, xalign=0.0) + label.add_css_class("sb-field-label") + col.append(label) + col.append(widget) + return col + + def _build_output_dropdown(self) -> Gtk.DropDown: + self._output_dropdown = Gtk.DropDown() + self._output_dropdown.add_css_class("sb-device-dropdown") + self._output_dropdown.connect("notify::selected", self._on_output_selected) + return self._output_dropdown + + def _build_input_dropdown(self) -> Gtk.DropDown: + self._input_dropdown = Gtk.DropDown() + self._input_dropdown.add_css_class("sb-device-dropdown") + self._input_dropdown.connect("notify::selected", self._on_input_selected) + return self._input_dropdown + + def _build_hologram_row(self) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("sb-general-field") + label = Gtk.Label(label="Hologram Effects", xalign=0.0, hexpand=True) + label.add_css_class("sb-field-label") + row.append(label) + switch = Gtk.Switch(valign=Gtk.Align.CENTER) + switch.set_active(config.hologram_enabled()) + switch.connect("state-set", self._on_hologram_toggled) + row.append(switch) + return row + + def _on_hologram_toggled(self, _switch: Gtk.Switch, value: bool) -> bool: + config.set_hologram_enabled(value) + if self._on_hologram_changed is not None: + self._on_hologram_changed(value) + return False + + # -- data ----------------------------------------------------------------------- + def refresh(self) -> None: + def got_server(info: dict) -> None: + name = info.get("server_name") or "unknown" + version = info.get("server_version") or "" + self._server_lbl.set_label(f"Audio server: {name} {version}".rstrip()) + + def got_sinks(sinks: list[dict]) -> None: + def got_sources(sources: list[dict]) -> None: + self._sync_dropdown(self._output_dropdown, sinks, info.get("default_sink", ""), "_sink_names") + self._sync_dropdown(self._input_dropdown, sources, info.get("default_source", ""), "_source_names") + self._backend.list_sources(got_sources) + self._backend.list_sinks(got_sinks) + self._backend.get_server_info(got_server) + + def _sync_dropdown(self, dropdown: Gtk.DropDown, devices: list[dict], + default_name: str, cache_attr: str) -> None: + names = [d["name"] for d in devices] + if names != getattr(self, cache_attr): + setattr(self, cache_attr, names) + self._guard = True + dropdown.set_model(Gtk.StringList.new([d["description"] for d in devices])) + self._guard = False + idx = names.index(default_name) if default_name in names else None + if idx is not None and dropdown.get_selected() != idx: + self._guard = True + dropdown.set_selected(idx) + self._guard = False + + def _on_output_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._sink_names): + self._backend.set_default_sink(self._sink_names[idx]) + + def _on_input_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._source_names): + self._backend.set_default_source(self._source_names[idx]) + + def activate(self) -> None: + self.refresh() + + def deactivate(self) -> None: + pass diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py new file mode 100644 index 0000000..abbd9d1 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py @@ -0,0 +1,48 @@ +"""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".""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.device_card import DeviceCard +from ui.scroll_row import ScrollRow + + +class InputTab(Gtk.Box): + def __init__(self, backend, peaks) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._peaks = peaks + self._default_name = "" + 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, + 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) + self._backend.get_defaults(got_defaults) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py b/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py new file mode 100644 index 0000000..fc2b605 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py @@ -0,0 +1,84 @@ +"""VolumeMeter — a single-channel volume control styled as a segmented meter +bar (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. + +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 + +COMMIT_DEBOUNCE_MS = 80 + + +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.HORIZONTAL, spacing=6) + 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 + + if label: + tag = Gtk.Label(label=label) + tag.add_css_class("sb-meter-label") + self.append(tag) + + self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 150, 1) + self.scale.set_value(value) + self.scale.set_draw_value(True) + self.scale.set_value_pos(Gtk.PositionType.RIGHT) + self.scale.set_hexpand(True) + self.scale.set_size_request(120, -1) + self.scale.add_css_class("sb-meter") + self.scale.add_mark(100, Gtk.PositionType.BOTTOM, None) + self.scale.connect("value-changed", self._on_value_changed) + 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) + + self.append(self.scale) + + def _on_value_changed(self, _scale) -> None: + self._pending = int(self.scale.get_value()) + 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) + + def set_muted(self, muted: bool) -> None: + if muted: + self.scale.add_css_class("sb-meter-muted") + else: + self.scale.remove_css_class("sb-meter-muted") diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py new file mode 100644 index 0000000..330ae63 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py @@ -0,0 +1,49 @@ +"""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. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.device_card import DeviceCard +from ui.scroll_row import ScrollRow + + +class OutputTab(Gtk.Box): + def __init__(self, backend, peaks) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._peaks = peaks + self._default_name = "" + 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, + 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) + self._backend.get_defaults(got_defaults) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py b/desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py new file mode 100644 index 0000000..a8f9eae --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py @@ -0,0 +1,25 @@ +"""PeakBar — a thin live amplitude indicator (see backend/peaklevel.py). Shows +whether audio is actually reaching the mix, independent of the volume slider +above it, so a silent app/device reads differently from one that's just quiet: +a stuck/crashed stream or a bad route shows a flat bar even at 100% volume.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class PeakBar: + def __init__(self) -> None: + self.widget = Gtk.LevelBar() + self.widget.add_css_class("sb-peakbar") + self.widget.set_min_value(0.0) + self.widget.set_max_value(1.0) + self.widget.set_value(0.0) + self.widget.set_size_request(-1, 6) + self.widget.set_hexpand(True) + + def set_level(self, level: float) -> None: + self.widget.set_value(max(0.0, min(1.0, level))) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/scroll_row.py b/desktopenvs/hyprdrive/supersonic-booster/ui/scroll_row.py new file mode 100644 index 0000000..dfd7c18 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/scroll_row.py @@ -0,0 +1,66 @@ +"""ScrollRow — a horizontally-scrolling row of id-keyed cards, diffed in place +on every refresh instead of torn down and rebuilt. That matters here for two +reasons: rebuilding would fight an in-progress VolumeMeter drag (see that +widget's own drag-guard), and it would tear down/respawn each card's live +`parec` peak-monitor process on every single `pactl subscribe` event instead +of only when a device/app actually appears or disappears. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class ScrollRow(Gtk.ScrolledWindow): + def __init__(self, make_card, empty_text: str = "Nothing here") -> None: + super().__init__() + self.set_policy(Gtk.PolicyType.EXTERNAL, Gtk.PolicyType.NEVER) + self.add_css_class("sb-scroll-row") + self._make_card = make_card + self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + self._row.add_css_class("sb-row") + self.set_child(self._row) + self._cards: dict = {} + self._empty_label = Gtk.Label(label=empty_text) + self._empty_label.add_css_class("sb-empty") + self._active = False + + def sync(self, items: list[dict], *extra) -> None: + seen = set() + for item in items: + iid = item["id"] + seen.add(iid) + card = self._cards.get(iid) + if card is None: + card = self._make_card(item, *extra) + self._cards[iid] = card + self._row.append(card) + if self._active: + card.start_peak() + else: + card.update(item, *extra) + for iid in [i for i in self._cards if i not in seen]: + card = self._cards.pop(iid) + card.stop_peak() + self._row.remove(card) + self._sync_empty_state() + + def _sync_empty_state(self) -> None: + empty = not self._cards + if empty and self._empty_label.get_parent() is None: + self._row.append(self._empty_label) + elif not empty and self._empty_label.get_parent() is not None: + self._row.remove(self._empty_label) + + def activate(self) -> None: + self._active = True + for card in self._cards.values(): + card.start_peak() + + def deactivate(self) -> None: + self._active = False + for card in self._cards.values(): + card.stop_peak() diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/tabs.py b/desktopenvs/hyprdrive/supersonic-booster/ui/tabs.py new file mode 100644 index 0000000..69513ad --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/tabs.py @@ -0,0 +1,48 @@ +"""TabSwitcher — a row of mutually-exclusive toggle buttons driving a +Gtk.Stack, styled as a segmented control (see .sb-tabbar/.sb-tab in +style/style.css). Plain manual mutual exclusion rather than Gtk.CheckButton +grouping, since that's simplest for a small fixed set of tabs.""" + +from __future__ import annotations + +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class TabSwitcher(Gtk.Box): + def __init__(self, tabs: list[tuple[str, str, Gtk.Widget]], stack: Gtk.Stack, + on_changed: Callable[[str], None]) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self.add_css_class("sb-tabbar") + self._stack = stack + self._on_changed = on_changed + self._buttons: dict[str, Gtk.ToggleButton] = {} + + first = None + for name, label, _widget in tabs: + btn = Gtk.ToggleButton(label=label) + btn.add_css_class("sb-tab") + btn.connect("toggled", self._make_handler(name)) + self.append(btn) + self._buttons[name] = btn + if first is None: + first = name + if first is not None: + self._buttons[first].set_active(True) + + def _make_handler(self, name: str): + def handler(btn: Gtk.ToggleButton) -> None: + if not btn.get_active(): + if not any(b.get_active() for b in self._buttons.values()): + btn.set_active(True) # one tab must always stay selected + return + for other_name, other_btn in self._buttons.items(): + if other_name != name and other_btn.get_active(): + other_btn.set_active(False) + self._stack.set_visible_child_name(name) + self._on_changed(name) + return handler diff --git a/desktopenvs/hyprdrive/supersonic-booster/window.py b/desktopenvs/hyprdrive/supersonic-booster/window.py new file mode 100644 index 0000000..981e6a1 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/window.py @@ -0,0 +1,189 @@ +"""The popup: a content-sized floating layer-shell panel anchored top-centre, +matching transmitter-panel/astro-menu's window.py idiom — dismissed with the +launcher toggle, Esc, or the ✕ button, no click-outside-to-close. + + Gtk.Window (layer TOP, anchored TOP -> horizontally centred, height = content) + └ Gtk.Overlay + main : .sb-panel (title, tab switcher, Gtk.Stack of the four tabs) + over : hologram overlay + over : ✕ close button (top-right) + +Owns the single AudioBackend and PeakMonitorManager shared by every tab — +one `pactl subscribe` process and one live-metering manager for the whole +panel, not one per tab. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import config +from backend.peaklevel import PeakMonitorManager +from backend.pipewire import AudioBackend +from lib.hologram import HologramOverlay +from ui.applications_tab import ApplicationsTab +from ui.general_tab import GeneralTab +from ui.input_tab import InputTab +from ui.output_tab import OutputTab +from ui.tabs import TabSwitcher + +PANEL_WIDTH = 900 +EDGE_MARGIN = 28 + + +class SupersonicWindow(Gtk.ApplicationWindow): + def __init__(self, app: Gtk.Application) -> None: + super().__init__(application=app) + self.set_name("supersonic-window") + self.add_css_class("sb-window") + self.set_decorated(False) + + self._init_layer_shell() + + self._backend = AudioBackend(self._on_backend_changed) + self._peaks = PeakMonitorManager() + + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.root.set_name("panel-root") + self.root.add_css_class("sb-panel") + self.root.set_size_request(PANEL_WIDTH, -1) + + header = Gtk.CenterBox() + header.add_css_class("sb-header") + title = Gtk.Label(label="Supersonic Booster", xalign=0.0) + title.add_css_class("sb-title") + header.set_start_widget(title) + self.root.append(header) + + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self._stack.set_transition_duration(150) + + 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._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, + "general": self._general_tab, + } + tab_order = [ + ("applications", "Applications", self._applications_tab), + ("input", "Input Devices", self._input_tab), + ("output", "Output Devices", self._output_tab), + ("general", "General Settings", self._general_tab), + ] + for name, label, widget in tab_order: + self._stack.add_titled(widget, name, label) + self._active_tab_name = tab_order[0][0] + + self._tabswitcher = TabSwitcher(tab_order, self._stack, self._on_tab_changed) + self.root.append(self._tabswitcher) + self.root.append(self._stack) + + overlay = Gtk.Overlay() + overlay.set_child(self.root) + + self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root) + overlay.add_overlay(self._hologram.widget) + + close = Gtk.Button(label="✕") + close.add_css_class("close-btn") + close.set_halign(Gtk.Align.END) + close.set_valign(Gtk.Align.START) + close.connect("clicked", lambda *_a: self.hide_panel()) + overlay.add_overlay(close) + self.set_child(overlay) + + key = Gtk.EventControllerKey() + key.connect("key-pressed", self._on_key) + self.add_controller(key) + + self._last_tick: float | None = None + self._tick_id: int | None = None + self.set_visible(False) + + # -- layer shell -------------------------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "supersonic-booster") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND) + LayerShell.set_anchor(self, LayerShell.Edge.TOP, True) + LayerShell.set_margin(self, LayerShell.Edge.TOP, EDGE_MARGIN) + + # -- visibility ----------------------------------------------------------------- + def show_panel(self) -> None: + self.set_visible(True) + self.present() + if self._hologram.enabled and self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + self._hologram.start_intro() + self._refresh_all() + self._tabs[self._active_tab_name].activate() + + def hide_panel(self) -> None: + self._tabs[self._active_tab_name].deactivate() + self._peaks.stop_all() + if self._hologram.enabled and self._tick_id is not None: + self._hologram.start_outro(self._finish_hide) + else: + self._finish_hide() + + def _finish_hide(self) -> None: + self.set_visible(False) + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None + + def toggle(self) -> None: + if self.get_visible(): + self.hide_panel() + else: + self.show_panel() + + # -- tabs --------------------------------------------------------------------- + def _on_tab_changed(self, name: str) -> None: + if name == self._active_tab_name: + return + self._tabs[self._active_tab_name].deactivate() + self._active_tab_name = name + self._tabs[name].activate() + + def _refresh_all(self) -> None: + for tab in self._tabs.values(): + tab.refresh() + + def _on_backend_changed(self) -> None: + if not self.get_visible(): + return + self._refresh_all() + + def _on_hologram_setting_changed(self, enabled: bool) -> None: + self._hologram.enabled = enabled + if enabled and self.get_visible() and self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + + # -- hologram tick -------------------------------------------------------------- + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._hologram.tick(dt) + return True + + def _on_key(self, _c, keyval, _kc, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self.hide_panel() + return True + return False diff --git a/desktopenvs/hyprlua/audio-panel-theme/_colors.css b/desktopenvs/hyprlua/audio-panel-theme/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel-theme/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprlua/audio-panel-theme/style.css b/desktopenvs/hyprlua/audio-panel-theme/style.css new file mode 100644 index 0000000..daf39f3 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel-theme/style.css @@ -0,0 +1,214 @@ +/* audio-panel — plain CyberQueer theme (hyprlua's standard look, no hologram/ + * glow treatment). Hand-maintained here, not generated: regen-audio-panel.sh + * copies this file into desktopenvs/hyprlua/audio-panel/style/ as-is, + * replacing whatever style.css hyprdrive's supersonic-booster shipped. + * Mirrors astal-menu's own plainness relative to astro-menu: flat @violet/@bg + * fills instead of alpha glass, no box-shadow glow, no hover transitions, no + * @text colour override. + * + * Targets the SAME `.sb-*` class names as the original — that prefix isn't + * touched by regen-audio-panel.sh's rename pass (it doesn't contain + * "supersonic-booster"/"supersonicbooster" as a substring), so nothing here + * needs renaming. */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 11pt; +} + +window, +window.background, +.sb-window, +.sb-scroll-row, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +.sb-panel { + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 14px 16px; +} + +.sb-header { margin: 0 64px 4px 0; } +.sb-title { + color: @text; + font-weight: bold; + font-size: 14pt; +} + +/* ---- tab switcher --------------------------------------------------------- */ +.sb-tabbar { margin-bottom: 4px; } +.sb-tab { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 16px; + padding: 4px 14px; + min-height: 26px; +} +.sb-tab:hover { + border-color: @accent; + color: @accent; +} +.sb-tab:checked { + background-color: @accent; + border-color: @accent; + color: @bg; +} + +.sb-tab-page { min-height: 260px; } + +/* ---- horizontal scroll row of cards ---------------------------------------- */ +.sb-scroll-row { padding: 4px 2px 10px 2px; } +.sb-row { padding: 2px; } +.sb-empty { + color: @text; + opacity: 0.6; + padding: 40px 20px; +} + +/* ---- cards ---------------------------------------------------------------- */ +.sb-card { + background-color: alpha(@violet, 0.5); + border: 2px solid @violet; + border-radius: 10px; + padding: 10px 12px; + min-width: 170px; +} +.sb-card-head { margin-bottom: 2px; } +.sb-card-title { + color: @text; + font-weight: bold; + font-size: 10.5pt; +} +.sb-field-label { + color: @text; + opacity: 0.65; + font-size: 8.5pt; +} + +/* ---- live peak bar ---------------------------------------------------------- */ +.sb-peakbar { min-height: 6px; } +.sb-peakbar trough { + background-color: @bg; + border-radius: 4px; + min-height: 6px; +} +.sb-peakbar block { + border-radius: 4px; + min-width: 2px; +} +.sb-peakbar block.filled { background-color: #34E27A; } +.sb-peakbar block.empty { background-color: transparent; } + +/* ---- volume meter (Gtk.Scale) ----------------------------------------------- */ +.sb-meter-row { margin: 2px 0; } +.sb-meter-label { + color: @text; + opacity: 0.75; + font-size: 9pt; + min-width: 12px; +} +.sb-meter trough { + background-color: @bg; + border-radius: 6px; + min-height: 10px; +} +.sb-meter highlight { + background-color: @accent; + border-radius: 6px; +} +.sb-meter slider { + background-color: @text; + border-radius: 50%; + min-width: 14px; + min-height: 14px; +} +.sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.3); } +.sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); } + +/* ---- controls (mute / L-R toggle / default) --------------------------------- */ +.sb-card-controls { margin-top: 2px; } +.sb-mute-btn, +.sb-lr-toggle, +.sb-default-btn { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 14px; + padding: 2px 10px; + min-height: 24px; + font-size: 9pt; +} +.sb-mute-btn:hover, +.sb-lr-toggle:hover, +.sb-default-btn:hover { + border-color: @accent; + color: @accent; +} +.sb-mute-btn:checked { + background-color: @danger; + border-color: @danger; + color: @bg; +} +.sb-lr-toggle:checked { + background-color: @accent; + border-color: @accent; + color: @bg; +} +.sb-default-btn:checked { + background-color: #34E27A; + border-color: #34E27A; + color: @bg; +} +.sb-default-btn:disabled { opacity: 0.85; } + +/* ---- dropdowns -------------------------------------------------------------- */ +.sb-device-dropdown { + color: @text; + background-color: @bg; + border: 2px solid @violet; + border-radius: 8px; + padding: 2px 6px; + min-height: 26px; + font-size: 9pt; +} +.sb-device-dropdown:hover { border-color: @accent; } + +/* ---- general settings tab ---------------------------------------------------- */ +.sb-general { padding: 6px 4px; } +.sb-general-field { margin-bottom: 4px; } +.sb-server-info { + color: @text; + opacity: 0.6; + font-size: 9pt; + margin-top: 8px; +} + +/* floating panel-level close button (top-right) — same idiom astal-menu uses + * for its own menu-window close button. */ +.close-btn { + color: @text; + background: @violet; + border: none; + border-radius: 20px; + min-width: 34px; + min-height: 34px; + margin: 16px 20px; +} +.close-btn:hover { + background: @accent; + color: @bg; +} + +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; min-height: 6px; } +scrollbar slider:hover { background: @accent; } + +.sb-hologram { background: transparent; } diff --git a/desktopenvs/hyprlua/audio-panel/.gitignore b/desktopenvs/hyprlua/audio-panel/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprlua/audio-panel/backend/peaklevel.py b/desktopenvs/hyprlua/audio-panel/backend/peaklevel.py new file mode 100644 index 0000000..8090ac4 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/backend/peaklevel.py @@ -0,0 +1,119 @@ +"""Live peak/amplitude metering, independent of the volume *setting* — the +whole point is to make "the app is playing but nothing is reaching the mix" +visually obvious even when volume sliders read 100%, instead of only ever +showing what the volume knob is set to. + +PipeWire/pipewire-pulse has no `pactl` subcommand for this (pactl only reports +configured volume, never signal level), so this shells out to `parec` +(pipewire-pulse's PulseAudio-protocol recorder) in raw-PCM mode and computes +peak amplitude client-side from the byte stream — the same technique +pavucontrol's meters use under the hood. `parec --monitor-stream=` taps a +single application's own stream (Applications tab); `parec --device=` +taps a physical device directly (`.monitor` for outputs, the source +name itself for inputs). + +One `parec` process per distinct target, shared across every card watching +it, and only running while that card is on the currently-visible tab of a +currently-visible panel (see ui/card_base.py's start_peak/stop_peak, driven by +ui/scroll_row.py's activate/deactivate) — a mixer panel comfortably shows a +dozen live meters, but there's no reason to keep any of them decoding audio +while the panel is hidden. +""" + +from __future__ import annotations + +import array +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +CHUNK_BYTES = 512 # ~256 s16le mono samples per read, small enough to feel live +RATE_HZ = 8000 # plenty of resolution for a peak/VU bar, cheap to decode +LATENCY_MSEC = 60 + + +def _peak_from_pcm16(data: bytes) -> float: + usable = len(data) - (len(data) % 2) + if usable <= 0: + return 0.0 + samples = array.array("h") + samples.frombytes(data[:usable]) + peak = max((abs(s) for s in samples), default=0) + return min(1.0, peak / 32768.0) + + +class PeakMonitorManager: + def __init__(self) -> None: + self._monitors: dict[str, dict] = {} + + def watch(self, key: str, target_args: list[str], + on_level: Callable[[float], None]) -> Callable[[], None]: + """target_args is the parec argv tail identifying what to tap, e.g. + ["--device=alsa_output.foo.monitor"] or ["--monitor-stream=42"]. + Returns an unwatch() callback; call it to stop receiving levels.""" + entry = self._monitors.get(key) + if entry is None: + entry = self._start(key, target_args) + entry["callbacks"].add(on_level) + + def unwatch() -> None: + live = self._monitors.get(key) + if live is None: + return + live["callbacks"].discard(on_level) + if not live["callbacks"]: + self._stop(key) + + return unwatch + + def _start(self, key: str, target_args: list[str]) -> dict: + argv = ["parec", "--format=s16le", f"--rate={RATE_HZ}", "--channels=1", + f"--latency-msec={LATENCY_MSEC}", *target_args] + entry = {"proc": None, "stream": None, "callbacks": set()} + self._monitors[key] = entry + try: + proc = Gio.Subprocess.new( + argv, Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + return entry + entry["proc"] = proc + entry["stream"] = proc.get_stdout_pipe() + self._read_next(key) + return entry + + def _read_next(self, key: str) -> None: + entry = self._monitors.get(key) + if entry is None or entry["stream"] is None: + return + entry["stream"].read_bytes_async(CHUNK_BYTES, GLib.PRIORITY_DEFAULT, None, + self._on_bytes, key) + + def _on_bytes(self, stream, result, key: str) -> None: + entry = self._monitors.get(key) + if entry is None: + return + try: + data = stream.read_bytes_finish(result).get_data() or b"" + except GLib.Error: + data = b"" + if not data: + # target went away (app closed, device unplugged) — the owning + # card's next refresh will drop it entirely; just stop quietly. + self._stop(key) + return + level = _peak_from_pcm16(data) + for cb in list(entry["callbacks"]): + cb(level) + self._read_next(key) + + def _stop(self, key: str) -> None: + entry = self._monitors.pop(key, None) + if entry is not None and entry.get("proc") is not None: + entry["proc"].force_exit() + + def stop_all(self) -> None: + for key in list(self._monitors.keys()): + self._stop(key) diff --git a/desktopenvs/hyprlua/audio-panel/backend/pipewire.py b/desktopenvs/hyprlua/audio-panel/backend/pipewire.py new file mode 100644 index 0000000..56e589f --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/backend/pipewire.py @@ -0,0 +1,310 @@ +"""Audio backend: talks to PipeWire through pipewire-pulse's `pactl` compat +layer — pipewire-pulse IS the PipeWire project's own pulse-protocol server +(see hyprlua.sh's package list: pipewire + pipewire-pulse + wireplumber), +not the legacy PulseAudio daemon. `pactl -f json` gives structured per-channel +volume, card profiles and stream routing with no text-scraping, and `pactl +subscribe` gives event-driven refresh instead of polling (same philosophy as +astro-menu's hypr_ipc-style event feeds). wpctl (WirePlumber's own CLI) was +considered and dropped: it has no per-channel volume or card-profile verbs, +both of which the Applications/Output/Input tabs need. + +Every read is async (Gio.Subprocess.communicate_utf8_async, never a blocking +`subprocess.run`) — a hung pactl must not freeze the panel, the same stance +transmitter-panel's history_client.py takes toward beacon's D-Bus calls. +Mutations are fire-and-forget Gio.Subprocess spawns; the resulting `pactl +subscribe` event is what actually refreshes the UI; no mutation waits on its +own result. +""" + +from __future__ import annotations + +import json +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +# Coalesce bursts of subscribe events (e.g. a drag-driven volume change fires +# many 'change' events in a row) into one refresh instead of one per event. +REFRESH_DEBOUNCE_MS = 120 + +_MUTATE_CMD = { + "sink": ("set-sink-volume", "set-sink-mute"), + "source": ("set-source-volume", "set-source-mute"), + "sink-input": ("set-sink-input-volume", "set-sink-input-mute"), + "source-output": ("set-source-output-volume", "set-source-output-mute"), +} + + +def _percent(vol_entry: dict) -> int: + raw = (vol_entry or {}).get("value_percent") or "0%" + try: + return int(str(raw).rstrip("%")) + except ValueError: + return 0 + + +def _clamp(pct: int) -> int: + return max(0, min(150, pct)) + + +def _normalize_device(raw: dict) -> dict: + """Sinks (output devices) and sources (input devices) share this shape.""" + props = raw.get("properties") or {} + volume = raw.get("volume") or {} + # dict insertion order mirrors pactl's own channel_map order (front-left + # before front-right for stereo) — Python's json.loads preserves it. + channels = list(volume.keys()) + return { + "id": raw.get("index"), + "name": raw.get("name") or "", + "description": raw.get("description") or raw.get("name") or "", + "mute": bool(raw.get("mute")), + "channels": channels, + "volume_percent": {ch: _percent(v) for ch, v in volume.items()}, + "card": raw.get("card"), + "icon": props.get("device.icon_name") or "audio-card", + "form_factor": props.get("device.form_factor") or "", + "state": raw.get("state") or "", + } + + +def _normalize_stream(raw: dict) -> dict: + """Sink-inputs (apps playing audio) and source-outputs (apps recording).""" + props = raw.get("properties") or {} + volume = raw.get("volume") or {} + channels = list(volume.keys()) + name = (props.get("application.name") or props.get("media.name") + or props.get("node.name") or f"Stream #{raw.get('index')}") + return { + "id": raw.get("index"), + "name": name, + "icon": props.get("application.icon_name") or props.get("window.icon_name") + or "audio-x-generic", + "mute": bool(raw.get("mute")), + "channels": channels, + "volume_percent": {ch: _percent(v) for ch, v in volume.items()}, + # whichever sink (sink-input) or source (source-output) it's routed to + "device": raw.get("sink") if "sink" in raw else raw.get("source"), + "corked": bool(raw.get("corked", False)), + "binary": props.get("application.process.binary") or "", + } + + +def _normalize_card(raw: dict) -> dict: + props = raw.get("properties") or {} + profiles_raw = raw.get("profiles") or {} + profiles = [] + for name, info in profiles_raw.items(): + if isinstance(info, dict): + profiles.append({ + "name": name, + "description": info.get("description", name), + "available": bool(info.get("available", True)), + }) + else: # defensive: older/other pactl builds may just give a string + profiles.append({"name": name, "description": str(info), "available": True}) + return { + "id": raw.get("index"), + "name": raw.get("name") or "", + "description": props.get("device.description") or raw.get("name") or "", + "profiles": profiles, + "active_profile": raw.get("active_profile") or "", + } + + +class AudioBackend: + def __init__(self, on_changed: Callable[[], None]) -> None: + self._on_changed = on_changed + self._debounce_id: Optional[int] = None + self._sub_proc: Optional[Gio.Subprocess] = None + self._sub_stream: Optional[Gio.DataInputStream] = None + self._start_subscribe() + + # -- live event stream ---------------------------------------------------- + def _start_subscribe(self) -> None: + try: + self._sub_proc = Gio.Subprocess.new( + ["pactl", "subscribe"], Gio.SubprocessFlags.STDOUT_PIPE) + except GLib.Error: + self._sub_proc = None + return + self._sub_stream = Gio.DataInputStream.new(self._sub_proc.get_stdout_pipe()) + self._read_next_event() + + def _read_next_event(self) -> None: + if self._sub_stream is None: + return + self._sub_stream.read_line_async(GLib.PRIORITY_DEFAULT, None, self._on_event_line) + + def _on_event_line(self, stream: Gio.DataInputStream, result: Gio.AsyncResult) -> None: + try: + line, _length = stream.read_line_finish_utf8(result) + except GLib.Error: + line = None + if line is None: + # `pactl subscribe` died (pipewire-pulse restarted, etc.) — respawn + # after a short delay instead of going silent for the panel's life. + self._sub_proc = None + self._sub_stream = None + GLib.timeout_add_seconds(2, self._restart_subscribe) + return + self._schedule_refresh() + self._read_next_event() + + def _restart_subscribe(self) -> bool: + self._start_subscribe() + return False + + def _schedule_refresh(self) -> None: + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = GLib.timeout_add(REFRESH_DEBOUNCE_MS, self._fire_refresh) + + def _fire_refresh(self) -> bool: + self._debounce_id = None + self._on_changed() + return False + + def stop(self) -> None: + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = None + if self._sub_proc is not None: + self._sub_proc.force_exit() + self._sub_proc = None + + # -- reads ------------------------------------------------------------------ + def _list_async(self, kind: str, normalize, callback: Callable[[list[dict]], None]) -> None: + def done(proc, result) -> None: + try: + ok, stdout, _stderr = proc.communicate_utf8_finish(result) + except GLib.Error: + ok, stdout = False, "" + if not ok or not stdout: + callback([]) + return + try: + raw = json.loads(stdout) + except json.JSONDecodeError: + raw = [] + callback([normalize(r) for r in raw]) + + try: + proc = Gio.Subprocess.new( + ["pactl", "-f", "json", "list", kind], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + callback([]) + return + proc.communicate_utf8_async(None, None, done) + + def list_sinks(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("sinks", _normalize_device, callback) + + def list_sources(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("sources", _normalize_device, callback) + + def list_sink_inputs(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("sink-inputs", _normalize_stream, callback) + + def list_source_outputs(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("source-outputs", _normalize_stream, callback) + + def list_cards(self, callback: Callable[[list[dict]], None]) -> None: + self._list_async("cards", _normalize_card, callback) + + def get_defaults(self, callback: Callable[[str, str], None]) -> None: + """callback(default_sink_name, default_source_name), read from `pactl info`.""" + def done(proc, result) -> None: + try: + ok, stdout, _stderr = proc.communicate_utf8_finish(result) + except GLib.Error: + ok, stdout = False, "" + sink = source = "" + if ok: + for line in stdout.splitlines(): + if line.startswith("Default Sink:"): + sink = line.split(":", 1)[1].strip() + elif line.startswith("Default Source:"): + source = line.split(":", 1)[1].strip() + callback(sink, source) + + try: + proc = Gio.Subprocess.new( + ["pactl", "info"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + callback("", "") + return + proc.communicate_utf8_async(None, None, done) + + def get_server_info(self, callback: Callable[[dict], None]) -> None: + """callback({"server_name", "server_version", "default_sink", "default_source"}), + read from `pactl info`. `server_name` is what confirms this is really + talking to pipewire-pulse ("PulseAudio (on PipeWire ...)") rather than + a legacy PulseAudio daemon — surfaced in the General tab.""" + empty = {"server_name": "", "server_version": "", "default_sink": "", "default_source": ""} + + def done(proc, result) -> None: + try: + ok, stdout, _stderr = proc.communicate_utf8_finish(result) + except GLib.Error: + ok, stdout = False, "" + info = dict(empty) + if ok: + for line in stdout.splitlines(): + for key, prefix in (("server_name", "Server Name:"), + ("server_version", "Server Version:"), + ("default_sink", "Default Sink:"), + ("default_source", "Default Source:")): + if line.startswith(prefix): + info[key] = line.split(":", 1)[1].strip() + callback(info) + + try: + proc = Gio.Subprocess.new( + ["pactl", "info"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + callback(dict(empty)) + return + proc.communicate_utf8_async(None, None, done) + + # -- mutations (fire-and-forget) -------------------------------------------- + @staticmethod + def _spawn(argv: list[str]) -> None: + try: + Gio.Subprocess.new( + argv, Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE) + except GLib.Error: + pass + + def set_volume(self, kind: str, ident, percents: list[int]) -> None: + """kind: 'sink' | 'source' | 'sink-input' | 'source-output'. `percents` + is one value per channel in that node's channel order (see the + `channels` list on its normalized dict) — pass a single-item list to + set every channel uniformly, or one value per channel for independent + left/right gain.""" + cmd, _mute_cmd = _MUTATE_CMD[kind] + values = [f"{_clamp(p)}%" for p in percents] + self._spawn(["pactl", cmd, str(ident), *values]) + + def set_mute(self, kind: str, ident, mute: bool) -> None: + _vol_cmd, cmd = _MUTATE_CMD[kind] + self._spawn(["pactl", cmd, str(ident), "1" if mute else "0"]) + + def set_default_sink(self, name: str) -> None: + self._spawn(["pactl", "set-default-sink", name]) + + def set_default_source(self, name: str) -> None: + self._spawn(["pactl", "set-default-source", name]) + + def move_sink_input(self, ident, sink: str) -> None: + self._spawn(["pactl", "move-sink-input", str(ident), sink]) + + def move_source_output(self, ident, source: str) -> None: + self._spawn(["pactl", "move-source-output", str(ident), source]) + + def set_card_profile(self, card_ident, profile: str) -> None: + self._spawn(["pactl", "set-card-profile", str(card_ident), profile]) diff --git a/desktopenvs/hyprlua/audio-panel/config.py b/desktopenvs/hyprlua/audio-panel/config.py new file mode 100644 index 0000000..fb535dc --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/config.py @@ -0,0 +1,36 @@ +"""Tiny user-editable config file: ~/.local/state/audio-panel/config.json. + +Read once at startup (main.py); same pattern as the rest of the Cosmonaut Shell +suite's config.py (orbit-menu, horizon-dock, transmitter-panel). +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"hologram": False} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) + + +def set_hologram_enabled(value: bool) -> None: + data = _load() + data["hologram"] = bool(value) + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n") diff --git a/desktopenvs/hyprlua/audio-panel/lib/hologram.py b/desktopenvs/hyprlua/audio-panel/lib/hologram.py new file mode 100644 index 0000000..83ec2a6 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/lib/hologram.py @@ -0,0 +1,279 @@ +"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as +the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu / +horizon-dock / beacon / transmitter-panel lib/hologram.py), reused here so the +audio-panel popup reads as one more orbit of the same look: scanline +grid + a slow vertical sweep + drifting noise specks, and a 'materialise out +of static' intro when the panel opens. + +One overlay covers the whole panel (same usage as astro-menu's window.py, +`fade_widget=self.root`), not a per-row overlay — window.py runs a single +frame-clock tick and feeds it via .tick(dt). +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.18 + SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 34.0 + NOISE_COUNT = 26 + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 34.0 # smooth vertical fade-out + INTRO_DURATION = 0.9 # brisk 'materialise out of static' when the panel opens + INTRO_STATIC = 900 # static specks at the very start of the intro + OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on close + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to a shape + # widget whose opacity is ramped 0->1 during the intro so the content + # genuinely fades in, rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None + self._outro_t: float | None = None + self._outro_done = None + # Wall-clock safety net: the frame-clock tick only advances while the + # compositor sends frame callbacks; if those stall the intro could freeze + # with content stuck at opacity 0. This timeout force-resolves it anyway. + self._intro_deadline_id: int | None = None + self._mask_cache: tuple | None = None + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from the panel underneath + self.widget.add_css_class("sb-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._finish_intro() + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def _finish_intro(self) -> None: + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = GLib.timeout_add( + int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) + + def _on_intro_deadline(self) -> bool: + self._intro_deadline_id = None + if self._intro_t is not None: + self._finish_intro() + return False # one-shot + + def start_outro(self, on_done) -> None: + """Reverse of the intro (dissolving back into static), then on_done.""" + if not self.enabled: + on_done() + return + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) + cr.clip() + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.09) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/desktopenvs/hyprlua/audio-panel/main.py b/desktopenvs/hyprlua/audio-panel/main.py new file mode 100644 index 0000000..829abfd --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/main.py @@ -0,0 +1,94 @@ +#!/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). + +Single-instance, same pattern as astro-menu/horizon-dock/transmitter-panel's +main.py: the first launch builds the (hidden) window and holds; later +invocations forward their verb over D-Bus via scripts/audio-panel.sh +instead of spawning a second python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --show show + main.py --hide hide + main.py --toggle whichever of the above applies +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# audio-panel-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering +# requirement ahead of libwayland-client). Drop it once resident so it isn't +# inherited by anything this process launches — same rationale as the rest of +# the suite's main.py. +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from paths import APP_ID # noqa: E402 +from window import AudioPanelWindow # noqa: E402 + + +class AudioPanelApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: AudioPanelWindow | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = AudioPanelWindow(self) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def guarded(fn): + def wrapper(*_a) -> None: + assert self.window is not None + fn(self.window) + return wrapper + + add("show", guarded(lambda w: w.show_panel())) + add("hide", guarded(lambda w: w.hide_panel())) + add("toggle", guarded(lambda w: w.toggle())) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--show": + self.window.show_panel() + elif verb == "--hide": + self.window.hide_panel() + elif verb == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("audio-panel") + return AudioPanelApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprlua/audio-panel/paths.py b/desktopenvs/hyprlua/audio-panel/paths.py new file mode 100644 index 0000000..eb42df5 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/paths.py @@ -0,0 +1,22 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does +# `rm -rf ~/.config/audio-panel` on every dotfiles sync (see orbit-menu/ +# horizon-dock/paths.py for the same rationale), which would wipe a hand-edited +# config on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "audio-panel" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.audiopanel" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/desktopenvs/hyprlua/audio-panel/style/_colors.css b/desktopenvs/hyprlua/audio-panel/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprlua/audio-panel/style/style.css b/desktopenvs/hyprlua/audio-panel/style/style.css new file mode 100644 index 0000000..48327ff --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/style/style.css @@ -0,0 +1,214 @@ +/* audio-panel — plain CyberQueer theme (hyprlua's standard look, no hologram/ + * glow treatment). Hand-maintained here, not generated: regen-audio-panel.sh + * copies this file into desktopenvs/hyprlua/audio-panel/style/ as-is, + * replacing whatever style.css hyprlua's audio-panel shipped. + * Mirrors astal-menu's own plainness relative to astro-menu: flat @violet/@bg + * fills instead of alpha glass, no box-shadow glow, no hover transitions, no + * @text colour override. + * + * Targets the SAME `.sb-*` class names as the original — that prefix isn't + * touched by regen-audio-panel.sh's rename pass (it doesn't contain + * "audio-panel"/"audiopanel" as a substring), so nothing here + * needs renaming. */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 11pt; +} + +window, +window.background, +.sb-window, +.sb-scroll-row, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +.sb-panel { + background-color: @violet; + border: 2px solid @violet; + border-radius: 12px; + padding: 14px 16px; +} + +.sb-header { margin: 0 64px 4px 0; } +.sb-title { + color: @text; + font-weight: bold; + font-size: 14pt; +} + +/* ---- tab switcher --------------------------------------------------------- */ +.sb-tabbar { margin-bottom: 4px; } +.sb-tab { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 16px; + padding: 4px 14px; + min-height: 26px; +} +.sb-tab:hover { + border-color: @accent; + color: @accent; +} +.sb-tab:checked { + background-color: @accent; + border-color: @accent; + color: @bg; +} + +.sb-tab-page { min-height: 260px; } + +/* ---- horizontal scroll row of cards ---------------------------------------- */ +.sb-scroll-row { padding: 4px 2px 10px 2px; } +.sb-row { padding: 2px; } +.sb-empty { + color: @text; + opacity: 0.6; + padding: 40px 20px; +} + +/* ---- cards ---------------------------------------------------------------- */ +.sb-card { + background-color: alpha(@violet, 0.5); + border: 2px solid @violet; + border-radius: 10px; + padding: 10px 12px; + min-width: 170px; +} +.sb-card-head { margin-bottom: 2px; } +.sb-card-title { + color: @text; + font-weight: bold; + font-size: 10.5pt; +} +.sb-field-label { + color: @text; + opacity: 0.65; + font-size: 8.5pt; +} + +/* ---- live peak bar ---------------------------------------------------------- */ +.sb-peakbar { min-height: 6px; } +.sb-peakbar trough { + background-color: @bg; + border-radius: 4px; + min-height: 6px; +} +.sb-peakbar block { + border-radius: 4px; + min-width: 2px; +} +.sb-peakbar block.filled { background-color: #34E27A; } +.sb-peakbar block.empty { background-color: transparent; } + +/* ---- volume meter (Gtk.Scale) ----------------------------------------------- */ +.sb-meter-row { margin: 2px 0; } +.sb-meter-label { + color: @text; + opacity: 0.75; + font-size: 9pt; + min-width: 12px; +} +.sb-meter trough { + background-color: @bg; + border-radius: 6px; + min-height: 10px; +} +.sb-meter highlight { + background-color: @accent; + border-radius: 6px; +} +.sb-meter slider { + background-color: @text; + border-radius: 50%; + min-width: 14px; + min-height: 14px; +} +.sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.3); } +.sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); } + +/* ---- controls (mute / L-R toggle / default) --------------------------------- */ +.sb-card-controls { margin-top: 2px; } +.sb-mute-btn, +.sb-lr-toggle, +.sb-default-btn { + color: @text; + background-color: @violet; + border: 2px solid @violet; + border-radius: 14px; + padding: 2px 10px; + min-height: 24px; + font-size: 9pt; +} +.sb-mute-btn:hover, +.sb-lr-toggle:hover, +.sb-default-btn:hover { + border-color: @accent; + color: @accent; +} +.sb-mute-btn:checked { + background-color: @danger; + border-color: @danger; + color: @bg; +} +.sb-lr-toggle:checked { + background-color: @accent; + border-color: @accent; + color: @bg; +} +.sb-default-btn:checked { + background-color: #34E27A; + border-color: #34E27A; + color: @bg; +} +.sb-default-btn:disabled { opacity: 0.85; } + +/* ---- dropdowns -------------------------------------------------------------- */ +.sb-device-dropdown { + color: @text; + background-color: @bg; + border: 2px solid @violet; + border-radius: 8px; + padding: 2px 6px; + min-height: 26px; + font-size: 9pt; +} +.sb-device-dropdown:hover { border-color: @accent; } + +/* ---- general settings tab ---------------------------------------------------- */ +.sb-general { padding: 6px 4px; } +.sb-general-field { margin-bottom: 4px; } +.sb-server-info { + color: @text; + opacity: 0.6; + font-size: 9pt; + margin-top: 8px; +} + +/* floating panel-level close button (top-right) — same idiom astal-menu uses + * for its own menu-window close button. */ +.close-btn { + color: @text; + background: @violet; + border: none; + border-radius: 20px; + min-width: 34px; + min-height: 34px; + margin: 16px 20px; +} +.close-btn:hover { + background: @accent; + color: @bg; +} + +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; min-height: 6px; } +scrollbar slider:hover { background: @accent; } + +.sb-hologram { background: transparent; } diff --git a/desktopenvs/hyprlua/audio-panel/theme.py b/desktopenvs/hyprlua/audio-panel/theme.py new file mode 100644 index 0000000..a4972c6 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/theme.py @@ -0,0 +1,32 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as the rest +of the Cosmonaut Shell suite's theme.py. _colors.css (generated from +~/Dotfiles/colors.conf by apply-theme.sh) defines the CyberQueer @define-color +names; style.css consumes them. + +Priority is USER+1 for the same reason as the other apps: the CyberQueer GTK +theme at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above +APPLICATION (600), and would beat our transparent structural containers +otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/desktopenvs/hyprlua/audio-panel/ui/app_card.py b/desktopenvs/hyprlua/audio-panel/ui/app_card.py new file mode 100644 index 0000000..a720bc5 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/app_card.py @@ -0,0 +1,61 @@ +"""ApplicationCard — one card in the Applications tab: everything MeterCard +gives every card (icon+name, live peak bar, volume meter/L-R pair, mute), +plus a dropdown to route this one app's playback stream to a different +output device (`pactl move-sink-input`).""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.card_base import MeterCard + + +class ApplicationCard(MeterCard): + def __init__(self, backend, peaks, item: dict, sinks: list[dict]) -> None: + self._sinks = sinks + self._sink_names: list[str] = [] + super().__init__(backend, peaks, "sink-input", item) + + def _build_extra(self, item: dict): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + label = Gtk.Label(label="Output", xalign=0.0) + label.add_css_class("sb-field-label") + box.append(label) + self._sink_dropdown = Gtk.DropDown() + self._sink_dropdown.add_css_class("sb-device-dropdown") + self._dropdown_guard = False + self._sink_dropdown.connect("notify::selected", self._on_sink_selected) + box.append(self._sink_dropdown) + self._sync_sink_dropdown(item, self._sinks) + return box + + def _update_extra(self, item: dict, sinks: list[dict]) -> None: + self._sinks = sinks + self._sync_sink_dropdown(item, sinks) + + def _sync_sink_dropdown(self, item: dict, sinks: list[dict]) -> None: + names = [s["name"] for s in sinks] + if names != self._sink_names: + self._sink_names = names + self._dropdown_guard = True + self._sink_dropdown.set_model(Gtk.StringList.new([s["description"] for s in sinks])) + self._dropdown_guard = False + current = item.get("device") + idx = next((i for i, s in enumerate(sinks) if s["id"] == current), None) + if idx is not None and self._sink_dropdown.get_selected() != idx: + self._dropdown_guard = True + self._sink_dropdown.set_selected(idx) + self._dropdown_guard = False + + def _on_sink_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._dropdown_guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._sink_names): + self._backend.move_sink_input(self._id, self._sink_names[idx]) + + def _peak_target(self, item: dict) -> list[str] | None: + return [f"--monitor-stream={item['id']}"] diff --git a/desktopenvs/hyprlua/audio-panel/ui/applications_tab.py b/desktopenvs/hyprlua/audio-panel/ui/applications_tab.py new file mode 100644 index 0000000..3065246 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/applications_tab.py @@ -0,0 +1,40 @@ +"""Applications tab: horizontally-scrolling row of every app currently +playing audio (pactl sink-inputs), each with its own volume meter (or +independent L/R pair), mute, live peak bar, and an output-device dropdown.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.app_card import ApplicationCard +from ui.scroll_row import ScrollRow + + +class ApplicationsTab(Gtk.Box): + def __init__(self, backend, peaks) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._peaks = peaks + self._row = ScrollRow(self._make_card, empty_text="Nothing is playing audio right now") + self.append(self._row) + + def _make_card(self, item: dict, sinks: list[dict]) -> ApplicationCard: + return ApplicationCard(self._backend, self._peaks, item, sinks) + + def refresh(self) -> None: + def got_sinks(sinks: list[dict]) -> None: + def got_inputs(inputs: list[dict]) -> None: + self._row.sync(inputs, sinks) + self._backend.list_sink_inputs(got_inputs) + self._backend.list_sinks(got_sinks) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprlua/audio-panel/ui/card_base.py b/desktopenvs/hyprlua/audio-panel/ui/card_base.py new file mode 100644 index 0000000..96087c8 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/card_base.py @@ -0,0 +1,176 @@ +"""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) diff --git a/desktopenvs/hyprlua/audio-panel/ui/device_card.py b/desktopenvs/hyprlua/audio-panel/ui/device_card.py new file mode 100644 index 0000000..dbf3f78 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/device_card.py @@ -0,0 +1,116 @@ +"""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. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.card_base import MeterCard + +_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 + 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 + + def _update_extra(self, item: dict, cards_by_id: dict[int, dict], default_name: str) -> None: + self._cards_by_id = cards_by_id + 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) + self._default_btn.set_active(is_default) + self._default_btn.handler_unblock_by_func(self._on_default_toggled) + self._default_btn.set_label("Default" if is_default else "Set Default") + self._default_btn.set_sensitive(not is_default) + + def _on_default_toggled(self, btn: Gtk.ToggleButton) -> None: + if btn.get_active(): + self._set_default(self._item.get("name")) + + def _peak_target(self, item: dict) -> list[str] | None: + name = item.get("name") + if not name: + return None + if self._kind == "sink": + return [f"--device={name}{_MONITOR_SUFFIX}"] + return [f"--device={name}"] diff --git a/desktopenvs/hyprlua/audio-panel/ui/general_tab.py b/desktopenvs/hyprlua/audio-panel/ui/general_tab.py new file mode 100644 index 0000000..8d0fd29 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/general_tab.py @@ -0,0 +1,124 @@ +"""General Settings tab: system-wide defaults (default output/input device) +and this panel's own display settings, plus a read-only line confirming +what audio server pactl is actually talking to (pipewire-pulse vs a legacy +PulseAudio daemon) — the whole point of the "build for the future" ask.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +import config + + +class GeneralTab(Gtk.Box): + def __init__(self, backend, on_hologram_changed=None) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=16) + self.add_css_class("sb-tab-page") + self.add_css_class("sb-general") + self._backend = backend + self._on_hologram_changed = on_hologram_changed + self._sink_names: list[str] = [] + self._source_names: list[str] = [] + self._guard = False + + self.append(self._field("Default Output Device", self._build_output_dropdown())) + self.append(self._field("Default Input Device", self._build_input_dropdown())) + + if on_hologram_changed is not None: + self.append(self._build_hologram_row()) + + self._server_lbl = Gtk.Label(xalign=0.0) + self._server_lbl.add_css_class("sb-server-info") + self.append(self._server_lbl) + + @staticmethod + def _field(title: str, widget: Gtk.Widget) -> Gtk.Widget: + col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + col.add_css_class("sb-general-field") + label = Gtk.Label(label=title, xalign=0.0) + label.add_css_class("sb-field-label") + col.append(label) + col.append(widget) + return col + + def _build_output_dropdown(self) -> Gtk.DropDown: + self._output_dropdown = Gtk.DropDown() + self._output_dropdown.add_css_class("sb-device-dropdown") + self._output_dropdown.connect("notify::selected", self._on_output_selected) + return self._output_dropdown + + def _build_input_dropdown(self) -> Gtk.DropDown: + self._input_dropdown = Gtk.DropDown() + self._input_dropdown.add_css_class("sb-device-dropdown") + self._input_dropdown.connect("notify::selected", self._on_input_selected) + return self._input_dropdown + + def _build_hologram_row(self) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("sb-general-field") + label = Gtk.Label(label="Hologram Effects", xalign=0.0, hexpand=True) + label.add_css_class("sb-field-label") + row.append(label) + switch = Gtk.Switch(valign=Gtk.Align.CENTER) + switch.set_active(config.hologram_enabled()) + switch.connect("state-set", self._on_hologram_toggled) + row.append(switch) + return row + + def _on_hologram_toggled(self, _switch: Gtk.Switch, value: bool) -> bool: + config.set_hologram_enabled(value) + if self._on_hologram_changed is not None: + self._on_hologram_changed(value) + return False + + # -- data ----------------------------------------------------------------------- + def refresh(self) -> None: + def got_server(info: dict) -> None: + name = info.get("server_name") or "unknown" + version = info.get("server_version") or "" + self._server_lbl.set_label(f"Audio server: {name} {version}".rstrip()) + + def got_sinks(sinks: list[dict]) -> None: + def got_sources(sources: list[dict]) -> None: + self._sync_dropdown(self._output_dropdown, sinks, info.get("default_sink", ""), "_sink_names") + self._sync_dropdown(self._input_dropdown, sources, info.get("default_source", ""), "_source_names") + self._backend.list_sources(got_sources) + self._backend.list_sinks(got_sinks) + self._backend.get_server_info(got_server) + + def _sync_dropdown(self, dropdown: Gtk.DropDown, devices: list[dict], + default_name: str, cache_attr: str) -> None: + names = [d["name"] for d in devices] + if names != getattr(self, cache_attr): + setattr(self, cache_attr, names) + self._guard = True + dropdown.set_model(Gtk.StringList.new([d["description"] for d in devices])) + self._guard = False + idx = names.index(default_name) if default_name in names else None + if idx is not None and dropdown.get_selected() != idx: + self._guard = True + dropdown.set_selected(idx) + self._guard = False + + def _on_output_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._sink_names): + self._backend.set_default_sink(self._sink_names[idx]) + + def _on_input_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._source_names): + self._backend.set_default_source(self._source_names[idx]) + + def activate(self) -> None: + self.refresh() + + def deactivate(self) -> None: + pass diff --git a/desktopenvs/hyprlua/audio-panel/ui/input_tab.py b/desktopenvs/hyprlua/audio-panel/ui/input_tab.py new file mode 100644 index 0000000..abbd9d1 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/input_tab.py @@ -0,0 +1,48 @@ +"""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".""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.device_card import DeviceCard +from ui.scroll_row import ScrollRow + + +class InputTab(Gtk.Box): + def __init__(self, backend, peaks) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._peaks = peaks + self._default_name = "" + 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, + 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) + self._backend.get_defaults(got_defaults) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprlua/audio-panel/ui/meter.py b/desktopenvs/hyprlua/audio-panel/ui/meter.py new file mode 100644 index 0000000..fc2b605 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/meter.py @@ -0,0 +1,84 @@ +"""VolumeMeter — a single-channel volume control styled as a segmented meter +bar (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. + +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 + +COMMIT_DEBOUNCE_MS = 80 + + +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.HORIZONTAL, spacing=6) + 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 + + if label: + tag = Gtk.Label(label=label) + tag.add_css_class("sb-meter-label") + self.append(tag) + + self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 150, 1) + self.scale.set_value(value) + self.scale.set_draw_value(True) + self.scale.set_value_pos(Gtk.PositionType.RIGHT) + self.scale.set_hexpand(True) + self.scale.set_size_request(120, -1) + self.scale.add_css_class("sb-meter") + self.scale.add_mark(100, Gtk.PositionType.BOTTOM, None) + self.scale.connect("value-changed", self._on_value_changed) + 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) + + self.append(self.scale) + + def _on_value_changed(self, _scale) -> None: + self._pending = int(self.scale.get_value()) + 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) + + def set_muted(self, muted: bool) -> None: + if muted: + self.scale.add_css_class("sb-meter-muted") + else: + self.scale.remove_css_class("sb-meter-muted") diff --git a/desktopenvs/hyprlua/audio-panel/ui/output_tab.py b/desktopenvs/hyprlua/audio-panel/ui/output_tab.py new file mode 100644 index 0000000..330ae63 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/output_tab.py @@ -0,0 +1,49 @@ +"""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. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.device_card import DeviceCard +from ui.scroll_row import ScrollRow + + +class OutputTab(Gtk.Box): + def __init__(self, backend, peaks) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._peaks = peaks + self._default_name = "" + 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, + 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) + self._backend.get_defaults(got_defaults) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprlua/audio-panel/ui/peakbar.py b/desktopenvs/hyprlua/audio-panel/ui/peakbar.py new file mode 100644 index 0000000..a8f9eae --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/peakbar.py @@ -0,0 +1,25 @@ +"""PeakBar — a thin live amplitude indicator (see backend/peaklevel.py). Shows +whether audio is actually reaching the mix, independent of the volume slider +above it, so a silent app/device reads differently from one that's just quiet: +a stuck/crashed stream or a bad route shows a flat bar even at 100% volume.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class PeakBar: + def __init__(self) -> None: + self.widget = Gtk.LevelBar() + self.widget.add_css_class("sb-peakbar") + self.widget.set_min_value(0.0) + self.widget.set_max_value(1.0) + self.widget.set_value(0.0) + self.widget.set_size_request(-1, 6) + self.widget.set_hexpand(True) + + def set_level(self, level: float) -> None: + self.widget.set_value(max(0.0, min(1.0, level))) diff --git a/desktopenvs/hyprlua/audio-panel/ui/scroll_row.py b/desktopenvs/hyprlua/audio-panel/ui/scroll_row.py new file mode 100644 index 0000000..dfd7c18 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/scroll_row.py @@ -0,0 +1,66 @@ +"""ScrollRow — a horizontally-scrolling row of id-keyed cards, diffed in place +on every refresh instead of torn down and rebuilt. That matters here for two +reasons: rebuilding would fight an in-progress VolumeMeter drag (see that +widget's own drag-guard), and it would tear down/respawn each card's live +`parec` peak-monitor process on every single `pactl subscribe` event instead +of only when a device/app actually appears or disappears. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class ScrollRow(Gtk.ScrolledWindow): + def __init__(self, make_card, empty_text: str = "Nothing here") -> None: + super().__init__() + self.set_policy(Gtk.PolicyType.EXTERNAL, Gtk.PolicyType.NEVER) + self.add_css_class("sb-scroll-row") + self._make_card = make_card + self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + self._row.add_css_class("sb-row") + self.set_child(self._row) + self._cards: dict = {} + self._empty_label = Gtk.Label(label=empty_text) + self._empty_label.add_css_class("sb-empty") + self._active = False + + def sync(self, items: list[dict], *extra) -> None: + seen = set() + for item in items: + iid = item["id"] + seen.add(iid) + card = self._cards.get(iid) + if card is None: + card = self._make_card(item, *extra) + self._cards[iid] = card + self._row.append(card) + if self._active: + card.start_peak() + else: + card.update(item, *extra) + for iid in [i for i in self._cards if i not in seen]: + card = self._cards.pop(iid) + card.stop_peak() + self._row.remove(card) + self._sync_empty_state() + + def _sync_empty_state(self) -> None: + empty = not self._cards + if empty and self._empty_label.get_parent() is None: + self._row.append(self._empty_label) + elif not empty and self._empty_label.get_parent() is not None: + self._row.remove(self._empty_label) + + def activate(self) -> None: + self._active = True + for card in self._cards.values(): + card.start_peak() + + def deactivate(self) -> None: + self._active = False + for card in self._cards.values(): + card.stop_peak() diff --git a/desktopenvs/hyprlua/audio-panel/ui/tabs.py b/desktopenvs/hyprlua/audio-panel/ui/tabs.py new file mode 100644 index 0000000..69513ad --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/tabs.py @@ -0,0 +1,48 @@ +"""TabSwitcher — a row of mutually-exclusive toggle buttons driving a +Gtk.Stack, styled as a segmented control (see .sb-tabbar/.sb-tab in +style/style.css). Plain manual mutual exclusion rather than Gtk.CheckButton +grouping, since that's simplest for a small fixed set of tabs.""" + +from __future__ import annotations + +from typing import Callable + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class TabSwitcher(Gtk.Box): + def __init__(self, tabs: list[tuple[str, str, Gtk.Widget]], stack: Gtk.Stack, + on_changed: Callable[[str], None]) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self.add_css_class("sb-tabbar") + self._stack = stack + self._on_changed = on_changed + self._buttons: dict[str, Gtk.ToggleButton] = {} + + first = None + for name, label, _widget in tabs: + btn = Gtk.ToggleButton(label=label) + btn.add_css_class("sb-tab") + btn.connect("toggled", self._make_handler(name)) + self.append(btn) + self._buttons[name] = btn + if first is None: + first = name + if first is not None: + self._buttons[first].set_active(True) + + def _make_handler(self, name: str): + def handler(btn: Gtk.ToggleButton) -> None: + if not btn.get_active(): + if not any(b.get_active() for b in self._buttons.values()): + btn.set_active(True) # one tab must always stay selected + return + for other_name, other_btn in self._buttons.items(): + if other_name != name and other_btn.get_active(): + other_btn.set_active(False) + self._stack.set_visible_child_name(name) + self._on_changed(name) + return handler diff --git a/desktopenvs/hyprlua/audio-panel/window.py b/desktopenvs/hyprlua/audio-panel/window.py new file mode 100644 index 0000000..33d2ae5 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/window.py @@ -0,0 +1,189 @@ +"""The popup: a content-sized floating layer-shell panel anchored top-centre, +matching transmitter-panel/astro-menu's window.py idiom — dismissed with the +launcher toggle, Esc, or the ✕ button, no click-outside-to-close. + + Gtk.Window (layer TOP, anchored TOP -> horizontally centred, height = content) + └ Gtk.Overlay + main : .sb-panel (title, tab switcher, Gtk.Stack of the four tabs) + over : hologram overlay + over : ✕ close button (top-right) + +Owns the single AudioBackend and PeakMonitorManager shared by every tab — +one `pactl subscribe` process and one live-metering manager for the whole +panel, not one per tab. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import config +from backend.peaklevel import PeakMonitorManager +from backend.pipewire import AudioBackend +from lib.hologram import HologramOverlay +from ui.applications_tab import ApplicationsTab +from ui.general_tab import GeneralTab +from ui.input_tab import InputTab +from ui.output_tab import OutputTab +from ui.tabs import TabSwitcher + +PANEL_WIDTH = 900 +EDGE_MARGIN = 28 + + +class AudioPanelWindow(Gtk.ApplicationWindow): + def __init__(self, app: Gtk.Application) -> None: + super().__init__(application=app) + self.set_name("audio-panel-window") + self.add_css_class("sb-window") + self.set_decorated(False) + + self._init_layer_shell() + + self._backend = AudioBackend(self._on_backend_changed) + self._peaks = PeakMonitorManager() + + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.root.set_name("panel-root") + self.root.add_css_class("sb-panel") + self.root.set_size_request(PANEL_WIDTH, -1) + + header = Gtk.CenterBox() + header.add_css_class("sb-header") + title = Gtk.Label(label="Audio Panel", xalign=0.0) + title.add_css_class("sb-title") + header.set_start_widget(title) + self.root.append(header) + + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self._stack.set_transition_duration(150) + + 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._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, + "general": self._general_tab, + } + tab_order = [ + ("applications", "Applications", self._applications_tab), + ("input", "Input Devices", self._input_tab), + ("output", "Output Devices", self._output_tab), + ("general", "General Settings", self._general_tab), + ] + for name, label, widget in tab_order: + self._stack.add_titled(widget, name, label) + self._active_tab_name = tab_order[0][0] + + self._tabswitcher = TabSwitcher(tab_order, self._stack, self._on_tab_changed) + self.root.append(self._tabswitcher) + self.root.append(self._stack) + + overlay = Gtk.Overlay() + overlay.set_child(self.root) + + self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root) + overlay.add_overlay(self._hologram.widget) + + close = Gtk.Button(label="✕") + close.add_css_class("close-btn") + close.set_halign(Gtk.Align.END) + close.set_valign(Gtk.Align.START) + close.connect("clicked", lambda *_a: self.hide_panel()) + overlay.add_overlay(close) + self.set_child(overlay) + + key = Gtk.EventControllerKey() + key.connect("key-pressed", self._on_key) + self.add_controller(key) + + self._last_tick: float | None = None + self._tick_id: int | None = None + self.set_visible(False) + + # -- layer shell -------------------------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "audio-panel") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND) + LayerShell.set_anchor(self, LayerShell.Edge.TOP, True) + LayerShell.set_margin(self, LayerShell.Edge.TOP, EDGE_MARGIN) + + # -- visibility ----------------------------------------------------------------- + def show_panel(self) -> None: + self.set_visible(True) + self.present() + if self._hologram.enabled and self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + self._hologram.start_intro() + self._refresh_all() + self._tabs[self._active_tab_name].activate() + + def hide_panel(self) -> None: + self._tabs[self._active_tab_name].deactivate() + self._peaks.stop_all() + if self._hologram.enabled and self._tick_id is not None: + self._hologram.start_outro(self._finish_hide) + else: + self._finish_hide() + + def _finish_hide(self) -> None: + self.set_visible(False) + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None + + def toggle(self) -> None: + if self.get_visible(): + self.hide_panel() + else: + self.show_panel() + + # -- tabs --------------------------------------------------------------------- + def _on_tab_changed(self, name: str) -> None: + if name == self._active_tab_name: + return + self._tabs[self._active_tab_name].deactivate() + self._active_tab_name = name + self._tabs[name].activate() + + def _refresh_all(self) -> None: + for tab in self._tabs.values(): + tab.refresh() + + def _on_backend_changed(self) -> None: + if not self.get_visible(): + return + self._refresh_all() + + def _on_hologram_setting_changed(self, enabled: bool) -> None: + self._hologram.enabled = enabled + if enabled and self.get_visible() and self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + + # -- hologram tick -------------------------------------------------------------- + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._hologram.tick(dt) + return True + + def _on_key(self, _c, keyval, _kc, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self.hide_panel() + return True + return False diff --git a/desktopenvs/hyprlua/config-updater/updater.conf b/desktopenvs/hyprlua/config-updater/updater.conf index fad0048..123b197 100644 --- a/desktopenvs/hyprlua/config-updater/updater.conf +++ b/desktopenvs/hyprlua/config-updater/updater.conf @@ -12,6 +12,7 @@ SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprlua # ── deployed as ~/.config/ ───────────────────────────────────────────── config alacritty config astal-menu +config audio-panel config btop config gtk-3.0 config hypr except usr @@ -49,6 +50,8 @@ ignore waybar # present but inactive; eww bar is used instead ignore mnotifd-theme # hand-maintained plain-theme seed for regen-beacon.sh, # not deployed to ~/.config itself ignore mnotifhist-theme +ignore audio-panel-theme # hand-maintained plain-theme seed for + # regen-audio-panel.sh, not deployed to ~/.config itself # hypr/usr/ contains device-specific lua files (monitors, binds, input, etc.). # They are excluded from automated syncs to preserve per-device customisations. # Deploy manually on a new device: diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.yuck b/desktopenvs/hyprlua/eww-nobattery/eww.yuck index 3789842..1bb91ea 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.yuck +++ b/desktopenvs/hyprlua/eww-nobattery/eww.yuck @@ -32,10 +32,12 @@ (defwidget sidestuff [] (box :class "sidestuff" :orientation "h" :space-evenly false :halign "end" (box :class "music" {"󰛳 ${IP}"}) + ; Volume slider — 󰓃 icon, drag to adjust, click to open audio-panel + ; (the PipeWire mixer panel — Applications/Output/Input/General tabs) (metric :label "󰓃" :value volume :onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%" - :onclick "killall pavucontrol || hyprctl eval 'hl.dsp.exec_cmd(\"[tag +mixer] pavucontrol\")'") + :onclick "~/.config/scripts/audio-panel.sh") (box :tooltip {disks} (metric :label "" diff --git a/desktopenvs/hyprlua/eww-touch/eww.yuck b/desktopenvs/hyprlua/eww-touch/eww.yuck index 776b261..1bfc8ea 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.yuck +++ b/desktopenvs/hyprlua/eww-touch/eww.yuck @@ -29,10 +29,12 @@ (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) ; notification-history launcher (button :class "music notif-launcher" :onclick "~/.config/scripts/mnotifhist.sh" {"󰂚"}) + ; Volume slider — 󰓃 icon, drag to adjust, tap to open audio-panel + ; (the PipeWire mixer panel — Applications/Output/Input/General tabs) (metric :label "󰓃 " :value volume :onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%" - :onclick "killall pavucontrol || hyprctl dispatch exec \[tag +mixer\] pavucontrol") + :onclick "~/.config/scripts/audio-panel.sh") ) ) diff --git a/desktopenvs/hyprlua/eww/eww.yuck b/desktopenvs/hyprlua/eww/eww.yuck index 97bf4db..9f1bdb2 100644 --- a/desktopenvs/hyprlua/eww/eww.yuck +++ b/desktopenvs/hyprlua/eww/eww.yuck @@ -73,12 +73,12 @@ (box :class "sidestuff" :orientation "h" :space-evenly false :halign "end" ; IP address badge — refreshed every 5s, shows current LAN/VPN IP (box :class "music" {"󰛳 ${IP}"}) - ; Volume slider — 󰓃 icon, drag to adjust, click to open pavucontrol mixer - ; hyprctl eval is used to run Lua dispatch inline so the window gets the +mixer tag + ; Volume slider — 󰓃 icon, drag to adjust, click to open audio-panel + ; (the PipeWire mixer panel — Applications/Output/Input/General tabs) (metric :label "󰓃" :value volume :onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%" - :onclick "killall pavucontrol || hyprctl eval 'hl.dsp.exec_cmd(\"[tag +mixer] pavucontrol\")'") + :onclick "~/.config/scripts/audio-panel.sh") ; Disk usage gauge — tooltip shows per-disk breakdown from dysk ; EWW_DISK is a built-in magic variable providing filesystem stats (box diff --git a/desktopenvs/hyprlua/hypr/usr/autostart.lua b/desktopenvs/hyprlua/hypr/usr/autostart.lua index c614454..bb4c4f0 100644 --- a/desktopenvs/hyprlua/hypr/usr/autostart.lua +++ b/desktopenvs/hyprlua/hypr/usr/autostart.lua @@ -17,6 +17,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") hl.exec_cmd("hyprpaper") hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/astal-menu-start.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/audio-panel-start.sh") hl.exec_cmd("blueman-applet") hl.exec_cmd("blueman-tray") hl.exec_cmd("hypridle") diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index 3c54adf..8af3cc4 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -318,6 +318,14 @@ hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d")) hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh toggle top"), { release = true }) hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh appdrawer")) +-------------------- +---- AUDIO-PANEL --- +-------------------- + +-- PipeWire mixer panel: Applications / Output / Input / General tabs. +-- Super+S is already the pavucontrol fallback mixer; this is the primary one. +hl.bind(mainMod .. " + SHIFT + S", hl.dsp.exec_cmd("~/.config/scripts/audio-panel.sh"), { release = true }) + -------------------- ---- SCREENSHOT ---- -------------------- diff --git a/desktopenvs/hyprlua/scripts/audio-panel-start.sh b/desktopenvs/hyprlua/scripts/audio-panel-start.sh new file mode 100755 index 0000000..ba818fb --- /dev/null +++ b/desktopenvs/hyprlua/scripts/audio-panel-start.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Resident launcher for audio-panel, the PipeWire mixer panel. Same +# LD_PRELOAD requirement and rationale as beacon-start.sh/horizon-dock-start.sh/ +# transmitter-panel-start.sh: gtk4-layer-shell must load before +# libwayland-client, which isn't guaranteed under PyGObject. + +APP="${HOME}/.config/audio-panel/main.py" +SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')" +if [[ -n "${SO:-}" ]]; then + export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi + +exec python3 "$APP" "$@" diff --git a/desktopenvs/hyprlua/scripts/audio-panel.sh b/desktopenvs/hyprlua/scripts/audio-panel.sh new file mode 100755 index 0000000..94c9f87 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/audio-panel.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Toggle audio-panel (the PipeWire mixer panel). Forwards a verb to the +# resident daemon over D-Bus; if the daemon isn't running yet, starts it first. +# Mirrors horizon-dock.sh/transmitter-panel.sh. +# +# audio-panel.sh -> --toggle (default) +# audio-panel.sh show -> --show +# audio-panel.sh hide -> --hide +# +# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not +# abort the script before it forwards the verb.) + +BUS="eu.abdelbaki.audiopanel" +OBJ="/eu/abdelbaki/audiopanel" +APP="${HOME}/.config/audio-panel/main.py" + +case "${1:-toggle}" in + show) VERB="--show"; ACTION="show" ;; + hide) VERB="--hide"; ACTION="hide" ;; + *) VERB="--toggle"; ACTION="toggle" ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null +fi + +"${HOME}/.config/scripts/audio-panel-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +exec python3 "$APP" "$VERB" diff --git a/desktopenvs/hyprlua/scripts/regen-audio-panel.sh b/desktopenvs/hyprlua/scripts/regen-audio-panel.sh new file mode 100755 index 0000000..c4849a4 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/regen-audio-panel.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Regenerates hyprlua's audio-panel from hyprdrive's supersonic-booster — the +# same PipeWire mixer panel (Applications / Output Devices / Input Devices / +# General Settings tabs), plain-themed instead of hologram-styled. +# +# Run BY HAND whenever hyprdrive's supersonic-booster changes and you want +# those changes reflected here. Never wired into sysupdate.sh. Modeled +# directly on regen-beacon.sh — see that script for the full rationale; the +# short version: +# +# 1. rm -rf + cp -r the hyprdrive source tree in fresh (not an incremental +# patch), dropping style/ and any __pycache__. +# 2. Copies in the hand-maintained plain stylesheet from +# desktopenvs/hyprlua/audio-panel-theme/ (never written to by this +# script — edit that by hand for a different plain look). +# 3. Flips the copied config.py's hologram default to False (the real +# hyprdrive supersonic-booster defaults to True and is never touched by +# this script). +# 4. Runs one ordered rename table over every file's content AND over +# filenames: supersonic-booster -> audio-panel, plus the CamelCase +# class-name and UI-label variants actually present in the source +# (checked empirically, not guessed — see the grep in the commit that +# introduced this script). Also rewrites the literal "hyprdrive" -> +# "hyprlua" (docstrings, and critically anything that still points at +# desktopenvs/hyprdrive/scripts/ after the rename). +# +# The rename table intentionally does NOT touch the `.sb-*` CSS class names +# supersonic-booster/audio-panel use — that prefix doesn't contain +# "supersonic-booster"/"supersonicbooster" as a substring, so the audio-panel +# plain stylesheet targets them unchanged. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HL="$(cd "$SCRIPT_DIR/.." && pwd)" +HD="$(cd "$HL/../hyprdrive" && pwd)" + +# -- the rename table --------------------------------------------------------- +# Order matters: longest/most-specific compounds first, so e.g. +# "SupersonicBoosterApp" resolves via its own rule before the bare +# "supersonic-booster" rule would otherwise mangle it mid-word. +rename_stream() { + sed \ + -e 's/SupersonicBoosterApp/AudioPanelApp/g' \ + -e 's/SupersonicWindow/AudioPanelWindow/g' \ + -e 's/Supersonic Booster/Audio Panel/g' \ + -e 's/supersonic-window/audio-panel-window/g' \ + -e 's/supersonicbooster/audiopanel/g' \ + -e 's/supersonic-booster/audio-panel/g' \ + -e 's/hyprdrive/hyprlua/g' +} + +rename_file_in_place() { + local f="$1" tmp + tmp="$(mktemp)" + rename_stream < "$f" > "$tmp" + mv "$tmp" "$f" +} + +rename_tree_contents() { + local dir="$1" f + while IFS= read -r -d '' f; do + rename_file_in_place "$f" + done < <(find "$dir" -type f -print0) +} + +flip_default() { + # Flip `"": True` -> `"": False` in a copied config.py's + # _DEFAULTS dict. No-op (silently) if the key isn't present. + local config_py="$1" key="$2" + sed -i "s/\"${key}\": *True/\"${key}\": False/" "$config_py" +} + +# -- per-component regeneration ------------------------------------------------ +regen_component() { + local src="$1" dst="$2" theme_dir="$3" + shift 3 + local flip_keys=("$@") + + rm -rf "$dst" + cp -r "$src" "$dst" + rm -rf "$dst/style" "$dst/__pycache__" "$dst/lib/__pycache__" "$dst/backend/__pycache__" "$dst/ui/__pycache__" + mkdir -p "$dst/style" + cp "$theme_dir/_colors.css" "$theme_dir/style.css" "$dst/style/" + + local key + for key in "${flip_keys[@]}"; do + flip_default "$dst/config.py" "$key" + done + + rename_tree_contents "$dst" + + # rename any files/dirs whose *name* itself needs the substitution + # (deepest paths first, so renaming a parent dir doesn't orphan a path + # to a file inside it that find already queued) + local p renamed + while IFS= read -r -d '' p; do + renamed="$(dirname "$p")/$(basename "$p" | rename_stream)" + if [[ "$p" != "$renamed" ]]; then + mv "$p" "$renamed" + fi + done < <(find "$dst" -depth -print0) + return 0 +} + +regen_component "$HD/supersonic-booster" "$HL/audio-panel" "$HL/audio-panel-theme" hologram + +# -- launcher / toggle scripts ------------------------------------------------- +regen_script() { + local src="$1" dst + dst="$SCRIPT_DIR/$(basename "$src" | rename_stream)" + rename_stream < "$src" > "$dst" + chmod +x "$dst" +} + +regen_script "$HD/scripts/supersonic-booster-start.sh" +regen_script "$HD/scripts/supersonic-booster.sh" + +echo "Regenerated:" +echo " $HL/audio-panel/" +echo " $SCRIPT_DIR/audio-panel-start.sh, audio-panel.sh"