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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkkH9eiCBfpWysUXAD9bDG
main
Amir Alexander Abdelbaki 2026-07-29 15:41:57 +02:00
parent b92a43c8ea
commit bda4640b54
62 changed files with 4786 additions and 6 deletions

View File

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

View File

@ -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

View File

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

View File

@ -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 ----
--------------------

View File

@ -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" "$@"

View File

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

View File

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

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -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=<id>` taps a
single application's own stream (Applications tab); `parec --device=<name>`
taps a physical device directly (`<sink>.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)

View File

@ -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])

View File

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

View File

@ -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()

View File

@ -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())

View File

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

View File

@ -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;

View File

@ -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; }

View File

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

View File

@ -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']}"]

View File

@ -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()

View File

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

View File

@ -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}"]

View File

@ -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

View File

@ -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()

View File

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

View File

@ -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()

View File

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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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; }

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -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=<id>` taps a
single application's own stream (Applications tab); `parec --device=<name>`
taps a physical device directly (`<sink>.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)

View File

@ -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])

View File

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

View File

@ -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()

View File

@ -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())

View File

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

View File

@ -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;

View File

@ -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; }

View File

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

View File

@ -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']}"]

View File

@ -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()

View File

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

View File

@ -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}"]

View File

@ -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

View File

@ -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()

View File

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

View File

@ -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()

View File

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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -12,6 +12,7 @@ SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprlua
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
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:

View File

@ -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 ""

View File

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

View File

@ -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

View File

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

View File

@ -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 ----
--------------------

View File

@ -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" "$@"

View File

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

View File

@ -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 `"<key>": True` -> `"<key>": 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"