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