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
This commit is contained in:
server2
2026-10-06 15:41:48 +02:00
parent c55e4cfbed
commit a8cd23546d
5 changed files with 596 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# written by amane compile / amane startup, with paths for one machine only
Cargo.toml
Cargo.lock
target/
+117
View File
@@ -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 <its stream> \| 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-<key>.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.<key>`; gains change live with `pw-cli set-param magic_island_eq.<key> 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 <stream> | 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".
+77
View File
@@ -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 <sink> # move the shown player to a sink (pactl sink name)
amane ipc call island status # player=<identity> expanded=<bool> menu=<bool>
```
Example Hyprland keybind:
```
bind = SUPER, M, exec, amane ipc call island toggle
```
## Runtime files
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
- Equalizer nodes are named `magic_island_eq.<key>`. 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.
+142
View File
@@ -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 <window> <w>x<h> gap <ms> view <ms> draw <ms> gpu <ms>
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;i<n;i++) if (gap[i]>20) slow++;
printf \"%-5s frames %2d over 20 ms %2d gpu wait %5.2f ms gaps:\", act, n, slow, gpu/n;
for (i=1;i<n && i<26;i++) printf \" %.0f\", gap[i]; print \"\"}'
done; done"
;;
ipc)
remote "amane ipc call $*"
;;
shot)
namespace=${1:?namespace}
file=${2:?local file}
pad=${3:-0}
# hyprctl layers -j: {monitor: {levels: {n: [{namespace, x, y, w, h}]}}}, x and y in global layout pixels
remote "geometry=\$(hyprctl layers -j | python3 -c '
import json, sys
for monitor in json.load(sys.stdin).values():
for level in monitor[\"levels\"].values():
for layer in level:
if layer[\"namespace\"] == \"$namespace\":
p = $pad
print(f\"{layer[\"x\"] - p},{max(0, layer[\"y\"] - p)} {layer[\"w\"] + 2 * p}x{layer[\"h\"] + 2 * p}\")
sys.exit()
sys.exit(\"no layer named $namespace\")
') && grim -g \"\$geometry\" shot.png && echo \"\$geometry\" >&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
+256
View File
@@ -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<PROFILE>, 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 = """
<node>
<interface name="org.mpris.MediaPlayer2">
<method name="Raise"/>
<method name="Quit"/>
<property name="Identity" type="s" access="read"/>
<property name="CanQuit" type="b" access="read"/>
<property name="CanRaise" type="b" access="read"/>
<property name="HasTrackList" type="b" access="read"/>
<property name="SupportedUriSchemes" type="as" access="read"/>
<property name="SupportedMimeTypes" type="as" access="read"/>
</interface>
<interface name="org.mpris.MediaPlayer2.Player">
<method name="PlayPause"/>
<method name="Play"/>
<method name="Pause"/>
<method name="Stop"/>
<method name="Next"/>
<method name="Previous"/>
<method name="Seek"><arg name="Offset" type="x" direction="in"/></method>
<method name="SetPosition">
<arg name="TrackId" type="o" direction="in"/>
<arg name="Position" type="x" direction="in"/>
</method>
<property name="PlaybackStatus" type="s" access="read"/>
<property name="Metadata" type="a{sv}" access="read"/>
<property name="Position" type="x" access="read"/>
<property name="Rate" type="d" access="read"/>
<property name="Volume" type="d" access="read"/>
<property name="CanGoNext" type="b" access="read"/>
<property name="CanGoPrevious" type="b" access="read"/>
<property name="CanPlay" type="b" access="read"/>
<property name="CanPause" type="b" access="read"/>
<property name="CanSeek" type="b" access="read"/>
<property name="CanControl" type="b" access="read"/>
</interface>
</node>
"""
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()