use std::collections::HashMap; use std::f32::consts::{PI, TAU}; use std::thread; 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, Monitor, Padding, Palette, Parent, Path, Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children, shapes, }; use crate::art::Art; 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; // hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW const HOVER_MS: u64 = 220; const PILL_HOVER_GROW: f32 = 10.0; // how much thicker, in px, the transport icons get when hovered const BOLD: f32 = 2.0; // sizes, in pixels const COMPACT_W: f32 = 220.0; const COMPACT_H: f32 = 38.0; const WIDE_W: f32 = 560.0; const WIDE_RADIUS: f32 = 28.0; const PAD: f32 = 22.0; const INNER_W: f32 = WIDE_W - 2.0 * PAD; const GAP: f32 = 16.0; const TOP_GAP: f32 = 6.0; const SHADOW: f32 = 34.0; // the cd: its box, the cover, the hole, and how far the candles reach past it const DISC_BOX: f32 = 204.0; const DISC: f32 = 136.0; const HOLE: f32 = 26.0; const CANDLES: usize = 40; const CANDLE_REACH: f32 = 20.0; const FLAME: Color = Color::rgb(255, 228, 240); const SIDE_GAP: f32 = 20.0; const SIDE_W: f32 = INNER_W - DISC_BOX - SIDE_GAP; const HEADER_H: f32 = DISC_BOX; const CHIP_H: f32 = 24.0; const OUTPUT_CHIP_MAX: f32 = 150.0; const VOLUME_CHIP_W: f32 = 60.0; const BAR_H: f32 = 8.0; const KNOB: f32 = 16.0; const CONTROLS_H: f32 = 44.0; const EQ_HEAD_H: f32 = 26.0; const SLIDER_H: f32 = 140.0; const TRACK_W: f32 = 10.0; const LABEL_H: f32 = 14.0; const EQ_H: f32 = SLIDER_H + 8.0 + LABEL_H; const CURVE_STEPS: usize = 72; const PRESET_H: f32 = 30.0; const PRESET_GAP: f32 = 8.0; const PRESET_W: f32 = (INNER_W - 3.0 * PRESET_GAP) / 4.0; const PRESETS_H: f32 = 2.0 * PRESET_H + PRESET_GAP; // every section has a fixed height, so the window is sized up front const WIDE_H: f32 = 2.0 * PAD + HEADER_H + 1.0 + EQ_HEAD_H + EQ_H + PRESETS_H + 4.0 * GAP; // the dropdowns open under the chips const MENU_X: f32 = PAD + DISC_BOX + SIDE_GAP; const MENU_Y: f32 = PAD + 92.0; const MENU_W: f32 = SIDE_W; const MENU_ROW_H: f32 = 30.0; const MENU_ROWS: usize = 10; // letters that fit a dropdown row and the output chip, for cutting long device names in the middle const MENU_LETTERS: usize = 30; const CHIP_LETTERS: usize = 13; // covers are decoded once at this size, for both the pill and the cd const THUMB: u32 = 256; // Fira Code renders about 0.64 em per letter here, so a chip can be sized to its label (plus slack) const LETTER: f32 = 0.66; const PINK: Color = Color::rgb(247, 168, 200); const PINK_DEEP: Color = Color::rgb(150, 82, 122); const INK: Color = Color::rgb(40, 18, 32); const TEXT: Color = Color::rgb(241, 236, 246); const DIM: Color = Color::rgba(205, 192, 218, 160); const CHIP: Color = Color::rgba(255, 255, 255, 18); const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34); // a hovered chip or button; CLEAR is its starting point where there is no fill, white so the fade stays grey-free const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46); const CLEAR: Color = Color::rgba(255, 255, 255, 0); const TRACK: Color = Color::rgba(255, 255, 255, 24); const EDGE: Color = Color::rgba(255, 255, 255, 26); const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18); const MENU_BG: Color = Color::rgb(35, 26, 44); #[derive(Clone, Copy, PartialEq)] pub enum Menu { Output, Player, } // everything that lights up under the pointer #[derive(Clone, Copy, PartialEq, Eq, Hash)] enum Spot { Island, Output, Player, Volume, Previous, PlayPause, Next, Preset(&'static str), Row(usize), Band(usize), Timeline, } pub struct Island { // 0 is the compact pill, 1 the open card; everything in between is drawn from it open: Animation, expanded: bool, // the player picked in the dropdown; none follows whichever one plays pub selected: Option, menu: Option, // where the time bar is pressed, 0 to 1, and how long a released seek stays on screen scrub: Option, seek_shown_until: Option, // each hovered spot's highlight, fading between 0 and 1 hover: HashMap, /* * 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, pointer_on: Option, // a clicked play/pause, (player, playing), shown until the player has answered playing_now: Option<(String, bool)>, } impl Service for Island { fn new() -> Self { Self { open: opening(), expanded: false, selected: None, menu: None, scrub: None, seek_shown_until: None, hover: HashMap::new(), opened_on: None, pointer_on: None, playing_now: None, } } // changes only through input fn listen() {} } impl Island { // how far the highlight on that spot has faded in, 0 to 1 fn lit(&self, spot: Spot) -> f32 { self.hover.get(&spot).map_or(0.0, Animation::value) } /* * amane tells hover targets apart by their place in the frame, so a button that * disappears never hears the pointer leave: highlights of buttons that come and go * with the menu or the card are dropped when those change */ fn set_menu(&mut self, menu: Option) { self.menu = menu; self.hover.retain(|spot, _| !matches!(spot, Spot::Row(_))); } } // an on_hover handler easing that spot's highlight in on enter and out on leave fn hovering(spot: Spot) -> impl Fn(bool) + 'static { move |inside| { let mut island = Island::write(); // one button at a time, so a leave amane lost (see set_menu) can't keep another one lit if inside && spot != Spot::Island { for (other, highlight) in &mut island.hover { if *other != spot && *other != Spot::Island { highlight.to(0.0); } } } island .hover .entry(spot) .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS))) .to(if inside { 1.0 } else { 0.0 }); } } // 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)); picked.or_else(|| media.active()) } // everything one frame of the card reads struct Scene<'a> { player: &'a MediaPlayer, players: &'a [MediaPlayer], island: &'a Island, sound: &'a Sound, art: &'a str, levels: &'a [f32], } 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 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 = LayerWindow::new() .width(window_w) .height(TOP_GAP + WIDE_H + SHADOW) .anchor_vertical(Vertical::Top) // Overlay: on a desktop of fullscreen windows, Top is hidden behind every one of them .layer(Layer::Overlay) .space(Zone::Ignore) .namespace("magic-island"); let Some(player) = shown(&media, island.selected.as_deref()) else { return window.visible(false); }; 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 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 !levels.is_empty() { amane::request_frame(); } let width = mix(COMPACT_W + PILL_HOVER_GROW * lift, WIDE_W, grow); let height = mix(COMPACT_H, WIDE_H, grow); let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow); // a gradient of the cover's own colours, for the pill and the card alike let colors = backdrop_colors(&palette); // 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(), island: &island, sound: &sound, art: &art.path, levels, }; veiled(card(&scene), width, height, radius, reveal, &colors) } else if pill > 0.0 { veiled(compact(player, playing(player, &island), &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) }; let shape = Rectangle::new() .width(width) .height(height) .radius(radius) .fill(backdrop(&colors, 255)) .border(1.0, Color::blend(EDGE, faded(PINK, 110), lift)); /* * one faint pink glow around the island, pill and card alike, stronger while the * pill is hovered. blur 22 fades out within ~28 px (amane blurs with a deviation * of blur / 2), inside the window's SHADOW margin. no clip anywhere: neither face * ever overflows the shape */ 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(move |_| tap(&tapped)) .on_hover(move |inside| { if inside { Island::write().pointer_on = Some(hovered.clone()); } hovering(Spot::Island)(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 && opened_here { shape } else { shape.cursor(Pointer) }; // clicks outside the island reach the windows underneath let region = InputArea { x: ((window_w - width) / 2.0).floor() as i32, y: TOP_GAP as i32, width: width.ceil() as i32, height: height.ceil() as i32, }; window.input_region(vec![region]).child( Rectangle::new() .width(Parent) .height(Parent) .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) .align_child(Center, Start) .child(shape), ) } // 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() { open_on(&mut island, Some(monitor.to_string())); } } // keybinds open the card on the focused monitor fn expand() { let focused = focused_monitor(); open_on(&mut Island::write(), focused); } fn open_on(island: &mut Island, monitor: Option) { // 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 { 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(); island.expanded = false; island.set_menu(None); island.open.to(0.0); // the card's buttons are about to go, see set_menu island.hover.retain(|spot, _| *spot == Spot::Island); } fn toggle_menu(menu: Menu) { let mut island = Island::write(); let menu = if island.menu == Some(menu) { None } else { Some(menu) }; island.set_menu(menu); } /* * `amane ipc call island ...` for keybinds (and tests): * open | close | toggle | next | play-pause | menu output|player|close | preset | output | status */ pub fn command(arguments: &[String]) -> String { let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); let selected = Island::read().selected.clone(); let shown_player = shown(&Media::read(), selected.as_deref()).map(|player| (player.name().to_string(), player.identity().to_string())); match words.as_slice() { ["open"] => expand(), ["close"] => collapse(), ["toggle"] => { let expanded = Island::read().expanded; if expanded { collapse() } else { expand() } } ["next"] => next_player(), ["play-pause"] => { // copied out, so both locks are let go before play_pause writes the island let player = shown(&Media::read(), selected.as_deref()).cloned(); let Some(player) = player else { return String::from("no player"); }; let before = playing(&player, &Island::read()); play_pause(&player, before); } ["menu", which] => { let menu = match *which { "output" => Some(Menu::Output), "player" => Some(Menu::Player), _ => None, }; Island::write().set_menu(menu); } ["preset", name] => { let Some((player, identity)) = shown_player else { return String::from("no player"); }; if !Sound::preset(&player, &identity, name) { return format!("no preset named {name}"); } } ["output", device] => { let Some((player, identity)) = shown_player else { return String::from("no player"); }; Sound::set_output(&player, &identity, device); } ["status"] => { let island = Island::read(); let identity = shown_player.map_or(String::from("none"), |(_, identity)| identity); return format!("player={identity} expanded={} menu={}", island.expanded, island.menu.is_some()); } _ => return String::from("usage: island open|close|toggle|next|play-pause|menu |preset |output |status"), } String::from("ok") } // the next player, wrapping around fn next_player() { let selected = Island::read().selected.clone(); let next = { let media = Media::read(); let players = media.players(); let current = shown(&media, selected.as_deref()) .and_then(|player| players.iter().position(|other| other.name() == player.name())); current.and_then(|index| players.get((index + 1) % players.len())).map(|player| player.name().to_string()) }; Island::write().selected = next; } 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 backdrop by laying the same gradient over it, * thinning as `shown` rises: the same picture as .opacity(shown), without a gpu layer. * the veil spans the whole shape (minus the 1 px rim), so its gradient lines up with * the backdrop's exactly; amane places gradients by each rectangle's own bounds */ fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle { if shown >= 1.0 { return face; } let veil = Rectangle::new() .width(width - 2.0) .height(height - 2.0) .radius(radius - 1.0) .fill(backdrop(colors, ((1.0 - shown) * 255.0).round() as u8)) .translate(1.0, 1.0); let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face); Rectangle::new().width(width).height(height).child(Stack::new(children![face, veil]).width(width).height(height)) } // the cover's colours for the backdrop: most common, second most common, darkest fn backdrop_colors(palette: &Palette) -> [Color; 3] { let first = palette.dominant(); let second = palette.colors().get(1).copied().unwrap_or(first); // darker toward the bottom right, where the equalizer and presets sit [deepen(first, 0.22), deepen(second, 0.15), deepen(palette.background(), 0.07)] } /* * one gradient for the pill, the card and the veils; 150 degrees runs from the * top left to the bottom right, like the reference's light */ fn backdrop(colors: &[Color; 3], alpha: u8) -> Gradient { Gradient::linear(150.0, [(0.0, faded(colors[0], alpha)), (0.55, faded(colors[1], alpha)), (1.0, faded(colors[2], alpha))]) } // scaled down, keeping its hue, until its brightness is at most `max` (0 to 1), so white text reads on it fn deepen(color: Color, max: f32) -> Color { let (red, green, blue) = (f32::from(color.red()), f32::from(color.green()), f32::from(color.blue())); let brightness = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0; let scale = if brightness > max { max / brightness } else { 1.0 }; Color::rgb((red * scale) as u8, (green * scale) as u8, (blue * scale) as u8) } fn faded(color: Color, alpha: u8) -> Color { Color::rgba(color.red(), color.green(), color.blue(), alpha) } // the pill: cover, title, the player's live levels fn compact(player: &MediaPlayer, playing: bool, art: &str, levels: &[f32]) -> Rectangle { Rectangle::new() .width(COMPACT_W) .height(COMPACT_H) .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), Rectangle::new() .width(Parent) .height(COMPACT_H) .align_child(Start, Center) .child(Text::new(title(player)).size(12.0).color(TEXT).elide()), pill_levels(playing, levels), ]) .gap(10.0) .align(Center), ) } fn card(scene: &Scene) -> Rectangle { let mut layers: Vec> = vec![Box::new(content(scene))]; if let Some(menu) = scene.island.menu { layers.push(Box::new(menu_panel(scene, menu))); } Rectangle::new().width(WIDE_W).height(WIDE_H).child(Stack::new(layers).width(WIDE_W).height(WIDE_H)) } fn content(scene: &Scene) -> Rectangle { Rectangle::new().width(WIDE_W).height(WIDE_H).padding(PAD).child( Column::new(children![ Row::new(children![disc(scene), side(scene)]).gap(SIDE_GAP).align(Center).width(INNER_W).height(HEADER_H), Rectangle::new().width(INNER_W).height(1.0).fill(EDGE), eq_header(scene), equalizer(scene), presets(scene), ]) .gap(GAP), ) } // the cd: a still cover with a hole, inside a ring of candles that move with this player's sound fn disc(scene: &Scene) -> Stack { let offset = (DISC_BOX - DISC) / 2.0; let player = scene.player.name().to_string(); let volume = scene.sound.channel(scene.player.name()).map_or(100, |channel| channel.volume); let cd = Rectangle::new() .width(DISC) .height(DISC) .radius(Full) .fill(PINK) .shadow(Shadow::drop(PINK).opacity(0.35).blur(18.0)) .align_child(Center, Center) .translate(offset, offset) // scroll over the cd for this player's volume .on_scroll(move |scroll| Sound::set_volume(&player, stepped(volume, scroll))) .child(cover(scene.art, DISC - 6.0, 0.0, true)); Stack::new(children![ring(scene.levels), cd]).width(DISC_BOX).height(DISC_BOX) } fn stepped(volume: u8, scroll: Scroll) -> u8 { let step = if scroll.y < 0.0 { 5 } else { -5 }; (i32::from(volume) + step).clamp(0, 100) as u8 } /* * every candle goes into one path per look (glow, body, flame) instead of a * shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing */ fn ring(levels: &[f32]) -> Canvas { let center = DISC_BOX / 2.0; let inner = DISC / 2.0 + 7.0; let mut glow = Path::new(); let mut body = Path::new(); let mut flames = Path::new(); for index in 0..CANDLES { // a gentle curve lifts the quiet bands, so the ring isn't all bass let level = mirrored(levels, index).powf(0.75); let (sin, cos) = (index as f32 / CANDLES as f32 * TAU).sin_cos(); let reach = inner + 3.0 + level * CANDLE_REACH; let (x0, y0) = (center + sin * inner, center - cos * inner); let (x1, y1) = (center + sin * reach, center - cos * reach); body = body.move_to(x0, y0).line_to(x1, y1); // the loud ones glow, and burn at the tip if level > 0.25 { glow = glow.move_to(x0, y0).line_to(x1, y1); } if level > 0.45 { flames = flames.move_to(x1 - sin * 0.5, y1 + cos * 0.5).line_to(x1, y1); } } Canvas::new().width(DISC_BOX).height(DISC_BOX).shapes(shapes![ glow.stroke(9.0, faded(PINK, 50)).cap(Cap::Round), body.stroke(4.5, faded(PINK, 215)).cap(Cap::Round), flames.stroke(4.0, FLAME).cap(Cap::Round), ]) } // low frequencies at the top, rising down both sides, so the ring is symmetric fn mirrored(levels: &[f32], index: usize) -> f32 { if levels.is_empty() { return 0.0; } let half = CANDLES / 2; let step = if index < half { index } else { CANDLES - 1 - index }; levels[(step * levels.len() / half).min(levels.len() - 1)] } fn side(scene: &Scene) -> Column { let player = scene.player; let artist = if player.artist().is_empty() { player.identity() } else { player.artist() }; Column::new(children![ Column::new(children![ Text::new(title(player)).size(19.0).weight(Weight::Bold).color(TEXT).elide(), Text::new(format!("BY {artist}")).size(12.0).color(DIM).elide(), chips(scene), ]) .gap(8.0) .width(SIDE_W), timeline(scene), controls(player, scene.island), ]) .justify(SpaceBetween) .width(SIDE_W) .height(HEADER_H) } // output device and player, each opening its dropdown fn chips(scene: &Scene) -> Row { let channel = scene.sound.channel(scene.player.name()); let device = channel.and_then(|channel| channel.output.as_ref()); let output_label = device.map_or(String::from("Output"), |device| parse::middle_ellipsis(sink_label(device), CHIP_LETTERS)); let output_w = label_width(&output_label, 11.0, 50.0).clamp(90.0, OUTPUT_CHIP_MAX); let player_label = format!("VIA {}", scene.player.identity()); let player_w = label_width(&player_label, 11.0, 22.0).clamp(50.0, SIDE_W - output_w - 8.0); let menu = scene.island.menu; let output_lit = scene.island.lit(Spot::Output); let player_lit = scene.island.lit(Spot::Player); let output = Rectangle::new() .width(output_w) .height(CHIP_H) .radius(6.0) .fill(Color::blend(if menu == Some(Menu::Output) { CHIP_OPEN } else { CHIP }, CHIP_HOVER, output_lit)) .padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 }) .align_child(Start, Center) .cursor(Pointer) .scale(1.0 + 0.04 * output_lit) .on_click(|_| toggle_menu(Menu::Output)) .on_hover(hovering(Spot::Output)) .child( Row::new(children![ icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, PINK), Text::new(output_label).size(11.0).color(TEXT).elide(), icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, output_lit)), ]) .gap(6.0) .align(Center), ); let player = Rectangle::new() .width(player_w) .height(CHIP_H) .radius(6.0) .fill(Color::blend(if menu == Some(Menu::Player) { CHIP_OPEN } else { CLEAR }, CHIP_HOVER, player_lit)) .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 }) .align_child(Start, Center) .cursor(Pointer) .scale(1.0 + 0.04 * player_lit) .on_click(|_| toggle_menu(Menu::Player)) .on_hover(hovering(Spot::Player)) .child( Row::new(children![ Text::new(player_label).size(11.0).color(Color::blend(DIM, TEXT, player_lit)).elide(), icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, player_lit)), ]) .gap(6.0) .align(Center), ); Row::new(children![output, player]).gap(8.0).align(Center).width(SIDE_W).height(CHIP_H) } // a monospace label's width plus whatever surrounds it fn label_width(label: &str, size: f32, extra: f32) -> f32 { label.chars().count() as f32 * size * LETTER + extra } fn timeline(scene: &Scene) -> Column { let player = scene.player; let island = scene.island; let length = player.length().as_secs_f32(); let position = player.position().as_secs_f32(); // a pressed or just-released bar shows where it was pressed, not the last poll let scrubbing = island.seek_shown_until.is_none_or(|until| Instant::now() < until); let polled = if length > 0.0 { position / length } else { 0.0 }; let fraction = island.scrub.filter(|_| scrubbing).unwrap_or(polled).clamp(0.0, 1.0); // a live stream has no length to scale by, so it just counts up let shown = if length > 0.0 { fraction * length } else { position }; // hovered, the track thickens and brightens and the knob grows let lit = island.lit(Spot::Timeline); let bar_h = BAR_H + 4.0 * lit; let bar = Rectangle::new().width(SIDE_W).height(KNOB).align_child(Start, Center).child( Stack::new(children![ Rectangle::new().width(SIDE_W).height(bar_h).radius(Full).fill(Color::blend(TRACK, CHIP_HOVER, lit)).child( Rectangle::new() .width((SIDE_W * fraction).max(bar_h)) .height(bar_h) .radius(Full) .fill(Color::blend(PINK, FLAME, 0.4 * lit)), ), Rectangle::new() .width(KNOB) .height(KNOB) .radius(Full) .fill(Color::WHITE) .shadow(Shadow::drop("#000000").opacity(0.4).blur(6.0)) .translate(SIDE_W * fraction - KNOB / 2.0, (bar_h - KNOB) / 2.0) .scale(1.0 + 0.25 * lit), ]) .width(SIDE_W) .height(bar_h), ); // live streams have no length, so there is nowhere to seek to let bar = if length > 0.0 { let name = player.name().to_string(); bar.cursor(Pointer) .on_hover(|inside| { hovering(Spot::Timeline)(inside); leave_bar(inside); }) .on_drag(|point| { let mut island = Island::write(); island.scrub = Some((point.x / SIDE_W).clamp(0.0, 1.0)); island.seek_shown_until = None; }) .on_click(move |_| seek(&name, length)) } else { bar }; let total = if length > 0.0 { clock(length) } else { String::from("LIVE") }; Column::new(children![ bar, Row::new(children![Text::new(clock(shown)).size(11.0).color(DIM), Text::new(total).size(11.0).color(DIM)]) .justify(SpaceBetween) .width(SIDE_W), ]) .gap(4.0) .width(SIDE_W) } fn leave_bar(inside: bool) { let mut island = Island::write(); // dragged off the bar and let go elsewhere: no seek, back to the real position if !inside && island.seek_shown_until.is_none() { island.scrub = None; } } // runs on its own thread: d-bus waits for the player, and handlers run on the drawing thread fn seek(player: &str, length: f32) { let fraction = { let mut island = Island::write(); let Some(fraction) = island.scrub else { return; }; island.seek_shown_until = Some(Instant::now() + Duration::from_millis(1500)); fraction }; let player = player.to_string(); thread::spawn(move || { let bus = Bus::session(); let path = "/org/mpris/MediaPlayer2"; let interface = "org.mpris.MediaPlayer2.Player"; // mpris counts microseconds; Seek moves relative to where the player is right now let now = bus.property(&player, path, interface, "Position").number(); let target = f64::from(fraction) * f64::from(length) * 1_000_000.0; bus.call(&player, path, interface, "Seek", &[Argument::Long((target - now) as i64)]); }); } fn clock(seconds: f32) -> String { let seconds = seconds.max(0.0) as u64; if seconds >= 3600 { return format!("{}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60); } format!("{:02}:{:02}", seconds / 60, seconds % 60) } fn controls(player: &MediaPlayer, island: &Island) -> Row { let previous = player.clone(); let toggle = player.clone(); let next = player.clone(); let shown = playing(player, island); // hovered, the transport icons go bold: thicker strokes, nothing else let lit = island.lit(Spot::PlayPause); let middle = Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center); let middle = if shown { let bar = || Rectangle::new().width(10.0 + BOLD * lit).height(30.0 + BOLD * lit).radius(3.0).fill(PINK); middle.child(Row::new(children![bar(), bar()]).gap(7.0 - BOLD * lit)) } else { middle.child(bold_icon(Glyph::Play, 30.0, PINK, BOLD * lit)) }; Row::new(children![ button(Glyph::Previous, island.lit(Spot::Previous)).on_hover(hovering(Spot::Previous)).on_click(move |_| { previous.previous(); refresh_soon(None); }), middle.cursor(Pointer).on_hover(hovering(Spot::PlayPause)).on_click(move |_| play_pause(&toggle, shown)), button(Glyph::Next, island.lit(Spot::Next)).on_hover(hovering(Spot::Next)).on_click(move |_| { next.next(); refresh_soon(None); }), ]) .gap(26.0) .align(Center) .justify(Center) .width(SIDE_W) .height(CONTROLS_H) } // whether to show `player` as playing: a just-clicked play/pause wins until the player has answered fn playing(player: &MediaPlayer, island: &Island) -> bool { match &island.playing_now { Some((name, playing)) if name == player.name() => *playing, _ => player.playing(), } } // the icon flips now, the player confirms a moment later; `shown` is what the island shows before fn play_pause(player: &MediaPlayer, shown: bool) { let flipped = (player.name().to_string(), !shown); Island::write().playing_now = Some(flipped.clone()); player.play_pause(); refresh_soon(Some(flipped)); } /* * Media polls the players once a second, so a click would show up to a second late: * read them again right after the player has had time to answer, then let the real * state replace the clicked one (unless another click has replaced it since) */ fn refresh_soon(clicked: Option<(String, bool)>) { thread::spawn(move || { for wait in [150, 450] { thread::sleep(Duration::from_millis(wait)); let _ = Media::write().update(); } if clicked.is_some() { let mut island = Island::write(); if island.playing_now == clicked { island.playing_now = None; } } }); } // a transport icon button, going bold as `lit` rises fn button(glyph: Glyph, lit: f32) -> Rectangle { Rectangle::new() .width(40.0) .height(40.0) .align_child(Center, Center) .cursor(Pointer) .child(bold_icon(glyph, 18.0, TEXT, BOLD * lit)) } fn eq_header(scene: &Scene) -> Row { let eq = scene.sound.eq(scene.player.identity()); let state = if scene.sound.unsaved() { "Saving" } else { "Saved" }; let mut right: Vec> = Vec::new(); // the player's own volume sits with its mix: scroll to change, click to mute if let Some(channel) = scene.sound.channel(scene.player.name()).filter(|channel| !channel.streams.is_empty()) { let scrolling = scene.player.name().to_string(); let muting = scene.player.name().to_string(); let volume = channel.volume; let label = if channel.muted { String::from("mute") } else { format!("{}%", volume.min(100)) }; right.push(Box::new( Rectangle::new() .width(VOLUME_CHIP_W) .height(22.0) .radius(6.0) .fill(Color::blend(CHIP, CHIP_HOVER, scene.island.lit(Spot::Volume))) .align_child(Center, Center) .cursor(Pointer) .scale(1.0 + 0.06 * scene.island.lit(Spot::Volume)) .on_hover(hovering(Spot::Volume)) .on_scroll(move |scroll| Sound::set_volume(&scrolling, stepped(volume, scroll))) .on_click(move |_| Sound::toggle_mute(&muting)) .child( Row::new(children![ icon(if channel.muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, DIM), Text::new(label).size(11.0).color(TEXT), ]) .gap(5.0) .align(Center), ), )); } right.push(Box::new( Rectangle::new().width(70.0).height(22.0).radius(6.0).fill(CHIP).align_child(Center, Center).child(Text::new(state).size(11.0).color(DIM)), )); right.push(Box::new(Text::new(eq.preset).size(12.0).color(TEXT))); Row::new(children![Text::new("Equalizer").size(15.0).color(PINK), Row::new(right).gap(12.0).align(Center)]) .justify(SpaceBetween) .align(Center) .width(INNER_W) .height(EQ_HEAD_H) } // the knob's centre for a gain, from the top of the slider fn knob_y(gain: f32) -> f32 { KNOB / 2.0 + (1.0 - (gain + MAX_GAIN) / (2.0 * MAX_GAIN)) * (SLIDER_H - KNOB) } fn gain_at(y: f32) -> f32 { (1.0 - (y - KNOB / 2.0) / (SLIDER_H - KNOB)) * 2.0 * MAX_GAIN - MAX_GAIN } // ten vertical sliders, a glowing curve through their knobs, and the bands' names fn equalizer(scene: &Scene) -> Column { let gains = scene.sound.eq(scene.player.identity()).gains; let column = INNER_W / BANDS.len() as f32; let mut tracks: Vec> = Vec::new(); let mut knobs: Vec> = Vec::new(); let mut labels: Vec> = Vec::new(); for (band, (frequency, gain)) in BANDS.iter().zip(gains).enumerate() { let y = knob_y(gain); // hovered, a band's track widens and brightens, its knob grows and its label turns pink let lit = scene.island.lit(Spot::Band(band)); let track_w = TRACK_W + 4.0 * lit; tracks.push(Box::new( Rectangle::new().width(column).height(SLIDER_H).align_child(Center, Start).child( Rectangle::new() .width(track_w) .height(SLIDER_H) .radius(track_w / 2.0) .fill(Color::blend(TRACK, CHIP_HOVER, lit)) .align_child(Center, End) .child( // rounded itself rather than clipped by the track: clips are gpu layers Rectangle::new() .width(track_w) .height(SLIDER_H - y) .radius(track_w / 2.0) .fill(Color::blend(PINK, FLAME, 0.45 * lit)), ), ), )); let player = scene.player.name().to_string(); let identity = scene.player.identity().to_string(); knobs.push(Box::new( Rectangle::new() .width(column) .height(SLIDER_H) .align_child(Center, Start) .cursor(Pointer) .on_hover(hovering(Spot::Band(band))) .on_drag(move |point| Sound::set_band(&player, &identity, band, gain_at(point.y))) .child( // a pink ring instead of a blurred shadow: ten blurs per frame cost the gpu more than the rest of the card Rectangle::new() .width(KNOB) .height(KNOB) .radius(Full) .fill(Color::WHITE) .border(2.0, Color::blend(faded(PINK, 150), PINK, lit)) .translate(0.0, y - KNOB / 2.0) .scale(1.0 + 0.25 * lit), ), )); let label = if *frequency >= 1000 { format!("{}k", frequency / 1000) } else { frequency.to_string() }; labels.push(Box::new( Rectangle::new().width(column).height(LABEL_H).align_child(Center, Center).child(Text::new(label).size(10.0).color(Color::blend(DIM, PINK, lit))), )); } Column::new(children![ Stack::new(children![Row::new(tracks), curve(&gains, scene.levels), Row::new(knobs)]).width(INNER_W).height(SLIDER_H), Row::new(labels), ]) .gap(8.0) } // the curve through the knobs, shivering with the player's sound fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas { let column = INNER_W / BANDS.len() as f32; let points: Vec<(f32, f32)> = (0..=CURVE_STEPS) .map(|step| { let along = step as f32 / CURVE_STEPS as f32; // cosine blend between neighbouring knobs keeps the curve round let place = along * (BANDS.len() - 1) as f32; let band = (place.floor() as usize).min(BANDS.len() - 2); let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0; let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased); let level = if levels.is_empty() { 0.0 } else { levels[((along * (levels.len() - 1) as f32).round() as usize).min(levels.len() - 1)] }; let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 }; (column / 2.0 + along * (INNER_W - column), y + shiver) }) .collect(); let stroke = |width: f32, alpha: u8| { let mut path = Path::new().move_to(points[0].0, points[0].1); for (x, y) in &points[1..] { path = path.line_to(*x, *y); } path.stroke(width, faded(PINK, alpha)).cap(Cap::Round) }; // a wide faint stroke under a thin bright one reads as a glow Canvas::new().width(INNER_W).height(SLIDER_H).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)]) } fn presets(scene: &Scene) -> Column { let current = scene.sound.eq(scene.player.identity()).preset; let mut rows: Vec> = Vec::new(); for row in EQ_PRESETS.chunks(4) { let mut buttons: Vec> = Vec::new(); for (name, _) in row { let chosen = current == *name; let lit = scene.island.lit(Spot::Preset(name)); let player = scene.player.name().to_string(); let identity = scene.player.identity().to_string(); // a hovered preset brightens: grey ones toward white, the chosen pink one toward its flame let fill = if chosen { Color::blend(PINK, FLAME, lit * 0.6) } else { Color::blend(CHIP, CHIP_HOVER, lit) }; buttons.push(Box::new( Rectangle::new() .width(PRESET_W) .height(PRESET_H) .radius(8.0) .fill(fill) .align_child(Center, Center) .cursor(Pointer) .scale(1.0 + 0.05 * lit) .on_hover(hovering(Spot::Preset(name))) .on_click(move |_| { Sound::preset(&player, &identity, name); }) .child( Text::new(*name) .size(12.0) .weight(if chosen { Weight::Bold } else { Weight::Regular }) .color(if chosen { INK } else { TEXT }), ), )); } rows.push(Box::new(Row::new(buttons).gap(PRESET_GAP))); } Column::new(rows).gap(PRESET_GAP) } // the dropdown of output devices or of players fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle { let mut rows: Vec> = Vec::new(); match menu { Menu::Output => { let current = scene.sound.channel(scene.player.name()).and_then(|channel| channel.output.as_ref()).map(|sink| sink.name.clone()); for (row, sink) in scene.sound.outputs().iter().enumerate() { let player = scene.player.name().to_string(); let identity = scene.player.identity().to_string(); let device = sink.name.clone(); let glyph = if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker }; let current = current.as_deref() == Some(sink.name.as_str()); rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), MENU_LETTERS), current, glyph, row, scene.island, move || { Sound::set_output(&player, &identity, &device); }))); } } Menu::Player => { for (row, other) in scene.players.iter().enumerate() { let name = other.name().to_string(); let glyph = if other.playing() { Glyph::Play } else { Glyph::Pause }; rows.push(Box::new(menu_row(other.identity(), other.name() == scene.player.name(), glyph, row, scene.island, move || { Island::write().selected = Some(name.clone()); }))); } } } let height = rows.len().clamp(1, MENU_ROWS) as f32 * MENU_ROW_H + 12.0; Rectangle::new() .width(MENU_W) .height(height) .radius(10.0) .fill(MENU_BG) .border(1.0, EDGE) .shadow(Shadow::drop("#000000").opacity(0.5).blur(20.0).offset(0.0, 8.0)) .padding(6.0) .clip() .translate(MENU_X, MENU_Y) .child(ScrollArea::new("island-menu", Column::new(rows))) } fn sink_label(sink: &Sink) -> &str { if sink.description.is_empty() { &sink.name } else { &sink.description } } fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Island, choose: impl Fn() + 'static) -> Rectangle { Rectangle::new() .width(Parent) .height(MENU_ROW_H) .radius(6.0) .fill(Color::blend(if current { CHIP } else { CLEAR }, CHIP_HOVER, island.lit(Spot::Row(row)))) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) .align_child(Start, Center) .cursor(Pointer) .translate(4.0 * island.lit(Spot::Row(row)), 0.0) .on_hover(hovering(Spot::Row(row))) .on_click(move |_| { choose(); Island::write().set_menu(None); }) .child( Row::new(children![ icon(glyph, 11.0, if current { PINK } else { DIM }), Text::new(label).size(12.0).color(if current { PINK } else { TEXT }).elide(), ]) .gap(9.0) .align(Center), ) } // some players send no title, a bare stream for example fn title(player: &MediaPlayer) -> &str { if player.title().is_empty() { player.identity() } else { player.title() } } // a round cover with a pink rim, optionally with a cd hole; a soft gradient while there is none or it decodes fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle { let frame = Rectangle::new() .width(size) .height(size) .radius(Full) .fill(PINK_DEEP) .align_child(Center, Center); let frame = if rim > 0.0 { frame.border(rim, PINK) } else { frame }; let picture = Rectangle::new().width(size).height(size).radius(Full).align_child(Center, Center); let picture = if art.is_empty() { picture } else { picture.fill(Image::cover(art).thumbnail(THUMB, THUMB)) }; // a rectangle holds one child, so the hole sits on the picture, not beside it let picture = if hole { picture.child(Rectangle::new().width(HOLE).height(HOLE).radius(Full).fill(SURFACE_BOTTOM).border(2.0, EDGE)) } else { picture }; frame.child(picture) } /* * the pill's visualiser: seven bars 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); } // lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line let middle = HEIGHT / 2.0; let mut glow = Path::new(); let mut body = Path::new(); for index in 0..BARS { let level = if levels.is_empty() { [0.55, 0.85, 0.6, 0.75, 0.45, 0.6, 0.35][index] } else { /* * the loudest band of this bar's seventh 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, index, BARS) * 1.4).min(1.0) }; let half = 1.5 + level * (middle - 3.0); let x = 3.0 + index as f32 * (WIDTH - 6.0) / (BARS - 1) as f32; body = body.move_to(x, middle - half).line_to(x, middle + half); if level > 0.3 { glow = glow.move_to(x, middle - half).line_to(x, middle + half); } } 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)] enum Glyph { Play, Pause, Previous, Next, Speaker, Muted, Bluetooth, Chevron, } // drawn, not typed, so the island looks the same whatever fonts are installed fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { bold_icon(glyph, size, color, 0.0) } // play, pause, previous and next thickened by an outline `weight` px wide in their own colour; 0 draws none fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas { let s = size; let triangle = |x0: f32, x1: f32| { Path::new().move_to(x0, 0.14 * s).line_to(x1, 0.5 * s).line_to(x0, 0.86 * s).close().fill(color).stroke(weight, color) }; let bar = |x0: f32, x1: f32| { Path::new() .move_to(x0, 0.14 * s) .line_to(x1, 0.14 * s) .line_to(x1, 0.86 * s) .line_to(x0, 0.86 * s) .close() .fill(color) .stroke(weight, color) }; let speaker = || { Path::new() .move_to(0.08 * s, 0.36 * s) .line_to(0.28 * s, 0.36 * s) .line_to(0.54 * s, 0.14 * s) .line_to(0.54 * s, 0.86 * s) .line_to(0.28 * s, 0.64 * s) .line_to(0.08 * s, 0.64 * s) .close() .fill(color) }; let thin = (0.12 * s).max(1.2); let shapes = match glyph { Glyph::Play => shapes![triangle(0.2 * s, 0.9 * s)], Glyph::Pause => shapes![bar(0.18 * s, 0.42 * s), bar(0.58 * s, 0.82 * s)], Glyph::Next => shapes![triangle(0.08 * s, 0.7 * s), bar(0.74 * s, 0.9 * s)], Glyph::Previous => shapes![triangle(0.92 * s, 0.3 * s), bar(0.1 * s, 0.26 * s)], Glyph::Speaker => shapes![ speaker(), Arc::new().center(0.56 * s, 0.5 * s).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round), ], Glyph::Muted => shapes![ speaker(), Line::new().from(0.68 * s, 0.36 * s).to(0.94 * s, 0.64 * s).stroke(thin, color).cap(Cap::Round), Line::new().from(0.94 * s, 0.36 * s).to(0.68 * s, 0.64 * s).stroke(thin, color).cap(Cap::Round), ], Glyph::Bluetooth => shapes![ Path::new() .move_to(0.28 * s, 0.3 * s) .line_to(0.72 * s, 0.68 * s) .line_to(0.5 * s, 0.9 * s) .line_to(0.5 * s, 0.1 * s) .line_to(0.72 * s, 0.32 * s) .line_to(0.28 * s, 0.7 * s) .stroke(thin, color) .cap(Cap::Round) ], Glyph::Chevron => shapes![ Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round) ], }; Canvas::new().width(size).height(size).shapes(shapes) }