From 78d6464c02f528104a35a12054191d1e26862b97 Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 2 Nov 2026 13:02:11 +0100 Subject: lyrics: cache lrclib results, redraw cleanly and show a now playing header Plain and unavailable lyrics now wait for a track change instead of exiting, synced frames erase the previous region so shrinking frames do not leave stale text, and lrclib lookups are cached next to the track. --- src/daemon.rs | 181 ++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 145 insertions(+), 36 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index bdee362..41c21c6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -643,27 +643,56 @@ fn format_error_chain(e: reqwest::Error) -> String { msg } -fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc) { - let track_path = match audio.current_track() { - Some(p) => p, - None => return, +enum LoadedLyrics { + Synced(Vec), + Plain(String), + Unavailable(String), +} + +/// Build the "Now playing" header shown above a track's lyrics. +fn lyrics_header(track: &Track) -> String { + let name = match Config::load() + .ok() + .and_then(|c| c.display_format.clone()) + .as_deref() + { + Some(fmt) => track.format(fmt), + None => { + let artist = track.artist.clone().unwrap_or_default(); + let title = track.title.clone().unwrap_or_else(|| { + track + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown") + .to_string() + }); + if artist.is_empty() { + title + } else { + format!("{} - {}", artist, title) + } + } }; + format!("\x1b[2KNow playing: {}\n\n", name) +} - let mut lrc_path = PathBuf::from(&track_path); +/// Load lyrics for a track: local .lrc (synced), then local .txt (plain), +/// then LRClib as a last resort (caching the result next to the file). +fn load_lyrics(track_path: &Path) -> LoadedLyrics { + let mut lrc_path = track_path.to_path_buf(); lrc_path.set_extension("lrc"); - let mut txt_path = PathBuf::from(&track_path); + let mut txt_path = track_path.to_path_buf(); txt_path.set_extension("txt"); // Try local .lrc first (synced), then local .txt (plain), then LRClib - if lrc_path.exists() { - let content = match fs::read_to_string(&lrc_path) { - Ok(c) => c, - Err(_) => return, - }; + if lrc_path.exists() + && let Ok(content) = fs::read_to_string(&lrc_path) + { let lyrics = parse_lrc(&content); if !lyrics.is_empty() { - return stream_synced_lyrics(stream, audio, &lyrics); + return LoadedLyrics::Synced(lyrics); } } @@ -671,59 +700,136 @@ fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc) { && let Ok(content) = fs::read_to_string(&txt_path) && !content.trim().is_empty() { - let _ = stream.write_all(content.as_bytes()); - let _ = stream.write_all(b"\n"); - return; + return LoadedLyrics::Plain(content); } // No local lyrics — fetch from LRClib - let track = Track::from_path(track_path.clone()); + let track = Track::from_path(track_path.to_path_buf()); let artist = track.artist.as_deref().unwrap_or(""); let title = track.title.as_deref().unwrap_or(""); + let album = track.album.as_deref(); if artist.is_empty() && title.is_empty() { - let _ = stream.write_all(b"No lyrics available and no metadata to query LRClib\n"); - return; + return LoadedLyrics::Unavailable( + "No lyrics available and no metadata to query LRClib".to_string(), + ); } - let album = track.album.as_deref(); - match fetch_lrclib_lyrics(artist, title, album) { Ok(lyrics) => { if let Some(ref synced) = lyrics.synced_lyrics && !synced.is_empty() { let _ = fs::write(&lrc_path, synced); - let content = match fs::read_to_string(&lrc_path) { - Ok(c) => c, - Err(_) => return, - }; - let parsed = parse_lrc(&content); + let parsed = parse_lrc(synced); if !parsed.is_empty() { - stream_synced_lyrics(stream, audio, &parsed) + LoadedLyrics::Synced(parsed) + } else { + LoadedLyrics::Unavailable("No synced lyrics found on LRClib".to_string()) } } else if let Some(ref plain) = lyrics.plain_lyrics && !plain.is_empty() { let _ = fs::write(&txt_path, plain); - let _ = stream.write_all(plain.as_bytes()); - let _ = stream.write_all(b"\n"); + LoadedLyrics::Plain(plain.clone()) } else { - let _ = stream.write_all(b"No lyrics found on LRClib\n"); + LoadedLyrics::Unavailable("No lyrics found on LRClib".to_string()) } } - Err(e) => { - let msg = format!("Failed to fetch lyrics: {}\n", e); - let _ = stream.write_all(msg.as_bytes()); + Err(e) => LoadedLyrics::Unavailable(format!("Failed to fetch lyrics: {}", e)), + } +} + +/// Stream lyrics for the currently playing track, following track changes: +/// when the song changes, reload and show the new song's lyrics in place. +fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc) { + let mut shown_path: Option = None; + let mut first_page = true; + + loop { + match audio.current_track() { + Some(track_path) => { + if shown_path.as_deref() == Some(track_path.as_path()) { + // Same track still playing — wait for a change. + thread::sleep(Duration::from_millis(100)); + continue; + } + + // New track: start a fresh full-screen page (kept in place on + // the very first invocation so the user's prompt isn't wiped). + if !first_page && stream.write_all(b"\x1b[2J\x1b[H").is_err() { + return; + } + first_page = false; + shown_path = Some(track_path.clone()); + + let track = Track::from_path(track_path.clone()); + if stream.write_all(lyrics_header(&track).as_bytes()).is_err() + || stream.flush().is_err() + { + return; + } + + match load_lyrics(&track_path) { + LoadedLyrics::Synced(lines) => { + stream_synced_lyrics(stream, audio, &lines, &track_path); + } + LoadedLyrics::Plain(text) | LoadedLyrics::Unavailable(text) => { + if stream.write_all(text.as_bytes()).is_err() + || stream.write_all(b"\n").is_err() + || stream.flush().is_err() + { + return; + } + } + } + // Synced lyrics return here on track change; plain/unavailable + // lyrics fall through and wait for the change above. + } + None => { + if shown_path.is_some() { + let _ = stream.write_all(b"\nPlayback stopped\n"); + let _ = stream.flush(); + shown_path = None; + } else if first_page { + // Nothing was ever playing — exit like before. + return; + } + thread::sleep(Duration::from_millis(100)); + } } } } /// Stream synced (LRC) lyrics in real time, tracking audio playback position. -fn stream_synced_lyrics(stream: &mut UnixStream, audio: &AudioEngine, lyrics: &[LrcLine]) { +/// Returns when the playing track changes (the caller reloads lyrics) or the +/// client disconnects. +fn stream_synced_lyrics( + stream: &mut UnixStream, + audio: &AudioEngine, + lyrics: &[LrcLine], + track_path: &Path, +) { let mut last_idx = None; let mut prev_line_count = 0; loop { + match audio.current_track().as_deref() { + Some(p) if p == track_path => {} + Some(_) => { + // Track changed: clear this track's display region so the + // next track starts on a clean screen. + if prev_line_count > 0 { + let _ = stream.write_all(format!("\x1b[{}A\x1b[J", prev_line_count).as_bytes()); + let _ = stream.flush(); + } + return; + } + None => { + // Playback stopped; leave the final frame visible. + return; + } + } + let pos = audio.position(); let current_idx = lyrics.iter().rposition(|l| l.time <= pos).unwrap_or(0); if last_idx == Some(current_idx) { @@ -744,18 +850,21 @@ fn stream_synced_lyrics(stream: &mut UnixStream, audio: &AudioEngine, lyrics: &[ } } let line_count = output.lines().count(); + let mut frame = String::new(); if prev_line_count > 0 { - frame.push_str(&format!("\x1b[{}A\x1b[1B", prev_line_count)); + // Move back to the top of the previous frame and erase the region + // before rewriting, so frames that shrink don't leave stale text. + frame.push_str(&format!("\x1b[{}A\x1b[J", prev_line_count)); } else { frame.push_str("\x1b[2K"); } frame.push_str(&output); if stream.write_all(frame.as_bytes()).is_err() { - break; + return; } if stream.flush().is_err() { - break; + return; } prev_line_count = line_count; thread::sleep(Duration::from_millis(100)); -- cgit v1.2.3