aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:02:11 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:02:11 +0100
commit78d6464c02f528104a35a12054191d1e26862b97 (patch)
treeb896002a00ed08a83646a246c716377b4b226eb1
parentaudio: http streaming, opus/mkv support and stream recovery (diff)
downloadrsplayer-78d6464c02f528104a35a12054191d1e26862b97.tar.gz
rsplayer-78d6464c02f528104a35a12054191d1e26862b97.zip
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.
-rw-r--r--src/daemon.rs181
1 files 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 {
643 msg 643 msg
644} 644}
645 645
646fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) { 646enum LoadedLyrics {
647 let track_path = match audio.current_track() { 647 Synced(Vec<LrcLine>),
648 Some(p) => p, 648 Plain(String),
649 None => return, 649 Unavailable(String),
650}
651
652/// Build the "Now playing" header shown above a track's lyrics.
653fn lyrics_header(track: &Track) -> String {
654 let name = match Config::load()
655 .ok()
656 .and_then(|c| c.display_format.clone())
657 .as_deref()
658 {
659 Some(fmt) => track.format(fmt),
660 None => {
661 let artist = track.artist.clone().unwrap_or_default();
662 let title = track.title.clone().unwrap_or_else(|| {
663 track
664 .path
665 .file_name()
666 .and_then(|n| n.to_str())
667 .unwrap_or("Unknown")
668 .to_string()
669 });
670 if artist.is_empty() {
671 title
672 } else {
673 format!("{} - {}", artist, title)
674 }
675 }
650 }; 676 };
677 format!("\x1b[2KNow playing: {}\n\n", name)
678}
651 679
652 let mut lrc_path = PathBuf::from(&track_path); 680/// Load lyrics for a track: local .lrc (synced), then local .txt (plain),
681/// then LRClib as a last resort (caching the result next to the file).
682fn load_lyrics(track_path: &Path) -> LoadedLyrics {
683 let mut lrc_path = track_path.to_path_buf();
653 lrc_path.set_extension("lrc"); 684 lrc_path.set_extension("lrc");
654 685
655 let mut txt_path = PathBuf::from(&track_path); 686 let mut txt_path = track_path.to_path_buf();
656 txt_path.set_extension("txt"); 687 txt_path.set_extension("txt");
657 688
658 // Try local .lrc first (synced), then local .txt (plain), then LRClib 689 // Try local .lrc first (synced), then local .txt (plain), then LRClib
659 if lrc_path.exists() { 690 if lrc_path.exists()
660 let content = match fs::read_to_string(&lrc_path) { 691 && let Ok(content) = fs::read_to_string(&lrc_path)
661 Ok(c) => c, 692 {
662 Err(_) => return,
663 };
664 let lyrics = parse_lrc(&content); 693 let lyrics = parse_lrc(&content);
665 if !lyrics.is_empty() { 694 if !lyrics.is_empty() {
666 return stream_synced_lyrics(stream, audio, &lyrics); 695 return LoadedLyrics::Synced(lyrics);
667 } 696 }
668 } 697 }
669 698
@@ -671,59 +700,136 @@ fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) {
671 && let Ok(content) = fs::read_to_string(&txt_path) 700 && let Ok(content) = fs::read_to_string(&txt_path)
672 && !content.trim().is_empty() 701 && !content.trim().is_empty()
673 { 702 {
674 let _ = stream.write_all(content.as_bytes()); 703 return LoadedLyrics::Plain(content);
675 let _ = stream.write_all(b"\n");
676 return;
677 } 704 }
678 705
679 // No local lyrics — fetch from LRClib 706 // No local lyrics — fetch from LRClib
680 let track = Track::from_path(track_path.clone()); 707 let track = Track::from_path(track_path.to_path_buf());
681 let artist = track.artist.as_deref().unwrap_or(""); 708 let artist = track.artist.as_deref().unwrap_or("");
682 let title = track.title.as_deref().unwrap_or(""); 709 let title = track.title.as_deref().unwrap_or("");
710 let album = track.album.as_deref();
683 711
684 if artist.is_empty() && title.is_empty() { 712 if artist.is_empty() && title.is_empty() {
685 let _ = stream.write_all(b"No lyrics available and no metadata to query LRClib\n"); 713 return LoadedLyrics::Unavailable(
686 return; 714 "No lyrics available and no metadata to query LRClib".to_string(),
715 );
687 } 716 }
688 717
689 let album = track.album.as_deref();
690
691 match fetch_lrclib_lyrics(artist, title, album) { 718 match fetch_lrclib_lyrics(artist, title, album) {
692 Ok(lyrics) => { 719 Ok(lyrics) => {
693 if let Some(ref synced) = lyrics.synced_lyrics 720 if let Some(ref synced) = lyrics.synced_lyrics
694 && !synced.is_empty() 721 && !synced.is_empty()
695 { 722 {
696 let _ = fs::write(&lrc_path, synced); 723 let _ = fs::write(&lrc_path, synced);
697 let content = match fs::read_to_string(&lrc_path) { 724 let parsed = parse_lrc(synced);
698 Ok(c) => c,
699 Err(_) => return,
700 };
701 let parsed = parse_lrc(&content);
702 if !parsed.is_empty() { 725 if !parsed.is_empty() {
703 stream_synced_lyrics(stream, audio, &parsed) 726 LoadedLyrics::Synced(parsed)
727 } else {
728 LoadedLyrics::Unavailable("No synced lyrics found on LRClib".to_string())
704 } 729 }
705 } else if let Some(ref plain) = lyrics.plain_lyrics 730 } else if let Some(ref plain) = lyrics.plain_lyrics
706 && !plain.is_empty() 731 && !plain.is_empty()
707 { 732 {
708 let _ = fs::write(&txt_path, plain); 733 let _ = fs::write(&txt_path, plain);
709 let _ = stream.write_all(plain.as_bytes()); 734 LoadedLyrics::Plain(plain.clone())
710 let _ = stream.write_all(b"\n");
711 } else { 735 } else {
712 let _ = stream.write_all(b"No lyrics found on LRClib\n"); 736 LoadedLyrics::Unavailable("No lyrics found on LRClib".to_string())
713 } 737 }
714 } 738 }
715 Err(e) => { 739 Err(e) => LoadedLyrics::Unavailable(format!("Failed to fetch lyrics: {}", e)),
716 let msg = format!("Failed to fetch lyrics: {}\n", e); 740 }
717 let _ = stream.write_all(msg.as_bytes()); 741}
742
743/// Stream lyrics for the currently playing track, following track changes:
744/// when the song changes, reload and show the new song's lyrics in place.
745fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) {
746 let mut shown_path: Option<PathBuf> = None;
747 let mut first_page = true;
748
749 loop {
750 match audio.current_track() {
751 Some(track_path) => {
752 if shown_path.as_deref() == Some(track_path.as_path()) {
753 // Same track still playing — wait for a change.
754 thread::sleep(Duration::from_millis(100));
755 continue;
756 }
757
758 // New track: start a fresh full-screen page (kept in place on
759 // the very first invocation so the user's prompt isn't wiped).
760 if !first_page && stream.write_all(b"\x1b[2J\x1b[H").is_err() {
761 return;
762 }
763 first_page = false;
764 shown_path = Some(track_path.clone());
765
766 let track = Track::from_path(track_path.clone());
767 if stream.write_all(lyrics_header(&track).as_bytes()).is_err()
768 || stream.flush().is_err()
769 {
770 return;
771 }
772
773 match load_lyrics(&track_path) {
774 LoadedLyrics::Synced(lines) => {
775 stream_synced_lyrics(stream, audio, &lines, &track_path);
776 }
777 LoadedLyrics::Plain(text) | LoadedLyrics::Unavailable(text) => {
778 if stream.write_all(text.as_bytes()).is_err()
779 || stream.write_all(b"\n").is_err()
780 || stream.flush().is_err()
781 {
782 return;
783 }
784 }
785 }
786 // Synced lyrics return here on track change; plain/unavailable
787 // lyrics fall through and wait for the change above.
788 }
789 None => {
790 if shown_path.is_some() {
791 let _ = stream.write_all(b"\nPlayback stopped\n");
792 let _ = stream.flush();
793 shown_path = None;
794 } else if first_page {
795 // Nothing was ever playing — exit like before.
796 return;
797 }
798 thread::sleep(Duration::from_millis(100));
799 }
718 } 800 }
719 } 801 }
720} 802}
721 803
722/// Stream synced (LRC) lyrics in real time, tracking audio playback position. 804/// Stream synced (LRC) lyrics in real time, tracking audio playback position.
723fn stream_synced_lyrics(stream: &mut UnixStream, audio: &AudioEngine, lyrics: &[LrcLine]) { 805/// Returns when the playing track changes (the caller reloads lyrics) or the
806/// client disconnects.
807fn stream_synced_lyrics(
808 stream: &mut UnixStream,
809 audio: &AudioEngine,
810 lyrics: &[LrcLine],
811 track_path: &Path,
812) {
724 let mut last_idx = None; 813 let mut last_idx = None;
725 let mut prev_line_count = 0; 814 let mut prev_line_count = 0;
726 loop { 815 loop {
816 match audio.current_track().as_deref() {
817 Some(p) if p == track_path => {}
818 Some(_) => {
819 // Track changed: clear this track's display region so the
820 // next track starts on a clean screen.
821 if prev_line_count > 0 {
822 let _ = stream.write_all(format!("\x1b[{}A\x1b[J", prev_line_count).as_bytes());
823 let _ = stream.flush();
824 }
825 return;
826 }
827 None => {
828 // Playback stopped; leave the final frame visible.
829 return;
830 }
831 }
832
727 let pos = audio.position(); 833 let pos = audio.position();
728 let current_idx = lyrics.iter().rposition(|l| l.time <= pos).unwrap_or(0); 834 let current_idx = lyrics.iter().rposition(|l| l.time <= pos).unwrap_or(0);
729 if last_idx == Some(current_idx) { 835 if last_idx == Some(current_idx) {
@@ -744,18 +850,21 @@ fn stream_synced_lyrics(stream: &mut UnixStream, audio: &AudioEngine, lyrics: &[
744 } 850 }
745 } 851 }
746 let line_count = output.lines().count(); 852 let line_count = output.lines().count();
853
747 let mut frame = String::new(); 854 let mut frame = String::new();
748 if prev_line_count > 0 { 855 if prev_line_count > 0 {
749 frame.push_str(&format!("\x1b[{}A\x1b[1B", prev_line_count)); 856 // Move back to the top of the previous frame and erase the region
857 // before rewriting, so frames that shrink don't leave stale text.
858 frame.push_str(&format!("\x1b[{}A\x1b[J", prev_line_count));
750 } else { 859 } else {
751 frame.push_str("\x1b[2K"); 860 frame.push_str("\x1b[2K");
752 } 861 }
753 frame.push_str(&output); 862 frame.push_str(&output);
754 if stream.write_all(frame.as_bytes()).is_err() { 863 if stream.write_all(frame.as_bytes()).is_err() {
755 break; 864 return;
756 } 865 }
757 if stream.flush().is_err() { 866 if stream.flush().is_err() {
758 break; 867 return;
759 } 868 }
760 prev_line_count = line_count; 869 prev_line_count = line_count;
761 thread::sleep(Duration::from_millis(100)); 870 thread::sleep(Duration::from_millis(100));