diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index fb9174047..41483f6a2 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -21,11 +21,11 @@ import { KARAOKE_EASING, KARAOKE_IDLE_LAYER_OPACITY, KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO, - KARAOKE_LINE_ENTER_MS, KARAOKE_LINE_LIFT_PX, KARAOKE_LINE_MOTION_EASING, KARAOKE_MANUAL_SCROLL_PAUSE_MS, KARAOKE_SCROLLBAR_VISIBLE_MS, + KARAOKE_TRANSLATION_IDLE_OPACITY, } from './lyricsKaraokeConstants' import { animateScrollTop, @@ -112,6 +112,7 @@ const useStyles = makeStyles((theme) => ({ '--lyrics-translation-current-color': 'var(--lyrics-translation-idle-color, currentColor)', '--lyrics-layer-opacity': KARAOKE_IDLE_LAYER_OPACITY, + '--lyrics-translation-opacity': KARAOKE_TRANSLATION_IDLE_OPACITY, transform: 'translateY(0)', transition: `background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`, '&[role="button"]:focus-visible': { @@ -122,14 +123,10 @@ const useStyles = makeStyles((theme) => ({ backgroundColor: alpha(theme.palette.text.primary, 0.055), }, }, - '&[data-raised="true"][data-line-motion="line"] $line': { - transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, - transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`, - }, '&[data-raised="true"][data-line-motion="character"][data-character-wave="false"] $line': { transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, - transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`, + transition: KARAOKE_LAYER_OPACITY_TRANSITION, }, '&[data-active="true"]': { '--lyrics-main-current-color': @@ -139,6 +136,7 @@ const useStyles = makeStyles((theme) => ({ '--lyrics-translation-current-color': 'var(--lyrics-translation-active-color, var(--lyrics-translation-idle-color, currentColor))', '--lyrics-layer-opacity': 1, + '--lyrics-translation-opacity': 1, }, '@media (prefers-reduced-motion: reduce)': { transition: 'none', @@ -184,18 +182,17 @@ const useStyles = makeStyles((theme) => ({ }, auxLine: { display: 'block', - marginTop: theme.spacing(0.8), fontWeight: 600, fontSize: 15, lineHeight: KARAOKE_AUX_LINE_HEIGHT, overflowWrap: 'anywhere', whiteSpace: 'pre-wrap', letterSpacing: 0, - opacity: 1, + opacity: 'var(--lyrics-translation-opacity)', color: 'var(--lyrics-translation-current-color, currentColor)', WebkitTextFillColor: 'var(--lyrics-translation-current-color, currentColor)', - transition: 'none', + transition: KARAOKE_LAYER_OPACITY_TRANSITION, '@media (prefers-reduced-motion: reduce)': { transition: 'none', transform: 'none', @@ -502,6 +499,7 @@ const LyricsPanel = ({ if (layer === 'translation') { return { opacity: 1, + marginTop: theme.spacing(0.6), color: sourceColor, '--lyrics-active-color': sourceColor, } @@ -526,7 +524,7 @@ const LyricsPanel = ({ pronunciation: styleFor('pronunciation'), translation: styleFor('translation'), } - }, [colors, hasTimedMainLines]) + }, [colors, hasTimedMainLines, theme]) const showScrollbarForManualScroll = useCallback(() => { if (scrollbarTimerRef.current) { diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index 64d2cfc07..3c535ab7b 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -12,12 +12,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import LyricsPanel from './LyricsPanel' import { KaraokeLineRow } from './LyricsLineRows' import { + KARAOKE_EASING, KARAOKE_LINE_OPACITY_MS, KARAOKE_CHARACTER_LIFT_PX, KARAOKE_CHARACTER_RISE_MS, KARAOKE_HIGHLIGHT_LEAD_MS, KARAOKE_LINE_LIFT_PX, KARAOKE_MANUAL_SCROLL_PAUSE_MS, + KARAOKE_TRANSLATION_IDLE_OPACITY, TOKEN_FUTURE_ALPHA, } from './lyricsKaraokeConstants' @@ -354,8 +356,8 @@ describe('', () => { expect(pronunciation.style.color).toBe('') }) - it('keeps static translations visually stable while main layers transition', () => { - renderPanel({ + it('dims inactive translations without moving them', () => { + const propsAt = (currentTime) => ({ mainLyric, pronunciationLyric: { synced: true, @@ -367,12 +369,12 @@ describe('', () => { }, showPronunciation: true, showTranslation: true, - audioInstance: { currentTime: 0.5, paused: true }, + audioInstance: { currentTime, paused: true }, }) + const { rerenderPanel } = renderPanel(propsAt(0.5)) - const mainRow = screen - .getByTestId('lyrics-line-group') - .querySelector('[data-tokenized]') + const lineGroup = screen.getByTestId('lyrics-line-group') + const mainRow = lineGroup.querySelector('[data-tokenized]') const translationRow = screen .getByText('translated line') .closest('[data-tokenized]') @@ -384,16 +386,36 @@ describe('', () => { expect(row.style.opacity).toBe('') }) expect(window.getComputedStyle(mainRow).transition).toContain( - `opacity ${KARAOKE_LINE_OPACITY_MS}ms`, + `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) + expect(window.getComputedStyle(translationRow).transition).toContain( + `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, ) - expect(window.getComputedStyle(translationRow).transition).toBe('none') pronunciation.forEach((token) => expect(token).toHaveAttribute('data-timed', 'false'), ) - expect(window.getComputedStyle(translationRow).opacity).toBe('1') + expect( + window + .getComputedStyle(lineGroup) + .getPropertyValue('--lyrics-translation-opacity'), + ).toBe('1') + expect(window.getComputedStyle(translationRow).transform).toBe('') + expect(window.getComputedStyle(translationRow).marginTop).toBe( + `${theme.spacing(0.6)}px`, + ) + + rerenderPanel(propsAt(1.1)) + + const inactiveOpacity = Number( + window + .getComputedStyle(lineGroup) + .getPropertyValue('--lyrics-translation-opacity'), + ) + expect(inactiveOpacity).toBe(KARAOKE_TRANSLATION_IDLE_OPACITY) + expect(window.getComputedStyle(translationRow).transform).toBe('') }) - it('raises the main line without moving its translation', () => { + it('keeps line-timed main and translation rows still', () => { const { rerenderPanel } = renderPanel({ mainLyric, translationLyric: { @@ -415,8 +437,12 @@ describe('', () => { expect(group).toHaveAttribute('data-line-motion', 'line') expect(group).toHaveAttribute('data-character-wave', 'false') expect(activeStyle.transform).toBe('translateY(0)') - expect(window.getComputedStyle(mainRow).transform).toBe( - `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + expect(window.getComputedStyle(mainRow).transform).toBe('') + expect(window.getComputedStyle(mainRow).transition).toContain( + `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) + expect(window.getComputedStyle(mainRow).transition).not.toContain( + 'transform', ) expect(window.getComputedStyle(translation).transform).toBe('') @@ -434,9 +460,7 @@ describe('', () => { expect(group).toHaveAttribute('data-highlight-active', 'false') expect(group).toHaveAttribute('data-raised', 'true') expect(releasedStyle.transform).toBe('translateY(0)') - expect(window.getComputedStyle(mainRow).transform).toBe( - `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, - ) + expect(window.getComputedStyle(mainRow).transform).toBe('') expect(window.getComputedStyle(translation).transform).toBe('') }) @@ -507,6 +531,9 @@ describe('', () => { expect(window.getComputedStyle(releasedMainRow).transform).toBe( `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, ) + expect(window.getComputedStyle(releasedMainRow).transition).not.toContain( + 'transform', + ) expect(window.getComputedStyle(translation).transform).toBe('') const idleText = screen .getAllByTestId('lyrics-token')[0] @@ -821,9 +848,7 @@ describe('', () => { ) const group = screen.getByTestId('lyrics-line-group') - const translation = screen - .getByText('Readable translation') - .closest('[data-tokenized]') + expect(screen.getByText('Readable translation')).toBeInTheDocument() const token = screen.getAllByTestId('lyrics-token')[0] const gradientNode = [token, ...token.querySelectorAll('*')].find( (node) => node.style.backgroundImage.includes('linear-gradient'), @@ -840,7 +865,11 @@ describe('', () => { expect( group.style.getPropertyValue('--lyrics-translation-active-color'), ).toBe(accessibleTheme.palette.text.secondary) - expect(window.getComputedStyle(translation).opacity).toBe('1') + expect( + window + .getComputedStyle(group) + .getPropertyValue('--lyrics-translation-opacity'), + ).toBe('1') expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA) expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5) expect( diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js index bb3976641..a542694e8 100644 --- a/ui/src/audioplayer/lyricsKaraokeConstants.js +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -5,17 +5,21 @@ export const KARAOKE_LINE_OPACITY_MS = 200 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_LINE_OPACITY_MS export const KARAOKE_LINE_LIFT_PX = 1.5 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 +const KARAOKE_OPACITY_EASING_X1 = 0.22 +const KARAOKE_OPACITY_EASING_Y1 = 1 +const KARAOKE_OPACITY_EASING_X2 = 0.36 +const KARAOKE_OPACITY_EASING_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 = KARAOKE_LINE_LIFT_PX 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_IDLE_OPACITY = 0.62 export const KARAOKE_IDLE_LAYER_OPACITY = 0.49 export const KARAOKE_LINE_RELEASE_MS = KARAOKE_LINE_OPACITY_MS export const KARAOKE_SCROLL_PRE_ROLL_MS = 320 @@ -23,7 +27,7 @@ export const KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO = 0.1 export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42 export const KARAOKE_SCROLL_SETTLE_PX = 2 export const KARAOKE_AUX_LINE_HEIGHT = 1.18 -export const KARAOKE_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)' +export const KARAOKE_EASING = `cubic-bezier(${KARAOKE_OPACITY_EASING_X1}, ${KARAOKE_OPACITY_EASING_Y1}, ${KARAOKE_OPACITY_EASING_X2}, ${KARAOKE_OPACITY_EASING_Y2})` export const TOKEN_FUTURE_ALPHA = 0.56 export const TOKEN_ACTIVE_ALPHA = 1 export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34 @@ -49,21 +53,13 @@ const cubicBezierSlope = (time, firstControl, secondControl) => { ) } -const solveStrongEaseInOutTime = (progress) => { +const solveCubicBezierTime = (progress, firstControl, secondControl) => { 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 + cubicBezierCoordinate(time, firstControl, secondControl) - progress if (Math.abs(error) < 0.000001) return time - const slope = cubicBezierSlope( - time, - KARAOKE_EASE_IN_OUT_X1, - KARAOKE_EASE_IN_OUT_X2, - ) + const slope = cubicBezierSlope(time, firstControl, secondControl) if (Math.abs(slope) < 0.000001) break time = clamp(time - error / slope, 0, 1) } @@ -72,13 +68,7 @@ const solveStrongEaseInOutTime = (progress) => { 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 - ) { + if (cubicBezierCoordinate(time, firstControl, secondControl) < progress) { lower = time } else { upper = time @@ -91,12 +81,30 @@ export const easeKaraokeMotion = (value) => { const progress = clamp(value, 0, 1) if (progress === 0 || progress === 1) return progress return cubicBezierCoordinate( - solveStrongEaseInOutTime(progress), + solveCubicBezierTime( + progress, + KARAOKE_EASE_IN_OUT_X1, + KARAOKE_EASE_IN_OUT_X2, + ), KARAOKE_EASE_IN_OUT_Y1, KARAOKE_EASE_IN_OUT_Y2, ) } +export const easeKaraokeOpacity = (value) => { + const progress = clamp(value, 0, 1) + if (progress === 0 || progress === 1) return progress + return cubicBezierCoordinate( + solveCubicBezierTime( + progress, + KARAOKE_OPACITY_EASING_X1, + KARAOKE_OPACITY_EASING_X2, + ), + KARAOKE_OPACITY_EASING_Y1, + KARAOKE_OPACITY_EASING_Y2, + ) +} + export const easeInOut = (value) => { const progress = clamp(value, 0, 1) return progress < 0.5 diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js index 6667426a9..5316e8703 100644 --- a/ui/src/audioplayer/useLyricsTimeline.js +++ b/ui/src/audioplayer/useLyricsTimeline.js @@ -15,6 +15,7 @@ import { KARAOKE_HIGHLIGHT_LEAD_MS, KARAOKE_LINE_RELEASE_MS, easeKaraokeMotion, + easeKaraokeOpacity, } from './lyricsKaraokeConstants' import { buildLyricsTimeline, @@ -233,6 +234,7 @@ const applyTokenState = (record, state, progress = 0) => { const setTokenReleasePresentation = (record, progress) => { const presentation = record.presentation || {} const nextProgress = Math.max(0, Math.min(1, progress)) + const easedProgress = easeKaraokeOpacity(nextProgress) if (record.state !== 'release') { applyTokenState(record, 'completed', 1) record.state = 'release' @@ -243,7 +245,7 @@ const setTokenReleasePresentation = (record, progress) => { setTokenOpacity(record, 1) setTokenActiveAlpha( record, - activeAlpha + (targetAlpha - activeAlpha) * nextProgress, + activeAlpha + (targetAlpha - activeAlpha) * easedProgress, ) settleCharacterLift(record) } diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js index 841fedbd9..54a70aa88 100644 --- a/ui/src/audioplayer/useLyricsTimeline.test.js +++ b/ui/src/audioplayer/useLyricsTimeline.test.js @@ -9,6 +9,7 @@ import { KARAOKE_CLOCK_DRIFT_RESET_MS, KARAOKE_HIGHLIGHT_LEAD_MS, KARAOKE_LINE_RELEASE_MS, + easeKaraokeOpacity, } from './lyricsKaraokeConstants' import useLyricsTimeline from './useLyricsTimeline' @@ -260,6 +261,31 @@ describe('useLyricsTimeline', () => { expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1') }) + it('uses the shared opacity easing throughout line release', () => { + const audio = createAudio({ currentTime: 0.25, paused: true }) + const { result } = renderTimeline({ audio }) + const lineNode = document.createElement('div') + act(() => result.current.registerLine(0, lineNode)) + const tokenNode = registerToken( + result, + '0:eased-release', + { start: 0, end: 500 }, + 'first', + ) + + syncNow(result, 1000 + KARAOKE_LINE_RELEASE_MS / 2) + + expect(lineNode).toHaveAttribute('data-lifecycle', 'release') + expect(lineNode).toHaveAttribute('data-active', 'false') + const expectedAlpha = + presentation.activeAlpha + + (presentation.futureAlpha - presentation.activeAlpha) * + easeKaraokeOpacity(0.5) + expect( + Number(tokenNode.style.getPropertyValue('--lyrics-token-active-alpha')), + ).toBeCloseTo(expectedAlpha, 5) + }) + it('completes long-token character waves while highlighting continues', () => { const audio = createAudio({ currentTime: 0, duration: 6, paused: true }) const longLines = [