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_01TkkH9eiCBfpWysUXAD9bDGmain
parent
b92a43c8ea
commit
bda4640b54
|
|
@ -47,6 +47,8 @@ USER_FILES=(
|
||||||
"desktopenvs/hyprdrive/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css"
|
"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/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/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/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
|
||||||
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
|
"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"
|
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ config mimeapps.list
|
||||||
config orbit-menu
|
config orbit-menu
|
||||||
config scripts
|
config scripts
|
||||||
config station-bar
|
config station-bar
|
||||||
|
config supersonic-booster
|
||||||
config transmitter-panel
|
config transmitter-panel
|
||||||
config vicinae
|
config vicinae
|
||||||
config xfce4
|
config xfce4
|
||||||
|
|
|
||||||
|
|
@ -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/orbit-menu-start.sh")
|
||||||
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprdrive/scripts/horizon-dock-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/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-applet")
|
||||||
hl.exec_cmd("blueman-tray")
|
hl.exec_cmd("blueman-tray")
|
||||||
hl.exec_cmd("hypridle")
|
hl.exec_cmd("hypridle")
|
||||||
|
|
|
||||||
|
|
@ -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 .. " + 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 })
|
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 ----
|
---- SCREENSHOT ----
|
||||||
--------------------
|
--------------------
|
||||||
|
|
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -429,11 +429,21 @@ class StationBar(Gtk.Window):
|
||||||
pod.append(self._tray_box)
|
pod.append(self._tray_box)
|
||||||
self._right.append(pod)
|
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 = Gtk.Button(label=f"{ICON_VOLUME} --%")
|
||||||
self._volume_btn.add_css_class("station-badge")
|
self._volume_btn.add_css_class("station-badge")
|
||||||
self._volume_btn.add_css_class("station-volume")
|
self._volume_btn.add_css_class("station-volume")
|
||||||
self._volume_btn.set_has_frame(False)
|
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 = Gtk.EventControllerScroll()
|
||||||
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
|
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
|
||||||
scroll.connect("scroll", self._on_volume_scroll)
|
scroll.connect("scroll", self._on_volume_scroll)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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']}"]
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)))
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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']}"]
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)))
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -12,6 +12,7 @@ SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprlua
|
||||||
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
|
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
|
||||||
config alacritty
|
config alacritty
|
||||||
config astal-menu
|
config astal-menu
|
||||||
|
config audio-panel
|
||||||
config btop
|
config btop
|
||||||
config gtk-3.0
|
config gtk-3.0
|
||||||
config hypr except usr
|
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,
|
ignore mnotifd-theme # hand-maintained plain-theme seed for regen-beacon.sh,
|
||||||
# not deployed to ~/.config itself
|
# not deployed to ~/.config itself
|
||||||
ignore mnotifhist-theme
|
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.).
|
# hypr/usr/ contains device-specific lua files (monitors, binds, input, etc.).
|
||||||
# They are excluded from automated syncs to preserve per-device customisations.
|
# They are excluded from automated syncs to preserve per-device customisations.
|
||||||
# Deploy manually on a new device:
|
# Deploy manually on a new device:
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,12 @@
|
||||||
(defwidget sidestuff []
|
(defwidget sidestuff []
|
||||||
(box :class "sidestuff" :orientation "h" :space-evenly false :halign "end"
|
(box :class "sidestuff" :orientation "h" :space-evenly false :halign "end"
|
||||||
(box :class "music" {" ${IP}"})
|
(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 ""
|
(metric :label ""
|
||||||
:value volume
|
:value volume
|
||||||
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
: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
|
(box
|
||||||
:tooltip {disks}
|
:tooltip {disks}
|
||||||
(metric :label ""
|
(metric :label ""
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,12 @@
|
||||||
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""})
|
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""})
|
||||||
; notification-history launcher
|
; notification-history launcher
|
||||||
(button :class "music notif-launcher" :onclick "~/.config/scripts/mnotifhist.sh" {""})
|
(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 " "
|
(metric :label " "
|
||||||
:value volume
|
:value volume
|
||||||
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
||||||
:onclick "killall pavucontrol || hyprctl dispatch exec \[tag +mixer\] pavucontrol")
|
:onclick "~/.config/scripts/audio-panel.sh")
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,12 @@
|
||||||
(box :class "sidestuff" :orientation "h" :space-evenly false :halign "end"
|
(box :class "sidestuff" :orientation "h" :space-evenly false :halign "end"
|
||||||
; IP address badge — refreshed every 5s, shows current LAN/VPN IP
|
; IP address badge — refreshed every 5s, shows current LAN/VPN IP
|
||||||
(box :class "music" {" ${IP}"})
|
(box :class "music" {" ${IP}"})
|
||||||
; Volume slider — icon, drag to adjust, click to open pavucontrol mixer
|
; Volume slider — icon, drag to adjust, click to open audio-panel
|
||||||
; hyprctl eval is used to run Lua dispatch inline so the window gets the +mixer tag
|
; (the PipeWire mixer panel — Applications/Output/Input/General tabs)
|
||||||
(metric :label ""
|
(metric :label ""
|
||||||
:value volume
|
:value volume
|
||||||
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
: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
|
; Disk usage gauge — tooltip shows per-disk breakdown from dysk
|
||||||
; EWW_DISK is a built-in magic variable providing filesystem stats
|
; EWW_DISK is a built-in magic variable providing filesystem stats
|
||||||
(box
|
(box
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ hl.on("hyprland.start", function()
|
||||||
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
||||||
hl.exec_cmd("hyprpaper")
|
hl.exec_cmd("hyprpaper")
|
||||||
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/astal-menu-start.sh")
|
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-applet")
|
||||||
hl.exec_cmd("blueman-tray")
|
hl.exec_cmd("blueman-tray")
|
||||||
hl.exec_cmd("hypridle")
|
hl.exec_cmd("hypridle")
|
||||||
|
|
|
||||||
|
|
@ -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 .. " + 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"))
|
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 ----
|
---- SCREENSHOT ----
|
||||||
--------------------
|
--------------------
|
||||||
|
|
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue