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:
+117
-48
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user