Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8cd23546d | ||
|
|
c55e4cfbed | ||
|
|
e7ff054fb1 |
@@ -0,0 +1,4 @@
|
|||||||
|
# written by amane compile / amane startup, with paths for one machine only
|
||||||
|
Cargo.toml
|
||||||
|
Cargo.lock
|
||||||
|
target/
|
||||||
@@ -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".
|
||||||
@@ -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.
|
||||||
+148
-56
@@ -3,7 +3,7 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use amane::{
|
use amane::{
|
||||||
Animation, Arc, Argument, Bus, Canvas, Cap, Center, Color, Column, End, Full,
|
Animation, Arc, Argument, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient,
|
||||||
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path,
|
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path,
|
||||||
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack,
|
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack,
|
||||||
Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
|
Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
|
||||||
@@ -14,8 +14,17 @@ use crate::parse::{self, BANDS, Sink};
|
|||||||
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
|
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
|
||||||
use crate::spectrum::Spectrum;
|
use crate::spectrum::Spectrum;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* opening is one linear timeline in two phases, so the shape never resizes with the
|
||||||
|
* whole card in it: the pill fades out, the empty shape grows, then the card fades in
|
||||||
|
* on the settled shape. closing runs the same timeline backwards
|
||||||
|
*/
|
||||||
|
const OPEN_MS: u64 = 560;
|
||||||
|
const PILL_GONE: f32 = 0.18;
|
||||||
|
const SHAPE_DONE: f32 = 0.6;
|
||||||
|
|
||||||
// sizes, in pixels
|
// sizes, in pixels
|
||||||
const COMPACT_W: f32 = 200.0;
|
const COMPACT_W: f32 = 220.0;
|
||||||
const COMPACT_H: f32 = 38.0;
|
const COMPACT_H: f32 = 38.0;
|
||||||
const WIDE_W: f32 = 560.0;
|
const WIDE_W: f32 = 560.0;
|
||||||
const WIDE_RADIUS: f32 = 28.0;
|
const WIDE_RADIUS: f32 = 28.0;
|
||||||
@@ -83,7 +92,6 @@ const CHIP: Color = Color::rgba(255, 255, 255, 18);
|
|||||||
const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34);
|
const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34);
|
||||||
const TRACK: Color = Color::rgba(255, 255, 255, 24);
|
const TRACK: Color = Color::rgba(255, 255, 255, 24);
|
||||||
const EDGE: Color = Color::rgba(255, 255, 255, 26);
|
const EDGE: Color = Color::rgba(255, 255, 255, 26);
|
||||||
const SURFACE_TOP: Color = Color::rgb(30, 21, 38);
|
|
||||||
const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18);
|
const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18);
|
||||||
const MENU_BG: Color = Color::rgb(35, 26, 44);
|
const MENU_BG: Color = Color::rgb(35, 26, 44);
|
||||||
|
|
||||||
@@ -111,7 +119,7 @@ pub struct Island {
|
|||||||
impl Service for Island {
|
impl Service for Island {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
open: Animation::new(0.0).duration(Duration::from_millis(420)),
|
open: Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear),
|
||||||
expanded: false,
|
expanded: false,
|
||||||
selected: None,
|
selected: None,
|
||||||
menu: None,
|
menu: None,
|
||||||
@@ -124,12 +132,6 @@ impl Service for Island {
|
|||||||
fn listen() {}
|
fn listen() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Island {
|
|
||||||
pub fn expanded(&self) -> bool {
|
|
||||||
self.expanded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the picked player while it's still open, else the one Media calls active
|
// the picked player while it's still open, else the one Media calls active
|
||||||
pub fn shown<'a>(media: &'a Media, selected: Option<&str>) -> Option<&'a MediaPlayer> {
|
pub fn shown<'a>(media: &'a Media, selected: Option<&str>) -> Option<&'a MediaPlayer> {
|
||||||
let picked = selected.and_then(|name| media.players().iter().find(|player| player.name() == name));
|
let picked = selected.and_then(|name| media.players().iter().find(|player| player.name() == name));
|
||||||
@@ -170,23 +172,25 @@ pub fn view() -> LayerWindow {
|
|||||||
return window.visible(false);
|
return window.visible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let open = island.open.value();
|
let (grow, pill, reveal) = phases(island.open.value());
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* a live ring keeps frames coming the way an animation does: a service change
|
* live levels keep frames coming the way an animation does: a service change
|
||||||
* alone asks with an empty commit, which hyprland only answers on its next repaint
|
* alone asks with an empty commit, which hyprland only answers on its next repaint
|
||||||
*/
|
*/
|
||||||
if island.expanded && !spectrum.levels.is_empty() {
|
if !spectrum.levels.is_empty() {
|
||||||
amane::request_frame();
|
amane::request_frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
let width = mix(COMPACT_W, WIDE_W, open);
|
let width = mix(COMPACT_W, WIDE_W, grow);
|
||||||
let height = mix(COMPACT_H, WIDE_H, open);
|
let height = mix(COMPACT_H, WIDE_H, grow);
|
||||||
|
let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow);
|
||||||
|
|
||||||
// the two faces cross-fade at the halfway point, while the shape keeps growing
|
// a gradient of the cover's own colours, for the pill and the card alike
|
||||||
let face = if open < 0.5 {
|
let colors = backdrop_colors(&palette);
|
||||||
compact(player, &art.path).opacity(1.0 - 2.0 * open)
|
|
||||||
} else {
|
// at most one face at a time, each faded by a veil instead of a gpu layer
|
||||||
|
let face = if reveal > 0.0 {
|
||||||
let scene = Scene {
|
let scene = Scene {
|
||||||
player,
|
player,
|
||||||
players: media.players(),
|
players: media.players(),
|
||||||
@@ -196,34 +200,31 @@ pub fn view() -> LayerWindow {
|
|||||||
levels: &spectrum.levels,
|
levels: &spectrum.levels,
|
||||||
};
|
};
|
||||||
|
|
||||||
// an opacity of 1 still costs a gpu layer, so the settled card has none
|
veiled(card(&scene), width, height, radius, reveal, &colors)
|
||||||
if open >= 1.0 { card(&scene) } else { card(&scene).opacity(2.0 * open - 1.0) }
|
} else if pill > 0.0 {
|
||||||
|
veiled(compact(player, &art.path, &spectrum.levels), width, height, radius, pill, &colors)
|
||||||
|
} else {
|
||||||
|
// mid-grow: the empty shape alone, the cheapest frames of the animation
|
||||||
|
Rectangle::new().width(0.0).height(0.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
let shape = Rectangle::new()
|
let shape = Rectangle::new()
|
||||||
.width(width)
|
.width(width)
|
||||||
.height(height)
|
.height(height)
|
||||||
.radius(mix(COMPACT_H / 2.0, WIDE_RADIUS, open))
|
.radius(radius)
|
||||||
/*
|
.fill(backdrop(&colors, 255))
|
||||||
* solid, tinted toward the cover: gradients turned out to be this iGPU's most
|
|
||||||
* expensive fill (60 fps without them, 30 with), and the card is redrawn every frame
|
|
||||||
*/
|
|
||||||
.fill(tint(palette.dominant()))
|
|
||||||
.border(1.0, EDGE);
|
.border(1.0, EDGE);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* amane redraws the whole window every frame, so big static effects are paid
|
* amane redraws the whole window every frame, so big static effects are paid
|
||||||
* 60 times a second: the settled card goes without a drop shadow (its rim and
|
* 60 times a second: the shadow belongs to the pill and the grow, and fades out
|
||||||
* tinted gradient set it apart), the pill and the morph keep theirs
|
* with the card coming in. no clip anywhere: neither face ever overflows the shape
|
||||||
*/
|
*/
|
||||||
let shape = if open < 1.0 { shape.shadow(Shadow::drop("#000000").opacity(0.55).blur(24.0).offset(0.0, 10.0)) } else { shape };
|
let shape = if reveal < 1.0 {
|
||||||
|
shape.shadow(Shadow::drop("#000000").opacity(0.55 * (1.0 - reveal)).blur(24.0).offset(0.0, 10.0))
|
||||||
/*
|
} else {
|
||||||
* the clip is only needed while the shape morphs and the face overflows it; a clip
|
shape
|
||||||
* over the whole card took this iGPU from 47 to 9 frames a second, so the settled
|
};
|
||||||
* card goes without (its glows stay inside on their own)
|
|
||||||
*/
|
|
||||||
let shape = if open < 1.0 { shape.clip() } else { shape };
|
|
||||||
|
|
||||||
let shape = shape
|
let shape = shape
|
||||||
.align_child(Center, Start)
|
.align_child(Center, Start)
|
||||||
@@ -365,16 +366,77 @@ fn mix(from: f32, to: f32, amount: f32) -> f32 {
|
|||||||
from + (to - from) * amount
|
from + (to - from) * amount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// one point on the open timeline as (shape grown, pill shown, card shown), each 0 to 1
|
||||||
|
fn phases(timeline: f32) -> (f32, f32, f32) {
|
||||||
|
let grow = (timeline / SHAPE_DONE).clamp(0.0, 1.0);
|
||||||
|
let pill = 1.0 - (timeline / PILL_GONE).clamp(0.0, 1.0);
|
||||||
|
let reveal = ((timeline - SHAPE_DONE) / (1.0 - SHAPE_DONE)).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
// the shape eases out (cubic), the card in and out (smoothstep)
|
||||||
|
(1.0 - (1.0 - grow).powi(3), pill, reveal * reveal * (3.0 - 2.0 * reveal))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* fades a face in over the island's backdrop by laying the same gradient over it,
|
||||||
|
* thinning as `shown` rises: the same picture as .opacity(shown), without a gpu layer.
|
||||||
|
* the veil spans the whole shape (minus the 1 px rim), so its gradient lines up with
|
||||||
|
* the backdrop's exactly; amane places gradients by each rectangle's own bounds
|
||||||
|
*/
|
||||||
|
fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle {
|
||||||
|
if shown >= 1.0 {
|
||||||
|
return face;
|
||||||
|
}
|
||||||
|
|
||||||
|
let veil = Rectangle::new()
|
||||||
|
.width(width - 2.0)
|
||||||
|
.height(height - 2.0)
|
||||||
|
.radius(radius - 1.0)
|
||||||
|
.fill(backdrop(colors, ((1.0 - shown) * 255.0).round() as u8))
|
||||||
|
.translate(1.0, 1.0);
|
||||||
|
|
||||||
|
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
|
||||||
|
|
||||||
|
Rectangle::new().width(width).height(height).child(Stack::new(children![face, veil]).width(width).height(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
// the cover's colours for the backdrop: most common, second most common, darkest
|
||||||
|
fn backdrop_colors(palette: &Palette) -> [Color; 3] {
|
||||||
|
let first = palette.dominant();
|
||||||
|
let second = palette.colors().get(1).copied().unwrap_or(first);
|
||||||
|
|
||||||
|
// darker toward the bottom right, where the equalizer and presets sit
|
||||||
|
[deepen(first, 0.22), deepen(second, 0.15), deepen(palette.background(), 0.07)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* one gradient for the pill, the card and the veils; 150 degrees runs from the
|
||||||
|
* top left to the bottom right, like the reference's light
|
||||||
|
*/
|
||||||
|
fn backdrop(colors: &[Color; 3], alpha: u8) -> Gradient {
|
||||||
|
Gradient::linear(150.0, [(0.0, faded(colors[0], alpha)), (0.55, faded(colors[1], alpha)), (1.0, faded(colors[2], alpha))])
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaled down, keeping its hue, until its brightness is at most `max` (0 to 1), so white text reads on it
|
||||||
|
fn deepen(color: Color, max: f32) -> Color {
|
||||||
|
let (red, green, blue) = (f32::from(color.red()), f32::from(color.green()), f32::from(color.blue()));
|
||||||
|
|
||||||
|
let brightness = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0;
|
||||||
|
|
||||||
|
let scale = if brightness > max { max / brightness } else { 1.0 };
|
||||||
|
|
||||||
|
Color::rgb((red * scale) as u8, (green * scale) as u8, (blue * scale) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
fn faded(color: Color, alpha: u8) -> Color {
|
fn faded(color: Color, alpha: u8) -> Color {
|
||||||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the pill: cover, title, level bars
|
// the pill: cover, title, the player's live levels
|
||||||
fn compact(player: &MediaPlayer, art: &str) -> Rectangle {
|
fn compact(player: &MediaPlayer, art: &str, levels: &[f32]) -> Rectangle {
|
||||||
Rectangle::new()
|
Rectangle::new()
|
||||||
.width(COMPACT_W)
|
.width(COMPACT_W)
|
||||||
.height(COMPACT_H)
|
.height(COMPACT_H)
|
||||||
.padding(Padding { top: 0.0, right: 14.0, bottom: 0.0, left: 6.0 })
|
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 6.0 })
|
||||||
.child(
|
.child(
|
||||||
Row::new(children![
|
Row::new(children![
|
||||||
cover(art, 26.0, 1.5, false),
|
cover(art, 26.0, 1.5, false),
|
||||||
@@ -383,7 +445,7 @@ fn compact(player: &MediaPlayer, art: &str) -> Rectangle {
|
|||||||
.height(COMPACT_H)
|
.height(COMPACT_H)
|
||||||
.align_child(Start, Center)
|
.align_child(Start, Center)
|
||||||
.child(Text::new(title(player)).size(12.0).color(TEXT).elide()),
|
.child(Text::new(title(player)).size(12.0).color(TEXT).elide()),
|
||||||
levels(player.playing()),
|
pill_levels(player.playing(), levels),
|
||||||
])
|
])
|
||||||
.gap(10.0)
|
.gap(10.0)
|
||||||
.align(Center),
|
.align(Center),
|
||||||
@@ -400,13 +462,6 @@ fn card(scene: &Scene) -> Rectangle {
|
|||||||
Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H))
|
Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H))
|
||||||
}
|
}
|
||||||
|
|
||||||
// the surface colour leaning a little toward the cover's
|
|
||||||
fn tint(color: Color) -> Color {
|
|
||||||
let lean = |surface: u8, cover: u8| (f32::from(surface) * 0.7 + f32::from(cover) * 0.3) as u8;
|
|
||||||
|
|
||||||
Color::rgb(lean(SURFACE_TOP.red(), color.red()), lean(SURFACE_TOP.green(), color.green()), lean(SURFACE_TOP.blue(), color.blue()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn content(scene: &Scene) -> Rectangle {
|
fn content(scene: &Scene) -> Rectangle {
|
||||||
Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child(
|
Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child(
|
||||||
Column::new(children![
|
Column::new(children![
|
||||||
@@ -1032,23 +1087,60 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
|||||||
frame.child(picture)
|
frame.child(picture)
|
||||||
}
|
}
|
||||||
|
|
||||||
// four bars while playing, a small pause sign otherwise (flat bars read as a "⋯" menu)
|
/*
|
||||||
fn levels(playing: bool) -> Canvas {
|
* the pill's visualiser: seven bars from the shown player's own sound, glowing when
|
||||||
|
* loud; a pause sign while paused, and still bars while the capture starts or the
|
||||||
|
* player has no stream to listen to
|
||||||
|
*/
|
||||||
|
fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
|
||||||
|
const BARS: usize = 7;
|
||||||
|
const WIDTH: f32 = 36.0;
|
||||||
|
const HEIGHT: f32 = 26.0;
|
||||||
|
|
||||||
if !playing {
|
if !playing {
|
||||||
return icon(Glyph::Pause, 13.0, DIM);
|
return icon(Glyph::Pause, 13.0, DIM);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bars: Vec<Box<dyn Shape>> = Vec::new();
|
// lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line
|
||||||
|
let middle = HEIGHT / 2.0;
|
||||||
|
|
||||||
for (index, height) in [6.0, 13.0, 9.0, 11.0].into_iter().enumerate() {
|
let mut glow = Path::new();
|
||||||
let x = 2.5 + index as f32 * 5.0;
|
let mut body = Path::new();
|
||||||
|
|
||||||
let bar = Line::new().from(x, 8.0 - height / 2.0).to(x, 8.0 + height / 2.0);
|
for index in 0..BARS {
|
||||||
|
let level = if levels.is_empty() {
|
||||||
|
[0.55, 0.85, 0.6, 0.75, 0.45, 0.6, 0.35][index]
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
* the loudest band of this bar's seventh of the spectrum, linear with a little
|
||||||
|
* gain: typical music sits at 0.15 to 0.35 here, and a lifting curve made every
|
||||||
|
* bar look nearly full
|
||||||
|
*/
|
||||||
|
(band(levels, index, BARS) * 1.4).min(1.0)
|
||||||
|
};
|
||||||
|
|
||||||
bars.push(Box::new(bar.stroke(3.0, PINK).cap(Cap::Round)));
|
let half = 1.5 + level * (middle - 3.0);
|
||||||
|
let x = 3.0 + index as f32 * (WIDTH - 6.0) / (BARS - 1) as f32;
|
||||||
|
|
||||||
|
body = body.move_to(x, middle - half).line_to(x, middle + half);
|
||||||
|
|
||||||
|
if level > 0.3 {
|
||||||
|
glow = glow.move_to(x, middle - half).line_to(x, middle + half);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Canvas::new().width(20.0).height(16.0).shapes(bars)
|
Canvas::new().width(WIDTH).height(HEIGHT).shapes(shapes![
|
||||||
|
glow.stroke(7.0, faded(PINK, 55)).cap(Cap::Round),
|
||||||
|
body.stroke(3.2, PINK).cap(Cap::Round),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// the loudest level in one of `groups` equal slices of the spectrum, low to high
|
||||||
|
fn band(levels: &[f32], group: usize, groups: usize) -> f32 {
|
||||||
|
let start = group * levels.len() / groups;
|
||||||
|
let end = ((group + 1) * levels.len() / groups).max(start + 1).min(levels.len());
|
||||||
|
|
||||||
|
levels[start..end].iter().copied().fold(0.0, f32::max)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
|||||||
+4
-12
@@ -16,8 +16,8 @@ pub const BARS: usize = 24;
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* the shown player's own sound as bars: `pw-record --target <its stream>`
|
* the shown player's own sound as bars: `pw-record --target <its stream>`
|
||||||
* piped into cava. only runs while the card is open and the player plays,
|
* piped into cava. runs while that player plays, open card or pill, and stops
|
||||||
* since every frame it sends redraws the island
|
* when it pauses, since every frame it sends redraws the island
|
||||||
*/
|
*/
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Spectrum {
|
pub struct Spectrum {
|
||||||
@@ -66,17 +66,9 @@ fn cava_config() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the first stream of the shown player, while the card is open and it plays
|
// the first stream of the shown player, while it plays
|
||||||
fn wanted_stream() -> Option<u32> {
|
fn wanted_stream() -> Option<u32> {
|
||||||
let (expanded, selected) = {
|
let selected = Island::read().selected.clone();
|
||||||
let island = Island::read();
|
|
||||||
|
|
||||||
(island.expanded(), island.selected.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
if !expanded {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let player = {
|
let player = {
|
||||||
let media = Media::read();
|
let media = Media::read();
|
||||||
|
|||||||
Executable
+142
@@ -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
|
||||||
Executable
+256
@@ -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()
|
||||||
Reference in New Issue
Block a user