fix(ui): refine karaoke highlighting

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-07-29 12:39:50 +03:00
commit ff1dcc9773
No known key found for this signature in database
5 changed files with 114 additions and 51 deletions

View file

@ -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) {

View file

@ -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('<LyricsPanel />', () => {
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('<LyricsPanel />', () => {
},
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('<LyricsPanel />', () => {
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('<LyricsPanel />', () => {
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('<LyricsPanel />', () => {
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('<LyricsPanel />', () => {
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('<LyricsPanel />', () => {
)
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('<LyricsPanel />', () => {
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(

View file

@ -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

View file

@ -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)
}

View file

@ -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 = [