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 (
event.stopPropagation() : undefined} > -
+ {!inline && ( +
+ )} -
+
{languageBadges.map((badge) => ( @@ -1536,7 +1590,12 @@ const KaraokeLyricsOverlay = ({
-
+
{mainLines.map((line, idx) => { diff --git a/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx b/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx index 412bc3946..2fccf693a 100644 --- a/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx +++ b/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx @@ -71,6 +71,16 @@ describe(' behavior', () => { expect(await screen.findByText('Appearance')).toBeInTheDocument() }) + it('renders inline mode without the desktop resize handle', () => { + renderOverlay({ inline: true }) + + expect(screen.getByTestId('karaoke-lyrics-overlay')).toHaveAttribute( + 'data-inline', + 'true', + ) + expect(screen.queryByTestId('lyrics-resize-handle')).not.toBeInTheDocument() + }) + it('renders the appearance popup with Main label and default line height for older settings', async () => { localStorage.setItem( 'karaoke-lyrics-settings', @@ -245,6 +255,49 @@ describe(' behavior', () => { ]) }) + it('uses cue byte offsets to preserve explicit space cues in multibyte karaoke lines', () => { + renderOverlay({ + mainLyric: { + kind: 'main', + lang: 'ko', + synced: true, + line: [{ start: 0, end: 900, value: '눈을 뜬 순간' }], + cueLine: [ + { + index: 0, + start: 0, + end: 900, + value: '눈을 뜬 순간', + cue: [ + { start: 0, end: 150, value: '눈을', byteStart: 0, byteEnd: 5 }, + { start: 150, end: 250, value: ' ', byteStart: 6, byteEnd: 6 }, + { start: 250, end: 450, value: '뜬', byteStart: 7, byteEnd: 9 }, + { start: 450, end: 550, value: ' ', byteStart: 10, byteEnd: 10 }, + { start: 550, end: 900, value: '순간', byteStart: 11, byteEnd: 16 }, + ], + }, + ], + }, + translationLyric: null, + pronunciationLyric: null, + showTranslation: false, + showPronunciation: false, + translationEnabled: false, + pronunciationEnabled: false, + audioInstance: { + ...audioInstance, + currentTime: 0.3, + }, + }) + + const mainLine = screen.getByText('눈을').parentElement + const segments = Array.from(mainLine.querySelectorAll('span')).map( + (span) => span.textContent, + ) + + expect(segments).toEqual(['눈을', ' ', '뜬', ' ', '순간']) + }) + it('highlights line-timed pronunciation and translation rows with the active main line', () => { renderOverlay({ mainLyric: { @@ -295,6 +348,55 @@ describe(' behavior', () => { ) }) + it('pre-wraps inactive main lines so the active line keeps the same wrap shape', () => { + renderOverlay({ + mainLyric: { + kind: 'main', + lang: 'en', + synced: true, + line: [ + { start: 1000, end: 1800, value: 'First line that is getting focus' }, + { start: 2500, end: 3300, value: 'Second line waiting below' }, + ], + }, + translationLyric: null, + pronunciationLyric: null, + showTranslation: false, + showPronunciation: false, + translationEnabled: false, + pronunciationEnabled: false, + audioInstance: { + ...audioInstance, + currentTime: 1.2, + }, + }) + + const activeLine = screen.getByText('First line that is getting focus') + .parentElement + const inactiveLine = screen.getByText('Second line waiting below') + .parentElement + + expect(parseFloat(activeLine.style.fontSize)).toBeGreaterThan( + parseFloat(inactiveLine.style.fontSize), + ) + expect(activeLine.style.maxWidth).toBe('100%') + expect(inactiveLine.style.maxWidth).toBe('80%') + }) + + it('centers pronunciation text inside the pill container', () => { + renderOverlay({ + showTranslation: false, + showPronunciation: true, + }) + + const pronunciationLine = screen.getByText('konnichiwa').parentElement + const styles = window.getComputedStyle(pronunciationLine) + + expect(styles.display).toBe('inline-flex') + expect(styles.justifyContent).toBe('center') + expect(styles.alignItems).toBe('center') + }) + it('renders untimed text lyrics in manual reading mode without a pinned active line', () => { renderOverlay({ mainLyric: { diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx new file mode 100644 index 000000000..636107184 --- /dev/null +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' + +export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR = + '.react-jinke-music-player-mobile-cover' +export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active' + +const resolveMobileLyricsHost = () => { + if (typeof document === 'undefined') { + return null + } + return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR) +} + +const MobileKaraokeLyricsPortal = ({ active, children }) => { + const [host, setHost] = useState(() => + active ? resolveMobileLyricsHost() : null, + ) + + useEffect(() => { + if (typeof document === 'undefined') { + setHost(null) + return undefined + } + + if (!active) { + setHost(null) + return undefined + } + + const syncHost = () => { + setHost(resolveMobileLyricsHost()) + } + + syncHost() + + const observer = new MutationObserver(syncHost) + observer.observe(document.body, { + childList: true, + subtree: true, + }) + + return () => observer.disconnect() + }, [active]) + + useEffect(() => { + if (!host) { + return undefined + } + + host.classList.toggle(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, active) + + return () => { + host.classList.remove(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + } + }, [active, host]) + + if (!active || !host) { + return null + } + + return createPortal(children, host) +} + +export default MobileKaraokeLyricsPortal diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx new file mode 100644 index 000000000..8b237e184 --- /dev/null +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx @@ -0,0 +1,55 @@ +import React from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import MobileKaraokeLyricsPortal, { + MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, +} from './MobileKaraokeLyricsPortal' + +const HOST_CLASS = 'react-jinke-music-player-mobile-cover' + +describe('', () => { + afterEach(() => { + cleanup() + document.body.innerHTML = '' + }) + + it('renders lyrics into the mobile cover host and toggles the active class', () => { + const host = document.createElement('div') + host.className = HOST_CLASS + document.body.appendChild(host) + + const { rerender } = render( + +
Lyrics
+
, + ) + + expect(host).toContainElement(screen.getByTestId('mobile-inline-lyrics')) + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + + rerender( + +
Lyrics
+
, + ) + + expect(screen.queryByTestId('mobile-inline-lyrics')).not.toBeInTheDocument() + expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + }) + + it('attaches when the mobile cover host appears after mount', async () => { + render( + +
Lyrics
+
, + ) + + const host = document.createElement('div') + host.className = HOST_CLASS + document.body.appendChild(host) + + await waitFor(() => + expect(host).toContainElement(screen.getByTestId('mobile-inline-lyrics')), + ) + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + }) +}) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index c6e73c916..9a60655cd 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -45,6 +45,7 @@ import { togglePronunciationPreference, } from './lyricsOverlayState' import KaraokeLyricsOverlay from './KaraokeLyricsOverlay' +import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal' const emptyLyricLayers = { main: null, @@ -172,6 +173,7 @@ const Player = () => { hasTranslationLyric, hasPronunciationLyric, }) + const useInlineMobileLyrics = karaokeVisible && !isDesktop const applyLyricToRuntimePlayer = useCallback((trackId, lyric) => { if (!trackId) { @@ -535,10 +537,13 @@ const Player = () => { ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { + if (!isDesktop && karaokeVisible) { + return + } if (mode === 'full' && audioInfo?.song?.albumId) { window.location.href = `#/album/${audioInfo.song.albumId}/show` } - }, []) + }, [isDesktop, karaokeVisible]) const onAudioError = useCallback( (error, currentPlayId, audioLists, audioInfo) => { @@ -603,28 +608,55 @@ const Player = () => { onBeforeDestroy={onBeforeDestroy} getAudioInstance={setAudioInstance} /> - - setTranslationPreference((previous) => - hasTranslationLyric ? !previous : false, - ) - } - onTogglePronunciation={() => - setPronunciationPreference((previous) => - togglePronunciationPreference(previous, hasPronunciationLyric), - ) - } - audioInstance={audioInstance} - onClose={() => setKaraokeVisiblePreference(false)} - /> + {isDesktop && ( + + setTranslationPreference((previous) => + hasTranslationLyric ? !previous : false, + ) + } + onTogglePronunciation={() => + setPronunciationPreference((previous) => + togglePronunciationPreference(previous, hasPronunciationLyric), + ) + } + audioInstance={audioInstance} + onClose={() => setKaraokeVisiblePreference(false)} + /> + )} + + + setTranslationPreference((previous) => + hasTranslationLyric ? !previous : false, + ) + } + onTogglePronunciation={() => + setPronunciationPreference((previous) => + togglePronunciationPreference(previous, hasPronunciationLyric), + ) + } + audioInstance={audioInstance} + onClose={() => setKaraokeVisiblePreference(false)} + /> + ) diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js index 6fa627ee5..ae49c89e5 100644 --- a/ui/src/audioplayer/lyrics.js +++ b/ui/src/audioplayer/lyrics.js @@ -86,7 +86,7 @@ const normalizeToken = (token) => { return null } const value = typeof token.value === 'string' ? token.value : '' - if (!value.trim()) { + if (value.length === 0) { return null } const byteStart = toByteOffset(token.byteStart) diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js index 961fdb10b..ae5fb5a66 100644 --- a/ui/src/audioplayer/lyrics.test.js +++ b/ui/src/audioplayer/lyrics.test.js @@ -455,6 +455,43 @@ describe('lyrics helpers', () => { ]) }) + it('preserves whitespace-only cues for exact byte-range rendering', () => { + const lines = buildKaraokeLines({ + lang: 'kor', + synced: true, + line: [{ start: 0, end: 900, value: '눈을 뜬 순간' }], + cueLine: [ + { + index: 0, + start: 0, + end: 900, + value: '눈을 뜬 순간', + cue: [ + { start: 0, end: 150, value: '눈을', byteStart: 0, byteEnd: 5 }, + { start: 150, end: 250, value: ' ', byteStart: 6, byteEnd: 6 }, + { start: 250, end: 450, value: '뜬', byteStart: 7, byteEnd: 9 }, + { start: 450, end: 550, value: ' ', byteStart: 10, byteEnd: 10 }, + { start: 550, end: 900, value: '순간', byteStart: 11, byteEnd: 16 }, + ], + }, + ], + }) + + expect( + lines[0].tokens.map((token) => [ + token.value, + token.byteStart, + token.byteEnd, + ]), + ).toEqual([ + ['눈을', 0, 5], + [' ', 6, 6], + ['뜬', 7, 9], + [' ', 10, 10], + ['순간', 11, 16], + ]) + }) + it('maps UTF-8 byte offsets to string ranges for multibyte lyrics', () => { const text = '눈을 뜬 순간' diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 30a14d4db..09ccb8fcf 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -62,12 +62,30 @@ const useStyle = makeStyles( // Fix cover display when image is not square aspectRatio: '1/1', display: 'flex', + position: 'relative', + }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active': + { + width: 'calc(100% - 40px)', + maxWidth: 'none', + height: 'clamp(280px, 42vh, 460px)', + aspectRatio: 'auto', + borderRadius: 24, + border: '1px solid rgba(255, 255, 255, 0.1)', + boxShadow: '0 18px 40px rgba(0, 0, 0, 0.32)', + background: 'rgba(6, 8, 12, 0.82)', + cursor: 'default', }, '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover': { animationDuration: (props) => !props.enableCoverAnimation && '0s', objectFit: 'contain', // Fix cover display when image is not square }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active img.cover': + { + opacity: 0, + pointerEvents: 'none', + }, // Hide old singer display '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer': {