From 921807956a8a1c172bf6e3a3d7f17f0bf214a4b7 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sat, 25 Jul 2026 15:10:49 +0300 Subject: [PATCH] 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 --- ui/src/audioplayer/LyricsPanel.jsx | 29 ++- ui/src/audioplayer/LyricsPanel.test.jsx | 130 ++++++++-- ui/src/audioplayer/lyricsKaraokeConstants.js | 87 ++++++- ui/src/audioplayer/useLyricsTimeline.js | 112 ++++++-- ui/src/audioplayer/useLyricsTimeline.test.js | 258 +++++++++++++++---- 5 files changed, 505 insertions(+), 111 deletions(-) diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index bc6cfe20d..8a73dda21 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -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} diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index 40bd9ea9b..032e369e3 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -106,6 +106,7 @@ const createAudio = ({ currentTime = 0, duration = 10 } = {}) => { describe('', () => { const originalMatchMedia = window.matchMedia + const originalResizeObserver = window.ResizeObserver beforeEach(() => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0) @@ -116,6 +117,7 @@ describe('', () => { vi.useRealTimers() vi.restoreAllMocks() window.matchMedia = originalMatchMedia + window.ResizeObserver = originalResizeObserver cleanup() }) @@ -330,18 +332,33 @@ describe('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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) => ({ diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js index db48b7bd9..bce62f151 100644 --- a/ui/src/audioplayer/lyricsKaraokeConstants.js +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -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 } diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js index b83aa4b0d..508da1069 100644 --- a/ui/src/audioplayer/useLyricsTimeline.js +++ b/ui/src/audioplayer/useLyricsTimeline.js @@ -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, diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js index 84f45918c..4c39a45fb 100644 --- a/ui/src/audioplayer/useLyricsTimeline.test.js +++ b/ui/src/audioplayer/useLyricsTimeline.test.js @@ -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