fix(ui): refine responsive lyrics presentation

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
Yuuta 2026-07-29 10:12:24 +03:00 • committed by ranokay
commit 70b4de1f78
No known key found for this signature in database
4 changed files with 49 additions and 63 deletions

View file

@ -37,7 +37,6 @@ import { finiteTime } from './lyricsTimeline'
import useLyricsTimeline from './useLyricsTimeline'
const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
const KARAOKE_AUX_LINE_TRANSITION = `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}, -webkit-text-fill-color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
const useStyles = makeStyles((theme) => ({
root: {
@ -123,18 +122,15 @@ const useStyles = makeStyles((theme) => ({
backgroundColor: alpha(theme.palette.text.primary, 0.055),
},
},
'&[data-raised="true"][data-line-motion="line"]': {
'&[data-raised="true"][data-line-motion="line"] $line': {
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}`,
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}`,
},
'&[data-raised="true"][data-line-motion="character"] $translationLine': {
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
transition: `${KARAOKE_AUX_LINE_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))',
@ -147,11 +143,10 @@ 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',
},
'& $line': {
transition: 'none',
transform: 'none',
},
},
},
waveCharacter: {
@ -200,7 +195,7 @@ const useStyles = makeStyles((theme) => ({
color: 'var(--lyrics-translation-current-color, currentColor)',
WebkitTextFillColor:
'var(--lyrics-translation-current-color, currentColor)',
transition: KARAOKE_AUX_LINE_TRANSITION,
transition: 'none',
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none',
@ -508,7 +503,7 @@ const LyricsPanel = ({
return {
opacity: 1,
color: sourceColor,
'--lyrics-active-color': colors.main,
'--lyrics-active-color': sourceColor,
}
}
if (!hasTimedMainLines) {

View file

@ -44,13 +44,20 @@ const renderPanel = (props, selectedTheme = theme) => {
}
const parseCssColor = (value) => {
const hex = String(value).match(/^#([0-9a-f]{6})$/i)
const hex = String(value).match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
if (hex) {
const normalized =
hex[1].length === 3
? hex[1]
.split('')
.map((channel) => channel.repeat(2))
.join('')
: hex[1]
return {
channels: [
parseInt(hex[1].slice(0, 2), 16),
parseInt(hex[1].slice(2, 4), 16),
parseInt(hex[1].slice(4, 6), 16),
parseInt(normalized.slice(0, 2), 16),
parseInt(normalized.slice(2, 4), 16),
parseInt(normalized.slice(4, 6), 16),
],
alpha: 1,
}
@ -347,7 +354,7 @@ describe('<LyricsPanel />', () => {
expect(pronunciation.style.color).toBe('')
})
it('uses one shared state transition for every static line layer', () => {
it('keeps static translations visually stable while main layers transition', () => {
renderPanel({
mainLyric,
pronunciationLyric: {
@ -379,16 +386,14 @@ describe('<LyricsPanel />', () => {
expect(window.getComputedStyle(mainRow).transition).toContain(
`opacity ${KARAOKE_LINE_OPACITY_MS}ms`,
)
expect(window.getComputedStyle(translationRow).transition).toContain(
`color ${KARAOKE_LINE_OPACITY_MS}ms`,
)
expect(window.getComputedStyle(translationRow).transition).toBe('none')
pronunciation.forEach((token) =>
expect(token).toHaveAttribute('data-timed', 'false'),
)
expect(window.getComputedStyle(translationRow).opacity).toBe('1')
})
it('raises a line once and keeps it elevated after release', () => {
it('raises the main line without moving its translation', () => {
const { rerenderPanel } = renderPanel({
mainLyric,
translationLyric: {
@ -403,12 +408,16 @@ describe('<LyricsPanel />', () => {
const translation = screen
.getByText('translated line')
.closest('[data-tokenized]')
const mainRow = screen.getByText('Main 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(activeStyle.transform).toBe('translateY(0)')
expect(window.getComputedStyle(mainRow).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
rerenderPanel({
@ -424,13 +433,14 @@ describe('<LyricsPanel />', () => {
const releasedStyle = window.getComputedStyle(group)
expect(group).toHaveAttribute('data-highlight-active', 'false')
expect(group).toHaveAttribute('data-raised', 'true')
expect(releasedStyle.transform).toBe(
expect(releasedStyle.transform).toBe('translateY(0)')
expect(window.getComputedStyle(mainRow).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', () => {
it('keeps word-timed translations still while main and pronunciation rise', () => {
const settledFirstCharacterTime =
(KARAOKE_CHARACTER_RISE_MS - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000
const propsAt = (currentTime) => ({
@ -464,9 +474,7 @@ describe('<LyricsPanel />', () => {
expect(group).toHaveAttribute('data-line-motion', 'character')
expect(group).toHaveAttribute('data-character-wave', 'true')
expect(window.getComputedStyle(group).transform).toBe('translateY(0)')
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
expect(mainCharacters).toHaveLength(4)
expect(pronunciationCharacters).toHaveLength(4)
expect(mainCharacters[0].style.transform).toBe(
@ -499,16 +507,14 @@ describe('<LyricsPanel />', () => {
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', () => {
it('keeps the translation still when a word-timed line wraps or unwraps', () => {
let wrapped = false
const observers = []
window.ResizeObserver = class {
@ -565,28 +571,20 @@ describe('<LyricsPanel />', () => {
.closest('[data-tokenized]')
expect(row).toHaveAttribute('data-wrapped', 'false')
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
wrapped = true
act(notifyResizeObservers)
expect(row).toHaveAttribute('data-wrapped', 'true')
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
rerenderPanel(propsAt(1.1))
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
wrapped = false
act(notifyResizeObservers)
expect(row).toHaveAttribute('data-wrapped', 'false')
expect(window.getComputedStyle(translation).transform).toBe(
`translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
)
expect(window.getComputedStyle(translation).transform).toBe('')
})
it('keeps detailed grapheme markup on active lines without changing token layout', () => {
@ -841,13 +839,18 @@ describe('<LyricsPanel />', () => {
).toBe(accessibleTheme.palette.text.secondary)
expect(
group.style.getPropertyValue('--lyrics-translation-active-color'),
).toBe(accessibleTheme.palette.text.primary)
).toBe(accessibleTheme.palette.text.secondary)
expect(window.getComputedStyle(translation).opacity).toBe('1')
expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA)
expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5)
expect(
contrastRatio(accessibleTheme.palette.text.secondary, background),
).toBeGreaterThanOrEqual(4.5)
expect(
contrastRatio(accessibleTheme.palette.text.secondary, background),
).toBeLessThan(
contrastRatio(accessibleTheme.palette.text.primary, background),
)
},
)
})

View file

@ -1,11 +1,4 @@
import { APP_SIDEBAR_OPEN_WIDTH } from '../layout/layoutWidths'
import { LYRICS_SIDEBAR_MAX_WIDTH } from './lyricsSidebarWidth'
export const LYRICS_SIDEBAR_MIN_CONTENT_WIDTH = LYRICS_SIDEBAR_MAX_WIDTH
export const PLAYER_DESKTOP_MIN_WIDTH =
APP_SIDEBAR_OPEN_WIDTH +
LYRICS_SIDEBAR_MAX_WIDTH +
LYRICS_SIDEBAR_MIN_CONTENT_WIDTH
export const PLAYER_DESKTOP_MIN_WIDTH = 810
export const PLAYER_MEDIA_QUERY_STEP = 0.05
export const PLAYER_MOBILE_MAX_WIDTH =
PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MEDIA_QUERY_STEP

View file

@ -1,9 +1,7 @@
import { describe, expect, it } from 'vitest'
import { APP_SIDEBAR_OPEN_WIDTH } from '../layout/layoutWidths'
import {
LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY,
LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH,
LYRICS_SIDEBAR_MIN_CONTENT_WIDTH,
PLAYER_DESKTOP_MEDIA_QUERY,
PLAYER_DESKTOP_MIN_WIDTH,
PLAYER_MOBILE_MATCH_MEDIA_QUERY,
@ -11,7 +9,6 @@ import {
PLAYER_MOBILE_MEDIA_QUERY,
PLAYER_MEDIA_QUERY_STEP,
} from './playerBreakpoints'
import { LYRICS_SIDEBAR_MAX_WIDTH } from './lyricsSidebarWidth'
describe('player breakpoints', () => {
it('keeps compact and desktop player ranges from overlapping', () => {
@ -29,15 +26,13 @@ describe('player breakpoints', () => {
)
})
it('only opens the desktop lyrics sidebar when primary content still fits', () => {
it('keeps tablet layouts on the desktop player and lyrics sidebar', () => {
expect(PLAYER_DESKTOP_MIN_WIDTH).toBe(810)
expect(PLAYER_MOBILE_MAX_WIDTH).toBe(809.95)
expect(LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH).toBe(810)
expect(LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY).toBe(
`(min-width:${LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH}px)`,
)
expect(
LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH -
APP_SIDEBAR_OPEN_WIDTH -
LYRICS_SIDEBAR_MAX_WIDTH,
).toBeGreaterThanOrEqual(LYRICS_SIDEBAR_MIN_CONTENT_WIDTH)
expect(LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH).toBe(PLAYER_DESKTOP_MIN_WIDTH)
})
})