island: open in two phases, live levels in the pill

- one linear timeline: the pill fades out, the empty shape grows, then the
  card fades in on the settled shape (and backwards on close)
- fades use a surface-coloured veil instead of .opacity(), and the shape
  has no clip, so neither costs a gpu layer
- measured on the desktop: open went from 11 frames over 20 ms to 1,
  close from 4 to 0
- the spectrum capture runs whenever the shown player plays, and the pill
  shows it as 7 mirrored, glowing bars (about 9% of one core while playing)
This commit is contained in:
server2
2026-10-06 03:44:00 +02:00
parent 93c57b93c4
commit e7ff054fb1
2 changed files with 121 additions and 60 deletions
+117 -48
View File
@@ -3,7 +3,7 @@ use std::thread;
use std::time::{Duration, Instant};
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,
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path,
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack,
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::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
const COMPACT_W: f32 = 200.0;
const COMPACT_W: f32 = 220.0;
const COMPACT_H: f32 = 38.0;
const WIDE_W: f32 = 560.0;
const WIDE_RADIUS: f32 = 28.0;
@@ -111,7 +120,7 @@ pub struct Island {
impl Service for Island {
fn new() -> 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,
selected: None,
menu: None,
@@ -124,12 +133,6 @@ impl Service for Island {
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
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));
@@ -170,23 +173,24 @@ pub fn view() -> LayerWindow {
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
*/
if island.expanded && !spectrum.levels.is_empty() {
if !spectrum.levels.is_empty() {
amane::request_frame();
}
let width = mix(COMPACT_W, WIDE_W, open);
let height = mix(COMPACT_H, WIDE_H, open);
let width = mix(COMPACT_W, WIDE_W, grow);
let height = mix(COMPACT_H, WIDE_H, grow);
// the two faces cross-fade at the halfway point, while the shape keeps growing
let face = if open < 0.5 {
compact(player, &art.path).opacity(1.0 - 2.0 * open)
} else {
// solid, tinted toward the cover: gradients were this iGPU's most expensive fill
let surface = tint(palette.dominant());
// 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 {
player,
players: media.players(),
@@ -196,34 +200,31 @@ pub fn view() -> LayerWindow {
levels: &spectrum.levels,
};
// an opacity of 1 still costs a gpu layer, so the settled card has none
if open >= 1.0 { card(&scene) } else { card(&scene).opacity(2.0 * open - 1.0) }
veiled(card(&scene), WIDE_W, WIDE_H, WIDE_RADIUS, reveal, surface)
} else if pill > 0.0 {
veiled(compact(player, &art.path, &spectrum.levels), COMPACT_W, COMPACT_H, COMPACT_H / 2.0, pill, surface)
} 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()
.width(width)
.height(height)
.radius(mix(COMPACT_H / 2.0, WIDE_RADIUS, open))
/*
* 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()))
.radius(mix(COMPACT_H / 2.0, WIDE_RADIUS, grow))
.fill(surface)
.border(1.0, EDGE);
/*
* 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
* tinted gradient set it apart), the pill and the morph keep theirs
* 60 times a second: the shadow belongs to the pill and the grow, and fades out
* 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 };
/*
* the clip is only needed while the shape morphs and the face overflows it; a clip
* 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 = if reveal < 1.0 {
shape.shadow(Shadow::drop("#000000").opacity(0.55 * (1.0 - reveal)).blur(24.0).offset(0.0, 10.0))
} else {
shape
};
let shape = shape
.align_child(Center, Start)
@@ -365,16 +366,46 @@ fn mix(from: f32, to: f32, amount: f32) -> f32 {
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 solid surface by laying that surface over it,
* thinning as `shown` rises: the same picture as .opacity(shown), without a gpu layer.
* inset by the 1 px rim, so the border stays visible
*/
fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, surface: Color) -> Rectangle {
if shown >= 1.0 {
return face;
}
let veil = Rectangle::new()
.width(width - 2.0)
.height(height - 2.0)
.radius(radius - 1.0)
.fill(faded(surface, ((1.0 - shown) * 255.0).round() as u8))
.translate(1.0, 1.0);
Rectangle::new().width(width).height(height).child(Stack::new(children![face, veil]).width(width).height(height))
}
fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha)
}
// the pill: cover, title, level bars
fn compact(player: &MediaPlayer, art: &str) -> Rectangle {
// the pill: cover, title, the player's live levels
fn compact(player: &MediaPlayer, art: &str, levels: &[f32]) -> Rectangle {
Rectangle::new()
.width(COMPACT_W)
.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(
Row::new(children![
cover(art, 26.0, 1.5, false),
@@ -383,7 +414,7 @@ fn compact(player: &MediaPlayer, art: &str) -> Rectangle {
.height(COMPACT_H)
.align_child(Start, Center)
.child(Text::new(title(player)).size(12.0).color(TEXT).elide()),
levels(player.playing()),
pill_levels(player.playing(), levels),
])
.gap(10.0)
.align(Center),
@@ -1032,23 +1063,61 @@ fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
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 mirrored around the bass in the middle, 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 {
return icon(Glyph::Pause, 13.0, DIM);
}
let mut bars: Vec<Box<dyn Shape>> = Vec::new();
let middle = BARS / 2;
for (index, height) in [6.0, 13.0, 9.0, 11.0].into_iter().enumerate() {
let x = 2.5 + index as f32 * 5.0;
let mut glow = Path::new();
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 distance = index.abs_diff(middle);
bars.push(Box::new(bar.stroke(3.0, PINK).cap(Cap::Round)));
let level = if levels.is_empty() {
[0.85, 0.55, 0.7, 0.35][distance]
} else {
/*
* the loudest band of this bar's quarter 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, distance, middle + 1) * 1.4).min(1.0)
};
let half = 1.5 + level * (HEIGHT / 2.0 - 3.0);
let x = 3.0 + index as f32 * (WIDTH - 6.0) / (BARS - 1) as f32;
body = body.move_to(x, HEIGHT / 2.0 - half).line_to(x, HEIGHT / 2.0 + half);
if level > 0.3 {
glow = glow.move_to(x, HEIGHT / 2.0 - half).line_to(x, HEIGHT / 2.0 + 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)]
+4 -12
View File
@@ -16,8 +16,8 @@ pub const BARS: usize = 24;
/*
* 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,
* since every frame it sends redraws the island
* piped into cava. runs while that player plays, open card or pill, and stops
* when it pauses, since every frame it sends redraws the island
*/
#[derive(Default)]
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> {
let (expanded, selected) = {
let island = Island::read();
(island.expanded(), island.selected.clone())
};
if !expanded {
return None;
}
let selected = Island::read().selected.clone();
let player = {
let media = Media::read();