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