diff --git a/docs/lyrics.md b/docs/lyrics.md index 928303d6..bb51a6b8 100644 --- a/docs/lyrics.md +++ b/docs/lyrics.md @@ -4,8 +4,8 @@ Press `y` to show lyrics for the current track. For local files, cliamp uses emb ## Modes -- **Synced lyrics**: for local files and Navidrome tracks, lyrics auto scroll and highlight the active line in time with playback. -- **Scroll mode**: for streams and plain lyrics without timestamps, use `j`/`k` or arrow keys to scroll manually. +- **Synced lyrics**: for local files, Navidrome tracks, and YouTube/yt-dlp tracks with a known duration, lyrics auto scroll and highlight the active line in time with playback. +- **Scroll mode**: for plain lyrics without timestamps, live radio (ICY), and YouTube Live (position is not song-relative), use `j`/`k` or arrow keys to scroll manually. Embedded LRC lyrics keep their timestamps. Embedded plain text lyrics are shown in scroll mode. diff --git a/ui/model/lyrics.go b/ui/model/lyrics.go index 103fcf24..20e45d06 100644 --- a/ui/model/lyrics.go +++ b/ui/model/lyrics.go @@ -59,17 +59,21 @@ func (m *Model) retryLyrics() tea.Cmd { } // lyricsSyncable reports whether synced lyrics can track the current playback -// position. This is true for local files and Navidrome streams (which have -// accurate position tracking), but false for live radio (ICY — position is -// from stream start, not song start) and yt-dlp pipe streams (position is 0). +// position. This is true for local files, Navidrome streams (which have +// accurate position tracking), and yt-dlp tracks (whose ytdlPipeStreamer +// reports position from decoded PCM frames). It is false for live radio (ICY — +// position is from stream start, not song start) and for live streams with no +// finite duration, where the position doesn't map to song time. func (m *Model) lyricsSyncable() bool { track, idx := m.currentPlaybackTrack() if idx < 0 { return false } - // YouTube/yt-dlp pipe streams report position 0. - if playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path) { - return false + // yt-dlp pipe streams track position from decoded frames, so synced lyrics + // can follow them. Exclude streams without a known duration (e.g. YouTube + // Live), where the position is not relative to the song. + if playlist.IsYTDL(track.Path) { + return track.DurationSecs > 0 } // ICY radio streams: position counts from stream connect, not song start. // Provider streams with metadata (e.g. Navidrome) track position correctly. diff --git a/ui/model/lyrics_test.go b/ui/model/lyrics_test.go new file mode 100644 index 00000000..cb555a2e --- /dev/null +++ b/ui/model/lyrics_test.go @@ -0,0 +1,62 @@ +package model + +import ( + "testing" + + "github.com/bjarneo/cliamp/playlist" +) + +func TestLyricsSyncable(t *testing.T) { + tests := []struct { + name string + track playlist.Track + want bool + }{ + { + name: "local file", + track: playlist.Track{Title: "Local", Path: "/tmp/a.mp3", DurationSecs: 180}, + want: true, + }, + { + name: "youtube music finite track", + track: playlist.Track{Title: "Song", Path: "https://music.youtube.com/watch?v=abc", Stream: true, DurationSecs: 240}, + want: true, + }, + { + name: "youtube finite track", + track: playlist.Track{Title: "Song", Path: "https://www.youtube.com/watch?v=abc", Stream: true, DurationSecs: 240}, + want: true, + }, + { + name: "yt-dlp track (soundcloud) finite", + track: playlist.Track{Title: "SC", Path: "https://soundcloud.com/x/y", Stream: true, DurationSecs: 120}, + want: true, + }, + { + name: "youtube live (no duration)", + track: playlist.Track{Title: "Live", Path: "https://music.youtube.com/watch?v=live", Stream: true, DurationSecs: 0}, + want: false, + }, + { + name: "icy radio stream without provider metadata", + track: playlist.Track{Title: "Radio", Path: "https://radio.example/stream", Stream: true}, + want: false, + }, + { + name: "navidrome provider stream", + track: playlist.Track{Title: "Nav", Path: "https://nav.example/stream", Stream: true, DurationSecs: 200, ProviderMeta: map[string]string{"navidrome": "id"}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := playlist.New() + p.Replace([]playlist.Track{tt.track}) + p.SetIndex(0) + m := Model{playlist: p} + if got := m.lyricsSyncable(); got != tt.want { + t.Fatalf("lyricsSyncable() = %v, want %v", got, tt.want) + } + }) + } +}