mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 12:57:10 +02:00
fix(ui): refine karaoke highlighting
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
70b4de1f78
commit
ff1dcc9773
5 changed files with 114 additions and 51 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue