1use std::io::{Stdout, Write, stdout};
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use anyhow::Context;
6use clap::{Parser, ValueEnum};
7use crossterm::{
8 cursor, execute, queue,
9 style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
10 terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
11};
12use rubecula::{
13 ActiveLyrics, FetchRequest, LookupKey, LrclibClient, LyricsOutcome, LyricsStore, LyricsView,
14 NowPlayingSource, PlayState, PlaybackClock, PlaybackSnapshot, PlaybackView, RenderChangeKey,
15 RenderModel, default_lyrics_data_dir, legacy_lyrics_data_paths,
16};
17use tokio::sync::mpsc;
18use tokio::task::JoinHandle;
19use unicode_width::UnicodeWidthStr;
20
21#[derive(Debug, Parser)]
22#[command(
23 version,
24 about = "Show the live lyric UI for the current song.",
25 long_about = "Show the live lyric UI for the current song.\n\nRun `rubin` or `rubecula` with no options for the default terminal UI. Flags are available for alternate sources, local LRC files, and one-shot rendering."
26)]
27struct Args {
28 #[arg(long)]
30 lrc: Option<PathBuf>,
31
32 #[arg(long, value_enum, default_value_t = SourceChoice::Music)]
34 source: SourceChoice,
35
36 #[arg(long, value_enum, default_value_t = Mode::Live)]
38 mode: Mode,
39
40 #[arg(long, default_value = "mediaremote-adapter")]
42 mediaremote_command: PathBuf,
43
44 #[arg(long, default_value_t = 500)]
46 poll_ms: u64,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
50enum Mode {
51 Live,
53 Once,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
58enum SourceChoice {
59 Music,
60 Mediaremote,
61 Auto,
62}
63
64#[derive(Debug)]
65struct FetchResult {
66 request: FetchRequest,
67 outcome: LyricsOutcome,
68}
69
70pub async fn run_from_args() -> anyhow::Result<()> {
71 let args = Args::parse();
72 run(args).await
73}
74
75async fn run(args: Args) -> anyhow::Result<()> {
76 let source = build_source(&args);
77 let local_lrc = load_local_lrc(args.lrc.as_ref())?;
78 let client = LrclibClient::new()?;
79 let mut lyrics_data = LyricsStore::new(default_lyrics_data_dir(), legacy_lyrics_data_paths());
80
81 match args.mode {
82 Mode::Live => run_live(source, local_lrc, client, lyrics_data, args.poll_ms).await,
83 Mode::Once => run_once(source, local_lrc, client, &mut lyrics_data).await,
84 }
85}
86
87fn build_source(args: &Args) -> NowPlayingSource {
88 match args.source {
89 SourceChoice::Music => NowPlayingSource::music(),
90 SourceChoice::Mediaremote => {
91 NowPlayingSource::mediaremote_command(args.mediaremote_command.clone())
92 }
93 SourceChoice::Auto => NowPlayingSource::auto(args.mediaremote_command.clone()),
94 }
95}
96
97fn load_local_lrc(path: Option<&PathBuf>) -> anyhow::Result<Option<rubecula::lrc::LrcDocument>> {
98 match path {
99 Some(path) => {
100 let contents = std::fs::read_to_string(path)
101 .with_context(|| format!("failed to read {}", path.display()))?;
102 Ok(Some(
103 rubecula::lrc::parse_lrc(&contents).context("failed to parse --lrc file")?,
104 ))
105 }
106 None => Ok(None),
107 }
108}
109
110async fn run_once(
111 source: NowPlayingSource,
112 local_lrc: Option<rubecula::lrc::LrcDocument>,
113 client: LrclibClient,
114 lyrics_data: &mut LyricsStore,
115) -> anyhow::Result<()> {
116 let snapshot = match source.snapshot().await {
117 Ok(snapshot) => snapshot,
118 Err(error) => PlaybackSnapshot::source_error(error.to_string()),
119 };
120 let active_lyrics =
121 lyrics_for_snapshot(&snapshot, local_lrc.as_ref(), &client, lyrics_data).await;
122 let model = RenderModel::from_state(Some(&snapshot), &active_lyrics);
123
124 let mut renderer = TerminalRenderer::new(RenderMode::Once)?;
125 renderer.render(&model)?;
126 renderer.finish()
127}
128
129async fn lyrics_for_snapshot(
130 snapshot: &PlaybackSnapshot,
131 local_lrc: Option<&rubecula::lrc::LrcDocument>,
132 client: &LrclibClient,
133 lyrics_data: &mut LyricsStore,
134) -> ActiveLyrics {
135 let Some(track) = snapshot.track.as_ref() else {
136 return ActiveLyrics::Empty;
137 };
138 let Some(key) = LookupKey::from_track(track) else {
139 return ActiveLyrics::Empty;
140 };
141 if let Some(document) = local_lrc {
142 return ActiveLyrics::Ready(LyricsOutcome::Synced(document.clone()));
143 }
144
145 if let Some(outcome) = lyrics_data.get(track, &key) {
146 return ActiveLyrics::Ready(outcome.clone());
147 }
148
149 let request = FetchRequest::new(key, track.source_track_id.clone());
150 let outcome = client.fetch(request.key()).await;
151 lyrics_data.insert(&request, &outcome);
152 ActiveLyrics::Ready(outcome)
153}
154
155async fn run_live(
156 source: NowPlayingSource,
157 local_lrc: Option<rubecula::lrc::LrcDocument>,
158 client: LrclibClient,
159 mut lyrics_data: LyricsStore,
160 poll_ms: u64,
161) -> anyhow::Result<()> {
162 let mut renderer = TerminalRenderer::new(RenderMode::Live)?;
163 let mut source_interval = tokio::time::interval(Duration::from_millis(poll_ms.max(100)));
164 source_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
165 let mut render_interval = tokio::time::interval(Duration::from_millis(250));
166 render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
167
168 let (fetch_tx, mut fetch_rx) = mpsc::channel::<FetchResult>(4);
169 let mut current_request: Option<FetchRequest> = None;
170 let mut fetching_request: Option<FetchRequest> = None;
171 let mut fetch_handle: Option<JoinHandle<()>> = None;
172 let mut active_lyrics = ActiveLyrics::Empty;
173 let mut playback_clock = PlaybackClock::default();
174
175 let exit_result = loop {
176 tokio::select! {
177 _ = tokio::signal::ctrl_c() => {
178 abort_fetch(&mut fetch_handle);
179 break Ok(());
180 },
181 Some(result) = fetch_rx.recv() => {
182 if fetching_request.as_ref() == Some(&result.request) {
183 fetching_request = None;
184 fetch_handle = None;
185 }
186
187 lyrics_data.insert(&result.request, &result.outcome);
188
189 if fetch_matches_current(current_request.as_ref(), &result.request) {
190 active_lyrics = ActiveLyrics::Ready(result.outcome);
191 if let Err(error) = render_clock_snapshot(&mut renderer, &playback_clock, &active_lyrics) {
192 break Err(error);
193 }
194 }
195 }
196 _ = render_interval.tick() => {
197 if let Err(error) = render_clock_snapshot(&mut renderer, &playback_clock, &active_lyrics) {
198 break Err(error);
199 }
200 }
201 _ = source_interval.tick() => {
202 let snapshot = tokio::select! {
203 _ = tokio::signal::ctrl_c() => {
204 abort_fetch(&mut fetch_handle);
205 break Ok(());
206 }
207 result = source.snapshot() => match result {
208 Ok(snapshot) => snapshot,
209 Err(error) => PlaybackSnapshot::source_error(error.to_string()),
210 }
211 };
212 let measured_at = Instant::now();
213
214 let next_request = snapshot.track.as_ref().and_then(|track| {
215 LookupKey::from_track(track)
216 .map(|key| FetchRequest::new(key, track.source_track_id.clone()))
217 });
218
219 if next_request != current_request {
220 current_request = next_request.clone();
221 fetching_request = None;
222 abort_fetch(&mut fetch_handle);
223 active_lyrics = ActiveLyrics::Empty;
224
225 if let Some(request) = next_request {
226 if let Some(document) = &local_lrc {
227 active_lyrics = ActiveLyrics::Ready(LyricsOutcome::Synced(document.clone()));
228 } else if let Some(outcome) = snapshot
229 .track
230 .as_ref()
231 .and_then(|track| lyrics_data.get(track, request.key()))
232 {
233 active_lyrics = ActiveLyrics::Ready(outcome.clone());
234 } else {
235 active_lyrics = ActiveLyrics::Loading;
236 fetching_request = Some(request.clone());
237 let tx = fetch_tx.clone();
238 let client = client.clone();
239 fetch_handle = Some(tokio::spawn(async move {
240 let outcome = client.fetch(request.key()).await;
241 let _ = tx.send(FetchResult { request, outcome }).await;
242 }));
243 }
244 }
245 }
246
247 playback_clock.update(snapshot, measured_at);
248 if let Err(error) = render_clock_snapshot(&mut renderer, &playback_clock, &active_lyrics) {
249 break Err(error);
250 }
251 }
252 }
253 };
254
255 let finish_result = renderer.finish();
257 exit_result.and(finish_result)
258}
259
260fn render_clock_snapshot(
261 renderer: &mut TerminalRenderer,
262 playback_clock: &PlaybackClock,
263 active_lyrics: &ActiveLyrics,
264) -> anyhow::Result<()> {
265 let snapshot = playback_clock.snapshot_at(Instant::now());
266 render_snapshot(renderer, snapshot.as_ref(), active_lyrics)
267}
268
269fn render_snapshot(
270 renderer: &mut TerminalRenderer,
271 snapshot: Option<&PlaybackSnapshot>,
272 active_lyrics: &ActiveLyrics,
273) -> anyhow::Result<()> {
274 let model = RenderModel::from_state(snapshot, active_lyrics);
275 renderer.render_if_changed(&model)
276}
277
278fn abort_fetch(fetch_handle: &mut Option<JoinHandle<()>>) {
279 if let Some(handle) = fetch_handle.take() {
280 handle.abort();
281 }
282}
283
284fn fetch_matches_current(current_request: Option<&FetchRequest>, fetched: &FetchRequest) -> bool {
285 current_request == Some(fetched)
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293enum RenderMode {
294 Live,
295 Once,
296}
297
298const MIN_CONTENT_WIDTH: u16 = 30;
299const MAX_CONTENT_WIDTH: u16 = 72;
300const PROGRESS_BAR_CELLS: u16 = 32;
301const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
302
303const COLOR_ACCENT: Color = Color::Cyan;
305const COLOR_TITLE: Color = Color::White;
306const COLOR_DIM: Color = Color::DarkGrey;
307const COLOR_PROGRESS_FILL: Color = Color::Cyan;
308const COLOR_PROGRESS_THUMB: Color = Color::White;
309const COLOR_PROGRESS_TRACK: Color = Color::DarkGrey;
310const COLOR_CURRENT_LYRIC: Color = Color::Cyan;
313const COLOR_INFO: Color = Color::DarkGrey;
314const COLOR_ERROR: Color = Color::Red;
315const COLOR_WARN: Color = Color::Yellow;
316
317struct TerminalRenderer {
318 stdout: Stdout,
319 mode: RenderMode,
320 last_key: Option<DisplayKey>,
321 tick: u64,
322 finished: bool,
323 frame_buffer: Option<Vec<u8>>,
326}
327
328#[derive(Clone, PartialEq, Eq)]
329struct DisplayKey {
330 base: RenderChangeKey,
331 size: (u16, u16),
332 spinner_frame: Option<usize>,
333}
334
335impl TerminalRenderer {
336 fn new(mode: RenderMode) -> anyhow::Result<Self> {
337 let mut stdout = stdout();
338 if mode == RenderMode::Live {
339 execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
340 }
341 Ok(Self {
342 stdout,
343 mode,
344 last_key: None,
345 tick: 0,
346 finished: false,
347 frame_buffer: None,
348 })
349 }
350
351 fn render_if_changed(&mut self, model: &RenderModel) -> anyhow::Result<()> {
352 self.tick = self.tick.wrapping_add(1);
353 let size = terminal::size().unwrap_or((80, 24));
354 let spinner_frame = match model {
355 RenderModel::Playback(view) if matches!(view.lyrics, LyricsView::Loading) => {
356 Some(self.tick as usize % SPINNER_FRAMES.len())
357 }
358 _ => None,
359 };
360 let key = DisplayKey {
361 base: model.change_key(),
362 size,
363 spinner_frame,
364 };
365 if self.last_key.as_ref() == Some(&key) {
366 return Ok(());
367 }
368
369 self.render(model)?;
370 self.last_key = Some(key);
371 Ok(())
372 }
373
374 fn render(&mut self, model: &RenderModel) -> anyhow::Result<()> {
375 match self.mode {
376 RenderMode::Live => self.render_live(model),
377 RenderMode::Once => self.print_once(model),
378 }
379 }
380
381 fn render_live(&mut self, model: &RenderModel) -> anyhow::Result<()> {
382 let (width, height) = terminal::size().unwrap_or((80, 24));
383
384 let mut frame = self
390 .frame_buffer
391 .take()
392 .unwrap_or_else(|| Vec::with_capacity(8 * 1024));
393 frame.clear();
394 queue!(frame, cursor::MoveTo(0, 0), Clear(ClearType::All))?;
395
396 match model {
397 RenderModel::Message(message) => {
398 draw_message_screen(&mut frame, message, width, height)?;
399 }
400 RenderModel::Playback(view) => {
401 draw_playback_screen(&mut frame, view, width, height, self.tick)?;
402 }
403 }
404
405 self.stdout.write_all(&frame)?;
406 self.stdout.flush()?;
407 self.frame_buffer = Some(frame);
408 Ok(())
409 }
410
411 fn print_once(&mut self, model: &RenderModel) -> anyhow::Result<()> {
412 match model {
413 RenderModel::Message(message) => {
414 queue!(self.stdout, Print(message), Print("\n"))?;
415 }
416 RenderModel::Playback(view) => {
417 print_once_playback(&mut self.stdout, view)?;
418 }
419 }
420 self.stdout.flush()?;
421 Ok(())
422 }
423
424 fn finish(&mut self) -> anyhow::Result<()> {
425 if self.finished {
426 return Ok(());
427 }
428 self.finished = true;
429 if self.mode == RenderMode::Live {
430 execute!(self.stdout, cursor::Show, LeaveAlternateScreen)?;
431 }
432 Ok(())
433 }
434}
435
436impl Drop for TerminalRenderer {
437 fn drop(&mut self) {
438 let _ = self.finish();
440 }
441}
442
443fn content_width(term_width: u16) -> u16 {
446 term_width.clamp(MIN_CONTENT_WIDTH, MAX_CONTENT_WIDTH)
447}
448
449fn draw_message_screen(
451 out: &mut impl Write,
452 message: &str,
453 width: u16,
454 height: u16,
455) -> anyhow::Result<()> {
456 let row = height / 2;
457 write_centered_line(
458 out,
459 row,
460 width,
461 &[Segment::new(message).fg(COLOR_DIM).attr(Attribute::Italic)],
462 )?;
463 Ok(())
464}
465
466fn draw_playback_screen(
468 out: &mut impl Write,
469 view: &PlaybackView,
470 width: u16,
471 height: u16,
472 tick: u64,
473) -> anyhow::Result<()> {
474 let inner = content_width(width);
475 let layout = Layout::compute(height);
476
477 let title = truncate_to_width(&view.title, inner.saturating_sub(4) as usize);
479 write_centered_line(
480 out,
481 layout.title_row,
482 width,
483 &[
484 Segment::new("♪ ").fg(COLOR_ACCENT).attr(Attribute::Bold),
485 Segment::new(&title).fg(COLOR_TITLE).attr(Attribute::Bold),
486 ],
487 )?;
488
489 let mut subtitle: Vec<Segment> = vec![Segment::new(view.artist.clone()).fg(COLOR_DIM)];
490 if let Some(album) = view.album.as_deref() {
491 subtitle.push(Segment::new(" · ").fg(COLOR_DIM));
492 subtitle.push(Segment::new(album.to_string()).fg(COLOR_DIM));
493 }
494 let trimmed_subtitle = trim_segments_to_width(subtitle, inner as usize);
496 write_centered_line(out, layout.subtitle_row, width, &trimmed_subtitle)?;
497
498 let bar = build_progress_segments(view.elapsed_ms, view.duration_ms);
500 let time_label = format!(
501 "{} / {}",
502 format_time(view.elapsed_ms),
503 format_time(view.duration_ms),
504 );
505 let mut progress: Vec<Segment> = Vec::with_capacity(8);
506 progress.push(
507 Segment::new(format!("{} ", state_icon(&view.state)))
508 .fg(state_color(&view.state))
509 .attr(Attribute::Bold),
510 );
511 progress.extend(bar);
512 progress.push(Segment::new(" ").fg(COLOR_DIM));
513 progress.push(Segment::new(time_label).fg(COLOR_INFO));
514 write_centered_line(out, layout.progress_row, width, &progress)?;
515
516 draw_lyric_block(out, view, width, inner, &layout, tick)?;
518
519 if height >= 2 {
521 let footer = format!("via {}", view.source);
522 let col = width.saturating_sub(footer.width() as u16 + 2);
523 queue!(
524 out,
525 cursor::MoveTo(col, height.saturating_sub(1)),
526 SetForegroundColor(COLOR_INFO),
527 SetAttribute(Attribute::Dim),
528 Print(footer),
529 SetAttribute(Attribute::Reset),
530 ResetColor
531 )?;
532 }
533
534 Ok(())
535}
536
537fn draw_lyric_block(
538 out: &mut impl Write,
539 view: &PlaybackView,
540 width: u16,
541 inner: u16,
542 layout: &Layout,
543 tick: u64,
544) -> anyhow::Result<()> {
545 match &view.lyrics {
546 LyricsView::Loading => {
547 let frame = SPINNER_FRAMES[tick as usize % SPINNER_FRAMES.len()];
548 write_centered_line(
549 out,
550 layout.lyric_block_center(),
551 width,
552 &[
553 Segment::new(format!("{frame} ")).fg(COLOR_ACCENT),
554 Segment::new("Loading synced lyrics").fg(COLOR_DIM),
555 ],
556 )?;
557 }
558 LyricsView::PlainOnly => {
559 write_centered_line(
560 out,
561 layout.lyric_block_center(),
562 width,
563 &[Segment::new("Lyrics found, but not synced.").fg(COLOR_WARN)],
564 )?;
565 }
566 LyricsView::Missing => {
567 write_centered_line(
568 out,
569 layout.lyric_block_center(),
570 width,
571 &[Segment::new("No synced lyrics found.").fg(COLOR_DIM)],
572 )?;
573 }
574 LyricsView::Error(error) => {
575 let truncated = truncate_to_width(error, inner.saturating_sub(2) as usize);
576 write_centered_line(
577 out,
578 layout.lyric_block_center(),
579 width,
580 &[
581 Segment::new("Lyrics lookup failed: ").fg(COLOR_ERROR),
582 Segment::new(truncated).fg(COLOR_DIM),
583 ],
584 )?;
585 }
586 LyricsView::Synced(window) => {
587 draw_dynamic_synced_lyrics(
588 out,
589 &window.lines,
590 window.current_index,
591 width,
592 inner,
593 layout,
594 )?;
595 }
596 }
597 Ok(())
598}
599
600fn draw_dynamic_synced_lyrics(
606 out: &mut impl Write,
607 lines: &[String],
608 current_index: Option<usize>,
609 width: u16,
610 inner: u16,
611 layout: &Layout,
612) -> anyhow::Result<()> {
613 let capacity = layout.lyric_capacity() as usize;
614 if capacity == 0 {
615 return Ok(());
616 }
617
618 if lines.is_empty() {
619 write_centered_line(
621 out,
622 layout.lyric_block_center(),
623 width,
624 &[Segment::new("· · ·").fg(COLOR_DIM)],
625 )?;
626 return Ok(());
627 }
628
629 let slice = select_window(lines.len(), current_index, capacity);
630 let visible = &lines[slice.start..slice.end];
631
632 for (i, text) in visible.iter().enumerate() {
633 let global_index = slice.start + i;
634 let row = layout
635 .lyric_block_start
636 .saturating_add(slice.top_offset as u16)
637 .saturating_add(i as u16);
638 if row > layout.lyric_block_end {
639 break;
640 }
641
642 let distance = current_index
643 .map(|c| (global_index as isize) - (c as isize))
644 .unwrap_or(isize::MAX);
645 let (color, attr) = lyric_style_for_distance(distance);
646 let max_width = inner.saturating_sub(4) as usize;
649 let trimmed = truncate_to_width(text, max_width);
650 let mut segment = Segment::new(trimmed).fg(color);
651 if let Some(attr) = attr {
652 segment = segment.attr(attr);
653 }
654 write_centered_line(out, row, width, &[segment])?;
655 }
656 Ok(())
657}
658
659#[derive(Clone, Copy, Debug, PartialEq, Eq)]
663struct WindowSlice {
664 start: usize,
665 end: usize,
666 top_offset: usize,
667}
668
669fn select_window(total: usize, current: Option<usize>, capacity: usize) -> WindowSlice {
670 if total == 0 || capacity == 0 {
671 return WindowSlice {
672 start: 0,
673 end: 0,
674 top_offset: 0,
675 };
676 }
677
678 if total <= capacity {
679 let top_offset = (capacity - total) / 2;
681 return WindowSlice {
682 start: 0,
683 end: total,
684 top_offset,
685 };
686 }
687
688 let window = capacity;
689 let Some(center) = current else {
690 return WindowSlice {
691 start: 0,
692 end: window,
693 top_offset: 0,
694 };
695 };
696
697 let half = window / 2;
698 let start = center.saturating_sub(half).min(total - window);
699 WindowSlice {
700 start,
701 end: start + window,
702 top_offset: 0,
703 }
704}
705
706fn lyric_style_for_distance(distance: isize) -> (Color, Option<Attribute>) {
710 let d = distance.unsigned_abs();
711 match d {
712 0 => (COLOR_CURRENT_LYRIC, Some(Attribute::Bold)),
713 1 => (Color::White, None),
714 2..=3 => (Color::Grey, None),
715 4..=6 => (Color::DarkGrey, None),
716 _ => (Color::DarkGrey, Some(Attribute::Dim)),
717 }
718}
719
720fn print_once_playback(out: &mut impl Write, view: &PlaybackView) -> anyhow::Result<()> {
721 queue!(
723 out,
724 SetAttribute(Attribute::Bold),
725 Print(format!("♪ {}", view.title)),
726 SetAttribute(Attribute::Reset),
727 Print("\n"),
728 )?;
729 let mut subtitle = view.artist.clone();
730 if let Some(album) = view.album.as_deref() {
731 subtitle.push_str(" · ");
732 subtitle.push_str(album);
733 }
734 queue!(out, Print(subtitle), Print("\n"))?;
735 queue!(
736 out,
737 Print(format!(
738 "{} {} / {} ({})\n",
739 state_icon(&view.state),
740 format_time(view.elapsed_ms),
741 format_time(view.duration_ms),
742 view.source,
743 ))
744 )?;
745 queue!(out, Print("\n"))?;
746
747 match &view.lyrics {
748 LyricsView::Loading => {
749 queue!(out, Print("Loading synced lyrics...\n"))?;
750 }
751 LyricsView::PlainOnly => {
752 queue!(out, Print("Lyrics found, but not synced.\n"))?;
753 }
754 LyricsView::Missing => {
755 queue!(out, Print("No synced lyrics found.\n"))?;
756 }
757 LyricsView::Error(error) => {
758 queue!(out, Print(format!("Lyrics lookup failed: {error}\n")))?;
759 }
760 LyricsView::Synced(window) => {
761 let total = window.lines.len();
762 match window.current_index {
763 Some(current) if current < total => {
764 if let Some(previous) = current.checked_sub(1).and_then(|i| window.lines.get(i))
765 {
766 queue!(out, Print(format!(" {previous}\n")))?;
767 }
768 queue!(
769 out,
770 SetAttribute(Attribute::Bold),
771 Print(format!("> {}\n", window.lines[current])),
772 SetAttribute(Attribute::Reset),
773 )?;
774 if let Some(next) = window.lines.get(current + 1) {
775 queue!(out, Print(format!(" {next}\n")))?;
776 }
777 }
778 _ => {
779 queue!(
780 out,
781 SetAttribute(Attribute::Bold),
782 Print("> ...\n"),
783 SetAttribute(Attribute::Reset),
784 )?;
785 if let Some(first) = window.lines.first() {
786 queue!(out, Print(format!(" {first}\n")))?;
787 }
788 }
789 }
790 }
791 }
792 Ok(())
793}
794
795#[derive(Clone, Copy)]
798struct Layout {
799 title_row: u16,
800 subtitle_row: u16,
801 progress_row: u16,
802 lyric_block_start: u16,
804 lyric_block_end: u16,
806}
807
808impl Layout {
809 fn compute(height: u16) -> Self {
810 let height = height.max(8);
815 let title_row = 1u16;
816 let subtitle_row = 2u16;
817 let progress_row = 4u16;
818 let lyric_block_start = 6u16;
819 let lyric_block_end = height.saturating_sub(3).max(lyric_block_start);
821 Self {
822 title_row,
823 subtitle_row,
824 progress_row,
825 lyric_block_start,
826 lyric_block_end,
827 }
828 }
829
830 fn lyric_capacity(&self) -> u16 {
831 self.lyric_block_end
832 .saturating_sub(self.lyric_block_start)
833 .saturating_add(1)
834 }
835
836 fn lyric_block_center(&self) -> u16 {
837 self.lyric_block_start
838 .saturating_add(self.lyric_capacity() / 2)
839 }
840}
841
842fn build_progress_segments(elapsed_ms: Option<u64>, duration_ms: Option<u64>) -> Vec<Segment> {
845 let total_cells = PROGRESS_BAR_CELLS as usize;
846 let ratio = match (elapsed_ms, duration_ms) {
847 (Some(elapsed), Some(duration)) if duration > 0 => {
848 (elapsed.min(duration) as f64) / (duration as f64)
849 }
850 _ => 0.0,
851 };
852 let filled = ((ratio * total_cells as f64).round() as usize).min(total_cells);
853
854 let mut segments = Vec::with_capacity(3);
855 if filled > 0 {
856 let head = "━".repeat(filled.saturating_sub(1));
857 segments.push(Segment::new(head).fg(COLOR_PROGRESS_FILL));
858 segments.push(
859 Segment::new("╸")
860 .fg(COLOR_PROGRESS_THUMB)
861 .attr(Attribute::Bold),
862 );
863 }
864 let track = total_cells.saturating_sub(filled);
865 if track > 0 {
866 segments.push(Segment::new("─".repeat(track)).fg(COLOR_PROGRESS_TRACK));
867 }
868 segments
869}
870
871#[derive(Clone)]
874struct Segment {
875 text: String,
876 fg: Option<Color>,
877 attr: Option<Attribute>,
878}
879
880impl Segment {
881 fn new(text: impl Into<String>) -> Self {
882 Self {
883 text: text.into(),
884 fg: None,
885 attr: None,
886 }
887 }
888
889 fn fg(mut self, color: Color) -> Self {
890 self.fg = Some(color);
891 self
892 }
893
894 fn attr(mut self, attr: Attribute) -> Self {
895 self.attr = Some(attr);
896 self
897 }
898
899 fn width(&self) -> usize {
900 self.text.width()
901 }
902}
903
904fn write_centered_line(
905 out: &mut impl Write,
906 row: u16,
907 width: u16,
908 segments: &[Segment],
909) -> anyhow::Result<()> {
910 let total: usize = segments.iter().map(Segment::width).sum();
911 let col = ((width as usize).saturating_sub(total) / 2) as u16;
912 queue!(out, cursor::MoveTo(col, row))?;
913 for segment in segments {
914 if let Some(color) = segment.fg {
915 queue!(out, SetForegroundColor(color))?;
916 }
917 if let Some(attr) = segment.attr {
918 queue!(out, SetAttribute(attr))?;
919 }
920 queue!(out, Print(&segment.text))?;
921 if segment.attr.is_some() {
922 queue!(out, SetAttribute(Attribute::Reset))?;
923 }
924 if segment.fg.is_some() {
925 queue!(out, ResetColor)?;
926 }
927 }
928 Ok(())
929}
930
931fn trim_segments_to_width(segments: Vec<Segment>, max: usize) -> Vec<Segment> {
932 let total: usize = segments.iter().map(Segment::width).sum();
933 if total <= max {
934 return segments;
935 }
936 let mut remaining = max.saturating_sub(1); let mut trimmed = Vec::with_capacity(segments.len() + 1);
938 for segment in segments {
939 if remaining == 0 {
940 break;
941 }
942 let w = segment.width();
943 if w <= remaining {
944 remaining -= w;
945 trimmed.push(segment);
946 } else {
947 let head = truncate_to_width(&segment.text, remaining);
948 remaining = 0;
949 trimmed.push(Segment {
950 text: head,
951 ..segment
952 });
953 }
954 }
955 trimmed.push(Segment::new("…").fg(COLOR_DIM));
956 trimmed
957}
958
959fn truncate_to_width(text: &str, max: usize) -> String {
960 if text.width() <= max {
961 return text.to_string();
962 }
963 if max <= 1 {
964 return "…".to_string();
965 }
966 let mut accumulated = 0usize;
967 let mut out = String::new();
968 for ch in text.chars() {
969 let w = ch.to_string().width();
970 if accumulated + w + 1 > max {
971 break;
972 }
973 accumulated += w;
974 out.push(ch);
975 }
976 out.push('…');
977 out
978}
979
980fn state_icon(state: &PlayState) -> &'static str {
983 match state {
984 PlayState::Playing => "▶",
985 PlayState::Paused => "❚❚",
986 PlayState::Stopped => "■",
987 PlayState::Unknown => "·",
988 }
989}
990
991fn state_color(state: &PlayState) -> Color {
992 match state {
993 PlayState::Playing => COLOR_ACCENT,
994 PlayState::Paused => COLOR_WARN,
995 PlayState::Stopped => COLOR_DIM,
996 PlayState::Unknown => COLOR_DIM,
997 }
998}
999
1000fn format_time(value_ms: Option<u64>) -> String {
1001 let Some(value_ms) = value_ms else {
1002 return "--:--".to_string();
1003 };
1004 let total_seconds = value_ms / 1_000;
1005 let minutes = total_seconds / 60;
1006 let seconds = total_seconds % 60;
1007 format!("{minutes:02}:{seconds:02}")
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 fn request_for(title: &str, id: &str) -> FetchRequest {
1015 let track = rubecula::TrackDescriptor {
1016 title: title.to_string(),
1017 artist: "artist".to_string(),
1018 album: None,
1019 duration_ms: Some(180_000),
1020 source_track_id: Some(id.to_string()),
1021 };
1022 let key = LookupKey::from_track(&track).unwrap();
1023 FetchRequest::new(key, track.source_track_id)
1024 }
1025
1026 #[test]
1027 fn no_args_open_the_live_music_ui() {
1028 for command in ["rubin", "rubecula"] {
1029 let args = Args::parse_from([command]);
1030
1031 assert_eq!(args.mode, Mode::Live);
1032 assert_eq!(args.source, SourceChoice::Music);
1033 assert_eq!(args.poll_ms, 500);
1034 assert_eq!(
1035 args.mediaremote_command,
1036 PathBuf::from("mediaremote-adapter")
1037 );
1038 assert!(args.lrc.is_none());
1039 }
1040 }
1041
1042 #[test]
1043 fn fetch_result_only_applies_to_current_track_key() {
1044 let first_request = request_for("song a", "music:first");
1045 let second_request = request_for("song b", "music:second");
1046
1047 assert!(fetch_matches_current(Some(&first_request), &first_request));
1048 assert!(!fetch_matches_current(
1049 Some(&second_request),
1050 &first_request
1051 ));
1052 assert!(!fetch_matches_current(None, &first_request));
1053 }
1054
1055 #[test]
1056 fn fetch_result_rejects_same_key_from_different_source_track() {
1057 let old_request = request_for("song", "music:old");
1058 let new_request = request_for("song", "music:new");
1059
1060 assert!(!fetch_matches_current(Some(&new_request), &old_request));
1061 }
1062
1063 #[test]
1064 fn progress_bar_fills_in_proportion_to_elapsed() {
1065 let segments = build_progress_segments(Some(0), Some(100_000));
1066 let visible: String = segments.iter().map(|s| s.text.clone()).collect();
1067 assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
1068 assert!(visible.contains('─'));
1069
1070 let segments = build_progress_segments(Some(100_000), Some(100_000));
1071 let visible: String = segments.iter().map(|s| s.text.clone()).collect();
1072 assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
1073 assert!(visible.contains('━') || visible.contains('╸'));
1074
1075 let segments = build_progress_segments(Some(50_000), Some(100_000));
1076 let visible: String = segments.iter().map(|s| s.text.clone()).collect();
1077 assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
1078 assert!(visible.contains('━'));
1079 assert!(visible.contains('─'));
1080 assert!(visible.contains('╸'));
1081 }
1082
1083 #[test]
1084 fn progress_bar_handles_missing_duration() {
1085 let segments = build_progress_segments(None, None);
1086 let visible: String = segments.iter().map(|s| s.text.clone()).collect();
1087 assert_eq!(visible.width(), PROGRESS_BAR_CELLS as usize);
1088 }
1089
1090 #[test]
1091 fn truncate_to_width_appends_ellipsis_when_overflow() {
1092 assert_eq!(truncate_to_width("short", 10), "short");
1093 let truncated = truncate_to_width("a much longer line of lyrics", 8);
1094 assert!(truncated.ends_with('…'));
1095 assert!(truncated.width() <= 8);
1096 }
1097
1098 #[test]
1099 fn state_icon_distinguishes_play_states() {
1100 assert_eq!(state_icon(&PlayState::Playing), "▶");
1101 assert_ne!(
1102 state_icon(&PlayState::Paused),
1103 state_icon(&PlayState::Playing)
1104 );
1105 assert_ne!(
1106 state_icon(&PlayState::Stopped),
1107 state_icon(&PlayState::Playing)
1108 );
1109 }
1110
1111 #[test]
1112 fn format_time_handles_missing_value() {
1113 assert_eq!(format_time(None), "--:--");
1114 assert_eq!(format_time(Some(0)), "00:00");
1115 assert_eq!(format_time(Some(75_500)), "01:15");
1116 }
1117
1118 #[test]
1119 fn select_window_centers_on_current() {
1120 let slice = select_window(20, Some(10), 5);
1121 assert_eq!(
1122 slice,
1123 WindowSlice {
1124 start: 8,
1125 end: 13,
1126 top_offset: 0,
1127 }
1128 );
1129 }
1130
1131 #[test]
1132 fn select_window_clamps_near_start() {
1133 let slice = select_window(20, Some(0), 7);
1134 assert_eq!(
1135 slice,
1136 WindowSlice {
1137 start: 0,
1138 end: 7,
1139 top_offset: 0,
1140 }
1141 );
1142 }
1143
1144 #[test]
1145 fn select_window_clamps_near_end() {
1146 let slice = select_window(20, Some(19), 7);
1147 assert_eq!(
1148 slice,
1149 WindowSlice {
1150 start: 13,
1151 end: 20,
1152 top_offset: 0,
1153 }
1154 );
1155 assert!((slice.start..slice.end).contains(&19));
1156 }
1157
1158 #[test]
1159 fn select_window_centers_short_song_vertically() {
1160 let slice = select_window(4, Some(2), 10);
1161 assert_eq!(
1162 slice,
1163 WindowSlice {
1164 start: 0,
1165 end: 4,
1166 top_offset: 3,
1167 }
1168 );
1169 }
1170
1171 #[test]
1172 fn select_window_handles_no_current_before_first_line() {
1173 let slice = select_window(20, None, 5);
1174 assert_eq!(
1175 slice,
1176 WindowSlice {
1177 start: 0,
1178 end: 5,
1179 top_offset: 0,
1180 }
1181 );
1182 }
1183
1184 #[test]
1185 fn select_window_handles_empty_lines_or_zero_capacity() {
1186 assert_eq!(
1187 select_window(0, None, 5),
1188 WindowSlice {
1189 start: 0,
1190 end: 0,
1191 top_offset: 0,
1192 }
1193 );
1194 assert_eq!(
1195 select_window(10, Some(3), 0),
1196 WindowSlice {
1197 start: 0,
1198 end: 0,
1199 top_offset: 0,
1200 }
1201 );
1202 }
1203
1204 #[test]
1205 fn lyric_style_emphasizes_current_and_fades_with_distance() {
1206 let (current_color, current_attr) = lyric_style_for_distance(0);
1207 assert_eq!(current_attr, Some(Attribute::Bold));
1208 assert_eq!(current_color, COLOR_CURRENT_LYRIC);
1209
1210 let (near_color, near_attr) = lyric_style_for_distance(2);
1211 assert_eq!(near_color, Color::Grey);
1212 assert!(near_attr.is_none());
1213
1214 let (far_color, _) = lyric_style_for_distance(10);
1215 assert_eq!(far_color, Color::DarkGrey);
1216 }
1217
1218 #[test]
1219 fn layout_carves_lyric_block_between_header_and_footer() {
1220 let layout = Layout::compute(30);
1221 assert!(layout.title_row < layout.progress_row);
1222 assert!(layout.progress_row < layout.lyric_block_start);
1223 assert!(layout.lyric_block_end <= 30 - 2);
1225 assert!(layout.lyric_capacity() >= 15);
1227 }
1228
1229 #[test]
1230 fn layout_collapses_gracefully_on_tiny_terminals() {
1231 let layout = Layout::compute(4);
1232 assert!(layout.lyric_block_end >= layout.lyric_block_start);
1235 assert!(layout.lyric_capacity() >= 1);
1236 }
1237}