1#![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
35pub const FADE_IN_SECS: f32 = 0.5;
37pub const HOLD_SECS: f32 = 4.0;
39pub const FADE_OUT_SECS: f32 = 1.0;
41pub const TOTAL_SECS: f32 = FADE_IN_SECS + HOLD_SECS + FADE_OUT_SECS;
43
44pub const SEPARATOR: &str = " - ";
48
49const INSET_X: f32 = 16.0;
52const INSET_BOTTOM: f32 = 24.0;
54const ARTIST_SIZE: f32 = 24.0;
56const TITLE_SIZE: f32 = 32.0;
58const LINE_HEIGHT_RATIO: f32 = 1.25;
61
62const AVG_ADVANCE_RATIO: f32 = 0.5;
69
70const MIN_CHARS: usize = 8;
72
73pub struct BannerLine<'a> {
77 pub text: Cow<'a, str>,
79 pub x: f32,
81 pub y: f32,
83 pub size: f32,
85 pub color: [f32; 4],
87}
88
89#[derive(Default)]
91pub struct NowPlaying {
92 text: String,
94 elapsed: f32,
98 reduced: bool,
101}
102
103impl NowPlaying {
104 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 self.elapsed = if text.is_empty() { TOTAL_SECS } else { 0.0 };
121 }
122
123 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 pub fn set_reduced_motion(&mut self, reduced: bool) {
137 self.reduced = reduced;
138 }
139
140 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 pub fn text(&self) -> &str {
154 &self.text
155 }
156
157 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 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 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
199pub 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
223pub 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
244pub 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
254fn 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
264fn 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
274fn 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
287fn 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 fn visible_duration(dt: f32) -> f32 {
302 let mut np = NowPlaying::default();
303 np.set("Artist - Title");
304 let mut steps = 0u32;
305 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 assert_eq!(np.alpha(), 0.0, "the banner must start transparent");
326
327 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 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 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 np.advance(10.0);
359 assert_eq!(np.alpha(), 0.0, "a finished banner must stay finished");
360 }
361
362 #[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 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 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 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 assert_eq!(
449 split("Godspeed - Storm - Levez Vos Skinny Fists"),
450 (Some("Godspeed"), "Storm - Levez Vos Skinny Fists")
451 );
452 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 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 assert!(artist.y > h * 0.5, "the banner belongs in the lower half");
516 }
517
518 #[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 #[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}