Skip to main content

rubecula_cli/
lib.rs

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    /// Load synced LRC lyrics from a local file instead of querying LRCLIB.
29    #[arg(long)]
30    lrc: Option<PathBuf>,
31
32    /// Now-playing source to poll.
33    #[arg(long, value_enum, default_value_t = SourceChoice::Music)]
34    source: SourceChoice,
35
36    /// CLI display mode.
37    #[arg(long, value_enum, default_value_t = Mode::Live)]
38    mode: Mode,
39
40    /// Command name or path for the optional MediaRemote adapter.
41    #[arg(long, default_value = "mediaremote-adapter")]
42    mediaremote_command: PathBuf,
43
44    /// Polling cadence in milliseconds.
45    #[arg(long, default_value_t = 500)]
46    poll_ms: u64,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
50enum Mode {
51    /// Continuously flush live lyrics in the terminal.
52    Live,
53    /// Render the current track and lyrics once, then exit.
54    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    // Always restore terminal state even if rendering errored out.
256    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// ---------------------------------------------------------------------------
289// Visual rendering
290// ---------------------------------------------------------------------------
291
292#[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
303// Colors form a small palette so the UI feels coherent.
304const 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;
310// The current lyric uses the system accent so it stands out from the
311// otherwise monochrome white/grey lyric ladder.
312const 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    /// Reusable byte buffer for atomic-frame rendering. Kept on the renderer
324    /// so we don't reallocate ~8 KB every tick.
325    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        // Build the entire frame into an in-memory buffer, then flush it to
385        // stdout in a single `write_all`. Most terminals render data read
386        // from one syscall as one atomic frame, so the user never sees the
387        // intermediate "cleared" state between the screen wipe and the
388        // redraw — the source of the previous flicker.
389        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        // Best-effort cleanup if `finish()` was not called (e.g. panic).
439        let _ = self.finish();
440    }
441}
442
443// ---- screen builders ------------------------------------------------------
444
445fn content_width(term_width: u16) -> u16 {
446    term_width.clamp(MIN_CONTENT_WIDTH, MAX_CONTENT_WIDTH)
447}
448
449/// Vertically centred, single-line message screen (no-playback, errors, etc.).
450fn 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
466/// Full-bleed playback screen: header, progress, lyric window, footer.
467fn 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    // ── Header: "♪  Title" + "Artist · Album"
478    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    // Truncate combined subtitle to inner width.
495    let trimmed_subtitle = trim_segments_to_width(subtitle, inner as usize);
496    write_centered_line(out, layout.subtitle_row, width, &trimmed_subtitle)?;
497
498    // ── Progress row: "▶  ━━━━╸────────  01:23 / 03:45"
499    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    // ── Lyric window (or status)
517    draw_lyric_block(out, view, width, inner, &layout, tick)?;
518
519    // ── Footer: "via Music.app", right-aligned, very dim
520    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
600/// Render the synced lyric block sized to whatever rows are available in
601/// `layout`. The window is centered on `current_index`; if all lines fit
602/// they are centered vertically inside the block. Each line is colored by
603/// distance to the current line so the focal point glows brightest and the
604/// edges fade into the background.
605fn 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        // No lyric lines at all — show a soft placeholder in the middle.
620        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        // Leave a couple of cells of breathing room so long lyric lines do
647        // not collide with the screen edges.
648        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/// Slice of the lyric list to display, plus the number of blank rows to
660/// leave above the first rendered line so a short song stays centered in a
661/// tall lyric block.
662#[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        // Every line fits; center vertically inside the available block.
680        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
706/// Color + attribute for a lyric line based on its signed distance from the
707/// current line. Distance `0` is the current line; positive distances are
708/// upcoming, negative are past. Lines beyond the near range fade out.
709fn 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    // Plain, scrollback-friendly version of the live screen.
722    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// ---- layout ---------------------------------------------------------------
796
797#[derive(Clone, Copy)]
798struct Layout {
799    title_row: u16,
800    subtitle_row: u16,
801    progress_row: u16,
802    /// First row reserved for the dynamic lyric block.
803    lyric_block_start: u16,
804    /// Last row reserved for the dynamic lyric block (inclusive).
805    lyric_block_end: u16,
806}
807
808impl Layout {
809    fn compute(height: u16) -> Self {
810        // Pin the header near the top and the footer at the very bottom so
811        // the lyric block can stretch across whatever rows remain. For very
812        // short terminals (height < 10) we collapse breathing room so the
813        // block is at least one row wide.
814        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        // Reserve the final row for the footer and one blank row above it.
820        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
842// ---- progress bar ---------------------------------------------------------
843
844fn 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// ---- segments + writing helpers ------------------------------------------
872
873#[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); // reserve 1 col for ellipsis
937    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
980// ---- glyphs + colors ------------------------------------------------------
981
982fn 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        // The block must leave room for the footer row and a blank above it.
1224        assert!(layout.lyric_block_end <= 30 - 2);
1225        // Capacity should be substantial for a "normal" 30-row terminal.
1226        assert!(layout.lyric_capacity() >= 15);
1227    }
1228
1229    #[test]
1230    fn layout_collapses_gracefully_on_tiny_terminals() {
1231        let layout = Layout::compute(4);
1232        // Even on a very short terminal the block has at least one row so
1233        // the renderer can show something rather than panicking.
1234        assert!(layout.lyric_block_end >= layout.lyric_block_start);
1235        assert!(layout.lyric_capacity() >= 1);
1236    }
1237}