Files
amane-config/src/island.rs
T
server2 c55e4cfbed island: cover gradient background, unmirrored pill levels
- the pill, the card and the fade veils share one 150-degree gradient from
  the cover's palette (most common, second, background), each darkened to
  a fixed brightness so white text stays readable; veils span the whole
  shape so their gradient lines up with the backdrop's
- measured on the desktop: card open 3.1 ms gpu (solid fill was ~3), pill
  1.7 ms (was 1.0), open/close animation unchanged (1 and 0 slow frames)
- pill levels: lows left, highs right, no mirroring; every bar still grows
  up and down from the middle line
2026-10-06 15:25:13 +02:00

1222 lines
41 KiB
Rust

use std::f32::consts::{PI, TAU};
use std::thread;
use std::time::{Duration, Instant};
use amane::{
Animation, Arc, Argument, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient,
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,
};
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;
// 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);
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,
}
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<String>,
menu: Option<Menu>,
// where the time bar is pressed, 0 to 1, and how long a released seek stays on screen
scrub: Option<f32>,
seek_shown_until: Option<Instant>,
}
impl Service for Island {
fn new() -> Self {
Self {
open: Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear),
expanded: false,
selected: None,
menu: None,
scrub: None,
seek_shown_until: None,
}
}
// changes only through input
fn listen() {}
}
// 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() -> 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 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(island.open.value());
/*
* 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() {
amane::request_frame();
}
let width = mix(COMPACT_W, 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: &spectrum.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)
} 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, EDGE);
/*
* amane redraws the whole window every frame, so big static effects are paid
* 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 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)
.on_click(|_| tap())
.on_hover(|inside| {
if !inside {
collapse();
}
})
.child(face);
let shape = if island.expanded { 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 itself: closes an open dropdown, else opens the card
fn tap() {
let mut island = Island::write();
if island.menu.take().is_none() {
island.expanded = true;
island.open.to(1.0);
}
}
fn expand() {
let mut island = Island::write();
island.expanded = true;
island.open.to(1.0);
}
fn collapse() {
let mut island = Island::write();
island.expanded = false;
island.menu = None;
island.open.to(0.0);
}
fn toggle_menu(menu: Menu) {
let mut island = Island::write();
island.menu = if island.menu == Some(menu) { None } else { Some(menu) };
}
/*
* `amane ipc call island ...` for keybinds (and tests):
* open | close | toggle | next | menu output|player|close | preset <name> | output <sink name> | 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(),
["menu", which] => {
let menu = match *which {
"output" => Some(Menu::Output),
"player" => Some(Menu::Player),
_ => None,
};
Island::write().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|menu <output|player|close>|preset <name>|output <sink>|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, 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(player.playing(), levels),
])
.gap(10.0)
.align(Center),
)
}
fn card(scene: &Scene) -> Rectangle {
let mut layers: Vec<Box<dyn Widget>> = 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),
])
.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 = Rectangle::new()
.width(output_w)
.height(CHIP_H)
.radius(6.0)
.fill(if menu == Some(Menu::Output) { CHIP_OPEN } else { CHIP })
.padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 })
.align_child(Start, Center)
.cursor(Pointer)
.on_click(|_| toggle_menu(Menu::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, DIM),
])
.gap(6.0)
.align(Center),
);
let player = Rectangle::new()
.width(player_w)
.height(CHIP_H)
.radius(6.0)
.fill(if menu == Some(Menu::Player) { CHIP_OPEN } else { Color::TRANSPARENT })
.padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 })
.align_child(Start, Center)
.cursor(Pointer)
.on_click(|_| toggle_menu(Menu::Player))
.child(
Row::new(children![Text::new(player_label).size(11.0).color(DIM).elide(), icon(Glyph::Chevron, 8.0, DIM)])
.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 };
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(TRACK).child(
Rectangle::new()
.width((SIDE_W * fraction).max(BAR_H))
.height(BAR_H)
.radius(Full)
.fill(PINK),
),
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),
])
.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(leave_bar)
.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) -> Row {
let previous = player.clone();
let toggle = player.clone();
let next = player.clone();
// pause is two tall pink bars, play a pink triangle
let middle = if player.playing() {
let bar = || Rectangle::new().width(10.0).height(30.0).radius(3.0).fill(PINK);
Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center).child(Row::new(children![bar(), bar()]).gap(7.0))
} else {
Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center).child(icon(Glyph::Play, 30.0, PINK))
};
Row::new(children![
button(icon(Glyph::Previous, 18.0, TEXT)).on_click(move |_| previous.previous()),
middle.cursor(Pointer).on_click(move |_| toggle.play_pause()),
button(icon(Glyph::Next, 18.0, TEXT)).on_click(move |_| next.next()),
])
.gap(26.0)
.align(Center)
.justify(Center)
.width(SIDE_W)
.height(CONTROLS_H)
}
fn button(glyph: Canvas) -> Rectangle {
Rectangle::new().width(40.0).height(40.0).radius(Full).align_child(Center, Center).cursor(Pointer).child(glyph)
}
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<Box<dyn Widget>> = 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(CHIP)
.align_child(Center, Center)
.cursor(Pointer)
.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<Box<dyn Widget>> = Vec::new();
let mut knobs: Vec<Box<dyn Widget>> = Vec::new();
let mut labels: Vec<Box<dyn Widget>> = Vec::new();
for (band, (frequency, gain)) in BANDS.iter().zip(gains).enumerate() {
let y = knob_y(gain);
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(TRACK)
.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(PINK),
),
),
));
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_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, faded(PINK, 150))
.translate(0.0, y - KNOB / 2.0),
),
));
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(DIM)),
));
}
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<Box<dyn Widget>> = Vec::new();
for row in EQ_PRESETS.chunks(4) {
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
for (name, _) in row {
let chosen = current == *name;
let player = scene.player.name().to_string();
let identity = scene.player.identity().to_string();
buttons.push(Box::new(
Rectangle::new()
.width(PRESET_W)
.height(PRESET_H)
.radius(8.0)
.fill(if chosen { PINK } else { CHIP })
.align_child(Center, Center)
.cursor(Pointer)
.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<Box<dyn Widget>> = 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 sink in scene.sound.outputs() {
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 };
rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), MENU_LETTERS), current.as_deref() == Some(sink.name.as_str()), glyph, move || {
Sound::set_output(&player, &identity, &device);
})));
}
}
Menu::Player => {
for other in scene.players {
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, 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, choose: impl Fn() + 'static) -> Rectangle {
Rectangle::new()
.width(Parent)
.height(MENU_ROW_H)
.radius(6.0)
.fill(if current { CHIP } else { Color::TRANSPARENT })
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center)
.cursor(Pointer)
.on_click(move |_| {
choose();
Island::write().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 {
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)
};
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)
};
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)
}