mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-30 23:17:51 +01:00
fix(ui): stabilize lyrics rise animations
- Keep translation lift stable across timing modes, wrapping, and release states.\n- Drive a compact character wave from the shared media timeline with smoother easing. Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
46e21ac066
commit
921807956a
5 changed files with 504 additions and 110 deletions
|
|
@ -127,10 +127,14 @@ const useStyles = makeStyles((theme) => ({
|
|||
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
transition: `transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}, background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`,
|
||||
},
|
||||
'&[data-raised="true"][data-line-motion="character"][data-character-wave="false"]':
|
||||
'&[data-raised="true"][data-line-motion="character"][data-character-wave="false"] $line':
|
||||
{
|
||||
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
},
|
||||
'&[data-raised="true"][data-line-motion="character"] $translationLine': {
|
||||
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`,
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
'--lyrics-main-current-color':
|
||||
'var(--lyrics-main-active-color, var(--lyrics-main-idle-color, currentColor))',
|
||||
|
|
@ -143,15 +147,22 @@ const useStyles = makeStyles((theme) => ({
|
|||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
'&[data-line-motion="character"] $line, &[data-line-motion="character"] $translationLine':
|
||||
{
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
waveCharacter: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'baseline',
|
||||
transform: 'translateY(0)',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
transformOrigin: 'center bottom',
|
||||
willChange: 'transform',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transform: 'none !important',
|
||||
willChange: 'auto',
|
||||
},
|
||||
},
|
||||
line: {
|
||||
|
|
@ -197,14 +208,6 @@ const useStyles = makeStyles((theme) => ({
|
|||
transform: 'none',
|
||||
},
|
||||
},
|
||||
characterWaveAuxLine: {
|
||||
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`,
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
activeAuxLine: {
|
||||
opacity: KARAOKE_TRANSLATION_OPACITY,
|
||||
},
|
||||
|
|
@ -746,7 +749,10 @@ const LyricsPanel = ({
|
|||
const canSeekLine = Boolean(audioInstance && line.start != null)
|
||||
const isActiveLine = activeIndexSet.has(idx)
|
||||
const renderCharacterWave = Boolean(
|
||||
!prefersReducedMotion && hasTimedMainLines && isActiveLine,
|
||||
!prefersReducedMotion &&
|
||||
hasTimedMainLines &&
|
||||
usesCharacterRise &&
|
||||
isActiveLine,
|
||||
)
|
||||
const isStaticLine = !hasTimedMainLines
|
||||
return (
|
||||
|
|
@ -882,7 +888,6 @@ const LyricsPanel = ({
|
|||
line={buildSynchronizedTranslationLine(line, trLine)}
|
||||
nextLineStart={null}
|
||||
className={clsx(classes.auxLine, classes.translationLine, {
|
||||
[classes.characterWaveAuxLine]: renderCharacterWave,
|
||||
[classes.activeAuxLine]: isStaticLine || isActiveLine,
|
||||
})}
|
||||
style={layerStyles.translation}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ const createAudio = ({ currentTime = 0, duration = 10 } = {}) => {
|
|||
|
||||
describe('<LyricsPanel />', () => {
|
||||
const originalMatchMedia = window.matchMedia
|
||||
const originalResizeObserver = window.ResizeObserver
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0)
|
||||
|
|
@ -116,6 +117,7 @@ describe('<LyricsPanel />', () => {
|
|||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
window.matchMedia = originalMatchMedia
|
||||
window.ResizeObserver = originalResizeObserver
|
||||
cleanup()
|
||||
})
|
||||
|
||||
|
|
@ -330,18 +332,33 @@ describe('<LyricsPanel />', () => {
|
|||
it('raises a line once and keeps it elevated after release', () => {
|
||||
const { rerenderPanel } = renderPanel({
|
||||
mainLyric,
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'translated line' }],
|
||||
},
|
||||
showTranslation: true,
|
||||
audioInstance: { currentTime: 0.5, paused: true },
|
||||
})
|
||||
|
||||
const group = screen.getByTestId('lyrics-line-group')
|
||||
const translation = screen
|
||||
.getByText('translated line')
|
||||
.closest('[data-tokenized]')
|
||||
const activeStyle = window.getComputedStyle(group)
|
||||
expect(group).toHaveAttribute('data-highlight-active', 'true')
|
||||
expect(group).toHaveAttribute('data-raised', 'true')
|
||||
expect(group).toHaveAttribute('data-line-motion', 'line')
|
||||
expect(group).toHaveAttribute('data-character-wave', 'false')
|
||||
expect(activeStyle.transform).toBe(`translateY(-${KARAOKE_LINE_LIFT_PX}px)`)
|
||||
expect(window.getComputedStyle(translation).transform).toBe('')
|
||||
|
||||
rerenderPanel({
|
||||
mainLyric,
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'translated line' }],
|
||||
},
|
||||
showTranslation: true,
|
||||
audioInstance: { currentTime: 1.1, paused: true },
|
||||
})
|
||||
|
||||
|
|
@ -351,24 +368,13 @@ describe('<LyricsPanel />', () => {
|
|||
expect(releasedStyle.transform).toBe(
|
||||
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
)
|
||||
expect(window.getComputedStyle(translation).transform).toBe('')
|
||||
})
|
||||
|
||||
it('keeps every word-timed layer on the same rise and release lifecycle', () => {
|
||||
const offsetPronunciationLyric = {
|
||||
...tokenizedPronunciationLyric,
|
||||
cueLine: [
|
||||
{
|
||||
...tokenizedPronunciationLyric.cueLine[0],
|
||||
cue: [
|
||||
{ start: 100, end: 850, value: 'mein' },
|
||||
{ start: 850, end: 1000, value: 'lain' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const propsAt = (currentTime) => ({
|
||||
mainLyric: tokenizedMainLyric,
|
||||
pronunciationLyric: offsetPronunciationLyric,
|
||||
pronunciationLyric: tokenizedPronunciationLyric,
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'translated line' }],
|
||||
|
|
@ -377,7 +383,7 @@ describe('<LyricsPanel />', () => {
|
|||
showTranslation: true,
|
||||
audioInstance: { currentTime, paused: true },
|
||||
})
|
||||
const { rerenderPanel } = renderPanel(propsAt(0.25))
|
||||
const { rerenderPanel } = renderPanel(propsAt(0.15))
|
||||
|
||||
const group = screen.getByTestId('lyrics-line-group')
|
||||
const translation = screen
|
||||
|
|
@ -403,7 +409,10 @@ describe('<LyricsPanel />', () => {
|
|||
expect(mainCharacters).toHaveLength(4)
|
||||
expect(pronunciationCharacters).toHaveLength(4)
|
||||
expect(mainCharacters[0].style.transform).toBe(
|
||||
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
|
||||
`translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px, 0)`,
|
||||
)
|
||||
expect(window.getComputedStyle(mainCharacters[0]).willChange).toBe(
|
||||
'transform',
|
||||
)
|
||||
expect(mainCharacters[3].style.transform).not.toBe(
|
||||
mainCharacters[0].style.transform,
|
||||
|
|
@ -422,16 +431,103 @@ describe('<LyricsPanel />', () => {
|
|||
expect(
|
||||
group.querySelectorAll('[data-lyrics-character="true"]'),
|
||||
).toHaveLength(0)
|
||||
expect(window.getComputedStyle(group).transform).toBe(
|
||||
expect(window.getComputedStyle(group).transform).toBe('translateY(0)')
|
||||
const releasedMainRow = screen
|
||||
.getAllByTestId('lyrics-token')[0]
|
||||
.closest('[data-tokenized]')
|
||||
expect(window.getComputedStyle(releasedMainRow).transform).toBe(
|
||||
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
)
|
||||
expect(window.getComputedStyle(translation).transform).toBe(
|
||||
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
)
|
||||
expect(window.getComputedStyle(translation).transform).toBe('')
|
||||
const idleText = screen
|
||||
.getAllByTestId('lyrics-token')[0]
|
||||
.querySelector('[data-lyrics-wave-text="true"]')
|
||||
expect(idleText.style.backgroundImage).toContain('linear-gradient')
|
||||
})
|
||||
|
||||
it('keeps the translation raised when a word-timed line wraps or unwraps', () => {
|
||||
let wrapped = false
|
||||
const observers = []
|
||||
window.ResizeObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback
|
||||
this.targets = []
|
||||
observers.push(this)
|
||||
}
|
||||
|
||||
observe(target) {
|
||||
this.targets.push(target)
|
||||
}
|
||||
|
||||
unobserve(target) {
|
||||
this.targets = this.targets.filter((candidate) => candidate !== target)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.targets = []
|
||||
}
|
||||
}
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation(
|
||||
function getOffsetTop() {
|
||||
if (!wrapped || this.dataset.stackedToken !== 'true') return 0
|
||||
const tokens = Array.from(
|
||||
this.parentElement?.querySelectorAll('[data-stacked-token="true"]') ||
|
||||
[],
|
||||
)
|
||||
return tokens.indexOf(this) > 0 ? 32 : 0
|
||||
},
|
||||
)
|
||||
const notifyResizeObservers = () => {
|
||||
observers.forEach(({ callback, targets }) => {
|
||||
callback(targets.map((target) => ({ target })))
|
||||
})
|
||||
}
|
||||
const propsAt = (currentTime) => ({
|
||||
mainLyric: tokenizedMainLyric,
|
||||
pronunciationLyric: tokenizedPronunciationLyric,
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'translated line' }],
|
||||
},
|
||||
showPronunciation: true,
|
||||
showTranslation: true,
|
||||
audioInstance: { currentTime, paused: true },
|
||||
})
|
||||
const { rerenderPanel } = renderPanel(propsAt(0.25))
|
||||
const row = screen
|
||||
.getAllByTestId('lyrics-token')[0]
|
||||
.closest('[data-wrapped]')
|
||||
const translation = screen
|
||||
.getByText('translated line')
|
||||
.closest('[data-tokenized]')
|
||||
|
||||
expect(row).toHaveAttribute('data-wrapped', 'false')
|
||||
expect(window.getComputedStyle(translation).transform).toBe(
|
||||
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
)
|
||||
|
||||
wrapped = true
|
||||
act(notifyResizeObservers)
|
||||
expect(row).toHaveAttribute('data-wrapped', 'true')
|
||||
expect(window.getComputedStyle(translation).transform).toBe(
|
||||
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
)
|
||||
|
||||
rerenderPanel(propsAt(1.1))
|
||||
expect(window.getComputedStyle(translation).transform).toBe(
|
||||
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
)
|
||||
|
||||
wrapped = false
|
||||
act(notifyResizeObservers)
|
||||
expect(row).toHaveAttribute('data-wrapped', 'false')
|
||||
expect(window.getComputedStyle(translation).transform).toBe(
|
||||
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps detailed grapheme markup on active lines without changing token layout', () => {
|
||||
const values = ['First word', 'Second word', 'Active word', 'Fourth word']
|
||||
const lyricFor = (pronunciation = false) => ({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140
|
||||
export const KARAOKE_HIGHLIGHT_LEAD_MS = 120
|
||||
export const KARAOKE_ANIMATION_MS = 220
|
||||
export const KARAOKE_ANIMATION_MS = 500
|
||||
export const KARAOKE_SCROLLBAR_VISIBLE_MS = 1400
|
||||
export const KARAOKE_MANUAL_SCROLL_PAUSE_MS = 2200
|
||||
export const KARAOKE_SCROLL_ANIMATION_MS = 300
|
||||
export const KARAOKE_LINE_ENTER_MS = KARAOKE_ANIMATION_MS
|
||||
export const KARAOKE_LINE_LIFT_PX = 1.5
|
||||
export const KARAOKE_LINE_MOTION_EASING = 'cubic-bezier(0.25, 0.1, 0.25, 1)'
|
||||
const KARAOKE_EASE_IN_OUT_X1 = 0.77
|
||||
const KARAOKE_EASE_IN_OUT_Y1 = 0
|
||||
const KARAOKE_EASE_IN_OUT_X2 = 0.175
|
||||
const KARAOKE_EASE_IN_OUT_Y2 = 1
|
||||
export const KARAOKE_LINE_MOTION_EASING = `cubic-bezier(${KARAOKE_EASE_IN_OUT_X1}, ${KARAOKE_EASE_IN_OUT_Y1}, ${KARAOKE_EASE_IN_OUT_X2}, ${KARAOKE_EASE_IN_OUT_Y2})`
|
||||
export const KARAOKE_CHARACTER_LIFT_PX = 1.5
|
||||
export const KARAOKE_CHARACTER_PHASE_SPREAD = 0.36
|
||||
export const KARAOKE_CHARACTER_RISE_MS = KARAOKE_ANIMATION_MS
|
||||
export const KARAOKE_CHARACTER_WAVE_SPAN_RATIO = 0.36
|
||||
export const KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS = 140
|
||||
export const KARAOKE_TRANSLATION_OPACITY = 0.62
|
||||
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_ANIMATION_MS
|
||||
export const KARAOKE_SCROLL_PRE_ROLL_MS = 320
|
||||
|
|
@ -23,7 +29,76 @@ export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34
|
|||
export const TOKEN_WIPE_EDGE_PCT = 12
|
||||
|
||||
export const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
|
||||
export const easeInOut = (value) => {
|
||||
const clamped = clamp(value, 0, 1)
|
||||
return clamped < 0.5 ? 2 * clamped * clamped : 1 - (-2 * clamped + 2) ** 2 / 2
|
||||
|
||||
const cubicBezierCoordinate = (time, firstControl, secondControl) => {
|
||||
const inverse = 1 - time
|
||||
return (
|
||||
3 * inverse * inverse * time * firstControl +
|
||||
3 * inverse * time * time * secondControl +
|
||||
time * time * time
|
||||
)
|
||||
}
|
||||
|
||||
const cubicBezierSlope = (time, firstControl, secondControl) => {
|
||||
const inverse = 1 - time
|
||||
return (
|
||||
3 * inverse * inverse * firstControl +
|
||||
6 * inverse * time * (secondControl - firstControl) +
|
||||
3 * time * time * (1 - secondControl)
|
||||
)
|
||||
}
|
||||
|
||||
const solveStrongEaseInOutTime = (progress) => {
|
||||
let time = progress
|
||||
for (let iteration = 0; iteration < 6; iteration += 1) {
|
||||
const error =
|
||||
cubicBezierCoordinate(
|
||||
time,
|
||||
KARAOKE_EASE_IN_OUT_X1,
|
||||
KARAOKE_EASE_IN_OUT_X2,
|
||||
) - progress
|
||||
if (Math.abs(error) < 0.000001) return time
|
||||
const slope = cubicBezierSlope(
|
||||
time,
|
||||
KARAOKE_EASE_IN_OUT_X1,
|
||||
KARAOKE_EASE_IN_OUT_X2,
|
||||
)
|
||||
if (Math.abs(slope) < 0.000001) break
|
||||
time = clamp(time - error / slope, 0, 1)
|
||||
}
|
||||
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
for (let iteration = 0; iteration < 12; iteration += 1) {
|
||||
time = (lower + upper) / 2
|
||||
if (
|
||||
cubicBezierCoordinate(
|
||||
time,
|
||||
KARAOKE_EASE_IN_OUT_X1,
|
||||
KARAOKE_EASE_IN_OUT_X2,
|
||||
) < progress
|
||||
) {
|
||||
lower = time
|
||||
} else {
|
||||
upper = time
|
||||
}
|
||||
}
|
||||
return time
|
||||
}
|
||||
|
||||
export const easeKaraokeMotion = (value) => {
|
||||
const progress = clamp(value, 0, 1)
|
||||
if (progress === 0 || progress === 1) return progress
|
||||
return cubicBezierCoordinate(
|
||||
solveStrongEaseInOutTime(progress),
|
||||
KARAOKE_EASE_IN_OUT_Y1,
|
||||
KARAOKE_EASE_IN_OUT_Y2,
|
||||
)
|
||||
}
|
||||
|
||||
export const easeInOut = (value) => {
|
||||
const progress = clamp(value, 0, 1)
|
||||
return progress < 0.5
|
||||
? 2 * progress * progress
|
||||
: 1 - (-2 * progress + 2) ** 2 / 2
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ import {
|
|||
} from 'react'
|
||||
import {
|
||||
KARAOKE_CHARACTER_LIFT_PX,
|
||||
KARAOKE_CHARACTER_PHASE_SPREAD,
|
||||
KARAOKE_CHARACTER_RISE_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
|
||||
KARAOKE_CLOCK_DRIFT_RESET_MS,
|
||||
KARAOKE_HIGHLIGHT_LEAD_MS,
|
||||
KARAOKE_LINE_RELEASE_MS,
|
||||
easeKaraokeMotion,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import {
|
||||
buildLyricsTimeline,
|
||||
|
|
@ -42,33 +45,83 @@ const setProgress = (record, value) => {
|
|||
record.node.style.setProperty('--lyrics-progress', String(next))
|
||||
}
|
||||
|
||||
const smootherStep = (value) =>
|
||||
value * value * value * (value * (value * 6 - 15) + 10)
|
||||
const setCharacterTransform = (node, localProgress) => {
|
||||
const lift = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
KARAOKE_CHARACTER_LIFT_PX,
|
||||
KARAOKE_CHARACTER_LIFT_PX * easeKaraokeMotion(localProgress),
|
||||
),
|
||||
)
|
||||
if (lift < 0.00005) {
|
||||
if (node.style.transform) node.style.removeProperty('transform')
|
||||
return
|
||||
}
|
||||
const nextTransform = `translate3d(0, -${lift.toFixed(4)}px, 0)`
|
||||
if (node.style.transform !== nextTransform) {
|
||||
node.style.transform = nextTransform
|
||||
}
|
||||
}
|
||||
|
||||
const setCharacterLift = (record, progress) => {
|
||||
const resetCharacterLift = (record) => {
|
||||
const characters = record.characters || []
|
||||
if (!characters.length) return
|
||||
if (record.characterMotionState === 'future') return
|
||||
|
||||
characters.forEach(({ node }) => setCharacterTransform(node, 0))
|
||||
record.characterMotionState = 'future'
|
||||
}
|
||||
|
||||
const settleCharacterLift = (record) => {
|
||||
const characters = record.characters || []
|
||||
if (!characters.length) return
|
||||
if (record.characterMotionState === 'settled') return
|
||||
|
||||
characters.forEach(({ node }) => setCharacterTransform(node, 1))
|
||||
record.characterMotionState = 'settled'
|
||||
}
|
||||
|
||||
const setCharacterLiftAtTime = (record, time) => {
|
||||
const characters = record.characters || []
|
||||
if (!characters.length) return
|
||||
|
||||
const riseSpan = Math.max(0.001, 1 - KARAOKE_CHARACTER_PHASE_SPREAD)
|
||||
const current = Number(time)
|
||||
if (!Number.isFinite(current) || current <= record.characterMotionStart) {
|
||||
resetCharacterLift(record)
|
||||
return
|
||||
}
|
||||
if (current >= record.characterMotionEnd) {
|
||||
settleCharacterLift(record)
|
||||
return
|
||||
}
|
||||
|
||||
characters.forEach(({ node, phase }) => {
|
||||
const local = Math.max(0, Math.min(1, (progress - phase) / riseSpan))
|
||||
const lift = Math.max(
|
||||
characters.forEach(({ node, start }) => {
|
||||
const localProgress = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
KARAOKE_CHARACTER_LIFT_PX,
|
||||
KARAOKE_CHARACTER_LIFT_PX * smootherStep(local),
|
||||
),
|
||||
Math.min(1, (current - start) / KARAOKE_CHARACTER_RISE_MS),
|
||||
)
|
||||
if (lift < 0.00005) {
|
||||
if (node.style.transform) node.style.removeProperty('transform')
|
||||
return
|
||||
}
|
||||
const nextTransform = `translateY(-${lift.toFixed(4)}px)`
|
||||
if (node.style.transform !== nextTransform) {
|
||||
node.style.transform = nextTransform
|
||||
}
|
||||
setCharacterTransform(node, localProgress)
|
||||
})
|
||||
record.characterMotionState = 'moving'
|
||||
}
|
||||
|
||||
const getCharacterTiming = (window, characterCount) => {
|
||||
const start = Number(window?.start)
|
||||
const duration = Number(window?.end) - Number(window?.start)
|
||||
const safeStart = Number.isFinite(start) ? start : 0
|
||||
const safeDuration = Number.isFinite(duration) ? Math.max(0, duration) : 0
|
||||
const waveSpan =
|
||||
characterCount <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
safeDuration * KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
|
||||
)
|
||||
return {
|
||||
start: safeStart,
|
||||
waveSpan,
|
||||
end: safeStart + waveSpan + KARAOKE_CHARACTER_RISE_MS,
|
||||
}
|
||||
}
|
||||
|
||||
const setCharacterGradient = (record) => {
|
||||
|
|
@ -171,7 +224,6 @@ const applyTokenState = (record, state, progress = 0) => {
|
|||
const isPast = state === 'inactive-past'
|
||||
const nextProgress = isFuture ? 0 : state === 'active' ? progress : 1
|
||||
setProgress(record, nextProgress)
|
||||
setCharacterLift(record, nextProgress)
|
||||
setTokenOpacity(record, 1)
|
||||
setTokenActiveAlpha(
|
||||
record,
|
||||
|
|
@ -196,10 +248,13 @@ const setTokenReleasePresentation = (record, progress) => {
|
|||
record,
|
||||
activeAlpha + (targetAlpha - activeAlpha) * nextProgress,
|
||||
)
|
||||
settleCharacterLift(record)
|
||||
}
|
||||
|
||||
const resetToken = (record, state = 'future') => {
|
||||
applyTokenState(record, state, 0)
|
||||
if (state === 'future') resetCharacterLift(record)
|
||||
else settleCharacterLift(record)
|
||||
}
|
||||
|
||||
const setTokenPresentation = (record, time) => {
|
||||
|
|
@ -209,6 +264,7 @@ const setTokenPresentation = (record, time) => {
|
|||
if (record.state !== state || state === 'active') {
|
||||
applyTokenState(record, state, progress)
|
||||
}
|
||||
setCharacterLiftAtTime(record, time)
|
||||
}
|
||||
|
||||
const canListenToMedia = (audio) =>
|
||||
|
|
@ -477,12 +533,17 @@ const useLyricsTimeline = ({
|
|||
? []
|
||||
: Array.from(node.querySelectorAll('[data-lyrics-character="true"]'))
|
||||
const characterCount = characterNodes.length
|
||||
const characterTiming = getCharacterTiming(
|
||||
descriptor.window,
|
||||
characterCount,
|
||||
)
|
||||
const characters = characterNodes.map((characterNode, index) => ({
|
||||
node: characterNode,
|
||||
phase:
|
||||
start:
|
||||
characterCount <= 1
|
||||
? 0
|
||||
: (index / (characterCount - 1)) * KARAOKE_CHARACTER_PHASE_SPREAD,
|
||||
? characterTiming.start
|
||||
: characterTiming.start +
|
||||
(index / (characterCount - 1)) * characterTiming.waveSpan,
|
||||
}))
|
||||
const record = {
|
||||
key,
|
||||
|
|
@ -492,6 +553,9 @@ const useLyricsTimeline = ({
|
|||
window: descriptor.window,
|
||||
presentation: descriptor.presentation,
|
||||
characters,
|
||||
characterMotionStart: characterTiming.start,
|
||||
characterMotionEnd: characterTiming.end,
|
||||
characterMotionState: null,
|
||||
progress: null,
|
||||
opacity: null,
|
||||
activeAlpha: null,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { act } from '@testing-library/react'
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { KARAOKE_CHARACTER_LIFT_PX } from './lyricsKaraokeConstants'
|
||||
import {
|
||||
KARAOKE_CHARACTER_LIFT_PX,
|
||||
KARAOKE_CHARACTER_RISE_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
|
||||
KARAOKE_HIGHLIGHT_LEAD_MS,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import useLyricsTimeline from './useLyricsTimeline'
|
||||
|
||||
const createAudio = ({
|
||||
|
|
@ -41,6 +47,10 @@ const presentation = {
|
|||
useCrossfade: false,
|
||||
}
|
||||
|
||||
const fullCharacterLift = `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(
|
||||
4,
|
||||
)}px, 0)`
|
||||
|
||||
const createTokenNode = (text = '') => {
|
||||
const tokenNode = document.createElement('span')
|
||||
Array.from(text).forEach((character) => {
|
||||
|
|
@ -209,7 +219,7 @@ describe('useLyricsTimeline', () => {
|
|||
tokenNode
|
||||
.querySelectorAll('[data-lyrics-character="true"]')
|
||||
.forEach((character) =>
|
||||
expect(character.style.transform).toBe('translateY(-1.5000px)'),
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -223,7 +233,7 @@ describe('useLyricsTimeline', () => {
|
|||
'first',
|
||||
)
|
||||
|
||||
syncNow(result, 1219)
|
||||
syncNow(result, 1259)
|
||||
const gradient = tokenNode.style.backgroundImage
|
||||
const releaseAlpha = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
|
||||
|
|
@ -232,7 +242,7 @@ describe('useLyricsTimeline', () => {
|
|||
expect(tokenNode.style.color).toBe('transparent')
|
||||
expect(tokenNode.style.opacity).toBe('1')
|
||||
|
||||
syncNow(result, 1220)
|
||||
syncNow(result, 1260)
|
||||
|
||||
const pastAlpha = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
|
||||
|
|
@ -247,20 +257,20 @@ describe('useLyricsTimeline', () => {
|
|||
expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
|
||||
})
|
||||
|
||||
it('uses the complete token duration for the character phase wave', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 5, paused: true })
|
||||
it('completes long-token character waves while highlighting continues', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 6, paused: true })
|
||||
const longLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 4000,
|
||||
tokens: [{ start: 0, end: 4000, value: 'super' }],
|
||||
end: 5000,
|
||||
tokens: [{ start: 500, end: 4500, value: 'super' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines: longLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:long-word',
|
||||
{ start: 0, end: 4000 },
|
||||
{ start: 500, end: 4500 },
|
||||
'super',
|
||||
)
|
||||
|
||||
|
|
@ -270,77 +280,138 @@ describe('useLyricsTimeline', () => {
|
|||
expect(characters[0].style.backgroundImage).toBe(
|
||||
tokenNode.style.backgroundImage,
|
||||
)
|
||||
const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
|
||||
syncNow(result, tokenStart)
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).not.toMatch(/^translateY\(\d/),
|
||||
)
|
||||
const firstTransforms = []
|
||||
;[0, 160, 320, 480].forEach((time) => {
|
||||
syncNow(result, time)
|
||||
firstTransforms.push(characters[0].style.transform)
|
||||
})
|
||||
|
||||
expect(new Set(firstTransforms).size).toBe(firstTransforms.length)
|
||||
firstTransforms.forEach((transform) =>
|
||||
expect(transform).toMatch(/^translateY\(-\d+\.\d{4}px\)$/),
|
||||
expect(character.style.transform).toBe(''),
|
||||
)
|
||||
|
||||
syncNow(result, 1320)
|
||||
const offsets = characters.map((character) =>
|
||||
Math.abs(
|
||||
Number.parseFloat(
|
||||
character.style.transform.match(/-?\d+\.\d+/)?.[0] || '0',
|
||||
),
|
||||
),
|
||||
)
|
||||
offsets.forEach((offset) => expect(offset).toBeGreaterThanOrEqual(0))
|
||||
expect(offsets.filter((offset) => offset > 0)).toHaveLength(4)
|
||||
expect(offsets[0]).toBeGreaterThan(offsets[1])
|
||||
expect(offsets[1]).toBeGreaterThan(offsets[2])
|
||||
expect(offsets[2]).toBeGreaterThan(offsets[3])
|
||||
expect(offsets[4]).toBe(0)
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2)
|
||||
expect(characters[0].style.transform).not.toBe(fullCharacterLift)
|
||||
expect(characters[0].style.transform).not.toBe('')
|
||||
expect(characters[4].style.transform).toBe('')
|
||||
|
||||
syncNow(result, 3600)
|
||||
expect(characters[4].style.transform).not.toBe(
|
||||
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS)
|
||||
expect(characters[0].style.transform).toBe(fullCharacterLift)
|
||||
expect(characters[4].style.transform).toMatch(
|
||||
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
|
||||
)
|
||||
expect(characters[4].style.transform).not.toBe(fullCharacterLift)
|
||||
|
||||
syncNow(result, 3880)
|
||||
const completeWaveAt =
|
||||
tokenStart +
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS +
|
||||
KARAOKE_CHARACTER_RISE_MS
|
||||
syncNow(result, completeWaveAt)
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).toBe(
|
||||
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
|
||||
),
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
expect(
|
||||
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
|
||||
).toBeCloseTo(
|
||||
(KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS + KARAOKE_CHARACTER_RISE_MS) / 4000,
|
||||
5,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the normalized wave shape for short token durations', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 1, paused: true })
|
||||
it('finishes short-token motion after highlighting without dropping the lift', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
|
||||
const shortLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 180,
|
||||
tokens: [{ start: 0, end: 180, value: 'go' }],
|
||||
end: 1200,
|
||||
tokens: [{ start: 500, end: 680, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines: shortLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:short-word',
|
||||
{ start: 0, end: 180 },
|
||||
{ start: 500, end: 680 },
|
||||
'go',
|
||||
)
|
||||
syncNow(result, 0)
|
||||
|
||||
const characters = tokenNode.querySelectorAll(
|
||||
'[data-lyrics-character="true"]',
|
||||
)
|
||||
const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
|
||||
const waveSpan = 180 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO
|
||||
syncNow(result, tokenStart)
|
||||
expect(characters[0].style.transform).toBe('')
|
||||
expect(characters[1].style.transform).toBe('')
|
||||
|
||||
syncNow(result, 680 - KARAOKE_HIGHLIGHT_LEAD_MS)
|
||||
expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
|
||||
expect(characters[0].style.transform).not.toBe('')
|
||||
expect(characters[1].style.transform).not.toBe(
|
||||
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
|
||||
expect(characters[1].style.transform).not.toBe(fullCharacterLift)
|
||||
|
||||
syncNow(result, tokenStart + waveSpan + KARAOKE_CHARACTER_RISE_MS)
|
||||
expect(characters[0].style.transform).toBe(fullCharacterLift)
|
||||
expect(characters[1].style.transform).toBe(fullCharacterLift)
|
||||
})
|
||||
|
||||
it('gives a single character the same fixed rise duration', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 5, paused: true })
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 5000,
|
||||
tokens: [{ start: 120, end: 4120, value: 'I' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:single-character',
|
||||
{ start: 120, end: 4120 },
|
||||
'I',
|
||||
)
|
||||
const character = tokenNode.querySelector('[data-lyrics-character="true"]')
|
||||
const tokenStart = 120 - KARAOKE_HIGHLIGHT_LEAD_MS
|
||||
|
||||
syncNow(result, tokenStart)
|
||||
expect(character.style.transform).toBe('')
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2)
|
||||
expect(character.style.transform).toMatch(
|
||||
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
|
||||
)
|
||||
expect(character.style.transform).not.toBe(fullCharacterLift)
|
||||
syncNow(result, tokenStart + 240)
|
||||
expect(character.style.transform).not.toBe(fullCharacterLift)
|
||||
syncNow(result, tokenStart + 260)
|
||||
expect(character.style.transform).toBe(fullCharacterLift)
|
||||
expect(
|
||||
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
|
||||
).toBeCloseTo(KARAOKE_CHARACTER_RISE_MS / 4000, 5)
|
||||
})
|
||||
|
||||
it('forces an unfinished character wave to its raised release state', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 600,
|
||||
tokens: [{ start: 500, end: 550, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:release-wave',
|
||||
{ start: 500, end: 550 },
|
||||
'go',
|
||||
)
|
||||
const characters = tokenNode.querySelectorAll(
|
||||
'[data-lyrics-character="true"]',
|
||||
)
|
||||
|
||||
syncNow(result, 60)
|
||||
expect(characters[1].style.transform).toBe(
|
||||
`translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`,
|
||||
syncNow(result, 500 - KARAOKE_HIGHLIGHT_LEAD_MS)
|
||||
expect(characters[1].style.transform).toBe('')
|
||||
|
||||
syncNow(result, 600)
|
||||
expect(tokenNode.dataset.lyricsState).toBe('release')
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -382,6 +453,89 @@ describe('useLyricsTimeline', () => {
|
|||
expect(secondProgress).toBeGreaterThanOrEqual(firstProgress)
|
||||
})
|
||||
|
||||
it('drives character motion from playback-rate-adjusted media time', () => {
|
||||
let frameCallback = null
|
||||
let now = 0
|
||||
window.requestAnimationFrame.mockImplementation((callback) => {
|
||||
frameCallback = callback
|
||||
return 1
|
||||
})
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
|
||||
const audio = createAudio({
|
||||
currentTime: 0.38,
|
||||
duration: 2,
|
||||
paused: false,
|
||||
playbackRate: 2,
|
||||
})
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 1200,
|
||||
tokens: [{ start: 500, end: 1000, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:playback-rate-wave',
|
||||
{ start: 500, end: 1000 },
|
||||
'go',
|
||||
)
|
||||
const character = tokenNode.querySelector('[data-lyrics-character="true"]')
|
||||
|
||||
expect(character.style.transform).toBe('')
|
||||
now = KARAOKE_CHARACTER_RISE_MS / 4
|
||||
act(() => frameCallback())
|
||||
expect(character.style.transform).toMatch(
|
||||
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
|
||||
)
|
||||
expect(character.style.transform).not.toBe(fullCharacterLift)
|
||||
})
|
||||
|
||||
it('reconstructs settled character motion after visibility recovery', () => {
|
||||
let visibility = 'visible'
|
||||
vi.spyOn(window.document, 'visibilityState', 'get').mockImplementation(
|
||||
() => visibility,
|
||||
)
|
||||
const audio = createAudio({
|
||||
currentTime: 0.38,
|
||||
duration: 2,
|
||||
paused: false,
|
||||
})
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 1200,
|
||||
tokens: [{ start: 500, end: 1000, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:visibility-wave',
|
||||
{ start: 500, end: 1000 },
|
||||
'go',
|
||||
)
|
||||
const characters = tokenNode.querySelectorAll(
|
||||
'[data-lyrics-character="true"]',
|
||||
)
|
||||
|
||||
visibility = 'hidden'
|
||||
act(() => {
|
||||
window.document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
audio.currentTime = 0.8
|
||||
visibility = 'visible'
|
||||
act(() => {
|
||||
window.document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
})
|
||||
|
||||
it('resets monotonic interpolation after an explicit backward seek', () => {
|
||||
let frameCallback = null
|
||||
let now = 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue