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
32pub const FADE_IN_SECS: f32 = 0.5;
34pub const HOLD_SECS: f32 = 4.0;
36pub const FADE_OUT_SECS: f32 = 1.0;
38pub const TOTAL_SECS: f32 = FADE_IN_SECS + HOLD_SECS + FADE_OUT_SECS;
40
41pub const SEPARATOR: &str = " - ";
45
46const INSET_X: f32 = 16.0;
49const INSET_BOTTOM: f32 = 24.0;
51const ARTIST_SIZE: f32 = 24.0;
53const TITLE_SIZE: f32 = 32.0;
55const LINE_HEIGHT_RATIO: f32 = 1.25;
58const ARTIST_COLOR: [f32; 3] = [0.72, 0.80, 0.92];
60const TITLE_COLOR: [f32; 3] = [0.95, 0.97, 1.0];
62
63const AVG_ADVANCE_RATIO: f32 = 0.5;
70
71const MIN_CHARS: usize = 8;
73
74pub struct BannerLine<'a> {
78 pub text: Cow<'a, str>,
80 pub x: f32,
82 pub y: f32,
84 pub size: f32,
86 pub color: [f32; 4],
88}
89
90#[derive(Default)]
92pub struct NowPlaying {
93 text: String,
95 elapsed: f32,
99}
100
101impl NowPlaying {
102 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 self.elapsed = if text.is_empty() { TOTAL_SECS } else { 0.0 };
119 }
120
121 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 pub fn alpha(&self) -> f32 {
132 if self.text.is_empty() {
133 return 0.0;
134 }
135 alpha_at(self.elapsed)
136 }
137
138 pub fn text(&self) -> &str {
140 &self.text
141 }
142
143 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 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
183pub 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
201fn 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
211fn 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
221fn 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
234fn 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 fn visible_duration(dt: f32) -> f32 {
249 let mut np = NowPlaying::default();
250 np.set("Artist - Title");
251 let mut steps = 0u32;
252 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 assert_eq!(np.alpha(), 0.0, "the banner must start transparent");
273
274 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 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 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 np.advance(10.0);
306 assert_eq!(np.alpha(), 0.0, "a finished banner must stay finished");
307 }
308
309 #[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 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 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 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 assert_eq!(
396 split("Godspeed - Storm - Levez Vos Skinny Fists"),
397 (Some("Godspeed"), "Storm - Levez Vos Skinny Fists")
398 );
399 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 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 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}