fix(lyrics): polish karaoke rendering and mobile layout

This commit is contained in:
ranokay 2026-04-14 05:10:54 +03:00
parent aeae6d2217
commit cc15a2f820
No known key found for this signature in database
13 changed files with 512 additions and 71 deletions

View File

@ -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)
}
}

View File

@ -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

View File

@ -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")

View File

@ -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
}

View File

@ -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))
})
})

View File

@ -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 (
<div
className={classes.overlay}
className={clsx(classes.overlay, {
[classes.overlayInline]: inline,
})}
data-testid="karaoke-lyrics-overlay"
data-inline={inline ? 'true' : 'false'}
style={overlayStyle}
onClick={inline ? (event) => event.stopPropagation() : undefined}
>
<div
className={classes.resizeHandle}
onMouseDown={onResizeStart}
data-testid="lyrics-resize-handle"
/>
{!inline && (
<div
className={classes.resizeHandle}
onMouseDown={onResizeStart}
data-testid="lyrics-resize-handle"
/>
)}
<div className={classes.header}>
<div
className={clsx(classes.header, {
[classes.headerInline]: inline,
})}
>
<div className={classes.headerLeft}>
<div className={classes.languageBadges}>
{languageBadges.map((badge) => (
@ -1536,7 +1590,12 @@ const KaraokeLyricsOverlay = ({
</div>
</div>
<div className={classes.body} ref={bodyRef}>
<div
className={clsx(classes.body, {
[classes.bodyInline]: inline,
})}
ref={bodyRef}
>
<div className={classes.lines} style={{ gap: lineGap }}>
<div aria-hidden style={{ height: centerSpacerPx }} />
{mainLines.map((line, idx) => {

View File

@ -71,6 +71,16 @@ describe('<KaraokeLyricsOverlay /> 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('<KaraokeLyricsOverlay /> 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('<KaraokeLyricsOverlay /> 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: {

View File

@ -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

View File

@ -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('<MobileKaraokeLyricsPortal />', () => {
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(
<MobileKaraokeLyricsPortal active={true}>
<div data-testid="mobile-inline-lyrics">Lyrics</div>
</MobileKaraokeLyricsPortal>,
)
expect(host).toContainElement(screen.getByTestId('mobile-inline-lyrics'))
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
rerender(
<MobileKaraokeLyricsPortal active={false}>
<div data-testid="mobile-inline-lyrics">Lyrics</div>
</MobileKaraokeLyricsPortal>,
)
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(
<MobileKaraokeLyricsPortal active={true}>
<div data-testid="mobile-inline-lyrics">Lyrics</div>
</MobileKaraokeLyricsPortal>,
)
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)
})
})

View File

@ -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}
/>
<KaraokeLyricsOverlay
visible={karaokeVisible}
mainLyric={selectedLyricLayers.main}
translationLyric={selectedLyricLayers.translation}
pronunciationLyric={selectedLyricLayers.pronunciation}
showTranslation={showTranslation}
showPronunciation={showPronunciation}
translationEnabled={hasTranslationLyric}
pronunciationEnabled={hasPronunciationLyric}
onToggleTranslation={() =>
setTranslationPreference((previous) =>
hasTranslationLyric ? !previous : false,
)
}
onTogglePronunciation={() =>
setPronunciationPreference((previous) =>
togglePronunciationPreference(previous, hasPronunciationLyric),
)
}
audioInstance={audioInstance}
onClose={() => setKaraokeVisiblePreference(false)}
/>
{isDesktop && (
<KaraokeLyricsOverlay
visible={karaokeVisible}
mainLyric={selectedLyricLayers.main}
translationLyric={selectedLyricLayers.translation}
pronunciationLyric={selectedLyricLayers.pronunciation}
showTranslation={showTranslation}
showPronunciation={showPronunciation}
translationEnabled={hasTranslationLyric}
pronunciationEnabled={hasPronunciationLyric}
onToggleTranslation={() =>
setTranslationPreference((previous) =>
hasTranslationLyric ? !previous : false,
)
}
onTogglePronunciation={() =>
setPronunciationPreference((previous) =>
togglePronunciationPreference(previous, hasPronunciationLyric),
)
}
audioInstance={audioInstance}
onClose={() => setKaraokeVisiblePreference(false)}
/>
)}
<MobileKaraokeLyricsPortal active={useInlineMobileLyrics}>
<KaraokeLyricsOverlay
visible={useInlineMobileLyrics}
inline={true}
mainLyric={selectedLyricLayers.main}
translationLyric={selectedLyricLayers.translation}
pronunciationLyric={selectedLyricLayers.pronunciation}
showTranslation={showTranslation}
showPronunciation={showPronunciation}
translationEnabled={hasTranslationLyric}
pronunciationEnabled={hasPronunciationLyric}
onToggleTranslation={() =>
setTranslationPreference((previous) =>
hasTranslationLyric ? !previous : false,
)
}
onTogglePronunciation={() =>
setPronunciationPreference((previous) =>
togglePronunciationPreference(previous, hasPronunciationLyric),
)
}
audioInstance={audioInstance}
onClose={() => setKaraokeVisiblePreference(false)}
/>
</MobileKaraokeLyricsPortal>
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
</ThemeProvider>
)

View File

@ -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)

View File

@ -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 = '눈을 뜬 순간'

View File

@ -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':
{