diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index 758053042..cc3d574b3 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -14,6 +14,12 @@ type Lyrics interface { GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) } +// BatchLyrics can resolve lyrics across multiple candidate media files while +// still honoring the configured source priority globally. +type BatchLyrics interface { + GetLyricsForMediaFiles(ctx context.Context, mediaFiles []model.MediaFile) (model.LyricList, error) +} + // PluginLoader discovers and loads lyrics provider plugins. type PluginLoader interface { LoadLyricsProvider(name string) (Lyrics, bool) @@ -32,28 +38,53 @@ func NewLyrics(pluginLoader PluginLoader) Lyrics { // GetLyrics returns lyrics for the given media file, trying sources in the // order specified by conf.Server.LyricsPriority. func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - var lyricsList model.LyricList - var err error + return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf}) +} +// GetLyricsForMediaFiles resolves lyrics across duplicate media files while +// preserving the configured source priority across the full candidate set. +func (l *lyricsService) GetLyricsForMediaFiles(ctx context.Context, mediaFiles []model.MediaFile) (model.LyricList, error) { + candidates := make([]*model.MediaFile, 0, len(mediaFiles)) + for i := range mediaFiles { + candidates = append(candidates, &mediaFiles[i]) + } + return l.getLyricsForCandidates(ctx, candidates) +} + +func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) - switch { - case strings.EqualFold(pattern, "embedded"): - lyricsList, err = fromEmbedded(ctx, mf) - case strings.HasPrefix(pattern, "."): - lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern)) - default: - lyricsList, err = l.fromPlugin(ctx, mf, pattern) + if pattern == "" { + continue } - if err != nil { - log.Error(ctx, "error getting lyrics", "source", pattern, err) - } + for _, mf := range mediaFiles { + if mf == nil { + continue + } - if len(lyricsList) > 0 { - return lyricsList, nil + lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) + if err != nil { + log.Error(ctx, "error getting lyrics", "source", pattern, err) + continue + } + + if len(lyricsList) > 0 { + return lyricsList, nil + } } } return nil, nil } + +func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { + switch { + case strings.EqualFold(pattern, "embedded"): + return fromEmbedded(ctx, mf) + case strings.HasPrefix(pattern, "."): + return fromExternalFile(ctx, mf, strings.ToLower(pattern)) + default: + return l.fromPlugin(ctx, mf, pattern) + } +} diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 822e975ce..26bdd5aa9 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -169,6 +169,29 @@ var _ = Describe("sources", func() { Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics), Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics)) + It("resolves source priority across duplicate media files", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + embeddedJSON, err := json.Marshal(embeddedLyrics) + Expect(err).To(BeNil()) + + svc := lyrics.NewLyrics(nil) + batchSvc, ok := svc.(lyrics.BatchLyrics) + Expect(ok).To(BeTrue()) + + list, err := batchSvc.GetLyricsForMediaFiles(ctx, []model.MediaFile{ + { + Lyrics: string(embeddedJSON), + Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", + }, + { + Lyrics: "[]", + Path: "tests/fixtures/test.mp3", + }, + }) + Expect(err).To(BeNil()) + Expect(list).To(Equal(ttmlLyrics)) + }) + Context("Errors", func() { var RegularUserContext = XContext var isRegularUser = os.Getuid() != 0 diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 5ba03336e..1e98323ca 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -299,7 +299,7 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line")) }) - It("should handle UTF-16 LE encoded TTML files", func() { + It("should handle UTF-16 BE encoded TTML files", func() { mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} lyrics, err := fromExternalFile(ctx, &mf, ".ttml") diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 16d0d2666..c3c6d98ea 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + lyricssvc "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" @@ -19,6 +20,8 @@ import ( "github.com/navidrome/navidrome/utils/req" ) +const maxLegacyLyricsCandidates = 10 + func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { if !conf.Server.EnableGravatar { return api.getPlaceHolderAvatar(w, r) @@ -99,9 +102,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse := responses.Lyrics{} response.Lyrics = &lyricsResponse opts := filter.SongsByArtistTitleWithLyricsFirst(artist, title) - // Keep the search exhaustive so an older duplicate can still supply the - // matching sidecar lyrics when the newest candidate only has embedded data. - opts.Max = 0 + // Search a bounded duplicate window so source-priority fallback can still + // reach older matches without turning legacy getLyrics into an unbounded scan. + opts.Max = maxLegacyLyricsCandidates mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(opts) if err != nil { @@ -112,26 +115,37 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { return response, nil } - for i := range mediaFiles { - structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[i]) + var structuredLyrics model.LyricList + if batchLyrics, ok := api.lyrics.(lyricssvc.BatchLyrics); ok { + structuredLyrics, err = batchLyrics.GetLyricsForMediaFiles(r.Context(), mediaFiles) if err != nil { return nil, err } - if len(structuredLyrics) == 0 { - continue + } else { + for i := range mediaFiles { + structuredLyrics, err = api.lyrics.GetLyrics(r.Context(), &mediaFiles[i]) + if err != nil { + return nil, err + } + if len(structuredLyrics) > 0 { + break + } } - - lyricsResponse.Artist = artist - lyricsResponse.Title = title - - var lyricsText strings.Builder - for _, line := range structuredLyrics[0].Line { - lyricsText.WriteString(line.Value + "\n") - } - lyricsResponse.Value = lyricsText.String() - break } + if len(structuredLyrics) == 0 { + return response, nil + } + + lyricsResponse.Artist = artist + lyricsResponse.Title = title + + var lyricsText strings.Builder + for _, line := range structuredLyrics[0].Line { + lyricsText.WriteString(line.Value + "\n") + } + lyricsResponse.Value = lyricsText.String() + return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index faa90e375..d02d5b9bd 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -187,18 +187,22 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) - It("should continue searching candidates for sidecar lyrics", func() { + It("should prefer higher-priority sidecar lyrics across duplicate candidates", func() { conf.Server.LyricsPriority = ".ttml,embedded" r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + embedded, err := model.ToLyrics("eng", "Newest duplicate embedded lyrics") + Expect(err).ToNot(HaveOccurred()) + embeddedJSON, err := json.Marshal(model.LyricList{*embedded}) + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { ID: "1", Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", Artist: "Rick Astley", Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(2 * time.Hour), // Newer, but no TTML sidecar + Lyrics: string(embeddedJSON), + UpdatedAt: baseTime.Add(2 * time.Hour), // Newer duplicate with embedded lyrics only }, { ID: "2", @@ -215,6 +219,7 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Artist).To(Equal("Rick Astley")) Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) + Expect(mockRepo.Options.Max).To(Equal(maxLegacyLyricsCandidates)) }) }) diff --git a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx b/ui/src/audioplayer/KaraokeLyricsOverlay.jsx index 799f8bdc2..c016df9e5 100644 --- a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx +++ b/ui/src/audioplayer/KaraokeLyricsOverlay.jsx @@ -48,6 +48,7 @@ const KARAOKE_MAX_LINE_HEIGHT = 2.2 const KARAOKE_LINE_HEIGHT_STEP = 0.02 const KARAOKE_GROUP_SPACING_BASE_PX = 14 const KARAOKE_AUX_LINE_HEIGHT = 1.2 +const KARAOKE_MAIN_INACTIVE_FONT_FACTOR = 0.8 const TOKEN_DONE_ALPHA = 1 const TOKEN_FUTURE_ALPHA = 0.34 @@ -160,6 +161,21 @@ const useStyles = makeStyles((theme) => ({ maxHeight: '65vh', }, }, + overlayInline: { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + minHeight: 0, + maxHeight: '100%', + transform: 'none', + borderRadius: 'inherit', + border: 'none', + boxShadow: 'none', + background: 'rgba(6, 8, 12, 0.92)', + backdropFilter: 'blur(12px)', + zIndex: 1, + }, resizeHandle: { height: 14, cursor: 'ns-resize', @@ -187,6 +203,10 @@ const useStyles = makeStyles((theme) => ({ gap: theme.spacing(1), padding: theme.spacing(0.3, 1.3, 0.4, 1.3), }, + headerInline: { + padding: theme.spacing(0.25, 0.65, 0.35, 0.65), + gap: theme.spacing(0.65), + }, headerLeft: { display: 'flex', alignItems: 'center', @@ -264,6 +284,8 @@ const useStyles = makeStyles((theme) => ({ }, inlineTr: { margin: 0, + display: 'inline-block', + maxWidth: '100%', textAlign: 'center', fontWeight: 400, lineHeight: KARAOKE_AUX_LINE_HEIGHT, @@ -272,6 +294,14 @@ const useStyles = makeStyles((theme) => ({ }, inlinePr: { margin: 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexWrap: 'wrap', + alignSelf: 'center', + width: 'fit-content', + maxWidth: '100%', + boxSizing: 'border-box', textAlign: 'center', fontWeight: 400, lineHeight: KARAOKE_AUX_LINE_HEIGHT, @@ -300,6 +330,9 @@ const useStyles = makeStyles((theme) => ({ padding: theme.spacing(0.35, 1.2, 1.2, 1.2), }, }, + bodyInline: { + padding: theme.spacing(0.25, 0.8, 0.85, 0.8), + }, lines: { display: 'flex', flexDirection: 'column', @@ -308,12 +341,14 @@ const useStyles = makeStyles((theme) => ({ }, line: { margin: 0, + display: 'inline-block', + maxWidth: '100%', fontWeight: 600, lineHeight: 1.24, letterSpacing: '0.01em', textAlign: 'center', color: 'rgba(255, 255, 255, 0.62)', - transition: `opacity ${KARAOKE_ANIMATION_MS}ms ease-in-out, color ${KARAOKE_ANIMATION_MS}ms ease-in-out, font-size 280ms ease-in-out`, + transition: `opacity ${KARAOKE_ANIMATION_MS}ms ease-in-out, color ${KARAOKE_ANIMATION_MS}ms ease-in-out, font-size 280ms ease-in-out, max-width 280ms ease-in-out`, }, token: { display: 'inline-block', @@ -858,7 +893,8 @@ const areLineStylesEqual = (prevStyle, nextStyle) => { a.color === b.color && a.fontSize === b.fontSize && a.fontWeight === b.fontWeight && - a.lineHeight === b.lineHeight + a.lineHeight === b.lineHeight && + a.maxWidth === b.maxWidth ) } @@ -1038,6 +1074,7 @@ const KaraokeLyricsOverlay = ({ onTogglePronunciation, audioInstance, onClose, + inline = false, }) => { const classes = useStyles() const [playbackMs, setPlaybackMs] = useState(0) @@ -1397,13 +1434,18 @@ const KaraokeLyricsOverlay = ({ } const baseFontSize = lyricsSettings.main.fontSize - const fontSize = isActive ? baseFontSize : Math.round(baseFontSize * 0.8) + const fontSize = isActive + ? baseFontSize + : Math.round(baseFontSize * KARAOKE_MAIN_INACTIVE_FONT_FACTOR) return { opacity, color, fontSize, lineHeight, + maxWidth: isActive + ? '100%' + : `${Math.round(KARAOKE_MAIN_INACTIVE_FONT_FACTOR * 100)}%`, } } @@ -1448,7 +1490,9 @@ const KaraokeLyricsOverlay = ({ } } - const overlayStyle = isCompact + const overlayStyle = inline + ? undefined + : isCompact ? undefined : { height: overlayHeight, @@ -1457,17 +1501,27 @@ const KaraokeLyricsOverlay = ({ return (