diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:02:11 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:02:11 +0100 |
| commit | 78d6464c02f528104a35a12054191d1e26862b97 (patch) | |
| tree | b896002a00ed08a83646a246c716377b4b226eb1 | |
| parent | audio: http streaming, opus/mkv support and stream recovery (diff) | |
| download | rsplayer-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.rs | 181 |
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 | ||
| 646 | fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) { | 646 | enum 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. | ||
| 653 | fn 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). | ||
| 682 | fn 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. | ||
| 745 | fn 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. |
| 723 | fn 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. | ||
| 807 | fn 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)); |
