Skip to main content

rlx_core/render/
now_playing.rs

1//! The now-playing banner: a core-owned, transient announcement of the current
2//! track (ADR-0110, Plan 0097).
3//!
4//! A shell pushes in a UTF-8 string and nothing else — SMTC on the standalone,
5//! foobar's `titleformat` through the C ABI — and everything downstream of that
6//! string is decided here: the fade envelope, the artist/title split, the
7//! placement, and the truncation rule. That is the whole point of the ADR: two
8//! frontends whose metadata sources have nothing in common cannot drift on what
9//! a track change looks like, because neither of them draws it.
10//!
11//! The envelope is a **pure function of accumulated `dt`** (Plan 0014's injected
12//! real seconds), so it runs for the same wall-clock duration on a 60 Hz and a
13//! 165 Hz display and is testable without a GPU. Nothing here reads a clock.
14//!
15//! This module is **not** behind the `text` feature. A build without it keeps
16//! the state and simply never asks for a [`layout`](NowPlaying::layout), which
17//! is what lets the plugin build turn the feature on without touching any of
18//! this.
19
20// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set). The
21// layout runs every frame a banner is up; a panic here is a visible crash.
22#![deny(
23    clippy::unwrap_used,
24    clippy::expect_used,
25    clippy::indexing_slicing,
26    clippy::panic,
27    clippy::unreachable
28)]
29
30use std::borrow::Cow;
31
32use super::panel::Panel;
33use super::theme::THEME;
34
35/// Seconds the banner takes to reach full opacity.
36pub const FADE_IN_SECS: f32 = 0.5;
37/// Seconds the banner holds at full opacity.
38pub const HOLD_SECS: f32 = 4.0;
39/// Seconds the banner takes to fade back out.
40pub const FADE_OUT_SECS: f32 = 1.0;
41/// Total lifetime of one announcement, after which nothing is drawn.
42pub const TOTAL_SECS: f32 = FADE_IN_SECS + HOLD_SECS + FADE_OUT_SECS;
43
44/// The separator a shell puts between artist and title. Both sources agree on
45/// it: the plugin renders `%artist% - %title%` (ADR-0110) and the standalone
46/// joins its SMTC fields the same way, so this one rule splits both.
47pub const SEPARATOR: &str = " - ";
48
49/// Left inset in device pixels — the same 16 px the shell's corner furniture
50/// uses, so the banner lines up with the preset name's column.
51const INSET_X: f32 = 16.0;
52/// Gap between the title's descender box and the bottom of the surface.
53const INSET_BOTTOM: f32 = 24.0;
54/// Font size of the artist line (the quieter of the two).
55const ARTIST_SIZE: f32 = 24.0;
56/// Font size of the title line.
57const TITLE_SIZE: f32 = 32.0;
58/// Must match `text::LINE_HEIGHT_RATIO` — the vertical extent glyphon gives a
59/// run, which is what stacks the two lines without them overlapping.
60const LINE_HEIGHT_RATIO: f32 = 1.25;
61
62/// Mean glyph advance as a fraction of the font size, used only to pick a
63/// character budget for truncation. A sans-serif estimate, deliberately not a
64/// shaped measurement: the core must decide the budget in a build that has no
65/// `text` feature and therefore no font system at all. Erring wide would let a
66/// title run under the clip bound and vanish mid-word, so this rounds toward
67/// truncating slightly early.
68const AVG_ADVANCE_RATIO: f32 = 0.5;
69
70/// Never truncate below this many characters, however narrow the surface.
71const MIN_CHARS: usize = 8;
72
73/// One positioned line of the banner, ready to become a `TextRun`. The `Cow`
74/// borrows the stored string whenever the line fits and owns only the truncated
75/// copy, so a steady banner frame allocates nothing.
76pub struct BannerLine<'a> {
77    /// The line's text, already truncated to the surface width.
78    pub text: Cow<'a, str>,
79    /// Left edge, device pixels from the surface's top-left.
80    pub x: f32,
81    /// Top edge, device pixels from the surface's top-left.
82    pub y: f32,
83    /// Font size in device pixels.
84    pub size: f32,
85    /// Linear RGBA in `0.0..=1.0`, with the envelope already applied to alpha.
86    pub color: [f32; 4],
87}
88
89/// The banner's whole state: what to announce, and how long ago it was set.
90#[derive(Default)]
91pub struct NowPlaying {
92    /// The string a shell pushed in. Empty means there is nothing to draw.
93    text: String,
94    /// Seconds since [`set`](Self::set) accepted a new string, accumulated from
95    /// injected `dt` and clamped at [`TOTAL_SECS`] so it cannot grow unbounded
96    /// across a long session.
97    elapsed: f32,
98    /// Whether the envelope is a step rather than the theme's curve — the
99    /// shell's reduced-motion choice.
100    reduced: bool,
101}
102
103impl NowPlaying {
104    /// Announce `text`, restarting the envelope.
105    ///
106    /// Setting the string that is **already** set is a no-op, so a source that
107    /// re-reports the current track — SMTC fires `MediaPropertiesChanged` for
108    /// artwork and position updates too — cannot re-trigger the banner. An empty
109    /// or whitespace-only string clears it immediately.
110    pub fn set(&mut self, text: &str) {
111        let text = text.trim();
112        if text == self.text {
113            return;
114        }
115        self.text.clear();
116        self.text.push_str(text);
117        // A cleared banner is finished, not starting: jumping `elapsed` to the
118        // end means `alpha` is zero on the same frame rather than one frame of
119        // full opacity before the empty string is noticed.
120        self.elapsed = if text.is_empty() { TOTAL_SECS } else { 0.0 };
121    }
122
123    /// Advance the envelope by `dt` real seconds. `dt` is trusted: `render`
124    /// replaces a degenerate delta with one nominal step before calling this
125    /// (ADR-0191).
126    pub fn advance(&mut self, dt: f32) {
127        if self.elapsed >= TOTAL_SECS {
128            return;
129        }
130        self.elapsed = (self.elapsed + dt).min(TOTAL_SECS);
131    }
132
133    /// Make the envelope a step (`true`) or the theme's eased fade (`false`,
134    /// the default). A step shows the banner at full opacity for its whole
135    /// lifetime and not at all after it.
136    pub fn set_reduced_motion(&mut self, reduced: bool) {
137        self.reduced = reduced;
138    }
139
140    /// The current opacity in `0.0..=1.0`; zero when there is nothing to draw.
141    pub fn alpha(&self) -> f32 {
142        if self.text.is_empty() {
143            return 0.0;
144        }
145        if self.reduced {
146            step_at(self.elapsed)
147        } else {
148            alpha_at(self.elapsed)
149        }
150    }
151
152    /// The string currently announced (`""` when the banner is unset).
153    pub fn text(&self) -> &str {
154        &self.text
155    }
156
157    /// The two positioned lines to draw on a `width`×`height` surface, or
158    /// `[None, None]` while the banner is invisible.
159    ///
160    /// The string splits on the **first** [`SEPARATOR`] into artist and title; a
161    /// string with no separator draws as a single title line. Both are truncated
162    /// to a character budget derived from the surface width, so a long title
163    /// ends in `...` rather than running off the edge.
164    pub fn layout(&self, width: f32, height: f32) -> [Option<BannerLine<'_>>; 2] {
165        let alpha = self.alpha();
166        if alpha <= 0.0 {
167            return [None, None];
168        }
169
170        let (artist, title) = split(&self.text);
171
172        // Bottom-up: the title sits one line off the floor, the artist directly
173        // above it. Placing from the bottom keeps the banner clear of the
174        // top-left furniture (the preset name and the F3 panel both live there).
175        let title_y = height - INSET_BOTTOM - TITLE_SIZE * LINE_HEIGHT_RATIO;
176        let artist_y = title_y - ARTIST_SIZE * LINE_HEIGHT_RATIO;
177
178        let title_line = Some(BannerLine {
179            text: fit(title, budget(width, TITLE_SIZE)),
180            x: INSET_X,
181            y: title_y,
182            size: TITLE_SIZE,
183            // The title is primary text, matching the preset name's weight in
184            // the corner; the artist is an attribution and reads dimmer.
185            color: rgba(THEME.text.rgb(), alpha),
186        });
187        let artist_line = artist.map(|artist| BannerLine {
188            text: fit(artist, budget(width, ARTIST_SIZE)),
189            x: INSET_X,
190            y: artist_y,
191            size: ARTIST_SIZE,
192            color: rgba(THEME.text_dim.rgb(), alpha),
193        });
194
195        [artist_line, title_line]
196    }
197}
198
199/// The panel the banner's lines sit on, given each line's measured width, or
200/// `None` while the banner is invisible.
201///
202/// It fades with the text: the panel's alpha is the envelope the lines carry,
203/// so the backdrop never lingers after the words, or arrives before them.
204pub fn backdrop(lines: &[Option<BannerLine<'_>>; 2], widths: [f32; 2]) -> Option<Panel> {
205    let mut bounds: Option<(f32, f32, f32, f32, f32)> = None;
206    for (line, width) in lines.iter().zip(widths) {
207        let Some(line) = line else { continue };
208        let (x0, y0) = (line.x, line.y);
209        let (x1, y1) = (line.x + width, line.y + line.size * LINE_HEIGHT_RATIO);
210        let [_, _, _, alpha] = line.color;
211        bounds = Some(match bounds {
212            None => (x0, y0, x1, y1, alpha),
213            Some((a, b, c, d, e)) => (a.min(x0), b.min(y0), c.max(x1), d.max(y1), e.max(alpha)),
214        });
215    }
216    let (x0, y0, x1, y1, alpha) = bounds?;
217    Some(Panel {
218        alpha,
219        ..Panel::around(x0, y0, x1, y1, THEME.space[1])
220    })
221}
222
223/// The envelope: ease in, hold, ease out, then nothing — both ramps on the
224/// theme's one curve, the fade-out its mirror so it leaves as it arrived. A pure
225/// function of elapsed seconds, which is what makes it identical at any refresh
226/// rate and testable without a device.
227pub fn alpha_at(elapsed: f32) -> f32 {
228    if !elapsed.is_finite() || elapsed <= 0.0 {
229        return 0.0;
230    }
231    if elapsed < FADE_IN_SECS {
232        THEME.ease.at(elapsed / FADE_IN_SECS)
233    } else if elapsed < FADE_IN_SECS + HOLD_SECS {
234        1.0
235    } else if elapsed < TOTAL_SECS {
236        1.0 - THEME
237            .ease
238            .at((elapsed - FADE_IN_SECS - HOLD_SECS) / FADE_OUT_SECS)
239    } else {
240        0.0
241    }
242}
243
244/// The reduced-motion envelope: full opacity for the banner's whole lifetime,
245/// nothing before or after it.
246pub fn step_at(elapsed: f32) -> f32 {
247    if elapsed.is_finite() && elapsed > 0.0 && elapsed < TOTAL_SECS {
248        1.0
249    } else {
250        0.0
251    }
252}
253
254/// Split a pushed string into `(artist, title)` on the first [`SEPARATOR`].
255/// Without one — or with an empty half — the whole string is the title, because
256/// a lone name reads better large than it does as an attribution to nothing.
257fn split(text: &str) -> (Option<&str>, &str) {
258    match text.split_once(SEPARATOR) {
259        Some((artist, title)) if !artist.is_empty() && !title.is_empty() => (Some(artist), title),
260        _ => (None, text),
261    }
262}
263
264/// How many characters of a `size`-pixel line fit across a `width`-pixel
265/// surface, inset on both sides.
266fn budget(width: f32, size: f32) -> usize {
267    let usable = width - 2.0 * INSET_X;
268    if !usable.is_finite() || usable <= 0.0 {
269        return MIN_CHARS;
270    }
271    ((usable / (size * AVG_ADVANCE_RATIO)) as usize).max(MIN_CHARS)
272}
273
274/// Truncate `s` to `max_chars`, ending in `...` when it had to cut. Character-
275/// counted rather than byte-counted, so a CJK or accented title cannot be sliced
276/// mid-codepoint. Follows the browse overlay's ASCII ellipsis rather than `…`.
277fn fit(s: &str, max_chars: usize) -> Cow<'_, str> {
278    if s.chars().count() <= max_chars {
279        return Cow::Borrowed(s);
280    }
281    let keep = max_chars.saturating_sub(3);
282    let mut out: String = s.chars().take(keep).collect();
283    out.push_str("...");
284    Cow::Owned(out)
285}
286
287/// Apply the envelope to a base colour.
288fn rgba([r, g, b]: [f32; 3], alpha: f32) -> [f32; 4] {
289    [r, g, b, alpha]
290}
291
292#[cfg(test)]
293mod tests {
294    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
295
296    use super::*;
297
298    /// Step the envelope at `dt` until it returns to zero, and report how many
299    /// real seconds that took. The property Plan 0014 bought, measured: this
300    /// number must not depend on `dt`.
301    fn visible_duration(dt: f32) -> f32 {
302        let mut np = NowPlaying::default();
303        np.set("Artist - Title");
304        let mut steps = 0u32;
305        // Stepped before the test, not after: alpha is legitimately zero at
306        // `elapsed = 0` (the banner starts transparent), so a leading check
307        // would exit before the first frame. Bounded well past the envelope so a
308        // regression fails the assertion rather than hanging the suite.
309        loop {
310            np.advance(dt);
311            steps += 1;
312            if np.alpha() <= 0.0 || steps >= 100_000 {
313                break;
314            }
315        }
316        steps as f32 * dt
317    }
318
319    #[test]
320    fn the_envelope_rises_plateaus_and_returns_to_zero() {
321        let mut np = NowPlaying::default();
322        np.set("Artist - Title");
323
324        // Starts dark, before any dt has been injected.
325        assert_eq!(np.alpha(), 0.0, "the banner must start transparent");
326
327        // Rises monotonically through the fade-in.
328        let mut prev = np.alpha();
329        for _ in 0..30 {
330            np.advance(FADE_IN_SECS / 30.0);
331            let a = np.alpha();
332            assert!(a >= prev, "alpha must not fall during the fade-in");
333            prev = a;
334        }
335        assert!(
336            (np.alpha() - 1.0).abs() < 1e-3,
337            "alpha must reach full at the end of the fade-in, got {}",
338            np.alpha()
339        );
340
341        // Plateaus for the whole hold.
342        for _ in 0..40 {
343            np.advance(HOLD_SECS / 40.0);
344            assert_eq!(np.alpha(), 1.0, "alpha must stay full through the hold");
345        }
346
347        // Falls monotonically through the fade-out.
348        let mut prev = np.alpha();
349        for _ in 0..20 {
350            np.advance(FADE_OUT_SECS / 20.0);
351            let a = np.alpha();
352            assert!(a <= prev, "alpha must not rise during the fade-out");
353            prev = a;
354        }
355        assert_eq!(np.alpha(), 0.0, "alpha must return to zero");
356
357        // And stays there — a finished banner does not come back.
358        np.advance(10.0);
359        assert_eq!(np.alpha(), 0.0, "a finished banner must stay finished");
360    }
361
362    /// The frame-rate independence Plan 0014 bought, stated as a property: the
363    /// banner is on screen for the same number of *seconds* at 60 Hz and at
364    /// 165 Hz, not for the same number of frames.
365    #[test]
366    fn the_envelope_lasts_the_same_time_at_60_and_165_hz() {
367        let at_60 = visible_duration(1.0 / 60.0);
368        let at_165 = visible_duration(1.0 / 165.0);
369
370        // Each is within one of its own steps of the nominal lifetime, and the
371        // two agree with each other within the coarser step.
372        assert!(
373            (at_60 - TOTAL_SECS).abs() <= 1.0 / 60.0,
374            "60 Hz ran for {at_60} s, expected {TOTAL_SECS} s"
375        );
376        assert!(
377            (at_165 - TOTAL_SECS).abs() <= 1.0 / 165.0,
378            "165 Hz ran for {at_165} s, expected {TOTAL_SECS} s"
379        );
380        assert!(
381            (at_60 - at_165).abs() <= 1.0 / 60.0,
382            "the two refresh rates disagreed: {at_60} s vs {at_165} s"
383        );
384    }
385
386    #[test]
387    fn setting_the_same_string_does_not_restart_the_envelope() {
388        let mut np = NowPlaying::default();
389        np.set("Artist - Title");
390        np.advance(FADE_IN_SECS + HOLD_SECS + FADE_OUT_SECS / 2.0);
391        let mid_fade = np.alpha();
392        assert!(
393            mid_fade > 0.0 && mid_fade < 1.0,
394            "expected a mid-fade alpha"
395        );
396
397        // The same track re-reported: SMTC fires for artwork and position too.
398        np.set("Artist - Title");
399        assert_eq!(
400            np.alpha(),
401            mid_fade,
402            "re-reporting the current track must not re-trigger the banner"
403        );
404
405        // Whitespace differences are not a new track either.
406        np.set("  Artist - Title  ");
407        assert_eq!(
408            np.alpha(),
409            mid_fade,
410            "trimming must happen before the compare"
411        );
412    }
413
414    #[test]
415    fn setting_a_new_string_restarts_the_envelope() {
416        let mut np = NowPlaying::default();
417        np.set("Artist - First");
418        np.advance(FADE_IN_SECS + HOLD_SECS);
419        assert_eq!(np.alpha(), 1.0);
420
421        np.set("Artist - Second");
422        assert_eq!(np.alpha(), 0.0, "a new track restarts from transparent");
423        np.advance(FADE_IN_SECS);
424        assert!((np.alpha() - 1.0).abs() < 1e-3);
425        assert_eq!(np.text(), "Artist - Second");
426    }
427
428    #[test]
429    fn an_empty_string_clears_the_banner_immediately() {
430        let mut np = NowPlaying::default();
431        np.set("Artist - Title");
432        np.advance(FADE_IN_SECS);
433        assert_eq!(np.alpha(), 1.0);
434
435        np.set("");
436        assert_eq!(np.alpha(), 0.0, "clearing must not leave one lit frame");
437        let [artist, title] = np.layout(1920.0, 1080.0);
438        assert!(artist.is_none() && title.is_none());
439    }
440
441    #[test]
442    fn the_first_separator_splits_artist_from_title() {
443        assert_eq!(
444            split("Boards of Canada - Roygbiv"),
445            (Some("Boards of Canada"), "Roygbiv")
446        );
447        // Only the first one splits — a title may contain the separator itself.
448        assert_eq!(
449            split("Godspeed - Storm - Levez Vos Skinny Fists"),
450            (Some("Godspeed"), "Storm - Levez Vos Skinny Fists")
451        );
452        // No separator, or an empty half, is a lone title.
453        assert_eq!(split("Untitled"), (None, "Untitled"));
454        assert_eq!(split(" - Roygbiv"), (None, " - Roygbiv"));
455    }
456
457    #[test]
458    fn a_long_title_truncates_rather_than_running_off_the_surface() {
459        let long = "A".repeat(400);
460        let np = {
461            let mut np = NowPlaying::default();
462            np.set(&format!("Artist - {long}"));
463            np.advance(FADE_IN_SECS);
464            np
465        };
466
467        let width = 1280.0;
468        let [_, title] = np.layout(width, 800.0);
469        let title = title.expect("a visible banner must produce a title line");
470        let chars = title.text.chars().count();
471
472        assert!(chars < 400, "the title must be cut, kept {chars} chars");
473        assert!(
474            title.text.ends_with("..."),
475            "a cut title must say so: {}",
476            title.text
477        );
478        // The kept run fits inside the insets at its own font size.
479        let drawn = chars as f32 * title.size * AVG_ADVANCE_RATIO;
480        assert!(
481            drawn <= width - 2.0 * INSET_X,
482            "{drawn} px of text does not fit {width} px"
483        );
484    }
485
486    #[test]
487    fn a_short_line_is_borrowed_rather_than_copied() {
488        let mut np = NowPlaying::default();
489        np.set("Air - La Femme d'Argent");
490        np.advance(FADE_IN_SECS);
491        let [artist, title] = np.layout(1920.0, 1080.0);
492        assert!(matches!(artist.unwrap().text, Cow::Borrowed(_)));
493        assert!(matches!(title.unwrap().text, Cow::Borrowed(_)));
494    }
495
496    #[test]
497    fn the_banner_sits_in_the_lower_left_and_stacks_upward() {
498        let mut np = NowPlaying::default();
499        np.set("Artist - Title");
500        np.advance(FADE_IN_SECS);
501
502        let (w, h) = (1920.0, 1080.0);
503        let [artist, title] = np.layout(w, h);
504        let (artist, title) = (artist.unwrap(), title.unwrap());
505
506        assert_eq!(artist.x, INSET_X);
507        assert_eq!(title.x, INSET_X);
508        assert!(artist.y < title.y, "the artist line sits above the title");
509        assert!(
510            title.y + title.size * LINE_HEIGHT_RATIO <= h,
511            "the title must not hang off the bottom"
512        );
513        // Clear of the top-left furniture the shell owns (the preset name at
514        // y = 16 and the F3 panel below it).
515        assert!(artist.y > h * 0.5, "the banner belongs in the lower half");
516    }
517
518    /// The fade takes the theme's ease-out: past halfway by a quarter of the
519    /// fade-in, where the linear ramp it replaces was at a quarter.
520    #[test]
521    fn the_fade_in_takes_the_theme_easing() {
522        let a = alpha_at(FADE_IN_SECS * 0.25);
523        assert!((a - THEME.ease.at(0.25)).abs() < 1e-6);
524        assert!(a > 0.5, "an ease-out is past halfway early, got {a}");
525    }
526
527    /// Reduced motion makes the envelope a step: full for the whole lifetime,
528    /// nothing after it.
529    #[test]
530    fn reduced_motion_makes_the_envelope_a_step() {
531        let mut np = NowPlaying::default();
532        np.set_reduced_motion(true);
533        np.set("Artist - Title");
534        np.advance(0.001);
535        assert_eq!(np.alpha(), 1.0, "a step is full from the first frame");
536        np.advance(TOTAL_SECS - 0.01);
537        assert_eq!(np.alpha(), 1.0, "and full to the last");
538        np.advance(1.0);
539        assert_eq!(np.alpha(), 0.0, "and gone after it");
540    }
541
542    #[test]
543    fn the_backdrop_holds_both_lines_and_fades_with_them() {
544        let mut np = NowPlaying::default();
545        np.set("Artist - Title");
546        assert!(backdrop(&np.layout(1920.0, 1080.0), [0.0; 2]).is_none());
547
548        np.advance(FADE_IN_SECS / 2.0);
549        let lines = np.layout(1920.0, 1080.0);
550        let panel = backdrop(&lines, [120.0, 200.0]).unwrap();
551        let (artist, title) = (lines[0].as_ref().unwrap(), lines[1].as_ref().unwrap());
552        assert!(panel.x < artist.x && panel.y < artist.y);
553        assert!(panel.x + panel.w >= title.x + 200.0);
554        assert!(panel.y + panel.h >= title.y + title.size * LINE_HEIGHT_RATIO);
555        assert!(
556            (panel.alpha - np.alpha()).abs() < 1e-6,
557            "the backdrop's alpha is the envelope's"
558        );
559    }
560
561    #[test]
562    fn a_lone_name_draws_as_one_title_line() {
563        let mut np = NowPlaying::default();
564        np.set("Untitled Broadcast");
565        np.advance(FADE_IN_SECS);
566        let [artist, title] = np.layout(1920.0, 1080.0);
567        assert!(artist.is_none(), "no separator means no artist line");
568        assert_eq!(title.unwrap().text, "Untitled Broadcast");
569    }
570}