From a8cd23546dc65ab7cb850b4cff5a02ed7bc56ae1 Mon Sep 17 00:00:00 2001 From: server2 Date: Tue, 6 Oct 2026 15:41:48 +0200 Subject: [PATCH] docs: README, AGENTS.md, test tools in tools/ - README: features, layout, requirements, install, IPC, runtime files, limits - AGENTS.md: the user's ground rules (branch + approval, never touch real players' sound), architecture, measured performance rules, type-check and test loop, desktop test driver and its pitfalls, open threads - tools/: amane-remote.sh and fake_mpris.py, so the test loop doesn't depend on the Hermes skill - .gitignore: the Cargo files amane generates --- .gitignore | 4 + AGENTS.md | 117 +++++++++++++++++++ README.md | 77 +++++++++++++ tools/amane-remote.sh | 142 +++++++++++++++++++++++ tools/fake_mpris.py | 256 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 596 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100755 tools/amane-remote.sh create mode 100755 tools/fake_mpris.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4280ffc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# written by amane compile / amane startup, with paths for one machine only +Cargo.toml +Cargo.lock +target/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca90f61 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# AGENTS.md + +Notes for agents continuing work on the Magic Island shell. Read README.md first for what it does and how it's laid out. + +## Ground rules from the user + +- **Branch per big change, and ask before merging.** Create a new branch for each big change, show the result, and merge into `master` only after the user explicitly approves. Small follow-ups the user asks for in the same breath can go on the same branch. +- **Never touch the user's real players' sound.** Cider and Firefox are real; their volume, equalizer and output are the user's. Before any `island preset|output` call or volume change, run `amane ipc call island status` and make sure `player=` is a fake test player. Test audio goes to inaudible null sinks (see Testing). +- **The desktop is reached only by `ssh -o BatchMode=yes fabian@192.168.2.169`** (Hyprland, Intel iGPU, monitors DP-2 and eDP-1). Don't probe the network, SSH keys or other hosts; if SSH fails, report it and wait. +- **Leave the desktop clean:** stop fake players and null sinks after testing, and restart the shell without frame logging (see "After testing"). +- Be honest in summaries about what was and wasn't checked on the real desktop. + +## Constraints of Amane itself + +- The shell can only depend on `amane` and the Rust standard library: `amane compile` generates `Cargo.toml` and no other crates can be added. External tools run as processes (`std::process::Command`, `amane::output`). +- Amane docs: `/root/amane/docs/src/*.md` on the dev host; library source in `/root/amane/src/`. +- Amane redraws the whole window every frame, so every effect in the view is paid at the frame rate. + +## Architecture + +| Piece | Kind | Job | +|---|---|---| +| `island.rs` `Island` | input-only Service | `open: Animation` (0 pill, 1 card), `expanded`, `selected` player (bus name, `None` follows the playing one), open dropdown, time-bar scrub. Also the view and the `island` IPC handler (`command`). | +| `sound.rs` `Sound` | Service, 1 s poll | `pactl list sinks` + `sink-inputs` → each player's streams (matched by process ancestry, then app name), volume, mute, output, equalizer. Curves saved by player identity to `~/.config/amane/island-eq`. | +| `spectrum.rs` `Spectrum` | Service, 200 ms check | While the shown player plays: `pw-record --target \| cava -p $XDG_RUNTIME_DIR/magic-island/cava.conf` → 24 levels. Stops when it pauses. | +| `art.rs` `Art` | Service, 1 s poll | Cover URL → local file (curl for https), copied to `palette-cover`, which `Palette` (opened in `main.rs`, 8 colours) watches. | +| `parse.rs` | plain functions, no amane | pactl/cava/proc parsing, filter-chain config, `pw-cli` params, EQ store, middle ellipsis. Unit-tested on its own. | + +The equalizer: one `setsid -f pipewire -c eq-.conf` process per player with `libpipewire-module-filter-chain`, 10 biquads (low shelf 31 Hz, peaking 63 Hz–8 kHz, high shelf 16 kHz). The player's streams are moved into sink `magic_island_eq.`; gains change live with `pw-cli set-param magic_island_eq. Props '{ params = [ "b1:Gain" 6.0 ... ] }'`. A player only gets one when its curve or output is first changed, or it has a saved non-flat curve. + +### The view (island.rs `view`) + +- One `LayerWindow` (namespace `magic-island`, `Layer::Overlay` because the user's windows are fullscreen) sized for the open card; an `InputArea` lets clicks outside the island through. +- `phases(open)` splits the one linear 560 ms timeline: pill fades out over 0–0.18, the empty shape grows over 0–0.6 (cubic ease-out), the card fades in over 0.6–1 (smoothstep). Closing runs it backwards. At most one face (pill or card) is built per frame. +- `veiled()` fades a face by laying the backdrop gradient over it with alpha `1 - shown` instead of `.opacity()`: same picture, no GPU layer. +- `backdrop_colors()` / `backdrop()`: one 150° three-stop gradient from the palette (dominant, `colors()[1]`, `background()`), each darkened by `deepen()` to a max brightness of 0.22 / 0.15 / 0.07. +- `pill_levels()`: 7 bars, bar *i* = loudest of spectrum slice *i* of 7, × 1.4, linear, drawn up and down from the middle line. +- `ring()`: 40 candles around the CD, levels from `mirrored()` (bass at the top, highs down both sides), `powf(0.75)`. Drawn as 3 paths (glow, body, flame tips). +- Moving the pointer off the island collapses it (`on_hover`). + +## Performance rules (measured on the Intel iGPU) + +Current numbers: card open with music 60 fps, about 3.1 ms GPU per frame; pill with music 60 fps, about 1.7 ms GPU, about 9% of one core for shell + cava; paused, about 1 frame a second. Opening has 1 frame over 20 ms (where the card first appears), closing 0. + +- **Measure before and after every visual change** with `tools/amane-remote.sh HOST fps` and `anim island`, baseline first. +- Don't resize a shape with the full card inside it; grow the empty shape, then fade the content in. +- `.clip()` and `.opacity(<1)` each add a GPU layer. A clip over the whole card once took the iGPU from 47 to 9 fps. +- Gradients are expensive in numbers: a gradient background plus 10 gradient slider fills halved the frame rate; the single backdrop gradient is affordable. +- A veil over a gradient must be the same gradient at the same size as the shape (inset 1 px for the rim): Amane places a gradient by each rectangle's own bounds, so a smaller veil shows patches. +- Blurred shadows cost every frame: the drop shadow only exists on the pill and during the animation. +- Many shapes cost: draw repeated marks (candles, bars) as one `Path` per look. +- Streaming data needs `amane::request_frame()` in the view while levels are live; a Service write alone only got about 11 redraws a second from Hyprland. +- `Rectangle::child` holds one child; stack layers with `Stack`. + +## Develop + +1. Type-check on the dev host without the desktop. A scratch crate outside the repo (don't commit a `Cargo.toml`, Amane generates its own): + + ```toml + [package] + name = "amane-shell" + version = "0.1.0" + edition = "2024" + + [[bin]] + name = "amane-shell" + path = '/root/amane-config/src/main.rs' + + [dependencies] + amane = { path = '/root/amane' } + + [workspace] + ``` + + Then `cargo clippy` in that crate. (It is at `/root/.hermes/cache/scratch/amane-check/` on the dev host.) + +2. Unit tests: `rustc --edition 2024 --test src/parse.rs -o /tmp/parse_test && /tmp/parse_test` (5 tests). + +## Testing on the desktop + +`tools/amane-remote.sh HOST COMMAND`; the full command list is in the comment at the top of the script. The main ones: + +```sh +H=fabian@192.168.2.169 +tools/amane-remote.sh $H deploy src # back up the remote src once, then replace it +tools/amane-remote.sh $H build # amane compile +tools/amane-remote.sh $H start # runs the shell WITH frame logging (AMANE_FRAMES=1) +tools/amane-remote.sh $H fps 3 # frames, average gap, gpu wait +tools/amane-remote.sh $H anim island # open/close 3x, every frame gap +tools/amane-remote.sh $H shot magic-island out.png +tools/amane-remote.sh $H sinks start # null sinks island_test_a/_b +tools/amane-remote.sh $H players start 4 # fake MPRIS players (tools/fake_mpris.py), "Synth Test" makes real sound into island_test_a +tools/amane-remote.sh $H ipc island status +tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical session's environment +``` + +- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound. +- The island follows the focused monitor: read its geometry from `hyprctl layers -j` (namespace `magic-island`) right before each `grim` capture. +- The user's pointer may be over the island and open or collapse it during a test; check `status` and capture right after `open`. +- To tune a visualiser, sample the real levels read-only (`pw-record --target | cava`, with `timeout -s KILL` on each stage; a plain `timeout` on a pipeline orphans cava) and look at the distribution first. Typical music sits at 0.15–0.35. +- In `pkill -f` patterns over SSH, use `[p]ython3 ...` so the pattern doesn't match the remote shell itself. + +### After testing + +```sh +tools/amane-remote.sh $H players stop +tools/amane-remote.sh $H sinks stop +tools/amane-remote.sh $H sh 'kill $(cat shell.pid); sleep 0.5; rm -f shell.log + setsid -f sh -c "echo \$\$ > shell.pid; exec ~/.cache/amane/project/target/debug/amane-shell" > /dev/null 2>&1' +``` + +The desktop's `amane` CLI is older than the docs: no `~/.cache/amane/amane-shell` is saved and `amane run` doesn't apply, so the shell runs from `~/.cache/amane/project/target/debug/amane-shell`. + +## Open threads + +- The candle ring is still mirrored (`mirrored()`); the user had the pill bars unmirrored and may want the same for the ring. +- See README "Known limits". diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc3e335 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Magic Island: an Amane media widget + +A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card. + +- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. +- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next. +- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays. +- **Output switcher:** moves only the shown player's audio to another device. +- **Per-player volume** (scroll the volume chip, click to mute), set on that player's own PulseAudio/PipeWire streams, so it also works for browsers that ignore MPRIS volume. +- **Real 10-band equalizer per player** (31 Hz to 16 kHz, ±12 dB): a PipeWire filter-chain per player, with 8 presets (Flat, Bass, Treble, Vocal, Pop, Rock, Jazz, Classic). Curves are saved per player. +- **Background:** a diagonal gradient from the cover's own colours, darkened so white text stays readable. +- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it. + +## Layout + +``` +src/main.rs app setup: Fira Code font, the `island` IPC handler, palette from the cover +src/island.rs the view: pill, card, ring, equalizer, dropdowns, animation, IPC commands +src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli, pipewire) +src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava) +src/art.rs cover download (curl) and the palette file +src/parse.rs pure text parsing with unit tests, no amane dependency +island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq) +tools/ remote test driver and fake MPRIS players, see AGENTS.md +src.before-test/ the original hello-world config, kept as a backup +``` + +## Requirements + +On the desktop that runs the shell: + +- the `amane` CLI and Rust toolchain (`amane compile` builds `src/`) +- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module) +- `cava`, `curl` +- the Fira Code font + +## Install and run + +The shell lives in `~/.config/amane/`. Copy (or clone) this repo's `src/` there, then build and start: + +```sh +amane compile +~/.cache/amane/project/target/debug/amane-shell +``` + +The installed CLI on the current desktop is older than the Amane docs: it doesn't save `~/.cache/amane/amane-shell`, so `amane run` has nothing to start. Run the binary above directly. + +## Control it + +```sh +amane ipc call island toggle # also: open, close +amane ipc call island next # show the next player +amane ipc call island menu output # open a dropdown: output | player | close +amane ipc call island preset Rock # apply an equalizer preset to the shown player +amane ipc call island output # move the shown player to a sink (pactl sink name) +amane ipc call island status # player= expanded= menu= +``` + +Example Hyprland keybind: + +``` +bind = SUPER, M, exec, amane ipc call island toggle +``` + +## Runtime files + +- `~/.config/amane/island-eq`: one line per player identity, `\t\t<10 gains in dB>`. +- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-.conf`. On tmpfs, gone at logout. +- Equalizer nodes are named `magic_island_eq.`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`). + +## Known limits + +- The time bar moves in 1 s steps (the MPRIS poll rate). +- Every slider step starts one `pactl` or `pw-cli` process. +- An equalizer keeps running, and keeps that player routed through its last curve, if the shell stops while the player is open. +- The candle ring around the CD is still mirrored left against right; the pill's bars are not. +- Dropdowns have no keyboard navigation. diff --git a/tools/amane-remote.sh b/tools/amane-remote.sh new file mode 100755 index 0000000..69934a3 --- /dev/null +++ b/tools/amane-remote.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Visual test loop for an amane shell on a remote Wayland desktop, driven over ssh. +# +# usage: amane-remote.sh HOST COMMAND [ARGS...] +# deploy SRC_DIR back up the remote ~/.config/amane/src once, then replace it with SRC_DIR +# build `amane compile` on the remote (backs up a saved amane-shell once) +# start | stop run / stop the compiled shell, with AMANE_FRAMES=1 logging +# log [N] last N lines of the shell's log (default 30) +# fps [SECONDS] frames drawn over SECONDS (default 3), with average gap and gpu wait +# anim HANDLER 3x `amane ipc call HANDLER open|close`: frames, gaps over 20 ms, every gap +# ipc ARGS... amane ipc call ARGS... +# shot NAMESPACE FILE [PAD] screenshot the layer window with that namespace (Hyprland) into local FILE +# players start N | stop run N fake MPRIS players (fake_mpris.py, next to this script) +# sinks start | stop two inaudible null sinks (island_test_a/_b); fake players play into _a +# sh 'COMMAND' any command inside the remote session environment +set -euo pipefail + +host=${1:?host} +command=${2:?command} +shift 2 + +here=$(dirname "$(readlink -f "$0")") +work='$HOME/.cache/amane-remote-test' + +# what a terminal inside the graphical session would have +session='uid=$(id -u) +export XDG_RUNTIME_DIR=/run/user/$uid +export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus +export WAYLAND_DISPLAY=$(ls "$XDG_RUNTIME_DIR" | grep -m1 -x "wayland-[0-9]*") +export HYPRLAND_INSTANCE_SIGNATURE=$(ls -t "$XDG_RUNTIME_DIR/hypr" 2>/dev/null | head -1) +export PATH=$HOME/.cargo/bin:$PATH +mkdir -p '"$work"' && cd '"$work"' +' + +remote() { + ssh -o BatchMode=yes "$host" "$session$1" +} + +case $command in +deploy) + source_dir=${1:?source folder} + remote 'test -d ~/.config/amane/src.before-test || cp -a ~/.config/amane/src ~/.config/amane/src.before-test 2>/dev/null || true + mkdir -p ~/.config/amane/src && find ~/.config/amane/src -maxdepth 1 -name "*.rs" -delete' + # -m: the two clocks may disagree, and future timestamps only cause warnings + tar -C "$source_dir" -cf - . | ssh -o BatchMode=yes "$host" 'tar -C ~/.config/amane/src -xmf -' + scp -q "$here/fake_mpris.py" "$host:.cache/amane-remote-test/" + remote 'ls ~/.config/amane/src' + ;; +build) + remote 'test -f ~/.cache/amane/amane-shell.before-test || cp -a ~/.cache/amane/amane-shell ~/.cache/amane/amane-shell.before-test 2>/dev/null || true + amane compile 2>&1 | tail -40' + ;; +start) + # newer CLIs save ~/.cache/amane/amane-shell; older ones only leave cargo's output + remote 'test -f shell.pid && kill $(cat shell.pid) 2>/dev/null; sleep 0.5 + binary=~/.cache/amane/amane-shell + test -x "$binary" || binary=~/.cache/amane/project/target/debug/amane-shell + AMANE_FRAMES=1 setsid -f sh -c "echo \$\$ > shell.pid; exec $binary" > shell.log 2>&1 + sleep 2; pgrep -F shell.pid > /dev/null && echo "running, pid $(cat shell.pid)" || { echo "exited:"; tail -20 shell.log; }' + ;; +stop) + remote 'test -f shell.pid && kill $(cat shell.pid) 2>/dev/null; rm -f shell.pid; echo stopped' + ;; +log) + remote "tail -n ${1:-30} shell.log" + ;; +fps) + # AMANE_FRAMES lines: frame x gap view draw gpu + remote "a=\$(wc -l < shell.log); sleep ${1:-3}; tail -n +\$((a + 1)) shell.log | grep '^frame' | + awk '{gap+=\$5; view+=\$7; gpu+=\$11; n++} END {if (n) printf \"%d frames in ${1:-3} s, gap %.1f ms, view %.2f ms, gpu wait %.2f ms\n\", n, gap/n, view/n, gpu/n; else print \"no frames\"}'" + ;; +anim) + # a smooth animation shows only ~17 ms gaps; 33 ms runs mean it fell to 30 fps + handler=${1:?ipc handler with open and close} + remote "for round in 1 2 3; do for action in open close; do + a=\$(wc -l < shell.log); amane ipc call $handler \$action > /dev/null; sleep 1 + tail -n +\$((a + 1)) shell.log | grep '^frame' | awk -v act=\$action ' + {gap[n++]=\$5; gpu+=\$11} + END {slow=0; for (i=1;i20) slow++; + printf \"%-5s frames %2d over 20 ms %2d gpu wait %5.2f ms gaps:\", act, n, slow, gpu/n; + for (i=1;i&2" && ssh -o BatchMode=yes "$host" 'cat ~/.cache/amane-remote-test/shot.png' > "$file" + echo "saved $file" + ;; +players) + case ${1:?start N or stop} in + start) + count=${2:-3} + remote "sink=\$(pactl list sinks short | grep -q island_test_a && echo island_test_a) + for index in \$(seq 0 $((count - 1))); do + FAKE_MPRIS_SINK=\$sink setsid -f python3 fake_mpris.py \$index >> players.log 2>&1 + done + sleep 1.5; playerctl -l" + ;; + stop) + # [p]: the remote shell's own command line holds this text, and must not match it + remote 'pkill -TERM -f "[p]ython3 fake_mpris.py" || true; sleep 0.5; playerctl -l 2>&1' + ;; + esac + ;; +sinks) + case ${1:?start or stop} in + start) + remote 'for name in island_test_a island_test_b; do + pactl list sinks short | grep -q "$name" || + pactl load-module module-null-sink sink_name=$name sink_properties=device.description=$name > /dev/null + done + pactl list sinks short | grep island_test' + ;; + stop) + remote 'pactl list modules short | awk "/module-null-sink/ && /island_test/ {print \$1}" | xargs -r -n1 pactl unload-module; pactl list sinks short | grep -c island_test' + ;; + esac + ;; +sh) + remote "${1:?command}" + ;; +*) + sed -n '2,15p' "$0" + exit 1 + ;; +esac diff --git a/tools/fake_mpris.py b/tools/fake_mpris.py new file mode 100755 index 0000000..7137f4b --- /dev/null +++ b/tools/fake_mpris.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Fake MPRIS players for visual tests of media widgets. + +usage: fake_mpris.py PROFILE (0, 1, 2, ... see PROFILES) + +Each process owns org.mpris.MediaPlayer2.fake, answers the calls a bar +makes (GetAll, PlayPause, Next, Previous, Seek) and, when the profile says so, +plays through `paplay` as its own child, so mixers that match sound streams to +players by process ancestry find it: "silence" (zeros) or "synth" (a beat, a +chord and a sweep, for visualisers and equalizers). Set FAKE_MPRIS_SINK to a +null sink (`pactl load-module module-null-sink sink_name=...`) so the synth is +never audible. Covers are generated PNGs in a folder with a space in its name +(exercises percent-encoded file:// links). Profiles cover: long title + +playing, >1 h paused podcast, live stream without cover or sound, synth. +Needs PyGObject (gi) and paplay. SIGTERM stops paplay too. +""" + +import array +import math +import os +import random +import signal +import struct +import subprocess +import sys +import threading +import time +import urllib.parse +import zlib + +from gi.repository import Gio, GLib + +HERE = os.path.dirname(os.path.abspath(__file__)) +RATE = 44100 + +# identity, playing, sound ("silence", "synth" or None), tracks of (title, artist, seconds, cover colours or None) +PROFILES = [ + ("Lo-Fi Lab", True, "silence", [ + ("Late Night Tape Hiss (Extended Rain Mix) feat. Somebody With A Long Name", + "Nujabes Tribute Collective", 214, ((255, 140, 60), (200, 40, 140))), + ("Second Track", "Lo-Fi Lab Band", 180, ((250, 210, 70), (60, 160, 120))), + ]), + ("Podcast Deck", False, "silence", [ + ("Episode 142: Shipping Small", "The Lazy Dev Show", 3725, ((40, 190, 200), (20, 30, 90))), + ]), + ("Radio Stream", True, None, [ + ("Live from the Basement", "Night FM", 0, None), + ]), + ("Synth Test", True, "synth", [ + ("Chest Pain (I Love)", "Malcolm Todd", 200, ((120, 110, 190), (30, 20, 60))), + ]), +] + + +def synth(out, stop): + """Endless s16le stereo: a kick every 0.5 s, hats, a breathing chord and a slow sweep.""" + frame = 0 + chunk = 2048 + while not stop.is_set(): + samples = array.array("h") + for _ in range(chunk): + t = frame / RATE + beat = t % 0.5 + kick = math.sin(2 * math.pi * (50 + 90 * math.exp(-beat * 30)) * beat) * math.exp(-beat * 8) + hat = (random.random() * 2 - 1) * math.exp(-(t % 0.25) * 60) * 0.25 + chord = sum(math.sin(2 * math.pi * f * t) for f in (220.0, 277.2, 329.6)) / 3 * (0.3 + 0.2 * math.sin(t)) + sweep = math.sin(2 * math.pi * (400 + 3000 * (0.5 + 0.5 * math.sin(t * 0.7))) * t) * 0.15 + value = int(max(-1.0, min(1.0, 0.5 * kick + hat + chord + sweep)) * 20000) + samples.append(value) + samples.append(value) + frame += 1 + try: + out.write(samples.tobytes()) + out.flush() + except (BrokenPipeError, ValueError): + return + +INTERFACES = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def png(path, colours, size=256): + """A diagonal two-colour gradient with a lighter disc, written with zlib only.""" + (r0, g0, b0), (r1, g1, b1) = colours + rows = [] + for y in range(size): + row = bytearray(b"\x00") + for x in range(size): + t = (x + y) / (2 * size - 2) + disc = (x - size * 0.62) ** 2 + (y - size * 0.38) ** 2 < (size * 0.22) ** 2 + lift = 60 if disc else 0 + row += bytes(min(255, int(a + (b - a) * t) + lift) for a, b in ((r0, r1), (g0, g1), (b0, b1))) + rows.append(bytes(row)) + + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + + header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) + with open(path, "wb") as out: + out.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(b"".join(rows))) + chunk(b"IEND", b"")) + + +class Player: + def __init__(self, index): + self.index = index + self.identity, self.playing, sound, self.tracks = PROFILES[index] + self.track = 0 + self.offset = 0.0 # seconds into the track at `since` + self.since = time.monotonic() + self.sound = None + self.stop_synth = threading.Event() + if sound: + command = ["paplay", "--raw", "--format=s16le", f"--rate={RATE}", "--channels=2", + f"--client-name={self.identity}"] + if os.environ.get("FAKE_MPRIS_SINK"): + command.append(f"--device={os.environ['FAKE_MPRIS_SINK']}") + if sound == "silence": + self.sound = subprocess.Popen(command + ["/dev/zero"]) + else: + # paplay reads stdin when no file is given + self.sound = subprocess.Popen(command, stdin=subprocess.PIPE) + threading.Thread(target=synth, args=(self.sound.stdin, self.stop_synth), daemon=True).start() + + covers = os.path.join(HERE, "fake covers") + os.makedirs(covers, exist_ok=True) + self.covers = [] + for number, (_, _, _, colours) in enumerate(self.tracks): + if colours is None: + self.covers.append("") + continue + path = os.path.join(covers, f"player{index}-track{number}.png") + if not os.path.exists(path): + png(path, colours) + self.covers.append("file://" + urllib.parse.quote(path)) + + def position(self): + length = self.tracks[self.track][2] + moved = time.monotonic() - self.since if self.playing else 0.0 + seconds = self.offset + moved + return min(seconds, length) if length else seconds + + def rebase(self, seconds): + self.offset = max(0.0, seconds) + self.since = time.monotonic() + + def metadata(self): + title, artist, length, _ = self.tracks[self.track] + data = { + "mpris:trackid": GLib.Variant("o", f"/org/mpris/MediaPlayer2/fake/track{self.track}"), + "xesam:title": GLib.Variant("s", title), + "xesam:artist": GLib.Variant("as", [artist]), + "mpris:artUrl": GLib.Variant("s", self.covers[self.track]), + } + if length: + data["mpris:length"] = GLib.Variant("x", length * 1_000_000) + return GLib.Variant("a{sv}", data) + + def get(self, _connection, _sender, _path, _interface, name): + constant = { + "Identity": GLib.Variant("s", self.identity), + "CanQuit": GLib.Variant("b", False), "CanRaise": GLib.Variant("b", False), + "HasTrackList": GLib.Variant("b", False), + "SupportedUriSchemes": GLib.Variant("as", []), "SupportedMimeTypes": GLib.Variant("as", []), + "Rate": GLib.Variant("d", 1.0), "Volume": GLib.Variant("d", 1.0), + } + if name in constant: + return constant[name] + if name == "PlaybackStatus": + return GLib.Variant("s", "Playing" if self.playing else "Paused") + if name == "Metadata": + return self.metadata() + if name == "Position": + return GLib.Variant("x", int(self.position() * 1_000_000)) + return GLib.Variant("b", True) # every Can* capability + + def call(self, _connection, _sender, _path, _interface, method, parameters, invocation): + if method in ("PlayPause", "Play", "Pause", "Stop"): + here = self.position() + self.playing = {"PlayPause": not self.playing, "Play": True}.get(method, False) + self.rebase(here) + elif method in ("Next", "Previous"): + step = 1 if method == "Next" else -1 + self.track = (self.track + step) % len(self.tracks) + self.rebase(0) + elif method == "Seek": + self.rebase(self.position() + parameters.unpack()[0] / 1_000_000) + elif method == "SetPosition": + self.rebase(parameters.unpack()[1] / 1_000_000) + print(f"{self.identity}: {method} {parameters.unpack() if parameters else ''} " + f"-> {'playing' if self.playing else 'paused'} at {self.position():.1f}s", flush=True) + invocation.return_value(None) + + def stop(self, *_): + self.stop_synth.set() + if self.sound: + self.sound.terminate() + sys.exit(0) + + +def main(): + player = Player(int(sys.argv[1])) + signal.signal(signal.SIGTERM, player.stop) + signal.signal(signal.SIGINT, player.stop) + + node = Gio.DBusNodeInfo.new_for_xml(INTERFACES) + + def acquired(connection, _name): + for interface in node.interfaces: + connection.register_object("/org/mpris/MediaPlayer2", interface, player.call, player.get, None) + + Gio.bus_own_name(Gio.BusType.SESSION, f"org.mpris.MediaPlayer2.fake{player.index}", + Gio.BusNameOwnerFlags.NONE, acquired, None, None) + + # GLib's loop ignores Python signal handlers unless it wakes up now and then + GLib.timeout_add(250, lambda: True) + GLib.MainLoop().run() + + +if __name__ == "__main__": + main()