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
This commit is contained in:
server2
2026-10-08 01:25:33 +02:00
parent 967ff37b78
commit 7844a98828
4 changed files with 85 additions and 22 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`)
- 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.
- `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.
@@ -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.
- 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`.
- 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.
+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.
- **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.
- **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
+76 -19
View File
@@ -5,9 +5,9 @@ use std::time::{Duration, Instant};
use amane::{
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,
Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children, shapes,
};
use crate::art::Art;
@@ -144,18 +144,28 @@ pub struct Island {
// each hovered spot's highlight, fading between 0 and 1
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 {
fn new() -> Self {
Self {
open: Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear),
open: opening(),
expanded: false,
selected: None,
menu: None,
scrub: None,
seek_shown_until: None,
hover: HashMap::new(),
opened_on: None,
pointer_on: None,
}
}
@@ -220,13 +230,27 @@ struct Scene<'a> {
levels: &'a [f32],
}
pub fn view() -> LayerWindow {
pub fn view(monitor: &Monitor) -> LayerWindow {
let media = Media::read();
let island = Island::read();
let sound = Sound::read();
let art = Art::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;
@@ -243,16 +267,17 @@ pub fn view() -> LayerWindow {
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
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
* 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();
}
@@ -271,12 +296,12 @@ pub fn view() -> LayerWindow {
island: &island,
sound: &sound,
art: &art.path,
levels: &spectrum.levels,
levels,
};
veiled(card(&scene), width, height, radius, reveal, &colors)
} 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 {
// mid-grow: the empty shape alone, the cheapest frames of the animation
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 tapped = monitor.name.clone();
let hovered = monitor.name.clone();
let shape = shape
.align_child(Center, Start)
.on_click(|_| tap())
.on_hover(|inside| {
.on_click(move |_| tap(&tapped))
.on_hover(move |inside| {
if inside {
Island::write().pointer_on = Some(hovered.clone());
}
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();
}
})
.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
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
fn tap() {
// a click on the island of `monitor`: closes an open dropdown, else opens the card there
fn tap(monitor: &str) {
let mut island = Island::write();
if island.menu.take().is_none() {
island.expanded = true;
island.open.to(1.0);
open_on(&mut island, Some(monitor.to_string()));
}
}
// keybinds open the card on the focused monitor
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.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() {
let mut island = Island::write();
+2 -2
View File
@@ -10,6 +10,6 @@ fn main() {
// the card's glow follows the cover art, which art::Art copies to this one path
Palette::write().open(&art::palette_file(), 8);
// one island on every monitor, all showing the same player and state
App::new().font("Fira Code").ipc("island", island::command).window_per_monitor(|_| 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();
}