54ca75b855
- shell.conf glass (0.05 to 1, default 1 = solid, as before): the surfaces' opacity; read live with the theme - surface.rs: shell() draws the island, the idle pill and the workspace pill (fill, rim, shadow), so a design is one file; the default is the look so far - see-through surfaces can't hide content under a veil, so with glass below 1 the open/close fades use .opacity() while they run - README: the Hyprland layer rule that frosts what's behind the shell (namespace match is a full match: ^magic-.*$; ignore_alpha keeps the transparent margin around the island sharp) - docs/design: the solid look and glass 0.5 with the rule, for review
1507 lines
53 KiB
Rust
1507 lines
53 KiB
Rust
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, Circle, 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;
|
|
use crate::surface;
|
|
use crate::theme;
|
|
|
|
/*
|
|
* 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
|
|
pub 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;
|
|
pub 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;
|
|
pub const TOP_GAP: f32 = 6.0;
|
|
pub 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 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;
|
|
|
|
// the white overlays below stay: they read on any dark surface. the theme colours are in theme.rs
|
|
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
|
|
pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46);
|
|
pub const CLEAR: Color = Color::rgba(255, 255, 255, 0);
|
|
const TRACK: Color = Color::rgba(255, 255, 255, 24);
|
|
|
|
#[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<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>,
|
|
|
|
// each hovered spot's highlight, fading between 0 and 1
|
|
hover: HashMap<Spot, Animation>,
|
|
|
|
/*
|
|
* 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<String>,
|
|
pointer_on: Option<String>,
|
|
|
|
// 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<Menu>) {
|
|
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 {
|
|
// the wallpaper's colours for this frame; cover_colors: the open card takes the cover's background
|
|
let cover_colors = theme::begin();
|
|
|
|
let media = Media::read();
|
|
let island = Island::read();
|
|
let sound = Sound::read();
|
|
let art = Art::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 {
|
|
/*
|
|
* nothing playing: a quiet pill, so the island is always there. never
|
|
* visible(false): hyprland 0.56 configures a layer surface before its first
|
|
* commit, so a window that starts hidden gets a size anyway, amane logs "no
|
|
* child to draw" and waits for frame callbacks an undrawn surface never gets
|
|
*/
|
|
return window.click_through().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(idle(&theme::now().surfaces())),
|
|
);
|
|
};
|
|
|
|
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);
|
|
|
|
/*
|
|
* the wallpaper's surfaces as a gradient for the pill and the card; with cover_colors
|
|
* the card's background fades over to the cover's gradient as the card comes in
|
|
* (only the background: accents and text keep the wallpaper's colours)
|
|
*/
|
|
let wallpaper = theme::now().surfaces();
|
|
|
|
let colors = if cover_colors {
|
|
let cover = backdrop_colors(&Palette::read());
|
|
|
|
std::array::from_fn(|stop| Color::blend(wallpaper[stop], cover[stop], reveal))
|
|
} else {
|
|
wallpaper
|
|
};
|
|
|
|
// 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)
|
|
};
|
|
|
|
// the face centred along the top: while the shape grows it is narrower than the shape
|
|
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
|
|
|
|
// the surface (fill, rim, glow) is surface.rs's; no clip anywhere: neither face ever overflows the shape
|
|
let shape = surface::shell(width, height, radius, &colors, lift, face);
|
|
|
|
let tapped = monitor.name.clone();
|
|
let hovered = monitor.name.clone();
|
|
|
|
let shape = shape
|
|
.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();
|
|
}
|
|
});
|
|
|
|
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<String>) {
|
|
// 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<String> {
|
|
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 <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(),
|
|
["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 <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.
|
|
* a see-through surface can't hide anything under a veil, so glass fades with
|
|
* .opacity(), a gpu layer only while the fade runs
|
|
*/
|
|
fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle {
|
|
if shown >= 1.0 {
|
|
return face;
|
|
}
|
|
|
|
if theme::glass() < 1.0 {
|
|
return Rectangle::new().width(width).height(height).align_child(Center, Start).child(face).opacity(shown);
|
|
}
|
|
|
|
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
|
|
pub 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
|
|
*/
|
|
pub 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)
|
|
}
|
|
|
|
pub fn faded(color: Color, alpha: u8) -> Color {
|
|
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
|
}
|
|
|
|
// the island while no player is open: a music note and "Nothing playing"
|
|
fn idle(colors: &[Color; 3]) -> Rectangle {
|
|
let content = Rectangle::new().width(COMPACT_W).height(COMPACT_H).align_child(Center, Center).child(
|
|
Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(12.0).color(theme::now().dim)])
|
|
.gap(8.0)
|
|
.align(Center),
|
|
);
|
|
|
|
surface::shell(COMPACT_W, COMPACT_H, COMPACT_H / 2.0, colors, 0.0, content)
|
|
}
|
|
|
|
// 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(theme::now().text).elide()),
|
|
pill_levels(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(theme::now().outline),
|
|
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(theme::now().accent)
|
|
.shadow(Shadow::drop(theme::now().accent).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(theme::now().accent, 50)).cap(Cap::Round),
|
|
body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round),
|
|
flames.stroke(4.0, theme::now().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(theme::now().text).elide(),
|
|
Text::new(format!("BY {artist}")).size(12.0).color(theme::now().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, theme::now().accent),
|
|
Text::new(output_label).size(11.0).color(theme::now().text).elide(),
|
|
icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().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(theme::now().dim, theme::now().text, player_lit)).elide(),
|
|
icon(Glyph::Chevron, 8.0, Color::blend(theme::now().dim, theme::now().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(theme::now().accent, theme::now().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(theme::now().dim), Text::new(total).size(11.0).color(theme::now().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(theme::now().accent);
|
|
|
|
middle.child(Row::new(children![bar(), bar()]).gap(7.0 - BOLD * lit))
|
|
} else {
|
|
middle.child(bold_icon(Glyph::Play, 30.0, theme::now().accent, 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, theme::now().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<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(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, theme::now().dim),
|
|
Text::new(label).size(11.0).color(theme::now().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(theme::now().dim)),
|
|
));
|
|
|
|
right.push(Box::new(Text::new(eq.preset).size(12.0).color(theme::now().text)));
|
|
|
|
Row::new(children![Text::new("Equalizer").size(15.0).color(theme::now().accent), 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);
|
|
|
|
// 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(theme::now().accent, theme::now().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(theme::now().accent, 150), theme::now().accent, 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(theme::now().dim, theme::now().accent, 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(theme::now().accent, 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 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(theme::now().accent, theme::now().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 { theme::now().on_accent } else { theme::now().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 (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(theme::now().surface_high)
|
|
.border(1.0, theme::now().outline)
|
|
.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 { theme::now().accent } else { theme::now().dim }),
|
|
Text::new(label).size(12.0).color(if current { theme::now().accent } else { theme::now().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(theme::now().accent_soft)
|
|
.align_child(Center, Center);
|
|
|
|
let frame = if rim > 0.0 { frame.border(rim, theme::now().accent) } 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(theme::now().surface_low).border(2.0, theme::now().outline))
|
|
} 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, theme::now().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(theme::now().accent, 55)).cap(Cap::Round),
|
|
body.stroke(3.2, theme::now().accent).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,
|
|
Note,
|
|
}
|
|
|
|
// 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)
|
|
],
|
|
// an eighth note: round head, stem, a curved flag
|
|
Glyph::Note => shapes![
|
|
Circle::new().center(0.32 * s, 0.74 * s).radius(0.17 * s).fill(color),
|
|
Line::new().from(0.47 * s, 0.74 * s).to(0.47 * s, 0.12 * s).stroke(thin, color).cap(Cap::Round),
|
|
Path::new().move_to(0.47 * s, 0.12 * s).quad_to(0.62 * s, 0.3 * s, 0.84 * s, 0.38 * s).stroke(thin, color).cap(Cap::Round),
|
|
],
|
|
};
|
|
|
|
Canvas::new().width(size).height(size).shapes(shapes)
|
|
}
|