From 70b4de1f7840d66ce338bf96b785f66a44a11297 Mon Sep 17 00:00:00 2001 From: Yuuta <61791392+ranokay@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:12:24 +0300 Subject: [PATCH] fix(ui): refine responsive lyrics presentation Signed-off-by: ranokay --- ui/src/audioplayer/LyricsPanel.jsx | 23 +++---- ui/src/audioplayer/LyricsPanel.test.jsx | 67 ++++++++++---------- ui/src/audioplayer/playerBreakpoints.js | 9 +-- ui/src/audioplayer/playerBreakpoints.test.js | 13 ++-- 4 files changed, 49 insertions(+), 63 deletions(-) diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index a998b4c5d..fb9174047 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -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) { diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index 0eb18386a..64d2cfc07 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { .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('', () => { ).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), + ) }, ) }) diff --git a/ui/src/audioplayer/playerBreakpoints.js b/ui/src/audioplayer/playerBreakpoints.js index 16dde7777..72dd2e68a 100644 --- a/ui/src/audioplayer/playerBreakpoints.js +++ b/ui/src/audioplayer/playerBreakpoints.js @@ -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 diff --git a/ui/src/audioplayer/playerBreakpoints.test.js b/ui/src/audioplayer/playerBreakpoints.test.js index c603665ad..4d19e5e68 100644 --- a/ui/src/audioplayer/playerBreakpoints.test.js +++ b/ui/src/audioplayer/playerBreakpoints.test.js @@ -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) }) })