mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
chore: split lyrics UI changes
This commit is contained in:
parent
58e6369544
commit
101ebc4738
@ -9,7 +9,6 @@ export const PLAYER_SET_VOLUME = 'PLAYER_SET_VOLUME'
|
||||
export const PLAYER_SET_MODE = 'PLAYER_SET_MODE'
|
||||
export const TRANSCODING_SET_PROFILE = 'TRANSCODING_SET_PROFILE'
|
||||
export const PLAYER_REFRESH_QUEUE = 'PLAYER_REFRESH_QUEUE'
|
||||
export const PLAYER_UPDATE_LYRIC = 'PLAYER_UPDATE_LYRIC'
|
||||
|
||||
export const setTrack = (data) => ({
|
||||
type: PLAYER_SET_TRACK,
|
||||
@ -115,8 +114,3 @@ export const refreshQueue = (resolvedUrls) => ({
|
||||
type: PLAYER_REFRESH_QUEUE,
|
||||
data: resolvedUrls,
|
||||
})
|
||||
|
||||
export const updateQueueLyric = (trackId, lyric) => ({
|
||||
type: PLAYER_UPDATE_LYRIC,
|
||||
data: { trackId, lyric },
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,514 +0,0 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import KaraokeLyricsOverlay from './KaraokeLyricsOverlay'
|
||||
|
||||
const DEFAULT_LINE_HEIGHT_TEXT = '1.30'
|
||||
const NEXT_LINE_HEIGHT_TEXT = '1.32'
|
||||
|
||||
const audioInstance = {
|
||||
currentTime: 0,
|
||||
paused: true,
|
||||
seeking: false,
|
||||
playbackRate: 1,
|
||||
}
|
||||
|
||||
const buildLyric = (kind, lang, value) => ({
|
||||
kind,
|
||||
lang,
|
||||
synced: true,
|
||||
line: [{ start: 1000, value }],
|
||||
})
|
||||
|
||||
const renderOverlay = (props = {}) =>
|
||||
render(
|
||||
<KaraokeLyricsOverlay
|
||||
visible={true}
|
||||
mainLyric={buildLyric('main', 'ja', 'こんにちは')}
|
||||
translationLyric={buildLyric('translation', 'en', 'Hello')}
|
||||
pronunciationLyric={buildLyric('pronunciation', 'ja-Latn', 'konnichiwa')}
|
||||
showTranslation={false}
|
||||
showPronunciation={true}
|
||||
translationEnabled={true}
|
||||
pronunciationEnabled={true}
|
||||
onToggleTranslation={() => {}}
|
||||
onTogglePronunciation={() => {}}
|
||||
audioInstance={audioInstance}
|
||||
onClose={() => {}}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
|
||||
describe('<KaraokeLyricsOverlay /> behavior', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
window.innerWidth = 1200
|
||||
window.innerHeight = 900
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('shows tooltips for translation, pronunciation, and appearance controls', async () => {
|
||||
renderOverlay()
|
||||
|
||||
fireEvent.mouseOver(screen.getByTestId('lyrics-language-badge-tr'))
|
||||
expect(await screen.findByText('Show translation')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseOver(screen.getByTestId('lyrics-language-badge-pr'))
|
||||
expect(await screen.findByText('Hide pronunciation')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseOver(screen.getByTestId('lyrics-settings-button'))
|
||||
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',
|
||||
JSON.stringify({
|
||||
tr: { fontSize: 16, colorKey: 'blue' },
|
||||
main: { fontSize: 26, colorKey: 'white' },
|
||||
pr: { fontSize: 15, colorKey: 'green' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderOverlay()
|
||||
|
||||
fireEvent.click(screen.getByTestId('lyrics-settings-button'))
|
||||
|
||||
expect(await screen.findByText('Appearance')).toBeInTheDocument()
|
||||
expect(screen.getByText('Main', { selector: 'div' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Default')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('lyrics-reset-appearance')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('lyrics-line-height-value')).toHaveTextContent(
|
||||
DEFAULT_LINE_HEIGHT_TEXT,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the lyric group in main, pronunciation, translation order with layer badges', () => {
|
||||
renderOverlay({
|
||||
showTranslation: true,
|
||||
showPronunciation: true,
|
||||
})
|
||||
|
||||
const mainLine = screen.getByText('こんにちは')
|
||||
const pronunciationLine = screen.getByText('konnichiwa')
|
||||
const translationLine = screen.getByText('Hello')
|
||||
|
||||
expect(
|
||||
mainLine.compareDocumentPosition(pronunciationLine) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
pronunciationLine.compareDocumentPosition(translationLine) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
|
||||
expect(screen.getByTestId('lyrics-language-badge-main')).toHaveTextContent(
|
||||
'Mainja',
|
||||
)
|
||||
expect(screen.getByTestId('lyrics-language-badge-pr')).toHaveTextContent(
|
||||
'PRja-Latn',
|
||||
)
|
||||
expect(screen.getByTestId('lyrics-language-badge-tr')).toHaveTextContent(
|
||||
'TRen',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders line-timed rows as whole-line spans without synthetic token splits', () => {
|
||||
renderOverlay({
|
||||
mainLyric: {
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 1000, end: 2400, value: 'Batter up, batter up, batter up' },
|
||||
],
|
||||
},
|
||||
translationLyric: {
|
||||
kind: 'translation',
|
||||
lang: 'ja',
|
||||
synced: true,
|
||||
line: [
|
||||
{
|
||||
start: 1000,
|
||||
end: 2400,
|
||||
value: 'バッターアップ、バッターアップ、バッターアップ',
|
||||
},
|
||||
],
|
||||
},
|
||||
pronunciationLyric: {
|
||||
kind: 'pronunciation',
|
||||
lang: 'ja-Latn',
|
||||
synced: true,
|
||||
line: [
|
||||
{
|
||||
start: 1000,
|
||||
end: 2400,
|
||||
value: 'Battaa appu, battaa appu, battaa appu',
|
||||
},
|
||||
],
|
||||
},
|
||||
showTranslation: true,
|
||||
showPronunciation: true,
|
||||
})
|
||||
|
||||
const mainLine = screen.getByText(
|
||||
'Batter up, batter up, batter up',
|
||||
).parentElement
|
||||
const pronunciationLine = screen.getByText(
|
||||
'Battaa appu, battaa appu, battaa appu',
|
||||
).parentElement
|
||||
const translationLine = screen.getByText(
|
||||
'バッターアップ、バッターアップ、バッターアップ',
|
||||
).parentElement
|
||||
|
||||
expect(mainLine.querySelectorAll('span')).toHaveLength(1)
|
||||
expect(pronunciationLine.querySelectorAll('span')).toHaveLength(1)
|
||||
expect(translationLine.querySelectorAll('span')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses cue byte offsets to segment repeated words in the karaoke line', () => {
|
||||
renderOverlay({
|
||||
mainLyric: {
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 2400, value: 'Oh love love me tonight' }],
|
||||
cueLine: [
|
||||
{
|
||||
index: 0,
|
||||
start: 0,
|
||||
end: 2400,
|
||||
value: 'Oh love love me tonight',
|
||||
cue: [
|
||||
{ start: 0, end: 300, value: 'Oh', byteStart: 0, byteEnd: 1 },
|
||||
{
|
||||
start: 900,
|
||||
end: 1300,
|
||||
value: 'love',
|
||||
byteStart: 8,
|
||||
byteEnd: 11,
|
||||
},
|
||||
{
|
||||
start: 1300,
|
||||
end: 1600,
|
||||
value: 'me',
|
||||
byteStart: 13,
|
||||
byteEnd: 14,
|
||||
},
|
||||
{
|
||||
start: 1600,
|
||||
end: 2400,
|
||||
value: 'tonight',
|
||||
byteStart: 16,
|
||||
byteEnd: 22,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
translationLyric: null,
|
||||
pronunciationLyric: null,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
translationEnabled: false,
|
||||
pronunciationEnabled: false,
|
||||
audioInstance: {
|
||||
...audioInstance,
|
||||
currentTime: 1.0,
|
||||
},
|
||||
})
|
||||
|
||||
const mainLine = screen.getByText('Oh').parentElement
|
||||
const segments = Array.from(mainLine.querySelectorAll('span')).map(
|
||||
(span) => span.textContent,
|
||||
)
|
||||
|
||||
expect(segments).toEqual([
|
||||
'Oh',
|
||||
' love ',
|
||||
'love',
|
||||
' ',
|
||||
'me',
|
||||
' ',
|
||||
'tonight',
|
||||
])
|
||||
})
|
||||
|
||||
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: {
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 1000, end: 1800, value: 'Line one' },
|
||||
{ start: 2500, end: 3300, value: 'Line two' },
|
||||
],
|
||||
},
|
||||
translationLyric: {
|
||||
kind: 'translation',
|
||||
lang: 'ja',
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 1000, end: 1800, value: '一行目' },
|
||||
{ start: 2500, end: 3300, value: '二行目' },
|
||||
],
|
||||
},
|
||||
pronunciationLyric: {
|
||||
kind: 'pronunciation',
|
||||
lang: 'ja-Latn',
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 1000, end: 1800, value: 'ichigyoume' },
|
||||
{ start: 2500, end: 3300, value: 'nigyoume' },
|
||||
],
|
||||
},
|
||||
showTranslation: true,
|
||||
showPronunciation: true,
|
||||
audioInstance: {
|
||||
...audioInstance,
|
||||
currentTime: 1.2,
|
||||
},
|
||||
})
|
||||
|
||||
const activePronunciation = screen.getByText('ichigyoume').parentElement
|
||||
const inactivePronunciation = screen.getByText('nigyoume').parentElement
|
||||
const activeTranslation = screen.getByText('一行目').parentElement
|
||||
const inactiveTranslation = screen.getByText('二行目').parentElement
|
||||
|
||||
expect(parseFloat(activePronunciation.style.opacity)).toBeGreaterThan(
|
||||
parseFloat(inactivePronunciation.style.opacity),
|
||||
)
|
||||
expect(parseFloat(activeTranslation.style.opacity)).toBeGreaterThan(
|
||||
parseFloat(inactiveTranslation.style.opacity),
|
||||
)
|
||||
})
|
||||
|
||||
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: {
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: false,
|
||||
line: [{ value: 'First plain line' }, { value: 'Second plain line' }],
|
||||
},
|
||||
translationLyric: null,
|
||||
pronunciationLyric: null,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
translationEnabled: false,
|
||||
pronunciationEnabled: false,
|
||||
})
|
||||
|
||||
const firstLine = screen.getByText('First plain line').parentElement
|
||||
const secondLine = screen.getByText('Second plain line').parentElement
|
||||
|
||||
expect(firstLine.style.opacity).toBe('1')
|
||||
expect(secondLine.style.opacity).toBe('1')
|
||||
expect(firstLine.style.color).toBe(secondLine.style.color)
|
||||
})
|
||||
|
||||
it('persists line height changes, keeps aux line spacing fixed, and stores overlay height', async () => {
|
||||
renderOverlay({
|
||||
mainLyric: buildLyric('main', 'en', 'Hello world'),
|
||||
translationLyric: buildLyric('translation', 'es', 'Hola'),
|
||||
pronunciationLyric: buildLyric('pronunciation', 'en-Latn', 'heh-loh'),
|
||||
showTranslation: true,
|
||||
showPronunciation: true,
|
||||
translationEnabled: true,
|
||||
pronunciationEnabled: true,
|
||||
})
|
||||
|
||||
const overlay = screen.getByTestId('karaoke-lyrics-overlay')
|
||||
const mainLine = screen.getByText('Hello world').parentElement
|
||||
const pronunciationLine = screen.getByText('heh-loh').parentElement
|
||||
expect(mainLine).toHaveStyle(`line-height: ${DEFAULT_LINE_HEIGHT_TEXT}`)
|
||||
expect(pronunciationLine).toHaveStyle('line-height: 1.2')
|
||||
|
||||
fireEvent.click(screen.getByTestId('lyrics-settings-button'))
|
||||
|
||||
const slider = screen.getByRole('slider', { name: 'Line height' })
|
||||
slider.focus()
|
||||
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('lyrics-line-height-value')).toHaveTextContent(
|
||||
NEXT_LINE_HEIGHT_TEXT,
|
||||
),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mainLine).toHaveStyle(`line-height: ${NEXT_LINE_HEIGHT_TEXT}`),
|
||||
)
|
||||
expect(pronunciationLine).toHaveStyle('line-height: 1.2')
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('lyrics-resize-handle'), {
|
||||
clientY: 400,
|
||||
})
|
||||
fireEvent.mouseMove(window, { clientY: 360 })
|
||||
fireEvent.mouseUp(window)
|
||||
|
||||
await waitFor(() => expect(overlay).toHaveStyle('height: 340px'))
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('karaoke-lyrics-settings'))
|
||||
expect(stored.lineHeight).toBeCloseTo(1.32, 2)
|
||||
expect(stored.overlayHeight).toBe(340)
|
||||
})
|
||||
|
||||
it('resets appearance back to the default spacing and overlay height', async () => {
|
||||
localStorage.setItem(
|
||||
'karaoke-lyrics-settings',
|
||||
JSON.stringify({
|
||||
lineHeight: 1.8,
|
||||
overlayHeight: 420,
|
||||
tr: { fontSize: 16, colorKey: 'yellow' },
|
||||
main: { fontSize: 28, colorKey: 'cyan' },
|
||||
pr: { fontSize: 15, colorKey: 'pink' },
|
||||
}),
|
||||
)
|
||||
|
||||
renderOverlay({
|
||||
mainLyric: buildLyric('main', 'en', 'Hello world'),
|
||||
translationLyric: null,
|
||||
pronunciationLyric: null,
|
||||
showPronunciation: false,
|
||||
translationEnabled: false,
|
||||
pronunciationEnabled: false,
|
||||
})
|
||||
|
||||
const overlay = screen.getByTestId('karaoke-lyrics-overlay')
|
||||
const mainLine = screen.getByText('Hello world').parentElement
|
||||
expect(overlay).toHaveStyle('height: 420px')
|
||||
expect(mainLine).toHaveStyle('line-height: 1.8')
|
||||
|
||||
fireEvent.click(screen.getByTestId('lyrics-settings-button'))
|
||||
fireEvent.click(screen.getByTestId('lyrics-reset-appearance'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('lyrics-line-height-value')).toHaveTextContent(
|
||||
DEFAULT_LINE_HEIGHT_TEXT,
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(overlay).toHaveStyle('height: 300px'))
|
||||
await waitFor(() =>
|
||||
expect(mainLine).toHaveStyle(`line-height: ${DEFAULT_LINE_HEIGHT_TEXT}`),
|
||||
)
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('karaoke-lyrics-settings'))
|
||||
expect(stored.lineHeight).toBeCloseTo(1.3, 2)
|
||||
expect(stored.overlayHeight).toBe(300)
|
||||
})
|
||||
})
|
||||
@ -1,65 +0,0 @@
|
||||
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
|
||||
@ -1,55 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -23,7 +23,6 @@ import {
|
||||
refreshQueue,
|
||||
setPlayMode,
|
||||
setTranscodingProfile,
|
||||
updateQueueLyric,
|
||||
setVolume,
|
||||
syncQueue,
|
||||
} from '../actions'
|
||||
@ -35,30 +34,6 @@ import { keyMap } from '../hotkeys'
|
||||
import keyHandlers from './keyHandlers'
|
||||
import { calculateGain } from '../utils/calculateReplayGain'
|
||||
import { detectBrowserProfile, decisionService } from '../transcode'
|
||||
import {
|
||||
getPreferredLyricLanguage,
|
||||
hasStructuredLyricContent,
|
||||
selectLyricLayers,
|
||||
structuredLyricToLrc,
|
||||
} from './lyrics'
|
||||
import {
|
||||
resolveLyricsOverlayState,
|
||||
togglePronunciationPreference,
|
||||
} from './lyricsOverlayState'
|
||||
import KaraokeLyricsOverlay from './KaraokeLyricsOverlay'
|
||||
import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
|
||||
|
||||
const emptyLyricLayers = {
|
||||
main: null,
|
||||
translation: null,
|
||||
pronunciation: null,
|
||||
}
|
||||
|
||||
const normalizeLyricLayers = (layers) => ({
|
||||
main: layers?.main || null,
|
||||
translation: layers?.translation || null,
|
||||
pronunciation: layers?.pronunciation || null,
|
||||
})
|
||||
|
||||
const Player = () => {
|
||||
const theme = useCurrentTheme()
|
||||
@ -163,83 +138,6 @@ const Player = () => {
|
||||
const gainInfo = useSelector((state) => state.replayGain)
|
||||
const [context, setContext] = useState(null)
|
||||
const [gainNode, setGainNode] = useState(null)
|
||||
const lyricCacheRef = useRef(new Map())
|
||||
const lyricRequestIdRef = useRef(0)
|
||||
const playerRef = useRef(null)
|
||||
const [karaokeVisiblePreference, setKaraokeVisiblePreference] =
|
||||
useState(false)
|
||||
const [selectedLyricLayers, setSelectedLyricLayers] =
|
||||
useState(emptyLyricLayers)
|
||||
const [translationPreference, setTranslationPreference] = useState(false)
|
||||
const [pronunciationPreference, setPronunciationPreference] = useState(null)
|
||||
const currentTrackId = playerState.current?.trackId
|
||||
const currentTrackIsRadio = playerState.current?.isRadio
|
||||
const selectedStructuredLyric = selectedLyricLayers.main
|
||||
const hasKaraokeLyric = hasStructuredLyricContent(selectedStructuredLyric)
|
||||
const hasTranslationLyric = hasStructuredLyricContent(
|
||||
selectedLyricLayers.translation,
|
||||
)
|
||||
const hasPronunciationLyric = hasStructuredLyricContent(
|
||||
selectedLyricLayers.pronunciation,
|
||||
)
|
||||
const { karaokeVisible, showTranslation, showPronunciation } =
|
||||
resolveLyricsOverlayState({
|
||||
karaokeVisiblePreference,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasKaraokeLyric,
|
||||
hasTranslationLyric,
|
||||
hasPronunciationLyric,
|
||||
})
|
||||
const useInlineMobileLyrics = karaokeVisible && !isDesktop
|
||||
|
||||
const applyLyricToRuntimePlayer = useCallback((trackId, lyric) => {
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
|
||||
const player = playerRef.current
|
||||
if (!player || typeof player.setState !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
player.setState((prevState) => {
|
||||
const prevLists = Array.isArray(prevState.audioLists)
|
||||
? prevState.audioLists
|
||||
: []
|
||||
let changed = false
|
||||
const audioLists = prevLists.map((item) => {
|
||||
if (item.trackId !== trackId) {
|
||||
return item
|
||||
}
|
||||
if (item.lyric === lyric) {
|
||||
return item
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...item,
|
||||
lyric,
|
||||
}
|
||||
})
|
||||
|
||||
const currentItem = audioLists.find(
|
||||
(item) => item.musicSrc === prevState.musicSrc,
|
||||
)
|
||||
const currentLyric =
|
||||
typeof currentItem?.lyric === 'string'
|
||||
? currentItem.lyric
|
||||
: prevState.lyric
|
||||
|
||||
if (!changed && currentLyric === prevState.lyric) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
audioLists,
|
||||
lyric: currentLyric,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -304,88 +202,6 @@ const Player = () => {
|
||||
}
|
||||
}, [playerState, audioInstance])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrackId || currentTrackIsRadio) {
|
||||
setSelectedLyricLayers(emptyLyricLayers)
|
||||
return
|
||||
}
|
||||
|
||||
const cached = lyricCacheRef.current.get(currentTrackId)
|
||||
let layers = emptyLyricLayers
|
||||
if (cached && typeof cached !== 'string') {
|
||||
if (cached.layers) {
|
||||
layers = normalizeLyricLayers(cached.layers)
|
||||
} else if (cached.structuredLyric) {
|
||||
layers = normalizeLyricLayers({
|
||||
main: cached.structuredLyric,
|
||||
})
|
||||
}
|
||||
}
|
||||
setSelectedLyricLayers(layers)
|
||||
}, [currentTrackId, currentTrackIsRadio])
|
||||
|
||||
useEffect(() => {
|
||||
lyricRequestIdRef.current += 1
|
||||
const requestId = lyricRequestIdRef.current
|
||||
|
||||
if (!currentTrackId || currentTrackIsRadio) {
|
||||
return
|
||||
}
|
||||
|
||||
const cached = lyricCacheRef.current.get(currentTrackId)
|
||||
if (cached !== undefined) {
|
||||
const cachedLyric =
|
||||
typeof cached === 'string' ? cached : cached?.lrc || ''
|
||||
const cachedLayers =
|
||||
typeof cached === 'string'
|
||||
? emptyLyricLayers
|
||||
: cached?.layers
|
||||
? normalizeLyricLayers(cached.layers)
|
||||
: normalizeLyricLayers({ main: cached?.structuredLyric })
|
||||
|
||||
setSelectedLyricLayers(cachedLayers)
|
||||
if (cachedLyric) {
|
||||
dispatch(updateQueueLyric(currentTrackId, cachedLyric))
|
||||
applyLyricToRuntimePlayer(currentTrackId, cachedLyric)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
subsonic
|
||||
.getLyricsBySongId(currentTrackId)
|
||||
.then((resp) => {
|
||||
if (lyricRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
const structuredLyrics =
|
||||
resp?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || []
|
||||
const layers = selectLyricLayers(
|
||||
structuredLyrics,
|
||||
getPreferredLyricLanguage(),
|
||||
)
|
||||
const lyric = layers.main ? structuredLyricToLrc(layers.main) : ''
|
||||
lyricCacheRef.current.set(currentTrackId, {
|
||||
lrc: lyric,
|
||||
layers,
|
||||
})
|
||||
setSelectedLyricLayers(layers)
|
||||
|
||||
if (lyric !== '') {
|
||||
dispatch(updateQueueLyric(currentTrackId, lyric))
|
||||
applyLyricToRuntimePlayer(currentTrackId, lyric)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (lyricRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
setSelectedLyricLayers(emptyLyricLayers)
|
||||
// Do not cache network/request failures as empty lyrics, so we can retry.
|
||||
lyricCacheRef.current.delete(currentTrackId)
|
||||
})
|
||||
}, [dispatch, currentTrackId, currentTrackIsRadio, applyLyricToRuntimePlayer])
|
||||
|
||||
const defaultOptions = useMemo(
|
||||
() => ({
|
||||
theme: playerTheme,
|
||||
@ -397,7 +213,7 @@ const Player = () => {
|
||||
clearPriorAudioLists: false,
|
||||
showDestroy: true,
|
||||
showDownload: false,
|
||||
showLyric: false,
|
||||
showLyric: true,
|
||||
showReload: false,
|
||||
toggleMode: !isDesktop,
|
||||
glassBg: false,
|
||||
@ -435,26 +251,12 @@ const Player = () => {
|
||||
(playerState.clear || playerState.playIndex === 0),
|
||||
clearPriorAudioLists: playerState.clear,
|
||||
extendsContent: (
|
||||
<PlayerToolbar
|
||||
id={current.trackId}
|
||||
isRadio={current.isRadio}
|
||||
onToggleLyrics={() =>
|
||||
setKaraokeVisiblePreference((visible) => !visible)
|
||||
}
|
||||
lyricsActive={karaokeVisible}
|
||||
lyricsDisabled={!hasKaraokeLyric}
|
||||
/>
|
||||
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
|
||||
),
|
||||
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
||||
showMediaSession: !current.isRadio,
|
||||
}
|
||||
}, [
|
||||
playerState,
|
||||
defaultOptions,
|
||||
isMobilePlayer,
|
||||
karaokeVisible,
|
||||
hasKaraokeLyric,
|
||||
])
|
||||
}, [playerState, defaultOptions, isMobilePlayer])
|
||||
|
||||
const onAudioListsChange = useCallback(
|
||||
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
|
||||
@ -576,13 +378,10 @@ 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) => {
|
||||
@ -640,7 +439,6 @@ const Player = () => {
|
||||
return (
|
||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||
<ReactJkMusicPlayer
|
||||
ref={playerRef}
|
||||
{...options}
|
||||
className={classes.player}
|
||||
onAudioListsChange={onAudioListsChange}
|
||||
@ -657,55 +455,6 @@ const Player = () => {
|
||||
onBeforeDestroy={onBeforeDestroy}
|
||||
getAudioInstance={setAudioInstance}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
import {
|
||||
resolveLyricsOverlayState,
|
||||
togglePronunciationPreference,
|
||||
} from './lyricsOverlayState'
|
||||
|
||||
describe('Player lyrics state helpers', () => {
|
||||
it('keeps the lyrics window preference across track changes in the session', () => {
|
||||
const visibleOnCurrentTrack = resolveLyricsOverlayState({
|
||||
karaokeVisiblePreference: true,
|
||||
translationPreference: false,
|
||||
pronunciationPreference: null,
|
||||
hasKaraokeLyric: true,
|
||||
hasTranslationLyric: true,
|
||||
hasPronunciationLyric: true,
|
||||
})
|
||||
expect(visibleOnCurrentTrack.karaokeVisible).toBe(true)
|
||||
|
||||
const hiddenForTrackWithoutLyrics = resolveLyricsOverlayState({
|
||||
karaokeVisiblePreference: true,
|
||||
translationPreference: false,
|
||||
pronunciationPreference: null,
|
||||
hasKaraokeLyric: false,
|
||||
hasTranslationLyric: false,
|
||||
hasPronunciationLyric: false,
|
||||
})
|
||||
expect(hiddenForTrackWithoutLyrics.karaokeVisible).toBe(false)
|
||||
|
||||
const restoredOnNextLyricsTrack = resolveLyricsOverlayState({
|
||||
karaokeVisiblePreference: true,
|
||||
translationPreference: false,
|
||||
pronunciationPreference: null,
|
||||
hasKaraokeLyric: true,
|
||||
hasTranslationLyric: false,
|
||||
hasPronunciationLyric: false,
|
||||
})
|
||||
expect(restoredOnNextLyricsTrack.karaokeVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('restores translation and pronunciation preferences after tracks without those layers', () => {
|
||||
const initialState = resolveLyricsOverlayState({
|
||||
karaokeVisiblePreference: false,
|
||||
translationPreference: false,
|
||||
pronunciationPreference: null,
|
||||
hasKaraokeLyric: true,
|
||||
hasTranslationLyric: true,
|
||||
hasPronunciationLyric: true,
|
||||
})
|
||||
expect(initialState.showTranslation).toBe(false)
|
||||
expect(initialState.showPronunciation).toBe(true)
|
||||
|
||||
const translationPreference = true
|
||||
const pronunciationPreference = togglePronunciationPreference(null, true)
|
||||
expect(pronunciationPreference).toBe(false)
|
||||
|
||||
const hiddenOnTrackWithoutAuxLayers = resolveLyricsOverlayState({
|
||||
karaokeVisiblePreference: false,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasKaraokeLyric: true,
|
||||
hasTranslationLyric: false,
|
||||
hasPronunciationLyric: false,
|
||||
})
|
||||
expect(hiddenOnTrackWithoutAuxLayers.showTranslation).toBe(false)
|
||||
expect(hiddenOnTrackWithoutAuxLayers.showPronunciation).toBe(false)
|
||||
|
||||
const restoredOnTrackWithAuxLayers = resolveLyricsOverlayState({
|
||||
karaokeVisiblePreference: false,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasKaraokeLyric: true,
|
||||
hasTranslationLyric: true,
|
||||
hasPronunciationLyric: true,
|
||||
})
|
||||
expect(restoredOnTrackWithAuxLayers.showTranslation).toBe(true)
|
||||
expect(restoredOnTrackWithAuxLayers.showPronunciation).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -4,9 +4,7 @@ import { useGetOne } from 'react-admin'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import Tooltip from '@material-ui/core/Tooltip'
|
||||
import { RiSaveLine } from 'react-icons/ri'
|
||||
import { RiFileMusicLine } from 'react-icons/ri'
|
||||
import { LoveButton, useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import { keyMap } from '../hotkeys'
|
||||
@ -57,13 +55,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const PlayerToolbar = ({
|
||||
id,
|
||||
isRadio,
|
||||
onToggleLyrics,
|
||||
lyricsActive = false,
|
||||
lyricsDisabled = false,
|
||||
}) => {
|
||||
const PlayerToolbar = ({ id, isRadio }) => {
|
||||
const dispatch = useDispatch()
|
||||
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
|
||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||
@ -107,25 +99,6 @@ const PlayerToolbar = ({
|
||||
/>
|
||||
)
|
||||
|
||||
const toggleLyricsButton = (
|
||||
<Tooltip title="Toggle lyrics">
|
||||
<span>
|
||||
<IconButton
|
||||
size={isDesktop ? 'small' : undefined}
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!onToggleLyrics || lyricsDisabled}
|
||||
data-testid="toggle-lyrics-button"
|
||||
className={buttonClass}
|
||||
color={lyricsActive ? 'primary' : 'default'}
|
||||
>
|
||||
<RiFileMusicLine
|
||||
className={!isDesktop ? classes.mobileIcon : undefined}
|
||||
/>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||
@ -133,13 +106,11 @@ const PlayerToolbar = ({
|
||||
<li className={`${listItemClass} item`}>
|
||||
{saveQueueButton}
|
||||
{loveButton}
|
||||
{toggleLyricsButton}
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||
<li className={`${listItemClass} item`}>{toggleLyricsButton}</li>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -71,7 +71,6 @@ describe('<PlayerToolbar />', () => {
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
@ -103,14 +102,6 @@ describe('<PlayerToolbar />', () => {
|
||||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
|
||||
it('triggers lyric toggle callback when lyrics button is clicked', () => {
|
||||
const onToggleLyrics = vi.fn()
|
||||
render(<PlayerToolbar id="song-1" onToggleLyrics={onToggleLyrics} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('toggle-lyrics-button'))
|
||||
expect(onToggleLyrics).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
@ -123,12 +114,11 @@ describe('<PlayerToolbar />', () => {
|
||||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(3)
|
||||
expect(listItems).toHaveLength(2)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
@ -150,13 +140,6 @@ describe('<PlayerToolbar />', () => {
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables lyrics button when lyrics are unavailable', () => {
|
||||
render(<PlayerToolbar id="song-1" lyricsDisabled={true} />)
|
||||
|
||||
const lyricsButton = screen.getByTestId('toggle-lyrics-button')
|
||||
expect(lyricsButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
|
||||
@ -1,725 +0,0 @@
|
||||
const normalizeLanguageTag = (language) =>
|
||||
(language || '').toLowerCase().replace('_', '-')
|
||||
|
||||
// Roughly one 60fps frame; keeps line/token switching stable near tight boundaries.
|
||||
const KARAOKE_SWITCH_EPSILON_MS = 50
|
||||
const LYRIC_KIND_MAIN = 'main'
|
||||
const LYRIC_KIND_TRANSLATION = 'translation'
|
||||
const LYRIC_KIND_PRONUNCIATION = 'pronunciation'
|
||||
|
||||
const padTime = (value) => {
|
||||
const str = value.toString()
|
||||
return str.length === 1 ? `0${str}` : str
|
||||
}
|
||||
|
||||
const toTime = (value) => {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
|
||||
const toByteOffset = (value) => {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const numeric = Number(value)
|
||||
if (!Number.isInteger(numeric) || numeric < 0) {
|
||||
return null
|
||||
}
|
||||
return numeric
|
||||
}
|
||||
|
||||
const compareNullableTime = (a, b) => {
|
||||
if (a == null && b == null) {
|
||||
return 0
|
||||
}
|
||||
if (a == null) {
|
||||
return 1
|
||||
}
|
||||
if (b == null) {
|
||||
return -1
|
||||
}
|
||||
return a - b
|
||||
}
|
||||
|
||||
const sortTokensByStart = (tokens) =>
|
||||
tokens
|
||||
.map((token, order) => ({ ...token, order }))
|
||||
.sort((a, b) => {
|
||||
const byStart = compareNullableTime(a.start, b.start)
|
||||
if (byStart !== 0) {
|
||||
return byStart
|
||||
}
|
||||
const byEnd = compareNullableTime(a.end, b.end)
|
||||
if (byEnd !== 0) {
|
||||
return byEnd
|
||||
}
|
||||
return a.order - b.order
|
||||
})
|
||||
.map(({ order, ...token }) => token)
|
||||
|
||||
const languageMatch = (candidate, preferred) => {
|
||||
if (!candidate || !preferred) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
candidate === preferred ||
|
||||
candidate.startsWith(`${preferred}-`) ||
|
||||
preferred.startsWith(`${candidate}-`)
|
||||
)
|
||||
}
|
||||
|
||||
const hasTimedLines = (lyric) =>
|
||||
lyric &&
|
||||
lyric.synced &&
|
||||
Array.isArray(lyric.line) &&
|
||||
lyric.line.some((line) => Number.isFinite(Number(line.start)))
|
||||
|
||||
const preferTimedLyrics = (lyrics) => {
|
||||
const timed = lyrics.filter(hasTimedLines)
|
||||
return timed.length > 0 ? timed : lyrics
|
||||
}
|
||||
|
||||
const normalizeToken = (token) => {
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
const value = typeof token.value === 'string' ? token.value : ''
|
||||
if (value.length === 0) {
|
||||
return null
|
||||
}
|
||||
const byteStart = toByteOffset(token.byteStart)
|
||||
const byteEnd = toByteOffset(token.byteEnd)
|
||||
return {
|
||||
start: toTime(token.start),
|
||||
end: toTime(token.end),
|
||||
value,
|
||||
...(byteStart != null ? { byteStart } : {}),
|
||||
...(byteEnd != null ? { byteEnd } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const utf8BytesForCodePoint = (codePoint) => {
|
||||
if (codePoint <= 0x7f) {
|
||||
return 1
|
||||
}
|
||||
if (codePoint <= 0x7ff) {
|
||||
return 2
|
||||
}
|
||||
if (codePoint <= 0xffff) {
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
export const utf8ByteOffsetToCodeUnitIndex = (text, targetByteOffset) => {
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const target = toByteOffset(targetByteOffset)
|
||||
if (target == null || target <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let byteOffset = 0
|
||||
let index = 0
|
||||
while (index < text.length) {
|
||||
if (byteOffset >= target) {
|
||||
return index
|
||||
}
|
||||
const codePoint = text.codePointAt(index)
|
||||
byteOffset += utf8BytesForCodePoint(codePoint)
|
||||
index += codePoint > 0xffff ? 2 : 1
|
||||
}
|
||||
|
||||
return text.length
|
||||
}
|
||||
|
||||
export const utf8ByteRangeToCodeUnitRange = (text, byteStart, byteEnd) => {
|
||||
if (typeof text !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const start = toByteOffset(byteStart)
|
||||
const end = toByteOffset(byteEnd)
|
||||
if (start == null || end == null || end < start) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startIndex = utf8ByteOffsetToCodeUnitIndex(text, start)
|
||||
const endIndex = utf8ByteOffsetToCodeUnitIndex(text, end + 1)
|
||||
if (
|
||||
startIndex >= endIndex ||
|
||||
startIndex > text.length ||
|
||||
endIndex > text.length
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
start: startIndex,
|
||||
end: endIndex,
|
||||
text: text.slice(startIndex, endIndex),
|
||||
}
|
||||
}
|
||||
|
||||
const buildAgentLookup = (structuredLyric) => {
|
||||
const lookup = new Map()
|
||||
const agents = Array.isArray(structuredLyric?.agents)
|
||||
? structuredLyric.agents
|
||||
: []
|
||||
for (const agent of agents) {
|
||||
const id = typeof agent?.id === 'string' ? agent.id : ''
|
||||
if (!id || lookup.has(id)) {
|
||||
continue
|
||||
}
|
||||
lookup.set(id, {
|
||||
id,
|
||||
role: typeof agent?.role === 'string' ? agent.role : '',
|
||||
name: typeof agent?.name === 'string' ? agent.name : '',
|
||||
})
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
const deriveUiRole = (agent) => {
|
||||
if (!agent?.role || agent.role === 'main') {
|
||||
return ''
|
||||
}
|
||||
return agent.role
|
||||
}
|
||||
|
||||
const normalizeCueLine = (cueLine, fallbackIndex, agentLookup) => {
|
||||
const index = Number.isFinite(Number(cueLine?.index))
|
||||
? Number(cueLine.index)
|
||||
: fallbackIndex
|
||||
const agentId = typeof cueLine?.agentId === 'string' ? cueLine.agentId : ''
|
||||
const agent = agentId ? agentLookup.get(agentId) || null : null
|
||||
const fallbackRole = typeof cueLine?.role === 'string' ? cueLine.role : ''
|
||||
const tokens = sortTokensByStart(
|
||||
Array.isArray(cueLine?.cue)
|
||||
? cueLine.cue.map(normalizeToken).filter(Boolean)
|
||||
: [],
|
||||
)
|
||||
|
||||
return {
|
||||
index,
|
||||
start: toTime(cueLine?.start),
|
||||
end: toTime(cueLine?.end),
|
||||
value: typeof cueLine?.value === 'string' ? cueLine.value : '',
|
||||
role: agent ? deriveUiRole(agent) : fallbackRole,
|
||||
agentId,
|
||||
agentRole: agent?.role || fallbackRole,
|
||||
agentName: agent?.name || '',
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeLyricKind = (kind) => {
|
||||
const normalized = (kind || '').toLowerCase().trim()
|
||||
switch (normalized) {
|
||||
case LYRIC_KIND_TRANSLATION:
|
||||
return LYRIC_KIND_TRANSLATION
|
||||
case LYRIC_KIND_PRONUNCIATION:
|
||||
return LYRIC_KIND_PRONUNCIATION
|
||||
default:
|
||||
return LYRIC_KIND_MAIN
|
||||
}
|
||||
}
|
||||
|
||||
const pickLyricByLanguage = (lyrics, preferredLanguage) => {
|
||||
if (!Array.isArray(lyrics) || lyrics.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const preferred = normalizeLanguageTag(preferredLanguage)
|
||||
const preferredBase = preferred.split('-')[0]
|
||||
|
||||
return (
|
||||
lyrics.find((lyric) =>
|
||||
languageMatch(normalizeLanguageTag(lyric.lang), preferred),
|
||||
) ||
|
||||
lyrics.find((lyric) =>
|
||||
languageMatch(normalizeLanguageTag(lyric.lang), preferredBase),
|
||||
) ||
|
||||
lyrics.find((lyric) =>
|
||||
languageMatch(normalizeLanguageTag(lyric.lang), 'en'),
|
||||
) ||
|
||||
lyrics[0]
|
||||
)
|
||||
}
|
||||
|
||||
const lineTimeWindow = (lines, index) => {
|
||||
const line = lines[index]
|
||||
if (!line) {
|
||||
return { start: null, end: null }
|
||||
}
|
||||
|
||||
const start = toTime(line.start)
|
||||
const end = toTime(line.end) ?? toTime(lines[index + 1]?.start)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export const hasCueTiming = (structuredLyric) =>
|
||||
Boolean(
|
||||
structuredLyric &&
|
||||
Array.isArray(structuredLyric.cueLine) &&
|
||||
structuredLyric.cueLine.some(
|
||||
(cueLine) =>
|
||||
Array.isArray(cueLine?.cue) &&
|
||||
cueLine.cue.some((cue) => Number.isFinite(Number(cue?.start))),
|
||||
),
|
||||
)
|
||||
|
||||
export const hasStructuredLyricContent = (structuredLyric) =>
|
||||
Boolean(
|
||||
structuredLyric &&
|
||||
((Array.isArray(structuredLyric.line) &&
|
||||
structuredLyric.line.some(
|
||||
(line) => typeof line?.value === 'string' && line.value.trim() !== '',
|
||||
)) ||
|
||||
hasCueTiming(structuredLyric)),
|
||||
)
|
||||
|
||||
export const getPreferredLyricLanguage = () => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
const stored = window.localStorage.getItem('locale')
|
||||
if (stored) {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
return navigator.language
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export const selectLyricLayers = (structuredLyrics, preferredLanguage) => {
|
||||
if (!Array.isArray(structuredLyrics)) {
|
||||
return {
|
||||
main: null,
|
||||
translation: null,
|
||||
pronunciation: null,
|
||||
}
|
||||
}
|
||||
|
||||
const available = structuredLyrics.filter(hasStructuredLyricContent)
|
||||
if (available.length === 0) {
|
||||
return {
|
||||
main: null,
|
||||
translation: null,
|
||||
pronunciation: null,
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = {
|
||||
[LYRIC_KIND_MAIN]: [],
|
||||
[LYRIC_KIND_TRANSLATION]: [],
|
||||
[LYRIC_KIND_PRONUNCIATION]: [],
|
||||
}
|
||||
|
||||
for (const lyric of available) {
|
||||
grouped[normalizeLyricKind(lyric?.kind)].push(lyric)
|
||||
}
|
||||
|
||||
const mainCandidates = grouped[LYRIC_KIND_MAIN].length
|
||||
? grouped[LYRIC_KIND_MAIN]
|
||||
: available
|
||||
|
||||
return {
|
||||
main: pickLyricByLanguage(
|
||||
preferTimedLyrics(mainCandidates),
|
||||
preferredLanguage,
|
||||
),
|
||||
translation: pickLyricByLanguage(
|
||||
preferTimedLyrics(grouped[LYRIC_KIND_TRANSLATION]),
|
||||
preferredLanguage,
|
||||
),
|
||||
pronunciation: pickLyricByLanguage(
|
||||
preferTimedLyrics(grouped[LYRIC_KIND_PRONUNCIATION]),
|
||||
preferredLanguage,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export const pickStructuredLyric = (structuredLyrics, preferredLanguage) =>
|
||||
selectLyricLayers(structuredLyrics, preferredLanguage).main
|
||||
|
||||
export const structuredLyricToLrc = (structuredLyric) => {
|
||||
if (!structuredLyric || !Array.isArray(structuredLyric.line)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let lyricText = ''
|
||||
for (const line of structuredLyric.line) {
|
||||
const start = Number(line.start)
|
||||
if (!Number.isFinite(start) || start < 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
let time = Math.floor(start / 10)
|
||||
const ms = time % 100
|
||||
time = Math.floor(time / 100)
|
||||
const sec = time % 60
|
||||
time = Math.floor(time / 60)
|
||||
const min = time % 60
|
||||
|
||||
lyricText += `[${padTime(min)}:${padTime(sec)}.${padTime(ms)}] ${line.value || ''}\n`
|
||||
}
|
||||
return lyricText
|
||||
}
|
||||
|
||||
export const structuredLyricsToLrc = (structuredLyrics, preferredLanguage) => {
|
||||
const selected = pickStructuredLyric(structuredLyrics, preferredLanguage)
|
||||
if (!selected) {
|
||||
return ''
|
||||
}
|
||||
return structuredLyricToLrc(selected)
|
||||
}
|
||||
|
||||
const buildBaseKaraokeLines = (baseLines) =>
|
||||
baseLines.map((line, index) => ({
|
||||
index,
|
||||
start: toTime(line.start),
|
||||
end: toTime(line.end),
|
||||
value: typeof line.value === 'string' ? line.value : '',
|
||||
tokens: [],
|
||||
}))
|
||||
|
||||
export const buildKaraokeLinesFromCueLines = (
|
||||
rawCueLines,
|
||||
baseLines,
|
||||
agentLookup,
|
||||
) => {
|
||||
const normalizedCueLines = rawCueLines.map((cueLine, fallbackIndex) => {
|
||||
const normalized = normalizeCueLine(cueLine, fallbackIndex, agentLookup)
|
||||
return {
|
||||
...normalized,
|
||||
tokens: normalized.tokens.map((token) => ({
|
||||
...token,
|
||||
role: normalized.role,
|
||||
agentId: normalized.agentId,
|
||||
agentName: normalized.agentName,
|
||||
agentRole: normalized.agentRole,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const byIndex = new Map()
|
||||
for (const cueLine of normalizedCueLines) {
|
||||
if (!byIndex.has(cueLine.index)) {
|
||||
byIndex.set(cueLine.index, [])
|
||||
}
|
||||
byIndex.get(cueLine.index).push(cueLine)
|
||||
}
|
||||
|
||||
return Array.from(byIndex.entries()).map(([index, group]) => {
|
||||
const first = group[0]
|
||||
const baseLine = baseLines[index] || {}
|
||||
const tokens = sortTokensByStart(group.flatMap((cueLine) => cueLine.tokens))
|
||||
const fallbackStart =
|
||||
tokens.find((token) => token.start != null)?.start ?? null
|
||||
const fallbackEnd =
|
||||
[...tokens].reverse().find((token) => token.end != null)?.end ?? null
|
||||
const value =
|
||||
first.value ||
|
||||
(typeof baseLine.value === 'string' ? baseLine.value : '') ||
|
||||
tokens.map((token) => token.value).join('')
|
||||
|
||||
return {
|
||||
index,
|
||||
start: first.start ?? toTime(baseLine.start) ?? fallbackStart,
|
||||
end: first.end ?? toTime(baseLine.end) ?? fallbackEnd,
|
||||
value,
|
||||
agentId: first.agentId,
|
||||
agentName: first.agentName,
|
||||
agentRole: first.agentRole,
|
||||
tokens,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const buildKaraokeLines = (structuredLyric) => {
|
||||
if (!structuredLyric) {
|
||||
return []
|
||||
}
|
||||
|
||||
const agentLookup = buildAgentLookup(structuredLyric)
|
||||
const baseLines = Array.isArray(structuredLyric.line)
|
||||
? structuredLyric.line
|
||||
: []
|
||||
const rawCueLines = Array.isArray(structuredLyric.cueLine)
|
||||
? structuredLyric.cueLine
|
||||
: []
|
||||
|
||||
const lines =
|
||||
rawCueLines.length > 0
|
||||
? buildKaraokeLinesFromCueLines(rawCueLines, baseLines, agentLookup)
|
||||
: buildBaseKaraokeLines(baseLines)
|
||||
|
||||
const normalized = lines
|
||||
.filter((line) => line.value || line.tokens.length > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.start == null && b.start == null) {
|
||||
return a.index - b.index
|
||||
}
|
||||
if (a.start == null) {
|
||||
return 1
|
||||
}
|
||||
if (b.start == null) {
|
||||
return -1
|
||||
}
|
||||
if (a.start !== b.start) {
|
||||
return a.start - b.start
|
||||
}
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
if (normalized[i].end == null) {
|
||||
const nextStart = normalized[i + 1]?.start
|
||||
if (nextStart != null) {
|
||||
normalized[i].end = nextStart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export const resolveKaraokeTokenWindow = (
|
||||
line,
|
||||
tokenIndex,
|
||||
lineEndFallback = null,
|
||||
) => {
|
||||
const tokens = Array.isArray(line?.tokens) ? line.tokens : []
|
||||
const token = tokens[tokenIndex]
|
||||
if (!token) {
|
||||
return { start: null, end: null }
|
||||
}
|
||||
|
||||
const prevToken = tokenIndex > 0 ? tokens[tokenIndex - 1] : null
|
||||
const nextToken =
|
||||
tokenIndex + 1 < tokens.length ? tokens[tokenIndex + 1] : null
|
||||
|
||||
const lineStart = toTime(line?.start)
|
||||
const lineEnd = toTime(line?.end) ?? toTime(lineEndFallback)
|
||||
const tokenCount = tokens.length
|
||||
const hasLineWindow =
|
||||
lineStart != null &&
|
||||
lineEnd != null &&
|
||||
Number.isFinite(lineStart) &&
|
||||
Number.isFinite(lineEnd) &&
|
||||
lineEnd > lineStart
|
||||
const estimatedStart =
|
||||
hasLineWindow && tokenCount > 0
|
||||
? lineStart + ((lineEnd - lineStart) * tokenIndex) / tokenCount
|
||||
: null
|
||||
const estimatedEnd =
|
||||
hasLineWindow && tokenCount > 0
|
||||
? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
|
||||
: null
|
||||
|
||||
let explicitStartCount = 0
|
||||
let explicitEndCount = 0
|
||||
const uniqueStarts = new Set()
|
||||
const uniqueEnds = new Set()
|
||||
|
||||
for (let i = 0; i < tokenCount; i += 1) {
|
||||
const explicitStart = toTime(tokens[i]?.start)
|
||||
if (explicitStart != null) {
|
||||
explicitStartCount += 1
|
||||
uniqueStarts.add(explicitStart)
|
||||
}
|
||||
|
||||
const explicitEnd = toTime(tokens[i]?.end)
|
||||
if (explicitEnd != null) {
|
||||
explicitEndCount += 1
|
||||
uniqueEnds.add(explicitEnd)
|
||||
}
|
||||
}
|
||||
|
||||
const collapsedStarts =
|
||||
explicitStartCount > 1 && uniqueStarts.size <= Math.max(1, tokenCount / 4)
|
||||
const collapsedEnds =
|
||||
explicitEndCount > 1 && uniqueEnds.size <= Math.max(1, tokenCount / 4)
|
||||
const shouldForceEstimated =
|
||||
hasLineWindow && tokenCount > 1 && (collapsedStarts || collapsedEnds)
|
||||
|
||||
if (shouldForceEstimated) {
|
||||
return {
|
||||
start: estimatedStart,
|
||||
end: estimatedEnd,
|
||||
}
|
||||
}
|
||||
const prevEnd = toTime(prevToken?.end) ?? toTime(prevToken?.start)
|
||||
|
||||
let start = toTime(token.start)
|
||||
if (start == null) {
|
||||
start = prevEnd ?? estimatedStart ?? lineStart
|
||||
}
|
||||
|
||||
let end = toTime(token.end)
|
||||
if (end == null) {
|
||||
const nextDirectStart = toTime(nextToken?.start)
|
||||
const nextEstimatedStart =
|
||||
hasLineWindow && tokenIndex + 1 < tokenCount
|
||||
? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
|
||||
: null
|
||||
end = nextDirectStart ?? nextEstimatedStart ?? estimatedEnd ?? lineEnd
|
||||
}
|
||||
|
||||
if (
|
||||
tokenCount === 1 &&
|
||||
hasLineWindow &&
|
||||
(start == null || end == null || end <= start + 1)
|
||||
) {
|
||||
start = lineStart
|
||||
end = lineEnd
|
||||
}
|
||||
|
||||
if (start != null && end != null && end < start) {
|
||||
end = start
|
||||
}
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export const getActiveKaraokeState = (lines, currentTimeMs) => {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
return { lineIndex: -1, tokenIndex: -1 }
|
||||
}
|
||||
|
||||
const current = Number.isFinite(Number(currentTimeMs))
|
||||
? Number(currentTimeMs)
|
||||
: 0
|
||||
let lineIndex = 0
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const lineStart = toTime(lines[i]?.start)
|
||||
if (lineStart == null || lineStart <= current + KARAOKE_SWITCH_EPSILON_MS) {
|
||||
lineIndex = i
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for (let i = lineIndex; i >= 0; i -= 1) {
|
||||
const lineStart = toTime(lines[i]?.start)
|
||||
const lineEnd = toTime(lines[i]?.end) ?? toTime(lines[i + 1]?.start)
|
||||
if (lineStart != null && current + KARAOKE_SWITCH_EPSILON_MS < lineStart) {
|
||||
continue
|
||||
}
|
||||
if (lineEnd == null || current <= lineEnd + KARAOKE_SWITCH_EPSILON_MS) {
|
||||
lineIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const activeLine = lines[lineIndex] || null
|
||||
const tokens = Array.isArray(activeLine?.tokens) ? activeLine.tokens : []
|
||||
let tokenIndex = -1
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const { start: tokenStart, end: tokenEnd } = resolveKaraokeTokenWindow(
|
||||
activeLine,
|
||||
i,
|
||||
lines[lineIndex + 1]?.start,
|
||||
)
|
||||
if (
|
||||
tokenStart == null ||
|
||||
tokenStart <= current + KARAOKE_SWITCH_EPSILON_MS
|
||||
) {
|
||||
tokenIndex = i
|
||||
if (tokenEnd != null && current <= tokenEnd + KARAOKE_SWITCH_EPSILON_MS) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return { lineIndex, tokenIndex }
|
||||
}
|
||||
|
||||
export const hasUsableKaraokeTiming = (lines) =>
|
||||
Array.isArray(lines) &&
|
||||
lines.some(
|
||||
(line) =>
|
||||
toTime(line?.start) != null ||
|
||||
(Array.isArray(line?.tokens) &&
|
||||
line.tokens.some(
|
||||
(token) => toTime(token?.start) != null || toTime(token?.end) != null,
|
||||
)),
|
||||
)
|
||||
|
||||
export const findLayerLineIndexForMain = (mainLines, layerLines, mainIndex) => {
|
||||
if (
|
||||
!Array.isArray(mainLines) ||
|
||||
!Array.isArray(layerLines) ||
|
||||
mainLines.length === 0 ||
|
||||
layerLines.length === 0 ||
|
||||
mainIndex < 0 ||
|
||||
mainIndex >= mainLines.length
|
||||
) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const { start: mainStart, end: mainEnd } = lineTimeWindow(
|
||||
mainLines,
|
||||
mainIndex,
|
||||
)
|
||||
|
||||
if (mainStart == null) {
|
||||
return -1
|
||||
}
|
||||
const mainWindowEnd = mainEnd ?? mainStart
|
||||
const mainWindowDuration = Math.max(0, mainWindowEnd - mainStart)
|
||||
const maxDelta = Math.max(550, Math.min(1400, mainWindowDuration + 420))
|
||||
|
||||
let bestIdx = -1
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
|
||||
for (let i = 0; i < layerLines.length; i += 1) {
|
||||
const { start, end } = lineTimeWindow(layerLines, i)
|
||||
|
||||
if (start != null && end != null) {
|
||||
const overlap = Math.min(end, mainEnd ?? end) - Math.max(start, mainStart)
|
||||
if (overlap >= 0) {
|
||||
const score = Math.abs(start - mainStart) + Math.abs(i - mainIndex) * 30
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
bestIdx = i
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (start != null) {
|
||||
if (Math.abs(start - mainStart) > maxDelta) {
|
||||
continue
|
||||
}
|
||||
const score = Math.abs(start - mainStart) + Math.abs(i - mainIndex) * 45
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
export const resolveLayerLineForMain = (mainLines, layerLines, mainIndex) => {
|
||||
const index = findLayerLineIndexForMain(mainLines, layerLines, mainIndex)
|
||||
return {
|
||||
index,
|
||||
line: index >= 0 ? layerLines[index] : null,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildHighlightedMainLine = (line) => line
|
||||
|
||||
export const buildHighlightedAuxLine = (_referenceLine, auxiliaryLine) =>
|
||||
auxiliaryLine ?? null
|
||||
@ -1,786 +0,0 @@
|
||||
import {
|
||||
buildHighlightedAuxLine,
|
||||
buildHighlightedMainLine,
|
||||
buildKaraokeLines,
|
||||
buildKaraokeLinesFromCueLines,
|
||||
findLayerLineIndexForMain,
|
||||
getActiveKaraokeState,
|
||||
getPreferredLyricLanguage,
|
||||
hasUsableKaraokeTiming,
|
||||
hasStructuredLyricContent,
|
||||
pickStructuredLyric,
|
||||
resolveKaraokeTokenWindow,
|
||||
resolveLayerLineForMain,
|
||||
selectLyricLayers,
|
||||
structuredLyricsToLrc,
|
||||
structuredLyricToLrc,
|
||||
utf8ByteOffsetToCodeUnitIndex,
|
||||
utf8ByteRangeToCodeUnitRange,
|
||||
} from './lyrics'
|
||||
|
||||
describe('lyrics helpers', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('prefers a lyric track that matches the locale', () => {
|
||||
const selected = pickStructuredLyric(
|
||||
[
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'English line' }],
|
||||
},
|
||||
{
|
||||
lang: 'pt-BR',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Linha em portugues' }],
|
||||
},
|
||||
],
|
||||
'pt-BR',
|
||||
)
|
||||
|
||||
expect(selected.lang).toBe('pt-BR')
|
||||
})
|
||||
|
||||
it('falls back to english when preferred locale is not available', () => {
|
||||
const selected = pickStructuredLyric(
|
||||
[
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'English line' }],
|
||||
},
|
||||
{
|
||||
lang: 'deu',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Deutsche Zeile' }],
|
||||
},
|
||||
],
|
||||
'pt-BR',
|
||||
)
|
||||
|
||||
expect(selected.lang).toBe('eng')
|
||||
})
|
||||
|
||||
it('falls back to first synced track when english is missing', () => {
|
||||
const selected = pickStructuredLyric(
|
||||
[
|
||||
{
|
||||
lang: 'jpn',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Nihongo' }],
|
||||
},
|
||||
{
|
||||
lang: 'deu',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Deutsch' }],
|
||||
},
|
||||
],
|
||||
'pt-BR',
|
||||
)
|
||||
|
||||
expect(selected.lang).toBe('jpn')
|
||||
})
|
||||
|
||||
it('selects translation and pronunciation layers by kind', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
{
|
||||
kind: 'main',
|
||||
lang: 'ja',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'こんにちは' }],
|
||||
},
|
||||
{
|
||||
kind: 'translation',
|
||||
lang: 'es',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Hola' }],
|
||||
},
|
||||
{
|
||||
kind: 'pronunciation',
|
||||
lang: 'ja-Latn',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'konnichiwa' }],
|
||||
},
|
||||
],
|
||||
'es-MX',
|
||||
)
|
||||
|
||||
expect(layers.main.lang).toBe('ja')
|
||||
expect(layers.translation.lang).toBe('es')
|
||||
expect(layers.pronunciation.lang).toBe('ja-Latn')
|
||||
})
|
||||
|
||||
it('treats missing kind as main for backward compatibility', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Main' }],
|
||||
},
|
||||
],
|
||||
'eng',
|
||||
)
|
||||
|
||||
expect(layers.main.lang).toBe('eng')
|
||||
expect(layers.translation).toBeNull()
|
||||
expect(layers.pronunciation).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to unsynced lyric content when no timed track exists', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: false,
|
||||
line: [{ value: 'Plain embedded lyric' }],
|
||||
},
|
||||
],
|
||||
'eng',
|
||||
)
|
||||
|
||||
expect(layers.main).toEqual({
|
||||
lang: 'eng',
|
||||
synced: false,
|
||||
line: [{ value: 'Plain embedded lyric' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('still prefers timed lyrics when both timed and untimed tracks exist', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: false,
|
||||
line: [{ value: 'Plain lyric' }],
|
||||
},
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Timed lyric' }],
|
||||
},
|
||||
],
|
||||
'eng',
|
||||
)
|
||||
|
||||
expect(layers.main).toEqual({
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Timed lyric' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches layer line by timing for the active main line', () => {
|
||||
const mainLines = [
|
||||
{ index: 0, start: 1000, end: 1800, value: 'Line A', tokens: [] },
|
||||
{ index: 1, start: 2000, end: 2800, value: 'Line B', tokens: [] },
|
||||
]
|
||||
const layerLines = [
|
||||
{ index: 0, start: 900, end: 1750, value: 'A2', tokens: [] },
|
||||
{ index: 1, start: 2050, end: 2900, value: 'B2', tokens: [] },
|
||||
]
|
||||
|
||||
expect(findLayerLineIndexForMain(mainLines, layerLines, 1)).toBe(1)
|
||||
expect(resolveLayerLineForMain(mainLines, layerLines, 0).line.value).toBe(
|
||||
'A2',
|
||||
)
|
||||
})
|
||||
|
||||
it('matches metadata layers by nearest timing even when indexes differ', () => {
|
||||
const mainLines = [
|
||||
{ index: 0, start: 1000, end: 1800, value: 'Line A', tokens: [] },
|
||||
{ index: 1, start: 2000, end: 2800, value: 'Line B', tokens: [] },
|
||||
{ index: 2, start: 3000, end: 3800, value: 'Line C', tokens: [] },
|
||||
]
|
||||
const layerLines = [
|
||||
{ index: 2, start: 3020, end: 3820, value: 'C2', tokens: [] },
|
||||
{ index: 0, start: 980, end: 1760, value: 'A2', tokens: [] },
|
||||
{ index: 1, start: 2010, end: 2810, value: 'B2', tokens: [] },
|
||||
]
|
||||
|
||||
expect(findLayerLineIndexForMain(mainLines, layerLines, 1)).toBe(2)
|
||||
expect(resolveLayerLineForMain(mainLines, layerLines, 2).line.value).toBe(
|
||||
'C2',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps translation lines line-level when they do not have real cue timing', () => {
|
||||
const mainLine = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 2200,
|
||||
value: '불을 질러라',
|
||||
tokens: [
|
||||
{ start: 1000, end: 1300, value: '불을 ' },
|
||||
{ start: 1300, end: 1650, value: '질' },
|
||||
{ start: 1650, end: 2200, value: '러라' },
|
||||
],
|
||||
}
|
||||
const translationLine = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 2200,
|
||||
value: 'Set it on fire',
|
||||
tokens: [],
|
||||
}
|
||||
|
||||
const highlighted = buildHighlightedAuxLine(mainLine, translationLine, 2600)
|
||||
|
||||
expect(highlighted).toBe(translationLine)
|
||||
expect(highlighted.tokens).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps pronunciation lines line-level when they do not have real cue timing', () => {
|
||||
const mainLine = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 2200,
|
||||
value: 'You もっと強く 素早く 吹き飛ばせ',
|
||||
tokens: [],
|
||||
}
|
||||
const pronunciationLine = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 2200,
|
||||
value: 'You motto tsuyoku subayaku fukitobase',
|
||||
tokens: [],
|
||||
}
|
||||
|
||||
const highlighted = buildHighlightedAuxLine(
|
||||
mainLine,
|
||||
pronunciationLine,
|
||||
2600,
|
||||
)
|
||||
|
||||
expect(highlighted).toBe(pronunciationLine)
|
||||
expect(highlighted.tokens).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps main lines line-level when they do not have real cue timing', () => {
|
||||
const line = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 2200,
|
||||
value: 'Youもっと強く 素早く 吹き飛ばせ',
|
||||
tokens: [],
|
||||
}
|
||||
|
||||
const highlighted = buildHighlightedMainLine(line, 2600)
|
||||
|
||||
expect(highlighted).toBe(line)
|
||||
expect(highlighted.tokens).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps auxiliary lines line-level when end time is missing and they lack cues', () => {
|
||||
const mainLine = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: null,
|
||||
value: 'Hello there',
|
||||
tokens: [],
|
||||
}
|
||||
const translationLine = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: null,
|
||||
value: 'Bonjour toi',
|
||||
tokens: [],
|
||||
}
|
||||
|
||||
const highlighted = buildHighlightedAuxLine(mainLine, translationLine, 2400)
|
||||
|
||||
expect(highlighted).toBe(translationLine)
|
||||
expect(highlighted.tokens).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps main lines line-level when end time is missing and they lack cues', () => {
|
||||
const line = {
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: null,
|
||||
value: 'One more time',
|
||||
tokens: [],
|
||||
}
|
||||
|
||||
const highlighted = buildHighlightedMainLine(line, 2400)
|
||||
|
||||
expect(highlighted).toBe(line)
|
||||
expect(highlighted.tokens).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no layer match when the nearest line is too far in time', () => {
|
||||
const mainLines = [
|
||||
{ index: 0, start: 1000, end: 1800, value: 'Line A', tokens: [] },
|
||||
{ index: 1, start: 2000, end: 2800, value: 'Line B', tokens: [] },
|
||||
]
|
||||
const layerLines = [
|
||||
{ index: 0, start: 60000, end: 60800, value: 'Far line', tokens: [] },
|
||||
]
|
||||
|
||||
expect(findLayerLineIndexForMain(mainLines, layerLines, 1)).toBe(-1)
|
||||
expect(resolveLayerLineForMain(mainLines, layerLines, 1).line).toBeNull()
|
||||
})
|
||||
|
||||
it('converts a structured lyric track to LRC', () => {
|
||||
const lrc = structuredLyricToLrc({
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 18800, value: "We're no strangers to love" },
|
||||
{ start: 22801, value: 'You know the rules and so do I' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(lrc).toBe(
|
||||
"[00:18.80] We're no strangers to love\n[00:22.80] You know the rules and so do I\n",
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty text when no synced lyrics are available', () => {
|
||||
const lrc = structuredLyricsToLrc(
|
||||
[{ lang: 'eng', synced: false, line: [{ value: 'Unsynced line' }] }],
|
||||
'eng',
|
||||
)
|
||||
|
||||
expect(lrc).toBe('')
|
||||
})
|
||||
|
||||
it('reads preferred language from localStorage first', () => {
|
||||
localStorage.setItem('locale', 'pt-BR')
|
||||
expect(getPreferredLyricLanguage()).toBe('pt-BR')
|
||||
})
|
||||
|
||||
it('builds karaoke lines from agent-based cueLine payload', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, end: 3000, value: 'Hello world' }],
|
||||
agents: [
|
||||
{ id: 'lead', role: 'main', name: 'Lead Vocal' },
|
||||
{ id: 'backing', role: 'bg' },
|
||||
],
|
||||
cueLine: [
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
agentId: 'lead',
|
||||
cue: [{ start: 1000, end: 1500, value: 'Hello' }],
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
agentId: 'backing',
|
||||
cue: [{ start: 2000, end: 2500, value: 'world' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
agentId: 'lead',
|
||||
agentName: 'Lead Vocal',
|
||||
agentRole: 'main',
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
tokens: [
|
||||
{
|
||||
start: 1000,
|
||||
end: 1500,
|
||||
value: 'Hello',
|
||||
role: '',
|
||||
agentId: 'lead',
|
||||
agentName: 'Lead Vocal',
|
||||
agentRole: 'main',
|
||||
},
|
||||
{
|
||||
start: 2000,
|
||||
end: 2500,
|
||||
value: 'world',
|
||||
role: 'bg',
|
||||
agentId: 'backing',
|
||||
agentName: '',
|
||||
agentRole: 'bg',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('builds grouped karaoke lines directly from cue lines', () => {
|
||||
const agentLookup = new Map([
|
||||
['lead', { id: 'lead', role: 'main', name: 'Lead Vocal' }],
|
||||
['backing', { id: 'backing', role: 'bg', name: '' }],
|
||||
])
|
||||
|
||||
const lines = buildKaraokeLinesFromCueLines(
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
agentId: 'lead',
|
||||
cue: [{ start: 1000, end: 1500, value: 'Hello' }],
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
agentId: 'backing',
|
||||
cue: [{ start: 2000, end: 2500, value: 'world' }],
|
||||
},
|
||||
],
|
||||
[{ start: 1000, end: 3000, value: 'Hello world' }],
|
||||
agentLookup,
|
||||
)
|
||||
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
agentId: 'lead',
|
||||
agentName: 'Lead Vocal',
|
||||
agentRole: 'main',
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
tokens: [
|
||||
{
|
||||
start: 1000,
|
||||
end: 1500,
|
||||
value: 'Hello',
|
||||
role: '',
|
||||
agentId: 'lead',
|
||||
agentName: 'Lead Vocal',
|
||||
agentRole: 'main',
|
||||
},
|
||||
{
|
||||
start: 2000,
|
||||
end: 2500,
|
||||
value: 'world',
|
||||
role: 'bg',
|
||||
agentId: 'backing',
|
||||
agentName: '',
|
||||
agentRole: 'bg',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves cue byte offsets on karaoke tokens', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 2400, value: 'Oh love love me tonight' }],
|
||||
cueLine: [
|
||||
{
|
||||
index: 0,
|
||||
start: 0,
|
||||
end: 2400,
|
||||
value: 'Oh love love me tonight',
|
||||
cue: [
|
||||
{ start: 0, end: 300, value: 'Oh', byteStart: 0, byteEnd: 1 },
|
||||
{ start: 900, end: 1300, value: 'love', byteStart: 8, byteEnd: 11 },
|
||||
{ start: 1300, end: 1600, value: 'me', byteStart: 13, byteEnd: 14 },
|
||||
{
|
||||
start: 1600,
|
||||
end: 2400,
|
||||
value: 'tonight',
|
||||
byteStart: 16,
|
||||
byteEnd: 22,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(
|
||||
lines[0].tokens.map((token) => [
|
||||
token.value,
|
||||
token.byteStart,
|
||||
token.byteEnd,
|
||||
]),
|
||||
).toEqual([
|
||||
['Oh', 0, 1],
|
||||
['love', 8, 11],
|
||||
['me', 13, 14],
|
||||
['tonight', 16, 22],
|
||||
])
|
||||
})
|
||||
|
||||
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 = '눈을 뜬 순간'
|
||||
|
||||
expect(utf8ByteOffsetToCodeUnitIndex(text, 0)).toBe(0)
|
||||
expect(utf8ByteOffsetToCodeUnitIndex(text, 3)).toBe(1)
|
||||
expect(utf8ByteOffsetToCodeUnitIndex(text, 7)).toBe(3)
|
||||
expect(utf8ByteRangeToCodeUnitRange(text, 11, 16)).toEqual({
|
||||
start: 5,
|
||||
end: 7,
|
||||
text: '순간',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to legacy cueLine role values when agents are absent', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, end: 3000, value: 'Hello world' }],
|
||||
cueLine: [
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
role: 'bg',
|
||||
cue: [{ start: 1000, end: 1500, value: 'Hello' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines[0].tokens[0].role).toBe('bg')
|
||||
expect(lines[0].tokens[0].agentId).toBe('')
|
||||
expect(lines[0].tokens[0].agentName).toBe('')
|
||||
})
|
||||
|
||||
it('sorts token timing by start to keep playback stable', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, end: 3000, value: 'Hello world' }],
|
||||
cueLine: [
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
role: '',
|
||||
cue: [
|
||||
{ start: 2000, end: 2500, value: 'world' },
|
||||
{ start: 1000, end: 1500, value: 'Hello' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines[0].tokens.map((token) => token.value)).toEqual([
|
||||
'Hello',
|
||||
'world',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a single full-line token unchanged instead of expanding it synthetically', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
lang: 'ko-Latn',
|
||||
synced: true,
|
||||
line: [{ start: 1000, end: 2000, value: 'Da-la-lun, dun' }],
|
||||
cueLine: [
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
value: 'Da-la-lun, dun',
|
||||
role: '',
|
||||
cue: [{ start: 1000, end: 2000, value: 'Da-la-lun, dun' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0].tokens).toHaveLength(1)
|
||||
expect(lines[0].tokens[0].value).toBe('Da-la-lun, dun')
|
||||
|
||||
const firstWindow = resolveKaraokeTokenWindow(lines[0], 0)
|
||||
|
||||
expect(firstWindow.start).toBeCloseTo(1000)
|
||||
expect(firstWindow.end).toBeCloseTo(2000)
|
||||
})
|
||||
|
||||
it('detects active line and token for karaoke timing', () => {
|
||||
const state = getActiveKaraokeState(
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
tokens: [
|
||||
{ start: 1000, end: 1500, value: 'Hello', role: '' },
|
||||
{ start: 2000, end: 2500, value: 'world', role: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
start: 3500,
|
||||
end: 5000,
|
||||
value: 'Second line',
|
||||
tokens: [],
|
||||
},
|
||||
],
|
||||
2200,
|
||||
)
|
||||
|
||||
expect(state).toEqual({ lineIndex: 0, tokenIndex: 1 })
|
||||
})
|
||||
|
||||
it('resolves token window fallback boundaries from neighboring tokens', () => {
|
||||
const line = {
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
tokens: [
|
||||
{ start: 1200, value: 'Hello', role: '' },
|
||||
{ start: 1800, value: 'world', role: '' },
|
||||
],
|
||||
}
|
||||
|
||||
expect(resolveKaraokeTokenWindow(line, 0)).toEqual({
|
||||
start: 1200,
|
||||
end: 1800,
|
||||
})
|
||||
expect(resolveKaraokeTokenWindow(line, 1)).toEqual({
|
||||
start: 1800,
|
||||
end: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
it('infers sequential token windows when token timings are missing', () => {
|
||||
const line = {
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
value: 'A B C',
|
||||
tokens: [
|
||||
{ value: 'A', role: '' },
|
||||
{ value: 'B', role: '' },
|
||||
{ value: 'C', role: '' },
|
||||
],
|
||||
}
|
||||
|
||||
const first = resolveKaraokeTokenWindow(line, 0)
|
||||
const second = resolveKaraokeTokenWindow(line, 1)
|
||||
const third = resolveKaraokeTokenWindow(line, 2)
|
||||
|
||||
expect(first.start).toBeCloseTo(1000)
|
||||
expect(first.end).toBeCloseTo(1333.3333333333333)
|
||||
|
||||
expect(second.start).toBeCloseTo(1333.3333333333333)
|
||||
expect(second.end).toBeCloseTo(1666.6666666666667)
|
||||
|
||||
expect(third.start).toBeCloseTo(1666.6666666666667)
|
||||
expect(third.end).toBeCloseTo(2000)
|
||||
})
|
||||
|
||||
it('falls back to sequential windows when token timings are collapsed', () => {
|
||||
const line = {
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
value: 'A B C',
|
||||
tokens: [
|
||||
{ start: 1000, end: 2000, value: 'A', role: '' },
|
||||
{ start: 1000, end: 2000, value: 'B', role: '' },
|
||||
{ start: 1000, end: 2000, value: 'C', role: '' },
|
||||
],
|
||||
}
|
||||
|
||||
const first = resolveKaraokeTokenWindow(line, 0)
|
||||
const second = resolveKaraokeTokenWindow(line, 1)
|
||||
const third = resolveKaraokeTokenWindow(line, 2)
|
||||
|
||||
expect(first.start).toBeCloseTo(1000)
|
||||
expect(first.end).toBeCloseTo(1333.3333333333333)
|
||||
expect(second.start).toBeCloseTo(1333.3333333333333)
|
||||
expect(second.end).toBeCloseTo(1666.6666666666667)
|
||||
expect(third.start).toBeCloseTo(1666.6666666666667)
|
||||
expect(third.end).toBeCloseTo(2000)
|
||||
})
|
||||
|
||||
it('keeps token selection stable near tight token boundaries', () => {
|
||||
const state = getActiveKaraokeState(
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
value: 'A B',
|
||||
tokens: [
|
||||
{ start: 1000, end: 1100, value: 'A', role: '' },
|
||||
{ start: 1110, end: 1300, value: 'B', role: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
1108,
|
||||
)
|
||||
|
||||
expect(state).toEqual({ lineIndex: 0, tokenIndex: 0 })
|
||||
})
|
||||
|
||||
it('reports structured lyric content when token timing exists', () => {
|
||||
expect(
|
||||
hasStructuredLyricContent({
|
||||
cueLine: [{ cue: [{ start: 100, value: 'a' }] }],
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('detects when built karaoke lines have no usable timing', () => {
|
||||
expect(
|
||||
hasUsableKaraokeTiming([
|
||||
{ index: 0, value: 'First line', tokens: [] },
|
||||
{ index: 1, value: 'Second line', tokens: [] },
|
||||
]),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
hasUsableKaraokeTiming([
|
||||
{ index: 0, start: 1000, value: 'Timed line', tokens: [] },
|
||||
]),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -1,27 +0,0 @@
|
||||
export const resolveLyricsOverlayState = ({
|
||||
karaokeVisiblePreference,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasKaraokeLyric,
|
||||
hasTranslationLyric,
|
||||
hasPronunciationLyric,
|
||||
}) => ({
|
||||
karaokeVisible: karaokeVisiblePreference && hasKaraokeLyric,
|
||||
showTranslation: translationPreference && hasTranslationLyric,
|
||||
showPronunciation:
|
||||
(pronunciationPreference == null
|
||||
? hasPronunciationLyric
|
||||
: pronunciationPreference) && hasPronunciationLyric,
|
||||
})
|
||||
|
||||
export const togglePronunciationPreference = (
|
||||
previousPreference,
|
||||
hasPronunciationLyric,
|
||||
) => {
|
||||
if (!hasPronunciationLyric) {
|
||||
return false
|
||||
}
|
||||
const currentPreference =
|
||||
previousPreference == null ? hasPronunciationLyric : previousPreference
|
||||
return !currentPreference
|
||||
}
|
||||
@ -62,30 +62,12 @@ 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: '100%',
|
||||
maxWidth: 'none',
|
||||
height: 'clamp(280px, 42vh, 460px)',
|
||||
aspectRatio: 'auto',
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
background: 'transparent',
|
||||
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':
|
||||
{
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
PLAYER_CURRENT,
|
||||
PLAYER_PLAY_NEXT,
|
||||
PLAYER_PLAY_TRACKS,
|
||||
PLAYER_UPDATE_LYRIC,
|
||||
PLAYER_SET_TRACK,
|
||||
PLAYER_SET_VOLUME,
|
||||
PLAYER_SYNC_QUEUE,
|
||||
@ -61,25 +60,21 @@ const mapToAudioLists = (item) => {
|
||||
let lyricText = ''
|
||||
|
||||
if (lyrics) {
|
||||
try {
|
||||
const structured = JSON.parse(lyrics)
|
||||
for (const structuredLyric of structured) {
|
||||
if (structuredLyric.synced) {
|
||||
for (const line of structuredLyric.line) {
|
||||
let time = Math.floor(line.start / 10)
|
||||
const ms = time % 100
|
||||
time = Math.floor(time / 100)
|
||||
const sec = time % 60
|
||||
time = Math.floor(time / 60)
|
||||
const min = time % 60
|
||||
const structured = JSON.parse(lyrics)
|
||||
for (const structuredLyric of structured) {
|
||||
if (structuredLyric.synced) {
|
||||
for (const line of structuredLyric.line) {
|
||||
let time = Math.floor(line.start / 10)
|
||||
const ms = time % 100
|
||||
time = Math.floor(time / 100)
|
||||
const sec = time % 60
|
||||
time = Math.floor(time / 60)
|
||||
const min = time % 60
|
||||
|
||||
ms.toString()
|
||||
lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n`
|
||||
}
|
||||
ms.toString()
|
||||
lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lyricText = ''
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,45 +208,6 @@ const reduceMode = (state, { data: { mode } }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const reduceUpdateLyric = (state, { data: { trackId, lyric } }) => {
|
||||
if (!trackId) {
|
||||
return state
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const queue = state.queue.map((item) => {
|
||||
if (item.trackId !== trackId) {
|
||||
return item
|
||||
}
|
||||
if (item.lyric === lyric) {
|
||||
return item
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...item,
|
||||
lyric,
|
||||
}
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
return state
|
||||
}
|
||||
|
||||
const current =
|
||||
state.current?.trackId === trackId
|
||||
? {
|
||||
...state.current,
|
||||
lyric,
|
||||
}
|
||||
: state.current
|
||||
|
||||
return {
|
||||
...state,
|
||||
queue,
|
||||
current,
|
||||
}
|
||||
}
|
||||
|
||||
export const playerReducer = (previousState = initialState, payload) => {
|
||||
const { type } = payload
|
||||
switch (type) {
|
||||
@ -289,8 +245,6 @@ export const playerReducer = (previousState = initialState, payload) => {
|
||||
previousState.savedPlayIndex >= 0 ? previousState.savedPlayIndex : 0,
|
||||
}
|
||||
}
|
||||
case PLAYER_UPDATE_LYRIC:
|
||||
return reduceUpdateLyric(previousState, payload)
|
||||
default:
|
||||
return previousState
|
||||
}
|
||||
|
||||
@ -1,24 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { playerReducer } from './playerReducer'
|
||||
import {
|
||||
PLAYER_SYNC_QUEUE,
|
||||
PLAYER_CURRENT,
|
||||
PLAYER_REFRESH_QUEUE,
|
||||
PLAYER_SET_TRACK,
|
||||
PLAYER_SYNC_QUEUE,
|
||||
PLAYER_UPDATE_LYRIC,
|
||||
} from '../actions'
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'test-uuid',
|
||||
}))
|
||||
|
||||
vi.mock('../subsonic', () => ({
|
||||
default: {
|
||||
streamUrl: vi.fn((id) => `/rest/stream?id=${id}`),
|
||||
getCoverArtUrl: vi.fn(() => '/rest/getCoverArt?id=test'),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('playerReducer', () => {
|
||||
describe('pending track selection survives SYNC_QUEUE and premature CURRENT', () => {
|
||||
// Simulates the real sequence when clicking a new song while one is playing:
|
||||
@ -67,6 +54,8 @@ describe('playerReducer', () => {
|
||||
})
|
||||
|
||||
it('CURRENT for old track preserves pending playIndex', () => {
|
||||
// After SYNC_QUEUE, queue has new UUIDs. The old track's UUID (zzz)
|
||||
// is at index 2, but playIndex is 0. This is a premature callback.
|
||||
const stateAfterSync = {
|
||||
...stateAfterPlayTracks,
|
||||
queue: [
|
||||
@ -82,7 +71,7 @@ describe('playerReducer', () => {
|
||||
const result = playerReducer(stateAfterSync, action)
|
||||
expect(result.playIndex).toBe(0)
|
||||
expect(result.clear).toBe(true)
|
||||
expect(result.savedPlayIndex).toBe(2)
|
||||
expect(result.savedPlayIndex).toBe(2) // preserved from before
|
||||
})
|
||||
|
||||
it('CURRENT for correct track consumes pending playIndex', () => {
|
||||
@ -94,6 +83,7 @@ describe('playerReducer', () => {
|
||||
{ trackId: 's3', uuid: 'zzz', name: 'Song 3' },
|
||||
],
|
||||
}
|
||||
// Player switched to Song 1 (uuid 'xxx', index 0 == playIndex)
|
||||
const action = {
|
||||
type: PLAYER_CURRENT,
|
||||
data: { uuid: 'xxx', name: 'Song 1', volume: 1 },
|
||||
@ -234,80 +224,4 @@ describe('playerReducer', () => {
|
||||
expect(result.playIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('maps embedded synced lyrics to LRC text', () => {
|
||||
const lyrics = JSON.stringify([
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'Line one' }],
|
||||
},
|
||||
{
|
||||
lang: 'eng',
|
||||
synced: false,
|
||||
line: [{ value: 'Unsynced line' }],
|
||||
},
|
||||
])
|
||||
|
||||
const state = playerReducer(undefined, {
|
||||
type: PLAYER_SET_TRACK,
|
||||
data: {
|
||||
id: 'song-1',
|
||||
title: 'Test Song',
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
duration: 60,
|
||||
lyrics,
|
||||
},
|
||||
})
|
||||
|
||||
expect(state.queue).toHaveLength(1)
|
||||
expect(state.queue[0].lyric).toBe('[00:01.00] Line one\n')
|
||||
})
|
||||
|
||||
it('updates queue lyric by track id', () => {
|
||||
const initial = playerReducer(undefined, {
|
||||
type: PLAYER_SET_TRACK,
|
||||
data: {
|
||||
id: 'song-1',
|
||||
title: 'Test Song',
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
duration: 60,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = playerReducer(initial, {
|
||||
type: PLAYER_UPDATE_LYRIC,
|
||||
data: {
|
||||
trackId: 'song-1',
|
||||
lyric: '[00:01.00] Updated lyric\n',
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated.queue[0].lyric).toBe('[00:01.00] Updated lyric\n')
|
||||
})
|
||||
|
||||
it('returns same state when lyric update does not match any track', () => {
|
||||
const initial = playerReducer(undefined, {
|
||||
type: PLAYER_SET_TRACK,
|
||||
data: {
|
||||
id: 'song-1',
|
||||
title: 'Test Song',
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
duration: 60,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = playerReducer(initial, {
|
||||
type: PLAYER_UPDATE_LYRIC,
|
||||
data: {
|
||||
trackId: 'missing-track',
|
||||
lyric: '[00:01.00] Updated lyric\n',
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated).toBe(initial)
|
||||
})
|
||||
})
|
||||
|
||||
@ -129,10 +129,6 @@ const getTopSongs = (artist, count = 50) => {
|
||||
return httpClient(url('getTopSongs', null, { artist, count }))
|
||||
}
|
||||
|
||||
const getLyricsBySongId = (id) => {
|
||||
return httpClient(url('getLyricsBySongId', id, { enhanced: true }))
|
||||
}
|
||||
|
||||
const streamUrl = (id, options) => {
|
||||
return baseUrl(
|
||||
url('stream', id, {
|
||||
@ -162,5 +158,4 @@ export default {
|
||||
getArtistInfo,
|
||||
getTopSongs,
|
||||
getSimilarSongs2,
|
||||
getLyricsBySongId,
|
||||
}
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
import { vi } from 'vitest'
|
||||
import config from '../config'
|
||||
import { httpClient } from '../dataProvider'
|
||||
import subsonic from './index'
|
||||
|
||||
vi.mock('../dataProvider', () => ({
|
||||
httpClient: vi.fn(() => Promise.resolve({})),
|
||||
clientUniqueId: 'test-client-id',
|
||||
clientUniqueIdHeader: 'X-ND-Client-Unique-Id',
|
||||
}))
|
||||
|
||||
describe('getCoverArtUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.location
|
||||
@ -202,33 +195,6 @@ describe('getAvatarUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLyricsBySongId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
})
|
||||
|
||||
it('calls the getLyricsBySongId endpoint with enhanced=true', async () => {
|
||||
await subsonic.getLyricsBySongId('song-1')
|
||||
|
||||
expect(httpClient).toHaveBeenCalledTimes(1)
|
||||
const calledUrl = httpClient.mock.calls[0][0]
|
||||
expect(calledUrl).toContain('/rest/getLyricsBySongId?')
|
||||
expect(calledUrl).toContain('id=song-1')
|
||||
expect(calledUrl).toContain('enhanced=true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportPlayback', () => {
|
||||
beforeEach(() => {
|
||||
const localStorageMock = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user