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