Compare commits

5 Commits
Author SHA1 Message Date
server2 e847cbde73 tools: pick the live hyprland instance
$XDG_RUNTIME_DIR/hypr keeps folders of dead instances, and the newest one
isn't always alive; take the newest with a .socket.sock
2026-10-08 01:25:33 +02:00
server2 7844a98828 island: card on one monitor, live bars only on the focused one
- the card opens on the monitor that was clicked (keybinds: the focused
  monitor); the others keep their pill. moving the card to another monitor
  restarts its open animation there
- only the focused monitor's window (and the one with the card) reads
  Spectrum, so the other pills show still bars and rest
- hover lights only the pill under the pointer
- measured with music on 3 monitors: pill 10.7% of a core (was 27.5%),
  open card 20.7% (was 58%), ~62 frames a second in total, 0-1 slow
  frames per open/close (was 7-14), back to one island's cost
2026-10-08 01:25:33 +02:00
server2 967ff37b78 spectrum: kill a cava left by an earlier shell on start
stopping the shell leaves its cava running (parent 1), reading on from a
closed pipe and ignoring SIGTERM; every restart while music played leaked
one. on start, pkill -KILL -f our cava.conf before starting our own
2026-10-08 01:25:33 +02:00
server2 85416b501e island: on every monitor (experiment)
window_per_monitor with the same view: every monitor shows the island,
all sharing one state (open, hover, player). measured with music on 3
monitors: 180 frames a second in total, pill 27% of a core (was 9%),
open card 58% (was 21%), gpu 1.4 / 3.0 ms per frame each; open/close
drops 7-14 of ~175 frames (was 0-1)
2026-10-06 19:09:18 +02:00
server2 0f6fb88e58 tools: start waits for the old shell to exit
an old shell can take a few seconds to let go of amane's socket, and the
new one then panics with 'amane is already running'; wait up to 5 s
2026-10-06 19:09:11 +02:00
6 changed files with 95 additions and 23 deletions
+6 -1
View File
@@ -30,7 +30,9 @@ The equalizer: one `setsid -f pipewire -c eq-<key>.conf` process per player with
### The view (island.rs `view`) ### 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. - One `LayerWindow` per monitor (`window_per_monitor`, 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.
- All windows share the `Island` state. `opened_on` names the monitor the card is open on (others show their pill), `pointer_on` the monitor whose pill is hovered. Only the focused monitor's window (and the one with the card) reads `Spectrum`, so the others don't redraw for every cava frame: with music, pill ~11% of a core and open card ~21%, as with one island. `focused_monitor()` comes from `Workspaces`, which follows the mouse on Hyprland.
- `Spectrum::listen` first `pkill -KILL -f`s any cava running our `cava.conf`: a stopped shell leaves its cava behind, and cava ignores SIGTERM.
- `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. - `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. - `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. - `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.
@@ -96,6 +98,9 @@ tools/amane-remote.sh $H sh 'COMMAND' # anything inside the graphical sessi
- Pick a fake player with `ipc island next` until `status` names it, before anything that changes sound. - 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 island follows the focused monitor: read its geometry from `hyprctl layers -j` (namespace `magic-island`) right before each `grim` capture.
- With one window per monitor, `fps` and `anim` count every window's frames together; resting windows add ~1000 ms gaps (one frame a second for the time bar). Those aren't stalls.
- The desktop's `$XDG_RUNTIME_DIR/hypr/` keeps folders of dead Hyprland instances; the live one is the one with `.socket.sock`, which `amane-remote.sh` now picks. A shell started with a dead signature gets an empty `Workspaces` list.
- Once, a fresh shell exited with "lost the gpu (Device is lost)" (Mesa warns the Xe driver is experimental); starting it again worked.
- The user's pointer may be over the island and open or collapse it during a test; check `status` and capture right after `open`. - 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. - 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. - In `pkill -f` patterns over SSH, use `[p]ython3 ...` so the pattern doesn't match the remote shell itself.
+1
View File
@@ -10,6 +10,7 @@ A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), runn
- **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. - **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. - **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. - **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.
- **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's.
## Layout ## Layout
+76 -19
View File
@@ -5,9 +5,9 @@ use std::time::{Duration, Instant};
use amane::{ use amane::{
Animation, Arc, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Animation, Arc, Argument, Blend, 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, Monitor, 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, Workspaces, Zone, children, shapes,
}; };
use crate::art::Art; use crate::art::Art;
@@ -144,18 +144,28 @@ pub struct Island {
// each hovered spot's highlight, fading between 0 and 1 // each hovered spot's highlight, fading between 0 and 1
hover: HashMap<Spot, Animation>, hover: HashMap<Spot, Animation>,
/*
* every monitor has an island, but the card opens on one: the monitor it was last
* opened on (none: every monitor, without a workspace list), and the monitor whose
* pill the pointer was last on, so only that pill lights up
*/
opened_on: Option<String>,
pointer_on: Option<String>,
} }
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(OPEN_MS)).easing(Easing::Linear), open: opening(),
expanded: false, expanded: false,
selected: None, selected: None,
menu: None, menu: None,
scrub: None, scrub: None,
seek_shown_until: None, seek_shown_until: None,
hover: HashMap::new(), hover: HashMap::new(),
opened_on: None,
pointer_on: None,
} }
} }
@@ -220,13 +230,27 @@ struct Scene<'a> {
levels: &'a [f32], levels: &'a [f32],
} }
pub fn view() -> LayerWindow { pub fn view(monitor: &Monitor) -> LayerWindow {
let media = Media::read(); let media = Media::read();
let island = Island::read(); let island = Island::read();
let sound = Sound::read(); let sound = Sound::read();
let art = Art::read(); let art = Art::read();
let palette = Palette::read(); let palette = Palette::read();
let spectrum = Spectrum::read();
let here = monitor.name.as_str();
let focused = focused_monitor();
// the other monitors keep their pill while the card is open here
let opened_here = island.opened_on.as_deref().is_none_or(|name| name == here);
/*
* only the focused monitor (and the one with the open card) shows live levels:
* reading Spectrum is what redraws a window for every cava frame, so the other
* windows skip it, rest, and show still bars
*/
let live = focused.as_deref().is_none_or(|name| name == here) || (opened_here && island.expanded);
let spectrum = live.then(Spectrum::read);
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
let window_w = WIDE_W + 2.0 * SHADOW; let window_w = WIDE_W + 2.0 * SHADOW;
@@ -243,16 +267,17 @@ pub fn view() -> LayerWindow {
return window.visible(false); return window.visible(false);
}; };
let (grow, pill, reveal) = phases(island.open.value()); let (grow, pill, reveal) = phases(if opened_here { island.open.value() } else { 0.0 });
// the pill under the pointer widens a little and glows pink; the card doesn't // the pill under the pointer widens a little and glows pink; the card doesn't
let lift = island.lit(Spot::Island) * (1.0 - grow); let hovered_here = island.pointer_on.as_deref() == Some(here);
let lift = if hovered_here { island.lit(Spot::Island) } else { 0.0 } * (1.0 - grow);
/* /*
* live levels keep 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 !spectrum.levels.is_empty() { if !levels.is_empty() {
amane::request_frame(); amane::request_frame();
} }
@@ -271,12 +296,12 @@ pub fn view() -> LayerWindow {
island: &island, island: &island,
sound: &sound, sound: &sound,
art: &art.path, art: &art.path,
levels: &spectrum.levels, levels,
}; };
veiled(card(&scene), width, height, radius, reveal, &colors) veiled(card(&scene), width, height, radius, reveal, &colors)
} else if pill > 0.0 { } else if pill > 0.0 {
veiled(compact(player, &art.path, &spectrum.levels), width, height, radius, pill, &colors) veiled(compact(player, &art.path, levels), width, height, radius, pill, &colors)
} else { } else {
// mid-grow: the empty shape alone, the cheapest frames of the animation // mid-grow: the empty shape alone, the cheapest frames of the animation
Rectangle::new().width(0.0).height(0.0) Rectangle::new().width(0.0).height(0.0)
@@ -297,19 +322,29 @@ pub fn view() -> LayerWindow {
*/ */
let shape = shape.shadow(Shadow::drop(PINK).opacity(mix(0.3, 0.7, lift)).blur(22.0)); let shape = shape.shadow(Shadow::drop(PINK).opacity(mix(0.3, 0.7, lift)).blur(22.0));
let tapped = monitor.name.clone();
let hovered = monitor.name.clone();
let shape = shape let shape = shape
.align_child(Center, Start) .align_child(Center, Start)
.on_click(|_| tap()) .on_click(move |_| tap(&tapped))
.on_hover(|inside| { .on_hover(move |inside| {
if inside {
Island::write().pointer_on = Some(hovered.clone());
}
hovering(Spot::Island)(inside); hovering(Spot::Island)(inside);
if !inside { // leaving a pill on another monitor leaves the open card alone
let card_here = Island::read().opened_on.as_deref().is_none_or(|name| name == hovered);
if !inside && card_here {
collapse(); collapse();
} }
}) })
.child(face); .child(face);
let shape = if island.expanded { shape } else { shape.cursor(Pointer) }; let shape = if island.expanded && opened_here { shape } else { shape.cursor(Pointer) };
// clicks outside the island reach the windows underneath // clicks outside the island reach the windows underneath
let region = InputArea { let region = InputArea {
@@ -329,23 +364,45 @@ pub fn view() -> LayerWindow {
) )
} }
// a click on the island itself: closes an open dropdown, else opens the card // a click on the island of `monitor`: closes an open dropdown, else opens the card there
fn tap() { fn tap(monitor: &str) {
let mut island = Island::write(); let mut island = Island::write();
if island.menu.take().is_none() { if island.menu.take().is_none() {
island.expanded = true; open_on(&mut island, Some(monitor.to_string()));
island.open.to(1.0);
} }
} }
// keybinds open the card on the focused monitor
fn expand() { fn expand() {
let mut island = Island::write(); let focused = focused_monitor();
open_on(&mut Island::write(), focused);
}
fn open_on(island: &mut Island, monitor: Option<String>) {
// open on another monitor: that one grows from its pill, this one shows its pill again
if island.opened_on != monitor {
island.open = opening();
island.opened_on = monitor;
}
island.expanded = true; island.expanded = true;
island.open.to(1.0); island.open.to(1.0);
} }
// the open timeline, at rest on the pill
fn opening() -> Animation {
Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear)
}
// the monitor of the focused workspace, which on hyprland follows the mouse; none without a workspace list
fn focused_monitor() -> Option<String> {
let workspaces = Workspaces::read();
workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from)
}
fn collapse() { fn collapse() {
let mut island = Island::write(); let mut island = Island::write();
+2 -1
View File
@@ -10,5 +10,6 @@ fn main() {
// the card's glow follows the cover art, which art::Art copies to this one path // the card's glow follows the cover art, which art::Art copies to this one path
Palette::write().open(&art::palette_file(), 8); Palette::write().open(&art::palette_file(), 8);
App::new().font("Fira Code").ipc("island", island::command).window(island::view).run(); // one island on every monitor; the card opens only on one of them
App::new().font("Fira Code").ipc("island", island::command).window_per_monitor(island::view).run();
} }
+6
View File
@@ -35,6 +35,12 @@ impl Service for Spectrum {
fn listen() { fn listen() {
let config = art::folder().join("cava.conf"); let config = art::folder().join("cava.conf");
/*
* a cava left by an earlier shell: stopping the shell doesn't stop its cava,
* which reads on from a closed pipe and ignores SIGTERM. none of ours runs yet
*/
let _ = Command::new("pkill").arg("-KILL").arg("-f").arg(&config).status();
let _ = fs::write(&config, cava_config()); let _ = fs::write(&config, cava_config());
let mut running: Option<Capture> = None; let mut running: Option<Capture> = None;
+4 -2
View File
@@ -27,7 +27,7 @@ session='uid=$(id -u)
export XDG_RUNTIME_DIR=/run/user/$uid export XDG_RUNTIME_DIR=/run/user/$uid
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus 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 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 HYPRLAND_INSTANCE_SIGNATURE=$(cd "$XDG_RUNTIME_DIR/hypr" 2>/dev/null && for d in $(ls -t); do [ -S "$d/.socket.sock" ] && echo "$d" && break; done)
export PATH=$HOME/.cargo/bin:$PATH export PATH=$HOME/.cargo/bin:$PATH
mkdir -p '"$work"' && cd '"$work"' mkdir -p '"$work"' && cd '"$work"'
' '
@@ -52,7 +52,9 @@ build)
;; ;;
start) start)
# newer CLIs save ~/.cache/amane/amane-shell; older ones only leave cargo's output # 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 # the old shell can take a few seconds to let go of amane's socket; wait for it, or the new one panics
remote 'test -f shell.pid && kill $(cat shell.pid) 2>/dev/null
for i in $(seq 50); do pgrep -F shell.pid > /dev/null 2>&1 || break; sleep 0.1; done
binary=~/.cache/amane/amane-shell binary=~/.cache/amane/amane-shell
test -x "$binary" || binary=~/.cache/amane/project/target/debug/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 AMANE_FRAMES=1 setsid -f sh -c "echo \$\$ > shell.pid; exec $binary" > shell.log 2>&1