aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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.
Diffstat (limited to '')
-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));