mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
fix(lyrics): polish karaoke rendering and mobile layout
This commit is contained in:
parent
aeae6d2217
commit
cc15a2f820
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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: {
|
||||
|
||||
65
ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
Normal file
65
ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
Normal 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
|
||||
55
ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
Normal file
55
ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = '눈을 뜬 순간'
|
||||
|
||||
|
||||
@ -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':
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user