From 79abc764a08a0c25e57bbe525815b43a8db953b5 Mon Sep 17 00:00:00 2001 From: fengqiu233 <977475514@qq.com> Date: Tue, 22 Sep 2026 00:02:00 +0800 Subject: [PATCH 1/2] feat(views): stage the fullscreen cover in a starry field --- CHANGELOG.md | 4 + README.md | 2 +- assets/i18n/en-US/main.ftl | 7 + crates/state/src/lib.rs | 4 +- crates/state/src/settings.rs | 39 ++ crates/ui/src/artwork.rs | 204 ++++++- crates/views/src/screens/settings.rs | 101 ++- crates/views/src/shared/mod.rs | 1 + crates/views/src/shared/starry.rs | 845 ++++++++++++++++++++++++++ crates/views/src/shells/fullscreen.rs | 136 ++++- 10 files changed, 1307 insertions(+), 36 deletions(-) create mode 100644 crates/views/src/shared/starry.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c0effd410..f2d4bcfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Fullscreen can stage the cover in a halo of drifting particles, with a ring of spectrum bars + ### Changed - An artist page opens as soon as their profile and popular tracks arrive, and fills its diff --git a/README.md b/README.md index a879d788c..23830a2e8 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ AI-assisted proofreading and translation of human-written text are permitted. | Language | Translated | Coverage | | --- | --- | --- | -| English (`en-US`) | 720/720 | 100% | +| English (`en-US`) | 727/727 | 100% | | Deutsch (`de`) | 631/720 | 88% | | Español (`es`) | 691/720 | 96% | | Français (`fr`) | 631/720 | 88% | diff --git a/assets/i18n/en-US/main.ftl b/assets/i18n/en-US/main.ftl index 1533dde23..6de3cd788 100644 --- a/assets/i18n/en-US/main.ftl +++ b/assets/i18n/en-US/main.ftl @@ -495,6 +495,13 @@ settings-ambient = Ambient background settings-ambient-detail = Fill the fullscreen player with colours drawn from the cover settings-ambient-motion = Ambient motion settings-ambient-motion-detail = Drift the ambient colours instead of holding them still +settings-starry = Starry stage +settings-starry-detail = Halo the fullscreen cover with drifting particles and a spectrum ring that follows the visualizer +settings-vinyl = Spinning vinyl +settings-vinyl-detail = Dress the cover as the label of a record that turns under a slow sheen +settings-particles = Drifting particles +settings-particles-detail = How many particles drift off the cover +settings-particles-value = { $count } settings-visualizer = Visualizer settings-visualizer-detail = How the spectrum is drawn behind fullscreen artwork settings-visualizer-style-none = Off diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index a113d4264..0be030551 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -52,8 +52,8 @@ pub use scrobble::{ScrobbleRow, ScrobbleState, Scrobbling}; pub use search::{AlbumHit, ArtistHit, Hit, Kind, PlaylistHit, Search}; pub use session::{Failure, ProviderInfo, Session, SessionEvent, SessionState}; pub use settings::{ - AppSettings, DiscordName, FilterValue, FullscreenControlsAutohide, RomanizationScripts, - SYSTEM_FONT, SideTab, remember_window, window_placement, + AppSettings, DiscordName, FilterValue, FullscreenControlsAutohide, MAX_PARTICLES, + RomanizationScripts, SYSTEM_FONT, SideTab, remember_window, window_placement, }; pub use song::SongDetail; pub use tags::{TagState, Tags}; diff --git a/crates/state/src/settings.rs b/crates/state/src/settings.rs index 302958e44..766a8c305 100644 --- a/crates/state/src/settings.rs +++ b/crates/state/src/settings.rs @@ -245,6 +245,8 @@ const DEFAULT_SIDEBAR_WIDTH: f32 = 195.; const DEFAULT_SIDEBAR_RIGHT_WIDTH: f32 = 254.; const DEFAULT_FONT_SIZE: f32 = 14.; const DEFAULT_LYRICS_SCALE: f32 = 1.; +/// The most particles the starry stage sheds. The stepper in settings steps to this too. +pub const MAX_PARTICLES: usize = 1024; const DEFAULT_STARTUP: &str = "home"; /// "Whatever the platform uses". pub const SYSTEM_FONT: &str = "auto"; @@ -314,6 +316,9 @@ struct Appearance { adaptive_theme: bool, ambient: bool, ambient_motion: bool, + starry: bool, + vinyl: bool, + particles: usize, visualizer: bool, visualizer_style: String, icons: String, @@ -504,6 +509,9 @@ impl Default for Appearance { adaptive_theme: true, ambient: true, ambient_motion: true, + starry: false, + vinyl: true, + particles: 36, visualizer: true, visualizer_style: ui::VisualizerStyle::default().id().to_owned(), icons: icons::BASE.to_owned(), @@ -796,6 +804,22 @@ impl AppSettings { self.values.appearance.ambient_motion } + /// Whether fullscreen stages the cover on a spinning vinyl record with + /// drifting particles and a spectrum ring around it. + pub fn starry(&self) -> bool { + self.values.appearance.starry + } + + /// Whether the starry stage dresses the cover as a vinyl record at all. + pub fn vinyl(&self) -> bool { + self.values.appearance.vinyl + } + + /// How many particles drift off the cover in the starry stage. + pub fn particles(&self) -> usize { + self.values.appearance.particles.min(MAX_PARTICLES) + } + /// Whether the playing cover should colour the theme, given whether fullscreen is up. The /// ambient background is painted out of the tint, so fullscreen tints whatever the adaptive /// theme setting says. @@ -1397,6 +1421,21 @@ impl AppSettings { self.schedule_save(cx); } + pub fn set_starry(&mut self, starry: bool, cx: &mut Context) { + self.values.appearance.starry = starry; + self.schedule_save(cx); + } + + pub fn set_vinyl(&mut self, vinyl: bool, cx: &mut Context) { + self.values.appearance.vinyl = vinyl; + self.schedule_save(cx); + } + + pub fn set_particles(&mut self, particles: usize, cx: &mut Context) { + self.values.appearance.particles = particles.clamp(0, MAX_PARTICLES); + self.schedule_save(cx); + } + /// Picking a style turns the visualizer on; picking `None` turns it off and leaves the style /// behind it alone, so the old choice comes back with it. pub fn set_visualizer_style(&mut self, style: ui::VisualizerStyle, cx: &mut Context) { diff --git a/crates/ui/src/artwork.rs b/crates/ui/src/artwork.rs index 1787829fa..674f0b892 100644 --- a/crates/ui/src/artwork.rs +++ b/crates/ui/src/artwork.rs @@ -6,14 +6,15 @@ use futures::AsyncReadExt as _; use gpui::prelude::*; use gpui::{ App, Asset, AssetLogger, Context, Div, ElementId, Entity, Global, Hsla, ImageCache, - ImageCacheError, ImageSource, Interactivity, ObjectFit, Pixels, RenderImage, Resource, + ImageCacheError, ImageId, ImageSource, Interactivity, ObjectFit, Pixels, RenderImage, Resource, SharedString, SharedUri, StyleRefinement, Styled, Task, Window, div, img, px, svg, }; use image::{ - AnimationDecoder, DynamicImage, Frame, ImageDecoder, ImageFormat, RgbaImage, + AnimationDecoder, DynamicImage, Frame, ImageDecoder, ImageFormat, Rgba, RgbaImage, codecs::{gif::GifDecoder, webp::WebPDecoder}, imageops, }; +use std::collections::VecDeque; use std::io::Cursor; use std::path::Path; use std::time::{Duration, Instant}; @@ -49,6 +50,19 @@ const REPRIEVE: Duration = Duration::from_millis(250); /// nothing beside the frames, and holding them past an eviction is what keeps a /// button its colour while its cover is decoded again. const TINT_ITEMS: usize = 4096; +/// A turned cover is cut this many times per revolution — one degree, finer +/// than the eye follows at the speed a record turns — and this many cuts are +/// kept. Holding them is cheaper than making them again, and the handful covers +/// a second of turning, long past the frame that last showed the oldest. +const TURN_STEPS: u32 = 360; +const TURN_HELD: usize = 24; +/// A cut is made from a copy no larger than this on a side. The cost of a turn +/// is the square of the edge, and a cover in motion hides detail a still one +/// would not. +const TURN_EDGE: u32 = 384; +/// How many covers may be held mid-turn at once. Only the fullscreen record +/// turns, but a scroll past it would otherwise leave a base behind per cover. +const TURN_COVERS: usize = 4; type ArtworkKey = (Resource, u32); @@ -244,10 +258,24 @@ struct ArtworkCache { /// The palette of every cover decoded this run, kept apart from the frames /// so an eviction never costs a button its colour. tints: HashMap, + /// The covers being turned, kept apart from `items` like `soft` is: a cut + /// is a frame of its own, and an eviction should not cost the record its + /// pose. + turns: HashMap, bytes: usize, _sweep: Task<()>, } +/// One cover being turned: the square every cut is taken from, and the cuts +/// taken from it so far. +struct Turned { + /// The frames the square was taken from, so a cover decoded again — or + /// softened, or sampled at another edge — is cut anew. + of: ImageId, + base: RgbaImage, + held: VecDeque<(u32, Arc)>, +} + struct Installed(Entity); impl Global for Installed {} @@ -263,6 +291,7 @@ impl ArtworkCache { pending: HashMap::new(), soft: HashMap::new(), tints: HashMap::new(), + turns: HashMap::new(), bytes: 0, _sweep: sweeper(cx), }); @@ -463,6 +492,7 @@ impl ArtworkCache { for resource in &stale { self.condemn(resource); } + self.trim_turns(); if !stale.is_empty() { self.condemned_at = Some(Instant::now()); cx.refresh_windows(); @@ -583,6 +613,152 @@ impl ArtworkCache { self.insert(key, value.clone(), cx); Some(value) } + + /// The cover turned by `turns` of a revolution about its own centre. The + /// renderer has no rotation for images, so a turn is cut on the CPU from + /// the decoded frames and handed back as a frame of its own; cuts are held + /// per degree, and only as many as the record is passing through. + fn turned( + &mut self, + key: &ArtworkKey, + image: &Arc, + turns: f32, + ) -> Arc { + let step = ((turns - turns.floor()) * TURN_STEPS as f32) as u32 % TURN_STEPS; + if self + .turns + .get(key) + .is_none_or(|turned| turned.of != image.id) + { + let Some(turned) = Turned::of(image) else { + log::warn!("artwork: cannot turn a cover"); + return image.clone(); + }; + self.turns.insert(key.clone(), turned); + } + let turned = self.turns.get_mut(key).expect("held above"); + if let Some(found) = turned.held.iter().find(|(at, _)| *at == step) { + return found.1.clone(); + } + + let Some(cut) = cut(image, &turned.base, step) else { + log::warn!("artwork: cannot cut a turn"); + return image.clone(); + }; + if turned.held.len() >= TURN_HELD { + turned.held.pop_front(); + } + turned.held.push_back((step, cut.clone())); + cut + } + + /// Keeps the covers mid-turn to those still held, then to those still worth + /// holding. A cut is cheap to rebuild and dear to keep, so a cover that has + /// left the cache takes its turns with it. + fn trim_turns(&mut self) { + if self.turns.len() <= TURN_COVERS { + return; + } + self.turns.retain(|key, _| self.items.contains_key(key)); + while self.turns.len() > TURN_COVERS { + let Some(key) = self.turns.keys().next().cloned() else { + break; + }; + self.turns.remove(&key); + } + } +} + +impl Turned { + /// The square a cover's turns are cut from: centred, and no larger than + /// `TURN_EDGE` however large the cover was decoded. + fn of(image: &RenderImage) -> Option { + let size = image.size(0); + let (width, height) = (size.width.0.max(0) as u32, size.height.0.max(0) as u32); + let bytes = image.as_bytes(0)?.to_vec(); + let whole = RgbaImage::from_raw(width, height, bytes)?; + + let side = width.min(height); + let square = + imageops::crop_imm(&whole, (width - side) / 2, (height - side) / 2, side, side) + .to_image(); + let base = match side > TURN_EDGE { + true => imageops::thumbnail(&square, TURN_EDGE, TURN_EDGE), + false => square, + }; + + Some(Self { + of: image.id, + base, + held: VecDeque::new(), + }) + } +} + +/// Cuts `base` turned by `step` of `TURN_STEPS` about its centre, masked to a +/// circle. A square turned about its centre leaves its corners behind, and the +/// record's label is round, so the mask costs a comparison a pixel and hides +/// what a turn would otherwise show. +fn cut(image: &RenderImage, base: &RgbaImage, step: u32) -> Option> { + let (edge, _) = base.dimensions(); + if edge == 0 { + return None; + } + let (sin, cos) = (std::f32::consts::TAU * step as f32 / TURN_STEPS as f32).sin_cos(); + let middle = edge as f32 / 2.; + + let mut turned = RgbaImage::new(edge, edge); + for y in 0..edge { + for x in 0..edge { + // Walking the destination outwards and asking where the pixel came + // from: the inverse of the turn, so every pixel is written once. + let dx = x as f32 - middle + 0.5; + let dy = y as f32 - middle + 0.5; + let reach = (dx * dx + dy * dy).sqrt(); + let cover = (middle - reach).clamp(0., 1.); + if cover <= 0. { + continue; + } + let from = x as f32 - middle; + let at = y as f32 - middle; + let sx = from * cos + at * sin + middle - 0.5; + let sy = -from * sin + at * cos + middle - 0.5; + + let mut pixel = sample(base, sx, sy); + pixel.0[3] = (pixel.0[3] as f32 * cover) as u8; + turned.put_pixel(x, y, pixel); + } + } + + Some(Arc::new(RenderImage::new([Frame::from_parts( + turned, + 0, + 0, + image.delay(0), + )]))) +} + +/// The colour of `base` at `(x, y)`, read between pixels so a turn stays smooth +/// instead of snapping from one source pixel to the next. +fn sample(base: &RgbaImage, x: f32, y: f32) -> Rgba { + let (width, height) = base.dimensions(); + let x = x.clamp(0., width as f32 - 1.); + let y = y.clamp(0., height as f32 - 1.); + let left = x as u32; + let top = y as u32; + let right = (left + 1).min(width - 1); + let bottom = (top + 1).min(height - 1); + let fx = x - left as f32; + let fy = y - top as f32; + + let read = |x: u32, y: u32, channel: usize| base.get_pixel(x, y).0[channel] as f32; + let mut mixed = Rgba([0; 4]); + for (channel, value) in mixed.0.iter_mut().enumerate() { + let upper = read(left, top, channel) * (1. - fx) + read(right, top, channel) * fx; + let lower = read(left, bottom, channel) * (1. - fx) + read(right, bottom, channel) * fx; + *value = (upper * (1. - fy) + lower * fy) as u8; + } + mixed } fn blurred(image: &RenderImage) -> Option> { @@ -692,6 +868,8 @@ pub struct Artwork { fallback: SharedString, accent: bool, soft: bool, + /// How far the cover is turned about its centre, in revolutions. + spin: Option, interactivity: Interactivity, } @@ -704,6 +882,7 @@ impl Artwork { circle: false, radius: None, soft: false, + spin: None, fallback: FALLBACK_ICON.into(), accent: false, interactivity: Interactivity::new(), @@ -744,6 +923,14 @@ impl Artwork { self.accent = true; self } + + /// Turns the cover about its own centre by `turns` of a revolution, the way + /// a record carries its label. Whole revolutions are meaningless, so the + /// fraction is all that is read. + pub fn spin(mut self, turns: f32) -> Self { + self.spin = Some(turns); + self + } } impl Styled for Artwork { @@ -768,6 +955,7 @@ impl RenderOnce for Artwork { fallback, accent, soft, + spin, interactivity, } = self; let theme = *cx.theme(); @@ -795,16 +983,24 @@ impl RenderOnce for Artwork { let source = ImageSource::Custom(Arc::new({ let cache = cache.clone(); move |window, cx| { + let key = (resource.clone(), edge); if let Some(prepared) = cache.update(cx, |cache, _| cache.prepared(&resource, edge, soft)) { - return Some(Ok(prepared)); + return Some(Ok(cache.update(cx, |cache, _| match spin { + Some(turns) => cache.turned(&key, &prepared, turns), + None => prepared, + }))); } let loaded = cache .update(cx, |cache, cx| cache.load_at(&resource, edge, window, cx))? .map(|image| { cache.update(cx, |cache, cx| { - cache.prepare(&resource, edge, soft, image, cx) + let image = cache.prepare(&resource, edge, soft, image, cx); + match spin { + Some(turns) => cache.turned(&key, &image, turns), + None => image, + } }) }); Some(loaded) diff --git a/crates/views/src/screens/settings.rs b/crates/views/src/screens/settings.rs index 48f78c855..2b739629a 100644 --- a/crates/views/src/screens/settings.rs +++ b/crates/views/src/screens/settings.rs @@ -22,8 +22,9 @@ use music::scrobble::{Link, Secret}; use music::{AccountChoice, SignIn, SignInPrompt, WritingSystem}; use router::{Destination, NavEntry, Screen, SettingsTab, navigate}; use state::{ - AppSettings, CdmState, DiscordName, Drm, Failure, FullscreenControlsAutohide, Io, Playback, - SYSTEM_FONT, Scan, ScrobbleState, Scrobbling, Session, SessionState, Sleep, Sonora, + AppSettings, CdmState, DiscordName, Drm, Failure, FullscreenControlsAutohide, Io, + MAX_PARTICLES, Playback, SYSTEM_FONT, Scan, ScrobbleState, Scrobbling, Session, SessionState, + Sleep, Sonora, }; use ui::{ActiveTheme as _, Deck, LEADING, Scrollbar, Scroller, eyebrow, snapped}; use ui::{ @@ -132,6 +133,9 @@ enum Slot { Adaptive, Ambient, AmbientMotion, + Starry, + Vinyl, + Particles, Visualizer, Icons, Opacity, @@ -549,6 +553,9 @@ impl SettingsView { .ambient() .then_some(Slot::AmbientMotion), ) + .chain([Slot::Starry]) + .chain(self.settings.read(cx).starry().then_some(Slot::Vinyl)) + .chain(self.settings.read(cx).starry().then_some(Slot::Particles)) .chain([ Slot::Visualizer, Slot::FullscreenControlsAutohide, @@ -649,6 +656,9 @@ impl SettingsView { t!("settings-ambient-motion"), t!("settings-ambient-motion-detail"), ), + Slot::Starry => (t!("settings-starry"), t!("settings-starry-detail")), + Slot::Vinyl => (t!("settings-vinyl"), t!("settings-vinyl-detail")), + Slot::Particles => (t!("settings-particles"), t!("settings-particles-detail")), Slot::Visualizer => (t!("settings-visualizer"), t!("settings-visualizer-detail")), Slot::Icons => (t!("settings-icons"), t!("settings-icons-detail")), Slot::Opacity => (t!("settings-opacity"), t!("settings-opacity-detail")), @@ -866,6 +876,9 @@ impl SettingsView { Slot::Adaptive => self.adaptive_row(cx).element, Slot::Ambient => self.ambient_row(cx).element, Slot::AmbientMotion => self.ambient_motion_row(cx).element, + Slot::Starry => self.starry_row(cx).element, + Slot::Vinyl => self.vinyl_row(cx).element, + Slot::Particles => self.particles_row(cx).element, Slot::Visualizer => self.visualizer_style_row(cx).element, Slot::Icons => self.icons_row(cx).element, Slot::Opacity => self.opacity_row(cx).element, @@ -1750,6 +1763,90 @@ impl SettingsView { ) } + /// The fullscreen vinyl stage: the cover spins as the record's label under + /// a slow sheen, particles drift off the rim, and a ring of spectrum bars + /// surrounds it all. + fn starry_row(&self, cx: &mut Context) -> Setting { + let theme = *cx.theme(); + let muted = theme.muted_foreground; + let small = theme.text(Text::Small); + let on = self.settings.read(cx).starry(); + + self.row( + t!("settings-starry"), + t!("settings-starry-detail"), + muted, + small, + Switch::new("starry", on) + .on_click(cx.listener(move |this, _, _, cx| { + this.settings + .update(cx, |settings, cx| settings.set_starry(!on, cx)); + })) + .into_any_element(), + ) + } + + /// The vinyl itself. The cover cannot turn, so the record only makes sense + /// while it does — the sheen and the groove markers are what carry the turn. + fn vinyl_row(&self, cx: &mut Context) -> Setting { + let theme = *cx.theme(); + let muted = theme.muted_foreground; + let small = theme.text(Text::Small); + let on = self.settings.read(cx).vinyl(); + + self.row( + t!("settings-vinyl"), + t!("settings-vinyl-detail"), + muted, + small, + Switch::new("vinyl", on) + .on_click(cx.listener(move |this, _, _, cx| { + this.settings + .update(cx, |settings, cx| settings.set_vinyl(!on, cx)); + })) + .into_any_element(), + ) + } + + /// How many particles drift off the cover, stepped like the lyrics sizes. + fn particles_row(&self, cx: &mut Context) -> Setting { + let theme = *cx.theme(); + let muted = theme.muted_foreground; + let small = theme.text(Text::Small); + let count = self.settings.read(cx).particles(); + + let step = move |suffix: &'static str, label: &'static str, delta: i64| { + let wanted = (count as i64 + delta).clamp(0, MAX_PARTICLES as i64) as usize; + + Button::new(SharedString::from(format!("particles-{suffix}"))) + .label(label) + .small() + .outline() + .disabled(wanted == count) + .on_click(cx.listener(move |this, _, _, cx| { + this.settings + .update(cx, |settings, cx| settings.set_particles(wanted, cx)); + cx.notify(); + })) + }; + + let actions = div() + .flex() + .items_center() + .gap_2() + .child(step("fewer", "\u{2212}", -32)) + .child(div().child(t!("settings-particles-value", count = count as i64))) + .child(step("more", "+", 32)); + + self.row( + t!("settings-particles"), + t!("settings-particles-detail"), + muted, + small, + actions.into_any_element(), + ) + } + /// How the spectrum is drawn behind the fullscreen artwork, off included. fn visualizer_style_row(&self, cx: &mut Context) -> Setting { let theme = *cx.theme(); diff --git a/crates/views/src/shared/mod.rs b/crates/views/src/shared/mod.rs index fcf50f800..6f7fa4a57 100644 --- a/crates/views/src/shared/mod.rs +++ b/crates/views/src/shared/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod pins; pub(crate) mod playlist_editor; pub(crate) mod popups; pub(crate) mod shelves; +pub(crate) mod starry; pub(crate) mod steps; pub(crate) mod tag_editor; pub(crate) mod text; diff --git a/crates/views/src/shared/starry.rs b/crates/views/src/shared/starry.rs new file mode 100644 index 000000000..50fc44dc3 --- /dev/null +++ b/crates/views/src/shared/starry.rs @@ -0,0 +1,845 @@ +use std::cell::Cell; +use std::f32::consts::TAU; +use std::rc::Rc; +use std::sync::OnceLock; +use std::time::Instant; + +use gpui::prelude::*; +use gpui::{ + App, Bounds, Div, EntityId, Hsla, PathBuilder, Pixels, Point, SharedString, Window, canvas, + div, point, px, +}; +use ui::{Artwork, Levels, Theme, VisualizerStyle}; + +/// One turn of the record, in seconds. A real 33⅓ single spins in 1.8, which +/// at this size reads as frantic; the stage settles for a slow, visible turn. +const SPIN: f32 = 18.; +/// The record's diameter, as a share of the square the stage is given. The +/// rest of the square is the room the ring, the halo and the particles live in, +/// and it has to end inside the square: the raster layer clips at its edge. +const DISC: f32 = 0.78; +/// The cover's diameter, as a share of the record's: seven of cover to three of +/// vinyl, so the art reads as the label and the record as its rim. +const LABEL: f32 = 0.7; +/// Where the grooves run, as shares of the record's radius: outside the cover, +/// which is all of the record the eye can see. +const GROOVE_IN: f32 = 0.72; +const GROOVE_OUT: f32 = 0.985; +/// The ring: how far its bars start off the rim, and how far a full band +/// reaches, as shares of the stage's side. +const RING_GAP: f32 = 0.025; +const RING_REACH: f32 = 0.075; +/// The lowest band stands this tall, so a silent track still shows a hairline. +const RING_FLOOR: f32 = 0.015; +/// How wide the halo's reach is past the rim, as a share of the side. It has +/// to fade out before the stage's own edge or the layer clips it mid-glow. +const HALO_REACH: f32 = 0.10; +/// How many concentric discs fake the halo's radial falloff. +const HALO: usize = 12; +/// How many grooves the record wears. +const GROOVES: usize = 20; +/// How bright a particle gets at the top of its drift, and how many shades that +/// brightness is rounded into. Every particle of one shade shares a path, so +/// the field costs one draw per shade and not one per particle. +const STAR: f32 = 0.52; +const TONES: usize = 8; +/// The sheen: one pass's angular width in radians, how bright it gets, and how +/// many cells a pass is subdivided into so its edges fade. +const SHEEN_SPAN: f32 = 0.72; +const SHEEN: f32 = 0.05; +const SHEEN_CELLS: usize = 10; +/// Short bright arcs on the grooves, rotating with the sheen. On a texture of +/// perfect circles they are the only cue that the record itself is turning. +/// They ride between the cover and the rim, the only vinyl there is to see. +const MARKERS: [f32; 3] = [0.76, 0.84, 0.92]; +const MARKER_SPAN: f32 = 0.26; +/// How many straight segments stand in for a smooth circle. +const SEGMENTS: usize = 72; + +/// Where the stage's clock started, so the angle carries over between visits +/// to fullscreen instead of resetting to the same pose every time. +pub(crate) fn spin() -> f32 { + static STARTED: OnceLock = OnceLock::new(); + STARTED.get_or_init(Instant::now).elapsed().as_secs_f32() +} + +/// The frames the stage's own motion needs: the turn of the record, the sweep +/// of the sheen, the drift of the particles. None of it depends on sound being +/// made, and once playback stops nothing else in fullscreen asks for frames at +/// all — the spectrum drive parks and the view goes quiet — so a stage that +/// rode along with playback would freeze mid-turn the moment it was paused. +/// This asks for frames on the stage's behalf, from inside the frame it was +/// last given, and wakes the view to redraw. Call it every render; it only +/// ever has one loop running, and it stops the moment `live` goes false. +/// How many frames the loop may run on without the view having rendered. A +/// render refreshes the allowance, so while the stage is on screen the loop +/// never runs dry; once the view stops asking — it was closed, or the stage +/// was switched off — it winds down on its own instead of spinning forever on +/// a view nobody is watching. +const GRACE: u32 = 3; + +#[derive(Clone)] +pub(crate) struct Drive { + armed: Rc>, + beats: Rc>, +} + +impl Default for Drive { + fn default() -> Self { + Self { + armed: Rc::new(Cell::new(false)), + beats: Rc::new(Cell::new(0)), + } + } +} + +impl Drive { + pub(crate) fn run(&self, watch: EntityId, live: bool, window: &mut Window) { + self.beats.set(match live { + true => GRACE, + false => 0, + }); + if !live || self.armed.replace(true) { + return; + } + let drive = self.clone(); + window.on_next_frame(move |window, cx| drive.step(watch, window, cx)); + } + + fn step(&self, watch: EntityId, window: &mut Window, cx: &mut App) { + let beats = self.beats.get(); + if beats == 0 { + self.armed.set(false); + return; + } + self.beats.set(beats - 1); + cx.notify(watch); + let drive = self.clone(); + window.on_next_frame(move |window, cx| drive.step(watch, window, cx)); + } +} + +/// How long the particle field takes to fade in or out, as a time constant: +/// it covers about two thirds of the way in this long, and is all but there +/// after three times as much. Not a share per frame, so it lasts the same on +/// any display. Raise it for a slower drift in and out. +const FADE: f32 = 1.6; + +/// What the clock hands back for one frame. +pub(crate) struct Pose { + /// Seconds the music has been playing: the record's turn. + pub(crate) turn: f32, + /// How much of the particle field is there, from none of it to all of it. + /// It eases out once the music stops and back in once it starts — the + /// drift belongs to the sound, not to the window being open. + pub(crate) presence: f32, +} + +/// How long the record has been turning, and nothing else: the clock only +/// banks time while sound is being made, so pausing parks it mid-turn — no +/// rewind, no coasting — and resuming picks the turn up from where it stood. +/// Call it once per render; it remembers when it was last called and adds the +/// gap only when `playing`. The cap keeps a long stall — a suspended window, +/// a hitch in the loop — from lurching the record forward. +#[derive(Default)] +pub(crate) struct Clock { + played: Cell, + presence: Cell, + last: Cell>, +} + +impl Clock { + pub(crate) fn tick(&self, playing: bool) -> Pose { + let now = Instant::now(); + if let Some(last) = self.last.replace(Some(now)) { + let gone = now.duration_since(last).as_secs_f32().min(0.1); + if playing { + self.played.set(self.played.get() + gone); + } + let target = match playing { + true => 1., + false => 0., + }; + let rate = 1. - (-gone / FADE).exp(); + let presence = self.presence.get(); + self.presence.set(presence + (target - presence) * rate); + } + Pose { + turn: self.played.get(), + presence: self.presence.get(), + } + } + + /// Whether anything on the stage still moves: the record turning, or the + /// field still fading. Once a paused stage has gone still there is nothing + /// left to draw, and the frame loop may wind down. + pub(crate) fn moving(&self, playing: bool) -> bool { + playing || self.presence.get() > 0.01 + } +} + +/// What the stage is asked to show: the spectrum the ring reads and the shape +/// it reads them in, how much drifts off the rim, whether a record is staged, +/// how far it has turned and for how long, and the theme the stage dresses in. +/// Two clocks on purpose: the record and everything riding it turn only while +/// sound plays, while the particles keep to the wall clock whether or not it +/// does. +pub(crate) struct Stage { + pub(crate) levels: Levels, + pub(crate) style: VisualizerStyle, + pub(crate) particles: usize, + pub(crate) vinyl: bool, + /// Seconds on the wall clock, driving the particles' drift. + pub(crate) elapsed: f32, + /// Seconds the music has been playing, driving the record's turn. + pub(crate) turn: f32, + /// How much of the particle field is present, easing with the music. + pub(crate) presence: f32, + pub(crate) theme: Theme, +} + +/// Everything on the stage that moves, and by how much. The record turns on +/// the music's own clock; the field is as present as the music has left it; +/// the particles drift on the wall clock, whatever the music is doing, and +/// fade with the field. +#[derive(Clone, Copy)] +struct Motion { + /// Seconds the music has been playing. + turn: f32, + /// How much of the particle field is there, from none to all of it. + presence: f32, + /// Seconds since the stage was first drawn. + elapsed: f32, +} + +/// The whole stage, sized to `side`: the halo always, the ring in whatever +/// shape the visualizer is set to, and the record with everything that sweeps +/// across it only when a record is asked for — the cover cannot turn, so +/// without the turning record around it there is no vinyl to stage. +pub(crate) fn stage( + side: Pixels, + label: Option>, + waiting: bool, + stage: Stage, +) -> Div { + let Stage { + levels, + style, + particles, + vinyl, + elapsed, + turn, + presence, + theme, + } = stage; + let paint = palette(&theme); + let side = side.as_f32(); + + div() + .relative() + .size(px(side)) + .child( + canvas(move |_, _, _| {}, { + let levels = levels.clone(); + move |bounds, _, window, _| field(bounds, &levels, style, vinyl, &paint, window) + }) + .absolute() + .inset_0(), + ) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child({ + // Seven of cover to three of vinyl, with the record or + // without it: the stage's size is not the record's to give. + let art = Artwork::new(label).size(px(side * DISC * LABEL)); + // The cover rides the record: the renderer cannot turn an + // image, so the turn is cut from its pixels and handed back + // as a frame of its own, once per degree. + match vinyl { + true => art.spin(turn / SPIN).circle().soft(waiting), + false => art.circle().soft(waiting), + } + }), + ) + .child( + canvas(move |_, _, _| {}, { + let motion = Motion { + turn, + presence, + elapsed, + }; + move |bounds, _, window, _| shine(bounds, motion, particles, vinyl, &paint, window) + }) + .absolute() + .inset_0(), + ) +} + +/// Every colour the stage paints with, resolved once from the theme. +#[derive(Clone, Copy)] +struct Palette { + halo: Hsla, + disc: Hsla, + groove: Hsla, + groove_dark: Hsla, + rim: Hsla, + ring: Hsla, + star: Hsla, + sheen: Hsla, + marker: Hsla, + label_edge: Hsla, +} + +/// The record dresses in the theme's own hue at a whisper: a dark disc with a +/// trace of it, lighter and darker grooves over it, and everything that glows +/// — halo, ring, particles — in the hue itself. On a light theme the disc cuts +/// the hue deeper and the glows darken, so the stage sits with the adaptive +/// theme instead of washing out against a bright background. +fn palette(theme: &Theme) -> Palette { + let hue = theme.primary; + let dark = theme.background.l < 0.5; + let neutral = |l: f32, a: f32| Hsla { + h: hue.h, + s: 0.08, + l, + a, + }; + + let disc = Hsla { + h: hue.h, + s: match dark { + true => (hue.s * 0.25).clamp(0.02, 0.28), + false => (hue.s * 0.5).clamp(0.08, 0.4), + }, + l: match dark { + true => 0.115, + false => 0.24, + }, + a: 1., + }; + let glow = Hsla { + l: match dark { + true => hue.l.clamp(0.42, 0.72), + false => hue.l.clamp(0.32, 0.52), + }, + ..hue + }; + + Palette { + halo: Hsla { + s: (hue.s * 0.9).clamp(0.15, 0.7), + l: match dark { + true => (hue.l * 0.95).clamp(0.25, 0.6), + false => hue.l.clamp(0.35, 0.55), + }, + ..hue + }, + disc, + groove: Hsla { + s: disc.s * 0.4, + l: disc.l + 0.075, + a: 0.5, + ..disc + }, + groove_dark: Hsla { + s: disc.s * 0.4, + l: disc.l - 0.065, + a: 0.55, + ..disc + }, + rim: Hsla { + l: disc.l + 0.2, + a: 0.45, + ..disc + }, + ring: glow, + // The particles carry the theme's own colour, not a neutral one: what + // drifts off the rim should read as part of the same scheme as the disc + // and the ring. On a light theme they take the hue deeper so they stay + // held against the background instead of bleaching out. + star: Hsla { + s: hue.s.clamp(0.25, 0.8), + l: match dark { + true => (hue.l + 0.3).clamp(0.55, 0.82), + false => (hue.l - 0.18).clamp(0.3, 0.5), + }, + ..hue + }, + sheen: neutral(0.9, SHEEN), + marker: neutral(0.8, 0.06), + label_edge: neutral( + match dark { + true => 0.04, + false => 0.08, + }, + 0.35, + ), + } +} + +/// The field under the label: halo, spectrum ring and, when the record is +/// staged, its face and grooves. +fn field( + bounds: Bounds, + levels: &Levels, + style: VisualizerStyle, + vinyl: bool, + paint: &Palette, + window: &mut Window, +) { + let Some(center) = center(bounds) else { + return; + }; + let side = bounds.size.width.min(bounds.size.height).as_f32(); + let radius = side * DISC / 2.; + + // The halo: concentric discs whose opacity falls off quadratically, the + // same trick the ambient field uses for its blobs. It is what makes the + // stage read as diffuse rather than as a disc on flat paint. + for step in 0..HALO { + let u = step as f32 / HALO as f32; + let reach = radius + side * HALO_REACH * u; + let alpha = (1. - u) * (1. - u) * 0.10; + let mut builder = PathBuilder::fill(); + circle(&mut builder, center, reach); + match builder.build() { + Ok(path) => window.paint_path(path, paint.halo.opacity(alpha)), + Err(error) => log::warn!("starry: cannot build the halo: {error}"), + } + } + + if style.shown() { + ring(center, side, radius, levels, style, paint, window); + } + if vinyl { + record(center, radius, paint, window); + } +} + +/// The ring around the rim, in whatever shape the visualizer is set to: bars +/// stand in a mirrored circle, the wave runs one smooth loop, and `Both` puts +/// the bars inside the wave, cut to its height. +fn ring( + center: Point, + side: f32, + radius: f32, + levels: &Levels, + style: VisualizerStyle, + paint: &Palette, + window: &mut Window, +) { + let bands = levels.mixed(); + if bands.is_empty() { + return; + } + let inner = radius + side * RING_GAP; + let reach = side * RING_REACH; + + match style { + VisualizerStyle::None => {} + VisualizerStyle::Bars => bars(center, inner, reach, &bands, |_| f32::MAX, paint, window), + VisualizerStyle::Wave => wave(center, inner, reach, &bands, paint, window), + VisualizerStyle::Both => { + wave(center, inner, reach, &bands, paint, window); + // Under the wave: a bar standing proud of it is cut back to the + // wave's own height at that angle, so the two read as one shape + // with the wave as its edge. + bars( + center, + inner, + reach, + &bands, + |angle| wave_radius(inner, reach, &bands, angle), + paint, + window, + ); + } + } +} + +/// The wave's own radius at `angle`, walked along the same cubics `wave` draws +/// with. The bars stand between the wave's samples, where a spline dips below +/// the points it passes through, so their own heights are not enough to keep +/// them in — they have to be cut to the curve itself. +fn wave_radius(inner: f32, reach: f32, bands: &[f32], angle: f32) -> f32 { + let count = bands.len(); + let start = -TAU / 4.; + let step = TAU / count as f32; + let at = |index: i64| { + let band = bands[index.rem_euclid(count as i64) as usize]; + let level = RING_FLOOR + band.clamp(0., 1.) * (1. - RING_FLOOR); + let turned = start + step * (index as f32 + 0.5); + let radius = inner + reach * level; + (radius * turned.cos(), radius * turned.sin()) + }; + + let walked = (angle - start) / step; + let segment = walked.floor(); + let t = (walked - segment).clamp(0., 1.); + let index = segment as i64; + let (x0, y0) = at(index); + let (x1, y1) = at(index + 1); + let (bx, by) = at(index - 1); + let (ax, ay) = at(index + 2); + + let (c1x, c1y) = (x0 + (x1 - bx) / 6., y0 + (y1 - by) / 6.); + let (c2x, c2y) = (x1 - (ax - x0) / 6., y1 - (ay - y0) / 6.); + let u = 1. - t; + let x = x0 * (u * u * u) + c1x * (3. * u * u * t) + c2x * (3. * u * t * t) + x1 * (t * t * t); + let y = y0 * (u * u * u) + c1y * (3. * u * u * t) + c2y * (3. * u * t * t) + y1 * (t * t * t); + (x * x + y * y).sqrt() +} + +/// The bars: the mixed bands walked twice around the rim, once and then +/// mirrored, so the circle reads as symmetric. All bars share one stroked +/// path, one subpath each. `ceiling` is the furthest a bar may reach at a +/// given angle, which is what keeps them under the wave when both are drawn. +fn bars( + center: Point, + inner: f32, + reach: f32, + bands: &[f32], + ceiling: impl Fn(f32) -> f32, + paint: &Palette, + window: &mut Window, +) { + let count = bands.len(); + let posts = 2 * count; + let width = (TAU * inner / posts as f32 * 0.5).max(1.2); + let mut builder = PathBuilder::stroke(px(width)); + for post in 0..posts { + let band = match post < count { + true => bands[post], + false => bands[posts - 1 - post], + }; + let level = RING_FLOOR + band.clamp(0., 1.) * (1. - RING_FLOOR); + let angle = -TAU / 4. + TAU * (post as f32 + 0.5) / posts as f32; + // A hair under the ceiling, so the wave's stroke reads as the edge and + // the bars as what fills it. + let tip = (inner + reach * level).min(ceiling(angle) - 0.5); + builder.move_to(on(center, inner, angle)); + builder.line_to(on(center, tip, angle)); + } + match builder.build() { + Ok(path) => window.paint_path(path, paint.ring.opacity(0.55)), + Err(error) => log::warn!("starry: cannot build the ring: {error}"), + } +} + +/// The wave: one smooth loop through every band, a closed Catmull-Rom spline — +/// the same spline the bottom wave runs — bent around the rim. +fn wave( + center: Point, + inner: f32, + reach: f32, + bands: &[f32], + paint: &Palette, + window: &mut Window, +) { + let mut points = Vec::with_capacity(bands.len()); + for (index, band) in bands.iter().enumerate() { + let level = RING_FLOOR + band.clamp(0., 1.) * (1. - RING_FLOOR); + let angle = -TAU / 4. + TAU * (index as f32 + 0.5) / bands.len() as f32; + points.push(on(center, inner + reach * level, angle)); + } + let mut builder = PathBuilder::stroke(px(1.5)); + wave_ring(&mut builder, &points); + match builder.build() { + Ok(path) => window.paint_path(path, paint.ring.opacity(0.55)), + Err(error) => log::warn!("starry: cannot build the wave: {error}"), + } +} + +/// Appends a closed Catmull-Rom spline through `points`, as cubics. +fn wave_ring(builder: &mut PathBuilder, points: &[Point]) { + let count = points.len(); + if count < 2 { + return; + } + builder.move_to(points[0]); + for index in 0..count { + let from = points[index]; + let to = points[(index + 1) % count]; + let before = points[(index + count - 1) % count]; + let after = points[(index + 2) % count]; + builder.cubic_bezier_to( + to, + point( + from.x + (to.x - before.x) / 6., + from.y + (to.y - before.y) / 6., + ), + point( + to.x - (after.x - from.x) / 6., + to.y - (after.y - from.y) / 6., + ), + ); + } + builder.close(); +} + +/// The record itself: face, grooves and rim. The grooves alternate two tones +/// so the surface has texture even where the sheen isn't passing. +fn record(center: Point, radius: f32, paint: &Palette, window: &mut Window) { + let mut face = PathBuilder::fill(); + circle(&mut face, center, radius); + match face.build() { + Ok(path) => window.paint_path(path, paint.disc), + Err(error) => log::warn!("starry: cannot build the record: {error}"), + } + + let mut light = PathBuilder::stroke(px(1.)); + let mut dark = PathBuilder::stroke(px(1.)); + for groove in 0..GROOVES { + let t = GROOVE_IN + (GROOVE_OUT - GROOVE_IN) * groove as f32 / (GROOVES - 1) as f32; + match groove % 2 == 0 { + true => circle(&mut light, center, radius * t), + false => circle(&mut dark, center, radius * t), + } + } + circle(&mut light, center, radius * 0.995); + match light.build() { + Ok(path) => window.paint_path(path, paint.groove), + Err(error) => log::warn!("starry: cannot build the grooves: {error}"), + } + match dark.build() { + Ok(path) => window.paint_path(path, paint.groove_dark), + Err(error) => log::warn!("starry: cannot build the grooves: {error}"), + } + + let mut rim = PathBuilder::stroke(px(1.5)); + circle(&mut rim, center, radius - 0.75); + match rim.build() { + Ok(path) => window.paint_path(path, paint.rim), + Err(error) => log::warn!("starry: cannot build the rim: {error}"), + } +} + +/// Everything that sweeps over the label: the sheen and its groove markers +/// turning only while the music does, the particles as present as the music +/// has left them, and a hairline at the cover's edge so the two read as +/// separate surfaces. With y pointing down, a growing angle already turns +/// clockwise on screen. +fn shine( + bounds: Bounds, + motion: Motion, + particles: usize, + vinyl: bool, + paint: &Palette, + window: &mut Window, +) { + let Motion { + turn, + presence, + elapsed, + } = motion; + let Some(center) = center(bounds) else { + return; + }; + let side = bounds.size.width.min(bounds.size.height).as_f32(); + let radius = side * DISC / 2.; + + if vinyl { + let angle = TAU * turn / SPIN; + + // Two sheen passes, opposite each other, each a fan of cells whose + // opacity rises to a peak in the middle and falls off at both edges. + // The fan is what rotates; the cells only keep its ends soft. + for pass in 0..2 { + let base = angle + pass as f32 * std::f32::consts::PI; + for cell in 0..SHEEN_CELLS { + let from = base + SHEEN_SPAN * cell as f32 / SHEEN_CELLS as f32; + let to = base + SHEEN_SPAN * (cell + 1) as f32 / SHEEN_CELLS as f32; + let alpha = + SHEEN * (std::f32::consts::PI * (cell as f32 + 0.5) / SHEEN_CELLS as f32).sin(); + let mut builder = PathBuilder::fill(); + // From the cover's edge outwards: the sheen sweeps the vinyl, + // not the art it carries. + wedge( + &mut builder, + center, + radius * LABEL, + radius * 0.985, + from, + to, + ); + match builder.build() { + Ok(path) => window.paint_path(path, paint.sheen.opacity(alpha)), + Err(error) => log::warn!("starry: cannot build the sheen: {error}"), + } + } + } + + // Three short bright arcs riding the grooves at the sheen's angle. + for marker in MARKERS { + let mut builder = PathBuilder::stroke(px(1.2)); + arc( + &mut builder, + center, + radius * marker, + angle + 0.4, + angle + 0.4 + MARKER_SPAN, + ); + match builder.build() { + Ok(path) => window.paint_path(path, paint.marker), + Err(error) => log::warn!("starry: cannot build a marker: {error}"), + } + } + } + + // The particles: each on its own cycle from the rim outward, fading in and + // back out over the trip, scattered by a hash so the field is the same + // every frame without a random source. They are gathered by how bright they + // are and one path is drawn per shade, so a thousand of them cost a + // handful of draws rather than a thousand. The whole field carries the + // music's presence: it fades out as the music stops and back in as it + // starts, and a field faded to nothing costs nothing at all. + if presence > 0.01 { + let mut shades: Vec = (0..TONES).map(|_| PathBuilder::fill()).collect(); + let mut counts = [0usize; TONES]; + for seed in 0..particles as u32 { + let first = scatter(seed); + let second = scatter(seed.wrapping_add(0x9E1)); + let third = scatter(seed.wrapping_add(0x37D)); + let period = 6. + first * 6.; + let journey = (elapsed / period + second).fract(); + let distance = radius * (1.03 + 0.23 * journey.powf(0.8)); + // A slow orbit in the record's own direction, a little different + // per particle, so the field circles rather than spins rigidly. + let heading = -TAU / 4. + TAU * third + elapsed * TAU / SPIN * (0.06 + first * 0.12); + let alpha = (std::f32::consts::PI * journey).sin() * (0.22 + second * 0.30) * presence; + let size = side * 0.0025 + third * side * 0.003; + let shade = ((alpha / STAR * TONES as f32) as usize).min(TONES - 1); + counts[shade] += 1; + disc(&mut shades[shade], on(center, distance, heading), size); + } + for (shade, builder) in shades.into_iter().enumerate() { + if counts[shade] == 0 { + continue; + } + let alpha = (shade as f32 + 0.5) / TONES as f32 * STAR; + match builder.build() { + Ok(path) => window.paint_path(path, paint.star.opacity(alpha)), + Err(error) => log::warn!("starry: cannot build the particles: {error}"), + } + } + } + + // A hairline pressing the label's edge into the record so the two read as + // separate surfaces. There is no spindle: the cover covers seven tenths of + // the record and sits over the hole, the way a real label does. + if vinyl { + let mut edge = PathBuilder::stroke(px(1.)); + circle(&mut edge, center, radius * LABEL + 0.5); + match edge.build() { + Ok(path) => window.paint_path(path, paint.label_edge), + Err(error) => log::warn!("starry: cannot build the label edge: {error}"), + } + } +} + +/// The stage's centre, or `None` while the canvas is still empty. +fn center(bounds: Bounds) -> Option> { + let side = bounds.size.width.min(bounds.size.height); + match side <= px(1.) { + true => None, + false => Some(point( + bounds.origin.x + bounds.size.width / 2., + bounds.origin.y + bounds.size.height / 2., + )), + } +} + +/// The point at `angle` around `center`, at `radius` in logical pixels. +fn on(center: Point, radius: f32, angle: f32) -> Point { + point( + center.x + px(radius * angle.cos()), + center.y + px(radius * angle.sin()), + ) +} + +/// Appends a full circle as a closed polygon of `SEGMENTS` straight segments. +fn circle(builder: &mut PathBuilder, center: Point, radius: f32) { + let step = TAU / SEGMENTS as f32; + for segment in 0..=SEGMENTS { + let at = on(center, radius, segment as f32 * step); + match segment { + 0 => builder.move_to(at), + _ => builder.line_to(at), + } + } + builder.close(); +} + +/// Appends a disc whose own size decides how many segments it takes. A particle +/// is a few pixels across and does not need the seventy-two a record does, and +/// a thousand of them share a path. +fn disc(builder: &mut PathBuilder, center: Point, radius: f32) { + let cells = ((radius * 0.8).ceil() as usize).clamp(6, SEGMENTS); + let step = TAU / cells as f32; + for cell in 0..=cells { + let at = on(center, radius, cell as f32 * step); + match cell { + 0 => builder.move_to(at), + _ => builder.line_to(at), + } + } + builder.close(); +} + +/// Appends a curved run from `from` to `to`, subdivided finely enough that the +/// chords never read as corners at these radii. +fn arc(builder: &mut PathBuilder, center: Point, radius: f32, from: f32, to: f32) { + let cells = ((to - from).abs() / 0.06).ceil().max(3.) as usize; + for cell in 0..=cells { + let at = on( + center, + radius, + from + (to - from) * cell as f32 / cells as f32, + ); + match cell { + 0 => builder.move_to(at), + _ => builder.line_to(at), + } + } +} + +/// Appends an annular sector — the sheen's cell — between two radii and two +/// angles, both ends curved. +fn wedge( + builder: &mut PathBuilder, + center: Point, + inner: f32, + outer: f32, + from: f32, + to: f32, +) { + let cells = ((to - from).abs() / 0.06).ceil().max(3.) as usize; + builder.move_to(on(center, inner, from)); + builder.line_to(on(center, outer, from)); + for cell in 1..=cells { + builder.line_to(on( + center, + outer, + from + (to - from) * cell as f32 / cells as f32, + )); + } + builder.line_to(on(center, inner, to)); + builder.line_to(on(center, inner, from)); + builder.close(); +} + +/// A stable pseudo-random fraction of the seed, in 0..1. The constants are the +/// usual integer-mixing suspects; only that the output is spread evenly and +/// repeatable matters here. +fn scatter(seed: u32) -> f32 { + let mut mixed = seed.wrapping_mul(0x9E37_79B9).wrapping_add(0x85EB_CA6B); + mixed ^= mixed >> 13; + mixed = mixed.wrapping_mul(0xC2B2_AE35); + mixed ^= mixed >> 16; + (mixed >> 8) as f32 / 16_777_216. +} diff --git a/crates/views/src/shells/fullscreen.rs b/crates/views/src/shells/fullscreen.rs index 3b981726d..168f06a53 100644 --- a/crates/views/src/shells/fullscreen.rs +++ b/crates/views/src/shells/fullscreen.rs @@ -12,7 +12,9 @@ use gpui::{Window, canvas, deferred, div, phi, px, relative}; use i18n::t; use input::{ToggleFullscreen, WORKSPACE_CONTEXT}; use router::{Destination, navigate}; -use state::{AppSettings, Cover, FullscreenControlsAutohide, Playback, Queue, SideTab, Sonora}; +use state::{ + AppSettings, Cover, FullscreenControlsAutohide, Playback, PlaybackState, Queue, SideTab, Sonora, +}; use ui::{ ActiveTheme as _, Artwork, Button, ExplicitBadge, InlineLink, InlineLinks, Motion, Motioned as _, Popup, Room, Scrollbar, Scrubber, ScrubberState, Springs, TabBar, Text, @@ -24,7 +26,7 @@ use crate::shared::menus::ItemMenu; use crate::shared::transport::{NOTCH, like, moved, percent, transport, volume_icon}; use crate::shared::veil::{Edge, veil}; use crate::shared::visualizer::VisualizerDrive; -use crate::shared::{self, ambient}; +use crate::shared::{self, ambient, starry}; use crate::shells::Shell; const COVER_TALL: f32 = 0.46; @@ -98,6 +100,8 @@ pub struct FullscreenView { rest: Option>, focus: FocusHandle, visualizer: VisualizerDrive, + stage: starry::Drive, + stage_clock: starry::Clock, root_bounds: Rc>>, artwork_bounds: Rc>>, } @@ -150,6 +154,8 @@ impl FullscreenView { rest: None, focus: cx.focus_handle(), visualizer: VisualizerDrive::default(), + stage: starry::Drive::default(), + stage_clock: starry::Clock::default(), root_bounds: Rc::new(Cell::new(Bounds::default())), artwork_bounds: Rc::new(Cell::new(Bounds::default())), }; @@ -357,6 +363,89 @@ impl FullscreenView { || small.as_ref().is_some_and(|url| url.starts_with("file://")); let waiting = !local && album.is_some() && cover_large.is_none(); let artwork_bounds = self.artwork_bounds.clone(); + let settings = self.settings.read(cx); + let starry = settings.starry(); + let style = settings.visualizer_style(); + let vinyl = settings.vinyl(); + let particles = settings.particles(); + let theme = *cx.theme(); + let levels = self.visualizer.levels(); + + // Two clocks. The record and everything riding it turn only while + // sound plays — pausing parks them mid-turn, resuming picks them up — + // and the particle field fades out with the music and back in with it. + // Both hold their pose when motion is reduced, and the ring reads the + // already-eased spectrum levels, so it settles the way the bottom + // visualizer does. + let playing = matches!( + self.playback.read(cx).state(), + PlaybackState::Playing | PlaybackState::Loading + ); + let pose = match starry && ui::motion::animates(cx) { + true => self.stage_clock.tick(playing), + false => starry::Pose { + turn: 0., + presence: match playing { + true => 1., + false => 0., + }, + }, + }; + let scene = starry::Stage { + levels, + style, + particles, + vinyl, + elapsed: match starry && ui::motion::animates(cx) { + true => starry::spin(), + false => 0., + }, + turn: pose.turn, + presence: pose.presence, + theme, + }; + let cover = if starry { + vec![ + div() + .absolute() + .top(pad) + .left(pad) + .child(starry::stage(raster_side, small, waiting, scene)) + .into_any_element(), + ] + } else { + let mut faces = vec![ + div() + .absolute() + .top(pad) + .left(pad) + .child( + Artwork::new(small) + .size(raster_side) + .corner_radius(radius) + .soft(waiting), + ) + .into_any_element(), + ]; + if let Some(url) = large { + faces.push( + div() + .absolute() + .top(pad) + .left(pad) + .child( + Artwork::new(Some(url)) + .size(raster_side) + .corner_radius(radius), + ) + .motion(("cover-large", revision), Motion::Slow, |art, t| { + art.opacity(t) + }) + .into_any_element(), + ); + } + faces + }; div() .id("fullscreen-artwork") @@ -382,30 +471,7 @@ impl FullscreenView { .left(inset) .size(raster_side + pad * 2.) .layer_scale(presentation_scale) - .child( - div().absolute().top(pad).left(pad).child( - Artwork::new(small) - .size(raster_side) - .corner_radius(radius) - .soft(waiting), - ), - ) - .when_some(large, |this, url| { - this.child( - div() - .absolute() - .top(pad) - .left(pad) - .child( - Artwork::new(Some(url)) - .size(raster_side) - .corner_radius(radius), - ) - .motion(("cover-large", revision), Motion::Slow, |art, t| { - art.opacity(t) - }), - ) - }), + .child(div().absolute().inset_0().children(cover)), ) } @@ -1076,16 +1142,32 @@ impl Render for FullscreenView { let cover_scale = presentation_scale(presented_side, raster_side); let lift = (presented_side - side) / 2.; let staged = self.panel.is_none() || split; + let starry = staged && self.settings.read(cx).starry(); let style = self.settings.read(cx).visualizer_style(); let visualizer_on = self.panel.is_none() && style.shown(); - match visualizer_on + match (visualizer_on || starry) .then(|| self.playback.read(cx).spectrum()) .flatten() { Some(spectrum) => self.visualizer.show(cx.entity_id(), spectrum, window), None => self.visualizer.hide(), } + // The ring rides the spectrum drive above, which only runs while sound + // is being made. Everything else on the stage asks for frames of its + // own — the record's turn, the sheen, the particles fading in and out + // around the music. Once a paused stage has faded to nothing there is + // nothing left to draw, and the loop winds down until it is asked for + // again; playback starting wakes the view on its own. + let playing = matches!( + self.playback.read(cx).state(), + PlaybackState::Playing | PlaybackState::Loading + ); + self.stage.run( + cx.entity_id(), + starry && ui::motion::animates(cx) && self.stage_clock.moving(playing), + window, + ); let bottom = |bounds: Bounds| bounds.origin.y + bounds.size.height; let visualizer_max = (bottom(self.root_bounds.get()) - bottom(self.artwork_bounds.get())) .max(px(VISUALIZER_MIN)); From 9dcd648a8c6b62ec2ea6942af8c2fd48f3adaa57 Mon Sep 17 00:00:00 2001 From: fengqiu233 <977475514@qq.com> Date: Tue, 22 Sep 2026 15:01:20 +0800 Subject: [PATCH 2/2] feat(views): stage the fullscreen cover --- CHANGELOG.md | 6 +- README.md | 26 ++-- assets/i18n/en-US/main.ftl | 10 +- crates/state/src/settings.rs | 43 ++++-- crates/ui/src/lib.rs | 2 + crates/ui/src/stage.rs | 66 ++++++++ crates/views/src/screens/settings.rs | 78 ++++------ crates/views/src/shared/starry.rs | 207 ++++++++++++++++++++++++-- crates/views/src/shells/fullscreen.rs | 15 +- 9 files changed, 355 insertions(+), 98 deletions(-) create mode 100644 crates/ui/src/stage.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d4bcfb8..a726a8c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- Fullscreen can stage the cover in a halo of drifting particles, with a ring of spectrum bars +- Fullscreen stages the cover four ways, picked from one Stage setting under Appearance > + Fullscreen: plain, haloed in drifting particles with a ring of spectrum bars, dressed as the + label of a record that turns under a slow sheen, or slid out of its square sleeve with the + record turning behind it. The cover turns with the record wherever there is one, the particles + fade in as the music starts and out as it stops, and how many of them drift is its own setting. ### Changed diff --git a/README.md b/README.md index 23830a2e8..a7140e05c 100644 --- a/README.md +++ b/README.md @@ -187,19 +187,19 @@ AI-assisted proofreading and translation of human-written text are permitted. | Language | Translated | Coverage | | --- | --- | --- | -| English (`en-US`) | 727/727 | 100% | -| Deutsch (`de`) | 631/720 | 88% | -| Español (`es`) | 691/720 | 96% | -| Français (`fr`) | 631/720 | 88% | -| Italiano (`it`) | 609/720 | 85% | -| Bahasa Indonesia (`id`) | 609/720 | 85% | -| 日本語 (`ja`) | 609/720 | 85% | -| Русский (`ru`) | 711/720 | 99% | -| Українська (`uk`) | 711/720 | 99% | -| Polski (`pl`) | 711/720 | 99% | -| Português (Brasil) (`pt-BR`) | 609/720 | 85% | -| 简体中文 (`zh-CN`) | 609/720 | 85% | -| Türkçe (`tr`) | 609/720 | 85% | +| English (`en-US`) | 733/733 | 100% | +| Deutsch (`de`) | 631/733 | 86% | +| Español (`es`) | 691/733 | 94% | +| Français (`fr`) | 631/733 | 86% | +| Italiano (`it`) | 609/733 | 83% | +| Bahasa Indonesia (`id`) | 609/733 | 83% | +| 日本語 (`ja`) | 609/733 | 83% | +| Русский (`ru`) | 711/733 | 96% | +| Українська (`uk`) | 711/733 | 96% | +| Polski (`pl`) | 711/733 | 96% | +| Português (Brasil) (`pt-BR`) | 609/733 | 83% | +| 简体中文 (`zh-CN`) | 609/733 | 83% | +| Türkçe (`tr`) | 609/733 | 83% | diff --git a/assets/i18n/en-US/main.ftl b/assets/i18n/en-US/main.ftl index 6de3cd788..df6b3f76c 100644 --- a/assets/i18n/en-US/main.ftl +++ b/assets/i18n/en-US/main.ftl @@ -495,10 +495,12 @@ settings-ambient = Ambient background settings-ambient-detail = Fill the fullscreen player with colours drawn from the cover settings-ambient-motion = Ambient motion settings-ambient-motion-detail = Drift the ambient colours instead of holding them still -settings-starry = Starry stage -settings-starry-detail = Halo the fullscreen cover with drifting particles and a spectrum ring that follows the visualizer -settings-vinyl = Spinning vinyl -settings-vinyl-detail = Dress the cover as the label of a record that turns under a slow sheen +settings-stage = Stage +settings-stage-detail = How fullscreen stages the cover +settings-stage-style-cover = Cover only +settings-stage-style-starry = Starry +settings-stage-style-vinyl = Spinning vinyl +settings-stage-style-sleeve = Sleeve and vinyl settings-particles = Drifting particles settings-particles-detail = How many particles drift off the cover settings-particles-value = { $count } diff --git a/crates/state/src/settings.rs b/crates/state/src/settings.rs index 766a8c305..aab23113c 100644 --- a/crates/state/src/settings.rs +++ b/crates/state/src/settings.rs @@ -316,9 +316,14 @@ struct Appearance { adaptive_theme: bool, ambient: bool, ambient_motion: bool, + /// Whether the fullscreen stage is on — the on bit of `stage_style`, kept + /// so an old settings file keeps its answer. starry: bool, + /// The record choice from before the stage styles were one setting, read + /// only while `stage_style` is still empty. vinyl: bool, particles: usize, + stage_style: String, visualizer: bool, visualizer_style: String, icons: String, @@ -512,6 +517,7 @@ impl Default for Appearance { starry: false, vinyl: true, particles: 36, + stage_style: String::new(), visualizer: true, visualizer_style: ui::VisualizerStyle::default().id().to_owned(), icons: icons::BASE.to_owned(), @@ -804,15 +810,20 @@ impl AppSettings { self.values.appearance.ambient_motion } - /// Whether fullscreen stages the cover on a spinning vinyl record with - /// drifting particles and a spectrum ring around it. - pub fn starry(&self) -> bool { - self.values.appearance.starry - } - - /// Whether the starry stage dresses the cover as a vinyl record at all. - pub fn vinyl(&self) -> bool { - self.values.appearance.vinyl + /// How fullscreen stages the cover. The old `starry` switch is still the + /// on bit and the old `vinyl` switch the record choice, so a settings file + /// written before the three were one setting keeps its answers. + pub fn stage_style(&self) -> ui::StageStyle { + match self.values.appearance.starry { + false => ui::StageStyle::Cover, + true => match self.values.appearance.stage_style.as_str() { + "" => match self.values.appearance.vinyl { + true => ui::StageStyle::Vinyl, + false => ui::StageStyle::Starry, + }, + id => ui::StageStyle::from_id(id), + }, + } } /// How many particles drift off the cover in the starry stage. @@ -1421,13 +1432,13 @@ impl AppSettings { self.schedule_save(cx); } - pub fn set_starry(&mut self, starry: bool, cx: &mut Context) { - self.values.appearance.starry = starry; - self.schedule_save(cx); - } - - pub fn set_vinyl(&mut self, vinyl: bool, cx: &mut Context) { - self.values.appearance.vinyl = vinyl; + /// Picking a style turns the stage on; picking `Cover` turns it off and + /// leaves the style behind it alone, so the old choice comes back with it. + pub fn set_stage_style(&mut self, style: ui::StageStyle, cx: &mut Context) { + self.values.appearance.starry = style.shown(); + if style.shown() { + self.values.appearance.stage_style = style.id().to_owned(); + } self.schedule_save(cx); } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 7c51f09eb..334826424 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -34,6 +34,7 @@ mod scrubber; mod separator; mod shield; mod skeleton; +mod stage; mod switch; mod table; mod tabs; @@ -98,6 +99,7 @@ pub use scrubber::{Scrubber, ScrubberState}; pub use separator::Separator; pub use shield::Shield; pub use skeleton::{Initials, Skeleton}; +pub use stage::StageStyle; pub use switch::Switch; pub use table::{ Activate, Cell, ColumnSpec, Deselect, Layout, Listing, ROW_GROUP, Remove, SelectNext, diff --git a/crates/ui/src/stage.rs b/crates/ui/src/stage.rs new file mode 100644 index 000000000..b0dd6c720 --- /dev/null +++ b/crates/ui/src/stage.rs @@ -0,0 +1,66 @@ +use gpui::SharedString; +use i18n::t; + +/// How fullscreen stages the cover: as itself, haloed in drifting particles, +/// riding a turning record as its label, or slid halfway out of its sleeve +/// with the record peeking out behind it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StageStyle { + /// The cover as it is, nothing staged around it. + #[default] + Cover, + /// The cover haloed in particles, with a spectrum ring around it. + Starry, + /// The starry stage with the cover dressed as a turning record's label. + Vinyl, + /// The cover slid out of its sleeve, the record turning behind it. + Sleeve, +} + +impl StageStyle { + pub const ALL: [Self; 4] = [Self::Cover, Self::Starry, Self::Vinyl, Self::Sleeve]; + + /// Whether fullscreen stages anything at all. + pub fn shown(self) -> bool { + self != Self::Cover + } + + /// Whether the style carries the halo, the particle field and the spectrum + /// ring — everything but the sleeve, which stages the cover on bare paint. + pub fn fielded(self) -> bool { + matches!(self, Self::Starry | Self::Vinyl) + } + + /// Whether the cover rides a turning record as its label. + pub fn turned(self) -> bool { + self == Self::Vinyl + } + + pub fn id(self) -> &'static str { + match self { + Self::Cover => "cover", + Self::Starry => "starry", + Self::Vinyl => "vinyl", + Self::Sleeve => "sleeve", + } + } + + pub fn from_id(id: &str) -> Self { + match id { + "starry" => Self::Starry, + "vinyl" => Self::Vinyl, + "sleeve" => Self::Sleeve, + _ => Self::default(), + } + } + + /// The localized name. Resolved at render, never stored. + pub fn label(self) -> SharedString { + match self { + Self::Cover => t!("settings-stage-style-cover"), + Self::Starry => t!("settings-stage-style-starry"), + Self::Vinyl => t!("settings-stage-style-vinyl"), + Self::Sleeve => t!("settings-stage-style-sleeve"), + } + } +} diff --git a/crates/views/src/screens/settings.rs b/crates/views/src/screens/settings.rs index 2b739629a..016b3a201 100644 --- a/crates/views/src/screens/settings.rs +++ b/crates/views/src/screens/settings.rs @@ -30,8 +30,8 @@ use ui::{ActiveTheme as _, Deck, LEADING, Scrollbar, Scroller, eyebrow, snapped} use ui::{ Avatar, Button, Dismiss, InfoCard, Initials, Input, Look, MAX_FONT, MAX_LYRICS_SCALE, MAX_TRANSPARENCY, MIN_FONT, MIN_LYRICS_SCALE, MenuItem, Modal, Pace, Picker, Popovers, Radio, - Rounding, Saver, Scrubber, ScrubberState, Separator, Skeleton, Stillness, Switch, TabBar, Text, - Theme, ThemeKind, Vacancy, VisualizerStyle, + Rounding, Saver, Scrubber, ScrubberState, Separator, Skeleton, StageStyle, Stillness, Switch, + TabBar, Text, Theme, ThemeKind, Vacancy, VisualizerStyle, }; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -55,6 +55,7 @@ const CORNERS: &str = "corners"; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] const WINDOW_ROUNDING: &str = "window-rounding"; const FULLSCREEN_CONTROLS_AUTOHIDE: &str = "fullscreen-controls-autohide"; +const STAGE_STYLE: &str = "stage-style"; const VISUALIZER_STYLE: &str = "visualizer-style"; const LANGUAGES: &str = "languages"; const TYPEFACES: &str = "typefaces"; @@ -133,8 +134,7 @@ enum Slot { Adaptive, Ambient, AmbientMotion, - Starry, - Vinyl, + Stage, Particles, Visualizer, Icons, @@ -553,9 +553,14 @@ impl SettingsView { .ambient() .then_some(Slot::AmbientMotion), ) - .chain([Slot::Starry]) - .chain(self.settings.read(cx).starry().then_some(Slot::Vinyl)) - .chain(self.settings.read(cx).starry().then_some(Slot::Particles)) + .chain([Slot::Stage]) + .chain( + self.settings + .read(cx) + .stage_style() + .fielded() + .then_some(Slot::Particles), + ) .chain([ Slot::Visualizer, Slot::FullscreenControlsAutohide, @@ -656,8 +661,7 @@ impl SettingsView { t!("settings-ambient-motion"), t!("settings-ambient-motion-detail"), ), - Slot::Starry => (t!("settings-starry"), t!("settings-starry-detail")), - Slot::Vinyl => (t!("settings-vinyl"), t!("settings-vinyl-detail")), + Slot::Stage => (t!("settings-stage"), t!("settings-stage-detail")), Slot::Particles => (t!("settings-particles"), t!("settings-particles-detail")), Slot::Visualizer => (t!("settings-visualizer"), t!("settings-visualizer-detail")), Slot::Icons => (t!("settings-icons"), t!("settings-icons-detail")), @@ -876,8 +880,7 @@ impl SettingsView { Slot::Adaptive => self.adaptive_row(cx).element, Slot::Ambient => self.ambient_row(cx).element, Slot::AmbientMotion => self.ambient_motion_row(cx).element, - Slot::Starry => self.starry_row(cx).element, - Slot::Vinyl => self.vinyl_row(cx).element, + Slot::Stage => self.stage_row(cx).element, Slot::Particles => self.particles_row(cx).element, Slot::Visualizer => self.visualizer_style_row(cx).element, Slot::Icons => self.icons_row(cx).element, @@ -1763,48 +1766,33 @@ impl SettingsView { ) } - /// The fullscreen vinyl stage: the cover spins as the record's label under - /// a slow sheen, particles drift off the rim, and a ring of spectrum bars - /// surrounds it all. - fn starry_row(&self, cx: &mut Context) -> Setting { + /// How fullscreen stages the cover: bare, haloed in drifting particles, + /// riding a turning record as its label, or slid out of its sleeve. One + /// choice covers what used to be the starry and the vinyl switches. + fn stage_row(&self, cx: &mut Context) -> Setting { let theme = *cx.theme(); let muted = theme.muted_foreground; let small = theme.text(Text::Small); - let on = self.settings.read(cx).starry(); + let chosen = self.settings.read(cx).stage_style(); - self.row( - t!("settings-starry"), - t!("settings-starry-detail"), - muted, - small, - Switch::new("starry", on) - .on_click(cx.listener(move |this, _, _, cx| { - this.settings - .update(cx, |settings, cx| settings.set_starry(!on, cx)); - })) - .into_any_element(), - ) - } - - /// The vinyl itself. The cover cannot turn, so the record only makes sense - /// while it does — the sheen and the groove markers are what carry the turn. - fn vinyl_row(&self, cx: &mut Context) -> Setting { - let theme = *cx.theme(); - let muted = theme.muted_foreground; - let small = theme.text(Text::Small); - let on = self.settings.read(cx).vinyl(); + let picker = Picker::new(STAGE_STYLE, &self.popovers, chosen.label()) + .width(Picker::NARROW) + .items(StageStyle::ALL.map(|style| { + MenuItem::new(style.id(), style.label()) + .selected(style == chosen) + .on_click(cx.listener(move |this, _, _, cx| { + this.settings + .update(cx, |settings, cx| settings.set_stage_style(style, cx)); + cx.notify(); + })) + })); self.row( - t!("settings-vinyl"), - t!("settings-vinyl-detail"), + t!("settings-stage"), + t!("settings-stage-detail"), muted, small, - Switch::new("vinyl", on) - .on_click(cx.listener(move |this, _, _, cx| { - this.settings - .update(cx, |settings, cx| settings.set_vinyl(!on, cx)); - })) - .into_any_element(), + picker.into_any_element(), ) } diff --git a/crates/views/src/shared/starry.rs b/crates/views/src/shared/starry.rs index 50fc44dc3..c9f788c28 100644 --- a/crates/views/src/shared/starry.rs +++ b/crates/views/src/shared/starry.rs @@ -55,6 +55,22 @@ const MARKERS: [f32; 3] = [0.76, 0.84, 0.92]; const MARKER_SPAN: f32 = 0.26; /// How many straight segments stand in for a smooth circle. const SEGMENTS: usize = 72; +/// The sleeve: the cover's edge and the record's diameter as shares of the +/// stage's side, how far the record's centre sits past the cover's right edge, +/// where the cover starts, and the label's diameter as a share of the +/// record's. Together they leave the record peeking out to the right with +/// about half of it showing, the way a record slid out of its sleeve sits in +/// the hand. +const SLEEVE: f32 = 0.64; +const SLEEVE_DISC: f32 = 0.52; +const SLEEVE_CX: f32 = 0.68; +const SLEEVE_X: f32 = 0.025; +const SLEEVE_LABEL: f32 = 0.52; +/// The sleeve's grooves, as shares of the record's radius: they run outside +/// the label and stop short of the rim. +const SLEEVE_GROOVES: usize = 6; +const SLEEVE_GROOVE_IN: f32 = 0.58; +const SLEEVE_GROOVE_OUT: f32 = 0.94; /// Where the stage's clock started, so the angle carries over between visits /// to fullscreen instead of resetting to the same pose every time. @@ -178,17 +194,18 @@ impl Clock { } } -/// What the stage is asked to show: the spectrum the ring reads and the shape -/// it reads them in, how much drifts off the rim, whether a record is staged, -/// how far it has turned and for how long, and the theme the stage dresses in. -/// Two clocks on purpose: the record and everything riding it turn only while -/// sound plays, while the particles keep to the wall clock whether or not it -/// does. +/// What the stage is asked to show: which way the cover is staged, the +/// spectrum the ring reads and the shape it reads them in, how much drifts off +/// the rim, how far the record has turned and for how long, and the theme the +/// stage dresses in. Two clocks on purpose: the record and everything riding +/// it turn only while sound plays, while the particles keep to the wall clock +/// whether or not it does. pub(crate) struct Stage { pub(crate) levels: Levels, pub(crate) style: VisualizerStyle, pub(crate) particles: usize, - pub(crate) vinyl: bool, + /// The way the cover is staged: bare, starry, on a record, in its sleeve. + pub(crate) layout: ui::StageStyle, /// Seconds on the wall clock, driving the particles' drift. pub(crate) elapsed: f32, /// Seconds the music has been playing, driving the record's turn. @@ -212,10 +229,10 @@ struct Motion { elapsed: f32, } -/// The whole stage, sized to `side`: the halo always, the ring in whatever -/// shape the visualizer is set to, and the record with everything that sweeps -/// across it only when a record is asked for — the cover cannot turn, so -/// without the turning record around it there is no vinyl to stage. +/// The whole stage, sized to `side`. The sleeve stages the cover on bare +/// paint with the record behind it; the rest halo the cover and read the +/// spectrum, staging the record with everything that sweeps across it only +/// when a record is asked for. pub(crate) fn stage( side: Pixels, label: Option>, @@ -226,7 +243,7 @@ pub(crate) fn stage( levels, style, particles, - vinyl, + layout, elapsed, turn, presence, @@ -234,6 +251,16 @@ pub(crate) fn stage( } = stage; let paint = palette(&theme); let side = side.as_f32(); + // Resolved once so both the sleeve's cover and its record label can carry + // the same art. + let label: Option = label.map(Into::into); + + // The sleeve is a different composition: the cover square with the record + // peeking out behind it, no field around either. + if layout == ui::StageStyle::Sleeve { + return sleeve(side, label, waiting, turn, &paint, theme.radius * 2.); + } + let vinyl = layout.turned(); div() .relative() @@ -280,6 +307,64 @@ pub(crate) fn stage( ) } +/// The sleeve layout: the cover slid most of the way out of its sleeve — that +/// is, off the record behind it — so the record peeks out to the right with +/// the cover's own art turning on it as its label. The cover itself stays +/// square and still; the turn belongs to the record and its label. +fn sleeve( + side: f32, + label: Option, + waiting: bool, + turn: f32, + paint: &Palette, + radius: Pixels, +) -> Div { + let cover = side * SLEEVE; + let disc = side * SLEEVE_DISC; + let center_x = side * SLEEVE_CX; + let center_y = side / 2.; + let label_side = disc * SLEEVE_LABEL; + + div() + .relative() + .size(px(side)) + .child( + canvas(move |_, _, _| {}, { + let paint = *paint; + move |bounds, _, window, _| platter(bounds, turn, &paint, window) + }) + .absolute() + .inset_0(), + ) + .child( + div() + .absolute() + .left(px(center_x - label_side / 2.)) + .top(px(center_y - label_side / 2.)) + // The cover's own art rides the record as its label, turning + // with it the way a record label does. + .child( + Artwork::new(label.clone()) + .size(px(label_side)) + .spin(turn / SPIN) + .circle() + .soft(waiting), + ), + ) + .child( + div() + .absolute() + .left(px(side * SLEEVE_X)) + .top(px((side - cover) / 2.)) + .child( + Artwork::new(label) + .size(px(cover)) + .corner_radius(radius) + .soft(waiting), + ), + ) +} + /// Every colour the stage paints with, resolved once from the theme. #[derive(Clone, Copy)] struct Palette { @@ -293,6 +378,11 @@ struct Palette { sheen: Hsla, marker: Hsla, label_edge: Hsla, + /// The sleeve's record: a pastel pressing in the theme's own hue. + record: Hsla, + record_groove: Hsla, + record_edge: Hsla, + hole: Hsla, } /// The record dresses in the theme's own hue at a whisper: a dark disc with a @@ -329,6 +419,16 @@ fn palette(theme: &Theme) -> Palette { }, ..hue }; + // The sleeve's record: a pastel pressing in the theme's own hue, lighter + // than the vinyl the starry stage paints so it reads against bare paint. + let record = Hsla { + s: (hue.s * 0.9).clamp(0.12, 0.5), + l: match dark { + true => 0.72, + false => 0.58, + }, + ..hue + }; Palette { halo: Hsla { @@ -379,6 +479,24 @@ fn palette(theme: &Theme) -> Palette { }, 0.35, ), + record, + record_groove: Hsla { + l: record.l + 0.06, + a: 0.7, + ..record + }, + record_edge: Hsla { + l: record.l - 0.10, + a: 0.6, + ..record + }, + hole: neutral( + match dark { + true => 0.10, + false => 0.22, + }, + 1., + ), } } @@ -619,6 +737,71 @@ fn record(center: Point, radius: f32, paint: &Palette, window: &mut Wind } } +/// The sleeve's record, as a pastel pressing: face, grooves, a sheen riding +/// the turn, and the spindle hole at its centre. The label — the cover's own +/// art, turning — is an element laid over this, not painted here. +fn platter(bounds: Bounds, turn: f32, paint: &Palette, window: &mut Window) { + let side = bounds.size.width.min(bounds.size.height).as_f32(); + // Painted paths read in window coordinates, so the record's centre is the + // canvas origin plus its share of the stage's side. + let center = bounds.origin + point(px(side * SLEEVE_CX), px(side / 2.)); + let radius = side * SLEEVE_DISC / 2.; + let angle = TAU * turn / SPIN; + + let mut face = PathBuilder::fill(); + circle(&mut face, center, radius); + match face.build() { + Ok(path) => window.paint_path(path, paint.record), + Err(error) => log::warn!("starry: cannot build the platter: {error}"), + } + + let mut grooves = PathBuilder::stroke(px(1.)); + for groove in 0..SLEEVE_GROOVES { + let t = SLEEVE_GROOVE_IN + + (SLEEVE_GROOVE_OUT - SLEEVE_GROOVE_IN) * groove as f32 / (SLEEVE_GROOVES - 1) as f32; + circle(&mut grooves, center, radius * t); + } + match grooves.build() { + Ok(path) => window.paint_path(path, paint.record_groove), + Err(error) => log::warn!("starry: cannot build the platter's grooves: {error}"), + } + + let mut rim = PathBuilder::stroke(px(1.5)); + circle(&mut rim, center, radius - 0.75); + match rim.build() { + Ok(path) => window.paint_path(path, paint.record_edge), + Err(error) => log::warn!("starry: cannot build the platter's rim: {error}"), + } + + // A short sheen riding the rim with the turn, the only cue on an even + // pastel face that the record is moving at all. + let mut sheen = PathBuilder::stroke(px(2.)); + arc( + &mut sheen, + center, + radius * 0.97, + angle + 0.4, + angle + 0.4 + MARKER_SPAN, + ); + match sheen.build() { + Ok(path) => window.paint_path(path, paint.record_groove), + Err(error) => log::warn!("starry: cannot build the platter's sheen: {error}"), + } + + let mut pin = PathBuilder::fill(); + circle(&mut pin, center, (side * 0.008).max(2.5)); + match pin.build() { + Ok(path) => window.paint_path(path, paint.hole), + Err(error) => log::warn!("starry: cannot build the spindle hole: {error}"), + } + let mut pin_rim = PathBuilder::stroke(px(1.)); + circle(&mut pin_rim, center, (side * 0.008).max(2.5) + 1.2); + match pin_rim.build() { + Ok(path) => window.paint_path(path, paint.record_edge), + Err(error) => log::warn!("starry: cannot build the spindle hole's rim: {error}"), + } +} + /// Everything that sweeps over the label: the sheen and its groove markers /// turning only while the music does, the particles as present as the music /// has left them, and a hairline at the cover's edge so the two read as diff --git a/crates/views/src/shells/fullscreen.rs b/crates/views/src/shells/fullscreen.rs index 168f06a53..fea467e99 100644 --- a/crates/views/src/shells/fullscreen.rs +++ b/crates/views/src/shells/fullscreen.rs @@ -364,9 +364,9 @@ impl FullscreenView { let waiting = !local && album.is_some() && cover_large.is_none(); let artwork_bounds = self.artwork_bounds.clone(); let settings = self.settings.read(cx); - let starry = settings.starry(); + let layout = settings.stage_style(); + let staged = layout.shown(); let style = settings.visualizer_style(); - let vinyl = settings.vinyl(); let particles = settings.particles(); let theme = *cx.theme(); let levels = self.visualizer.levels(); @@ -381,7 +381,7 @@ impl FullscreenView { self.playback.read(cx).state(), PlaybackState::Playing | PlaybackState::Loading ); - let pose = match starry && ui::motion::animates(cx) { + let pose = match staged && ui::motion::animates(cx) { true => self.stage_clock.tick(playing), false => starry::Pose { turn: 0., @@ -395,8 +395,8 @@ impl FullscreenView { levels, style, particles, - vinyl, - elapsed: match starry && ui::motion::animates(cx) { + layout, + elapsed: match staged && ui::motion::animates(cx) { true => starry::spin(), false => 0., }, @@ -404,7 +404,7 @@ impl FullscreenView { presence: pose.presence, theme, }; - let cover = if starry { + let cover = if staged { vec![ div() .absolute() @@ -1142,7 +1142,8 @@ impl Render for FullscreenView { let cover_scale = presentation_scale(presented_side, raster_side); let lift = (presented_side - side) / 2.; let staged = self.panel.is_none() || split; - let starry = staged && self.settings.read(cx).starry(); + let layout = self.settings.read(cx).stage_style(); + let starry = staged && layout.shown(); let style = self.settings.read(cx).visualizer_style(); let visualizer_on = self.panel.is_none() && style.shown();