diff --git a/src/island.rs b/src/island.rs index 4860d95..b92eb7d 100644 --- a/src/island.rs +++ b/src/island.rs @@ -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> = 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)] diff --git a/src/spectrum.rs b/src/spectrum.rs index 12fea22..44dcd11 100644 --- a/src/spectrum.rs +++ b/src/spectrum.rs @@ -16,8 +16,8 @@ pub const BARS: usize = 24; /* * the shown player's own sound as bars: `pw-record --target ` - * 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 { - 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();