From 917338dc56cfeee89331da9ab3f1afbd5d82d36f Mon Sep 17 00:00:00 2001 From: ranokay Date: Fri, 17 Jul 2026 09:20:45 +0300 Subject: [PATCH 01/16] feat(ui): load and cache enhanced lyrics - Fetch structured lyric layers lazily with bounded, retryable request caching. - Preserve language, track-update, abort, and Subsonic compatibility behavior. Signed-off-by: ranokay --- ui/src/audioplayer/lyrics.js | 500 +++++++++++++++++++ ui/src/audioplayer/lyrics.test.js | 347 +++++++++++++ ui/src/audioplayer/useEnhancedLyrics.js | 215 ++++++++ ui/src/audioplayer/useEnhancedLyrics.test.js | 205 ++++++++ ui/src/subsonic/index.js | 9 +- ui/src/subsonic/index.test.js | 113 +++-- 6 files changed, 1339 insertions(+), 50 deletions(-) create mode 100644 ui/src/audioplayer/lyrics.js create mode 100644 ui/src/audioplayer/lyrics.test.js create mode 100644 ui/src/audioplayer/useEnhancedLyrics.js create mode 100644 ui/src/audioplayer/useEnhancedLyrics.test.js diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js new file mode 100644 index 000000000..ec93cc6f0 --- /dev/null +++ b/ui/src/audioplayer/lyrics.js @@ -0,0 +1,500 @@ +const normalizeLanguageTag = (language) => + (language || '').toLowerCase().replace(/_/g, '-') + +const LYRIC_KIND_MAIN = 'main' +const LYRIC_KIND_TRANSLATION = 'translation' +const LYRIC_KIND_PRONUNCIATION = 'pronunciation' + +const toTime = (value) => { + if (value == null || value === '') { + return null + } + const numeric = Number(value) + return Number.isFinite(numeric) ? numeric : null +} + +const applyTimeOffset = (value, offset = 0) => { + const time = toTime(value) + return time == null ? null : time + offset +} + +const toByteOffset = (value) => { + if (value == null || value === '') { + return null + } + const numeric = Number(value) + if (!Number.isInteger(numeric) || numeric < 0) { + return null + } + return numeric +} + +const compareNullableTime = (a, b) => { + if (a == null && b == null) return 0 + if (a == null) return 1 + if (b == null) return -1 + return a - b +} + +const sortTokensByStart = (tokens) => + tokens + .map((token, order) => ({ ...token, order })) + .sort((a, b) => { + const byStart = compareNullableTime(a.start, b.start) + if (byStart !== 0) return byStart + const byEnd = compareNullableTime(a.end, b.end) + if (byEnd !== 0) return byEnd + return a.order - b.order + }) + .map(({ order, ...token }) => token) + +const languageMatch = (candidate, preferred) => { + if (!candidate || !preferred) return false + return ( + candidate === preferred || + candidate.startsWith(`${preferred}-`) || + preferred.startsWith(`${candidate}-`) + ) +} + +const hasTimedCueLines = (lyric) => + Array.isArray(lyric?.cueLine) && + lyric.cueLine.some( + (cueLine) => + toTime(cueLine?.start) != null || + (Array.isArray(cueLine?.cue) && + cueLine.cue.some((cue) => toTime(cue?.start) != null)), + ) + +const hasTimedLines = (lyric) => + Boolean( + lyric && + lyric.synced && + ((Array.isArray(lyric.line) && + lyric.line.some((line) => toTime(line.start) != null)) || + hasTimedCueLines(lyric)), + ) + +const preferTimedLyrics = (lyrics) => { + const timed = lyrics.filter(hasTimedLines) + return timed.length > 0 ? timed : lyrics +} + +const normalizeToken = (token, offset = 0) => { + if (!token) return null + const value = typeof token.value === 'string' ? token.value : '' + if (value.length === 0) return null + const byteStart = toByteOffset(token.byteStart) + const byteEnd = toByteOffset(token.byteEnd) + return { + start: applyTimeOffset(token.start, offset), + end: applyTimeOffset(token.end, offset), + value, + ...(byteStart != null ? { byteStart } : {}), + ...(byteEnd != null ? { byteEnd } : {}), + } +} + +const utf8BytesForCodePoint = (codePoint) => { + if (codePoint <= 0x7f) return 1 + if (codePoint <= 0x7ff) return 2 + if (codePoint <= 0xffff) return 3 + return 4 +} + +export const utf8ByteOffsetToCodeUnitIndex = (text, targetByteOffset) => { + if (typeof text !== 'string' || text.length === 0) return 0 + + const target = toByteOffset(targetByteOffset) + if (target == null || target <= 0) return 0 + + let byteOffset = 0 + let index = 0 + while (index < text.length) { + if (byteOffset >= target) return index + const codePoint = text.codePointAt(index) + byteOffset += utf8BytesForCodePoint(codePoint) + index += codePoint > 0xffff ? 2 : 1 + } + + return text.length +} + +export const utf8ByteRangeToCodeUnitRange = (text, byteStart, byteEnd) => { + if (typeof text !== 'string') return null + + const start = toByteOffset(byteStart) + const end = toByteOffset(byteEnd) + if (start == null || end == null || end < start) return null + + const startIndex = utf8ByteOffsetToCodeUnitIndex(text, start) + const endIndex = utf8ByteOffsetToCodeUnitIndex(text, end + 1) + if ( + startIndex >= endIndex || + startIndex > text.length || + endIndex > text.length + ) { + return null + } + + return { + start: startIndex, + end: endIndex, + text: text.slice(startIndex, endIndex), + } +} + +const buildAgentLookup = (structuredLyric) => { + const lookup = new Map() + const agents = Array.isArray(structuredLyric?.agents) + ? structuredLyric.agents + : [] + for (const agent of agents) { + const id = typeof agent?.id === 'string' ? agent.id : '' + if (!id || lookup.has(id)) continue + lookup.set(id, { + id, + role: typeof agent?.role === 'string' ? agent.role : '', + name: typeof agent?.name === 'string' ? agent.name : '', + }) + } + return lookup +} + +const deriveUiRole = (agent) => { + if (!agent?.role || agent.role === 'main') return '' + return agent.role +} + +const normalizeCueLine = (cueLine, fallbackIndex, agentLookup, offset = 0) => { + const index = Number.isFinite(Number(cueLine?.index)) + ? Number(cueLine.index) + : fallbackIndex + const agentId = typeof cueLine?.agentId === 'string' ? cueLine.agentId : '' + const agent = agentId ? agentLookup.get(agentId) || null : null + const fallbackRole = typeof cueLine?.role === 'string' ? cueLine.role : '' + const tokens = sortTokensByStart( + Array.isArray(cueLine?.cue) + ? cueLine.cue.map((cue) => normalizeToken(cue, offset)).filter(Boolean) + : [], + ) + + return { + index, + start: applyTimeOffset(cueLine?.start, offset), + end: applyTimeOffset(cueLine?.end, offset), + value: typeof cueLine?.value === 'string' ? cueLine.value : '', + role: agent ? deriveUiRole(agent) : fallbackRole, + agentId, + agentRole: agent?.role || fallbackRole, + agentName: agent?.name || '', + tokens, + } +} + +const normalizeLyricKind = (kind) => { + const normalized = (kind || '').toLowerCase().trim() + switch (normalized) { + case LYRIC_KIND_TRANSLATION: + return LYRIC_KIND_TRANSLATION + case LYRIC_KIND_PRONUNCIATION: + return LYRIC_KIND_PRONUNCIATION + default: + return LYRIC_KIND_MAIN + } +} + +const lyricsInPreferredLanguage = (lyrics, preferredLanguage) => { + if (!Array.isArray(lyrics) || lyrics.length === 0) return null + + const preferred = normalizeLanguageTag(preferredLanguage) + const preferredBase = preferred.split('-')[0] + + const preferredMatches = lyrics.filter((lyric) => + languageMatch(normalizeLanguageTag(lyric.lang), preferred), + ) + if (preferredMatches.length > 0) return preferredMatches + + const baseMatches = lyrics.filter((lyric) => + languageMatch(normalizeLanguageTag(lyric.lang), preferredBase), + ) + if (baseMatches.length > 0) return baseMatches + + const englishMatches = lyrics.filter((lyric) => + languageMatch(normalizeLanguageTag(lyric.lang), 'en'), + ) + return englishMatches.length > 0 ? englishMatches : lyrics +} + +const pickPreferredLyric = (lyrics, preferredLanguage) => { + const preferred = lyricsInPreferredLanguage(lyrics, preferredLanguage) + return preferred ? preferTimedLyrics(preferred)[0] || null : null +} + +export const hasCueTiming = (structuredLyric) => + hasTimedCueLines(structuredLyric) + +export const hasStructuredLyricContent = (structuredLyric) => + Boolean( + structuredLyric && + ((Array.isArray(structuredLyric.line) && + structuredLyric.line.some( + (line) => typeof line?.value === 'string' && line.value.trim() !== '', + )) || + hasCueTiming(structuredLyric)), + ) + +export const getPreferredLyricLanguage = () => { + try { + if (typeof window !== 'undefined' && window.localStorage) { + const stored = window.localStorage.getItem('locale') + if (stored) return stored + } + } catch { + // Fall back to the browser language when storage access is restricted. + } + if (typeof navigator !== 'undefined' && navigator.language) { + return navigator.language + } + return 'en' +} + +export const selectLyricLayers = (structuredLyrics, preferredLanguage) => { + if (!Array.isArray(structuredLyrics)) { + return { main: null, translation: null, pronunciation: null } + } + + const available = structuredLyrics.filter(hasStructuredLyricContent) + if (available.length === 0) { + return { main: null, translation: null, pronunciation: null } + } + + const grouped = { + [LYRIC_KIND_MAIN]: [], + [LYRIC_KIND_TRANSLATION]: [], + [LYRIC_KIND_PRONUNCIATION]: [], + } + + for (const lyric of available) { + grouped[normalizeLyricKind(lyric?.kind)].push(lyric) + } + + const mainCandidates = grouped[LYRIC_KIND_MAIN].length + ? grouped[LYRIC_KIND_MAIN] + : available + + return { + main: pickPreferredLyric(mainCandidates, preferredLanguage), + translation: pickPreferredLyric( + grouped[LYRIC_KIND_TRANSLATION], + preferredLanguage, + ), + pronunciation: pickPreferredLyric( + grouped[LYRIC_KIND_PRONUNCIATION], + preferredLanguage, + ), + } +} + +const buildBaseKaraokeLine = (line, index, offset = 0) => ({ + index, + start: applyTimeOffset(line?.start, offset), + end: applyTimeOffset(line?.end, offset), + value: typeof line?.value === 'string' ? line.value : '', + tokens: [], + lanes: [], +}) + +const buildBaseKaraokeLines = (baseLines, offset = 0) => + baseLines.map((line, index) => buildBaseKaraokeLine(line, index, offset)) + +const minNullableTime = (...values) => { + const times = values.filter((value) => value != null) + return times.length > 0 ? Math.min(...times) : null +} + +const maxNullableTime = (...values) => { + const times = values.filter((value) => value != null) + return times.length > 0 ? Math.max(...times) : null +} + +const laneRoleRank = (line) => { + const role = (line?.agentRole || line?.role || '').toLowerCase() + switch (role) { + case '': + case 'main': + return 0 + case 'voice': + return 1 + case 'group': + case 'chorus': + case 'choir': + return 2 + case 'bg': + case 'background': + case 'background vocals': + case 'background-vocals': + case 'backing': + case 'backing vocals': + case 'backing-vocals': + return 3 + default: + return 1 + } +} + +const sortCueLineLanes = (lanes) => + [...lanes].sort((a, b) => { + const byRole = laneRoleRank(a) - laneRoleRank(b) + if (byRole !== 0) return byRole + return a.order - b.order + }) + +const buildLaneFromCueLine = (cueLine, laneIndex) => ({ + key: `${cueLine.index}-${cueLine.agentId || 'default'}-${laneIndex}`, + index: cueLine.index, + start: cueLine.start, + end: cueLine.end, + value: cueLine.value, + role: cueLine.role, + agentId: cueLine.agentId, + agentName: cueLine.agentName, + agentRole: cueLine.agentRole, + tokens: cueLine.tokens, +}) + +const buildLineFromCueLineGroup = (index, group, baseLines, offset = 0) => { + const baseLine = buildBaseKaraokeLine(baseLines[index] || {}, index, offset) + const orderedGroup = sortCueLineLanes(group) + const lanes = orderedGroup.map(buildLaneFromCueLine) + const first = lanes[0] || {} + const tokens = sortTokensByStart(lanes.flatMap((lane) => lane.tokens || [])) + const fallbackStart = + tokens.find((token) => token.start != null)?.start ?? null + const fallbackEnd = + [...tokens].reverse().find((token) => token.end != null)?.end ?? null + const laneStart = minNullableTime( + ...lanes.flatMap((lane) => [lane.start, lane.tokens?.[0]?.start]), + ) + const laneEnd = maxNullableTime( + ...lanes.flatMap((lane) => [ + lane.end, + [...(lane.tokens || [])].reverse().find((token) => token.end != null) + ?.end, + ]), + ) + const fallbackValue = tokens + .map((token) => token.value) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim() + const value = baseLine.value || first.value || fallbackValue + + return { + ...baseLine, + index, + start: minNullableTime( + baseLine.start, + first.start, + laneStart, + fallbackStart, + ), + end: maxNullableTime(baseLine.end, first.end, laneEnd, fallbackEnd), + value, + agentId: first.agentId, + agentName: first.agentName, + agentRole: first.agentRole, + tokens, + lanes, + } +} + +export const buildKaraokeLinesFromCueLines = ( + rawCueLines, + baseLines, + agentLookup, + offset = 0, +) => { + const normalizedCueLines = rawCueLines.map((cueLine, fallbackIndex) => { + const normalized = normalizeCueLine( + cueLine, + fallbackIndex, + agentLookup, + offset, + ) + return { + ...normalized, + order: fallbackIndex, + tokens: normalized.tokens.map((token) => ({ + ...token, + role: normalized.role, + agentId: normalized.agentId, + agentName: normalized.agentName, + agentRole: normalized.agentRole, + })), + } + }) + + const byIndex = new Map() + for (const cueLine of normalizedCueLines) { + if (!byIndex.has(cueLine.index)) byIndex.set(cueLine.index, []) + byIndex.get(cueLine.index).push(cueLine) + } + + const indexes = new Set(baseLines.map((_line, index) => index)) + byIndex.forEach((_group, index) => indexes.add(index)) + + return Array.from(indexes) + .sort((a, b) => a - b) + .map((index) => { + const group = byIndex.get(index) || [] + if (group.length === 0) { + return buildBaseKaraokeLine(baseLines[index] || {}, index, offset) + } + return buildLineFromCueLineGroup(index, group, baseLines, offset) + }) +} + +export const buildKaraokeLines = (structuredLyric) => { + if (!structuredLyric) return [] + + const offset = toTime(structuredLyric.offset) ?? 0 + const agentLookup = buildAgentLookup(structuredLyric) + const baseLines = Array.isArray(structuredLyric.line) + ? structuredLyric.line + : [] + const rawCueLines = Array.isArray(structuredLyric.cueLine) + ? structuredLyric.cueLine + : [] + + const lines = + rawCueLines.length > 0 + ? buildKaraokeLinesFromCueLines( + rawCueLines, + baseLines, + agentLookup, + offset, + ) + : buildBaseKaraokeLines(baseLines, offset) + + return lines + .map((line) => ({ + ...line, + renderable: Boolean(line.value?.trim() || line.tokens.length > 0), + })) + .sort((a, b) => a.index - b.index) +} + +export const hasUsableKaraokeTiming = (lines) => + Array.isArray(lines) && + lines.some( + (line) => + line?.renderable !== false && + (toTime(line?.start) != null || + (Array.isArray(line?.tokens) && + line.tokens.some( + (token) => + toTime(token?.start) != null || toTime(token?.end) != null, + ))), + ) diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js new file mode 100644 index 000000000..c5ef6d736 --- /dev/null +++ b/ui/src/audioplayer/lyrics.test.js @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildKaraokeLines, + getPreferredLyricLanguage, + hasStructuredLyricContent, + selectLyricLayers, + utf8ByteRangeToCodeUnitRange, +} from './lyrics' + +const timed = (value, start, end, extra = {}) => ({ + ...extra, + start, + ...(end == null ? {} : { end }), + value, +}) + +const structured = (kind, lang, value) => ({ + kind, + lang, + synced: true, + line: [timed(value, 0)], +}) + +const cueLine = (index, value, start, end, cue, extra = {}) => ({ + index, + ...timed(value, start, end, extra), + cue, +}) + +describe('lyrics helpers', () => { + it('selects main, pronunciation, and translation layers by kind and language', () => { + const layers = selectLyricLayers( + [ + structured('translation', 'es', 'Hola'), + structured('main', 'en-US', 'Hello'), + structured('pronunciation', 'en', 'heh-low'), + ], + 'en', + ) + + expect(layers.main.value || layers.main.line[0].value).toBe('Hello') + expect(layers.pronunciation.line[0].value).toBe('heh-low') + expect(layers.translation.line[0].value).toBe('Hola') + }) + + it('prefers cue-timed lyrics when line starts are omitted', () => { + const layers = selectLyricLayers( + [ + { + kind: 'main', + lang: 'en', + synced: false, + line: [{ value: 'Plain lyrics' }], + }, + { + kind: 'main', + lang: 'en', + synced: true, + line: [{ value: 'Cue-timed lyrics' }], + cueLine: [ + { + index: 0, + value: 'Cue-timed lyrics', + cue: [{ start: 1000, value: 'Cue-timed lyrics' }], + }, + ], + }, + ], + 'en', + ) + + expect(layers.main.line[0].value).toBe('Cue-timed lyrics') + }) + + it('prefers the requested language before timed variants', () => { + const layers = selectLyricLayers( + [ + { + kind: 'main', + lang: 'en', + synced: false, + line: [{ value: 'Preferred plain lyrics' }], + }, + structured('main', 'ja', '別の言語'), + ], + 'en-US', + ) + + expect(layers.main.line[0].value).toBe('Preferred plain lyrics') + }) + + it('matches language tags with multiple underscore separators', () => { + const layers = selectLyricLayers( + [ + structured('main', 'zh-Hans', '简体'), + structured('main', 'zh-Hant-TW', '繁體'), + ], + 'zh_Hant_TW', + ) + + expect(layers.main.line[0].value).toBe('繁體') + }) + + it('resolves UTF-8 byte ranges without confusing repeated words', () => { + const text = 'caf\u00e9 caf\u00e9' + + expect(utf8ByteRangeToCodeUnitRange(text, 0, 4)).toMatchObject({ + text: 'caf\u00e9', + }) + expect(utf8ByteRangeToCodeUnitRange(text, 6, 10)).toMatchObject({ + text: 'caf\u00e9', + start: 5, + }) + }) + + it('applies structured lyric offsets to line and cue timing', () => { + const delayed = buildKaraokeLines({ + synced: true, + offset: 500, + line: [timed('Delayed line', 1000, 3000)], + cueLine: [ + cueLine(0, 'Delayed line', 1000, 3000, [ + timed('Delayed ', 1000, 2000, { byteStart: 0, byteEnd: 7 }), + timed('line', 2000, 3000, { byteStart: 8, byteEnd: 11 }), + ]), + ], + }) + const advanced = buildKaraokeLines({ + synced: true, + offset: -250, + line: [timed('Advanced line', 1000)], + }) + + expect(delayed[0].start).toBe(1500) + expect(delayed[0].end).toBe(3500) + expect(delayed[0].tokens[0].start).toBe(1500) + expect(delayed[0].tokens[0].end).toBe(2500) + expect(advanced[0].start).toBe(750) + }) + + it('keeps base lyric lines that do not have word-level cue lines', () => { + const lines = buildKaraokeLines({ + synced: true, + line: [ + timed('Word timed', 1000), + timed('Plain timed line', 2000), + timed('More words', 3000), + ], + cueLine: [ + cueLine(0, 'Word timed', 1000, null, [ + timed('Word ', 1000, null, { byteStart: 0, byteEnd: 4 }), + timed('timed', 1500, null, { byteStart: 5, byteEnd: 9 }), + ]), + cueLine(2, 'More words', 3000, null, [ + timed('More ', 3000, null, { byteStart: 0, byteEnd: 4 }), + timed('words', 3500, null, { byteStart: 5, byteEnd: 9 }), + ]), + ], + }) + + expect(lines.map((line) => line.value)).toEqual([ + 'Word timed', + 'Plain timed line', + 'More words', + ]) + expect(lines[1].tokens).toEqual([]) + }) + + it('preserves repeated ELRC-style cue timing and trailing cue ends', () => { + const repeatedLine = (index, start) => + cueLine(index, 'Hello world', start, start + 900, [ + timed('Hello ', start + 100, start + 500, { + byteStart: 0, + byteEnd: 5, + }), + timed('world', start + 500, start + 900, { + byteStart: 6, + byteEnd: 10, + }), + ]) + const lines = buildKaraokeLines({ + synced: true, + line: [ + timed('Hello world', 10000, 10900), + timed('Hello world', 30000, 30900), + ], + cueLine: [repeatedLine(0, 10000), repeatedLine(1, 30000)], + }) + + expect(lines).toHaveLength(2) + expect(lines[0].tokens[1].end).toBe(10900) + expect(lines[1].tokens[0].start).toBe(30100) + }) + + it('keeps multiline SRT, TTML, and plain text values intact', () => { + const lines = buildKaraokeLines({ + synced: true, + line: [{ start: 1000, value: 'first line\nsecond line' }], + }) + + expect(lines[0].value).toBe('first line\nsecond line') + }) + + it('keeps adjacent TTML syllable cue tokens in order', () => { + const lines = buildKaraokeLines({ + synced: true, + line: [timed('konni', 2000)], + cueLine: [ + cueLine(0, 'konni', 2000, 2600, [ + timed('ko', 2000, 2300, { byteStart: 0, byteEnd: 1 }), + timed('nni', 2300, 2600, { byteStart: 2, byteEnd: 4 }), + ]), + ], + }) + + expect(lines[0].tokens.map((token) => token.value)).toEqual(['ko', 'nni']) + }) + + it('keeps same-index agent cue lines as ordered voice lanes', () => { + const agentCue = (value, agentId, start, end) => + cueLine( + 0, + value, + start, + end, + [timed(value, start, end, { byteStart: 0, byteEnd: value.length - 1 })], + { agentId }, + ) + const lines = buildKaraokeLines({ + synced: true, + agents: [ + { id: 'lead', role: 'main' }, + { id: 'all', role: 'group' }, + { id: 'lead-bg', role: 'bg' }, + ], + line: [timed('Lead all echo', 1000, 4000)], + cueLine: [ + agentCue('echo', 'lead-bg', 2000, 3000), + agentCue('all', 'all', 1500, 2500), + agentCue('Lead', 'lead', 1000, 2000), + ], + }) + + expect(lines).toHaveLength(1) + expect(lines[0].lanes).toHaveLength(3) + expect(lines[0].lanes.map((lane) => lane.agentRole)).toEqual([ + 'main', + 'group', + 'bg', + ]) + expect(lines[0].tokens.map((token) => token.value)).toEqual([ + 'Lead', + 'all', + 'echo', + ]) + }) + + it('uses whitespace-aware text when cue-only fallback values are needed', () => { + const lines = buildKaraokeLines({ + synced: true, + cueLine: [ + cueLine(0, '', 1000, 3000, [ + timed('Lead', 1000, 2000), + timed('all', 2000, 3000), + timed('echo', 3000, 3500), + ]), + ], + }) + + expect(lines[0].value).toBe('Lead all echo') + }) + + it('preserves untimed lyric rows between surrounding timed rows', () => { + const lines = buildKaraokeLines({ + synced: true, + line: [ + { start: 1000, value: 'First verse' }, + { value: '[instrumental]' }, + { start: 2000, value: 'Second verse' }, + ], + }) + + expect(lines.map((line) => line.value)).toEqual([ + 'First verse', + '[instrumental]', + 'Second verse', + ]) + }) + + it('preserves source display order independently from timestamps', () => { + const lines = buildKaraokeLines({ + synced: true, + line: [ + { start: 3000, value: 'Displayed first' }, + { start: 1000, value: 'Displayed second' }, + { start: 2000, value: 'Displayed third' }, + ], + }) + + expect(lines.map((line) => line.value)).toEqual([ + 'Displayed first', + 'Displayed second', + 'Displayed third', + ]) + expect(lines.map((line) => line.start)).toEqual([3000, 1000, 2000]) + }) + + it('preserves timed blank rows as non-renderable timing markers', () => { + const lines = buildKaraokeLines({ + synced: true, + line: [ + { start: 1000, value: 'Before pause' }, + { start: 2000, value: '' }, + { start: 4000, value: 'After pause' }, + ], + }) + + expect(lines).toHaveLength(3) + expect(lines[1]).toMatchObject({ + start: 2000, + value: '', + renderable: false, + }) + }) + + it('falls back to the browser language when locale storage is unavailable', () => { + const storage = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => { + throw new DOMException('Access denied', 'SecurityError') + }) + + expect(getPreferredLyricLanguage()).toBe(navigator.language) + storage.mockRestore() + }) + + it('treats instrumental empty lyrics as no renderable content', () => { + expect( + hasStructuredLyricContent({ + kind: 'main', + lang: 'en', + synced: false, + line: [], + }), + ).toBe(false) + }) +}) diff --git a/ui/src/audioplayer/useEnhancedLyrics.js b/ui/src/audioplayer/useEnhancedLyrics.js new file mode 100644 index 000000000..69343c4ac --- /dev/null +++ b/ui/src/audioplayer/useEnhancedLyrics.js @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import subsonic from '../subsonic' +import { getPreferredLyricLanguage, selectLyricLayers } from './lyrics' + +export const emptyLyricLayers = Object.freeze({ + main: null, + translation: null, + pronunciation: null, +}) + +const LYRICS_CACHE_SCHEMA_VERSION = 3 +const MAX_LYRIC_CACHE_ENTRIES = 75 +const NEGATIVE_CACHE_TTL_MS = 30_000 + +const cache = new Map() +const inFlight = new Map() + +const normalizeLyricLayers = (layers) => ({ + main: layers?.main || null, + translation: layers?.translation || null, + pronunciation: layers?.pronunciation || null, +}) + +const readStructuredLyrics = (response) => + response?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || [] + +const buildCacheKey = ({ trackId, preferredLanguage, updatedAt }) => + [ + trackId || '', + updatedAt || '', + preferredLanguage || '', + LYRICS_CACHE_SCHEMA_VERSION, + ].join('\u0000') + +const rememberLyrics = (cacheKey, layers, expiresAt = null) => { + cache.delete(cacheKey) + cache.set(cacheKey, { layers, expiresAt }) + while (cache.size > MAX_LYRIC_CACHE_ENTRIES) { + const oldestCacheKey = cache.keys().next().value + cache.delete(oldestCacheKey) + } +} + +const readCachedLyrics = (cacheKey) => { + const cached = cache.get(cacheKey) + if (!cached) return null + if (cached.expiresAt != null && cached.expiresAt <= Date.now()) { + cache.delete(cacheKey) + return null + } + cache.delete(cacheKey) + cache.set(cacheKey, cached) + return cached.layers +} + +const createLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => { + const controller = new AbortController() + const entry = { + controller, + consumers: 0, + settled: false, + promise: null, + } + entry.promise = subsonic + .getLyricsBySongId(trackId, { signal: controller.signal }) + .then((response) => { + const selected = normalizeLyricLayers( + selectLyricLayers(readStructuredLyrics(response), preferredLanguage), + ) + const hasAnyLayer = Boolean( + selected.main || selected.translation || selected.pronunciation, + ) + rememberLyrics( + cacheKey, + selected, + hasAnyLayer ? null : Date.now() + NEGATIVE_CACHE_TTL_MS, + ) + return selected + }) + .finally(() => { + entry.settled = true + if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey) + }) + inFlight.set(cacheKey, entry) + return entry +} + +const acquireLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => { + const existing = inFlight.get(cacheKey) + const entry = + existing && !existing.controller.signal.aborted + ? existing + : createLyricsRequest({ trackId, preferredLanguage, cacheKey }) + entry.consumers += 1 + let released = false + return { + promise: entry.promise, + release: () => { + if (released) return + released = true + entry.consumers = Math.max(0, entry.consumers - 1) + if (!entry.settled && entry.consumers === 0) { + if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey) + entry.controller.abort() + } + }, + } +} + +const useEnhancedLyrics = ({ + trackId, + updatedAt, + disabled = false, + requested = true, +}) => { + const preferredLanguage = getPreferredLyricLanguage() + const cacheKey = useMemo( + () => buildCacheKey({ trackId, preferredLanguage, updatedAt }), + [preferredLanguage, trackId, updatedAt], + ) + const [state, setState] = useState(() => ({ + cacheKey, + layers: emptyLyricLayers, + loading: false, + error: null, + })) + const [requestGeneration, setRequestGeneration] = useState(0) + + const retry = useCallback(() => { + cache.delete(cacheKey) + setRequestGeneration((current) => current + 1) + }, [cacheKey]) + + useEffect(() => { + if (!trackId || disabled || !requested) { + setState({ + cacheKey, + layers: emptyLyricLayers, + loading: false, + error: null, + }) + return undefined + } + + const cached = readCachedLyrics(cacheKey) + if (cached) { + setState({ cacheKey, layers: cached, loading: false, error: null }) + return undefined + } + + let active = true + const request = acquireLyricsRequest({ + trackId, + preferredLanguage, + cacheKey, + }) + setState({ + cacheKey, + layers: emptyLyricLayers, + loading: true, + error: null, + }) + + request.promise + .then((layers) => { + if (!active) return + setState({ cacheKey, layers, loading: false, error: null }) + }) + .catch((error) => { + if (!active || error?.name === 'AbortError') return + cache.delete(cacheKey) + setState({ + cacheKey, + layers: emptyLyricLayers, + loading: false, + error, + }) + }) + + return () => { + active = false + request.release() + } + }, [ + cacheKey, + disabled, + preferredLanguage, + requestGeneration, + requested, + trackId, + ]) + + if (state.cacheKey !== cacheKey) { + return { + layers: emptyLyricLayers, + loading: Boolean(trackId && !disabled && requested), + error: null, + retry, + } + } + return { + layers: state.layers, + loading: state.loading, + error: state.error, + retry, + } +} + +export const clearEnhancedLyricsCache = () => { + cache.clear() + inFlight.forEach((entry) => entry.controller.abort()) + inFlight.clear() +} + +export default useEnhancedLyrics diff --git a/ui/src/audioplayer/useEnhancedLyrics.test.js b/ui/src/audioplayer/useEnhancedLyrics.test.js new file mode 100644 index 000000000..a1e26c43d --- /dev/null +++ b/ui/src/audioplayer/useEnhancedLyrics.test.js @@ -0,0 +1,205 @@ +import { act, waitFor } from '@testing-library/react' +import { renderHook } from '@testing-library/react-hooks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import subsonic from '../subsonic' +import useEnhancedLyrics, { + clearEnhancedLyricsCache, + emptyLyricLayers, +} from './useEnhancedLyrics' + +vi.mock('../subsonic', () => ({ + default: { + getLyricsBySongId: vi.fn(), + }, +})) + +const responseFor = (value, lang = 'en') => ({ + json: { + 'subsonic-response': { + lyricsList: { + structuredLyrics: [ + { + kind: 'main', + lang, + synced: true, + line: [{ start: 0, value }], + }, + ], + }, + }, + }, +}) + +const createDeferred = () => { + let resolve + let reject + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +const renderLyrics = (initialProps) => + renderHook((props) => useEnhancedLyrics(props), { initialProps }) + +const expectLyric = (result, value) => + waitFor(() => expect(result.current.layers.main?.line[0].value).toBe(value)) + +describe('useEnhancedLyrics', () => { + beforeEach(() => { + localStorage.setItem('locale', 'en') + clearEnhancedLyricsCache() + subsonic.getLyricsBySongId.mockReset() + }) + + afterEach(() => { + localStorage.clear() + clearEnhancedLyricsCache() + }) + + it('fetches enhanced structured lyrics and caches them by track id', async () => { + subsonic.getLyricsBySongId + .mockResolvedValueOnce(responseFor('Track one')) + .mockResolvedValueOnce(responseFor('Track two')) + + const { result, rerender } = renderLyrics({ trackId: 'song-1' }) + + await expectLyric(result, 'Track one') + expect(subsonic.getLyricsBySongId).toHaveBeenCalledWith( + 'song-1', + expect.objectContaining({ signal: expect.any(Object) }), + ) + + rerender({ trackId: 'song-2' }) + await expectLyric(result, 'Track two') + + rerender({ trackId: 'song-1' }) + await expectLyric(result, 'Track one') + expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) + }) + + it('clears previous lyrics while loading an uncached track', async () => { + const nextRequest = createDeferred() + subsonic.getLyricsBySongId + .mockResolvedValueOnce(responseFor('Track one')) + .mockReturnValueOnce(nextRequest.promise) + + const { result, rerender } = renderLyrics({ trackId: 'song-1' }) + + await expectLyric(result, 'Track one') + + rerender({ trackId: 'song-2' }) + + expect(result.current.loading).toBe(true) + expect(result.current.layers).toBe(emptyLyricLayers) + + nextRequest.resolve(responseFor('Track two')) + await expectLyric(result, 'Track two') + }) + + it('stays empty when disabled or not requested', () => { + const { result } = renderLyrics({ + trackId: 'song-1', + disabled: true, + requested: false, + }) + + expect(result.current.layers).toBe(emptyLyricLayers) + expect(result.current.loading).toBe(false) + expect(subsonic.getLyricsBySongId).not.toHaveBeenCalled() + }) + + it('resets layers and retries the same lyrics identity after an error', async () => { + const error = new Error('lyrics failed') + subsonic.getLyricsBySongId + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(responseFor('Recovered lyrics')) + + const { result } = renderLyrics({ trackId: 'song-error' }) + + await waitFor(() => expect(result.current.error).toBe(error)) + expect(result.current.layers).toBe(emptyLyricLayers) + expect(result.current.loading).toBe(false) + + act(() => { + result.current.retry() + }) + + await expectLyric(result, 'Recovered lyrics') + expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) + }) + + it('keeps cached lyrics separate by preferred language', async () => { + subsonic.getLyricsBySongId + .mockResolvedValueOnce(responseFor('English lyrics', 'en')) + .mockResolvedValueOnce(responseFor('Japanese lyrics', 'ja')) + + const { result, rerender } = renderLyrics({ trackId: 'song-1' }) + + await expectLyric(result, 'English lyrics') + + localStorage.setItem('locale', 'ja') + rerender({ trackId: 'song-1' }) + + await expectLyric(result, 'Japanese lyrics') + expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) + }) + + it('invalidates cache entries when the track update identity changes', async () => { + subsonic.getLyricsBySongId + .mockResolvedValueOnce(responseFor('Old lyrics')) + .mockResolvedValueOnce(responseFor('Updated lyrics')) + + const { result, rerender } = renderLyrics({ + trackId: 'song-1', + updatedAt: 'old', + }) + + await expectLyric(result, 'Old lyrics') + rerender({ trackId: 'song-1', updatedAt: 'new' }) + await expectLyric(result, 'Updated lyrics') + expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) + }) + + it('deduplicates concurrent consumers and aborts only after the last one leaves', () => { + let signal + subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + + const first = renderLyrics({ trackId: 'shared-song' }) + const second = renderLyrics({ trackId: 'shared-song' }) + + expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(1) + first.unmount() + expect(signal.aborted).toBe(false) + second.unmount() + expect(signal.aborted).toBe(true) + }) + + it('starts a fresh request after an abandoned request is aborted', () => { + const signals = [] + subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => { + signals.push(options.signal) + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + + const first = renderLyrics({ trackId: 'retry-song' }) + first.unmount() + expect(signals[0].aborted).toBe(true) + + const second = renderLyrics({ trackId: 'retry-song' }) + expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) + second.unmount() + }) +}) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..09f49464a 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -96,7 +96,6 @@ const getCoverArtUrl = (record, size, square) => { // This is a playlist return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) } else if (record.streamUrl !== undefined) { - // This is a radio station return baseUrl(url('getCoverArt', 'ra-' + record.id, options)) } else { return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) @@ -129,6 +128,13 @@ const getTopSongs = (artist, count = 50) => { return httpClient(url('getTopSongs', null, { artist, count })) } +const getLyricsBySongId = (id, requestOptions) => { + const requestUrl = url('getLyricsBySongId', id, { enhanced: true }) + return requestOptions + ? httpClient(requestUrl, requestOptions) + : httpClient(requestUrl) +} + const streamUrl = (id, options) => { return baseUrl( url('stream', id, { @@ -158,4 +164,5 @@ export default { getArtistInfo, getTopSongs, getSimilarSongs2, + getLyricsBySongId, } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index ad4764c24..cb4e23838 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -1,27 +1,56 @@ -import { vi } from 'vitest' +import { afterEach, vi } from 'vitest' import config from '../config' +import { httpClient } from '../dataProvider' import subsonic from './index' +vi.mock('../dataProvider', () => ({ + httpClient: vi.fn((target) => Promise.resolve({ target })), + clientUniqueId: 'test-client-id', + clientUniqueIdHeader: 'x-test-client-id', +})) + +const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor( + window, + 'localStorage', +) + +const mockSubsonicLocalStorage = () => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + setItem: vi.fn(), + clear: vi.fn(), + } + + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: localStorageMock, + }) +} + +afterEach(() => { + if (originalLocalStorageDescriptor) { + Object.defineProperty( + window, + 'localStorage', + originalLocalStorageDescriptor, + ) + } +}) + describe('getCoverArtUrl', () => { beforeEach(() => { // Mock window.location delete window.location window.location = { href: 'http://localhost:3000/app' } - // Mock localStorage values required by subsonic - const localStorageMock = { - getItem: vi.fn((key) => { - const values = { - username: 'testuser', - 'subsonic-token': 'testtoken', - 'subsonic-salt': 'testsalt', - } - return values[key] || null - }), - setItem: vi.fn(), - clear: vi.fn(), - } - Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + mockSubsonicLocalStorage() }) it('should return playlist cover art URL for records with sync property', () => { @@ -122,19 +151,26 @@ describe('getCoverArtUrl', () => { }) }) +describe('getLyricsBySongId', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSubsonicLocalStorage() + }) + + it('requests enhanced structured lyrics for the song id', async () => { + await subsonic.getLyricsBySongId('song-123') + + expect(httpClient).toHaveBeenCalledTimes(1) + const requestedUrl = httpClient.mock.calls[0][0] + expect(requestedUrl).toContain('/rest/getLyricsBySongId?') + expect(requestedUrl).toContain('id=song-123') + expect(requestedUrl).toContain('enhanced=true') + }) +}) + describe('getDiscCoverArtUrl', () => { beforeEach(() => { - const localStorageMock = { - getItem: vi.fn((key) => { - const values = { - username: 'testuser', - 'subsonic-token': 'testtoken', - 'subsonic-salt': 'testsalt', - } - return values[key] || null - }), - } - Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + mockSubsonicLocalStorage() }) it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => { @@ -174,18 +210,7 @@ describe('getDiscCoverArtUrl', () => { describe('getAvatarUrl', () => { beforeEach(() => { - // Mock localStorage values required by subsonic - const localStorageMock = { - getItem: vi.fn((key) => { - const values = { - username: 'testuser', - 'subsonic-token': 'testtoken', - 'subsonic-salt': 'testsalt', - } - return values[key] || null - }), - } - Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + mockSubsonicLocalStorage() }) it('should include username parameter', () => { @@ -197,17 +222,7 @@ describe('getAvatarUrl', () => { describe('reportPlayback', () => { beforeEach(() => { - const localStorageMock = { - getItem: vi.fn((key) => { - const values = { - username: 'testuser', - 'subsonic-token': 'testtoken', - 'subsonic-salt': 'testsalt', - } - return values[key] || null - }), - } - Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + mockSubsonicLocalStorage() }) it('should construct reportPlayback URL with correct parameters', () => { From b38362255eec285b8f7b52543cd074ae83f5b7bf Mon Sep 17 00:00:00 2001 From: ranokay Date: Fri, 17 Jul 2026 09:21:26 +0300 Subject: [PATCH 02/16] perf(ui): drive karaoke from a shared timeline - Precompute line and token windows and update karaoke paint outside React renders. - Synchronize highlighting, character lift, release, scrolling, and reduced motion. Signed-off-by: ranokay --- ui/src/audioplayer/LyricsLineRows.jsx | 630 +++++++++++++ ui/src/audioplayer/LyricsPanel.jsx | 902 +++++++++++++++++++ ui/src/audioplayer/LyricsPanel.test.jsx | 710 +++++++++++++++ ui/src/audioplayer/lyricsKaraokeConstants.js | 29 + ui/src/audioplayer/lyricsKaraokeStyles.js | 45 + ui/src/audioplayer/lyricsScroll.js | 73 ++ ui/src/audioplayer/lyricsScroll.test.js | 157 ++++ ui/src/audioplayer/lyricsSegments.js | 146 +++ ui/src/audioplayer/lyricsSegments.test.js | 15 + ui/src/audioplayer/lyricsTimeline.js | 312 +++++++ ui/src/audioplayer/lyricsTimeline.test.js | 196 ++++ ui/src/audioplayer/useLyricsTimeline.js | 713 +++++++++++++++ ui/src/audioplayer/useLyricsTimeline.test.js | 456 ++++++++++ 13 files changed, 4384 insertions(+) create mode 100644 ui/src/audioplayer/LyricsLineRows.jsx create mode 100644 ui/src/audioplayer/LyricsPanel.jsx create mode 100644 ui/src/audioplayer/LyricsPanel.test.jsx create mode 100644 ui/src/audioplayer/lyricsKaraokeConstants.js create mode 100644 ui/src/audioplayer/lyricsKaraokeStyles.js create mode 100644 ui/src/audioplayer/lyricsScroll.js create mode 100644 ui/src/audioplayer/lyricsScroll.test.js create mode 100644 ui/src/audioplayer/lyricsSegments.js create mode 100644 ui/src/audioplayer/lyricsSegments.test.js create mode 100644 ui/src/audioplayer/lyricsTimeline.js create mode 100644 ui/src/audioplayer/lyricsTimeline.test.js create mode 100644 ui/src/audioplayer/useLyricsTimeline.js create mode 100644 ui/src/audioplayer/useLyricsTimeline.test.js diff --git a/ui/src/audioplayer/LyricsLineRows.jsx b/ui/src/audioplayer/LyricsLineRows.jsx new file mode 100644 index 000000000..77d9fa00b --- /dev/null +++ b/ui/src/audioplayer/LyricsLineRows.jsx @@ -0,0 +1,630 @@ +import Typography from '@material-ui/core/Typography' +import clsx from 'clsx' +import React, { + memo, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { buildSegmentsFromLine } from './lyricsSegments' +import { + TOKEN_ACTIVE_ALPHA, + TOKEN_FUTURE_ALPHA, + TOKEN_WIPE_EDGE_PCT, + TOKEN_WIPE_SOFT_SPREAD_PCT, +} from './lyricsKaraokeConstants' +import { + buildEmphasisStyle, + isEmphasisRole, + parseColorRGB, +} from './lyricsKaraokeStyles' +import { resolveKaraokeTokenWindows } from './lyricsTimeline' + +const EMPHASIS_TONE = 0.7 +const EMPHASIS_PAINT_OVERHANG = '0.18em' + +const emphasisPaintOverhangStyle = { + marginInlineEnd: `-${EMPHASIS_PAINT_OVERHANG}`, + paddingInlineEnd: EMPHASIS_PAINT_OVERHANG, +} + +const graphemeSegmenter = + typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null + +const splitGraphemes = (value) => { + const text = String(value || '') + return graphemeSegmenter + ? Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment) + : Array.from(text) +} + +const waveTextRootStyle = { + display: 'inline-block', + position: 'relative', + verticalAlign: 'baseline', +} + +const waveTextMeasureStyle = { visibility: 'hidden' } + +const waveTextOverlayStyle = { + display: 'block', + inset: 0, + position: 'absolute', + whiteSpace: 'nowrap', +} + +const renderWaveText = (text, eligible, enabled, emphasized, className) => { + if (!eligible) return text + + return ( + + ) +} + +const tokenColor = (rgb, alpha) => { + const [r, g, b] = rgb || [255, 255, 255] + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +const toneEmphasisRGB = (rgb) => + rgb ? rgb.map((channel) => Math.round(channel * EMPHASIS_TONE)) : rgb + +const getTokenRGB = (token, rgb) => + isEmphasisRole(token) ? toneEmphasisRGB(rgb) : rgb + +const stripLayerColors = (style) => { + const result = { ...(style || {}) } + delete result.opacity + delete result.color + delete result.WebkitTextFillColor + delete result['--lyrics-active-color'] + return result +} + +const buildLineStyle = (line, style) => { + const emphasisStyle = buildEmphasisStyle(line) + return { + ...stripLayerColors(style), + ...emphasisStyle, + ...(emphasisStyle ? { filter: `brightness(${EMPHASIS_TONE})` } : {}), + } +} + +const buildStaticEmphasisStyle = (token) => { + const emphasisStyle = buildEmphasisStyle(token) + if (!emphasisStyle) return undefined + return { + ...emphasisStyle, + filter: `brightness(${EMPHASIS_TONE})`, + } +} + +const buildTokenData = (token, rgb, line) => { + const emphasized = isEmphasisRole(token) || isEmphasisRole(line) + const tonedRGB = getTokenRGB(token, rgb) + const futureColor = tokenColor(tonedRGB, TOKEN_FUTURE_ALPHA) + const gradientDoneColor = tokenColor( + tonedRGB, + 'var(--lyrics-token-active-alpha, 1)', + ) + const softColor = tokenColor( + tonedRGB, + TOKEN_FUTURE_ALPHA + (TOKEN_ACTIVE_ALPHA - TOKEN_FUTURE_ALPHA) * 0.58, + ) + const sweepRange = 100 + TOKEN_WIPE_SOFT_SPREAD_PCT + const activeStop = `calc(var(--lyrics-progress) * ${sweepRange}% - ${TOKEN_WIPE_SOFT_SPREAD_PCT}%)` + const softStop = `calc(var(--lyrics-progress) * ${sweepRange}% - ${TOKEN_WIPE_EDGE_PCT}%)` + const futureStop = `calc(var(--lyrics-progress) * ${sweepRange}%)` + const gradient = `linear-gradient(90deg, ${gradientDoneColor} 0%, ${gradientDoneColor} ${activeStop}, ${softColor} ${softStop}, ${futureColor} ${futureStop}, ${futureColor} 100%)` + + return { + style: { + '--lyrics-progress': 0, + '--lyrics-token-active-alpha': TOKEN_ACTIVE_ALPHA, + color: 'transparent', + WebkitTextFillColor: 'transparent', + backgroundImage: gradient, + backgroundSize: '100% 100%', + backgroundClip: 'text', + WebkitBackgroundClip: 'text', + ...buildEmphasisStyle(token), + ...(emphasized ? emphasisPaintOverhangStyle : {}), + }, + presentation: { + futureAlpha: TOKEN_FUTURE_ALPHA, + activeAlpha: TOKEN_ACTIVE_ALPHA, + gradient, + }, + } +} + +const isTimedWindow = (window) => + Number.isFinite(window?.start) && Number.isFinite(window?.end) + +const useStableTokenRef = (registerToken) => { + const registerTokenRef = useRef(registerToken) + const callbacksRef = useRef(new Map()) + registerTokenRef.current = registerToken + + return useCallback( + ({ key, lineIndex, window, presentation, waveEnabled }) => { + const signature = [ + lineIndex, + window?.start ?? '', + window?.end ?? '', + presentation?.futureAlpha ?? '', + presentation?.activeAlpha ?? '', + presentation?.gradient ?? '', + waveEnabled ? 1 : 0, + ].join('\u0000') + const cached = callbacksRef.current.get(key) + if (cached?.signature === signature) return cached.callback + + const descriptor = { lineIndex, window, presentation } + const callback = (node) => { + registerTokenRef.current?.(key, descriptor, node) + } + callbacksRef.current.set(key, { signature, callback }) + return callback + }, + [], + ) +} + +export const KaraokeLineRow = memo( + ({ + lineIndex, + line, + nextLineStart, + className, + style, + tokenClassName, + waveCharacterClassName, + registerToken, + renderCharacterWave = true, + rowKey = 'main', + testId, + }) => { + const getTokenRef = useStableTokenRef(registerToken) + const segments = useMemo(() => buildSegmentsFromLine(line), [line]) + const windows = useMemo( + () => resolveKaraokeTokenWindows(line, nextLineStart), + [line, nextLineStart], + ) + const tokenRGB = useMemo( + () => (style?.color ? parseColorRGB(style.color) : [255, 255, 255]), + [style?.color], + ) + const hasTimedTokens = windows.some( + (window) => window?.start != null && window?.end != null, + ) + const lineStyle = useMemo(() => buildLineStyle(line, style), [line, style]) + + return ( + + {segments.map((segment, idx) => { + if (!segment.token) + return {segment.text} + + const window = windows[segment.tokenIndex] + const timedWindow = isTimedWindow(window) ? window : null + const key = `${lineIndex}:${rowKey}:${segment.tokenIndex}:main` + const emphasized = + isEmphasisRole(segment.token) || isEmphasisRole(line) + const tokenData = buildTokenData(segment.token, tokenRGB, line) + return ( + + {renderWaveText( + segment.text, + Boolean(timedWindow), + Boolean(renderCharacterWave && timedWindow), + emphasized, + waveCharacterClassName, + )} + + ) + })} + + ) + }, +) + +KaraokeLineRow.displayName = 'KaraokeLineRow' + +const splitTextSegments = (value) => + (value || '') + .split(/(\s+)/) + .filter((text) => text.length > 0) + .map((text) => ({ + text, + token: null, + tokenIndex: -1, + isWhitespace: /^\s+$/.test(text), + })) + +const buildPronunciationParts = (line) => { + if (!line?.value) return [] + + const segments = buildSegmentsFromLine(line) + const tokenParts = segments + .filter((segment) => segment.token && segment.text.trim()) + .map((segment) => ({ + text: segment.text.trim(), + segment, + })) + + if (tokenParts.length > 0) return tokenParts + + return splitTextSegments(line.value) + .filter((segment) => !segment.isWhitespace) + .map((segment) => ({ + text: segment.text.trim(), + segment: null, + })) +} + +const canPairPronunciationSegment = (segment, hasTokenSegments) => + Boolean( + segment.token || + (!hasTokenSegments && !segment.isWhitespace && segment.text.trim()), + ) + +const buildStackedPronunciationSegments = (line, pronunciationLine) => { + const lineSegments = buildSegmentsFromLine(line) + const hasTokenSegments = lineSegments.some((segment) => segment.token) + const mainSegments = hasTokenSegments + ? lineSegments + : splitTextSegments(line?.value || '') + const pronunciationParts = buildPronunciationParts(pronunciationLine) + const pairableSegments = mainSegments.filter((segment) => + canPairPronunciationSegment(segment, hasTokenSegments), + ) + + if ( + !hasTokenSegments && + pronunciationParts.length > 0 && + pairableSegments.length > 0 && + pronunciationParts.length !== pairableSegments.length + ) { + return [ + { + text: line?.value || '', + token: null, + tokenIndex: -1, + isWhitespace: false, + pronunciation: pronunciationLine?.value?.trim() || '', + pronunciationSegment: null, + }, + ] + } + + const plainPronunciationParts = [] + const emphasisPronunciationParts = [] + for (const part of pronunciationParts) { + if (hasTokenSegments && isEmphasisRole(part.segment?.token)) { + emphasisPronunciationParts.push(part) + } else { + plainPronunciationParts.push(part) + } + } + + let pronunciationIndex = 0 + let plainPronunciationIndex = 0 + let emphasisPronunciationIndex = 0 + + const getPronunciationPart = (segment) => { + if (!hasTokenSegments) { + const part = pronunciationParts[pronunciationIndex] || null + pronunciationIndex += 1 + return part + } + + if (isEmphasisRole(segment.token)) { + const part = + emphasisPronunciationParts[emphasisPronunciationIndex] || null + emphasisPronunciationIndex += 1 + return part + } + + const part = plainPronunciationParts[plainPronunciationIndex] || null + plainPronunciationIndex += 1 + return part + } + + return mainSegments.map((segment) => { + if (!canPairPronunciationSegment(segment, hasTokenSegments)) { + return { ...segment, pronunciation: '' } + } + const pronunciationPart = getPronunciationPart(segment) + return { + ...segment, + pronunciation: pronunciationPart?.text || '', + pronunciationSegment: pronunciationPart?.segment || null, + } + }) +} + +export const KaraokeStackedLineRow = memo( + ({ + lineIndex, + line, + pronunciationLine, + pronunciationStyle, + nextLineStart, + className, + style, + tokenClassName, + waveCharacterClassName, + classes, + registerToken, + renderCharacterWave = true, + rowKey = 'main', + testId, + }) => { + const rowRef = useRef(null) + const getTokenRef = useStableTokenRef(registerToken) + const [isWrapped, setIsWrapped] = useState(false) + const segments = useMemo( + () => buildStackedPronunciationSegments(line, pronunciationLine), + [line, pronunciationLine], + ) + const mainWindows = useMemo( + () => resolveKaraokeTokenWindows(line, nextLineStart), + [line, nextLineStart], + ) + const pronunciationWindows = useMemo( + () => resolveKaraokeTokenWindows(pronunciationLine, null), + [pronunciationLine], + ) + const tokenRGB = useMemo( + () => (style?.color ? parseColorRGB(style.color) : [255, 255, 255]), + [style?.color], + ) + const pronunciationRGB = useMemo( + () => + pronunciationStyle?.color + ? parseColorRGB(pronunciationStyle.color) + : [255, 255, 255], + [pronunciationStyle?.color], + ) + const hasTimedTokens = mainWindows.some( + (window) => window?.start != null && window?.end != null, + ) + const lineStyle = useMemo(() => buildLineStyle(line, style), [line, style]) + + useLayoutEffect(() => { + const row = rowRef.current + if (!row) return undefined + + const updateWrappedState = () => { + const tokenRows = new Set( + Array.from(row.querySelectorAll('[data-stacked-token="true"]')).map( + (node) => node.offsetTop, + ), + ) + const wrapped = tokenRows.size > 1 + setIsWrapped((current) => (current === wrapped ? current : wrapped)) + } + + updateWrappedState() + const ResizeObserverConstructor = + typeof window !== 'undefined' ? window.ResizeObserver : null + if (!ResizeObserverConstructor) return undefined + const resizeObserver = new ResizeObserverConstructor(updateWrappedState) + resizeObserver.observe(row) + return () => resizeObserver.disconnect() + }, [segments]) + + return ( + + {segments.map((segment, idx) => { + const resolvedMainWindow = segment.token + ? mainWindows[segment.tokenIndex] + : null + const mainWindow = isTimedWindow(resolvedMainWindow) + ? resolvedMainWindow + : null + const mainKey = `${lineIndex}:${rowKey}:${segment.tokenIndex}:main` + const mainTokenData = segment.token + ? buildTokenData(segment.token, tokenRGB, line) + : null + const mainEmphasized = + isEmphasisRole(segment.token) || isEmphasisRole(line) + const mainText = segment.token ? ( + + {renderWaveText( + segment.text, + Boolean(mainWindow?.start != null && mainWindow?.end != null), + Boolean( + renderCharacterWave && + mainWindow?.start != null && + mainWindow?.end != null, + ), + mainEmphasized, + waveCharacterClassName, + )} + + ) : ( + + {segment.text} + + ) + + if (!segment.pronunciation) return mainText + + const pronunciationToken = segment.pronunciationSegment?.token + const resolvedPronunciationWindow = pronunciationToken + ? pronunciationWindows[segment.pronunciationSegment.tokenIndex] + : null + const pronunciationWindow = + mainWindow || + (isTimedWindow(resolvedPronunciationWindow) + ? resolvedPronunciationWindow + : null) + const pronunciationTokenIndex = + segment.pronunciationSegment?.tokenIndex >= 0 + ? segment.pronunciationSegment.tokenIndex + : segment.tokenIndex >= 0 + ? segment.tokenIndex + : idx + const pronunciationKey = `${lineIndex}:${rowKey}:${pronunciationTokenIndex}:pronunciation` + const pronunciationTokenData = pronunciationWindow + ? buildTokenData( + pronunciationToken || segment.token, + pronunciationRGB, + pronunciationLine || line, + ) + : null + const pronunciationEmphasized = Boolean( + isEmphasisRole(pronunciationToken) || + isEmphasisRole(segment.token) || + isEmphasisRole(pronunciationLine) || + isEmphasisRole(line), + ) + return ( + + {mainText} + + {renderWaveText( + segment.pronunciation, + Boolean(pronunciationWindow), + Boolean(renderCharacterWave && pronunciationWindow), + pronunciationEmphasized, + waveCharacterClassName, + )} + + + ) + })} + + ) + }, +) + +KaraokeStackedLineRow.displayName = 'KaraokeStackedLineRow' diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx new file mode 100644 index 000000000..bc6cfe20d --- /dev/null +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -0,0 +1,902 @@ +import { alpha, makeStyles, useTheme } from '@material-ui/core/styles' +import clsx from 'clsx' +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + buildKaraokeLines, + hasStructuredLyricContent, + hasUsableKaraokeTiming, +} from './lyrics' +import { KaraokeLineRow, KaraokeStackedLineRow } from './LyricsLineRows' +import { + KARAOKE_ANIMATION_MS, + KARAOKE_AUX_LINE_HEIGHT, + KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO, + KARAOKE_EASING, + 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_OPACITY, +} from './lyricsKaraokeConstants' +import { + animateScrollTop, + cancelScrollAnimation, + getAnchoredScrollTop, + getScrollEndPadding, +} from './lyricsScroll' +import useLyricsTimeline from './useLyricsTimeline' + +const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_ANIMATION_MS}ms ${KARAOKE_EASING}` +const KARAOKE_IDLE_LAYER_OPACITY = 0.49 +const KARAOKE_TRANSLATION_IDLE_OPACITY = + KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY + +const useStyles = makeStyles((theme) => ({ + root: { + height: '100%', + minHeight: 0, + display: 'flex', + flexDirection: 'column', + color: theme.palette.text.primary, + }, + inlineRoot: { + borderRadius: 'inherit', + background: 'transparent', + backdropFilter: 'blur(16px)', + WebkitBackdropFilter: 'blur(16px)', + }, + body: { + flex: 1, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + padding: theme.spacing(4, 2.25, 3.25), + overscrollBehavior: 'contain', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + maskImage: + 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)', + WebkitMaskImage: + 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)', + '&::-webkit-scrollbar': { + width: 0, + height: 0, + }, + }, + bodyTopFade: { + maskImage: + 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 8px, #000 24px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)', + WebkitMaskImage: + 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 8px, #000 24px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)', + }, + bodyUserScrolling: { + scrollbarWidth: 'thin', + msOverflowStyle: 'auto', + '&::-webkit-scrollbar': { + width: 8, + height: 8, + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: theme.palette.action.disabled, + borderRadius: 999, + }, + '&::-webkit-scrollbar-track': { + backgroundColor: 'transparent', + }, + }, + inlineBody: { + padding: theme.spacing(0.5, 1.25, 1.5), + textAlign: 'center', + }, + lines: { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + gap: theme.spacing(3), + }, + lineGroup: { + width: '100%', + borderRadius: theme.shape.borderRadius, + '--lyrics-main-current-color': + 'var(--lyrics-main-idle-color, currentColor)', + '--lyrics-pronunciation-current-color': + 'var(--lyrics-pronunciation-idle-color, currentColor)', + '--lyrics-translation-current-color': + 'var(--lyrics-translation-idle-color, currentColor)', + '--lyrics-layer-opacity': KARAOKE_IDLE_LAYER_OPACITY, + transform: 'translateY(0)', + transition: `background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`, + '&[role="button"]:focus-visible': { + backgroundColor: alpha(theme.palette.text.primary, 0.055), + }, + '@media (hover: hover) and (pointer: fine)': { + '&[role="button"]:hover': { + backgroundColor: alpha(theme.palette.text.primary, 0.055), + }, + }, + '&[data-raised="true"][data-line-motion="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}`, + }, + '&[data-raised="true"][data-line-motion="character"][data-character-wave="false"]': + { + transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + }, + '&[data-active="true"]': { + '--lyrics-main-current-color': + 'var(--lyrics-main-active-color, var(--lyrics-main-idle-color, currentColor))', + '--lyrics-pronunciation-current-color': + 'var(--lyrics-pronunciation-active-color, var(--lyrics-pronunciation-idle-color, currentColor))', + '--lyrics-translation-current-color': + 'var(--lyrics-translation-active-color, var(--lyrics-translation-idle-color, currentColor))', + '--lyrics-layer-opacity': 1, + }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + transform: 'none', + }, + }, + waveCharacter: { + display: 'inline-block', + verticalAlign: 'baseline', + transform: 'translateY(0)', + transformOrigin: 'center bottom', + '@media (prefers-reduced-motion: reduce)': { + transform: 'none !important', + }, + }, + line: { + display: 'inline-block', + width: '100%', + maxWidth: '100%', + fontWeight: 700, + fontSize: 24, + lineHeight: 1.18, + overflowWrap: 'anywhere', + whiteSpace: 'pre-wrap', + letterSpacing: 0, + color: 'var(--lyrics-main-current-color, currentColor)', + WebkitTextFillColor: 'var(--lyrics-main-current-color, currentColor)', + '&[data-tokenized="false"]': { + opacity: 'var(--lyrics-layer-opacity)', + color: 'var(--lyrics-main-active-color, currentColor)', + WebkitTextFillColor: 'var(--lyrics-main-active-color, currentColor)', + transition: KARAOKE_LAYER_OPACITY_TRANSITION, + }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + }, + }, + inlineLine: { + fontSize: 24, + }, + 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: KARAOKE_TRANSLATION_IDLE_OPACITY, + color: 'var(--lyrics-translation-active-color, currentColor)', + WebkitTextFillColor: 'var(--lyrics-translation-active-color, currentColor)', + transition: KARAOKE_LAYER_OPACITY_TRANSITION, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + transform: 'none', + }, + }, + characterWaveAuxLine: { + transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + transform: 'none', + }, + }, + activeAuxLine: { + opacity: KARAOKE_TRANSLATION_OPACITY, + }, + stackedToken: { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'flex-start', + verticalAlign: 'top', + width: 'max-content', + minWidth: 0, + maxWidth: '100%', + overflowWrap: 'anywhere', + whiteSpace: 'pre-wrap', + paddingRight: theme.spacing(0.5), + marginBottom: theme.spacing(0.25), + }, + wrappedStackedLine: { + '& $stackedToken': { + marginBottom: theme.spacing(0.95), + }, + }, + stackedSpacer: { + display: 'inline', + whiteSpace: 'pre-wrap', + }, + stackedMainText: { + display: 'block', + lineHeight: 1.05, + maxWidth: '100%', + overflowWrap: 'anywhere', + whiteSpace: 'pre-wrap', + }, + stackedPronunciation: { + display: 'block', + marginTop: theme.spacing(0.15), + fontSize: 15, + lineHeight: 1.05, + fontWeight: 700, + maxWidth: '100%', + overflowWrap: 'anywhere', + whiteSpace: 'pre-wrap', + color: 'var(--lyrics-pronunciation-current-color, currentColor)', + WebkitTextFillColor: + 'var(--lyrics-pronunciation-current-color, currentColor)', + '&[data-timed="false"]': { + color: 'var(--lyrics-pronunciation-active-color, currentColor)', + WebkitTextFillColor: + 'var(--lyrics-pronunciation-active-color, currentColor)', + }, + '&[data-timed="true"]': { + transition: 'none', + }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + }, + }, + translationLine: { + fontWeight: 600, + }, + token: { + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + fontKerning: 'none', + fontVariantLigatures: 'none', + }, + voiceLanes: { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + gap: theme.spacing(0.7), + }, + secondaryVoiceLane: { + fontSize: 22, + }, + emptyState: { + flex: 1, + minHeight: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(3), + color: alpha(theme.palette.text.primary, 0.68), + fontWeight: 600, + textAlign: 'center', + }, +})) + +const normalizeLineText = (value) => + String(value || '') + .normalize('NFKC') + .toLocaleLowerCase() + .replace(/[\p{P}\p{S}\s]+/gu, '') + +const shouldShowAuxLine = (mainLine, auxLine) => + Boolean( + auxLine?.value && + normalizeLineText(auxLine.value) !== normalizeLineText(mainLine?.value), + ) + +const finiteLineTime = (value) => { + if (value == null || value === '') return null + const number = Number(value) + return Number.isFinite(number) ? number : null +} + +const getLayerMatchWindow = (lines, index) => { + const line = lines[index] + if (!line) return { start: null, end: null } + return { + start: finiteLineTime(line.start), + end: finiteLineTime(line.end) ?? finiteLineTime(lines[index + 1]?.start), + } +} + +const buildUniqueLayerMap = (mainLines, layerLines) => { + if (!mainLines.length || !layerLines.length) return {} + + const candidates = [] + for (let layerIndex = 0; layerIndex < layerLines.length; layerIndex += 1) { + if (layerLines[layerIndex]?.renderable === false) continue + const layerWindow = getLayerMatchWindow(layerLines, layerIndex) + for (let mainIndex = 0; mainIndex < mainLines.length; mainIndex += 1) { + if (mainLines[mainIndex]?.renderable === false) continue + const mainWindow = getLayerMatchWindow(mainLines, mainIndex) + let score = null + + if (layerWindow.start != null && mainWindow.start != null) { + const mainEnd = mainWindow.end ?? mainWindow.start + const layerEnd = layerWindow.end ?? layerWindow.start + const overlap = + Math.min(mainEnd, layerEnd) - + Math.max(mainWindow.start, layerWindow.start) + const mainDuration = Math.max(0, mainEnd - mainWindow.start) + const maxDelta = Math.max(550, Math.min(1400, mainDuration + 420)) + const startDelta = Math.abs(layerWindow.start - mainWindow.start) + if (overlap < 0 && startDelta > maxDelta) continue + score = + startDelta + + Math.abs(layerIndex - mainIndex) * 30 + + (overlap < 0 ? 200 : 0) + } else if (layerIndex === mainIndex) { + score = 2000 + } + + if (score != null) { + candidates.push({ layerIndex, mainIndex, score }) + } + } + } + + candidates.sort( + (left, right) => + left.score - right.score || + left.layerIndex - right.layerIndex || + left.mainIndex - right.mainIndex, + ) + + const usedLayers = new Set() + const usedMainLines = new Set() + const result = {} + for (const candidate of candidates) { + if ( + usedLayers.has(candidate.layerIndex) || + usedMainLines.has(candidate.mainIndex) + ) { + continue + } + usedLayers.add(candidate.layerIndex) + usedMainLines.add(candidate.mainIndex) + result[candidate.mainIndex] = layerLines[candidate.layerIndex] + } + return result +} + +const getLineLanes = (line) => + Array.isArray(line?.lanes) && line.lanes.length > 0 ? line.lanes : [line] + +const buildSynchronizedTranslationLine = (_mainLine, translationLine) => { + if (!translationLine) return null + return { + ...translationLine, + tokens: [], + lanes: undefined, + } +} + +const buildLineGroupStyle = (canSeekLine, layerStyles) => ({ + cursor: canSeekLine ? 'pointer' : undefined, + '--lyrics-main-idle-color': layerStyles.main.color, + '--lyrics-main-active-color': + layerStyles.main['--lyrics-active-color'] || layerStyles.main.color, + '--lyrics-pronunciation-idle-color': layerStyles.pronunciation.color, + '--lyrics-pronunciation-active-color': + layerStyles.pronunciation['--lyrics-active-color'] || + layerStyles.pronunciation.color, + '--lyrics-translation-idle-color': layerStyles.translation.color, + '--lyrics-translation-active-color': + layerStyles.translation['--lyrics-active-color'] || + layerStyles.translation.color, +}) + +const usePrefersReducedMotion = () => { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return undefined + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + const updatePreference = () => + setPrefersReducedMotion(Boolean(mediaQuery.matches)) + updatePreference() + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', updatePreference) + return () => mediaQuery.removeEventListener('change', updatePreference) + } + mediaQuery.addListener(updatePreference) + return () => mediaQuery.removeListener(updatePreference) + }, []) + + return prefersReducedMotion +} + +const LyricsPanel = ({ + visible = true, + mainLyric, + translationLyric, + pronunciationLyric, + showTranslation, + showPronunciation, + audioInstance, + inline = false, + loading = false, + error = null, + labels = {}, +}) => { + const classes = useStyles() + const theme = useTheme() + const bodyRef = useRef(null) + const scrollAnimationRef = useRef(null) + const scrollbarTimerRef = useRef(null) + const manualScrollTimerRef = useRef(null) + const manualScrollUntilRef = useRef(0) + const [showScrollbar, setShowScrollbar] = useState(false) + const [autoScrollResumeKey, setAutoScrollResumeKey] = useState(0) + const [layoutVersion, setLayoutVersion] = useState(0) + const [hasTopFade, setHasTopFade] = useState(false) + const [scrollEndPadding, setScrollEndPadding] = useState(0) + const prefersReducedMotion = usePrefersReducedMotion() + const activeLineAnchorRatio = inline + ? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO + : KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO + + const mainLines = useMemo(() => buildKaraokeLines(mainLyric), [mainLyric]) + const translationLines = useMemo( + () => buildKaraokeLines(translationLyric), + [translationLyric], + ) + const pronunciationLines = useMemo( + () => buildKaraokeLines(pronunciationLyric), + [pronunciationLyric], + ) + const hasTimedMainLines = useMemo( + () => hasUsableKaraokeTiming(mainLines), + [mainLines], + ) + const { + activeIndexes, + primaryIndex: activeIndex, + scrollTargetIndex, + registerLine, + registerToken, + getLineNode, + syncNow, + timeline, + } = useLyricsTimeline({ + lines: mainLines, + audioInstance, + visible: visible && hasTimedMainLines, + reducedMotion: prefersReducedMotion, + }) + const activeIndexSet = useMemo(() => new Set(activeIndexes), [activeIndexes]) + + const trByMainIndex = useMemo(() => { + if (!showTranslation || translationLines.length === 0) return {} + return buildUniqueLayerMap(mainLines, translationLines) + }, [mainLines, translationLines, showTranslation]) + + const prByMainIndex = useMemo(() => { + if (!showPronunciation || pronunciationLines.length === 0) return {} + return buildUniqueLayerMap(mainLines, pronunciationLines) + }, [mainLines, pronunciationLines, showPronunciation]) + + const colors = useMemo( + () => ({ + main: theme.palette.text.primary, + pronunciation: theme.palette.primary.main, + translation: + theme.palette.text.secondary || + theme.palette.secondary?.main || + theme.palette.text.primary, + }), + [theme], + ) + + const layerStyles = useMemo(() => { + const styleFor = (layer) => { + const sourceColor = colors[layer] + if (layer === 'translation') { + return { + opacity: 1, + color: sourceColor, + '--lyrics-active-color': sourceColor, + } + } + if (!hasTimedMainLines) { + return { + opacity: 1, + color: alpha(sourceColor, layer === 'main' ? 0.98 : 0.86), + } + } + const activeAlpha = layer === 'main' ? 0.98 : 0.78 + const pronunciationFadeRatio = 0.38 / 0.78 + const idleAlpha = activeAlpha * pronunciationFadeRatio + return { + opacity: 1, + color: alpha(sourceColor, idleAlpha), + '--lyrics-active-color': alpha(sourceColor, activeAlpha), + } + } + return { + main: styleFor('main'), + pronunciation: styleFor('pronunciation'), + translation: styleFor('translation'), + } + }, [colors, hasTimedMainLines]) + + const showScrollbarForManualScroll = useCallback(() => { + if (scrollbarTimerRef.current) { + window.clearTimeout(scrollbarTimerRef.current) + } + setShowScrollbar(true) + scrollbarTimerRef.current = window.setTimeout(() => { + setShowScrollbar(false) + scrollbarTimerRef.current = null + }, KARAOKE_SCROLLBAR_VISIBLE_MS) + }, []) + + const clearManualScrollTimer = useCallback(() => { + if (manualScrollTimerRef.current) { + window.clearTimeout(manualScrollTimerRef.current) + manualScrollTimerRef.current = null + } + }, []) + + const resumeAutoScroll = useCallback(() => { + clearManualScrollTimer() + manualScrollUntilRef.current = 0 + setAutoScrollResumeKey((current) => current + 1) + }, [clearManualScrollTimer]) + + const markManualScrollIntent = useCallback(() => { + cancelScrollAnimation(scrollAnimationRef) + clearManualScrollTimer() + manualScrollUntilRef.current = + performance.now() + KARAOKE_MANUAL_SCROLL_PAUSE_MS + manualScrollTimerRef.current = window.setTimeout(() => { + manualScrollTimerRef.current = null + resumeAutoScroll() + }, KARAOKE_MANUAL_SCROLL_PAUSE_MS) + showScrollbarForManualScroll() + }, [clearManualScrollTimer, resumeAutoScroll, showScrollbarForManualScroll]) + + const updateTopFade = useCallback(() => { + const body = bodyRef.current + setHasTopFade((current) => { + const next = Boolean(body && body.scrollTop > 1) + return current === next ? current : next + }) + }, []) + + useEffect( + () => () => { + if (scrollbarTimerRef.current) { + window.clearTimeout(scrollbarTimerRef.current) + } + if (manualScrollTimerRef.current) { + window.clearTimeout(manualScrollTimerRef.current) + } + cancelScrollAnimation(scrollAnimationRef) + }, + [], + ) + + useLayoutEffect(() => { + const body = bodyRef.current + if (!visible || !body) return undefined + const ResizeObserverConstructor = + typeof window !== 'undefined' ? window.ResizeObserver : null + if (!ResizeObserverConstructor) return undefined + const resizeObserver = new ResizeObserverConstructor(() => { + setLayoutVersion((current) => current + 1) + }) + resizeObserver.observe(body) + return () => resizeObserver.disconnect() + }, [visible]) + + useLayoutEffect(() => { + const body = bodyRef.current + if (!visible || !body) { + setScrollEndPadding(0) + return + } + const nextPadding = getScrollEndPadding(body, activeLineAnchorRatio) + setScrollEndPadding((current) => + current === nextPadding ? current : nextPadding, + ) + }, [activeLineAnchorRatio, layoutVersion, mainLines.length, visible]) + + useLayoutEffect(() => { + const body = bodyRef.current + if (!visible || !body) return + cancelScrollAnimation(scrollAnimationRef) + clearManualScrollTimer() + manualScrollUntilRef.current = 0 + body.scrollTop = 0 + setHasTopFade(false) + }, [clearManualScrollTimer, mainLyric, visible]) + + useEffect(() => { + if (!visible || !hasTimedMainLines || scrollTargetIndex < 0) { + cancelScrollAnimation(scrollAnimationRef) + return undefined + } + + const animFrameId = window.requestAnimationFrame(() => { + if (manualScrollUntilRef.current > performance.now()) return + const body = bodyRef.current + const targetNode = getLineNode(scrollTargetIndex) + if (!body || !targetNode) return + animateScrollTop({ + body, + targetTop: getAnchoredScrollTop( + body, + targetNode, + activeLineAnchorRatio, + ), + reducedMotion: prefersReducedMotion, + scrollAnimationRef, + }) + }) + + return () => window.cancelAnimationFrame(animFrameId) + }, [ + activeLineAnchorRatio, + autoScrollResumeKey, + getLineNode, + hasTimedMainLines, + layoutVersion, + prefersReducedMotion, + scrollTargetIndex, + visible, + ]) + + if (!visible) return null + + if (!hasStructuredLyricContent(mainLyric) || mainLines.length === 0) { + const message = loading + ? labels.loading || 'Loading lyrics' + : error + ? labels.unavailable || 'Lyrics unavailable' + : labels.empty || 'No lyrics available' + return ( +
event.stopPropagation() : undefined} + > +
+ {message} +
+
+ ) + } + + const seekToLine = (line) => { + if (!audioInstance || line.start == null) return + audioInstance.currentTime = line.start / 1000 + syncNow(line.start, true) + resumeAutoScroll() + } + + return ( +
event.stopPropagation() : undefined} + > +
+
0 + ? { paddingBottom: scrollEndPadding } + : undefined + } + > + {mainLines.map((line, idx) => { + if (line.renderable === false) return null + const trLine = trByMainIndex[idx] + const prLine = prByMainIndex[idx] + const mainNextLineStart = + timeline.windows[idx]?.nextTimedStart ?? null + const showTr = shouldShowAuxLine(line, trLine) + const showPr = shouldShowAuxLine(line, prLine) + const lineLanes = getLineLanes(line) + const usesCharacterRise = lineLanes.some( + (lane) => Array.isArray(lane?.tokens) && lane.tokens.length > 0, + ) + const canSeekLine = Boolean(audioInstance && line.start != null) + const isActiveLine = activeIndexSet.has(idx) + const renderCharacterWave = Boolean( + !prefersReducedMotion && hasTimedMainLines && isActiveLine, + ) + const isStaticLine = !hasTimedMainLines + return ( +
registerLine(idx, node) + : undefined + } + className={classes.lineGroup} + data-line-motion={usesCharacterRise ? 'character' : 'line'} + data-character-wave={renderCharacterWave ? 'true' : 'false'} + data-active={isStaticLine || isActiveLine ? 'true' : 'false'} + {...(isStaticLine + ? { + 'data-lifecycle': 'active', + 'data-highlight-active': 'true', + 'data-raised': 'false', + } + : {})} + aria-current={idx === activeIndex ? 'true' : undefined} + data-scroll-target={ + idx === scrollTargetIndex ? 'true' : 'false' + } + data-testid="lyrics-line-group" + style={buildLineGroupStyle(canSeekLine, layerStyles)} + role={canSeekLine ? 'button' : undefined} + tabIndex={canSeekLine ? 0 : undefined} + onClick={() => seekToLine(line)} + onMouseDown={ + canSeekLine + ? (event) => { + event.preventDefault() + } + : undefined + } + onKeyDown={(event) => { + if ( + canSeekLine && + (event.key === 'Enter' || event.key === ' ') + ) { + event.preventDefault() + seekToLine(line) + } + }} + > + {lineLanes.length > 1 ? ( +
+ {lineLanes.map((lane, laneIdx) => { + const laneClassName = clsx(classes.line, { + [classes.inlineLine]: inline, + [classes.secondaryVoiceLane]: laneIdx > 0, + }) + const rowKey = lane.key || `lane-${laneIdx}` + + return showPr && laneIdx === 0 ? ( + + ) : ( + + ) + })} +
+ ) : showPr ? ( + + ) : ( + + )} + {showTr && ( + + )} +
+ ) + })} +
+
+
+ ) +} + +export default LyricsPanel diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx new file mode 100644 index 000000000..40bd9ea9b --- /dev/null +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -0,0 +1,710 @@ +import React from 'react' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LyricsPanel from './LyricsPanel' +import { KaraokeLineRow } from './LyricsLineRows' +import { + KARAOKE_ANIMATION_MS, + KARAOKE_CHARACTER_LIFT_PX, + KARAOKE_LINE_LIFT_PX, + KARAOKE_MANUAL_SCROLL_PAUSE_MS, + KARAOKE_TRANSLATION_OPACITY, +} from './lyricsKaraokeConstants' + +const theme = createTheme({ + palette: { + primary: { main: '#2266aa' }, + text: { primary: '#111111', secondary: '#778899' }, + }, +}) + +const panel = (props, selectedTheme = theme) => ( + + + +) + +const renderPanel = (props, selectedTheme = theme) => { + const view = render(panel(props, selectedTheme)) + return { + ...view, + rerenderPanel: (nextProps) => + view.rerender(panel(nextProps, selectedTheme)), + } +} + +const mainLyric = { + synced: true, + line: [{ start: 0, end: 1000, value: 'Main line' }], +} + +const createTokenizedLyric = (value, first, second) => ({ + synced: true, + line: [{ start: 0, end: 1000, value }], + cueLine: [ + { + index: 0, + start: 0, + end: 1000, + value, + cue: [ + { start: 0, end: 500, value: first, byteStart: 0, byteEnd: 3 }, + { start: 500, end: 1000, value: second, byteStart: 5, byteEnd: 8 }, + ], + }, + ], +}) + +const tokenizedMainLyric = createTokenizedLyric('Main line', 'Main', 'line') +const tokenizedPronunciationLyric = createTokenizedLyric( + 'mein lain', + 'mein', + 'lain', +) + +const voiceCue = (value, agentId, start, end) => ({ + index: 0, + start, + end, + value, + agentId, + cue: [{ start, end, value }], +}) + +const multiAgentLyric = { + synced: true, + agents: [ + { id: 'lead', role: 'main' }, + { id: 'all', role: 'group' }, + { id: 'echo', role: 'bg' }, + ], + line: [{ start: 1000, end: 4000, value: 'Lead all echo' }], + cueLine: [ + voiceCue('Lead', 'lead', 1000, 2000), + voiceCue('all', 'all', 1500, 2600), + voiceCue('echo', 'echo', 2200, 3400), + ], +} + +const createAudio = ({ currentTime = 0, duration = 10 } = {}) => { + const audio = new EventTarget() + audio.currentTime = currentTime + audio.duration = duration + audio.paused = true + audio.playbackRate = 1 + audio.seeking = false + return audio +} + +describe('', () => { + const originalMatchMedia = window.matchMedia + + beforeEach(() => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + window.matchMedia = originalMatchMedia + cleanup() + }) + + it('renders main, stacked pronunciation, and translation in layer order', () => { + renderPanel({ + mainLyric: tokenizedMainLyric, + pronunciationLyric: tokenizedPronunciationLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translation line' }], + }, + showPronunciation: true, + showTranslation: true, + }) + + const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token') + expect(pronunciation).toHaveLength(2) + expect(pronunciation[0]).toHaveTextContent('mein') + expect(pronunciation[1]).toHaveTextContent('lain') + expect(screen.getByText('translation line')).toBeInTheDocument() + }) + + it('updates every pronunciation-only token with a distinct registry key', () => { + const audioInstance = createAudio() + renderPanel({ + mainLyric, + pronunciationLyric: tokenizedPronunciationLyric, + showPronunciation: true, + audioInstance, + }) + + const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token') + act(() => { + audioInstance.currentTime = 0.75 + audioInstance.dispatchEvent(new Event('seeking')) + }) + + expect(pronunciation[0]).toHaveAttribute('data-lyrics-state', 'completed') + expect(pronunciation[1]).toHaveAttribute('data-lyrics-state', 'active') + }) + + it('renders incomplete pronunciation timing as static text', () => { + renderPanel({ + mainLyric, + pronunciationLyric: { + synced: true, + line: [{ value: 'mein lain' }], + cueLine: [ + { + index: 0, + value: 'mein lain', + cue: [{ value: 'mein' }, { value: 'lain' }], + }, + ], + }, + showPronunciation: true, + }) + + screen.getAllByTestId('lyrics-pronunciation-token').forEach((token) => { + expect(token).toHaveAttribute('data-timed', 'false') + expect(token).not.toHaveAttribute('aria-label') + }) + }) + + it('keeps token ref callbacks stable across equivalent rerenders', () => { + const registerToken = vi.fn() + const line = { + start: 0, + end: 1000, + value: 'Main', + tokens: [ + { + start: 0, + end: 1000, + value: 'Main', + byteStart: 0, + byteEnd: 3, + }, + ], + } + const row = () => ( + + ) + const { rerender } = render(row()) + + expect(registerToken).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.any(HTMLElement), + ) + registerToken.mockClear() + + rerender(row()) + + expect(registerToken).not.toHaveBeenCalled() + }) + + it('renders each translation line under only its closest main line', () => { + renderPanel({ + mainLyric: { + synced: true, + line: [ + { start: 0, end: 1000, value: 'First main line' }, + { start: 1000, end: 2000, value: 'Closest main line' }, + { start: 2000, end: 3000, value: 'Later main line' }, + ], + }, + translationLyric: { + synced: true, + line: [{ start: 1100, end: 2800, value: 'One translated line' }], + }, + showTranslation: true, + }) + + const translations = screen.getAllByText('One translated line') + expect(translations).toHaveLength(1) + expect( + translations[0].closest('[data-testid="lyrics-line-group"]'), + ).toHaveTextContent('Closest main line') + }) + + it('suppresses duplicate translations without shifting untimed indexes', () => { + renderPanel({ + mainLyric: { + synced: false, + line: [{ value: 'Hello' }, { value: 'World' }], + }, + translationLyric: { + synced: false, + line: [{ value: 'Hello' }, { value: 'Mundo' }], + }, + showTranslation: true, + }) + + const groups = screen.getAllByTestId('lyrics-line-group') + expect(groups[0]).toHaveTextContent('Hello') + expect(groups[0]).not.toHaveTextContent('Mundo') + expect(groups[1]).toHaveTextContent('World') + expect(groups[1]).toHaveTextContent('Mundo') + }) + + it('renders line-level pronunciation without inventing word timing', () => { + renderPanel({ + mainLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: '我总要给一些别的' }], + }, + pronunciationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'wo zong yao gei yi xie bie de' }], + }, + showPronunciation: true, + audioInstance: { currentTime: 0.2, paused: true }, + }) + + expect(screen.getByText('我总要给一些别的')).toBeInTheDocument() + const pronunciation = screen.getByText('wo zong yao gei yi xie bie de') + const group = pronunciation.closest('[data-testid="lyrics-line-group"]') + expect(group).toHaveAttribute('data-active', 'true') + expect( + group.style.getPropertyValue('--lyrics-pronunciation-active-color'), + ).not.toBe('') + expect(pronunciation.style.color).toBe('') + }) + + it('uses one shared opacity animation for every static line layer', () => { + renderPanel({ + mainLyric, + pronunciationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'main pronunciation' }], + }, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showPronunciation: true, + showTranslation: true, + audioInstance: { currentTime: 0.5, paused: true }, + }) + + const mainRow = screen + .getByTestId('lyrics-line-group') + .querySelector('[data-tokenized]') + const translationRow = screen + .getByText('translated line') + .closest('[data-tokenized]') + const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token') + + ;[mainRow, translationRow].forEach((row) => { + expect(row).toHaveAttribute('data-layer-animation', 'shared-opacity') + expect(row).toHaveAttribute('data-tokenized', 'false') + expect(row.style.opacity).toBe('') + expect(window.getComputedStyle(row).transition).toContain( + `opacity ${KARAOKE_ANIMATION_MS}ms`, + ) + }) + pronunciation.forEach((token) => + expect(token).toHaveAttribute('data-timed', 'false'), + ) + expect(window.getComputedStyle(translationRow).opacity).toBe( + String(KARAOKE_TRANSLATION_OPACITY), + ) + }) + + it('raises a line once and keeps it elevated after release', () => { + const { rerenderPanel } = renderPanel({ + mainLyric, + audioInstance: { currentTime: 0.5, paused: true }, + }) + + const group = screen.getByTestId('lyrics-line-group') + 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(activeStyle.transform).toBe(`translateY(-${KARAOKE_LINE_LIFT_PX}px)`) + + rerenderPanel({ + mainLyric, + audioInstance: { currentTime: 1.1, paused: true }, + }) + + const releasedStyle = window.getComputedStyle(group) + expect(group).toHaveAttribute('data-highlight-active', 'false') + expect(group).toHaveAttribute('data-raised', 'true') + expect(releasedStyle.transform).toBe( + `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + ) + }) + + it('keeps every word-timed layer on the same rise and release lifecycle', () => { + const offsetPronunciationLyric = { + ...tokenizedPronunciationLyric, + cueLine: [ + { + ...tokenizedPronunciationLyric.cueLine[0], + cue: [ + { start: 100, end: 850, value: 'mein' }, + { start: 850, end: 1000, value: 'lain' }, + ], + }, + ], + } + const propsAt = (currentTime) => ({ + mainLyric: tokenizedMainLyric, + pronunciationLyric: offsetPronunciationLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showPronunciation: true, + showTranslation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(0.25)) + + const group = screen.getByTestId('lyrics-line-group') + const translation = screen + .getByText('translated line') + .closest('[data-tokenized]') + const mainToken = screen.getAllByTestId('lyrics-token')[0] + const pronunciationToken = screen.getAllByTestId( + 'lyrics-pronunciation-token', + )[0] + const mainCharacters = mainToken.querySelectorAll( + '[data-lyrics-character="true"]', + ) + const pronunciationCharacters = pronunciationToken.querySelectorAll( + '[data-lyrics-character="true"]', + ) + + 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(mainCharacters).toHaveLength(4) + expect(pronunciationCharacters).toHaveLength(4) + expect(mainCharacters[0].style.transform).toBe( + `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + ) + expect(mainCharacters[3].style.transform).not.toBe( + mainCharacters[0].style.transform, + ) + expect(pronunciationCharacters[0].style.transform).toBe( + mainCharacters[0].style.transform, + ) + expect(mainToken.style.backgroundImage).toBe('none') + expect(mainCharacters[0].style.backgroundImage).toContain('linear-gradient') + + rerenderPanel(propsAt(1.1)) + + expect(group).toHaveAttribute('data-highlight-active', 'false') + expect(group).toHaveAttribute('data-raised', 'true') + expect(group).toHaveAttribute('data-character-wave', 'false') + expect( + group.querySelectorAll('[data-lyrics-character="true"]'), + ).toHaveLength(0) + expect(window.getComputedStyle(group).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 detailed grapheme markup on active lines without changing token layout', () => { + const values = ['First word', 'Second word', 'Active word', 'Fourth word'] + const lyricFor = (pronunciation = false) => ({ + synced: true, + line: values.map((value, index) => ({ + start: index * 1000, + end: (index + 1) * 1000, + value: pronunciation ? `spoken ${index}` : value, + })), + cueLine: values.map((value, index) => ({ + index, + start: index * 1000, + end: (index + 1) * 1000, + value: pronunciation ? `spoken ${index}` : value, + cue: (pronunciation ? `spoken ${index}` : value) + .split(' ') + .map((word, wordIndex, words) => ({ + start: index * 1000 + (wordIndex * 1000) / words.length, + end: index * 1000 + ((wordIndex + 1) * 1000) / words.length, + value: word, + })), + })), + }) + const propsAt = (currentTime) => ({ + mainLyric: lyricFor(), + pronunciationLyric: lyricFor(true), + showPronunciation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(2.5)) + + let groups = screen.getAllByTestId('lyrics-line-group') + expect( + groups.map((group) => + Boolean(group.querySelector('[data-lyrics-character="true"]')), + ), + ).toEqual([false, false, true, false]) + expect( + groups[2].querySelectorAll('[data-lyrics-wave-measure="true"]'), + ).not.toHaveLength(0) + const activeRow = groups[2].querySelector('[data-wrapped]') + const spacers = activeRow.querySelectorAll( + ':scope > span:not([data-stacked-token="true"])', + ) + expect(activeRow).toHaveAttribute('data-wrapped', 'false') + expect(spacers).not.toHaveLength(0) + spacers.forEach((spacer) => + expect(window.getComputedStyle(spacer).display).toBe('inline'), + ) + + rerenderPanel(propsAt(3.5)) + groups = screen.getAllByTestId('lyrics-line-group') + expect( + groups[2].querySelectorAll('[data-lyrics-character="true"]'), + ).toHaveLength(0) + expect( + groups[3].querySelectorAll('[data-lyrics-character="true"]'), + ).not.toHaveLength(0) + }) + + it('renders unsynced lyrics as static selectable text', () => { + renderPanel({ + mainLyric: { + synced: false, + line: [{ value: 'first plain line' }, { value: 'second plain line' }], + }, + }) + + const groups = screen.getAllByTestId('lyrics-line-group') + expect(groups).toHaveLength(2) + groups.forEach((group) => { + expect(group).toHaveAttribute('data-active', 'true') + expect(group).toHaveAttribute('data-lifecycle', 'active') + expect(group).toHaveAttribute('data-highlight-active', 'true') + expect(group).not.toHaveAttribute('aria-current') + expect(group).toHaveAttribute('data-scroll-target', 'false') + }) + }) + + it('tracks overlapping lines while selecting one primary line', () => { + renderPanel({ + mainLyric: { + synced: true, + line: [ + { start: 1000, end: 4000, value: 'Lead vocal' }, + { start: 2000, end: 3000, value: 'Answer vocal' }, + { start: 5000, end: 6000, value: 'Later vocal' }, + ], + }, + audioInstance: { currentTime: 2.5, paused: true }, + }) + + const groups = screen.getAllByTestId('lyrics-line-group') + expect(groups[0]).toHaveAttribute('data-active', 'true') + expect(groups[1]).toHaveAttribute('data-active', 'true') + expect(groups[2]).toHaveAttribute('data-active', 'false') + expect(groups[0]).not.toHaveAttribute('aria-current') + expect(groups[1]).toHaveAttribute('aria-current', 'true') + expect(groups[1]).toHaveAttribute('data-scroll-target', 'true') + }) + + it('keeps multi-agent cue lines in separate voice lanes', () => { + renderPanel({ + mainLyric: multiAgentLyric, + audioInstance: { currentTime: 2.5, paused: true }, + }) + + const lanes = screen.getAllByTestId('lyrics-voice-lane') + expect(lanes).toHaveLength(3) + expect(lanes[0]).toHaveTextContent('Lead') + expect(lanes[1]).toHaveTextContent('all') + expect(lanes[1].style.fontStyle).toBe('italic') + expect(lanes[2]).toHaveTextContent('echo') + expect(lanes[2].style.fontStyle).toBe('italic') + + const mainToken = lanes[0].querySelector('[data-testid="lyrics-token"]') + const emphasisToken = lanes[1].querySelector('[data-testid="lyrics-token"]') + const emphasisCharacter = emphasisToken.querySelector( + '[data-lyrics-character="true"]', + ) + expect(mainToken).toHaveTextContent('Lead') + expect(emphasisToken.style.paddingInlineEnd).not.toBe('') + expect(emphasisToken.style.marginInlineEnd).toBe( + `-${emphasisToken.style.paddingInlineEnd}`, + ) + expect(emphasisCharacter.style.paddingInlineEnd).toBe( + emphasisToken.style.paddingInlineEnd, + ) + }) + + it('seeks and synchronizes immediately when a line is activated', () => { + const audioInstance = { currentTime: 0 } + renderPanel({ + mainLyric: { + synced: true, + line: [{ start: 2300, end: 3200, value: 'Seek line' }], + }, + audioInstance, + }) + + const group = screen + .getByText('Seek line') + .closest('[data-testid="lyrics-line-group"]') + fireEvent.click(group) + expect(audioInstance.currentTime).toBe(2.3) + expect(group).toHaveAttribute('data-active', 'true') + + audioInstance.currentTime = 0 + fireEvent.keyDown(group, { key: 'Enter' }) + expect(audioInstance.currentTime).toBe(2.3) + }) + + it('pauses auto-scroll only for genuine manual scroll intent', async () => { + vi.useFakeTimers() + const requestAnimationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation(() => 0) + renderPanel({ + mainLyric, + audioInstance: { currentTime: 0.5, paused: true }, + }) + + const body = screen.getByTestId('lyrics-scroll-body') + const initialFrames = requestAnimationFrameSpy.mock.calls.length + fireEvent.wheel(body) + expect(body).toHaveAttribute('data-scrollbar-visible', 'true') + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(initialFrames) + + act(() => { + vi.advanceTimersByTime(KARAOKE_MANUAL_SCROLL_PAUSE_MS) + }) + await waitFor(() => { + expect(requestAnimationFrameSpy.mock.calls.length).toBeGreaterThan( + initialFrames, + ) + }) + }) + + it('resets scroll position when lyric content changes', () => { + const { rerenderPanel } = renderPanel({ mainLyric }) + const body = screen.getByTestId('lyrics-scroll-body') + body.scrollTop = 180 + + rerenderPanel({ + mainLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Different song' }], + }, + }) + expect(body.scrollTop).toBe(0) + }) + + it('respects reduced motion and empty states', async () => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + const { unmount } = renderPanel({ mainLyric }) + await waitFor(() => { + expect(screen.getByTestId('lyrics-scroll-body')).toHaveAttribute( + 'data-reduced-motion', + 'true', + ) + }) + unmount() + + renderPanel({ mainLyric: null, loading: true }) + expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent( + 'Loading lyrics', + ) + }) + + it('dims translations relative to their semantic theme color', () => { + const sameTextTheme = createTheme({ + palette: { + primary: { main: '#ffffff' }, + text: { primary: '#ffffff', secondary: '#ffffff' }, + }, + }) + renderPanel( + { + mainLyric: { + synced: true, + line: [ + { start: 0, end: 1000, value: 'Active' }, + { start: 1000, end: 2000, value: 'Future' }, + ], + }, + translationLyric: { + synced: true, + line: [ + { start: 0, end: 1000, value: 'Activa' }, + { start: 1000, end: 2000, value: 'Futura' }, + ], + }, + showTranslation: true, + audioInstance: { currentTime: 0.5, paused: true }, + }, + sameTextTheme, + ) + + const groups = screen.getAllByTestId('lyrics-line-group') + const activeTranslation = screen + .getByText('Activa') + .closest('[data-tokenized]') + const futureTranslation = screen + .getByText('Futura') + .closest('[data-tokenized]') + groups.forEach((group) => { + expect( + group.style.getPropertyValue('--lyrics-translation-active-color'), + ).toBe('#ffffff') + }) + expect(groups[0]).toHaveAttribute('data-active', 'true') + expect(groups[1]).toHaveAttribute('data-active', 'false') + const layerOpacity = (group) => + window + .getComputedStyle(group) + .getPropertyValue('--lyrics-layer-opacity') + .trim() + expect(groups.map(layerOpacity)).toEqual(['1', '0.49']) + expect(window.getComputedStyle(activeTranslation).opacity).toBe( + String(KARAOKE_TRANSLATION_OPACITY), + ) + expect(Number(window.getComputedStyle(futureTranslation).opacity)).toBe( + 0.49 * KARAOKE_TRANSLATION_OPACITY, + ) + ;[activeTranslation, futureTranslation].forEach((translation) => + expect(window.getComputedStyle(translation).transition).toContain( + `opacity ${KARAOKE_ANIMATION_MS}ms`, + ), + ) + }) +}) diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js new file mode 100644 index 000000000..db48b7bd9 --- /dev/null +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -0,0 +1,29 @@ +export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140 +export const KARAOKE_HIGHLIGHT_LEAD_MS = 120 +export const KARAOKE_ANIMATION_MS = 220 +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_ANIMATION_MS +export const KARAOKE_LINE_LIFT_PX = 1.5 +export const KARAOKE_LINE_MOTION_EASING = 'cubic-bezier(0.25, 0.1, 0.25, 1)' +export const KARAOKE_CHARACTER_LIFT_PX = 1.5 +export const KARAOKE_CHARACTER_PHASE_SPREAD = 0.36 +export const KARAOKE_TRANSLATION_OPACITY = 0.62 +export const KARAOKE_LINE_RELEASE_MS = KARAOKE_ANIMATION_MS +export const KARAOKE_SCROLL_PRE_ROLL_MS = 320 +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 TOKEN_FUTURE_ALPHA = 0.34 +export const TOKEN_ACTIVE_ALPHA = 1 +export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34 +export const TOKEN_WIPE_EDGE_PCT = 12 + +export const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) +export const easeInOut = (value) => { + const clamped = clamp(value, 0, 1) + return clamped < 0.5 ? 2 * clamped * clamped : 1 - (-2 * clamped + 2) ** 2 / 2 +} diff --git a/ui/src/audioplayer/lyricsKaraokeStyles.js b/ui/src/audioplayer/lyricsKaraokeStyles.js new file mode 100644 index 000000000..6e751133f --- /dev/null +++ b/ui/src/audioplayer/lyricsKaraokeStyles.js @@ -0,0 +1,45 @@ +const EMPHASIS_ROLES = new Set([ + 'adlib', + 'backing', + 'backing vocals', + 'backing-vocals', + 'background', + 'background vocals', + 'background-vocals', + 'bg', + 'choir', + 'chorus', + 'group', + 'harmony', +]) + +const normalizeRole = (role) => + String(role || '') + .trim() + .toLowerCase() + +export const isEmphasisRole = (token) => + EMPHASIS_ROLES.has(normalizeRole(token?.role)) || + EMPHASIS_ROLES.has(normalizeRole(token?.agentRole)) + +export const buildEmphasisStyle = (token) => + isEmphasisRole(token) ? { fontStyle: 'italic' } : undefined + +export const parseColorRGB = (color) => { + const value = String(color || '').trim() + const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i) + if (hex) { + const raw = hex[1] + if (raw.length === 3) { + return raw.split('').map((part) => parseInt(part + part, 16)) + } + return [ + parseInt(raw.slice(0, 2), 16), + parseInt(raw.slice(2, 4), 16), + parseInt(raw.slice(4, 6), 16), + ] + } + + const rgb = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) + return rgb ? [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])] : null +} diff --git a/ui/src/audioplayer/lyricsScroll.js b/ui/src/audioplayer/lyricsScroll.js new file mode 100644 index 000000000..adf05d728 --- /dev/null +++ b/ui/src/audioplayer/lyricsScroll.js @@ -0,0 +1,73 @@ +import { + KARAOKE_SCROLL_ANIMATION_MS, + KARAOKE_SCROLL_SETTLE_PX, + clamp, + easeInOut, +} from './lyricsKaraokeConstants' + +export const cancelScrollAnimation = (scrollAnimationRef) => { + const animation = scrollAnimationRef.current + if (animation?.frameId != null) { + window.cancelAnimationFrame(animation.frameId) + } + scrollAnimationRef.current = null +} + +export const getScrollEndPadding = (body, anchorRatio) => { + const height = Number(body?.clientHeight) + if (!Number.isFinite(height) || height <= 0) return 0 + return Math.round(height * (1 - anchorRatio)) +} + +export const getAnchoredScrollTop = (body, targetNode, anchorRatio) => { + const bodyRect = body.getBoundingClientRect() + const targetRect = targetNode.getBoundingClientRect() + const activeAnchorTop = body.clientHeight * anchorRatio + const deltaWithinBody = targetRect.top - bodyRect.top - activeAnchorTop + const maxTop = Math.max(0, body.scrollHeight - body.clientHeight) + return clamp(body.scrollTop + deltaWithinBody, 0, maxTop) +} + +export const animateScrollTop = ({ + body, + targetTop, + reducedMotion, + scrollAnimationRef, +}) => { + cancelScrollAnimation(scrollAnimationRef) + + const startTop = body.scrollTop + const maxTop = Math.max(0, body.scrollHeight - body.clientHeight) + const nextTargetTop = clamp(targetTop, 0, maxTop) + const distance = nextTargetTop - startTop + if (Math.abs(distance) < KARAOKE_SCROLL_SETTLE_PX) return + + if (reducedMotion) { + body.scrollTop = nextTargetTop + return + } + + const startedAt = performance.now() + const animation = { frameId: null } + const step = () => { + if (scrollAnimationRef.current !== animation) return + + const progress = clamp( + (performance.now() - startedAt) / KARAOKE_SCROLL_ANIMATION_MS, + 0, + 1, + ) + body.scrollTop = + progress >= 1 ? nextTargetTop : startTop + distance * easeInOut(progress) + + if (progress < 1) { + animation.frameId = window.requestAnimationFrame(step) + return + } + + scrollAnimationRef.current = null + } + + animation.frameId = window.requestAnimationFrame(step) + scrollAnimationRef.current = animation +} diff --git a/ui/src/audioplayer/lyricsScroll.test.js b/ui/src/audioplayer/lyricsScroll.test.js new file mode 100644 index 000000000..f75a44fe7 --- /dev/null +++ b/ui/src/audioplayer/lyricsScroll.test.js @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { KARAOKE_SCROLL_ANIMATION_MS } from './lyricsKaraokeConstants' +import { + animateScrollTop, + cancelScrollAnimation, + getAnchoredScrollTop, + getScrollEndPadding, +} from './lyricsScroll' + +const createScrollableBody = (scrollTop = 0) => ({ + clientHeight: 200, + scrollHeight: 1000, + scrollTop, +}) + +const animate = (body, scrollAnimationRef, targetTop, reducedMotion = false) => + animateScrollTop({ + body, + targetTop, + reducedMotion, + scrollAnimationRef, + }) + +describe('lyrics scroll helpers', () => { + let animationFrames + let now + + beforeEach(() => { + animationFrames = [] + now = 0 + vi.spyOn(performance, 'now').mockImplementation(() => now) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback) + return animationFrames.length + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('calculates end padding and clamps anchored targets', () => { + expect(getScrollEndPadding({ clientHeight: 500 }, 0.42)).toBe(290) + expect(getScrollEndPadding({ clientHeight: 200 }, 0.5)).toBe(100) + expect(getScrollEndPadding({ clientHeight: 0 }, 0.5)).toBe(0) + expect(getScrollEndPadding(null, 0.5)).toBe(0) + const body = { + ...createScrollableBody(120), + getBoundingClientRect: () => ({ top: 50 }), + } + const target = { + getBoundingClientRect: () => ({ top: 310 }), + } + + // 120 + (310 - 50 - 200 * 0.4) = 300 + expect(getAnchoredScrollTop(body, target, 0.4)).toBe(300) + const clampedBody = { + ...createScrollableBody(20), + getBoundingClientRect: () => ({ top: 100 }), + } + const above = { + getBoundingClientRect: () => ({ top: -500 }), + } + const below = { + getBoundingClientRect: () => ({ top: 5000 }), + } + + expect(getAnchoredScrollTop(clampedBody, above, 0.4)).toBe(0) + expect(getAnchoredScrollTop(clampedBody, below, 0.4)).toBe(800) + }) + + it('stores a cancellable frame while animating scroll position', () => { + const body = createScrollableBody() + const scrollAnimationRef = { current: null } + window.requestAnimationFrame.mockImplementationOnce((callback) => { + animationFrames.push(callback) + return 0 + }) + + animate(body, scrollAnimationRef, 400) + + expect(scrollAnimationRef.current?.frameId).toBe(0) + + cancelScrollAnimation(scrollAnimationRef) + + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(0) + expect(scrollAnimationRef.current).toBeNull() + }) + + it('updates scroll position immediately when reduced motion is enabled', () => { + const body = createScrollableBody() + const scrollAnimationRef = { current: null } + + animate(body, scrollAnimationRef, 400, true) + + expect(body.scrollTop).toBe(400) + expect(window.requestAnimationFrame).not.toHaveBeenCalled() + expect(scrollAnimationRef.current).toBeNull() + }) + + it('skips tiny scroll adjustments within the settle distance', () => { + const body = createScrollableBody() + const scrollAnimationRef = { current: null } + + animate(body, scrollAnimationRef, 1) + + expect(body.scrollTop).toBe(0) + expect(window.requestAnimationFrame).not.toHaveBeenCalled() + expect(scrollAnimationRef.current).toBeNull() + }) + + it('clears the current animation after natural completion', () => { + const body = createScrollableBody() + const scrollAnimationRef = { current: null } + + animate(body, scrollAnimationRef, 400) + + now = 1000 + animationFrames[0]() + + expect(body.scrollTop).toBe(400) + expect(scrollAnimationRef.current).toBeNull() + }) + + it('ignores stale frames after a newer scroll animation starts', () => { + const body = createScrollableBody() + const scrollAnimationRef = { current: null } + + animate(body, scrollAnimationRef, 400) + const firstAnimation = scrollAnimationRef.current + + animate(body, scrollAnimationRef, 500) + const secondAnimation = scrollAnimationRef.current + + expect(secondAnimation).not.toBe(firstAnimation) + + now = KARAOKE_SCROLL_ANIMATION_MS / 2 + animationFrames[0]() + + expect(body.scrollTop).toBe(0) + expect(scrollAnimationRef.current).toBe(secondAnimation) + expect(animationFrames).toHaveLength(2) + + animationFrames[1]() + + expect(body.scrollTop).toBe(250) + expect(scrollAnimationRef.current).toBe(secondAnimation) + expect(animationFrames).toHaveLength(3) + + now = KARAOKE_SCROLL_ANIMATION_MS + animationFrames[2]() + + expect(body.scrollTop).toBe(500) + expect(scrollAnimationRef.current).toBeNull() + }) +}) diff --git a/ui/src/audioplayer/lyricsSegments.js b/ui/src/audioplayer/lyricsSegments.js new file mode 100644 index 000000000..81b6eb5c7 --- /dev/null +++ b/ui/src/audioplayer/lyricsSegments.js @@ -0,0 +1,146 @@ +import { utf8ByteRangeToCodeUnitRange } from './lyrics' + +export const buildSegmentsFromLine = (line) => { + if (!line || !Array.isArray(line.tokens) || line.tokens.length === 0) { + return [{ text: line?.value || '', token: null, tokenIndex: -1 }] + } + + const text = line.value || '' + const exactSegments = (() => { + if (!text) return null + + const rangedTokens = line.tokens + .map((token, tokenIndex) => ({ + token, + tokenIndex, + range: utf8ByteRangeToCodeUnitRange( + text, + token?.byteStart, + token?.byteEnd, + ), + })) + .filter((entry) => entry.range != null) + + if ( + rangedTokens.length !== line.tokens.length || + rangedTokens.length === 0 + ) { + return null + } + + rangedTokens.sort( + (a, b) => + a.range.start - b.range.start || + a.range.end - b.range.end || + a.tokenIndex - b.tokenIndex, + ) + + const segments = [] + let cursor = 0 + for (const entry of rangedTokens) { + if (entry.range.start < cursor) return null + if (entry.range.start > cursor) { + segments.push({ + text: text.slice(cursor, entry.range.start), + token: null, + tokenIndex: -1, + }) + } + segments.push({ + text: entry.range.text, + token: entry.token, + tokenIndex: entry.tokenIndex, + }) + cursor = entry.range.end + } + + if (cursor < text.length) { + segments.push({ text: text.slice(cursor), token: null, tokenIndex: -1 }) + } + + return segments + })() + + if (exactSegments) return exactSegments + + const matchedSegments = [] + const fallbackSegments = [] + let cursor = 0 + let allMatched = text.length > 0 + let anyMatched = false + + const pushFallbackSeparatorIfNeeded = (nextTokenText) => { + if (fallbackSegments.length === 0) return + const prevText = fallbackSegments[fallbackSegments.length - 1].text || '' + if (!prevText || !nextTokenText) return + if (/\s$/.test(prevText) || /^\s/.test(nextTokenText)) return + if (/[A-Za-z0-9]$/.test(prevText) && /^[A-Za-z0-9]/.test(nextTokenText)) { + fallbackSegments.push({ text: ' ', token: null, tokenIndex: -1 }) + } + } + + for (let tokenIndex = 0; tokenIndex < line.tokens.length; tokenIndex += 1) { + const token = line.tokens[tokenIndex] + const tokenText = token.value || '' + if (!tokenText) continue + + pushFallbackSeparatorIfNeeded(tokenText) + fallbackSegments.push({ text: tokenText, token, tokenIndex }) + + if (!text) { + allMatched = false + continue + } + + const foundAt = text.indexOf(tokenText, cursor) + const lowerText = foundAt < 0 ? text.toLowerCase() : text + const lowerTokenText = foundAt < 0 ? tokenText.toLowerCase() : tokenText + const canReuseLowercaseOffsets = + lowerText.length === text.length && + lowerTokenText.length === tokenText.length + const normalizedFoundAt = + foundAt >= 0 + ? foundAt + : canReuseLowercaseOffsets + ? lowerText.indexOf(lowerTokenText, cursor) + : -1 + + if (normalizedFoundAt >= 0) { + anyMatched = true + if (normalizedFoundAt > cursor) { + matchedSegments.push({ + text: text.slice(cursor, normalizedFoundAt), + token: null, + tokenIndex: -1, + }) + } + const matchedTokenText = text.slice( + normalizedFoundAt, + normalizedFoundAt + tokenText.length, + ) + matchedSegments.push({ + text: matchedTokenText || tokenText, + token, + tokenIndex, + }) + cursor = normalizedFoundAt + tokenText.length + } else { + allMatched = false + } + } + + if (allMatched && anyMatched) { + if (cursor < text.length) { + matchedSegments.push({ + text: text.slice(cursor), + token: null, + tokenIndex: -1, + }) + } + return matchedSegments + } + + if (fallbackSegments.length > 0) return fallbackSegments + + return [{ text, token: null, tokenIndex: -1 }] +} diff --git a/ui/src/audioplayer/lyricsSegments.test.js b/ui/src/audioplayer/lyricsSegments.test.js new file mode 100644 index 000000000..209b42e5c --- /dev/null +++ b/ui/src/audioplayer/lyricsSegments.test.js @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { buildSegmentsFromLine } from './lyricsSegments' + +describe('buildSegmentsFromLine', () => { + it('does not reuse lowercase offsets when Unicode case folding expands text', () => { + const segments = buildSegmentsFromLine({ + value: '\u0130A', + tokens: [{ value: 'a' }], + }) + + expect(segments).toEqual([ + { text: 'a', token: { value: 'a' }, tokenIndex: 0 }, + ]) + }) +}) diff --git a/ui/src/audioplayer/lyricsTimeline.js b/ui/src/audioplayer/lyricsTimeline.js new file mode 100644 index 000000000..c7a7157a2 --- /dev/null +++ b/ui/src/audioplayer/lyricsTimeline.js @@ -0,0 +1,312 @@ +import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants' + +const finiteTime = (value) => { + if (value == null || value === '') return null + const number = Number(value) + return Number.isFinite(number) ? number : null +} + +const upperBound = (items, time, getTime) => { + let low = 0 + let high = items.length + while (low < high) { + const middle = (low + high) >>> 1 + if (getTime(items[middle]) <= time) low = middle + 1 + else high = middle + } + return low +} + +const sameIndexes = (left, right) => + left.length === right.length && + left.every((value, index) => value === right[index]) + +const getPrimaryActiveIndex = (timeline, indexes) => + indexes.reduce((primaryIndex, lineIndex) => { + if (primaryIndex < 0) return lineIndex + const primary = timeline?.windows?.[primaryIndex] + const candidate = timeline?.windows?.[lineIndex] + if ((candidate?.start ?? -Infinity) !== (primary?.start ?? -Infinity)) { + return (candidate?.start ?? -Infinity) > (primary?.start ?? -Infinity) + ? lineIndex + : primaryIndex + } + return lineIndex > primaryIndex ? lineIndex : primaryIndex + }, -1) + +const earliestTokenStart = (line) => { + const tokens = Array.isArray(line?.tokens) ? line.tokens : [] + let earliest = null + for (const token of tokens) { + const start = finiteTime(token?.start) + if (start != null && (earliest == null || start < earliest)) + earliest = start + } + return earliest +} + +const latestExplicitTokenEnd = (line) => { + const tokens = Array.isArray(line?.tokens) ? line.tokens : [] + let latest = null + for (const token of tokens) { + const end = finiteTime(token?.end) + if (end != null && (latest == null || end > latest)) latest = end + } + return latest +} + +export const buildLyricsTimeline = ( + lines, + { durationMs = null, fallbackLineDurationMs = 8000 } = {}, +) => { + const sourceLines = Array.isArray(lines) ? lines : [] + const starts = sourceLines.map( + (line) => finiteTime(line?.start) ?? earliestTokenStart(line), + ) + const nextTimedStarts = new Array(sourceLines.length).fill(null) + const timedEntries = starts + .map((start, lineIndex) => ({ start, lineIndex })) + .filter((entry) => entry.start != null) + .sort( + (left, right) => + left.start - right.start || left.lineIndex - right.lineIndex, + ) + let nextDistinctStart = null + for (let index = timedEntries.length - 1; index >= 0; ) { + const groupStart = timedEntries[index].start + let groupIndex = index + while (groupIndex >= 0 && timedEntries[groupIndex].start === groupStart) { + nextTimedStarts[timedEntries[groupIndex].lineIndex] = nextDistinctStart + groupIndex -= 1 + } + nextDistinctStart = groupStart + index = groupIndex + } + + const trackEnd = finiteTime(durationMs) + const windows = sourceLines.map((line, lineIndex) => { + const start = starts[lineIndex] + let end = finiteTime(line?.end) ?? latestExplicitTokenEnd(line) + if (end == null) end = nextTimedStarts[lineIndex] + if (end == null && start != null) { + end = start + fallbackLineDurationMs + if (trackEnd != null && trackEnd > start) { + end = Math.min(end, trackEnd) + } + } + const renderable = line?.renderable !== false + const intervalValid = start != null && end != null && end > start + const valid = renderable && intervalValid + return { + lineIndex, + start, + end, + renderable, + intervalValid, + valid, + preRollStart: + start == null ? null : Math.max(0, start - KARAOKE_SCROLL_PRE_ROLL_MS), + nextTimedStart: nextTimedStarts[lineIndex], + } + }) + + const events = windows + .filter((window) => window.valid) + .flatMap((window) => [ + { time: window.start, type: 'start', lineIndex: window.lineIndex }, + { time: window.end, type: 'end', lineIndex: window.lineIndex }, + ]) + .sort((left, right) => { + if (left.time !== right.time) return left.time - right.time + if (left.type !== right.type) return left.type === 'end' ? -1 : 1 + return left.lineIndex - right.lineIndex + }) + + const checkpoints = [] + const active = new Set() + events.forEach((event, index) => { + if (event.type === 'start') active.add(event.lineIndex) + else active.delete(event.lineIndex) + if ((index + 1) % 64 === 0) { + checkpoints.push({ + eventIndex: index + 1, + time: event.time, + active: Array.from(active), + }) + } + }) + + const scrollOrder = windows + .filter((window) => window.valid && window.preRollStart != null) + .sort( + (left, right) => + left.preRollStart - right.preRollStart || + left.start - right.start || + left.lineIndex - right.lineIndex, + ) + + return { windows, events, checkpoints, scrollOrder } +} + +export const getTimelineScrollTarget = (timeline, time) => { + const current = finiteTime(time) ?? 0 + const order = timeline?.scrollOrder || [] + const index = upperBound(order, current, (item) => item.preRollStart) - 1 + return index >= 0 ? order[index].lineIndex : -1 +} + +export class LyricTimelineCursor { + constructor(timeline) { + this.timeline = timeline + this.eventIndex = 0 + this.active = new Set() + this.lastTime = -Infinity + this.lastIndexes = [] + this.result = { + indexes: this.lastIndexes, + changed: false, + primaryIndex: -1, + } + } + + reset(timeline = this.timeline) { + this.timeline = timeline + this.eventIndex = 0 + this.active.clear() + this.lastTime = -Infinity + this.lastIndexes = [] + this.result.indexes = this.lastIndexes + this.result.changed = false + this.result.primaryIndex = -1 + } + + applyEvent(event) { + if (event.type === 'start') this.active.add(event.lineIndex) + else this.active.delete(event.lineIndex) + } + + seek(time) { + const events = this.timeline?.events || [] + const checkpoints = this.timeline?.checkpoints || [] + const checkpointIndex = + upperBound(checkpoints, time, (item) => item.time) - 1 + const checkpoint = + checkpointIndex >= 0 ? checkpoints[checkpointIndex] : null + this.active = new Set(checkpoint?.active || []) + this.eventIndex = checkpoint?.eventIndex || 0 + while ( + this.eventIndex < events.length && + events[this.eventIndex].time <= time + ) { + this.applyEvent(events[this.eventIndex]) + this.eventIndex += 1 + } + } + + update(time, forceSeek = false) { + const current = finiteTime(time) ?? 0 + const events = this.timeline?.events || [] + const previousEventIndex = this.eventIndex + if (forceSeek || current < this.lastTime) { + this.seek(current) + } else { + while ( + this.eventIndex < events.length && + events[this.eventIndex].time <= current + ) { + this.applyEvent(events[this.eventIndex]) + this.eventIndex += 1 + } + } + this.lastTime = current + if (!forceSeek && previousEventIndex === this.eventIndex) { + this.result.changed = false + return this.result + } + const indexes = Array.from(this.active).sort((a, b) => a - b) + const changed = !sameIndexes(indexes, this.lastIndexes) + this.lastIndexes = indexes + this.result.indexes = indexes + this.result.changed = changed + this.result.primaryIndex = getPrimaryActiveIndex(this.timeline, indexes) + return this.result + } +} + +export const resolveKaraokeTokenWindows = (line, lineEndFallback = null) => { + const tokens = Array.isArray(line?.tokens) ? line.tokens : [] + if (tokens.length === 0) return [] + + const lineStart = finiteTime(line?.start) + const lineEnd = finiteTime(line?.end) ?? finiteTime(lineEndFallback) + const hasLineWindow = + lineStart != null && lineEnd != null && lineEnd > lineStart + const tokenCount = tokens.length + const windows = new Array(tokenCount) + + for (let tokenIndex = 0; tokenIndex < tokenCount; tokenIndex += 1) { + const token = tokens[tokenIndex] + const previousToken = tokenIndex > 0 ? tokens[tokenIndex - 1] : null + const nextToken = + tokenIndex + 1 < tokenCount ? tokens[tokenIndex + 1] : null + const estimatedStart = hasLineWindow + ? lineStart + ((lineEnd - lineStart) * tokenIndex) / tokenCount + : null + const estimatedEnd = hasLineWindow + ? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount + : null + const previousEnd = + finiteTime(previousToken?.end) ?? finiteTime(previousToken?.start) + + let start = finiteTime(token?.start) + if (start == null) start = previousEnd ?? estimatedStart ?? lineStart + + let end = finiteTime(token?.end) + if (end == null) { + const nextStart = finiteTime(nextToken?.start) + const nextEstimatedStart = + hasLineWindow && tokenIndex + 1 < tokenCount + ? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount + : null + end = nextStart ?? nextEstimatedStart ?? estimatedEnd ?? lineEnd + } + + if ( + tokenCount === 1 && + hasLineWindow && + (start == null || end == null || end <= start + 1) + ) { + start = lineStart + end = lineEnd + } + if (start != null && end != null && end < start) end = start + + const previousWindow = tokenIndex > 0 ? windows[tokenIndex - 1] : null + const visualStart = + start != null && previousWindow?.end != null + ? Math.max(start, previousWindow.end) + : start + const visualEnd = + end != null && visualStart != null && end <= visualStart + ? visualStart + 1 + : end + windows[tokenIndex] = { + start: visualStart, + end: visualEnd, + sourceStart: start, + sourceEnd: end, + } + } + + return windows +} + +export const tokenProgressAt = (window, time) => { + const current = finiteTime(time) ?? 0 + if (!window || window.start == null || window.end == null) return 0 + if (window.end <= window.start) return current >= window.start ? 1 : 0 + return Math.max( + 0, + Math.min(1, (current - window.start) / (window.end - window.start)), + ) +} diff --git a/ui/src/audioplayer/lyricsTimeline.test.js b/ui/src/audioplayer/lyricsTimeline.test.js new file mode 100644 index 000000000..f357d60eb --- /dev/null +++ b/ui/src/audioplayer/lyricsTimeline.test.js @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' +import { + buildLyricsTimeline, + getTimelineScrollTarget, + LyricTimelineCursor, + resolveKaraokeTokenWindows, + tokenProgressAt, +} from './lyricsTimeline' +import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants' + +describe('lyricsTimeline', () => { + it('supports overlapping active lines and backward seeks', () => { + const timeline = buildLyricsTimeline([ + { start: 1000, end: 3000, tokens: [] }, + { start: 2000, end: 4000, tokens: [] }, + ]) + const cursor = new LyricTimelineCursor(timeline) + + expect(cursor.update(1500, true).indexes).toEqual([0]) + expect(cursor.update(2500).indexes).toEqual([0, 1]) + expect(cursor.update(3500).indexes).toEqual([1]) + expect(cursor.update(1500).indexes).toEqual([0]) + }) + + it('excludes zero and negative duration intervals', () => { + const timeline = buildLyricsTimeline([ + { start: 1000, end: 1000, tokens: [] }, + { start: 2000, end: 1500, tokens: [] }, + { start: 3000, end: 4000, tokens: [] }, + ]) + + expect(timeline.events).toEqual([ + { time: 3000, type: 'start', lineIndex: 2 }, + { time: 4000, type: 'end', lineIndex: 2 }, + ]) + }) + + it('uses the next timed line across untimed display rows', () => { + const timeline = buildLyricsTimeline([ + { start: 1000, tokens: [] }, + { value: 'untimed annotation', tokens: [] }, + { start: 5000, end: 6000, tokens: [] }, + ]) + + expect(timeline.windows[0].end).toBe(5000) + expect(timeline.windows[0].nextTimedStart).toBe(5000) + expect(timeline.windows[1].valid).toBe(false) + }) + + it('uses chronological timing independently from display order', () => { + const timeline = buildLyricsTimeline([ + { start: 3000, end: 5000, value: 'Displayed first', tokens: [] }, + { start: 1000, end: 4000, value: 'Displayed second', tokens: [] }, + { start: 2000, value: 'Displayed third', tokens: [] }, + ]) + const cursor = new LyricTimelineCursor(timeline) + + expect(timeline.windows[2].end).toBe(3000) + expect(cursor.update(2500, true)).toMatchObject({ + indexes: [1, 2], + primaryIndex: 2, + }) + expect(cursor.update(3500)).toMatchObject({ + indexes: [0, 1], + primaryIndex: 0, + }) + }) + + it('uses timed blank markers as boundaries without activating or scrolling to them', () => { + const timeline = buildLyricsTimeline([ + { + start: 1000, + value: 'Before pause', + tokens: [], + renderable: true, + }, + { start: 2000, value: '', tokens: [], renderable: false }, + { + start: 4000, + end: 5000, + value: 'After pause', + tokens: [], + renderable: true, + }, + ]) + const cursor = new LyricTimelineCursor(timeline) + + expect(timeline.windows[0].end).toBe(2000) + expect(timeline.windows[1]).toMatchObject({ + renderable: false, + intervalValid: true, + valid: false, + }) + expect(cursor.update(2500, true).indexes).toEqual([]) + expect(timeline.scrollOrder.map((window) => window.lineIndex)).toEqual([ + 0, 2, + ]) + }) + + it('bounds a final open line when media duration is unavailable', () => { + const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], { + fallbackLineDurationMs: 8000, + }) + + expect(timeline.windows[0]).toMatchObject({ + start: 7000, + end: 15000, + valid: true, + }) + }) + + it('keeps a start-only line active until the next timestamp', () => { + const timeline = buildLyricsTimeline([ + { start: 1000, value: 'A lyric line', tokens: [] }, + { start: 5000, end: 6000, tokens: [] }, + ]) + const cursor = new LyricTimelineCursor(timeline) + + expect(timeline.windows[0].end).toBe(5000) + expect(cursor.update(4999, true).indexes).toEqual([0]) + expect(cursor.update(5000).indexes).toEqual([1]) + }) + + it('caps a final open line with track duration', () => { + const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], { + durationMs: 10000, + }) + + expect(timeline.windows[0]).toMatchObject({ + start: 7000, + end: 10000, + valid: true, + }) + }) + + it('ignores a stale track duration that precedes the final line', () => { + const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], { + durationMs: 5000, + fallbackLineDurationMs: 8000, + }) + + expect(timeline.windows[0]).toMatchObject({ + start: 7000, + end: 15000, + valid: true, + }) + }) + + it('seeks through checkpoints in timelines longer than 32 lines', () => { + const timeline = buildLyricsTimeline( + Array.from({ length: 40 }, (_, index) => ({ + start: index * 1000, + end: (index + 1) * 1000, + tokens: [], + })), + ) + const cursor = new LyricTimelineCursor(timeline) + + expect(timeline.checkpoints.length).toBeGreaterThan(0) + expect(cursor.update(35500, true)).toMatchObject({ + indexes: [35], + primaryIndex: 35, + }) + }) + + it('moves the scroll target at pre-roll without changing active state', () => { + const timeline = buildLyricsTimeline([ + { start: 1000, end: 1500, tokens: [] }, + { start: 3000, end: 3500, tokens: [] }, + ]) + + expect(getTimelineScrollTarget(timeline, 0)).toBe(-1) + expect( + getTimelineScrollTarget(timeline, 3000 - KARAOKE_SCROLL_PRE_ROLL_MS), + ).toBe(1) + }) + + it('precomputes token windows once and keeps progress deterministic', () => { + const line = { + start: 1000, + end: 3000, + tokens: [ + { start: 1000, value: 'one' }, + { start: 2000, end: 3000, value: 'two' }, + ], + } + const windows = resolveKaraokeTokenWindows(line) + + expect(windows).toEqual([ + { start: 1000, end: 2000, sourceStart: 1000, sourceEnd: 2000 }, + { start: 2000, end: 3000, sourceStart: 2000, sourceEnd: 3000 }, + ]) + expect(tokenProgressAt(windows[0], 1500)).toBe(0.5) + expect(tokenProgressAt(windows[0], 2500)).toBe(1) + }) +}) diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js new file mode 100644 index 000000000..b83aa4b0d --- /dev/null +++ b/ui/src/audioplayer/useLyricsTimeline.js @@ -0,0 +1,713 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + KARAOKE_CHARACTER_LIFT_PX, + KARAOKE_CHARACTER_PHASE_SPREAD, + KARAOKE_CLOCK_DRIFT_RESET_MS, + KARAOKE_HIGHLIGHT_LEAD_MS, + KARAOKE_LINE_RELEASE_MS, +} from './lyricsKaraokeConstants' +import { + buildLyricsTimeline, + getTimelineScrollTarget, + LyricTimelineCursor, + tokenProgressAt, +} from './lyricsTimeline' + +const mediaTimeMs = (audio) => { + const seconds = Number(audio?.currentTime) + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 0 +} + +const mediaDurationMs = (audio) => { + const seconds = Number(audio?.duration) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null +} + +const sameIndexes = (left, right) => + left.length === right.length && + left.every((value, index) => value === right[index]) + +const setProgress = (record, value) => { + const next = Math.max(0, Math.min(1, value)) + if (record.progress != null && Math.abs(record.progress - next) < 0.001) + return + record.progress = next + record.node.style.setProperty('--lyrics-progress', String(next)) +} + +const smootherStep = (value) => + value * value * value * (value * (value * 6 - 15) + 10) + +const setCharacterLift = (record, progress) => { + const characters = record.characters || [] + if (!characters.length) return + + const riseSpan = Math.max(0.001, 1 - KARAOKE_CHARACTER_PHASE_SPREAD) + + characters.forEach(({ node, phase }) => { + const local = Math.max(0, Math.min(1, (progress - phase) / riseSpan)) + const lift = Math.max( + 0, + Math.min( + KARAOKE_CHARACTER_LIFT_PX, + KARAOKE_CHARACTER_LIFT_PX * smootherStep(local), + ), + ) + if (lift < 0.00005) { + if (node.style.transform) node.style.removeProperty('transform') + return + } + const nextTransform = `translateY(-${lift.toFixed(4)}px)` + if (node.style.transform !== nextTransform) { + node.style.transform = nextTransform + } + }) +} + +const setCharacterGradient = (record) => { + const tokenRect = record.node.getBoundingClientRect() + const tokenWidth = tokenRect.width || record.node.offsetWidth || 1 + if (!record.presentation?.gradient) return + + const measurements = record.characters.map((character) => ({ + character, + offset: character.node.getBoundingClientRect().left - tokenRect.left, + })) + + measurements.forEach(({ character, offset }) => { + const { node } = character + if ( + character.gradientWidth === tokenWidth && + character.gradientOffset === offset + ) { + return + } + character.gradientWidth = tokenWidth + character.gradientOffset = offset + node.style.color = 'transparent' + node.style.webkitTextFillColor = 'transparent' + node.style.backgroundImage = record.presentation.gradient + node.style.backgroundSize = `${tokenWidth}px 100%` + node.style.backgroundPosition = `${-offset}px 0` + node.style.backgroundRepeat = 'no-repeat' + node.style.backgroundClip = 'text' + node.style.webkitBackgroundClip = 'text' + }) +} + +const setTokenOpacity = (record, value) => { + const next = Math.max(0, Math.min(1, value)) + if (record.opacity != null && Math.abs(record.opacity - next) < 0.001) return + record.opacity = next + record.node.style.opacity = String(next) +} + +const setTokenActiveAlpha = (record, value) => { + const next = Math.max(0, Math.min(1, value)) + if (record.activeAlpha != null && Math.abs(record.activeAlpha - next) < 0.001) + return + record.activeAlpha = next + record.node.style.setProperty('--lyrics-token-active-alpha', String(next)) +} + +const setGradientTokenColor = (record) => { + const paintNode = record.paintNode || record.node + const paintsOnChild = paintNode !== record.node + + record.node.style.color = 'transparent' + record.node.style.webkitTextFillColor = 'transparent' + record.node.style.backgroundImage = paintsOnChild + ? 'none' + : record.presentation.gradient + record.node.style.backgroundSize = paintsOnChild ? 'auto' : '100% 100%' + record.node.style.backgroundClip = paintsOnChild ? 'border-box' : 'text' + record.node.style.webkitBackgroundClip = paintsOnChild ? 'border-box' : 'text' + + if (!paintsOnChild) return + + paintNode.style.color = 'transparent' + paintNode.style.webkitTextFillColor = 'transparent' + paintNode.style.backgroundImage = record.characters.length + ? 'none' + : record.presentation.gradient + paintNode.style.backgroundSize = '100% 100%' + paintNode.style.backgroundPosition = '0 0' + paintNode.style.backgroundRepeat = 'no-repeat' + paintNode.style.backgroundClip = 'text' + paintNode.style.webkitBackgroundClip = 'text' +} + +const getInactiveTokenAlpha = (presentation = {}) => + Math.min(1, Math.max(0, presentation.futureAlpha ?? 0.34)) + +const getActiveTokenAlpha = (presentation = {}) => + Math.min(1, Math.max(0, presentation.activeAlpha ?? 1)) + +const isGradientTokenState = (state) => + state === 'future' || + state === 'active' || + state === 'completed' || + state === 'release' || + state === 'inactive-past' + +const applyTokenState = (record, state, progress = 0) => { + const previousState = record.state + record.state = state + record.node.dataset.lyricsState = state + const presentation = record.presentation || {} + + if (!isGradientTokenState(previousState)) { + setGradientTokenColor(record) + } + + const isFuture = state === 'future' + const isPast = state === 'inactive-past' + const nextProgress = isFuture ? 0 : state === 'active' ? progress : 1 + setProgress(record, nextProgress) + setCharacterLift(record, nextProgress) + setTokenOpacity(record, 1) + setTokenActiveAlpha( + record, + isPast + ? getInactiveTokenAlpha(presentation) + : getActiveTokenAlpha(presentation), + ) +} + +const setTokenReleasePresentation = (record, progress) => { + const presentation = record.presentation || {} + const nextProgress = Math.max(0, Math.min(1, progress)) + if (record.state !== 'release') { + applyTokenState(record, 'completed', 1) + record.state = 'release' + record.node.dataset.lyricsState = 'release' + } + const activeAlpha = getActiveTokenAlpha(presentation) + const targetAlpha = getInactiveTokenAlpha(presentation) + setTokenOpacity(record, 1) + setTokenActiveAlpha( + record, + activeAlpha + (targetAlpha - activeAlpha) * nextProgress, + ) +} + +const resetToken = (record, state = 'future') => { + applyTokenState(record, state, 0) +} + +const setTokenPresentation = (record, time) => { + const progress = tokenProgressAt(record.window, time) + const state = + progress <= 0 ? 'future' : progress >= 1 ? 'completed' : 'active' + if (record.state !== state || state === 'active') { + applyTokenState(record, state, progress) + } +} + +const canListenToMedia = (audio) => + Boolean( + audio && + typeof audio.addEventListener === 'function' && + typeof audio.removeEventListener === 'function', + ) + +const useLyricsTimeline = ({ + lines, + audioInstance, + visible, + reducedMotion, +}) => { + const [durationMs, setDurationMs] = useState(() => + mediaDurationMs(audioInstance), + ) + + useEffect(() => { + const updateDuration = () => setDurationMs(mediaDurationMs(audioInstance)) + updateDuration() + if (!canListenToMedia(audioInstance)) return undefined + audioInstance.addEventListener('loadedmetadata', updateDuration) + audioInstance.addEventListener('durationchange', updateDuration) + return () => { + audioInstance.removeEventListener('loadedmetadata', updateDuration) + audioInstance.removeEventListener('durationchange', updateDuration) + } + }, [audioInstance]) + + const timeline = useMemo( + () => buildLyricsTimeline(lines, { durationMs }), + [durationMs, lines], + ) + const cursorRef = useRef(new LyricTimelineCursor(timeline)) + const lineNodesRef = useRef(new Map()) + const tokenRecordsRef = useRef(new Map()) + const tokenRecordsByNodeRef = useRef(new WeakMap()) + const tokenResizeObserverRef = useRef(null) + const lineTokenKeysRef = useRef(new Map()) + const releaseIndexesRef = useRef(new Set()) + const frameRef = useRef(0) + const resetInterpolationRef = useRef(null) + const lastPublishedIndexesRef = useRef([]) + const lastPublishedPrimaryIndexRef = useRef(-1) + const lastScrollTargetRef = useRef(-1) + const lastAppliedTimeRef = useRef(0) + const [activeState, setActiveState] = useState(() => ({ + indexes: [], + primaryIndex: -1, + })) + const [scrollTargetIndex, setScrollTargetIndex] = useState(-1) + + useLayoutEffect(() => { + const ResizeObserverConstructor = window.ResizeObserver + if (!ResizeObserverConstructor || reducedMotion) return undefined + + const observer = new ResizeObserverConstructor((entries) => { + entries.forEach(({ target }) => { + const record = tokenRecordsByNodeRef.current.get(target) + if (record) setCharacterGradient(record) + }) + }) + tokenResizeObserverRef.current = observer + tokenRecordsRef.current.forEach((record) => { + if (record.characters.length) observer.observe(record.node) + }) + return () => { + observer.disconnect() + tokenResizeObserverRef.current = null + } + }, [reducedMotion]) + + const setLineState = useCallback((lineIndex, phase) => { + const node = lineNodesRef.current.get(lineIndex) + if (!node) return + node.dataset.active = phase === 'active' ? 'true' : 'false' + node.dataset.lifecycle = phase + node.dataset.highlightActive = phase === 'active' ? 'true' : 'false' + node.dataset.raised = phase === 'idle' ? 'false' : 'true' + }, []) + + const resetLineTokens = useCallback((lineIndex, state = 'future') => { + lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => { + const record = tokenRecordsRef.current.get(key) + if (record) resetToken(record, state) + }) + }, []) + + const updateLineTokens = useCallback((lineIndex, time) => { + lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => { + const record = tokenRecordsRef.current.get(key) + if (record) setTokenPresentation(record, time) + }) + }, []) + + const updateLineReleaseTokens = useCallback((lineIndex, progress) => { + lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => { + const record = tokenRecordsRef.current.get(key) + if (record) setTokenReleasePresentation(record, progress) + }) + }, []) + + const publishActiveState = useCallback((indexes, nextPrimaryIndex) => { + if ( + sameIndexes(lastPublishedIndexesRef.current, indexes) && + lastPublishedPrimaryIndexRef.current === nextPrimaryIndex + ) { + return + } + lastPublishedIndexesRef.current = indexes + lastPublishedPrimaryIndexRef.current = nextPrimaryIndex + setActiveState({ indexes, primaryIndex: nextPrimaryIndex }) + }, []) + + const publishScrollTarget = useCallback( + (time) => { + const nextIndex = getTimelineScrollTarget(timeline, time) + if (nextIndex === lastScrollTargetRef.current) return + lastScrollTargetRef.current = nextIndex + setScrollTargetIndex(nextIndex) + }, + [timeline], + ) + + const apply = useCallback( + (time, forceSeek = false) => { + const current = Number.isFinite(Number(time)) + ? Math.max(0, Number(time)) + : 0 + const lead = reducedMotion ? 0 : KARAOKE_HIGHLIGHT_LEAD_MS + lastAppliedTimeRef.current = current + const cursor = cursorRef.current + const previousIndexes = cursor.lastIndexes + const result = cursor.update(current, forceSeek) + + if (forceSeek) { + releaseIndexesRef.current.clear() + timeline.windows.forEach((window) => { + if (!window.valid || current < window.start) { + setLineState(window.lineIndex, 'idle') + resetLineTokens(window.lineIndex, 'future') + return + } + if (current >= window.end) { + if (current < window.end + KARAOKE_LINE_RELEASE_MS) { + releaseIndexesRef.current.add(window.lineIndex) + setLineState(window.lineIndex, 'release') + updateLineReleaseTokens( + window.lineIndex, + (current - window.end) / KARAOKE_LINE_RELEASE_MS, + ) + } else { + setLineState(window.lineIndex, 'past') + resetLineTokens(window.lineIndex, 'inactive-past') + } + } + }) + } else if (result.changed) { + previousIndexes.forEach((lineIndex) => { + if (cursor.active.has(lineIndex)) return + const window = timeline.windows[lineIndex] + if ( + window?.valid && + current >= window.end && + current < window.end + KARAOKE_LINE_RELEASE_MS + ) { + releaseIndexesRef.current.add(lineIndex) + setLineState(lineIndex, 'release') + updateLineReleaseTokens(lineIndex, 0) + } else { + setLineState(lineIndex, 'past') + resetLineTokens(lineIndex, 'inactive-past') + } + }) + } + + result.indexes.forEach((lineIndex) => { + releaseIndexesRef.current.delete(lineIndex) + setLineState(lineIndex, 'active') + updateLineTokens(lineIndex, current + lead) + }) + + releaseIndexesRef.current.forEach((lineIndex) => { + const window = timeline.windows[lineIndex] + if ( + !window?.valid || + current >= window.end + KARAOKE_LINE_RELEASE_MS || + current < window.end + ) { + releaseIndexesRef.current.delete(lineIndex) + const isPast = current >= (window?.end ?? Infinity) + setLineState(lineIndex, isPast ? 'past' : 'idle') + resetLineTokens(lineIndex, isPast ? 'inactive-past' : 'future') + return + } + updateLineReleaseTokens( + lineIndex, + (current - window.end) / KARAOKE_LINE_RELEASE_MS, + ) + }) + + if (result.changed || forceSeek) { + publishActiveState(result.indexes, result.primaryIndex) + } + publishScrollTarget(current) + return result + }, + [ + publishActiveState, + publishScrollTarget, + reducedMotion, + resetLineTokens, + setLineState, + timeline.windows, + updateLineReleaseTokens, + updateLineTokens, + ], + ) + + const registerLine = useCallback( + (lineIndex, node) => { + if (!node) { + lineNodesRef.current.delete(lineIndex) + return + } + lineNodesRef.current.set(lineIndex, node) + const window = timeline.windows[lineIndex] + const time = lastAppliedTimeRef.current + if (cursorRef.current.active.has(lineIndex)) { + setLineState(lineIndex, 'active') + } else if ( + window?.valid && + time >= window.end && + time < window.end + KARAOKE_LINE_RELEASE_MS + ) { + setLineState(lineIndex, 'release') + } else if (window?.valid && time >= window.end) { + setLineState(lineIndex, 'past') + } else { + setLineState(lineIndex, 'idle') + } + }, + [setLineState, timeline.windows], + ) + + const registerToken = useCallback( + (key, descriptor, node) => { + const existing = tokenRecordsRef.current.get(key) + if (!node) { + if (existing) { + tokenResizeObserverRef.current?.unobserve(existing.node) + tokenRecordsByNodeRef.current.delete(existing.node) + tokenRecordsRef.current.delete(key) + const keys = lineTokenKeysRef.current.get(existing.lineIndex) + keys?.delete(key) + if (keys?.size === 0) { + lineTokenKeysRef.current.delete(existing.lineIndex) + } + } + return + } + + const characterNodes = reducedMotion + ? [] + : Array.from(node.querySelectorAll('[data-lyrics-character="true"]')) + const characterCount = characterNodes.length + const characters = characterNodes.map((characterNode, index) => ({ + node: characterNode, + phase: + characterCount <= 1 + ? 0 + : (index / (characterCount - 1)) * KARAOKE_CHARACTER_PHASE_SPREAD, + })) + const record = { + key, + node, + paintNode: node.querySelector('[data-lyrics-wave-text="true"]'), + lineIndex: descriptor.lineIndex, + window: descriptor.window, + presentation: descriptor.presentation, + characters, + progress: null, + opacity: null, + activeAlpha: null, + state: null, + } + setCharacterGradient(record) + tokenRecordsByNodeRef.current.set(node, record) + if (characters.length) tokenResizeObserverRef.current?.observe(node) + tokenRecordsRef.current.set(key, record) + if (!lineTokenKeysRef.current.has(record.lineIndex)) { + lineTokenKeysRef.current.set(record.lineIndex, new Set()) + } + lineTokenKeysRef.current.get(record.lineIndex).add(key) + + if (cursorRef.current.active.has(record.lineIndex)) { + setTokenPresentation( + record, + lastAppliedTimeRef.current + + (reducedMotion ? 0 : KARAOKE_HIGHLIGHT_LEAD_MS), + ) + } else { + const lineWindow = timeline.windows[record.lineIndex] + if ( + lineWindow?.valid && + lastAppliedTimeRef.current >= lineWindow.end && + lastAppliedTimeRef.current < lineWindow.end + KARAOKE_LINE_RELEASE_MS + ) { + setTokenReleasePresentation( + record, + (lastAppliedTimeRef.current - lineWindow.end) / + KARAOKE_LINE_RELEASE_MS, + ) + } else { + resetToken( + record, + lineWindow?.valid && lastAppliedTimeRef.current >= lineWindow.end + ? 'inactive-past' + : 'future', + ) + } + } + }, + [reducedMotion, timeline.windows], + ) + + const getLineNode = useCallback( + (lineIndex) => lineNodesRef.current.get(lineIndex) || null, + [], + ) + + useLayoutEffect(() => { + lineNodesRef.current.forEach((node) => { + node.dataset.active = 'false' + node.dataset.lifecycle = 'idle' + node.dataset.highlightActive = 'false' + node.dataset.raised = 'false' + }) + tokenRecordsRef.current.forEach((record) => resetToken(record)) + cursorRef.current = new LyricTimelineCursor(timeline) + releaseIndexesRef.current.clear() + lastPublishedIndexesRef.current = [] + lastPublishedPrimaryIndexRef.current = -1 + lastScrollTargetRef.current = -1 + setActiveState({ indexes: [], primaryIndex: -1 }) + setScrollTargetIndex(-1) + apply(mediaTimeMs(audioInstance), true) + }, [apply, audioInstance, timeline]) + + useEffect(() => { + if ( + !audioInstance || + !timeline.events.length || + !canListenToMedia(audioInstance) + ) { + return undefined + } + const seek = () => { + resetInterpolationRef.current?.() + apply(mediaTimeMs(audioInstance), true) + } + audioInstance.addEventListener('seeking', seek) + audioInstance.addEventListener('seeked', seek) + return () => { + audioInstance.removeEventListener('seeking', seek) + audioInstance.removeEventListener('seeked', seek) + } + }, [apply, audioInstance, timeline.events.length]) + + useEffect(() => { + if (!audioInstance || !timeline.events.length || !visible) return undefined + if (!canListenToMedia(audioInstance)) { + apply(mediaTimeMs(audioInstance), true) + return undefined + } + + let cancelled = false + let anchorAudioMs = mediaTimeMs(audioInstance) + let anchorPerfMs = performance.now() + let lastFrameTime = anchorAudioMs + + const stop = () => { + if (frameRef.current) window.cancelAnimationFrame(frameRef.current) + frameRef.current = 0 + } + + const resetAnchor = () => { + anchorAudioMs = mediaTimeMs(audioInstance) + anchorPerfMs = performance.now() + lastFrameTime = anchorAudioMs + } + resetInterpolationRef.current = resetAnchor + + const readInterpolatedTime = () => { + const observed = mediaTimeMs(audioInstance) + const now = performance.now() + const rate = Number(audioInstance.playbackRate) + const canInterpolate = + !audioInstance.paused && + !audioInstance.seeking && + Number.isFinite(rate) && + rate > 0 + if (!canInterpolate) { + anchorAudioMs = observed + anchorPerfMs = now + lastFrameTime = observed + return observed + } + const predicted = anchorAudioMs + (now - anchorPerfMs) * rate + const drift = observed - predicted + let current = + Math.abs(drift) > KARAOKE_CLOCK_DRIFT_RESET_MS ? observed : predicted + if (Math.abs(drift) > KARAOKE_CLOCK_DRIFT_RESET_MS) { + anchorAudioMs = observed + anchorPerfMs = now + } + const backwards = lastFrameTime - current + if (backwards > 0) { + current = lastFrameTime + } + lastFrameTime = Math.max(0, current) + return lastFrameTime + } + + const tick = () => { + if ( + cancelled || + audioInstance.paused || + window.document.visibilityState !== 'visible' + ) { + stop() + return + } + apply(readInterpolatedTime()) + frameRef.current = window.requestAnimationFrame(tick) + } + + const start = () => { + if ( + frameRef.current || + audioInstance.paused || + window.document.visibilityState !== 'visible' + ) { + return + } + resetAnchor() + frameRef.current = window.requestAnimationFrame(tick) + } + + const syncAndStop = () => { + stop() + apply(mediaTimeMs(audioInstance), true) + } + + const visibility = () => { + if (window.document.visibilityState !== 'visible') { + stop() + return + } + apply(mediaTimeMs(audioInstance), true) + start() + } + + audioInstance.addEventListener('play', start) + audioInstance.addEventListener('pause', syncAndStop) + window.document.addEventListener('visibilitychange', visibility) + apply(mediaTimeMs(audioInstance), true) + start() + + return () => { + cancelled = true + stop() + if (resetInterpolationRef.current === resetAnchor) { + resetInterpolationRef.current = null + } + audioInstance.removeEventListener('play', start) + audioInstance.removeEventListener('pause', syncAndStop) + window.document.removeEventListener('visibilitychange', visibility) + } + }, [apply, audioInstance, timeline.events.length, visible]) + + const syncNow = useCallback( + (time = mediaTimeMs(audioInstance), forceSeek = true) => + apply(time, forceSeek), + [apply, audioInstance], + ) + + return { + activeIndexes: activeState.indexes, + primaryIndex: activeState.primaryIndex, + scrollTargetIndex, + registerLine, + registerToken, + getLineNode, + syncNow, + timeline, + } +} + +export default useLyricsTimeline diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js new file mode 100644 index 000000000..84f45918c --- /dev/null +++ b/ui/src/audioplayer/useLyricsTimeline.test.js @@ -0,0 +1,456 @@ +import { act } from '@testing-library/react' +import { renderHook } from '@testing-library/react-hooks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { KARAOKE_CHARACTER_LIFT_PX } from './lyricsKaraokeConstants' +import useLyricsTimeline from './useLyricsTimeline' + +const createAudio = ({ + currentTime = 0, + duration = 10, + paused = true, + playbackRate = 1, +} = {}) => { + const target = new EventTarget() + target.currentTime = currentTime + target.duration = duration + target.paused = paused + target.playbackRate = playbackRate + target.seeking = false + return target +} + +const lines = [ + { + start: 0, + end: 1000, + tokens: [ + { start: 0, end: 500, value: 'first' }, + { start: 500, end: 1000, value: 'second' }, + ], + }, + { start: 1000, end: 2000, tokens: [] }, +] + +const presentation = { + rgb: [255, 255, 255], + futureAlpha: 0.34, + activeAlpha: 1, + futureColor: 'rgba(255, 255, 255, 0.34)', + doneColor: 'rgba(255, 255, 255, 1)', + gradient: 'linear-gradient(90deg, white, transparent)', + useCrossfade: false, +} + +const createTokenNode = (text = '') => { + const tokenNode = document.createElement('span') + Array.from(text).forEach((character) => { + const node = document.createElement('span') + node.dataset.lyricsCharacter = 'true' + node.textContent = character + tokenNode.appendChild(node) + }) + return tokenNode +} + +const registerToken = (result, key, window, text = '') => { + const tokenNode = createTokenNode(text) + act(() => { + result.current.registerToken( + key, + { lineIndex: 0, window, presentation }, + tokenNode, + ) + }) + return tokenNode +} + +const syncNow = (result, time) => { + act(() => { + result.current.syncNow(time, true) + }) +} + +const renderTimeline = ({ + audio = createAudio(), + sourceLines = lines, + reducedMotion = false, +} = {}) => + renderHook(() => + useLyricsTimeline({ + lines: sourceLines, + audioInstance: audio, + visible: true, + reducedMotion, + }), + ) + +describe('useLyricsTimeline', () => { + beforeEach(() => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('synchronizes paused media without starting an animation loop', () => { + const audio = createAudio({ currentTime: 0.25, paused: true }) + const { result } = renderTimeline({ audio }) + const lineNode = document.createElement('div') + const tokenNode = createTokenNode() + + act(() => { + result.current.registerLine(0, lineNode) + result.current.registerToken( + '0:main:0', + { + lineIndex: 0, + window: { start: 0, end: 500 }, + presentation, + }, + tokenNode, + ) + }) + + expect(result.current.activeIndexes).toEqual([0]) + expect(lineNode.dataset.active).toBe('true') + expect(tokenNode.dataset.lyricsState).toBe('active') + expect( + Number(tokenNode.style.getPropertyValue('--lyrics-progress')), + ).toBeCloseTo(0.74, 2) + expect(window.requestAnimationFrame).not.toHaveBeenCalled() + }) + + it('recomputes token state immediately after seeking backward', () => { + const audio = createAudio({ currentTime: 0.75, paused: true }) + const { result } = renderTimeline({ audio, reducedMotion: true }) + const first = document.createElement('span') + const second = document.createElement('span') + + act(() => { + result.current.registerToken( + '0:first', + { + lineIndex: 0, + window: { start: 0, end: 500 }, + presentation, + }, + first, + ) + result.current.registerToken( + '0:second', + { + lineIndex: 0, + window: { start: 500, end: 1000 }, + presentation, + }, + second, + ) + }) + + expect(first.dataset.lyricsState).toBe('completed') + expect(second.dataset.lyricsState).toBe('active') + + act(() => { + audio.currentTime = 0.1 + audio.dispatchEvent(new Event('seeking')) + }) + + expect(first.dataset.lyricsState).toBe('active') + expect(second.dataset.lyricsState).toBe('future') + }) + + it('tracks overlapping line intervals as an active set', () => { + const audio = createAudio({ currentTime: 2.5, paused: true }) + const overlappingLines = [ + { start: 1000, end: 4000, tokens: [] }, + { start: 2000, end: 3000, tokens: [] }, + { start: 5000, end: 6000, tokens: [] }, + ] + const { result } = renderTimeline({ audio, sourceLines: overlappingLines }) + + expect(result.current.activeIndexes).toEqual([0, 1]) + expect(result.current.primaryIndex).toBe(1) + }) + + it('publishes the most recently started line when display order differs', () => { + const audio = createAudio({ currentTime: 2.5, paused: true }) + const displayOrderLines = [ + { start: 2000, end: 4000, tokens: [] }, + { start: 1000, end: 4000, tokens: [] }, + ] + const { result } = renderTimeline({ audio, sourceLines: displayOrderLines }) + + expect(result.current.activeIndexes).toEqual([0, 1]) + expect(result.current.primaryIndex).toBe(0) + }) + + it('keeps the same gradient paint when an active word completes', () => { + const audio = createAudio({ currentTime: 0.25, paused: true }) + const { result } = renderTimeline({ audio }) + const tokenNode = registerToken( + result, + '0:stable-completion', + { start: 0, end: 500 }, + 'first', + ) + + const activeBackground = tokenNode.style.backgroundImage + expect(tokenNode.dataset.lyricsState).toBe('active') + expect(tokenNode.style.color).toBe('transparent') + + syncNow(result, 600) + + expect(tokenNode.dataset.lyricsState).toBe('completed') + expect(tokenNode.style.backgroundImage).toBe(activeBackground) + expect(tokenNode.style.color).toBe('transparent') + expect(tokenNode.style.webkitTextFillColor).toBe('transparent') + tokenNode + .querySelectorAll('[data-lyrics-character="true"]') + .forEach((character) => + expect(character.style.transform).toBe('translateY(-1.5000px)'), + ) + }) + + it('keeps gradient paint and opacity continuous when release becomes past', () => { + const audio = createAudio({ currentTime: 0.25, paused: true }) + const { result } = renderTimeline({ audio }) + const tokenNode = registerToken( + result, + '0:no-release-blink', + { start: 0, end: 500 }, + 'first', + ) + + syncNow(result, 1219) + const gradient = tokenNode.style.backgroundImage + const releaseAlpha = Number( + tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'), + ) + expect(tokenNode.dataset.lyricsState).toBe('release') + expect(tokenNode.style.color).toBe('transparent') + expect(tokenNode.style.opacity).toBe('1') + + syncNow(result, 1220) + + const pastAlpha = Number( + tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'), + ) + expect(tokenNode.dataset.lyricsState).toBe('inactive-past') + expect(tokenNode.style.backgroundImage).toBe(gradient) + expect(tokenNode.style.color).toBe('transparent') + expect(tokenNode.style.webkitTextFillColor).toBe('transparent') + expect(tokenNode.style.opacity).toBe('1') + expect(Math.abs(pastAlpha - releaseAlpha)).toBeLessThan(0.01) + expect(pastAlpha).toBeCloseTo(presentation.futureAlpha, 5) + expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1') + }) + + it('uses the complete token duration for the character phase wave', () => { + const audio = createAudio({ currentTime: 0, duration: 5, paused: true }) + const longLines = [ + { + start: 0, + end: 4000, + tokens: [{ start: 0, end: 4000, value: 'super' }], + }, + ] + const { result } = renderTimeline({ audio, sourceLines: longLines }) + const tokenNode = registerToken( + result, + '0:long-word', + { start: 0, end: 4000 }, + 'super', + ) + + const characters = Array.from( + tokenNode.querySelectorAll('[data-lyrics-character="true"]'), + ) + expect(characters[0].style.backgroundImage).toBe( + tokenNode.style.backgroundImage, + ) + characters.forEach((character) => + expect(character.style.transform).not.toMatch(/^translateY\(\d/), + ) + const firstTransforms = [] + ;[0, 160, 320, 480].forEach((time) => { + syncNow(result, time) + firstTransforms.push(characters[0].style.transform) + }) + + expect(new Set(firstTransforms).size).toBe(firstTransforms.length) + firstTransforms.forEach((transform) => + expect(transform).toMatch(/^translateY\(-\d+\.\d{4}px\)$/), + ) + + syncNow(result, 1320) + const offsets = characters.map((character) => + Math.abs( + Number.parseFloat( + character.style.transform.match(/-?\d+\.\d+/)?.[0] || '0', + ), + ), + ) + offsets.forEach((offset) => expect(offset).toBeGreaterThanOrEqual(0)) + expect(offsets.filter((offset) => offset > 0)).toHaveLength(4) + expect(offsets[0]).toBeGreaterThan(offsets[1]) + expect(offsets[1]).toBeGreaterThan(offsets[2]) + expect(offsets[2]).toBeGreaterThan(offsets[3]) + expect(offsets[4]).toBe(0) + + syncNow(result, 3600) + expect(characters[4].style.transform).not.toBe( + `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + ) + + syncNow(result, 3880) + characters.forEach((character) => + expect(character.style.transform).toBe( + `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + ), + ) + }) + + it('keeps the normalized wave shape for short token durations', () => { + const audio = createAudio({ currentTime: 0, duration: 1, paused: true }) + const shortLines = [ + { + start: 0, + end: 180, + tokens: [{ start: 0, end: 180, value: 'go' }], + }, + ] + const { result } = renderTimeline({ audio, sourceLines: shortLines }) + const tokenNode = registerToken( + result, + '0:short-word', + { start: 0, end: 180 }, + 'go', + ) + syncNow(result, 0) + + const characters = tokenNode.querySelectorAll( + '[data-lyrics-character="true"]', + ) + expect(characters[0].style.transform).not.toBe('') + expect(characters[1].style.transform).not.toBe( + `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + ) + + syncNow(result, 60) + expect(characters[1].style.transform).toBe( + `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + ) + }) + + it('keeps interpolated playback time monotonic between coarse media updates', () => { + let frameCallback = null + let now = 0 + window.requestAnimationFrame.mockImplementation((callback) => { + frameCallback = callback + return 1 + }) + vi.spyOn(performance, 'now').mockImplementation(() => now) + + const audio = createAudio({ currentTime: 0.2, paused: false }) + const { result } = renderTimeline({ audio }) + const tokenNode = document.createElement('span') + act(() => { + result.current.registerToken( + '0:monotonic', + { + lineIndex: 0, + window: { start: 0, end: 1000 }, + presentation, + }, + tokenNode, + ) + }) + + now = 100 + act(() => frameCallback()) + const firstProgress = Number( + tokenNode.style.getPropertyValue('--lyrics-progress'), + ) + now = 180 + act(() => frameCallback()) + const secondProgress = Number( + tokenNode.style.getPropertyValue('--lyrics-progress'), + ) + + expect(secondProgress).toBeGreaterThanOrEqual(firstProgress) + }) + + it('resets monotonic interpolation after an explicit backward seek', () => { + let frameCallback = null + let now = 0 + window.requestAnimationFrame.mockImplementation((callback) => { + frameCallback = callback + return 1 + }) + vi.spyOn(performance, 'now').mockImplementation(() => now) + + const audio = createAudio({ currentTime: 0.8, paused: false }) + const { result } = renderTimeline({ audio }) + const tokenNode = registerToken(result, '0:backward-seek', { + start: 0, + end: 1000, + }) + + now = 100 + act(() => frameCallback()) + const beforeSeek = Number( + tokenNode.style.getPropertyValue('--lyrics-progress'), + ) + + audio.currentTime = 0.1 + act(() => { + audio.dispatchEvent(new Event('seeked')) + }) + now = 120 + act(() => frameCallback()) + + const afterSeek = Number( + tokenNode.style.getPropertyValue('--lyrics-progress'), + ) + expect(afterSeek).toBeLessThan(beforeSeek) + expect(afterSeek).toBeCloseTo(0.24, 2) + }) + + it('starts and stops requestAnimationFrame with playback visibility', () => { + const audio = createAudio({ currentTime: 0.25, paused: true }) + renderTimeline({ audio }) + + expect(window.requestAnimationFrame).not.toHaveBeenCalled() + + act(() => { + audio.paused = false + audio.dispatchEvent(new Event('play')) + }) + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1) + + act(() => { + audio.paused = true + audio.dispatchEvent(new Event('pause')) + }) + expect(window.cancelAnimationFrame).toHaveBeenCalled() + }) + + it('rebuilds open-line timing when media duration changes', () => { + const audio = createAudio({ duration: 10, paused: true }) + const { result } = renderTimeline({ + audio, + sourceLines: [{ start: 7000, tokens: [] }], + }) + + expect(result.current.timeline.windows[0].end).toBe(10000) + + act(() => { + audio.duration = 20 + audio.dispatchEvent(new Event('durationchange')) + }) + + expect(result.current.timeline.windows[0].end).toBe(15000) + }) +}) From 20ffa444b95a3e4ef5f1c7e27277152457b7d610 Mon Sep 17 00:00:00 2001 From: ranokay Date: Fri, 17 Jul 2026 09:21:56 +0300 Subject: [PATCH 03/16] feat(ui): add the responsive lyrics sidebar - Integrate accessible desktop and mobile lyrics controls with responsive layout behavior. - Render localized lyric layers with stable wrapping, dimmed translations, and resilient portals. Signed-off-by: ranokay --- ui/src/App.jsx | 5 +- ui/src/audioplayer/LyricsLayoutContext.jsx | 41 ++ ui/src/audioplayer/LyricsSidebar.jsx | 415 ++++++++++++++++++ ui/src/audioplayer/LyricsSidebar.test.jsx | 292 ++++++++++++ .../audioplayer/MobileKaraokeLyricsPortal.jsx | 73 +++ .../MobileKaraokeLyricsPortal.test.jsx | 214 +++++++++ ui/src/audioplayer/Player.jsx | 56 ++- ui/src/audioplayer/PlayerToolbar.jsx | 39 +- ui/src/audioplayer/PlayerToolbar.test.jsx | 56 ++- ui/src/audioplayer/lyricsSidebarState.js | 28 ++ ui/src/audioplayer/lyricsSidebarState.test.js | 41 ++ ui/src/audioplayer/lyricsSidebarWidth.js | 61 +++ ui/src/audioplayer/styles.js | 48 +- ui/src/audioplayer/useEnterExitTransition.js | 22 + ui/src/audioplayer/usePlayerLyrics.jsx | 185 ++++++++ ui/src/i18n/en.json | 8 + ui/src/layout/Layout.jsx | 5 +- ui/src/layout/LyricsLayoutFrame.jsx | 55 +++ ui/src/layout/LyricsLayoutFrame.test.jsx | 114 +++++ 19 files changed, 1740 insertions(+), 18 deletions(-) create mode 100644 ui/src/audioplayer/LyricsLayoutContext.jsx create mode 100644 ui/src/audioplayer/LyricsSidebar.jsx create mode 100644 ui/src/audioplayer/LyricsSidebar.test.jsx create mode 100644 ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx create mode 100644 ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx create mode 100644 ui/src/audioplayer/lyricsSidebarState.js create mode 100644 ui/src/audioplayer/lyricsSidebarState.test.js create mode 100644 ui/src/audioplayer/lyricsSidebarWidth.js create mode 100644 ui/src/audioplayer/useEnterExitTransition.js create mode 100644 ui/src/audioplayer/usePlayerLyrics.jsx create mode 100644 ui/src/layout/LyricsLayoutFrame.jsx create mode 100644 ui/src/layout/LyricsLayoutFrame.test.jsx diff --git a/ui/src/App.jsx b/ui/src/App.jsx index d10aa5a33..a86470ea4 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -23,6 +23,7 @@ import share from './share' import library from './library' import plugin from './plugin' import { Player } from './audioplayer' +import { LyricsLayoutProvider } from './audioplayer/LyricsLayoutContext' import customRoutes from './routes' import { libraryReducer, @@ -85,7 +86,9 @@ const adminStore = createAdminStore({ const App = () => ( - + + + ) diff --git a/ui/src/audioplayer/LyricsLayoutContext.jsx b/ui/src/audioplayer/LyricsLayoutContext.jsx new file mode 100644 index 000000000..70aa0b06c --- /dev/null +++ b/ui/src/audioplayer/LyricsLayoutContext.jsx @@ -0,0 +1,41 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' + +const noop = () => {} + +const fallbackContext = { + desktopLyricsProps: null, + setDesktopLyricsProps: noop, +} + +const LyricsLayoutContext = createContext(fallbackContext) + +export const LyricsLayoutProvider = ({ children }) => { + const [desktopLyricsProps, setDesktopLyricsPropsState] = useState(null) + + const setDesktopLyricsProps = useCallback((nextProps) => { + setDesktopLyricsPropsState(nextProps || null) + }, []) + + const value = useMemo( + () => ({ + desktopLyricsProps, + setDesktopLyricsProps, + }), + [desktopLyricsProps, setDesktopLyricsProps], + ) + + return ( + + {children} + + ) +} + +export const useLyricsLayout = () => useContext(LyricsLayoutContext) diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx new file mode 100644 index 000000000..cfb398b6c --- /dev/null +++ b/ui/src/audioplayer/LyricsSidebar.jsx @@ -0,0 +1,415 @@ +import IconButton from '@material-ui/core/IconButton' +import Tooltip from '@material-ui/core/Tooltip' +import { alpha, makeStyles, useTheme } from '@material-ui/core/styles' +import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver' +import TranslateIcon from '@material-ui/icons/Translate' +import clsx from 'clsx' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import LyricsPanel from './LyricsPanel' +import { + LYRICS_SIDEBAR_MAX_WIDTH, + LYRICS_SIDEBAR_MIN_WIDTH, + LYRICS_SIDEBAR_TRANSITION_MS, + LYRICS_SIDEBAR_WIDTH_STEP, + clampSidebarWidth, + loadSidebarWidth, + notifySidebarWidthChange, + saveSidebarWidth, +} from './lyricsSidebarWidth' +import useEnterExitTransition from './useEnterExitTransition' + +const useStyles = makeStyles((theme) => ({ + sidebar: { + position: 'fixed', + top: 48, + right: 0, + bottom: 80, + minHeight: 0, + minWidth: LYRICS_SIDEBAR_MIN_WIDTH, + maxWidth: LYRICS_SIDEBAR_MAX_WIDTH, + display: 'flex', + flexDirection: 'column', + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.default, + backgroundImage: 'none', + borderLeft: `1px solid ${alpha(theme.palette.divider, 0.1)}`, + boxShadow: 'none', + zIndex: theme.zIndex.appBar - 1, + transition: `transform ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`, + willChange: 'transform, opacity', + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + }, + '@media (hover: hover) and (pointer: fine)': { + '&:hover $resizer::after': { + background: alpha(theme.palette.primary.main, 0.32), + }, + }, + }, + resizer: { + position: 'absolute', + top: 0, + bottom: 0, + left: -6, + width: 12, + padding: 0, + border: 0, + background: 'transparent', + cursor: 'ew-resize', + zIndex: 3, + '&::after': { + content: '""', + position: 'absolute', + top: 0, + bottom: 0, + left: 5, + width: 2, + background: 'transparent', + transition: 'background 160ms ease', + }, + '&:focus::after': { + background: alpha(theme.palette.primary.main, 0.48), + }, + '@media (hover: hover) and (pointer: fine)': { + '&:hover::after': { + background: alpha(theme.palette.primary.main, 0.48), + }, + }, + '&:focus': { + outline: 'none', + }, + }, + controls: { + position: 'absolute', + bottom: theme.spacing(1), + right: theme.spacing(0.75), + zIndex: 2, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.25), + padding: theme.spacing(0.25), + borderRadius: theme.shape.borderRadius * 2, + backgroundColor: 'transparent', + }, + controlButton: { + padding: theme.spacing(0.75), + color: alpha(theme.palette.text.primary, 0.58), + backgroundColor: 'transparent', + transition: + 'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)', + '@media (hover: hover) and (pointer: fine)': { + '&:hover': { + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.primary.main, 0.08), + }, + '&$controlActive:hover': { + color: theme.palette.primary.main, + }, + }, + '&:focus-visible': { + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.primary.main, 0.1), + }, + '&:active:not(:disabled)': { + transform: 'scale(0.97)', + }, + '&$controlActive': { + color: theme.palette.primary.main, + }, + '&$controlActive:focus-visible': { + color: theme.palette.primary.main, + }, + '&:disabled': { + color: alpha(theme.palette.text.primary, 0.28), + }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + '&:active:not(:disabled)': { + transform: 'none', + }, + }, + }, + controlActive: {}, + panel: { + flex: 1, + minHeight: 0, + position: 'relative', + }, +})) + +const LayerButton = ({ + active, + classes, + disabled, + label, + onClick, + testId, + children, +}) => { + const theme = useTheme() + const activeStyle = + active && !disabled ? { color: theme.palette.primary.main } : undefined + + return ( + + + + {children} + + + + ) +} + +const LyricsSidebar = ({ + visible, + mainLyric, + translationLyric, + pronunciationLyric, + showTranslation, + showPronunciation, + translationEnabled, + pronunciationEnabled, + onToggleTranslation, + onTogglePronunciation, + audioInstance, + loading = false, + error = null, + labels = {}, +}) => { + const [width, setWidth] = useState(loadSidebarWidth) + const sidebarRef = useRef(null) + const resizerRef = useRef(null) + const widthRef = useRef(width) + const resizeCleanupRef = useRef(null) + const { rendered, entered } = useEnterExitTransition( + visible, + LYRICS_SIDEBAR_TRANSITION_MS, + ) + const [isResizing, setIsResizing] = useState(false) + const classes = useStyles() + + useEffect(() => { + widthRef.current = width + }, [width]) + + useEffect( + () => () => { + resizeCleanupRef.current?.() + resizeCleanupRef.current = null + }, + [], + ) + + useEffect(() => { + if (entered || typeof document === 'undefined') return + const activeElement = document.activeElement + if (activeElement && sidebarRef.current?.contains(activeElement)) { + activeElement.blur() + } + }, [entered]) + + const applyWidth = useCallback((next) => { + const resolvedWidth = clampSidebarWidth( + typeof next === 'function' ? next(widthRef.current) : next, + ) + widthRef.current = resolvedWidth + if (sidebarRef.current) { + sidebarRef.current.style.width = `${resolvedWidth}px` + } + resizerRef.current?.setAttribute('aria-valuenow', String(resolvedWidth)) + notifySidebarWidthChange(resolvedWidth) + return resolvedWidth + }, []) + + const updateWidth = useCallback( + (next, { persist = false } = {}) => { + const resolvedWidth = applyWidth(next) + setWidth(resolvedWidth) + if (persist) saveSidebarWidth(resolvedWidth) + }, + [applyWidth], + ) + + const handleResizePointerDown = useCallback( + (event) => { + event.preventDefault() + resizeCleanupRef.current?.() + + const target = event.currentTarget + const pointerId = event.pointerId + const startX = event.clientX + const startWidth = widthRef.current + let latestWidth = startWidth + let cleanedUp = false + const canCapture = + pointerId != null && typeof target.setPointerCapture === 'function' + const listenerTarget = window + setIsResizing(true) + + const handlePointerMove = (moveEvent) => { + latestWidth = applyWidth(startWidth + startX - moveEvent.clientX) + } + + const cleanupResize = ({ persist = false } = {}) => { + if (cleanedUp) return + cleanedUp = true + listenerTarget.removeEventListener('pointermove', handlePointerMove) + listenerTarget.removeEventListener('pointerup', handlePointerUp) + listenerTarget.removeEventListener('pointercancel', handlePointerCancel) + target.removeEventListener('lostpointercapture', handlePointerCancel) + if ( + canCapture && + typeof target.releasePointerCapture === 'function' && + (!target.hasPointerCapture || target.hasPointerCapture(pointerId)) + ) { + try { + target.releasePointerCapture(pointerId) + } catch { + // Ignore stale pointer capture state. + } + } + setWidth(latestWidth) + if (persist) saveSidebarWidth(latestWidth) + setIsResizing(false) + if (resizeCleanupRef.current === cleanupResize) { + resizeCleanupRef.current = null + } + } + + const handlePointerUp = () => cleanupResize({ persist: true }) + const handlePointerCancel = () => cleanupResize() + + resizeCleanupRef.current = cleanupResize + listenerTarget.addEventListener('pointermove', handlePointerMove) + listenerTarget.addEventListener('pointerup', handlePointerUp) + listenerTarget.addEventListener('pointercancel', handlePointerCancel) + target.addEventListener('lostpointercapture', handlePointerCancel) + if (canCapture) { + try { + target.setPointerCapture(pointerId) + } catch { + // Window-level listeners keep resize working when capture is unavailable. + } + } + }, + [applyWidth], + ) + + const handleResizeKeyDown = useCallback( + (event) => { + if (event.key === 'ArrowLeft') { + event.preventDefault() + updateWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, { + persist: true, + }) + } else if (event.key === 'ArrowRight') { + event.preventDefault() + updateWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, { + persist: true, + }) + } else if (event.key === 'Home') { + event.preventDefault() + updateWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true }) + } else if (event.key === 'End') { + event.preventDefault() + updateWidth(LYRICS_SIDEBAR_MAX_WIDTH, { persist: true }) + } + }, + [updateWidth], + ) + + if (!rendered) return null + + return ( + + ) +} + +export default LyricsSidebar diff --git a/ui/src/audioplayer/LyricsSidebar.test.jsx b/ui/src/audioplayer/LyricsSidebar.test.jsx new file mode 100644 index 000000000..7781eedc9 --- /dev/null +++ b/ui/src/audioplayer/LyricsSidebar.test.jsx @@ -0,0 +1,292 @@ +import React from 'react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { afterEach, describe, expect, it, vi } from 'vitest' +import LyricsSidebar from './LyricsSidebar' +import { + LYRICS_SIDEBAR_MAX_WIDTH, + LYRICS_SIDEBAR_MIN_WIDTH, + LYRICS_SIDEBAR_STORAGE_KEY, + LYRICS_SIDEBAR_TRANSITION_MS, +} from './lyricsSidebarWidth' + +const theme = createTheme({ + palette: { + primary: { + main: '#35aa66', + }, + background: { + default: '#101820', + paper: '#ffffff', + }, + }, +}) +const lyric = { + synced: true, + line: [{ start: 0, end: 1000, value: 'Main line' }], +} +const originalPointerEvent = window.PointerEvent + +const sidebarView = (props = {}) => ( + + + +) + +const renderSidebar = (props) => render(sidebarView(props)) + +describe('', () => { + afterEach(() => { + vi.useRealTimers() + cleanup() + localStorage.clear() + document.body.className = '' + Object.defineProperty(window, 'PointerEvent', { + configurable: true, + writable: true, + value: originalPointerEvent, + }) + }) + + it('renders as an embedded sidebar without global layout side effects', () => { + renderSidebar() + + expect(document.body.className).toBe('') + const sidebar = screen.getByTestId('lyrics-sidebar') + expect(sidebar).toHaveStyle({ + transform: 'translateX(0)', + opacity: '1', + }) + expect(screen.getByTestId('lyrics-sidebar-resizer')).toBeInTheDocument() + }) + + it('keeps the sidebar mounted while sliding closed', () => { + vi.useFakeTimers() + const { rerender } = renderSidebar() + const resizer = screen.getByTestId('lyrics-sidebar-resizer') + resizer.focus() + + rerender(sidebarView({ visible: false })) + + expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({ + transform: 'translateX(100%)', + opacity: '0', + }) + expect(document.body.className).toBe('') + expect(document.activeElement).not.toBe(resizer) + + vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS) + + expect(screen.queryByTestId('lyrics-sidebar')).toBeNull() + vi.useRealTimers() + }) + + it('clamps persisted and keyboard-resized widths', () => { + localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '999') + renderSidebar() + + const sidebar = screen.getByTestId('lyrics-sidebar') + const resizer = screen.getByTestId('lyrics-sidebar-resizer') + + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }) + expect(resizer).toHaveAttribute( + 'aria-valuenow', + String(LYRICS_SIDEBAR_MAX_WIDTH), + ) + + fireEvent.keyDown(resizer, { key: 'Home' }) + + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }) + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe( + String(LYRICS_SIDEBAR_MIN_WIDTH), + ) + + fireEvent.keyDown(resizer, { key: 'End' }) + + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }) + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe( + String(LYRICS_SIDEBAR_MAX_WIDTH), + ) + }) + + it('clamps pointer resizing from the left separator', async () => { + Object.defineProperty(window, 'PointerEvent', { + configurable: true, + writable: true, + value: MouseEvent, + }) + renderSidebar() + + const sidebar = screen.getByTestId('lyrics-sidebar') + const resizer = screen.getByTestId('lyrics-sidebar-resizer') + resizer.setPointerCapture = vi.fn(() => { + throw new Error('pointer capture unavailable') + }) + resizer.releasePointerCapture = vi.fn() + + fireEvent.pointerDown(resizer, { clientX: 500 }) + window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 })) + await waitFor(() => + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }), + ) + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull() + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 })) + await waitFor(() => + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }), + ) + + window.dispatchEvent(new MouseEvent('pointerup')) + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe( + String(LYRICS_SIDEBAR_MIN_WIDTH), + ) + }) + + it('keeps the live pointer width through an unrelated rerender', async () => { + Object.defineProperty(window, 'PointerEvent', { + configurable: true, + writable: true, + value: MouseEvent, + }) + const { rerender } = renderSidebar() + const sidebar = screen.getByTestId('lyrics-sidebar') + const resizer = screen.getByTestId('lyrics-sidebar-resizer') + + fireEvent.pointerDown(resizer, { clientX: 500 }) + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 450 })) + await waitFor(() => expect(sidebar).toHaveStyle({ width: '410px' })) + expect(resizer).toHaveAttribute('aria-valuenow', '410') + + rerender(sidebarView({ labels: { title: 'Updated lyrics' } })) + + expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({ width: '410px' }) + expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute( + 'aria-valuenow', + '410', + ) + window.dispatchEvent(new MouseEvent('pointercancel')) + }) + + it('cleans pointer resizing on cancellation and unmount', async () => { + Object.defineProperty(window, 'PointerEvent', { + configurable: true, + writable: true, + value: MouseEvent, + }) + const { unmount } = renderSidebar() + + const sidebar = screen.getByTestId('lyrics-sidebar') + const resizer = screen.getByTestId('lyrics-sidebar-resizer') + + fireEvent.pointerDown(resizer, { clientX: 500 }) + window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 })) + await waitFor(() => + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }), + ) + + window.dispatchEvent(new MouseEvent('pointercancel')) + await waitFor(() => + expect(sidebar).toHaveAttribute('data-resizing', 'false'), + ) + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 })) + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }) + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull() + + fireEvent.pointerDown(resizer, { clientX: 500 }) + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 })) + await waitFor(() => + expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }), + ) + + unmount() + window.dispatchEvent(new MouseEvent('pointerup')) + + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull() + }) + + it('uses icon toggle buttons with pressed and disabled states', () => { + const onTogglePronunciation = vi.fn() + const onToggleTranslation = vi.fn() + renderSidebar({ + showPronunciation: true, + showTranslation: false, + pronunciationEnabled: true, + translationEnabled: false, + onTogglePronunciation, + onToggleTranslation, + labels: { + title: 'Song words', + resize: 'Resize song words', + showTranslation: 'Reveal translation', + hidePronunciation: 'Conceal pronunciation', + }, + }) + + const pronunciation = screen.getByTestId('toggle-pronunciation-button') + const translation = screen.getByTestId('toggle-translation-button') + expect(screen.getByLabelText('Song words')).toBeInTheDocument() + expect(screen.getByLabelText('Resize song words')).toBeInTheDocument() + expect(pronunciation).toHaveAttribute('aria-label', 'Conceal pronunciation') + expect(pronunciation).toHaveAttribute('aria-pressed', 'true') + expect(window.getComputedStyle(pronunciation).color).toBe( + 'rgb(53, 170, 102)', + ) + fireEvent.click(pronunciation) + expect(onTogglePronunciation).toHaveBeenCalledTimes(1) + + expect(translation).toHaveAttribute('aria-label', 'Reveal translation') + expect(translation).toHaveAttribute('aria-pressed', 'false') + expect(translation).toBeDisabled() + expect(window.getComputedStyle(translation).color).not.toBe( + 'rgb(53, 170, 102)', + ) + fireEvent.click(translation) + expect(onToggleTranslation).not.toHaveBeenCalled() + }) + + it('stays mounted without re-entering when an open sidebar receives no lyrics', () => { + const { rerender } = renderSidebar() + + const sidebar = screen.getByTestId('lyrics-sidebar') + expect(sidebar).toHaveStyle({ transform: 'translateX(0)' }) + expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument() + + rerender( + sidebarView({ + mainLyric: null, + showTranslation: false, + showPronunciation: false, + translationEnabled: false, + pronunciationEnabled: false, + }), + ) + + expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({ + transform: 'translateX(0)', + }) + expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument() + expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent( + 'No lyrics available', + ) + expect(screen.queryByTestId('lyrics-line-group')).toBeNull() + expect(screen.getByTestId('toggle-pronunciation-button')).toBeDisabled() + expect(screen.getByTestId('toggle-translation-button')).toBeDisabled() + }) +}) diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx new file mode 100644 index 000000000..f853cd977 --- /dev/null +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import useEnterExitTransition from './useEnterExitTransition' + +export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260 +export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR = + '.react-jinke-music-player-mobile-cover' +export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active' +export const MOBILE_KARAOKE_LYRICS_ENTERED_CLASS = 'nd-mobile-lyrics-entered' +export const MOBILE_KARAOKE_LYRICS_LAYER_CLASS = 'nd-mobile-lyrics-layer' + +const resolveMobileLyricsHost = () => { + if (typeof document === 'undefined') return null + return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR) +} + +const MobileKaraokeLyricsPortal = ({ active, children }) => { + const { rendered, entered } = useEnterExitTransition( + active, + MOBILE_KARAOKE_LYRICS_TRANSITION_MS, + ) + const [host, setHost] = useState(() => + active ? resolveMobileLyricsHost() : null, + ) + + useEffect(() => { + if (typeof document === 'undefined') { + setHost(null) + return undefined + } + + if (!rendered) { + setHost(null) + return undefined + } + + setHost(resolveMobileLyricsHost()) + + const observer = new MutationObserver(() => { + const nextHost = resolveMobileLyricsHost() + setHost((current) => (current === nextHost ? current : nextHost)) + }) + observer.observe(document.body, { childList: true, subtree: true }) + + return () => observer.disconnect() + }, [rendered]) + + useEffect(() => { + if (!host) return undefined + + host.classList.toggle(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, rendered) + host.classList.toggle(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS, entered) + return () => { + host.classList.remove(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + host.classList.remove(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS) + } + }, [entered, host, rendered]) + + if (!rendered || !host) return null + return createPortal( +
+ {children} +
, + host, + ) +} + +export default MobileKaraokeLyricsPortal diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx new file mode 100644 index 000000000..935d822dd --- /dev/null +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx @@ -0,0 +1,214 @@ +import React from 'react' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import MobileKaraokeLyricsPortal, { + MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, + MOBILE_KARAOKE_LYRICS_ENTERED_CLASS, + MOBILE_KARAOKE_LYRICS_HOST_SELECTOR, + MOBILE_KARAOKE_LYRICS_LAYER_CLASS, + MOBILE_KARAOKE_LYRICS_TRANSITION_MS, +} from './MobileKaraokeLyricsPortal' +import usePlayerLyrics from './usePlayerLyrics' + +const { useEnhancedLyricsMock } = vi.hoisted(() => ({ + useEnhancedLyricsMock: vi.fn(() => ({ + layers: { + main: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Persistent panel lyrics' }], + }, + translation: null, + pronunciation: null, + }, + loading: false, + error: null, + retry: vi.fn(), + })), +})) + +vi.mock('./useEnhancedLyrics', () => ({ + default: useEnhancedLyricsMock, +})) + +const createHost = () => { + const host = document.createElement('div') + host.className = MOBILE_KARAOKE_LYRICS_HOST_SELECTOR.slice(1) + document.body.appendChild(host) + return host +} + +const portal = (text, active = true) => ( + + {text} + +) + +const MobileLyricsHarness = ({ trackId = 'track-1', isRadio = false }) => { + const { toolbarLyricsProps, mobileLyricsSurface } = usePlayerLyrics({ + trackId, + trackUpdatedAt: 'now', + isRadio, + audioInstance: null, + isDesktop: false, + translate: (key) => key, + }) + return ( + <> + + {mobileLyricsSurface} + + ) +} + +describe('', () => { + afterEach(() => { + vi.useRealTimers() + useEnhancedLyricsMock.mockClear() + cleanup() + document.body.innerHTML = '' + }) + + it('mounts active lyrics into the mobile cover host and cleans up the class', () => { + vi.useFakeTimers() + const host = createHost() + const { rerender } = render(portal('Inline lyrics')) + + expect(within(host).getByText('Inline lyrics')).toBeInTheDocument() + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS) + expect( + host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`), + ).toHaveAttribute('data-entered', 'true') + expect( + host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`), + ).toHaveStyle({ pointerEvents: 'auto' }) + + rerender(portal('Inline lyrics', false)) + + expect(within(host).getByText('Inline lyrics')).toBeInTheDocument() + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS) + expect( + host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`), + ).toHaveAttribute('data-entered', 'false') + expect( + host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`), + ).toHaveStyle({ pointerEvents: 'none' }) + + act(() => { + vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS) + }) + + expect(screen.queryByText('Inline lyrics')).not.toBeInTheDocument() + expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS) + }) + + it('attaches when the mobile cover host appears after activation', async () => { + vi.useFakeTimers() + + render(portal('Late lyrics')) + + expect(screen.queryByText('Late lyrics')).not.toBeInTheDocument() + + const host = createHost() + + await waitFor(() => + expect(within(host).getByText('Late lyrics')).toBeInTheDocument(), + ) + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + }) + + it('moves active lyrics when the mobile cover host is replaced', async () => { + const firstHost = createHost() + + render(portal('Persistent lyrics')) + expect(within(firstHost).getByText('Persistent lyrics')).toBeInTheDocument() + + const secondHost = createHost() + firstHost.replaceWith(secondHost) + + await waitFor(() => + expect( + within(secondHost).getByText('Persistent lyrics'), + ).toBeInTheDocument(), + ) + expect(firstHost).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + expect(secondHost).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + }) + + it('keeps panel contents rendered for the complete mobile exit', async () => { + vi.useFakeTimers() + const host = createHost() + const { rerender } = render() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' })) + expect( + within(host).getByText('Persistent panel lyrics'), + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' })) + + expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ requested: false }), + ) + rerender() + const trackTwoRequests = useEnhancedLyricsMock.mock.calls + .filter(([options]) => options.trackId === 'track-2') + .map(([options]) => options.requested) + expect(trackTwoRequests.length).toBeGreaterThan(0) + expect(trackTwoRequests).not.toContain(true) + expect( + within(host).getByText('Persistent panel lyrics'), + ).toBeInTheDocument() + expect( + host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`), + ).toHaveStyle({ pointerEvents: 'none' }) + + act(() => { + vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS) + }) + + expect( + screen.queryByText('Persistent panel lyrics'), + ).not.toBeInTheDocument() + }) + + it('keeps open lyrics closable after playback switches to radio', () => { + vi.useFakeTimers() + const host = createHost() + const { rerender } = render() + const toggle = screen.getByRole('button', { name: 'Toggle lyrics' }) + + fireEvent.click(toggle) + expect( + within(host).getByText('Persistent panel lyrics'), + ).toBeInTheDocument() + + rerender() + expect(toggle).toBeEnabled() + fireEvent.click(toggle) + + act(() => { + vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS) + }) + expect( + screen.queryByText('Persistent panel lyrics'), + ).not.toBeInTheDocument() + expect(toggle).toBeDisabled() + }) +}) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index c3d795b26..1b21f9592 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -34,6 +34,8 @@ import { keyMap } from '../hotkeys' import keyHandlers from './keyHandlers' import { calculateGain } from '../utils/calculateReplayGain' import { detectBrowserProfile, decisionService } from '../transcode' +import usePlayerLyrics from './usePlayerLyrics' +import { useLyricsLayout } from './LyricsLayoutContext' const Player = () => { const theme = useCurrentTheme() @@ -138,6 +140,32 @@ const Player = () => { const gainInfo = useSelector((state) => state.replayGain) const [context, setContext] = useState(null) const [gainNode, setGainNode] = useState(null) + const { setDesktopLyricsProps } = useLyricsLayout() + const { + toolbarLyricsProps, + desktopLyricsProps, + mobileLyricsSurface, + useInlineMobileLyrics, + closeLyrics, + } = usePlayerLyrics({ + trackId: playerState.current?.trackId || currentTrackId, + trackUpdatedAt: + playerState.current?.song?.updatedAt || playerState.current?.updatedAt, + isRadio: playerState.current?.isRadio || false, + audioInstance, + isDesktop, + translate, + }) + + useEffect(() => { + if (!isDesktop) { + setDesktopLyricsProps(null) + return undefined + } + + setDesktopLyricsProps(desktopLyricsProps) + return () => setDesktopLyricsProps(null) + }, [desktopLyricsProps, isDesktop, setDesktopLyricsProps]) useEffect(() => { if ( @@ -213,7 +241,7 @@ const Player = () => { clearPriorAudioLists: false, showDestroy: true, showDownload: false, - showLyric: true, + showLyric: false, showReload: false, toggleMode: !isDesktop, glassBg: false, @@ -251,12 +279,16 @@ const Player = () => { (playerState.clear || playerState.playIndex === 0), clearPriorAudioLists: playerState.clear, extendsContent: ( - + ), defaultVolume: isMobilePlayer ? 1 : playerState.volume, showMediaSession: !current.isRadio, } - }, [playerState, defaultOptions, isMobilePlayer]) + }, [playerState, defaultOptions, isMobilePlayer, toolbarLyricsProps]) const onAudioListsChange = useCallback( (_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)), @@ -365,11 +397,15 @@ const Player = () => { [dispatch, dataProvider, currentTrackId], ) - const onCoverClick = useCallback((mode, audioLists, audioInfo) => { - if (mode === 'full' && audioInfo?.song?.albumId) { - window.location.href = `#/album/${audioInfo.song.albumId}/show` - } - }, []) + const onCoverClick = useCallback( + (mode, audioLists, audioInfo) => { + if (useInlineMobileLyrics) return + if (mode === 'full' && audioInfo?.song?.albumId) { + window.location.href = `#/album/${audioInfo.song.albumId}/show` + } + }, + [useInlineMobileLyrics], + ) const onAudioError = useCallback( (error, currentPlayId, audioLists, audioInfo) => { @@ -404,10 +440,11 @@ const Player = () => { } setHeartbeatTrackId(null) setCurrentTrackId(null) + closeLyrics() dispatch(clearQueue()) reject() }) - }, [dispatch, currentTrackId]) + }, [closeLyrics, dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -471,6 +508,7 @@ const Player = () => { onBeforeDestroy={onBeforeDestroy} getAudioInstance={setAudioInstance} /> + {mobileLyricsSurface} ) diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx index 4812141ab..494c5bf98 100644 --- a/ui/src/audioplayer/PlayerToolbar.jsx +++ b/ui/src/audioplayer/PlayerToolbar.jsx @@ -4,6 +4,8 @@ import { useGetOne } from 'react-admin' import { GlobalHotKeys } from 'react-hotkeys' import IconButton from '@material-ui/core/IconButton' import { useMediaQuery } from '@material-ui/core' +import Tooltip from '@material-ui/core/Tooltip' +import { RiFileMusicLine } from 'react-icons/ri' import { RiSaveLine } from 'react-icons/ri' import { LoveButton, useToggleLove } from '../common' import { openSaveQueueDialog } from '../actions' @@ -55,7 +57,16 @@ const useStyles = makeStyles((theme) => ({ }, })) -const PlayerToolbar = ({ id, isRadio }) => { +const PlayerToolbar = ({ + id, + isRadio, + onToggleLyrics, + lyricsActive = false, + lyricsDisabled = false, + lyricsLoading = false, + lyricsLabel = 'Toggle lyrics', + lyricsLoadingLabel = 'Loading lyrics', +}) => { const dispatch = useDispatch() const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio }) const [toggleLove, toggling] = useToggleLove('song', data) @@ -76,6 +87,7 @@ const PlayerToolbar = ({ id, isRadio }) => { const buttonClass = isDesktop ? classes.button : classes.mobileButton const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem + const lyricsUnavailable = lyricsDisabled || (lyricsLoading && !lyricsActive) const saveQueueButton = ( { /> ) + const currentLyricsLabel = lyricsLoading ? lyricsLoadingLabel : lyricsLabel + const lyricsButton = ( + + + + + + + + ) + return ( <> @@ -106,11 +141,13 @@ const PlayerToolbar = ({ id, isRadio }) => {
  • {saveQueueButton} {loveButton} + {lyricsButton}
  • ) : ( <>
  • {saveQueueButton}
  • {loveButton}
  • +
  • {lyricsButton}
  • )} diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx index d0368b0f0..70d153290 100644 --- a/ui/src/audioplayer/PlayerToolbar.test.jsx +++ b/ui/src/audioplayer/PlayerToolbar.test.jsx @@ -61,16 +61,17 @@ describe('', () => { useMediaQuery.mockReturnValue(true) // isDesktop = true }) - it('renders desktop toolbar with both buttons', () => { + it('renders desktop toolbar with all buttons', () => { render() - // Both buttons should be in a single list item + // All buttons should be in a single list item const listItems = screen.getAllByRole('listitem') expect(listItems).toHaveLength(1) - // Verify both buttons are rendered + // Verify all buttons are rendered expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() expect(screen.getByTestId('love-button')).toBeInTheDocument() + expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument() // Verify desktop classes are applied expect(listItems[0].className).toContain('toolbar') @@ -102,6 +103,29 @@ describe('', () => { type: 'OPEN_SAVE_QUEUE_DIALOG', }) }) + + it('toggles lyrics from an accessible toolbar button', () => { + const onToggleLyrics = vi.fn() + render( + , + ) + + const lyricsButton = screen.getByLabelText('Toggle lyrics') + expect(lyricsButton).toHaveAttribute( + 'data-testid', + 'toggle-lyrics-button', + ) + expect(lyricsButton).toHaveClass('MuiIconButton-colorPrimary') + expect(lyricsButton).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(lyricsButton) + + expect(onToggleLyrics).toHaveBeenCalledTimes(1) + }) }) describe('Mobile layout', () => { @@ -114,15 +138,17 @@ describe('', () => { // Each button should be in its own list item const listItems = screen.getAllByRole('listitem') - expect(listItems).toHaveLength(2) + expect(listItems).toHaveLength(3) - // Verify both buttons are rendered + // Verify all buttons are rendered expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() expect(screen.getByTestId('love-button')).toBeInTheDocument() + expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument() // Verify mobile classes are applied expect(listItems[0].className).toContain('mobileListItem') expect(listItems[1].className).toContain('mobileListItem') + expect(listItems[2].className).toContain('mobileListItem') }) it('disables save queue button when isRadio is true', () => { @@ -140,6 +166,26 @@ describe('', () => { const loveButton = screen.getByTestId('love-button') expect(loveButton).toBeDisabled() }) + + it('disables lyrics toggle when lyrics are unavailable', () => { + render( + , + ) + + expect(screen.getByTestId('toggle-lyrics-button')).toBeDisabled() + }) + + it('shows loading state on the lyrics toggle before lyrics are available', () => { + render( + , + ) + + const lyricsButton = screen.getByTestId('toggle-lyrics-button') + expect(lyricsButton).toBeDisabled() + expect(lyricsButton).toHaveAttribute('aria-label', 'Loading lyrics') + expect(lyricsButton).toHaveAttribute('aria-busy', 'true') + expect(lyricsButton).toHaveAttribute('aria-pressed', 'false') + }) }) describe('Common behavior', () => { diff --git a/ui/src/audioplayer/lyricsSidebarState.js b/ui/src/audioplayer/lyricsSidebarState.js new file mode 100644 index 000000000..c4fbbc6ba --- /dev/null +++ b/ui/src/audioplayer/lyricsSidebarState.js @@ -0,0 +1,28 @@ +export const resolveLyricsSidebarState = ({ + lyricsVisiblePreference, + translationPreference, + pronunciationPreference, + hasTranslationLyric, + hasPronunciationLyric, +}) => ({ + lyricsVisible: Boolean(lyricsVisiblePreference), + showTranslation: Boolean( + (translationPreference == null ? true : translationPreference) && + hasTranslationLyric, + ), + showPronunciation: Boolean( + (pronunciationPreference == null ? true : pronunciationPreference) && + hasPronunciationLyric, + ), +}) + +export const toggleLayerPreference = ( + previousPreference, + hasLayer, + defaultEnabled = true, +) => { + if (!hasLayer) return false + const currentPreference = + previousPreference == null ? defaultEnabled : previousPreference + return !currentPreference +} diff --git a/ui/src/audioplayer/lyricsSidebarState.test.js b/ui/src/audioplayer/lyricsSidebarState.test.js new file mode 100644 index 000000000..db2f02360 --- /dev/null +++ b/ui/src/audioplayer/lyricsSidebarState.test.js @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + resolveLyricsSidebarState, + toggleLayerPreference, +} from './lyricsSidebarState' + +describe('lyricsSidebarState', () => { + it('defaults available layers on and keeps preferences local', () => { + const resolve = (availability, preferences = {}) => + resolveLyricsSidebarState({ + lyricsVisiblePreference: true, + translationPreference: null, + pronunciationPreference: null, + ...availability, + ...preferences, + }) + + expect( + resolve({ hasTranslationLyric: true, hasPronunciationLyric: true }), + ).toEqual({ + lyricsVisible: true, + showTranslation: true, + showPronunciation: true, + }) + expect( + resolve( + { hasTranslationLyric: true, hasPronunciationLyric: false }, + { translationPreference: false, pronunciationPreference: true }, + ), + ).toEqual({ + lyricsVisible: true, + showTranslation: false, + showPronunciation: false, + }) + + expect(toggleLayerPreference(null, true)).toBe(false) + expect(toggleLayerPreference(null, true, false)).toBe(true) + expect(toggleLayerPreference(false, true)).toBe(true) + expect(toggleLayerPreference(true, false)).toBe(false) + }) +}) diff --git a/ui/src/audioplayer/lyricsSidebarWidth.js b/ui/src/audioplayer/lyricsSidebarWidth.js new file mode 100644 index 000000000..9c72deacb --- /dev/null +++ b/ui/src/audioplayer/lyricsSidebarWidth.js @@ -0,0 +1,61 @@ +export const LYRICS_SIDEBAR_STORAGE_KEY = 'nd.lyricsSidebar.width.v1' +export const LYRICS_SIDEBAR_WIDTH_EVENT = 'nd:lyrics-sidebar-width' +export const LYRICS_SIDEBAR_DEFAULT_WIDTH = 360 +export const LYRICS_SIDEBAR_MIN_WIDTH = 300 +export const LYRICS_SIDEBAR_MAX_WIDTH = 520 +export const LYRICS_SIDEBAR_WIDTH_STEP = 20 +export const LYRICS_SIDEBAR_TRANSITION_MS = 260 + +export const clampSidebarWidth = (value) => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_DEFAULT_WIDTH + return Math.round( + Math.min( + LYRICS_SIDEBAR_MAX_WIDTH, + Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric), + ), + ) +} + +const hasLocalStorage = () => { + try { + return typeof window !== 'undefined' && Boolean(window.localStorage) + } catch { + return false + } +} + +export const loadSidebarWidth = () => { + if (!hasLocalStorage()) return LYRICS_SIDEBAR_DEFAULT_WIDTH + try { + const stored = window.localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY) + return stored == null + ? LYRICS_SIDEBAR_DEFAULT_WIDTH + : clampSidebarWidth(stored) + } catch { + return LYRICS_SIDEBAR_DEFAULT_WIDTH + } +} + +export const notifySidebarWidthChange = (width) => { + if (typeof window === 'undefined') return + + window.dispatchEvent( + new CustomEvent(LYRICS_SIDEBAR_WIDTH_EVENT, { + detail: { width: clampSidebarWidth(width) }, + }), + ) +} + +export const saveSidebarWidth = (width) => { + const resolvedWidth = clampSidebarWidth(width) + if (!hasLocalStorage()) return + try { + window.localStorage.setItem( + LYRICS_SIDEBAR_STORAGE_KEY, + String(resolvedWidth), + ) + } catch { + // Ignore storage failures. + } +} diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 30a14d4db..5ceea079b 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -32,7 +32,7 @@ const useStyle = makeStyles( display: 'none', }, }, - '@media (prefers-reduced-motion)': { + '@media (prefers-reduced-motion: reduce)': { '& .music-player-panel .panel-content div.img-rotate': { animation: 'none', }, @@ -62,11 +62,57 @@ const useStyle = makeStyles( // Fix cover display when image is not square aspectRatio: '1/1', display: 'flex', + position: 'relative', + }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active': + { + width: '100%', + maxWidth: '100%', + height: '100%', + aspectRatio: 'auto', + alignItems: 'stretch', + justifyContent: 'stretch', + }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer': + { + position: 'absolute', + inset: 0, + opacity: 0, + transform: 'scale(0.985)', + transition: + 'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)', + '@media (prefers-reduced-motion: reduce)': { + transition: 'opacity 120ms linear', + transform: 'none', + }, + }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer[data-entered="true"]': + { + opacity: 1, + transform: 'scale(1)', + '@media (prefers-reduced-motion: reduce)': { + transform: 'none', + }, }, '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover': { animationDuration: (props) => !props.enableCoverAnimation && '0s', objectFit: 'contain', // Fix cover display when image is not square + transition: + 'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)', + '@media (prefers-reduced-motion: reduce)': { + transition: 'opacity 120ms linear', + transform: 'none', + }, + }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active img.cover': + { + opacity: 0, + transform: 'scale(0.96)', + pointerEvents: 'none', + '@media (prefers-reduced-motion: reduce)': { + transform: 'none', + }, }, // Hide old singer display '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer': diff --git a/ui/src/audioplayer/useEnterExitTransition.js b/ui/src/audioplayer/useEnterExitTransition.js new file mode 100644 index 000000000..cdabf58f8 --- /dev/null +++ b/ui/src/audioplayer/useEnterExitTransition.js @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react' + +const useEnterExitTransition = (active, transitionMs) => { + const [rendered, setRendered] = useState(active) + const [entered, setEntered] = useState(active) + + useEffect(() => { + if (active) { + setRendered(true) + const frameId = window.requestAnimationFrame(() => setEntered(true)) + return () => window.cancelAnimationFrame(frameId) + } + + setEntered(false) + const timerId = window.setTimeout(() => setRendered(false), transitionMs) + return () => window.clearTimeout(timerId) + }, [active, transitionMs]) + + return { rendered, entered } +} + +export default useEnterExitTransition diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx new file mode 100644 index 000000000..ad9971e27 --- /dev/null +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -0,0 +1,185 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import LyricsPanel from './LyricsPanel' +import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal' +import { hasStructuredLyricContent } from './lyrics' +import { + resolveLyricsSidebarState, + toggleLayerPreference, +} from './lyricsSidebarState' +import useEnhancedLyrics from './useEnhancedLyrics' + +const usePlayerLyrics = ({ + trackId, + trackUpdatedAt, + isRadio, + audioInstance, + isDesktop, + translate, +}) => { + const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false) + const [lyricsRequested, setLyricsRequested] = useState(false) + const [translationPreference, setTranslationPreference] = useState(null) + const [pronunciationPreference, setPronunciationPreference] = useState(null) + + useEffect(() => { + setLyricsRequested(lyricsVisiblePreference) + }, [trackId]) // eslint-disable-line react-hooks/exhaustive-deps + + const { + layers: lyricLayers, + loading: lyricsLoading, + error: lyricsError, + retry: retryLyrics, + } = useEnhancedLyrics({ + trackId, + updatedAt: trackUpdatedAt, + disabled: isRadio, + requested: lyricsRequested || lyricsVisiblePreference, + }) + + const hasTranslationLyric = hasStructuredLyricContent(lyricLayers.translation) + const hasPronunciationLyric = hasStructuredLyricContent( + lyricLayers.pronunciation, + ) + const { lyricsVisible, showTranslation, showPronunciation } = + resolveLyricsSidebarState({ + lyricsVisiblePreference, + translationPreference, + pronunciationPreference, + hasTranslationLyric, + hasPronunciationLyric, + }) + const lyricsToggleDisabled = !lyricsVisiblePreference && (isRadio || !trackId) + const useInlineMobileLyrics = lyricsVisible && !isDesktop + + const labels = useMemo( + () => ({ + title: translate('player.lyricsTitleText'), + toggle: translate('player.toggleLyricText'), + loading: translate('player.lyricsLoadingText'), + unavailable: translate('player.lyricsUnavailableText'), + empty: translate('player.emptyLyricText'), + resize: translate('player.resizeLyricsSidebarText'), + showTranslation: translate('player.showLyricsTranslationText'), + hideTranslation: translate('player.hideLyricsTranslationText'), + showPronunciation: translate('player.showLyricsPronunciationText'), + hidePronunciation: translate('player.hideLyricsPronunciationText'), + }), + [translate], + ) + + const toggleLyrics = useCallback(() => { + const next = !lyricsVisiblePreference + setLyricsVisiblePreference(next) + setLyricsRequested(next) + if (next) { + if (lyricsError) retryLyrics() + } + }, [lyricsError, lyricsVisiblePreference, retryLyrics]) + + const closeLyrics = useCallback(() => { + setLyricsVisiblePreference(false) + setLyricsRequested(false) + }, []) + + const toggleTranslation = useCallback(() => { + setTranslationPreference((current) => + toggleLayerPreference(current, hasTranslationLyric), + ) + }, [hasTranslationLyric]) + + const togglePronunciation = useCallback(() => { + setPronunciationPreference((current) => + toggleLayerPreference(current, hasPronunciationLyric, true), + ) + }, [hasPronunciationLyric]) + + const toolbarLyricsProps = useMemo( + () => ({ + onToggleLyrics: toggleLyrics, + lyricsActive: lyricsVisible, + lyricsDisabled: lyricsToggleDisabled, + lyricsLoading, + lyricsLabel: labels.toggle, + lyricsLoadingLabel: labels.loading, + }), + [labels, lyricsLoading, lyricsToggleDisabled, lyricsVisible, toggleLyrics], + ) + + const desktopLyricsProps = useMemo( + () => ({ + visible: isDesktop && lyricsVisible, + mainLyric: lyricLayers.main, + translationLyric: lyricLayers.translation, + pronunciationLyric: lyricLayers.pronunciation, + showTranslation, + showPronunciation, + translationEnabled: hasTranslationLyric, + pronunciationEnabled: hasPronunciationLyric, + onToggleTranslation: toggleTranslation, + onTogglePronunciation: togglePronunciation, + audioInstance, + loading: lyricsLoading, + error: lyricsError, + labels, + }), + [ + audioInstance, + hasPronunciationLyric, + hasTranslationLyric, + isDesktop, + labels, + lyricLayers.main, + lyricLayers.pronunciation, + lyricLayers.translation, + lyricsError, + lyricsLoading, + lyricsVisible, + showPronunciation, + showTranslation, + togglePronunciation, + toggleTranslation, + ], + ) + + const mobileLyricsSurface = useMemo( + () => ( + + + + ), + [ + audioInstance, + lyricLayers.main, + lyricLayers.pronunciation, + lyricLayers.translation, + lyricsError, + lyricsLoading, + labels, + showPronunciation, + showTranslation, + useInlineMobileLyrics, + ], + ) + + return { + toolbarLyricsProps, + desktopLyricsProps, + mobileLyricsSurface, + useInlineMobileLyrics, + closeLyrics, + } +} + +export default usePlayerLyrics diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index c0e226453..4f1a53789 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -652,6 +652,14 @@ "reloadText": "Reload", "volumeText": "Volume", "toggleLyricText": "Toggle lyrics", + "lyricsTitleText": "Lyrics", + "lyricsLoadingText": "Loading lyrics", + "lyricsUnavailableText": "Lyrics unavailable", + "resizeLyricsSidebarText": "Resize lyrics sidebar", + "showLyricsTranslationText": "Show translation", + "hideLyricsTranslationText": "Hide translation", + "showLyricsPronunciationText": "Show pronunciation", + "hideLyricsPronunciationText": "Hide pronunciation", "toggleMiniModeText": "Minimize", "destroyText": "Destroy", "downloadText": "Download", diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx index 44cf9b42c..9b54c5aff 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.jsx @@ -8,6 +8,7 @@ import AppBar from './AppBar' import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' import { useSearchRefocus } from '../common' +import LyricsLayoutFrame from './LyricsLayoutFrame' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, @@ -33,7 +34,9 @@ const Layout = (props) => { appBar={AppBar} theme={theme} notification={Notification} - /> + > + {props.children} + ) } diff --git a/ui/src/layout/LyricsLayoutFrame.jsx b/ui/src/layout/LyricsLayoutFrame.jsx new file mode 100644 index 000000000..5dad12872 --- /dev/null +++ b/ui/src/layout/LyricsLayoutFrame.jsx @@ -0,0 +1,55 @@ +import React, { useEffect, useState } from 'react' +import { makeStyles } from '@material-ui/core/styles' +import LyricsSidebar from '../audioplayer/LyricsSidebar' +import { useLyricsLayout } from '../audioplayer/LyricsLayoutContext' +import { + LYRICS_SIDEBAR_WIDTH_EVENT, + clampSidebarWidth, + loadSidebarWidth, +} from '../audioplayer/lyricsSidebarWidth' + +const useStyles = makeStyles( + () => ({ + frame: { + minWidth: 0, + }, + }), + { name: 'NDLyricsLayoutFrame' }, +) + +const LyricsLayoutFrame = ({ children }) => { + const { desktopLyricsProps } = useLyricsLayout() + const hasVisibleLyrics = Boolean(desktopLyricsProps?.visible) + const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth) + const classes = useStyles() + + useEffect(() => { + if (!hasVisibleLyrics) return undefined + + const handleSidebarWidth = (event) => { + const next = clampSidebarWidth(event.detail?.width) + setSidebarWidth((current) => (current === next ? current : next)) + } + + setSidebarWidth(loadSidebarWidth()) + window.addEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth) + return () => + window.removeEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth) + }, [hasVisibleLyrics]) + + return ( +
    + {children} + {desktopLyricsProps && } +
    + ) +} + +export default LyricsLayoutFrame diff --git a/ui/src/layout/LyricsLayoutFrame.test.jsx b/ui/src/layout/LyricsLayoutFrame.test.jsx new file mode 100644 index 000000000..bb8ee0bc5 --- /dev/null +++ b/ui/src/layout/LyricsLayoutFrame.test.jsx @@ -0,0 +1,114 @@ +import React, { useEffect } from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + LyricsLayoutProvider, + useLyricsLayout, +} from '../audioplayer/LyricsLayoutContext' +import { LYRICS_SIDEBAR_TRANSITION_MS } from '../audioplayer/lyricsSidebarWidth' +import LyricsLayoutFrame from './LyricsLayoutFrame' + +const theme = createTheme({ + palette: { + primary: { main: '#35aa66' }, + background: { default: '#101820' }, + }, +}) + +const lyric = { + synced: true, + line: [{ start: 0, end: 1000, value: 'Main line' }], +} + +const lyricsProps = { + visible: true, + mainLyric: lyric, + showTranslation: false, + showPronunciation: false, + translationEnabled: false, + pronunciationEnabled: false, + onToggleTranslation: vi.fn(), + onTogglePronunciation: vi.fn(), +} + +const PublishLyricsProps = ({ props }) => { + const { setDesktopLyricsProps } = useLyricsLayout() + + useEffect(() => { + setDesktopLyricsProps(props) + return () => setDesktopLyricsProps(null) + }, [props, setDesktopLyricsProps]) + + return null +} + +const frame = (props) => ( + + + + +
    Albums grid
    +
    +
    +
    +) + +const renderFrame = (props) => { + const view = render(frame(props)) + return { + ...view, + rerenderFrame: (nextProps) => view.rerender(frame(nextProps)), + } +} + +describe('', () => { + afterEach(() => { + vi.useRealTimers() + cleanup() + }) + + it('reserves a native right-hand lyrics pane beside route content', async () => { + renderFrame(lyricsProps) + + const sidebar = await screen.findByTestId('lyrics-sidebar') + const frame = screen.getByTestId('lyrics-layout-frame') + const content = screen.getByTestId('route-content') + + expect(frame).toHaveAttribute('data-lyrics-sidebar-visible', 'true') + expect(frame).toHaveStyle({ marginRight: '360px' }) + expect(frame).toContainElement(content) + expect(frame).toContainElement(sidebar) + expect(window.getComputedStyle(sidebar).position).toBe('fixed') + }) + + it('releases the layout frame after the sidebar exit transition', async () => { + vi.useFakeTimers() + const { rerenderFrame } = renderFrame(lyricsProps) + + await screen.findByTestId('lyrics-sidebar') + + rerenderFrame({ ...lyricsProps, visible: false }) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute( + 'data-lyrics-sidebar-visible', + 'false', + ) + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '0px', + }) + expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({ + transform: 'translateX(100%)', + }) + + act(() => { + vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS) + }) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute( + 'data-lyrics-sidebar-visible', + 'false', + ) + expect(screen.queryByTestId('lyrics-sidebar')).toBeNull() + }) +}) From f8a03b48c4645066b5a07c7aa54dac067b6960f5 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sat, 25 Jul 2026 15:10:49 +0300 Subject: [PATCH 04/16] fix(ui): stabilize lyrics rise animations - Keep translation lift stable across timing modes, wrapping, and release states.\n- Drive a compact character wave from the shared media timeline with smoother easing. Signed-off-by: ranokay --- ui/src/audioplayer/LyricsPanel.jsx | 29 ++- ui/src/audioplayer/LyricsPanel.test.jsx | 130 ++++++++-- ui/src/audioplayer/lyricsKaraokeConstants.js | 87 ++++++- ui/src/audioplayer/useLyricsTimeline.js | 112 ++++++-- ui/src/audioplayer/useLyricsTimeline.test.js | 258 +++++++++++++++---- 5 files changed, 505 insertions(+), 111 deletions(-) diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index bc6cfe20d..8a73dda21 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -127,10 +127,14 @@ const useStyles = makeStyles((theme) => ({ 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}`, }, - '&[data-raised="true"][data-line-motion="character"][data-character-wave="false"]': + '&[data-raised="true"][data-line-motion="character"][data-character-wave="false"] $line': { transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, }, + '&[data-raised="true"][data-line-motion="character"] $translationLine': { + transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + transition: `${KARAOKE_LAYER_OPACITY_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))', @@ -143,15 +147,22 @@ 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', + }, }, }, waveCharacter: { display: 'inline-block', verticalAlign: 'baseline', - transform: 'translateY(0)', + transform: 'translate3d(0, 0, 0)', transformOrigin: 'center bottom', + willChange: 'transform', '@media (prefers-reduced-motion: reduce)': { transform: 'none !important', + willChange: 'auto', }, }, line: { @@ -197,14 +208,6 @@ const useStyles = makeStyles((theme) => ({ transform: 'none', }, }, - characterWaveAuxLine: { - transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, - transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - transform: 'none', - }, - }, activeAuxLine: { opacity: KARAOKE_TRANSLATION_OPACITY, }, @@ -746,7 +749,10 @@ const LyricsPanel = ({ const canSeekLine = Boolean(audioInstance && line.start != null) const isActiveLine = activeIndexSet.has(idx) const renderCharacterWave = Boolean( - !prefersReducedMotion && hasTimedMainLines && isActiveLine, + !prefersReducedMotion && + hasTimedMainLines && + usesCharacterRise && + isActiveLine, ) const isStaticLine = !hasTimedMainLines return ( @@ -882,7 +888,6 @@ const LyricsPanel = ({ line={buildSynchronizedTranslationLine(line, trLine)} nextLineStart={null} className={clsx(classes.auxLine, classes.translationLine, { - [classes.characterWaveAuxLine]: renderCharacterWave, [classes.activeAuxLine]: isStaticLine || isActiveLine, })} style={layerStyles.translation} diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index 40bd9ea9b..032e369e3 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -106,6 +106,7 @@ const createAudio = ({ currentTime = 0, duration = 10 } = {}) => { describe('', () => { const originalMatchMedia = window.matchMedia + const originalResizeObserver = window.ResizeObserver beforeEach(() => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0) @@ -116,6 +117,7 @@ describe('', () => { vi.useRealTimers() vi.restoreAllMocks() window.matchMedia = originalMatchMedia + window.ResizeObserver = originalResizeObserver cleanup() }) @@ -330,18 +332,33 @@ describe('', () => { it('raises a line once and keeps it elevated after release', () => { const { rerenderPanel } = renderPanel({ mainLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showTranslation: true, audioInstance: { currentTime: 0.5, paused: true }, }) const group = screen.getByTestId('lyrics-line-group') + const translation = screen + .getByText('translated 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(window.getComputedStyle(translation).transform).toBe('') rerenderPanel({ mainLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showTranslation: true, audioInstance: { currentTime: 1.1, paused: true }, }) @@ -351,24 +368,13 @@ describe('', () => { expect(releasedStyle.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', () => { - const offsetPronunciationLyric = { - ...tokenizedPronunciationLyric, - cueLine: [ - { - ...tokenizedPronunciationLyric.cueLine[0], - cue: [ - { start: 100, end: 850, value: 'mein' }, - { start: 850, end: 1000, value: 'lain' }, - ], - }, - ], - } const propsAt = (currentTime) => ({ mainLyric: tokenizedMainLyric, - pronunciationLyric: offsetPronunciationLyric, + pronunciationLyric: tokenizedPronunciationLyric, translationLyric: { synced: true, line: [{ start: 0, end: 1000, value: 'translated line' }], @@ -377,7 +383,7 @@ describe('', () => { showTranslation: true, audioInstance: { currentTime, paused: true }, }) - const { rerenderPanel } = renderPanel(propsAt(0.25)) + const { rerenderPanel } = renderPanel(propsAt(0.15)) const group = screen.getByTestId('lyrics-line-group') const translation = screen @@ -403,7 +409,10 @@ describe('', () => { expect(mainCharacters).toHaveLength(4) expect(pronunciationCharacters).toHaveLength(4) expect(mainCharacters[0].style.transform).toBe( - `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px, 0)`, + ) + expect(window.getComputedStyle(mainCharacters[0]).willChange).toBe( + 'transform', ) expect(mainCharacters[3].style.transform).not.toBe( mainCharacters[0].style.transform, @@ -422,16 +431,103 @@ describe('', () => { expect( group.querySelectorAll('[data-lyrics-character="true"]'), ).toHaveLength(0) - expect(window.getComputedStyle(group).transform).toBe( + expect(window.getComputedStyle(group).transform).toBe('translateY(0)') + const releasedMainRow = screen + .getAllByTestId('lyrics-token')[0] + .closest('[data-tokenized]') + 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', () => { + let wrapped = false + const observers = [] + window.ResizeObserver = class { + constructor(callback) { + this.callback = callback + this.targets = [] + observers.push(this) + } + + observe(target) { + this.targets.push(target) + } + + unobserve(target) { + this.targets = this.targets.filter((candidate) => candidate !== target) + } + + disconnect() { + this.targets = [] + } + } + vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation( + function getOffsetTop() { + if (!wrapped || this.dataset.stackedToken !== 'true') return 0 + const tokens = Array.from( + this.parentElement?.querySelectorAll('[data-stacked-token="true"]') || + [], + ) + return tokens.indexOf(this) > 0 ? 32 : 0 + }, + ) + const notifyResizeObservers = () => { + observers.forEach(({ callback, targets }) => { + callback(targets.map((target) => ({ target }))) + }) + } + const propsAt = (currentTime) => ({ + mainLyric: tokenizedMainLyric, + pronunciationLyric: tokenizedPronunciationLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showPronunciation: true, + showTranslation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(0.25)) + const row = screen + .getAllByTestId('lyrics-token')[0] + .closest('[data-wrapped]') + const translation = screen + .getByText('translated line') + .closest('[data-tokenized]') + + expect(row).toHaveAttribute('data-wrapped', 'false') + expect(window.getComputedStyle(translation).transform).toBe( + `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + ) + + wrapped = true + act(notifyResizeObservers) + expect(row).toHaveAttribute('data-wrapped', 'true') + expect(window.getComputedStyle(translation).transform).toBe( + `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + ) + + rerenderPanel(propsAt(1.1)) + expect(window.getComputedStyle(translation).transform).toBe( + `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + ) + + wrapped = false + act(notifyResizeObservers) + expect(row).toHaveAttribute('data-wrapped', 'false') + expect(window.getComputedStyle(translation).transform).toBe( + `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + ) + }) + it('keeps detailed grapheme markup on active lines without changing token layout', () => { const values = ['First word', 'Second word', 'Active word', 'Fourth word'] const lyricFor = (pronunciation = false) => ({ diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js index db48b7bd9..bce62f151 100644 --- a/ui/src/audioplayer/lyricsKaraokeConstants.js +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -1,14 +1,20 @@ export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140 export const KARAOKE_HIGHLIGHT_LEAD_MS = 120 -export const KARAOKE_ANIMATION_MS = 220 +export const KARAOKE_ANIMATION_MS = 500 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_ANIMATION_MS export const KARAOKE_LINE_LIFT_PX = 1.5 -export const KARAOKE_LINE_MOTION_EASING = 'cubic-bezier(0.25, 0.1, 0.25, 1)' +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 +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 = 1.5 -export const KARAOKE_CHARACTER_PHASE_SPREAD = 0.36 +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_OPACITY = 0.62 export const KARAOKE_LINE_RELEASE_MS = KARAOKE_ANIMATION_MS export const KARAOKE_SCROLL_PRE_ROLL_MS = 320 @@ -23,7 +29,76 @@ export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34 export const TOKEN_WIPE_EDGE_PCT = 12 export const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) -export const easeInOut = (value) => { - const clamped = clamp(value, 0, 1) - return clamped < 0.5 ? 2 * clamped * clamped : 1 - (-2 * clamped + 2) ** 2 / 2 + +const cubicBezierCoordinate = (time, firstControl, secondControl) => { + const inverse = 1 - time + return ( + 3 * inverse * inverse * time * firstControl + + 3 * inverse * time * time * secondControl + + time * time * time + ) +} + +const cubicBezierSlope = (time, firstControl, secondControl) => { + const inverse = 1 - time + return ( + 3 * inverse * inverse * firstControl + + 6 * inverse * time * (secondControl - firstControl) + + 3 * time * time * (1 - secondControl) + ) +} + +const solveStrongEaseInOutTime = (progress) => { + 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 + if (Math.abs(error) < 0.000001) return time + const slope = cubicBezierSlope( + time, + KARAOKE_EASE_IN_OUT_X1, + KARAOKE_EASE_IN_OUT_X2, + ) + if (Math.abs(slope) < 0.000001) break + time = clamp(time - error / slope, 0, 1) + } + + let lower = 0 + 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 + ) { + lower = time + } else { + upper = time + } + } + return time +} + +export const easeKaraokeMotion = (value) => { + const progress = clamp(value, 0, 1) + if (progress === 0 || progress === 1) return progress + return cubicBezierCoordinate( + solveStrongEaseInOutTime(progress), + KARAOKE_EASE_IN_OUT_Y1, + KARAOKE_EASE_IN_OUT_Y2, + ) +} + +export const easeInOut = (value) => { + const progress = clamp(value, 0, 1) + return progress < 0.5 + ? 2 * progress * progress + : 1 - (-2 * progress + 2) ** 2 / 2 } diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js index b83aa4b0d..508da1069 100644 --- a/ui/src/audioplayer/useLyricsTimeline.js +++ b/ui/src/audioplayer/useLyricsTimeline.js @@ -8,10 +8,13 @@ import { } from 'react' import { KARAOKE_CHARACTER_LIFT_PX, - KARAOKE_CHARACTER_PHASE_SPREAD, + KARAOKE_CHARACTER_RISE_MS, + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS, + KARAOKE_CHARACTER_WAVE_SPAN_RATIO, KARAOKE_CLOCK_DRIFT_RESET_MS, KARAOKE_HIGHLIGHT_LEAD_MS, KARAOKE_LINE_RELEASE_MS, + easeKaraokeMotion, } from './lyricsKaraokeConstants' import { buildLyricsTimeline, @@ -42,33 +45,83 @@ const setProgress = (record, value) => { record.node.style.setProperty('--lyrics-progress', String(next)) } -const smootherStep = (value) => - value * value * value * (value * (value * 6 - 15) + 10) +const setCharacterTransform = (node, localProgress) => { + const lift = Math.max( + 0, + Math.min( + KARAOKE_CHARACTER_LIFT_PX, + KARAOKE_CHARACTER_LIFT_PX * easeKaraokeMotion(localProgress), + ), + ) + if (lift < 0.00005) { + if (node.style.transform) node.style.removeProperty('transform') + return + } + const nextTransform = `translate3d(0, -${lift.toFixed(4)}px, 0)` + if (node.style.transform !== nextTransform) { + node.style.transform = nextTransform + } +} -const setCharacterLift = (record, progress) => { +const resetCharacterLift = (record) => { + const characters = record.characters || [] + if (!characters.length) return + if (record.characterMotionState === 'future') return + + characters.forEach(({ node }) => setCharacterTransform(node, 0)) + record.characterMotionState = 'future' +} + +const settleCharacterLift = (record) => { + const characters = record.characters || [] + if (!characters.length) return + if (record.characterMotionState === 'settled') return + + characters.forEach(({ node }) => setCharacterTransform(node, 1)) + record.characterMotionState = 'settled' +} + +const setCharacterLiftAtTime = (record, time) => { const characters = record.characters || [] if (!characters.length) return - const riseSpan = Math.max(0.001, 1 - KARAOKE_CHARACTER_PHASE_SPREAD) + const current = Number(time) + if (!Number.isFinite(current) || current <= record.characterMotionStart) { + resetCharacterLift(record) + return + } + if (current >= record.characterMotionEnd) { + settleCharacterLift(record) + return + } - characters.forEach(({ node, phase }) => { - const local = Math.max(0, Math.min(1, (progress - phase) / riseSpan)) - const lift = Math.max( + characters.forEach(({ node, start }) => { + const localProgress = Math.max( 0, - Math.min( - KARAOKE_CHARACTER_LIFT_PX, - KARAOKE_CHARACTER_LIFT_PX * smootherStep(local), - ), + Math.min(1, (current - start) / KARAOKE_CHARACTER_RISE_MS), ) - if (lift < 0.00005) { - if (node.style.transform) node.style.removeProperty('transform') - return - } - const nextTransform = `translateY(-${lift.toFixed(4)}px)` - if (node.style.transform !== nextTransform) { - node.style.transform = nextTransform - } + setCharacterTransform(node, localProgress) }) + record.characterMotionState = 'moving' +} + +const getCharacterTiming = (window, characterCount) => { + const start = Number(window?.start) + const duration = Number(window?.end) - Number(window?.start) + const safeStart = Number.isFinite(start) ? start : 0 + const safeDuration = Number.isFinite(duration) ? Math.max(0, duration) : 0 + const waveSpan = + characterCount <= 1 + ? 0 + : Math.min( + safeDuration * KARAOKE_CHARACTER_WAVE_SPAN_RATIO, + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS, + ) + return { + start: safeStart, + waveSpan, + end: safeStart + waveSpan + KARAOKE_CHARACTER_RISE_MS, + } } const setCharacterGradient = (record) => { @@ -171,7 +224,6 @@ const applyTokenState = (record, state, progress = 0) => { const isPast = state === 'inactive-past' const nextProgress = isFuture ? 0 : state === 'active' ? progress : 1 setProgress(record, nextProgress) - setCharacterLift(record, nextProgress) setTokenOpacity(record, 1) setTokenActiveAlpha( record, @@ -196,10 +248,13 @@ const setTokenReleasePresentation = (record, progress) => { record, activeAlpha + (targetAlpha - activeAlpha) * nextProgress, ) + settleCharacterLift(record) } const resetToken = (record, state = 'future') => { applyTokenState(record, state, 0) + if (state === 'future') resetCharacterLift(record) + else settleCharacterLift(record) } const setTokenPresentation = (record, time) => { @@ -209,6 +264,7 @@ const setTokenPresentation = (record, time) => { if (record.state !== state || state === 'active') { applyTokenState(record, state, progress) } + setCharacterLiftAtTime(record, time) } const canListenToMedia = (audio) => @@ -477,12 +533,17 @@ const useLyricsTimeline = ({ ? [] : Array.from(node.querySelectorAll('[data-lyrics-character="true"]')) const characterCount = characterNodes.length + const characterTiming = getCharacterTiming( + descriptor.window, + characterCount, + ) const characters = characterNodes.map((characterNode, index) => ({ node: characterNode, - phase: + start: characterCount <= 1 - ? 0 - : (index / (characterCount - 1)) * KARAOKE_CHARACTER_PHASE_SPREAD, + ? characterTiming.start + : characterTiming.start + + (index / (characterCount - 1)) * characterTiming.waveSpan, })) const record = { key, @@ -492,6 +553,9 @@ const useLyricsTimeline = ({ window: descriptor.window, presentation: descriptor.presentation, characters, + characterMotionStart: characterTiming.start, + characterMotionEnd: characterTiming.end, + characterMotionState: null, progress: null, opacity: null, activeAlpha: null, diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js index 84f45918c..4c39a45fb 100644 --- a/ui/src/audioplayer/useLyricsTimeline.test.js +++ b/ui/src/audioplayer/useLyricsTimeline.test.js @@ -1,7 +1,13 @@ import { act } from '@testing-library/react' import { renderHook } from '@testing-library/react-hooks' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { KARAOKE_CHARACTER_LIFT_PX } from './lyricsKaraokeConstants' +import { + KARAOKE_CHARACTER_LIFT_PX, + KARAOKE_CHARACTER_RISE_MS, + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS, + KARAOKE_CHARACTER_WAVE_SPAN_RATIO, + KARAOKE_HIGHLIGHT_LEAD_MS, +} from './lyricsKaraokeConstants' import useLyricsTimeline from './useLyricsTimeline' const createAudio = ({ @@ -41,6 +47,10 @@ const presentation = { useCrossfade: false, } +const fullCharacterLift = `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed( + 4, +)}px, 0)` + const createTokenNode = (text = '') => { const tokenNode = document.createElement('span') Array.from(text).forEach((character) => { @@ -209,7 +219,7 @@ describe('useLyricsTimeline', () => { tokenNode .querySelectorAll('[data-lyrics-character="true"]') .forEach((character) => - expect(character.style.transform).toBe('translateY(-1.5000px)'), + expect(character.style.transform).toBe(fullCharacterLift), ) }) @@ -223,7 +233,7 @@ describe('useLyricsTimeline', () => { 'first', ) - syncNow(result, 1219) + syncNow(result, 1259) const gradient = tokenNode.style.backgroundImage const releaseAlpha = Number( tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'), @@ -232,7 +242,7 @@ describe('useLyricsTimeline', () => { expect(tokenNode.style.color).toBe('transparent') expect(tokenNode.style.opacity).toBe('1') - syncNow(result, 1220) + syncNow(result, 1260) const pastAlpha = Number( tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'), @@ -247,20 +257,20 @@ describe('useLyricsTimeline', () => { expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1') }) - it('uses the complete token duration for the character phase wave', () => { - const audio = createAudio({ currentTime: 0, duration: 5, paused: true }) + it('completes long-token character waves while highlighting continues', () => { + const audio = createAudio({ currentTime: 0, duration: 6, paused: true }) const longLines = [ { start: 0, - end: 4000, - tokens: [{ start: 0, end: 4000, value: 'super' }], + end: 5000, + tokens: [{ start: 500, end: 4500, value: 'super' }], }, ] const { result } = renderTimeline({ audio, sourceLines: longLines }) const tokenNode = registerToken( result, '0:long-word', - { start: 0, end: 4000 }, + { start: 500, end: 4500 }, 'super', ) @@ -270,77 +280,138 @@ describe('useLyricsTimeline', () => { expect(characters[0].style.backgroundImage).toBe( tokenNode.style.backgroundImage, ) + const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS + syncNow(result, tokenStart) characters.forEach((character) => - expect(character.style.transform).not.toMatch(/^translateY\(\d/), - ) - const firstTransforms = [] - ;[0, 160, 320, 480].forEach((time) => { - syncNow(result, time) - firstTransforms.push(characters[0].style.transform) - }) - - expect(new Set(firstTransforms).size).toBe(firstTransforms.length) - firstTransforms.forEach((transform) => - expect(transform).toMatch(/^translateY\(-\d+\.\d{4}px\)$/), + expect(character.style.transform).toBe(''), ) - syncNow(result, 1320) - const offsets = characters.map((character) => - Math.abs( - Number.parseFloat( - character.style.transform.match(/-?\d+\.\d+/)?.[0] || '0', - ), - ), - ) - offsets.forEach((offset) => expect(offset).toBeGreaterThanOrEqual(0)) - expect(offsets.filter((offset) => offset > 0)).toHaveLength(4) - expect(offsets[0]).toBeGreaterThan(offsets[1]) - expect(offsets[1]).toBeGreaterThan(offsets[2]) - expect(offsets[2]).toBeGreaterThan(offsets[3]) - expect(offsets[4]).toBe(0) + syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2) + expect(characters[0].style.transform).not.toBe(fullCharacterLift) + expect(characters[0].style.transform).not.toBe('') + expect(characters[4].style.transform).toBe('') - syncNow(result, 3600) - expect(characters[4].style.transform).not.toBe( - `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS) + expect(characters[0].style.transform).toBe(fullCharacterLift) + expect(characters[4].style.transform).toMatch( + /^translate3d\(0, -\d+\.\d{4}px, 0\)$/, ) + expect(characters[4].style.transform).not.toBe(fullCharacterLift) - syncNow(result, 3880) + const completeWaveAt = + tokenStart + + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS + + KARAOKE_CHARACTER_RISE_MS + syncNow(result, completeWaveAt) characters.forEach((character) => - expect(character.style.transform).toBe( - `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, - ), + expect(character.style.transform).toBe(fullCharacterLift), + ) + expect( + Number(tokenNode.style.getPropertyValue('--lyrics-progress')), + ).toBeCloseTo( + (KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS + KARAOKE_CHARACTER_RISE_MS) / 4000, + 5, ) }) - it('keeps the normalized wave shape for short token durations', () => { - const audio = createAudio({ currentTime: 0, duration: 1, paused: true }) + it('finishes short-token motion after highlighting without dropping the lift', () => { + const audio = createAudio({ currentTime: 0, duration: 2, paused: true }) const shortLines = [ { start: 0, - end: 180, - tokens: [{ start: 0, end: 180, value: 'go' }], + end: 1200, + tokens: [{ start: 500, end: 680, value: 'go' }], }, ] const { result } = renderTimeline({ audio, sourceLines: shortLines }) const tokenNode = registerToken( result, '0:short-word', - { start: 0, end: 180 }, + { start: 500, end: 680 }, 'go', ) - syncNow(result, 0) const characters = tokenNode.querySelectorAll( '[data-lyrics-character="true"]', ) + const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS + const waveSpan = 180 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO + syncNow(result, tokenStart) + expect(characters[0].style.transform).toBe('') + expect(characters[1].style.transform).toBe('') + + syncNow(result, 680 - KARAOKE_HIGHLIGHT_LEAD_MS) + expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1') expect(characters[0].style.transform).not.toBe('') - expect(characters[1].style.transform).not.toBe( - `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + expect(characters[1].style.transform).not.toBe(fullCharacterLift) + + syncNow(result, tokenStart + waveSpan + KARAOKE_CHARACTER_RISE_MS) + expect(characters[0].style.transform).toBe(fullCharacterLift) + expect(characters[1].style.transform).toBe(fullCharacterLift) + }) + + it('gives a single character the same fixed rise duration', () => { + const audio = createAudio({ currentTime: 0, duration: 5, paused: true }) + const sourceLines = [ + { + start: 0, + end: 5000, + tokens: [{ start: 120, end: 4120, value: 'I' }], + }, + ] + const { result } = renderTimeline({ audio, sourceLines }) + const tokenNode = registerToken( + result, + '0:single-character', + { start: 120, end: 4120 }, + 'I', + ) + const character = tokenNode.querySelector('[data-lyrics-character="true"]') + const tokenStart = 120 - KARAOKE_HIGHLIGHT_LEAD_MS + + syncNow(result, tokenStart) + expect(character.style.transform).toBe('') + syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2) + expect(character.style.transform).toMatch( + /^translate3d\(0, -\d+\.\d{4}px, 0\)$/, + ) + expect(character.style.transform).not.toBe(fullCharacterLift) + syncNow(result, tokenStart + 240) + expect(character.style.transform).not.toBe(fullCharacterLift) + syncNow(result, tokenStart + 260) + expect(character.style.transform).toBe(fullCharacterLift) + expect( + Number(tokenNode.style.getPropertyValue('--lyrics-progress')), + ).toBeCloseTo(KARAOKE_CHARACTER_RISE_MS / 4000, 5) + }) + + it('forces an unfinished character wave to its raised release state', () => { + const audio = createAudio({ currentTime: 0, duration: 2, paused: true }) + const sourceLines = [ + { + start: 0, + end: 600, + tokens: [{ start: 500, end: 550, value: 'go' }], + }, + ] + const { result } = renderTimeline({ audio, sourceLines }) + const tokenNode = registerToken( + result, + '0:release-wave', + { start: 500, end: 550 }, + 'go', + ) + const characters = tokenNode.querySelectorAll( + '[data-lyrics-character="true"]', ) - syncNow(result, 60) - expect(characters[1].style.transform).toBe( - `translateY(-${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px)`, + syncNow(result, 500 - KARAOKE_HIGHLIGHT_LEAD_MS) + expect(characters[1].style.transform).toBe('') + + syncNow(result, 600) + expect(tokenNode.dataset.lyricsState).toBe('release') + characters.forEach((character) => + expect(character.style.transform).toBe(fullCharacterLift), ) }) @@ -382,6 +453,89 @@ describe('useLyricsTimeline', () => { expect(secondProgress).toBeGreaterThanOrEqual(firstProgress) }) + it('drives character motion from playback-rate-adjusted media time', () => { + let frameCallback = null + let now = 0 + window.requestAnimationFrame.mockImplementation((callback) => { + frameCallback = callback + return 1 + }) + vi.spyOn(performance, 'now').mockImplementation(() => now) + + const audio = createAudio({ + currentTime: 0.38, + duration: 2, + paused: false, + playbackRate: 2, + }) + const sourceLines = [ + { + start: 0, + end: 1200, + tokens: [{ start: 500, end: 1000, value: 'go' }], + }, + ] + const { result } = renderTimeline({ audio, sourceLines }) + const tokenNode = registerToken( + result, + '0:playback-rate-wave', + { start: 500, end: 1000 }, + 'go', + ) + const character = tokenNode.querySelector('[data-lyrics-character="true"]') + + expect(character.style.transform).toBe('') + now = KARAOKE_CHARACTER_RISE_MS / 4 + act(() => frameCallback()) + expect(character.style.transform).toMatch( + /^translate3d\(0, -\d+\.\d{4}px, 0\)$/, + ) + expect(character.style.transform).not.toBe(fullCharacterLift) + }) + + it('reconstructs settled character motion after visibility recovery', () => { + let visibility = 'visible' + vi.spyOn(window.document, 'visibilityState', 'get').mockImplementation( + () => visibility, + ) + const audio = createAudio({ + currentTime: 0.38, + duration: 2, + paused: false, + }) + const sourceLines = [ + { + start: 0, + end: 1200, + tokens: [{ start: 500, end: 1000, value: 'go' }], + }, + ] + const { result } = renderTimeline({ audio, sourceLines }) + const tokenNode = registerToken( + result, + '0:visibility-wave', + { start: 500, end: 1000 }, + 'go', + ) + const characters = tokenNode.querySelectorAll( + '[data-lyrics-character="true"]', + ) + + visibility = 'hidden' + act(() => { + window.document.dispatchEvent(new Event('visibilitychange')) + }) + audio.currentTime = 0.8 + visibility = 'visible' + act(() => { + window.document.dispatchEvent(new Event('visibilitychange')) + }) + + characters.forEach((character) => + expect(character.style.transform).toBe(fullCharacterLift), + ) + }) + it('resets monotonic interpolation after an explicit backward seek', () => { let frameCallback = null let now = 0 From 79b920cc03d60c72b89ed39c7659890752dc9386 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 26 Jul 2026 11:14:39 +0300 Subject: [PATCH 05/16] fix(ui): improve lyrics mobile UX and code quality - Center lyrics start position on mobile via dynamic paddingTop - Fix oval panel shape on mobile by forcing borderRadius: 0 - Remove touch highlight from PR/TR toggle buttons - Separate line-by-line animation timing (260ms) from syllable (500ms) - Deduplicate sameIndexes, finiteTime, and transition string constants - Move KARAOKE_IDLE_LAYER_OPACITY to shared constants file - Remove redundant useTheme hook and inlineLine class - Extract DEFAULT_RGB constant in LyricsLineRows - Use nullish coalescing in lyricsSidebarState - Fix pointer-events kebab-case inconsistency in styles - Link KARAOKE_CHARACTER_LIFT_PX to KARAOKE_LINE_LIFT_PX Signed-off-by: ranokay --- ui/src/audioplayer/LyricsLayerControls.jsx | 158 ++++++++++++++++++ ui/src/audioplayer/LyricsLineRows.jsx | 5 +- ui/src/audioplayer/LyricsPanel.jsx | 59 ++++--- ui/src/audioplayer/LyricsPanel.test.jsx | 20 ++- ui/src/audioplayer/LyricsSidebar.jsx | 137 ++------------- ui/src/audioplayer/LyricsSidebar.test.jsx | 8 +- .../MobileKaraokeLyricsPortal.test.jsx | 69 +++++++- ui/src/audioplayer/lyricsKaraokeConstants.js | 8 +- ui/src/audioplayer/lyricsSidebarState.js | 10 +- ui/src/audioplayer/lyricsTimeline.js | 4 +- ui/src/audioplayer/styles.js | 12 +- ui/src/audioplayer/useLyricsTimeline.js | 5 +- ui/src/audioplayer/useLyricsTimeline.test.js | 24 ++- ui/src/audioplayer/usePlayerLyrics.jsx | 16 ++ 14 files changed, 336 insertions(+), 199 deletions(-) create mode 100644 ui/src/audioplayer/LyricsLayerControls.jsx diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx new file mode 100644 index 000000000..56d5e27f0 --- /dev/null +++ b/ui/src/audioplayer/LyricsLayerControls.jsx @@ -0,0 +1,158 @@ +import IconButton from '@material-ui/core/IconButton' +import Tooltip from '@material-ui/core/Tooltip' +import { alpha, makeStyles } from '@material-ui/core/styles' +import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver' +import TranslateIcon from '@material-ui/icons/Translate' +import clsx from 'clsx' +import React from 'react' + +const useStyles = makeStyles( + (theme) => ({ + controls: { + position: 'absolute', + bottom: theme.spacing(1), + zIndex: 2, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.25), + padding: theme.spacing(0.25), + borderRadius: theme.shape.borderRadius * 2, + backgroundColor: 'transparent', + }, + sidebarPlacement: { + right: theme.spacing(0.75), + }, + mobilePlacement: { + left: '50%', + transform: 'translateX(-50%)', + }, + controlButton: { + padding: theme.spacing(0.75), + color: alpha(theme.palette.text.primary, 0.58), + backgroundColor: 'transparent', + WebkitTapHighlightColor: 'transparent', + transition: + 'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)', + '@media (hover: hover) and (pointer: fine)': { + '&:hover': { + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.primary.main, 0.08), + }, + '&$controlActive:hover': { + color: theme.palette.primary.main, + }, + }, + '&:focus-visible': { + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.primary.main, 0.1), + }, + '&:active:not(:disabled)': { + transform: 'scale(0.97)', + }, + '&$controlActive': { + color: theme.palette.primary.main, + }, + '&$controlActive:focus-visible': { + color: theme.palette.primary.main, + }, + '&:disabled': { + color: alpha(theme.palette.text.primary, 0.28), + }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + '&:active:not(:disabled)': { + transform: 'none', + }, + }, + }, + controlActive: {}, + }), + { name: 'NDLyricsLayerControls' }, +) + +const LayerButton = ({ + active, + classes, + disabled, + label, + onClick, + testId, + children, +}) => { + return ( + + + + {children} + + + + ) +} + +const LyricsLayerControls = ({ + placement = 'sidebar', + showTranslation, + showPronunciation, + translationEnabled, + pronunciationEnabled, + onToggleTranslation, + onTogglePronunciation, + labels = {}, + testId = 'lyrics-layer-controls', +}) => { + const classes = useStyles() + + return ( +
    event.stopPropagation()} + > + + + + + + +
    + ) +} + +export default LyricsLayerControls diff --git a/ui/src/audioplayer/LyricsLineRows.jsx b/ui/src/audioplayer/LyricsLineRows.jsx index 77d9fa00b..964025e51 100644 --- a/ui/src/audioplayer/LyricsLineRows.jsx +++ b/ui/src/audioplayer/LyricsLineRows.jsx @@ -24,6 +24,7 @@ import { resolveKaraokeTokenWindows } from './lyricsTimeline' const EMPHASIS_TONE = 0.7 const EMPHASIS_PAINT_OVERHANG = '0.18em' +const DEFAULT_RGB = [255, 255, 255] const emphasisPaintOverhangStyle = { marginInlineEnd: `-${EMPHASIS_PAINT_OVERHANG}`, @@ -225,7 +226,7 @@ export const KaraokeLineRow = memo( [line, nextLineStart], ) const tokenRGB = useMemo( - () => (style?.color ? parseColorRGB(style.color) : [255, 255, 255]), + () => (style?.color ? parseColorRGB(style.color) : DEFAULT_RGB), [style?.color], ) const hasTimedTokens = windows.some( @@ -444,7 +445,7 @@ export const KaraokeStackedLineRow = memo( () => pronunciationStyle?.color ? parseColorRGB(pronunciationStyle.color) - : [255, 255, 255], + : DEFAULT_RGB, [pronunciationStyle?.color], ) const hasTimedTokens = mainWindows.some( diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index 8a73dda21..fa47bf519 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -15,10 +15,11 @@ import { } from './lyrics' import { KaraokeLineRow, KaraokeStackedLineRow } from './LyricsLineRows' import { - KARAOKE_ANIMATION_MS, + KARAOKE_LINE_OPACITY_MS, KARAOKE_AUX_LINE_HEIGHT, KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO, KARAOKE_EASING, + KARAOKE_IDLE_LAYER_OPACITY, KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO, KARAOKE_LINE_ENTER_MS, KARAOKE_LINE_LIFT_PX, @@ -33,10 +34,10 @@ import { getAnchoredScrollTop, getScrollEndPadding, } from './lyricsScroll' +import { finiteTime } from './lyricsTimeline' import useLyricsTimeline from './useLyricsTimeline' -const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_ANIMATION_MS}ms ${KARAOKE_EASING}` -const KARAOKE_IDLE_LAYER_OPACITY = 0.49 +const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}` const KARAOKE_TRANSLATION_IDLE_OPACITY = KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY @@ -103,6 +104,7 @@ const useStyles = makeStyles((theme) => ({ alignItems: 'stretch', gap: theme.spacing(3), }, + inlineLines: {}, lineGroup: { width: '100%', borderRadius: theme.shape.borderRadius, @@ -187,9 +189,6 @@ const useStyles = makeStyles((theme) => ({ transition: 'none', }, }, - inlineLine: { - fontSize: 24, - }, auxLine: { display: 'block', marginTop: theme.spacing(0.8), @@ -307,18 +306,12 @@ const shouldShowAuxLine = (mainLine, auxLine) => normalizeLineText(auxLine.value) !== normalizeLineText(mainLine?.value), ) -const finiteLineTime = (value) => { - if (value == null || value === '') return null - const number = Number(value) - return Number.isFinite(number) ? number : null -} - const getLayerMatchWindow = (lines, index) => { const line = lines[index] if (!line) return { start: null, end: null } return { - start: finiteLineTime(line.start), - end: finiteLineTime(line.end) ?? finiteLineTime(lines[index + 1]?.start), + start: finiteTime(line.start), + end: finiteTime(line.end) ?? finiteTime(lines[index + 1]?.start), } } @@ -454,6 +447,7 @@ const LyricsPanel = ({ const [layoutVersion, setLayoutVersion] = useState(0) const [hasTopFade, setHasTopFade] = useState(false) const [scrollEndPadding, setScrollEndPadding] = useState(0) + const [scrollStartPadding, setScrollStartPadding] = useState(0) const prefersReducedMotion = usePrefersReducedMotion() const activeLineAnchorRatio = inline ? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO @@ -617,13 +611,24 @@ const LyricsPanel = ({ const body = bodyRef.current if (!visible || !body) { setScrollEndPadding(0) + setScrollStartPadding(0) return } - const nextPadding = getScrollEndPadding(body, activeLineAnchorRatio) + const nextEndPadding = getScrollEndPadding(body, activeLineAnchorRatio) setScrollEndPadding((current) => - current === nextPadding ? current : nextPadding, + current === nextEndPadding ? current : nextEndPadding, ) - }, [activeLineAnchorRatio, layoutVersion, mainLines.length, visible]) + if (inline) { + const nextStartPadding = Math.round( + body.clientHeight * activeLineAnchorRatio, + ) + setScrollStartPadding((current) => + current === nextStartPadding ? current : nextStartPadding, + ) + } else { + setScrollStartPadding(0) + } + }, [activeLineAnchorRatio, inline, layoutVersion, mainLines.length, visible]) useLayoutEffect(() => { const body = bodyRef.current @@ -726,11 +731,16 @@ const LyricsPanel = ({ onTouchMove={markManualScrollIntent} >
    0 - ? { paddingBottom: scrollEndPadding } + scrollEndPadding > 0 || scrollStartPadding > 0 + ? { + paddingTop: scrollStartPadding || undefined, + paddingBottom: scrollEndPadding || undefined, + } : undefined } > @@ -807,7 +817,6 @@ const LyricsPanel = ({ > {lineLanes.map((lane, laneIdx) => { const laneClassName = clsx(classes.line, { - [classes.inlineLine]: inline, [classes.secondaryVoiceLane]: laneIdx > 0, }) const rowKey = lane.key || `lane-${laneIdx}` @@ -855,9 +864,7 @@ const LyricsPanel = ({ pronunciationLine={prLine} pronunciationStyle={layerStyles.pronunciation} nextLineStart={mainNextLineStart} - className={clsx(classes.line, { - [classes.inlineLine]: inline, - })} + className={classes.line} style={layerStyles.main} tokenClassName={classes.token} waveCharacterClassName={classes.waveCharacter} @@ -871,9 +878,7 @@ const LyricsPanel = ({ lineIndex={idx} line={line} nextLineStart={mainNextLineStart} - className={clsx(classes.line, { - [classes.inlineLine]: inline, - })} + className={classes.line} style={layerStyles.main} tokenClassName={classes.token} waveCharacterClassName={classes.waveCharacter} diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index 032e369e3..3a0533382 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -12,8 +12,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import LyricsPanel from './LyricsPanel' import { KaraokeLineRow } from './LyricsLineRows' import { - KARAOKE_ANIMATION_MS, + KARAOKE_LINE_OPACITY_MS, KARAOKE_CHARACTER_LIFT_PX, + KARAOKE_CHARACTER_RISE_MS, + KARAOKE_HIGHLIGHT_LEAD_MS, + KARAOKE_IDLE_LAYER_OPACITY, KARAOKE_LINE_LIFT_PX, KARAOKE_MANUAL_SCROLL_PAUSE_MS, KARAOKE_TRANSLATION_OPACITY, @@ -318,7 +321,7 @@ describe('', () => { expect(row).toHaveAttribute('data-tokenized', 'false') expect(row.style.opacity).toBe('') expect(window.getComputedStyle(row).transition).toContain( - `opacity ${KARAOKE_ANIMATION_MS}ms`, + `opacity ${KARAOKE_LINE_OPACITY_MS}ms`, ) }) pronunciation.forEach((token) => @@ -372,6 +375,8 @@ describe('', () => { }) it('keeps every word-timed layer on the same rise and release lifecycle', () => { + const settledFirstCharacterTime = + (KARAOKE_CHARACTER_RISE_MS - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000 const propsAt = (currentTime) => ({ mainLyric: tokenizedMainLyric, pronunciationLyric: tokenizedPronunciationLyric, @@ -383,7 +388,7 @@ describe('', () => { showTranslation: true, audioInstance: { currentTime, paused: true }, }) - const { rerenderPanel } = renderPanel(propsAt(0.15)) + const { rerenderPanel } = renderPanel(propsAt(settledFirstCharacterTime)) const group = screen.getByTestId('lyrics-line-group') const translation = screen @@ -790,16 +795,19 @@ describe('', () => { .getComputedStyle(group) .getPropertyValue('--lyrics-layer-opacity') .trim() - expect(groups.map(layerOpacity)).toEqual(['1', '0.49']) + expect(groups.map(layerOpacity)).toEqual([ + '1', + String(KARAOKE_IDLE_LAYER_OPACITY), + ]) expect(window.getComputedStyle(activeTranslation).opacity).toBe( String(KARAOKE_TRANSLATION_OPACITY), ) expect(Number(window.getComputedStyle(futureTranslation).opacity)).toBe( - 0.49 * KARAOKE_TRANSLATION_OPACITY, + KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY, ) ;[activeTranslation, futureTranslation].forEach((translation) => expect(window.getComputedStyle(translation).transition).toContain( - `opacity ${KARAOKE_ANIMATION_MS}ms`, + `opacity ${KARAOKE_LINE_OPACITY_MS}ms`, ), ) }) diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx index cfb398b6c..0ad0aa908 100644 --- a/ui/src/audioplayer/LyricsSidebar.jsx +++ b/ui/src/audioplayer/LyricsSidebar.jsx @@ -1,10 +1,6 @@ -import IconButton from '@material-ui/core/IconButton' -import Tooltip from '@material-ui/core/Tooltip' -import { alpha, makeStyles, useTheme } from '@material-ui/core/styles' -import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver' -import TranslateIcon from '@material-ui/icons/Translate' -import clsx from 'clsx' +import { alpha, makeStyles } from '@material-ui/core/styles' import React, { useCallback, useEffect, useRef, useState } from 'react' +import LyricsLayerControls from './LyricsLayerControls' import LyricsPanel from './LyricsPanel' import { LYRICS_SIDEBAR_MAX_WIDTH, @@ -79,57 +75,6 @@ const useStyles = makeStyles((theme) => ({ outline: 'none', }, }, - controls: { - position: 'absolute', - bottom: theme.spacing(1), - right: theme.spacing(0.75), - zIndex: 2, - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.25), - padding: theme.spacing(0.25), - borderRadius: theme.shape.borderRadius * 2, - backgroundColor: 'transparent', - }, - controlButton: { - padding: theme.spacing(0.75), - color: alpha(theme.palette.text.primary, 0.58), - backgroundColor: 'transparent', - transition: - 'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)', - '@media (hover: hover) and (pointer: fine)': { - '&:hover': { - color: theme.palette.text.primary, - backgroundColor: alpha(theme.palette.primary.main, 0.08), - }, - '&$controlActive:hover': { - color: theme.palette.primary.main, - }, - }, - '&:focus-visible': { - color: theme.palette.text.primary, - backgroundColor: alpha(theme.palette.primary.main, 0.1), - }, - '&:active:not(:disabled)': { - transform: 'scale(0.97)', - }, - '&$controlActive': { - color: theme.palette.primary.main, - }, - '&$controlActive:focus-visible': { - color: theme.palette.primary.main, - }, - '&:disabled': { - color: alpha(theme.palette.text.primary, 0.28), - }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:active:not(:disabled)': { - transform: 'none', - }, - }, - }, - controlActive: {}, panel: { flex: 1, minHeight: 0, @@ -137,41 +82,6 @@ const useStyles = makeStyles((theme) => ({ }, })) -const LayerButton = ({ - active, - classes, - disabled, - label, - onClick, - testId, - children, -}) => { - const theme = useTheme() - const activeStyle = - active && !disabled ? { color: theme.palette.primary.main } : undefined - - return ( - - - - {children} - - - - ) -} - const LyricsSidebar = ({ visible, mainLyric, @@ -362,39 +272,16 @@ const LyricsSidebar = ({ onKeyDown={handleResizeKeyDown} />
    -
    - - - - - - -
    + ', () => { expect(screen.getByLabelText('Resize song words')).toBeInTheDocument() expect(pronunciation).toHaveAttribute('aria-label', 'Conceal pronunciation') expect(pronunciation).toHaveAttribute('aria-pressed', 'true') - expect(window.getComputedStyle(pronunciation).color).toBe( - 'rgb(53, 170, 102)', - ) + expect(pronunciation.className).toContain('controlActive') fireEvent.click(pronunciation) expect(onTogglePronunciation).toHaveBeenCalledTimes(1) expect(translation).toHaveAttribute('aria-label', 'Reveal translation') expect(translation).toHaveAttribute('aria-pressed', 'false') expect(translation).toBeDisabled() - expect(window.getComputedStyle(translation).color).not.toBe( - 'rgb(53, 170, 102)', - ) + expect(translation.className).not.toContain('controlActive') fireEvent.click(translation) expect(onToggleTranslation).not.toHaveBeenCalled() }) diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx index 935d822dd..3bc0d24da 100644 --- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx @@ -18,8 +18,8 @@ import MobileKaraokeLyricsPortal, { } from './MobileKaraokeLyricsPortal' import usePlayerLyrics from './usePlayerLyrics' -const { useEnhancedLyricsMock } = vi.hoisted(() => ({ - useEnhancedLyricsMock: vi.fn(() => ({ +const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => { + const defaultLyricsResponse = { layers: { main: { synced: true, @@ -31,8 +31,12 @@ const { useEnhancedLyricsMock } = vi.hoisted(() => ({ loading: false, error: null, retry: vi.fn(), - })), -})) + } + return { + defaultLyricsResponse, + useEnhancedLyricsMock: vi.fn(() => defaultLyricsResponse), + } +}) vi.mock('./useEnhancedLyrics', () => ({ default: useEnhancedLyricsMock, @@ -77,7 +81,8 @@ const MobileLyricsHarness = ({ trackId = 'track-1', isRadio = false }) => { describe('', () => { afterEach(() => { vi.useRealTimers() - useEnhancedLyricsMock.mockClear() + useEnhancedLyricsMock.mockReset() + useEnhancedLyricsMock.mockImplementation(() => defaultLyricsResponse) cleanup() document.body.innerHTML = '' }) @@ -188,6 +193,60 @@ describe('', () => { ).not.toBeInTheDocument() }) + it('centers working pronunciation and translation toggles below mobile lyrics', () => { + useEnhancedLyricsMock.mockImplementation(() => ({ + ...defaultLyricsResponse, + layers: { + ...defaultLyricsResponse.layers, + pronunciation: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Spoken lyrics' }], + }, + translation: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Translated lyrics' }], + }, + }, + })) + const host = createHost() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' })) + + const controls = within(host).getByTestId('lyrics-mobile-layer-controls') + expect(controls).toHaveAttribute('data-placement', 'mobile') + expect(controls).toHaveStyle({ + bottom: '8px', + left: '50%', + transform: 'translateX(-50%)', + }) + + const pronunciation = within(host).getByTestId( + 'toggle-pronunciation-button', + ) + const translation = within(host).getByTestId('toggle-translation-button') + expect(pronunciation).toHaveAttribute( + 'aria-label', + 'player.hideLyricsPronunciationText', + ) + expect(pronunciation).toHaveAttribute('aria-pressed', 'true') + expect(translation).toHaveAttribute( + 'aria-label', + 'player.hideLyricsTranslationText', + ) + expect(translation).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(pronunciation) + fireEvent.click(translation) + + expect(pronunciation).toHaveAttribute('aria-pressed', 'false') + expect(translation).toHaveAttribute('aria-pressed', 'false') + expect(within(host).queryByText('Spoken lyrics')).not.toBeInTheDocument() + expect( + within(host).queryByText('Translated lyrics'), + ).not.toBeInTheDocument() + }) + it('keeps open lyrics closable after playback switches to radio', () => { vi.useFakeTimers() const host = createHost() diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js index bce62f151..629ccfa6b 100644 --- a/ui/src/audioplayer/lyricsKaraokeConstants.js +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -1,22 +1,24 @@ export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140 export const KARAOKE_HIGHLIGHT_LEAD_MS = 120 export const KARAOKE_ANIMATION_MS = 500 +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_ANIMATION_MS +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 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 = 1.5 +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_OPACITY = 0.62 -export const KARAOKE_LINE_RELEASE_MS = KARAOKE_ANIMATION_MS +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 export const KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO = 0.1 export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42 diff --git a/ui/src/audioplayer/lyricsSidebarState.js b/ui/src/audioplayer/lyricsSidebarState.js index c4fbbc6ba..15322a366 100644 --- a/ui/src/audioplayer/lyricsSidebarState.js +++ b/ui/src/audioplayer/lyricsSidebarState.js @@ -7,12 +7,10 @@ export const resolveLyricsSidebarState = ({ }) => ({ lyricsVisible: Boolean(lyricsVisiblePreference), showTranslation: Boolean( - (translationPreference == null ? true : translationPreference) && - hasTranslationLyric, + (translationPreference ?? true) && hasTranslationLyric, ), showPronunciation: Boolean( - (pronunciationPreference == null ? true : pronunciationPreference) && - hasPronunciationLyric, + (pronunciationPreference ?? true) && hasPronunciationLyric, ), }) @@ -22,7 +20,5 @@ export const toggleLayerPreference = ( defaultEnabled = true, ) => { if (!hasLayer) return false - const currentPreference = - previousPreference == null ? defaultEnabled : previousPreference - return !currentPreference + return !(previousPreference ?? defaultEnabled) } diff --git a/ui/src/audioplayer/lyricsTimeline.js b/ui/src/audioplayer/lyricsTimeline.js index c7a7157a2..325d6d494 100644 --- a/ui/src/audioplayer/lyricsTimeline.js +++ b/ui/src/audioplayer/lyricsTimeline.js @@ -1,6 +1,6 @@ import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants' -const finiteTime = (value) => { +export const finiteTime = (value) => { if (value == null || value === '') return null const number = Number(value) return Number.isFinite(number) ? number : null @@ -17,7 +17,7 @@ const upperBound = (items, time, getTime) => { return low } -const sameIndexes = (left, right) => +export const sameIndexes = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]) diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 5ceea079b..bc5283b0f 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -1,5 +1,8 @@ import { makeStyles } from '@material-ui/core/styles' +const MOBILE_LYRICS_TRANSITION = + 'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)' + const useStyle = makeStyles( (theme) => ({ audioTitle: { @@ -42,7 +45,7 @@ const useStyle = makeStyles( flexDirection: 'column', }, '& .play-mode-title': { - 'pointer-events': 'none', + pointerEvents: 'none', }, '& .music-player-panel .panel-content div.img-rotate': { // Customize desktop player when cover animation is disabled @@ -70,6 +73,7 @@ const useStyle = makeStyles( maxWidth: '100%', height: '100%', aspectRatio: 'auto', + borderRadius: 0, alignItems: 'stretch', justifyContent: 'stretch', }, @@ -79,8 +83,7 @@ const useStyle = makeStyles( inset: 0, opacity: 0, transform: 'scale(0.985)', - transition: - 'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)', + transition: MOBILE_LYRICS_TRANSITION, '@media (prefers-reduced-motion: reduce)': { transition: 'opacity 120ms linear', transform: 'none', @@ -98,8 +101,7 @@ const useStyle = makeStyles( { animationDuration: (props) => !props.enableCoverAnimation && '0s', objectFit: 'contain', // Fix cover display when image is not square - transition: - 'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)', + transition: MOBILE_LYRICS_TRANSITION, '@media (prefers-reduced-motion: reduce)': { transition: 'opacity 120ms linear', transform: 'none', diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js index 508da1069..6667426a9 100644 --- a/ui/src/audioplayer/useLyricsTimeline.js +++ b/ui/src/audioplayer/useLyricsTimeline.js @@ -20,6 +20,7 @@ import { buildLyricsTimeline, getTimelineScrollTarget, LyricTimelineCursor, + sameIndexes, tokenProgressAt, } from './lyricsTimeline' @@ -33,10 +34,6 @@ const mediaDurationMs = (audio) => { return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null } -const sameIndexes = (left, right) => - left.length === right.length && - left.every((value, index) => value === right[index]) - const setProgress = (record, value) => { const next = Math.max(0, Math.min(1, value)) if (record.progress != null && Math.abs(record.progress - next) < 0.001) diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js index 4c39a45fb..841fedbd9 100644 --- a/ui/src/audioplayer/useLyricsTimeline.test.js +++ b/ui/src/audioplayer/useLyricsTimeline.test.js @@ -6,7 +6,9 @@ import { KARAOKE_CHARACTER_RISE_MS, KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS, KARAOKE_CHARACTER_WAVE_SPAN_RATIO, + KARAOKE_CLOCK_DRIFT_RESET_MS, KARAOKE_HIGHLIGHT_LEAD_MS, + KARAOKE_LINE_RELEASE_MS, } from './lyricsKaraokeConstants' import useLyricsTimeline from './useLyricsTimeline' @@ -233,7 +235,8 @@ describe('useLyricsTimeline', () => { 'first', ) - syncNow(result, 1259) + const releaseEnd = 1000 + KARAOKE_LINE_RELEASE_MS + syncNow(result, releaseEnd - 1) const gradient = tokenNode.style.backgroundImage const releaseAlpha = Number( tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'), @@ -242,7 +245,7 @@ describe('useLyricsTimeline', () => { expect(tokenNode.style.color).toBe('transparent') expect(tokenNode.style.opacity).toBe('1') - syncNow(result, 1260) + syncNow(result, releaseEnd) const pastAlpha = Number( tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'), @@ -286,7 +289,7 @@ describe('useLyricsTimeline', () => { expect(character.style.transform).toBe(''), ) - syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2) + syncNow(result, tokenStart + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS / 2) expect(characters[0].style.transform).not.toBe(fullCharacterLift) expect(characters[0].style.transform).not.toBe('') expect(characters[4].style.transform).toBe('') @@ -376,9 +379,9 @@ describe('useLyricsTimeline', () => { /^translate3d\(0, -\d+\.\d{4}px, 0\)$/, ) expect(character.style.transform).not.toBe(fullCharacterLift) - syncNow(result, tokenStart + 240) + syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS - 20) expect(character.style.transform).not.toBe(fullCharacterLift) - syncNow(result, tokenStart + 260) + syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS) expect(character.style.transform).toBe(fullCharacterLift) expect( Number(tokenNode.style.getPropertyValue('--lyrics-progress')), @@ -485,7 +488,7 @@ describe('useLyricsTimeline', () => { const character = tokenNode.querySelector('[data-lyrics-character="true"]') expect(character.style.transform).toBe('') - now = KARAOKE_CHARACTER_RISE_MS / 4 + now = KARAOKE_CLOCK_DRIFT_RESET_MS / 4 act(() => frameCallback()) expect(character.style.transform).toMatch( /^translate3d\(0, -\d+\.\d{4}px, 0\)$/, @@ -525,7 +528,14 @@ describe('useLyricsTimeline', () => { act(() => { window.document.dispatchEvent(new Event('visibilitychange')) }) - audio.currentTime = 0.8 + const characterMotionEnd = + 500 + + Math.min( + 500 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO, + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS, + ) + + KARAOKE_CHARACTER_RISE_MS + audio.currentTime = (characterMotionEnd - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000 visibility = 'visible' act(() => { window.document.dispatchEvent(new Event('visibilitychange')) diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx index ad9971e27..4cf6630dd 100644 --- a/ui/src/audioplayer/usePlayerLyrics.jsx +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' +import LyricsLayerControls from './LyricsLayerControls' import LyricsPanel from './LyricsPanel' import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal' import { hasStructuredLyricContent } from './lyrics' @@ -157,10 +158,23 @@ const usePlayerLyrics = ({ labels={labels} inline /> + ), [ audioInstance, + hasPronunciationLyric, + hasTranslationLyric, lyricLayers.main, lyricLayers.pronunciation, lyricLayers.translation, @@ -169,6 +183,8 @@ const usePlayerLyrics = ({ labels, showPronunciation, showTranslation, + togglePronunciation, + toggleTranslation, useInlineMobileLyrics, ], ) From e06fcbbaebbac169794f700324d3dca83a754f98 Mon Sep 17 00:00:00 2001 From: Yuuta <61791392+ranokay@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:22:19 +0300 Subject: [PATCH 06/16] fix(ui): harden responsive lyrics layout and accessibility Signed-off-by: ranokay --- ui/src/audioplayer/AudioTitle.jsx | 3 +- ui/src/audioplayer/LyricsPanel.jsx | 24 +-- ui/src/audioplayer/LyricsPanel.test.jsx | 179 +++++++++++------- ui/src/audioplayer/LyricsSidebar.jsx | 17 +- ui/src/audioplayer/LyricsSidebar.test.jsx | 33 +++- .../audioplayer/MobileKaraokeLyricsPortal.jsx | 15 +- .../MobileKaraokeLyricsPortal.test.jsx | 38 +++- ui/src/audioplayer/Player.jsx | 7 +- ui/src/audioplayer/PlayerToolbar.jsx | 5 +- ui/src/audioplayer/PlayerToolbar.test.jsx | 3 + ui/src/audioplayer/lyricsKaraokeConstants.js | 3 +- ui/src/audioplayer/playerBreakpoints.js | 17 ++ ui/src/audioplayer/playerBreakpoints.test.js | 43 +++++ ui/src/audioplayer/styles.js | 3 +- ui/src/audioplayer/usePlayerLyrics.jsx | 10 +- ui/src/audioplayer/useRestoreFocusOnExit.js | 38 ++++ ui/src/layout/Menu.jsx | 8 +- ui/src/layout/layoutWidths.js | 2 + ui/src/themes/nutball.css.js | 4 +- 19 files changed, 340 insertions(+), 112 deletions(-) create mode 100644 ui/src/audioplayer/playerBreakpoints.js create mode 100644 ui/src/audioplayer/playerBreakpoints.test.js create mode 100644 ui/src/audioplayer/useRestoreFocusOnExit.js create mode 100644 ui/src/layout/layoutWidths.js diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index df37edfbb..340b8d730 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -7,11 +7,12 @@ import { decisionService } from '../transcode' import useStyle from './styles' import { useDrag } from 'react-dnd' import { DraggableTypes } from '../consts' +import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints' const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { const classes = useStyle() const className = classes.audioTitle - const isDesktop = useMediaQuery('(min-width:810px)') + const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY) const song = audioInfo.song const [, dragSongRef] = useDrag( diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index fa47bf519..a998b4c5d 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -26,7 +26,6 @@ import { KARAOKE_LINE_MOTION_EASING, KARAOKE_MANUAL_SCROLL_PAUSE_MS, KARAOKE_SCROLLBAR_VISIBLE_MS, - KARAOKE_TRANSLATION_OPACITY, } from './lyricsKaraokeConstants' import { animateScrollTop, @@ -38,8 +37,7 @@ import { finiteTime } from './lyricsTimeline' import useLyricsTimeline from './useLyricsTimeline' const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}` -const KARAOKE_TRANSLATION_IDLE_OPACITY = - KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY +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: { @@ -135,7 +133,7 @@ const useStyles = makeStyles((theme) => ({ }, '&[data-raised="true"][data-line-motion="character"] $translationLine': { transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, - transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`, + transition: `${KARAOKE_AUX_LINE_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`, }, '&[data-active="true"]': { '--lyrics-main-current-color': @@ -198,18 +196,16 @@ const useStyles = makeStyles((theme) => ({ overflowWrap: 'anywhere', whiteSpace: 'pre-wrap', letterSpacing: 0, - opacity: KARAOKE_TRANSLATION_IDLE_OPACITY, - color: 'var(--lyrics-translation-active-color, currentColor)', - WebkitTextFillColor: 'var(--lyrics-translation-active-color, currentColor)', - transition: KARAOKE_LAYER_OPACITY_TRANSITION, + opacity: 1, + color: 'var(--lyrics-translation-current-color, currentColor)', + WebkitTextFillColor: + 'var(--lyrics-translation-current-color, currentColor)', + transition: KARAOKE_AUX_LINE_TRANSITION, '@media (prefers-reduced-motion: reduce)': { transition: 'none', transform: 'none', }, }, - activeAuxLine: { - opacity: KARAOKE_TRANSLATION_OPACITY, - }, stackedToken: { display: 'inline-flex', flexDirection: 'column', @@ -512,7 +508,7 @@ const LyricsPanel = ({ return { opacity: 1, color: sourceColor, - '--lyrics-active-color': sourceColor, + '--lyrics-active-color': colors.main, } } if (!hasTimedMainLines) { @@ -892,9 +888,7 @@ const LyricsPanel = ({ lineIndex={idx} line={buildSynchronizedTranslationLine(line, trLine)} nextLineStart={null} - className={clsx(classes.auxLine, classes.translationLine, { - [classes.activeAuxLine]: isStaticLine || isActiveLine, - })} + className={clsx(classes.auxLine, classes.translationLine)} style={layerStyles.translation} tokenClassName={classes.token} rowKey="translation" diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index 3a0533382..0eb18386a 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -16,10 +16,9 @@ import { KARAOKE_CHARACTER_LIFT_PX, KARAOKE_CHARACTER_RISE_MS, KARAOKE_HIGHLIGHT_LEAD_MS, - KARAOKE_IDLE_LAYER_OPACITY, KARAOKE_LINE_LIFT_PX, KARAOKE_MANUAL_SCROLL_PAUSE_MS, - KARAOKE_TRANSLATION_OPACITY, + TOKEN_FUTURE_ALPHA, } from './lyricsKaraokeConstants' const theme = createTheme({ @@ -44,6 +43,62 @@ const renderPanel = (props, selectedTheme = theme) => { } } +const parseCssColor = (value) => { + const hex = String(value).match(/^#([0-9a-f]{6})$/i) + if (hex) { + return { + channels: [ + parseInt(hex[1].slice(0, 2), 16), + parseInt(hex[1].slice(2, 4), 16), + parseInt(hex[1].slice(4, 6), 16), + ], + alpha: 1, + } + } + + const rgb = String(value).match( + /^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)$/i, + ) + if (!rgb) throw new Error(`Unsupported CSS color: ${value}`) + return { + channels: rgb.slice(1, 4).map(Number), + alpha: rgb[4] == null ? 1 : Number(rgb[4]), + } +} + +const compositeColor = (foreground, background) => { + const fg = parseCssColor(foreground) + const bg = parseCssColor(background) + return fg.channels.map( + (channel, index) => + channel * fg.alpha + bg.channels[index] * (1 - fg.alpha), + ) +} + +const relativeLuminance = (channels) => + channels + .map((channel) => channel / 255) + .map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ) + .reduce( + (total, channel, index) => + total + channel * [0.2126, 0.7152, 0.0722][index], + 0, + ) + +const contrastRatio = (foreground, background) => { + const foregroundLuminance = relativeLuminance( + compositeColor(foreground, background), + ) + const backgroundLuminance = relativeLuminance( + parseCssColor(background).channels, + ) + const lighter = Math.max(foregroundLuminance, backgroundLuminance) + const darker = Math.min(foregroundLuminance, backgroundLuminance) + return (lighter + 0.05) / (darker + 0.05) +} + const mainLyric = { synced: true, line: [{ start: 0, end: 1000, value: 'Main line' }], @@ -292,7 +347,7 @@ describe('', () => { expect(pronunciation.style.color).toBe('') }) - it('uses one shared opacity animation for every static line layer', () => { + it('uses one shared state transition for every static line layer', () => { renderPanel({ mainLyric, pronunciationLyric: { @@ -320,16 +375,17 @@ describe('', () => { expect(row).toHaveAttribute('data-layer-animation', 'shared-opacity') expect(row).toHaveAttribute('data-tokenized', 'false') expect(row.style.opacity).toBe('') - expect(window.getComputedStyle(row).transition).toContain( - `opacity ${KARAOKE_LINE_OPACITY_MS}ms`, - ) }) + expect(window.getComputedStyle(mainRow).transition).toContain( + `opacity ${KARAOKE_LINE_OPACITY_MS}ms`, + ) + expect(window.getComputedStyle(translationRow).transition).toContain( + `color ${KARAOKE_LINE_OPACITY_MS}ms`, + ) pronunciation.forEach((token) => expect(token).toHaveAttribute('data-timed', 'false'), ) - expect(window.getComputedStyle(translationRow).opacity).toBe( - String(KARAOKE_TRANSLATION_OPACITY), - ) + expect(window.getComputedStyle(translationRow).opacity).toBe('1') }) it('raises a line once and keeps it elevated after release', () => { @@ -747,68 +803,51 @@ describe('', () => { ) }) - it('dims translations relative to their semantic theme color', () => { - const sameTextTheme = createTheme({ - palette: { - primary: { main: '#ffffff' }, - text: { primary: '#ffffff', secondary: '#ffffff' }, - }, - }) - renderPanel( - { - mainLyric: { - synced: true, - line: [ - { start: 0, end: 1000, value: 'Active' }, - { start: 1000, end: 2000, value: 'Future' }, - ], - }, - translationLyric: { - synced: true, - line: [ - { start: 0, end: 1000, value: 'Activa' }, - { start: 1000, end: 2000, value: 'Futura' }, - ], - }, - showTranslation: true, - audioInstance: { currentTime: 0.5, paused: true }, - }, - sameTextTheme, - ) + it.each(['light', 'dark'])( + 'keeps future tokens and translations readable in the %s theme', + (type) => { + const accessibleTheme = createTheme({ palette: { type } }) + const background = accessibleTheme.palette.background.default - const groups = screen.getAllByTestId('lyrics-line-group') - const activeTranslation = screen - .getByText('Activa') - .closest('[data-tokenized]') - const futureTranslation = screen - .getByText('Futura') - .closest('[data-tokenized]') - groups.forEach((group) => { + renderPanel( + { + mainLyric: tokenizedMainLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Readable translation' }], + }, + showTranslation: true, + audioInstance: { currentTime: 0.25, paused: true }, + }, + accessibleTheme, + ) + + const group = screen.getByTestId('lyrics-line-group') + const translation = screen + .getByText('Readable translation') + .closest('[data-tokenized]') + const token = screen.getAllByTestId('lyrics-token')[0] + const gradientNode = [token, ...token.querySelectorAll('*')].find( + (node) => node.style.backgroundImage.includes('linear-gradient'), + ) + const gradientColors = Array.from( + gradientNode.style.backgroundImage.matchAll(/rgba?\([^)]+\)/g), + (match) => match[0], + ) + const futureColor = gradientColors.at(-1) + + expect( + group.style.getPropertyValue('--lyrics-translation-idle-color'), + ).toBe(accessibleTheme.palette.text.secondary) expect( group.style.getPropertyValue('--lyrics-translation-active-color'), - ).toBe('#ffffff') - }) - expect(groups[0]).toHaveAttribute('data-active', 'true') - expect(groups[1]).toHaveAttribute('data-active', 'false') - const layerOpacity = (group) => - window - .getComputedStyle(group) - .getPropertyValue('--lyrics-layer-opacity') - .trim() - expect(groups.map(layerOpacity)).toEqual([ - '1', - String(KARAOKE_IDLE_LAYER_OPACITY), - ]) - expect(window.getComputedStyle(activeTranslation).opacity).toBe( - String(KARAOKE_TRANSLATION_OPACITY), - ) - expect(Number(window.getComputedStyle(futureTranslation).opacity)).toBe( - KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY, - ) - ;[activeTranslation, futureTranslation].forEach((translation) => - expect(window.getComputedStyle(translation).transition).toContain( - `opacity ${KARAOKE_LINE_OPACITY_MS}ms`, - ), - ) - }) + ).toBe(accessibleTheme.palette.text.primary) + 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) + }, + ) }) diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx index 0ad0aa908..04f42512e 100644 --- a/ui/src/audioplayer/LyricsSidebar.jsx +++ b/ui/src/audioplayer/LyricsSidebar.jsx @@ -13,6 +13,7 @@ import { saveSidebarWidth, } from './lyricsSidebarWidth' import useEnterExitTransition from './useEnterExitTransition' +import useRestoreFocusOnExit from './useRestoreFocusOnExit' const useStyles = makeStyles((theme) => ({ sidebar: { @@ -97,6 +98,7 @@ const LyricsSidebar = ({ loading = false, error = null, labels = {}, + returnFocusRef, }) => { const [width, setWidth] = useState(loadSidebarWidth) const sidebarRef = useRef(null) @@ -110,6 +112,12 @@ const LyricsSidebar = ({ const [isResizing, setIsResizing] = useState(false) const classes = useStyles() + useRestoreFocusOnExit({ + surfaceRef: sidebarRef, + entered, + returnFocusRef, + }) + useEffect(() => { widthRef.current = width }, [width]) @@ -122,14 +130,6 @@ const LyricsSidebar = ({ [], ) - useEffect(() => { - if (entered || typeof document === 'undefined') return - const activeElement = document.activeElement - if (activeElement && sidebarRef.current?.contains(activeElement)) { - activeElement.blur() - } - }, [entered]) - const applyWidth = useCallback((next) => { const resolvedWidth = clampSidebarWidth( typeof next === 'function' ? next(widthRef.current) : next, @@ -255,6 +255,7 @@ const LyricsSidebar = ({ }} aria-label={labels.title || 'Lyrics'} aria-hidden={!entered} + inert={entered ? undefined : ''} data-resizing={isResizing ? 'true' : 'false'} > + , + ) + const layerControl = within(host).getByRole('button', { + name: 'Layer control', + }) + layerControl.focus() + + rerender( + + + , + ) + + const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`) + expect(layer).toHaveAttribute('aria-hidden', 'true') + expect(layer).toHaveAttribute('inert') + expect(document.activeElement).toBe(returnTarget) + }) + it('attaches when the mobile cover host appears after activation', async () => { vi.useFakeTimers() diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 1b21f9592..5aa9fdaf3 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -36,6 +36,10 @@ import { calculateGain } from '../utils/calculateReplayGain' import { detectBrowserProfile, decisionService } from '../transcode' import usePlayerLyrics from './usePlayerLyrics' import { useLyricsLayout } from './LyricsLayoutContext' +import { + PLAYER_DESKTOP_MEDIA_QUERY, + PLAYER_MOBILE_MATCH_MEDIA_QUERY, +} from './playerBreakpoints' const Player = () => { const theme = useCurrentTheme() @@ -50,7 +54,7 @@ const Player = () => { const currentTrackIdRef = useRef(null) const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) - const isDesktop = useMediaQuery('(min-width:810px)') + const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY) const isMobilePlayer = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent, @@ -247,6 +251,7 @@ const Player = () => { glassBg: false, showThemeSwitch: false, showMediaSession: true, + mobileMediaQuery: PLAYER_MOBILE_MATCH_MEDIA_QUERY, restartCurrentOnPrev: true, quietUpdate: true, defaultPosition: { diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx index 494c5bf98..e146bc238 100644 --- a/ui/src/audioplayer/PlayerToolbar.jsx +++ b/ui/src/audioplayer/PlayerToolbar.jsx @@ -11,6 +11,7 @@ import { LoveButton, useToggleLove } from '../common' import { openSaveQueueDialog } from '../actions' import { keyMap } from '../hotkeys' import { makeStyles } from '@material-ui/core/styles' +import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -66,11 +67,12 @@ const PlayerToolbar = ({ lyricsLoading = false, lyricsLabel = 'Toggle lyrics', lyricsLoadingLabel = 'Loading lyrics', + lyricsToggleRef, }) => { const dispatch = useDispatch() const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio }) const [toggleLove, toggling] = useToggleLove('song', data) - const isDesktop = useMediaQuery('(min-width:810px)') + const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY) const classes = useStyles() const handlers = { @@ -116,6 +118,7 @@ const PlayerToolbar = ({ ', () => { it('toggles lyrics from an accessible toolbar button', () => { const onToggleLyrics = vi.fn() + const lyricsToggleRef = React.createRef() render( , ) @@ -121,6 +123,7 @@ describe('', () => { ) expect(lyricsButton).toHaveClass('MuiIconButton-colorPrimary') expect(lyricsButton).toHaveAttribute('aria-pressed', 'true') + expect(lyricsToggleRef.current).toBe(lyricsButton) fireEvent.click(lyricsButton) diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js index 629ccfa6b..bb3976641 100644 --- a/ui/src/audioplayer/lyricsKaraokeConstants.js +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -16,7 +16,6 @@ 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_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 @@ -25,7 +24,7 @@ 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 TOKEN_FUTURE_ALPHA = 0.34 +export const TOKEN_FUTURE_ALPHA = 0.56 export const TOKEN_ACTIVE_ALPHA = 1 export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34 export const TOKEN_WIPE_EDGE_PCT = 12 diff --git a/ui/src/audioplayer/playerBreakpoints.js b/ui/src/audioplayer/playerBreakpoints.js new file mode 100644 index 000000000..16dde7777 --- /dev/null +++ b/ui/src/audioplayer/playerBreakpoints.js @@ -0,0 +1,17 @@ +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_MEDIA_QUERY_STEP = 0.05 +export const PLAYER_MOBILE_MAX_WIDTH = + PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MEDIA_QUERY_STEP +export const LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH = PLAYER_DESKTOP_MIN_WIDTH + +export const PLAYER_DESKTOP_MEDIA_QUERY = `(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)` +export const PLAYER_MOBILE_MATCH_MEDIA_QUERY = `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)` +export const PLAYER_MOBILE_MEDIA_QUERY = `@media screen and ${PLAYER_MOBILE_MATCH_MEDIA_QUERY}` +export const LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY = PLAYER_DESKTOP_MEDIA_QUERY diff --git a/ui/src/audioplayer/playerBreakpoints.test.js b/ui/src/audioplayer/playerBreakpoints.test.js new file mode 100644 index 000000000..c603665ad --- /dev/null +++ b/ui/src/audioplayer/playerBreakpoints.test.js @@ -0,0 +1,43 @@ +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, + PLAYER_MOBILE_MAX_WIDTH, + 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', () => { + expect(PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MOBILE_MAX_WIDTH).toBeCloseTo( + PLAYER_MEDIA_QUERY_STEP, + ) + expect(PLAYER_DESKTOP_MEDIA_QUERY).toBe( + `(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)`, + ) + expect(PLAYER_MOBILE_MEDIA_QUERY).toBe( + `@media screen and (max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`, + ) + expect(PLAYER_MOBILE_MATCH_MEDIA_QUERY).toBe( + `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`, + ) + }) + + it('only opens the desktop lyrics sidebar when primary content still fits', () => { + 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) + }) +}) diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index bc5283b0f..84c5e1483 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -1,4 +1,5 @@ import { makeStyles } from '@material-ui/core/styles' +import { PLAYER_MOBILE_MEDIA_QUERY } from './playerBreakpoints' const MOBILE_LYRICS_TRANSITION = 'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)' @@ -30,7 +31,7 @@ const useStyle = makeStyles( }, player: { display: (props) => (props.visible ? 'block' : 'none'), - '@media screen and (max-width:810px)': { + [PLAYER_MOBILE_MEDIA_QUERY]: { '& .sound-operation': { display: 'none', }, diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx index 4cf6630dd..429d6ae1f 100644 --- a/ui/src/audioplayer/usePlayerLyrics.jsx +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import LyricsLayerControls from './LyricsLayerControls' import LyricsPanel from './LyricsPanel' import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal' @@ -21,6 +21,7 @@ const usePlayerLyrics = ({ const [lyricsRequested, setLyricsRequested] = useState(false) const [translationPreference, setTranslationPreference] = useState(null) const [pronunciationPreference, setPronunciationPreference] = useState(null) + const lyricsToggleRef = useRef(null) useEffect(() => { setLyricsRequested(lyricsVisiblePreference) @@ -103,6 +104,7 @@ const usePlayerLyrics = ({ lyricsLoading, lyricsLabel: labels.toggle, lyricsLoadingLabel: labels.loading, + lyricsToggleRef, }), [labels, lyricsLoading, lyricsToggleDisabled, lyricsVisible, toggleLyrics], ) @@ -123,6 +125,7 @@ const usePlayerLyrics = ({ loading: lyricsLoading, error: lyricsError, labels, + returnFocusRef: lyricsToggleRef, }), [ audioInstance, @@ -145,7 +148,10 @@ const usePlayerLyrics = ({ const mobileLyricsSurface = useMemo( () => ( - + { + if (!surface || typeof document === 'undefined') return + + const activeElement = document.activeElement + if (!activeElement || !surface.contains(activeElement)) return + + const returnTarget = returnFocusRef?.current + const canReturnFocus = + returnTarget?.isConnected && + !returnTarget.disabled && + returnTarget.getAttribute?.('aria-disabled') !== 'true' && + typeof returnTarget.focus === 'function' + + if (canReturnFocus) { + returnTarget.focus() + return + } + + activeElement.blur?.() +} + +const useRestoreFocusOnExit = ({ + surfaceRef, + entered, + returnFocusRef, + surfaceKey, +}) => { + useLayoutEffect(() => { + const surface = surfaceRef.current + if (!entered) restoreFocusFromSurface(surface, returnFocusRef) + + return () => restoreFocusFromSurface(surface, returnFocusRef) + }, [entered, returnFocusRef, surfaceKey, surfaceRef]) +} + +export default useRestoreFocusOnExit diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx index 45f40b26d..899c2e869 100644 --- a/ui/src/layout/Menu.jsx +++ b/ui/src/layout/Menu.jsx @@ -11,6 +11,10 @@ import albumLists from '../album/albumLists' import PlaylistsSubMenu from './PlaylistsSubMenu' import LibrarySelector from '../common/LibrarySelector' import config from '../config' +import { + APP_SIDEBAR_CLOSED_WIDTH, + APP_SIDEBAR_OPEN_WIDTH, +} from './layoutWidths' const useStyles = makeStyles((theme) => ({ root: { @@ -23,10 +27,10 @@ const useStyles = makeStyles((theme) => ({ paddingBottom: (props) => (props.addPadding ? '80px' : '20px'), }, open: { - width: 240, + width: APP_SIDEBAR_OPEN_WIDTH, }, closed: { - width: 55, + width: APP_SIDEBAR_CLOSED_WIDTH, }, active: { color: theme.palette.text.primary, diff --git a/ui/src/layout/layoutWidths.js b/ui/src/layout/layoutWidths.js new file mode 100644 index 000000000..4587891f6 --- /dev/null +++ b/ui/src/layout/layoutWidths.js @@ -0,0 +1,2 @@ +export const APP_SIDEBAR_OPEN_WIDTH = 240 +export const APP_SIDEBAR_CLOSED_WIDTH = 55 diff --git a/ui/src/themes/nutball.css.js b/ui/src/themes/nutball.css.js index aaf1c0e16..a1428461e 100644 --- a/ui/src/themes/nutball.css.js +++ b/ui/src/themes/nutball.css.js @@ -1,3 +1,5 @@ +import { PLAYER_MOBILE_MAX_WIDTH } from '../audioplayer/playerBreakpoints' + const stylesheet = ` html { scrollbar-width: none; @@ -136,7 +138,7 @@ button[data-testid="save-queue-button"] { stroke: #fff; height: .8em; } -@media screen and (max-width: 810px) { +@media screen and (max-width: ${PLAYER_MOBILE_MAX_WIDTH}px) { .react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon { margin-left: 5px; margin-right: 0; 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 07/16] 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) }) }) From ff1dcc9773df4b436dee78c9e2dfccd7bd3da67c Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 29 Jul 2026 12:39:50 +0300 Subject: [PATCH 08/16] fix(ui): refine karaoke highlighting Signed-off-by: ranokay --- ui/src/audioplayer/LyricsPanel.jsx | 18 +++--- ui/src/audioplayer/LyricsPanel.test.jsx | 67 ++++++++++++++------ ui/src/audioplayer/lyricsKaraokeConstants.js | 50 +++++++++------ ui/src/audioplayer/useLyricsTimeline.js | 4 +- ui/src/audioplayer/useLyricsTimeline.test.js | 26 ++++++++ 5 files changed, 114 insertions(+), 51 deletions(-) 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 = [ From d562462a3b2fb60a19da4e7d56daa805d13fb4ce Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 29 Jul 2026 14:16:32 +0300 Subject: [PATCH 09/16] fix(ui): harden responsive lyrics interactions Signed-off-by: ranokay --- ui/src/audioplayer/LyricsPanel.jsx | 94 ++++-- ui/src/audioplayer/LyricsPanel.test.jsx | 157 ++++++++-- ui/src/audioplayer/LyricsSidebar.jsx | 88 +++--- ui/src/audioplayer/LyricsSidebar.test.jsx | 130 ++++++-- .../audioplayer/MobileKaraokeLyricsPortal.jsx | 18 +- .../MobileKaraokeLyricsPortal.test.jsx | 50 ++- ui/src/audioplayer/Player.jsx | 3 + ui/src/audioplayer/lyricsKaraokeConstants.js | 2 +- ui/src/audioplayer/lyricsSidebarWidth.js | 38 ++- ui/src/audioplayer/styles.js | 7 +- ui/src/audioplayer/usePlayerLyrics.jsx | 7 + ui/src/layout/LyricsLayoutFrame.jsx | 140 ++++++++- ui/src/layout/LyricsLayoutFrame.test.jsx | 284 +++++++++++++++++- 13 files changed, 838 insertions(+), 180 deletions(-) diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index 41483f6a2..d8f04dd1d 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -25,7 +25,6 @@ import { KARAOKE_LINE_MOTION_EASING, KARAOKE_MANUAL_SCROLL_PAUSE_MS, KARAOKE_SCROLLBAR_VISIBLE_MS, - KARAOKE_TRANSLATION_IDLE_OPACITY, } from './lyricsKaraokeConstants' import { animateScrollTop, @@ -112,7 +111,6 @@ 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': { @@ -136,7 +134,6 @@ 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', @@ -182,17 +179,17 @@ const useStyles = makeStyles((theme) => ({ }, auxLine: { display: 'block', + opacity: 1, fontWeight: 600, fontSize: 15, lineHeight: KARAOKE_AUX_LINE_HEIGHT, overflowWrap: 'anywhere', whiteSpace: 'pre-wrap', letterSpacing: 0, - opacity: 'var(--lyrics-translation-opacity)', color: 'var(--lyrics-translation-current-color, currentColor)', WebkitTextFillColor: 'var(--lyrics-translation-current-color, currentColor)', - transition: KARAOKE_LAYER_OPACITY_TRANSITION, + transition: 'none', '@media (prefers-reduced-motion: reduce)': { transition: 'none', transform: 'none', @@ -412,6 +409,7 @@ const usePrefersReducedMotion = () => { const LyricsPanel = ({ visible = true, + obscured = false, mainLyric, translationLyric, pronunciationLyric, @@ -436,7 +434,9 @@ const LyricsPanel = ({ const [hasTopFade, setHasTopFade] = useState(false) const [scrollEndPadding, setScrollEndPadding] = useState(0) const [scrollStartPadding, setScrollStartPadding] = useState(0) + const [focusedLineIndex, setFocusedLineIndex] = useState(null) const prefersReducedMotion = usePrefersReducedMotion() + const timelineVisible = visible && !obscured const activeLineAnchorRatio = inline ? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO : KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO @@ -466,10 +466,26 @@ const LyricsPanel = ({ } = useLyricsTimeline({ lines: mainLines, audioInstance, - visible: visible && hasTimedMainLines, + visible: timelineVisible && hasTimedMainLines, reducedMotion: prefersReducedMotion, }) const activeIndexSet = useMemo(() => new Set(activeIndexes), [activeIndexes]) + const seekableLineIndexes = useMemo(() => { + if (!audioInstance) return [] + return mainLines.reduce((indexes, line, index) => { + if (line.renderable !== false && line.start != null) indexes.push(index) + return indexes + }, []) + }, [audioInstance, mainLines]) + const tabbableLineIndex = seekableLineIndexes.includes(focusedLineIndex) + ? focusedLineIndex + : seekableLineIndexes.includes(activeIndex) + ? activeIndex + : seekableLineIndexes[0] + + useEffect(() => { + setFocusedLineIndex(null) + }, [mainLyric]) const trByMainIndex = useMemo(() => { if (!showTranslation || translationLines.length === 0) return {} @@ -630,7 +646,7 @@ const LyricsPanel = ({ }, [clearManualScrollTimer, mainLyric, visible]) useEffect(() => { - if (!visible || !hasTimedMainLines || scrollTargetIndex < 0) { + if (!timelineVisible || !hasTimedMainLines || scrollTargetIndex < 0) { cancelScrollAnimation(scrollAnimationRef) return undefined } @@ -661,7 +677,7 @@ const LyricsPanel = ({ layoutVersion, prefersReducedMotion, scrollTargetIndex, - visible, + timelineVisible, ]) if (!visible) return null @@ -697,6 +713,38 @@ const LyricsPanel = ({ resumeAutoScroll() } + const focusSeekableLine = (index) => { + setFocusedLineIndex(index) + getLineNode(index)?.focus() + } + + const handleLineKeyDown = (event, index, line) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + seekToLine(line) + return + } + + const currentPosition = seekableLineIndexes.indexOf(index) + if (currentPosition < 0) return + let nextPosition = null + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + nextPosition = Math.min( + currentPosition + 1, + seekableLineIndexes.length - 1, + ) + } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + nextPosition = Math.max(currentPosition - 1, 0) + } else if (event.key === 'Home') { + nextPosition = 0 + } else if (event.key === 'End') { + nextPosition = seekableLineIndexes.length - 1 + } + if (nextPosition == null) return + event.preventDefault() + focusSeekableLine(seekableLineIndexes[nextPosition]) + } + return (
    seekToLine(line)} + onFocus={ + canSeekLine ? () => setFocusedLineIndex(idx) : undefined + } + onBlur={ + canSeekLine + ? (event) => { + if (!bodyRef.current?.contains(event.relatedTarget)) { + setFocusedLineIndex(null) + } + } + : undefined + } onMouseDown={ canSeekLine ? (event) => { @@ -789,15 +851,11 @@ const LyricsPanel = ({ } : undefined } - onKeyDown={(event) => { - if ( - canSeekLine && - (event.key === 'Enter' || event.key === ' ') - ) { - event.preventDefault() - seekToLine(line) - } - }} + onKeyDown={ + canSeekLine + ? (event) => handleLineKeyDown(event, idx, line) + : undefined + } > {lineLanes.length > 1 ? (
    ', () => { expect(pronunciation.style.color).toBe('') }) - it('dims inactive translations without moving them', () => { + it('keeps translations subordinate and stable between line states', () => { const propsAt = (currentTime) => ({ mainLyric, pronunciationLyric: { @@ -388,17 +387,17 @@ describe('', () => { expect(window.getComputedStyle(mainRow).transition).toContain( `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') + lineGroup.style.getPropertyValue('--lyrics-translation-idle-color'), + ).toBe(theme.palette.text.secondary) + expect( + lineGroup.style.getPropertyValue('--lyrics-translation-active-color'), + ).toBe(theme.palette.text.secondary) expect(window.getComputedStyle(translationRow).transform).toBe('') expect(window.getComputedStyle(translationRow).marginTop).toBe( `${theme.spacing(0.6)}px`, @@ -406,12 +405,8 @@ describe('', () => { 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).opacity).toBe('1') + expect(window.getComputedStyle(translationRow).transition).toBe('none') expect(window.getComputedStyle(translationRow).transform).toBe('') }) @@ -766,6 +761,97 @@ describe('', () => { expect(audioInstance.currentTime).toBe(2.3) }) + it('uses roving focus for seekable lines without seeking on navigation', () => { + const audioInstance = createAudio({ currentTime: 1.5 }) + renderPanel({ + mainLyric: { + synced: true, + line: [ + { start: 0, end: 900, value: 'First seekable line' }, + { value: 'Untimed line' }, + { start: 1000, end: 1900, value: 'Active seekable line' }, + { start: 2000, end: 2900, value: 'Last seekable line' }, + ], + }, + audioInstance, + }) + + const first = screen + .getByText('First seekable line') + .closest('[data-testid="lyrics-line-group"]') + const untimed = screen + .getByText('Untimed line') + .closest('[data-testid="lyrics-line-group"]') + const active = screen + .getByText('Active seekable line') + .closest('[data-testid="lyrics-line-group"]') + const last = screen + .getByText('Last seekable line') + .closest('[data-testid="lyrics-line-group"]') + + expect(first).toHaveAttribute('tabindex', '-1') + expect(untimed).not.toHaveAttribute('tabindex') + expect(active).toHaveAttribute('tabindex', '0') + expect(last).toHaveAttribute('tabindex', '-1') + + active.focus() + fireEvent.keyDown(active, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(last) + expect(last).toHaveAttribute('tabindex', '0') + expect(audioInstance.currentTime).toBe(1.5) + + fireEvent.keyDown(last, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(last) + fireEvent.keyDown(last, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(active) + fireEvent.keyDown(active, { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(first) + expect(audioInstance.currentTime).toBe(1.5) + + fireEvent.keyDown(first, { key: 'End' }) + expect(document.activeElement).toBe(last) + fireEvent.keyDown(last, { key: 'Home' }) + expect(document.activeElement).toBe(first) + expect(audioInstance.currentTime).toBe(1.5) + + fireEvent.keyDown(first, { key: ' ' }) + expect(audioInstance.currentTime).toBe(0) + fireEvent.keyDown(last, { key: 'Enter' }) + expect(audioInstance.currentTime).toBe(2) + }) + + it('keeps lyrics mounted but pauses timeline frames while obscured', () => { + const audioInstance = createAudio({ currentTime: 0.25 }) + audioInstance.paused = false + window.requestAnimationFrame.mockImplementation(() => 7) + window.requestAnimationFrame.mockClear() + window.cancelAnimationFrame.mockClear() + + const { rerenderPanel } = renderPanel({ + mainLyric: tokenizedMainLyric, + audioInstance, + obscured: true, + }) + + expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument() + expect(window.requestAnimationFrame).not.toHaveBeenCalled() + + rerenderPanel({ + mainLyric: tokenizedMainLyric, + audioInstance, + obscured: false, + }) + expect(window.requestAnimationFrame).toHaveBeenCalled() + + rerenderPanel({ + mainLyric: tokenizedMainLyric, + audioInstance, + obscured: true, + }) + expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument() + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7) + }) + it('pauses auto-scroll only for genuine manual scroll intent', async () => { vi.useFakeTimers() const requestAnimationFrameSpy = vi @@ -834,21 +920,23 @@ describe('', () => { const accessibleTheme = createTheme({ palette: { type } }) const background = accessibleTheme.palette.background.default - renderPanel( - { - mainLyric: tokenizedMainLyric, - translationLyric: { - synced: true, - line: [{ start: 0, end: 1000, value: 'Readable translation' }], - }, - showTranslation: true, - audioInstance: { currentTime: 0.25, paused: true }, + const propsAt = (currentTime) => ({ + mainLyric: tokenizedMainLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Readable translation' }], }, - accessibleTheme, - ) + showTranslation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(0.25), accessibleTheme) const group = screen.getByTestId('lyrics-line-group') - expect(screen.getByText('Readable translation')).toBeInTheDocument() + const translation = screen + .getByText('Readable translation') + .closest('[data-tokenized]') + expect(translation).toBeInTheDocument() + expect(window.getComputedStyle(translation).opacity).toBe('1') const token = screen.getAllByTestId('lyrics-token')[0] const gradientNode = [token, ...token.querySelectorAll('*')].find( (node) => node.style.backgroundImage.includes('linear-gradient'), @@ -865,11 +953,6 @@ describe('', () => { expect( group.style.getPropertyValue('--lyrics-translation-active-color'), ).toBe(accessibleTheme.palette.text.secondary) - 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( @@ -880,6 +963,16 @@ describe('', () => { ).toBeLessThan( contrastRatio(accessibleTheme.palette.text.primary, background), ) + + rerenderPanel(propsAt(1.25)) + expect(group).toHaveAttribute('data-active', 'false') + expect(window.getComputedStyle(translation).opacity).toBe('1') + expect( + group.style.getPropertyValue('--lyrics-translation-idle-color'), + ).toBe(accessibleTheme.palette.text.secondary) + expect( + group.style.getPropertyValue('--lyrics-translation-active-color'), + ).toBe(accessibleTheme.palette.text.secondary) }, ) }) diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx index 04f42512e..cb4aa5867 100644 --- a/ui/src/audioplayer/LyricsSidebar.jsx +++ b/ui/src/audioplayer/LyricsSidebar.jsx @@ -1,16 +1,15 @@ import { alpha, makeStyles } from '@material-ui/core/styles' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef } from 'react' import LyricsLayerControls from './LyricsLayerControls' import LyricsPanel from './LyricsPanel' import { + LYRICS_SIDEBAR_DEFAULT_WIDTH, LYRICS_SIDEBAR_MAX_WIDTH, LYRICS_SIDEBAR_MIN_WIDTH, LYRICS_SIDEBAR_TRANSITION_MS, LYRICS_SIDEBAR_WIDTH_STEP, + clampSidebarMaxWidth, clampSidebarWidth, - loadSidebarWidth, - notifySidebarWidthChange, - saveSidebarWidth, } from './lyricsSidebarWidth' import useEnterExitTransition from './useEnterExitTransition' import useRestoreFocusOnExit from './useRestoreFocusOnExit' @@ -83,8 +82,14 @@ const useStyles = makeStyles((theme) => ({ }, })) +const noop = () => {} + const LyricsSidebar = ({ visible, + obscuredByQueue = false, + width = LYRICS_SIDEBAR_DEFAULT_WIDTH, + maxWidth = LYRICS_SIDEBAR_MAX_WIDTH, + onWidthChange = noop, mainLyric, translationLyric, pronunciationLyric, @@ -100,27 +105,28 @@ const LyricsSidebar = ({ labels = {}, returnFocusRef, }) => { - const [width, setWidth] = useState(loadSidebarWidth) + const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth) + const resolvedWidth = clampSidebarWidth(width, resolvedMaxWidth) const sidebarRef = useRef(null) const resizerRef = useRef(null) - const widthRef = useRef(width) + const widthRef = useRef(resolvedWidth) const resizeCleanupRef = useRef(null) const { rendered, entered } = useEnterExitTransition( visible, LYRICS_SIDEBAR_TRANSITION_MS, ) - const [isResizing, setIsResizing] = useState(false) + const interactive = entered && !obscuredByQueue const classes = useStyles() useRestoreFocusOnExit({ surfaceRef: sidebarRef, - entered, + entered: interactive, returnFocusRef, }) useEffect(() => { - widthRef.current = width - }, [width]) + widthRef.current = resolvedWidth + }, [resolvedWidth]) useEffect( () => () => { @@ -130,26 +136,21 @@ const LyricsSidebar = ({ [], ) - const applyWidth = useCallback((next) => { - const resolvedWidth = clampSidebarWidth( - typeof next === 'function' ? next(widthRef.current) : next, - ) - widthRef.current = resolvedWidth - if (sidebarRef.current) { - sidebarRef.current.style.width = `${resolvedWidth}px` - } - resizerRef.current?.setAttribute('aria-valuenow', String(resolvedWidth)) - notifySidebarWidthChange(resolvedWidth) - return resolvedWidth - }, []) - - const updateWidth = useCallback( + const applyWidth = useCallback( (next, { persist = false } = {}) => { - const resolvedWidth = applyWidth(next) - setWidth(resolvedWidth) - if (persist) saveSidebarWidth(resolvedWidth) + const nextWidth = clampSidebarWidth( + typeof next === 'function' ? next(widthRef.current) : next, + resolvedMaxWidth, + ) + widthRef.current = nextWidth + if (sidebarRef.current) { + sidebarRef.current.style.width = `${nextWidth}px` + } + resizerRef.current?.setAttribute('aria-valuenow', String(nextWidth)) + onWidthChange(nextWidth, { persist }) + return nextWidth }, - [applyWidth], + [onWidthChange, resolvedMaxWidth], ) const handleResizePointerDown = useCallback( @@ -166,7 +167,6 @@ const LyricsSidebar = ({ const canCapture = pointerId != null && typeof target.setPointerCapture === 'function' const listenerTarget = window - setIsResizing(true) const handlePointerMove = (moveEvent) => { latestWidth = applyWidth(startWidth + startX - moveEvent.clientX) @@ -190,9 +190,7 @@ const LyricsSidebar = ({ // Ignore stale pointer capture state. } } - setWidth(latestWidth) - if (persist) saveSidebarWidth(latestWidth) - setIsResizing(false) + if (persist) onWidthChange(latestWidth, { persist: true }) if (resizeCleanupRef.current === cleanupResize) { resizeCleanupRef.current = null } @@ -214,30 +212,30 @@ const LyricsSidebar = ({ } } }, - [applyWidth], + [applyWidth, onWidthChange], ) const handleResizeKeyDown = useCallback( (event) => { if (event.key === 'ArrowLeft') { event.preventDefault() - updateWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, { + applyWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, { persist: true, }) } else if (event.key === 'ArrowRight') { event.preventDefault() - updateWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, { + applyWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, { persist: true, }) } else if (event.key === 'Home') { event.preventDefault() - updateWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true }) + applyWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true }) } else if (event.key === 'End') { event.preventDefault() - updateWidth(LYRICS_SIDEBAR_MAX_WIDTH, { persist: true }) + applyWidth(resolvedMaxWidth, { persist: true }) } }, - [updateWidth], + [applyWidth, resolvedMaxWidth], ) if (!rendered) return null @@ -248,15 +246,14 @@ const LyricsSidebar = ({ data-testid="lyrics-sidebar" ref={sidebarRef} style={{ - width: widthRef.current, + width: resolvedWidth, transform: entered ? 'translateX(0)' : 'translateX(100%)', opacity: entered ? 1 : 0, - pointerEvents: entered ? 'auto' : 'none', + pointerEvents: interactive ? 'auto' : 'none', }} aria-label={labels.title || 'Lyrics'} - aria-hidden={!entered} - inert={entered ? undefined : ''} - data-resizing={isResizing ? 'true' : 'false'} + aria-hidden={!interactive} + inert={interactive ? undefined : ''} >
    , diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx index 7d5522d6a..2676db7f5 100644 --- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx @@ -14,8 +14,8 @@ import MobileKaraokeLyricsPortal, { MOBILE_KARAOKE_LYRICS_ENTERED_CLASS, MOBILE_KARAOKE_LYRICS_HOST_SELECTOR, MOBILE_KARAOKE_LYRICS_LAYER_CLASS, - MOBILE_KARAOKE_LYRICS_TRANSITION_MS, } from './MobileKaraokeLyricsPortal' +import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants' import usePlayerLyrics from './usePlayerLyrics' const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => { @@ -49,19 +49,28 @@ const createHost = () => { return host } -const portal = (text, active = true, returnFocusRef) => ( - +const portal = (text, active = true, returnFocusRef, obscured = false) => ( + {text} ) -const MobileLyricsHarness = ({ trackId = 'track-1', isRadio = false }) => { +const MobileLyricsHarness = ({ + trackId = 'track-1', + isRadio = false, + obscuredByQueue = false, +}) => { const { toolbarLyricsProps, mobileLyricsSurface } = usePlayerLyrics({ trackId, trackUpdatedAt: 'now', isRadio, audioInstance: null, isDesktop: false, + obscuredByQueue, translate: (key) => key, }) return ( @@ -157,6 +166,38 @@ describe('', () => { expect(document.activeElement).toBe(returnTarget) }) + it('keeps open lyrics mounted and inert while the queue obscures them', async () => { + const host = createHost() + const { rerender } = render() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' })) + const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`) + const panel = within(host).getByTestId('karaoke-lyrics-panel') + await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false')) + + rerender() + + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS) + expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS) + expect(host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)).toBe( + layer, + ) + expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel) + expect(layer).toHaveAttribute('aria-hidden', 'true') + expect(layer).toHaveAttribute('inert') + expect(layer).toHaveStyle({ pointerEvents: 'none' }) + expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ requested: true }), + ) + + rerender() + + expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel) + await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false')) + expect(layer).not.toHaveAttribute('inert') + expect(layer).toHaveStyle({ pointerEvents: 'auto' }) + }) + it('attaches when the mobile cover host appears after activation', async () => { vi.useFakeTimers() @@ -248,7 +289,6 @@ describe('', () => { fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' })) const controls = within(host).getByTestId('lyrics-mobile-layer-controls') - expect(controls).toHaveAttribute('data-placement', 'mobile') expect(controls).toHaveStyle({ bottom: '8px', left: '50%', diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 5aa9fdaf3..f20828d44 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -54,6 +54,7 @@ const Player = () => { const currentTrackIdRef = useRef(null) const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) + const [audioListsPanelVisible, setAudioListsPanelVisible] = useState(false) const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY) const isMobilePlayer = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( @@ -158,6 +159,7 @@ const Player = () => { isRadio: playerState.current?.isRadio || false, audioInstance, isDesktop, + obscuredByQueue: audioListsPanelVisible, translate, }) @@ -501,6 +503,7 @@ const Player = () => { {...options} className={classes.player} onAudioListsChange={onAudioListsChange} + onAudioListsPanelChange={setAudioListsPanelVisible} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} onAudioPlay={onAudioPlay} diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js index a542694e8..28dc1b917 100644 --- a/ui/src/audioplayer/lyricsKaraokeConstants.js +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -19,9 +19,9 @@ 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 MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260 export const KARAOKE_SCROLL_PRE_ROLL_MS = 320 export const KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO = 0.1 export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42 diff --git a/ui/src/audioplayer/lyricsSidebarWidth.js b/ui/src/audioplayer/lyricsSidebarWidth.js index 9c72deacb..2d0152d69 100644 --- a/ui/src/audioplayer/lyricsSidebarWidth.js +++ b/ui/src/audioplayer/lyricsSidebarWidth.js @@ -1,14 +1,14 @@ export const LYRICS_SIDEBAR_STORAGE_KEY = 'nd.lyricsSidebar.width.v1' -export const LYRICS_SIDEBAR_WIDTH_EVENT = 'nd:lyrics-sidebar-width' export const LYRICS_SIDEBAR_DEFAULT_WIDTH = 360 export const LYRICS_SIDEBAR_MIN_WIDTH = 300 export const LYRICS_SIDEBAR_MAX_WIDTH = 520 +export const LYRICS_ROUTE_MIN_WIDTH = 320 export const LYRICS_SIDEBAR_WIDTH_STEP = 20 export const LYRICS_SIDEBAR_TRANSITION_MS = 260 -export const clampSidebarWidth = (value) => { +export const clampSidebarMaxWidth = (value) => { const numeric = Number(value) - if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_DEFAULT_WIDTH + if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_MAX_WIDTH return Math.round( Math.min( LYRICS_SIDEBAR_MAX_WIDTH, @@ -17,6 +17,28 @@ export const clampSidebarWidth = (value) => { ) } +export const clampSidebarWidth = ( + value, + maxWidth = LYRICS_SIDEBAR_MAX_WIDTH, +) => { + const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth) + const numeric = Number(value) + if (!Number.isFinite(numeric)) { + return Math.min(LYRICS_SIDEBAR_DEFAULT_WIDTH, resolvedMaxWidth) + } + return Math.round( + Math.min(resolvedMaxWidth, Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric)), + ) +} + +export const getSidebarMaxWidth = (contentWidth) => { + const numeric = Number(contentWidth) + if (!Number.isFinite(numeric) || numeric <= 0) { + return LYRICS_SIDEBAR_MAX_WIDTH + } + return clampSidebarMaxWidth(numeric - LYRICS_ROUTE_MIN_WIDTH) +} + const hasLocalStorage = () => { try { return typeof window !== 'undefined' && Boolean(window.localStorage) @@ -37,16 +59,6 @@ export const loadSidebarWidth = () => { } } -export const notifySidebarWidthChange = (width) => { - if (typeof window === 'undefined') return - - window.dispatchEvent( - new CustomEvent(LYRICS_SIDEBAR_WIDTH_EVENT, { - detail: { width: clampSidebarWidth(width) }, - }), - ) -} - export const saveSidebarWidth = (width) => { const resolvedWidth = clampSidebarWidth(width) if (!hasLocalStorage()) return diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 84c5e1483..dae045807 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -1,8 +1,8 @@ import { makeStyles } from '@material-ui/core/styles' +import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants' import { PLAYER_MOBILE_MEDIA_QUERY } from './playerBreakpoints' -const MOBILE_LYRICS_TRANSITION = - 'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)' +const MOBILE_LYRICS_TRANSITION = `opacity ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), transform ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)` const useStyle = makeStyles( (theme) => ({ @@ -48,6 +48,9 @@ const useStyle = makeStyles( '& .play-mode-title': { pointerEvents: 'none', }, + '& .audio-lists-panel': { + zIndex: theme.zIndex.appBar, + }, '& .music-player-panel .panel-content div.img-rotate': { // Customize desktop player when cover animation is disabled animationDuration: (props) => !props.enableCoverAnimation && '0s', diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx index 429d6ae1f..e80f4cbfd 100644 --- a/ui/src/audioplayer/usePlayerLyrics.jsx +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -15,6 +15,7 @@ const usePlayerLyrics = ({ isRadio, audioInstance, isDesktop, + obscuredByQueue = false, translate, }) => { const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false) @@ -125,6 +126,7 @@ const usePlayerLyrics = ({ loading: lyricsLoading, error: lyricsError, labels, + obscuredByQueue, returnFocusRef: lyricsToggleRef, }), [ @@ -139,6 +141,7 @@ const usePlayerLyrics = ({ lyricsError, lyricsLoading, lyricsVisible, + obscuredByQueue, showPronunciation, showTranslation, togglePronunciation, @@ -150,9 +153,12 @@ const usePlayerLyrics = ({ () => ( ({ frame: { @@ -20,25 +28,126 @@ const useStyles = makeStyles( const LyricsLayoutFrame = ({ children }) => { const { desktopLyricsProps } = useLyricsLayout() const hasVisibleLyrics = Boolean(desktopLyricsProps?.visible) - const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth) + const [preferredSidebarWidth, setPreferredSidebarWidth] = + useState(loadSidebarWidth) + const [contentWidth, setContentWidth] = useState(null) + const frameRef = useRef(null) + const autoCollapsedRef = useRef(false) + const manualSidebarOverrideRef = useRef(false) + const pendingSidebarStateRef = useRef(null) + const previousSidebarOpenRef = useRef(null) + const dispatch = useDispatch() + const sidebarOpen = useSelector( + (state) => state.admin?.ui?.sidebarOpen ?? false, + ) const classes = useStyles() + const sidebarMaxWidth = getSidebarMaxWidth(contentWidth) + const sidebarWidth = clampSidebarWidth(preferredSidebarWidth, sidebarMaxWidth) useEffect(() => { - if (!hasVisibleLyrics) return undefined + const parent = frameRef.current?.parentElement + if (!parent) return undefined - const handleSidebarWidth = (event) => { - const next = clampSidebarWidth(event.detail?.width) - setSidebarWidth((current) => (current === next ? current : next)) + const updateContentWidth = (nextWidth) => { + const numeric = Number(nextWidth) + if (!Number.isFinite(numeric) || numeric <= 0) return + const rounded = Math.round(numeric) + setContentWidth((current) => (current === rounded ? current : rounded)) } - setSidebarWidth(loadSidebarWidth()) - window.addEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth) - return () => - window.removeEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth) + const measureParent = () => { + updateContentWidth(parent.clientWidth) + } + + measureParent() + const ResizeObserverConstructor = window.ResizeObserver + if (!ResizeObserverConstructor) { + window.addEventListener('resize', measureParent) + return () => window.removeEventListener('resize', measureParent) + } + + const resizeObserver = new ResizeObserverConstructor((entries) => { + const entry = entries.find((candidate) => candidate.target === parent) + const borderBoxSize = Array.isArray(entry?.borderBoxSize) + ? entry.borderBoxSize[0] + : entry?.borderBoxSize + updateContentWidth( + borderBoxSize?.inlineSize || + entry?.target?.clientWidth || + entry?.contentRect?.width, + ) + }) + resizeObserver.observe(parent) + return () => resizeObserver.disconnect() + }, []) + + useEffect(() => { + if (hasVisibleLyrics) { + setPreferredSidebarWidth(loadSidebarWidth()) + } }, [hasVisibleLyrics]) + useEffect(() => { + const previousSidebarOpen = previousSidebarOpenRef.current + previousSidebarOpenRef.current = sidebarOpen + if (previousSidebarOpen == null || previousSidebarOpen === sidebarOpen) { + return + } + + if (pendingSidebarStateRef.current === sidebarOpen) { + pendingSidebarStateRef.current = null + return + } + + if (hasVisibleLyrics) { + manualSidebarOverrideRef.current = true + autoCollapsedRef.current = false + } + }, [hasVisibleLyrics, sidebarOpen]) + + useEffect(() => { + if (!hasVisibleLyrics) { + if ( + autoCollapsedRef.current && + !manualSidebarOverrideRef.current && + !sidebarOpen + ) { + autoCollapsedRef.current = false + pendingSidebarStateRef.current = true + dispatch(setSidebarVisibility(true)) + } + manualSidebarOverrideRef.current = false + return + } + + if ( + manualSidebarOverrideRef.current || + !Number.isFinite(contentWidth) || + contentWidth <= 0 + ) { + return + } + + if (sidebarOpen && contentWidth < MIN_CONTENT_WITH_LYRICS) { + autoCollapsedRef.current = true + pendingSidebarStateRef.current = false + dispatch(setSidebarVisibility(false)) + return + } + }, [contentWidth, dispatch, hasVisibleLyrics, sidebarOpen]) + + const handleSidebarWidthChange = useCallback( + (nextWidth, { persist = false } = {}) => { + const resolvedWidth = clampSidebarWidth(nextWidth, sidebarMaxWidth) + setPreferredSidebarWidth(resolvedWidth) + if (persist) saveSidebarWidth(resolvedWidth) + }, + [sidebarMaxWidth], + ) + return (
    { }} > {children} - {desktopLyricsProps && } + {desktopLyricsProps && ( + + )}
    ) } diff --git a/ui/src/layout/LyricsLayoutFrame.test.jsx b/ui/src/layout/LyricsLayoutFrame.test.jsx index bb8ee0bc5..632acf6b6 100644 --- a/ui/src/layout/LyricsLayoutFrame.test.jsx +++ b/ui/src/layout/LyricsLayoutFrame.test.jsx @@ -1,12 +1,25 @@ import React, { useEffect } from 'react' -import { act, cleanup, render, screen } from '@testing-library/react' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { setSidebarVisibility } from 'react-admin' +import { Provider } from 'react-redux' +import { createStore } from 'redux' import { afterEach, describe, expect, it, vi } from 'vitest' import { LyricsLayoutProvider, useLyricsLayout, } from '../audioplayer/LyricsLayoutContext' -import { LYRICS_SIDEBAR_TRANSITION_MS } from '../audioplayer/lyricsSidebarWidth' +import { + LYRICS_SIDEBAR_STORAGE_KEY, + LYRICS_SIDEBAR_TRANSITION_MS, +} from '../audioplayer/lyricsSidebarWidth' import LyricsLayoutFrame from './LyricsLayoutFrame' const theme = createTheme({ @@ -32,6 +45,61 @@ const lyricsProps = { onTogglePronunciation: vi.fn(), } +const originalResizeObserver = window.ResizeObserver +const resizeObservers = [] + +class TestResizeObserver { + constructor(callback) { + this.callback = callback + this.target = null + this.disconnected = false + resizeObservers.push(this) + } + + observe(target) { + this.target = target + } + + disconnect() { + this.disconnected = true + } +} + +const notifyContentWidth = (width) => { + act(() => { + resizeObservers.forEach((observer) => { + if (observer.disconnected || !observer.target) return + observer.callback([ + { + target: observer.target, + contentRect: { width }, + borderBoxSize: [{ inlineSize: width }], + }, + ]) + }) + }) +} + +const sidebarVisibilityActionType = setSidebarVisibility(true).type + +const createLayoutStore = (sidebarOpen = false) => + createStore( + (state, action) => { + if (action.type !== sidebarVisibilityActionType) return state + return { + ...state, + admin: { + ...state.admin, + ui: { + ...state.admin.ui, + sidebarOpen: action.payload, + }, + }, + } + }, + { admin: { ui: { sidebarOpen } } }, + ) + const PublishLyricsProps = ({ props }) => { const { setDesktopLyricsProps } = useLyricsLayout() @@ -43,22 +111,27 @@ const PublishLyricsProps = ({ props }) => { return null } -const frame = (props) => ( - - - - -
    Albums grid
    -
    -
    -
    +const frame = (props, store) => ( + + + + + +
    Albums grid
    +
    +
    +
    +
    ) -const renderFrame = (props) => { - const view = render(frame(props)) +const renderFrame = (props, { sidebarOpen = false, store } = {}) => { + window.ResizeObserver = TestResizeObserver + const layoutStore = store || createLayoutStore(sidebarOpen) + const view = render(frame(props, layoutStore)) return { ...view, - rerenderFrame: (nextProps) => view.rerender(frame(nextProps)), + store: layoutStore, + rerenderFrame: (nextProps) => view.rerender(frame(nextProps, layoutStore)), } } @@ -66,6 +139,9 @@ describe('', () => { afterEach(() => { vi.useRealTimers() cleanup() + localStorage.clear() + resizeObservers.splice(0) + window.ResizeObserver = originalResizeObserver }) it('reserves a native right-hand lyrics pane beside route content', async () => { @@ -82,6 +158,186 @@ describe('', () => { expect(window.getComputedStyle(sidebar).position).toBe('fixed') }) + it('caps a persisted width against the measured route reserve without overwriting it', async () => { + localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520') + renderFrame(lyricsProps) + const sidebar = await screen.findByTestId('lyrics-sidebar') + const resizer = screen.getByTestId('lyrics-sidebar-resizer') + + notifyContentWidth(755) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '435px', + }) + expect(sidebar).toHaveStyle({ width: '435px' }) + expect(resizer).toHaveAttribute('aria-valuemax', '435') + expect(resizer).toHaveAttribute('aria-valuenow', '435') + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520') + + notifyContentWidth(840) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '520px', + }) + expect(sidebar).toHaveStyle({ width: '520px' }) + expect(resizer).toHaveAttribute('aria-valuemax', '520') + expect(resizer).toHaveAttribute('aria-valuenow', '520') + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520') + }) + + it('keeps the layout width reserved while the queue obscures lyrics', async () => { + localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520') + renderFrame({ ...lyricsProps, obscuredByQueue: true }) + const sidebar = await screen.findByTestId('lyrics-sidebar') + + notifyContentWidth(755) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '435px', + }) + expect(sidebar).toHaveStyle({ + width: '435px', + transform: 'translateX(0)', + pointerEvents: 'none', + }) + expect(sidebar).toHaveAttribute('aria-hidden', 'true') + expect(sidebar).toHaveAttribute('inert') + expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument() + }) + + it('collapses an open app menu only when the minimum pane cannot fit', async () => { + localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520') + const { rerenderFrame, store } = renderFrame(lyricsProps, { + sidebarOpen: true, + }) + await screen.findByTestId('lyrics-sidebar') + + notifyContentWidth(570) + + await waitFor(() => + expect(store.getState().admin.ui.sidebarOpen).toBe(false), + ) + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '300px', + }) + expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute( + 'aria-valuemax', + '300', + ) + + notifyContentWidth(755) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '435px', + }) + expect(store.getState().admin.ui.sidebarOpen).toBe(false) + + rerenderFrame({ ...lyricsProps, visible: false }) + + await waitFor(() => + expect(store.getState().admin.ui.sidebarOpen).toBe(true), + ) + }) + + it('keeps the app menu open at the 960px and 1280px layout caps', async () => { + localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520') + const { store } = renderFrame(lyricsProps, { sidebarOpen: true }) + await screen.findByTestId('lyrics-sidebar') + + notifyContentWidth(720) + + expect(store.getState().admin.ui.sidebarOpen).toBe(true) + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '400px', + }) + expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute( + 'aria-valuemax', + '400', + ) + + notifyContentWidth(1040) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '520px', + }) + expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute( + 'aria-valuemax', + '520', + ) + }) + + it('keeps an automatic collapse until lyrics close when space returns', async () => { + const { rerenderFrame, store } = renderFrame(lyricsProps, { + sidebarOpen: true, + }) + await screen.findByTestId('lyrics-sidebar') + notifyContentWidth(570) + await waitFor(() => + expect(store.getState().admin.ui.sidebarOpen).toBe(false), + ) + + notifyContentWidth(1040) + + expect(store.getState().admin.ui.sidebarOpen).toBe(false) + + rerenderFrame({ ...lyricsProps, visible: false }) + + await waitFor(() => + expect(store.getState().admin.ui.sidebarOpen).toBe(true), + ) + }) + + it('does not restore the menu after a manual override', async () => { + const { rerenderFrame, store } = renderFrame(lyricsProps, { + sidebarOpen: true, + }) + await screen.findByTestId('lyrics-sidebar') + notifyContentWidth(570) + await waitFor(() => + expect(store.getState().admin.ui.sidebarOpen).toBe(false), + ) + + act(() => { + store.dispatch(setSidebarVisibility(true)) + }) + await waitFor(() => + expect(store.getState().admin.ui.sidebarOpen).toBe(true), + ) + act(() => { + store.dispatch(setSidebarVisibility(false)) + }) + await waitFor(() => + expect(store.getState().admin.ui.sidebarOpen).toBe(false), + ) + + rerenderFrame({ ...lyricsProps, visible: false }) + + expect(store.getState().admin.ui.sidebarOpen).toBe(false) + }) + + it('persists deliberate keyboard resizing at the measured cap', async () => { + localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520') + renderFrame(lyricsProps) + await screen.findByTestId('lyrics-sidebar') + notifyContentWidth(755) + const resizer = screen.getByTestId('lyrics-sidebar-resizer') + + fireEvent.keyDown(resizer, { key: 'Home' }) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '300px', + }) + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('300') + + fireEvent.keyDown(resizer, { key: 'End' }) + + expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({ + marginRight: '435px', + }) + expect(resizer).toHaveAttribute('aria-valuenow', '435') + expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('435') + }) + it('releases the layout frame after the sidebar exit transition', async () => { vi.useFakeTimers() const { rerenderFrame } = renderFrame(lyricsProps) From 04fffca1e8517628cdb5681721554c7c486f4708 Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 29 Jul 2026 14:17:46 +0300 Subject: [PATCH 10/16] refactor(ui): simplify lyrics sidebar internals Signed-off-by: ranokay --- ui/src/audioplayer/LyricsLayerControls.jsx | 1 - ui/src/audioplayer/LyricsLineRows.jsx | 12 +---- ui/src/audioplayer/LyricsPanel.jsx | 7 +-- ui/src/audioplayer/LyricsPanel.test.jsx | 1 - ui/src/audioplayer/PlayerToolbar.test.jsx | 48 +++++++------------- ui/src/audioplayer/playerBreakpoints.js | 2 - ui/src/audioplayer/playerBreakpoints.test.js | 14 +----- ui/src/audioplayer/useEnhancedLyrics.js | 8 +--- ui/src/audioplayer/usePlayerLyrics.jsx | 11 +---- 9 files changed, 23 insertions(+), 81 deletions(-) diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx index 56d5e27f0..62c43f37a 100644 --- a/ui/src/audioplayer/LyricsLayerControls.jsx +++ b/ui/src/audioplayer/LyricsLayerControls.jsx @@ -120,7 +120,6 @@ const LyricsLayerControls = ({ [classes.sidebarPlacement]: placement !== 'mobile', })} data-testid={testId} - data-placement={placement} onClick={(event) => event.stopPropagation()} > {segments.map((segment, idx) => { @@ -485,9 +482,6 @@ export const KaraokeStackedLineRow = memo( data-wrapped={isWrapped ? 'true' : 'false'} data-testid={testId} data-tokenized={hasTimedTokens ? 'true' : 'false'} - data-layer-animation={ - hasTimedTokens ? 'token-gradient' : 'shared-opacity' - } ref={rowRef} style={lineStyle} > @@ -537,11 +531,7 @@ export const KaraokeStackedLineRow = memo( )} ) : ( - + {segment.text} ) diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index d8f04dd1d..09f00402a 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -100,7 +100,6 @@ const useStyles = makeStyles((theme) => ({ alignItems: 'stretch', gap: theme.spacing(3), }, - inlineLines: {}, lineGroup: { width: '100%', borderRadius: theme.shape.borderRadius, @@ -692,7 +691,6 @@ const LyricsPanel = ({
    event.stopPropagation() : undefined} >
    event.stopPropagation() : undefined} >
    0 || scrollStartPadding > 0 diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index 49705ad13..d37e31d95 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -380,7 +380,6 @@ describe('', () => { const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token') ;[mainRow, translationRow].forEach((row) => { - expect(row).toHaveAttribute('data-layer-animation', 'shared-opacity') expect(row).toHaveAttribute('data-tokenized', 'false') expect(row.style.opacity).toBe('') }) diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx index d4779c771..ef03b1cad 100644 --- a/ui/src/audioplayer/PlayerToolbar.test.jsx +++ b/ui/src/audioplayer/PlayerToolbar.test.jsx @@ -77,22 +77,6 @@ describe('', () => { expect(listItems[0].className).toContain('toolbar') }) - it('disables save queue button when isRadio is true', () => { - render() - - const saveQueueButton = screen.getByTestId('save-queue-button') - expect(saveQueueButton).toBeDisabled() - }) - - it('disables love button when conditions are met', () => { - useGetOne.mockReturnValue({ data: mockSongData, loading: true }) - - render() - - const loveButton = screen.getByTestId('love-button') - expect(loveButton).toBeDisabled() - }) - it('opens save queue dialog when save button is clicked', () => { render() @@ -154,22 +138,6 @@ describe('', () => { expect(listItems[2].className).toContain('mobileListItem') }) - it('disables save queue button when isRadio is true', () => { - render() - - const saveQueueButton = screen.getByTestId('save-queue-button') - expect(saveQueueButton).toBeDisabled() - }) - - it('disables love button when conditions are met', () => { - useGetOne.mockReturnValue({ data: mockSongData, loading: true }) - - render() - - const loveButton = screen.getByTestId('love-button') - expect(loveButton).toBeDisabled() - }) - it('disables lyrics toggle when lyrics are unavailable', () => { render( , @@ -192,6 +160,22 @@ describe('', () => { }) describe('Common behavior', () => { + it('disables save queue button when isRadio is true', () => { + useMediaQuery.mockReturnValue(true) + render() + + expect(screen.getByTestId('save-queue-button')).toBeDisabled() + }) + + it('disables love button when conditions are met', () => { + useMediaQuery.mockReturnValue(true) + useGetOne.mockReturnValue({ data: mockSongData, loading: true }) + + render() + + expect(screen.getByTestId('love-button')).toBeDisabled() + }) + it('renders global hotkeys in both layouts', () => { // Test desktop layout useMediaQuery.mockReturnValue(true) diff --git a/ui/src/audioplayer/playerBreakpoints.js b/ui/src/audioplayer/playerBreakpoints.js index 72dd2e68a..a646b76fa 100644 --- a/ui/src/audioplayer/playerBreakpoints.js +++ b/ui/src/audioplayer/playerBreakpoints.js @@ -2,9 +2,7 @@ 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 -export const LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH = PLAYER_DESKTOP_MIN_WIDTH export const PLAYER_DESKTOP_MEDIA_QUERY = `(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)` export const PLAYER_MOBILE_MATCH_MEDIA_QUERY = `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)` export const PLAYER_MOBILE_MEDIA_QUERY = `@media screen and ${PLAYER_MOBILE_MATCH_MEDIA_QUERY}` -export const LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY = PLAYER_DESKTOP_MEDIA_QUERY diff --git a/ui/src/audioplayer/playerBreakpoints.test.js b/ui/src/audioplayer/playerBreakpoints.test.js index 4d19e5e68..4d40b56c8 100644 --- a/ui/src/audioplayer/playerBreakpoints.test.js +++ b/ui/src/audioplayer/playerBreakpoints.test.js @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest' import { - LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY, - LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH, PLAYER_DESKTOP_MEDIA_QUERY, PLAYER_DESKTOP_MIN_WIDTH, PLAYER_MOBILE_MATCH_MEDIA_QUERY, @@ -12,6 +10,8 @@ import { describe('player breakpoints', () => { it('keeps compact and desktop player ranges from overlapping', () => { + expect(PLAYER_DESKTOP_MIN_WIDTH).toBe(810) + expect(PLAYER_MOBILE_MAX_WIDTH).toBe(809.95) expect(PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MOBILE_MAX_WIDTH).toBeCloseTo( PLAYER_MEDIA_QUERY_STEP, ) @@ -25,14 +25,4 @@ describe('player breakpoints', () => { `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`, ) }) - - 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).toBe(PLAYER_DESKTOP_MIN_WIDTH) - }) }) diff --git a/ui/src/audioplayer/useEnhancedLyrics.js b/ui/src/audioplayer/useEnhancedLyrics.js index 69343c4ac..735922583 100644 --- a/ui/src/audioplayer/useEnhancedLyrics.js +++ b/ui/src/audioplayer/useEnhancedLyrics.js @@ -8,7 +8,6 @@ export const emptyLyricLayers = Object.freeze({ pronunciation: null, }) -const LYRICS_CACHE_SCHEMA_VERSION = 3 const MAX_LYRIC_CACHE_ENTRIES = 75 const NEGATIVE_CACHE_TTL_MS = 30_000 @@ -25,12 +24,7 @@ const readStructuredLyrics = (response) => response?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || [] const buildCacheKey = ({ trackId, preferredLanguage, updatedAt }) => - [ - trackId || '', - updatedAt || '', - preferredLanguage || '', - LYRICS_CACHE_SCHEMA_VERSION, - ].join('\u0000') + [trackId || '', updatedAt || '', preferredLanguage || ''].join('\u0000') const rememberLyrics = (cacheKey, layers, expiresAt = null) => { cache.delete(cacheKey) diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx index e80f4cbfd..edbb6ae3d 100644 --- a/ui/src/audioplayer/usePlayerLyrics.jsx +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import LyricsLayerControls from './LyricsLayerControls' import LyricsPanel from './LyricsPanel' import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal' @@ -19,15 +19,10 @@ const usePlayerLyrics = ({ translate, }) => { const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false) - const [lyricsRequested, setLyricsRequested] = useState(false) const [translationPreference, setTranslationPreference] = useState(null) const [pronunciationPreference, setPronunciationPreference] = useState(null) const lyricsToggleRef = useRef(null) - useEffect(() => { - setLyricsRequested(lyricsVisiblePreference) - }, [trackId]) // eslint-disable-line react-hooks/exhaustive-deps - const { layers: lyricLayers, loading: lyricsLoading, @@ -37,7 +32,7 @@ const usePlayerLyrics = ({ trackId, updatedAt: trackUpdatedAt, disabled: isRadio, - requested: lyricsRequested || lyricsVisiblePreference, + requested: lyricsVisiblePreference, }) const hasTranslationLyric = hasStructuredLyricContent(lyricLayers.translation) @@ -74,7 +69,6 @@ const usePlayerLyrics = ({ const toggleLyrics = useCallback(() => { const next = !lyricsVisiblePreference setLyricsVisiblePreference(next) - setLyricsRequested(next) if (next) { if (lyricsError) retryLyrics() } @@ -82,7 +76,6 @@ const usePlayerLyrics = ({ const closeLyrics = useCallback(() => { setLyricsVisiblePreference(false) - setLyricsRequested(false) }, []) const toggleTranslation = useCallback(() => { From c0f06b292bdf62a17a2b70a95da88448f6b614cf Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 29 Jul 2026 16:22:10 +0300 Subject: [PATCH 11/16] fix(ui): polish lyrics presentation Signed-off-by: ranokay --- ui/src/audioplayer/LyricsPanel.jsx | 69 ++++++++---------- ui/src/audioplayer/LyricsPanel.test.jsx | 76 ++++++++++++-------- ui/src/audioplayer/lyricsKaraokeConstants.js | 2 +- 3 files changed, 78 insertions(+), 69 deletions(-) diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index 09f00402a..9b2a45f57 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -36,6 +36,7 @@ import { finiteTime } from './lyricsTimeline' import useLyricsTimeline from './useLyricsTimeline' const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}` +const KARAOKE_LAYER_COLOR_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: { @@ -58,15 +59,24 @@ const useStyles = makeStyles((theme) => ({ overflowX: 'hidden', padding: theme.spacing(4, 2.25, 3.25), overscrollBehavior: 'contain', - scrollbarWidth: 'none', - msOverflowStyle: 'none', + scrollbarGutter: 'stable', + scrollbarWidth: 'thin', + scrollbarColor: 'transparent transparent', maskImage: 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)', WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)', '&::-webkit-scrollbar': { - width: 0, - height: 0, + width: 8, + height: 8, + }, + // Player themes use two-class scrollbar selectors. + '&&&::-webkit-scrollbar-thumb': { + backgroundColor: 'transparent', + borderRadius: 999, + }, + '&::-webkit-scrollbar-track': { + backgroundColor: 'transparent', }, }, bodyTopFade: { @@ -76,18 +86,9 @@ const useStyles = makeStyles((theme) => ({ 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 8px, #000 24px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)', }, bodyUserScrolling: { - scrollbarWidth: 'thin', - msOverflowStyle: 'auto', - '&::-webkit-scrollbar': { - width: 8, - height: 8, - }, - '&::-webkit-scrollbar-thumb': { + scrollbarColor: `${theme.palette.action.disabled} transparent`, + '&&&::-webkit-scrollbar-thumb': { backgroundColor: theme.palette.action.disabled, - borderRadius: 999, - }, - '&::-webkit-scrollbar-track': { - backgroundColor: 'transparent', }, }, inlineBody: { @@ -176,7 +177,7 @@ const useStyles = makeStyles((theme) => ({ transition: 'none', }, }, - auxLine: { + translationLine: { display: 'block', opacity: 1, fontWeight: 600, @@ -188,10 +189,12 @@ const useStyles = makeStyles((theme) => ({ color: 'var(--lyrics-translation-current-color, currentColor)', WebkitTextFillColor: 'var(--lyrics-translation-current-color, currentColor)', - transition: 'none', + transition: KARAOKE_LAYER_COLOR_TRANSITION, + '&&': { + marginTop: theme.spacing(0.6), + }, '@media (prefers-reduced-motion: reduce)': { transition: 'none', - transform: 'none', }, }, stackedToken: { @@ -247,9 +250,6 @@ const useStyles = makeStyles((theme) => ({ transition: 'none', }, }, - translationLine: { - fontWeight: 600, - }, token: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', @@ -362,7 +362,7 @@ const buildUniqueLayerMap = (mainLines, layerLines) => { const getLineLanes = (line) => Array.isArray(line?.lanes) && line.lanes.length > 0 ? line.lanes : [line] -const buildSynchronizedTranslationLine = (_mainLine, translationLine) => { +const buildStaticTranslationLine = (translationLine) => { if (!translationLine) return null return { ...translationLine, @@ -382,8 +382,7 @@ const buildLineGroupStyle = (canSeekLine, layerStyles) => ({ layerStyles.pronunciation.color, '--lyrics-translation-idle-color': layerStyles.translation.color, '--lyrics-translation-active-color': - layerStyles.translation['--lyrics-active-color'] || - layerStyles.translation.color, + layerStyles.translation['--lyrics-active-color'], }) const usePrefersReducedMotion = () => { @@ -500,10 +499,6 @@ const LyricsPanel = ({ () => ({ main: theme.palette.text.primary, pronunciation: theme.palette.primary.main, - translation: - theme.palette.text.secondary || - theme.palette.secondary?.main || - theme.palette.text.primary, }), [theme], ) @@ -511,14 +506,6 @@ const LyricsPanel = ({ const layerStyles = useMemo(() => { const styleFor = (layer) => { const sourceColor = colors[layer] - if (layer === 'translation') { - return { - opacity: 1, - marginTop: theme.spacing(0.6), - color: sourceColor, - '--lyrics-active-color': sourceColor, - } - } if (!hasTimedMainLines) { return { opacity: 1, @@ -537,7 +524,10 @@ const LyricsPanel = ({ return { main: styleFor('main'), pronunciation: styleFor('pronunciation'), - translation: styleFor('translation'), + translation: { + color: theme.palette.text.disabled, + '--lyrics-active-color': theme.palette.text.secondary, + }, } }, [colors, hasTimedMainLines, theme]) @@ -932,10 +922,9 @@ const LyricsPanel = ({ {showTr && ( diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx index d37e31d95..cb5e0712c 100644 --- a/ui/src/audioplayer/LyricsPanel.test.jsx +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -19,6 +19,7 @@ import { KARAOKE_HIGHLIGHT_LEAD_MS, KARAOKE_LINE_LIFT_PX, KARAOKE_MANUAL_SCROLL_PAUSE_MS, + KARAOKE_SCROLLBAR_VISIBLE_MS, TOKEN_FUTURE_ALPHA, } from './lyricsKaraokeConstants' @@ -29,6 +30,11 @@ const theme = createTheme({ }, }) +const translationColorsFor = (selectedTheme) => ({ + active: selectedTheme.palette.text.secondary, + idle: selectedTheme.palette.text.disabled, +}) + const panel = (props, selectedTheme = theme) => ( @@ -355,7 +361,7 @@ describe('', () => { expect(pronunciation.style.color).toBe('') }) - it('keeps translations subordinate and stable between line states', () => { + it('highlights translations with the shared layer transition', () => { const propsAt = (currentTime) => ({ mainLyric, pronunciationLyric: { @@ -386,26 +392,29 @@ describe('', () => { expect(window.getComputedStyle(mainRow).transition).toContain( `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, ) - expect(window.getComputedStyle(translationRow).transition).toBe('none') + expect(window.getComputedStyle(translationRow).transition).toContain( + `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) pronunciation.forEach((token) => expect(token).toHaveAttribute('data-timed', 'false'), ) expect(window.getComputedStyle(translationRow).opacity).toBe('1') + const translationColors = translationColorsFor(theme) expect( lineGroup.style.getPropertyValue('--lyrics-translation-idle-color'), - ).toBe(theme.palette.text.secondary) + ).toBe(translationColors.idle) expect( lineGroup.style.getPropertyValue('--lyrics-translation-active-color'), - ).toBe(theme.palette.text.secondary) + ).toBe(translationColors.active) expect(window.getComputedStyle(translationRow).transform).toBe('') - expect(window.getComputedStyle(translationRow).marginTop).toBe( - `${theme.spacing(0.6)}px`, - ) rerenderPanel(propsAt(1.1)) + expect(lineGroup).toHaveAttribute('data-active', 'false') expect(window.getComputedStyle(translationRow).opacity).toBe('1') - expect(window.getComputedStyle(translationRow).transition).toBe('none') + expect(window.getComputedStyle(translationRow).transition).toContain( + `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) expect(window.getComputedStyle(translationRow).transform).toBe('') }) @@ -877,6 +886,28 @@ describe('', () => { }) }) + it('keeps scrollbar geometry stable before, during, and after manual scroll', () => { + vi.useFakeTimers() + renderPanel({ mainLyric }) + + const body = screen.getByTestId('lyrics-scroll-body') + const initialStyle = window.getComputedStyle(body) + expect(initialStyle.scrollbarGutter).toBe('stable') + expect(initialStyle.scrollbarWidth).toBe('thin') + + fireEvent.wheel(body) + expect(body).toHaveAttribute('data-scrollbar-visible', 'true') + expect(window.getComputedStyle(body).scrollbarGutter).toBe('stable') + expect(window.getComputedStyle(body).scrollbarWidth).toBe('thin') + + act(() => { + vi.advanceTimersByTime(KARAOKE_SCROLLBAR_VISIBLE_MS) + }) + expect(body).toHaveAttribute('data-scrollbar-visible', 'false') + expect(window.getComputedStyle(body).scrollbarGutter).toBe('stable') + expect(window.getComputedStyle(body).scrollbarWidth).toBe('thin') + }) + it('resets scroll position when lyric content changes', () => { const { rerenderPanel } = renderPanel({ mainLyric }) const body = screen.getByTestId('lyrics-scroll-body') @@ -914,7 +945,7 @@ describe('', () => { }) it.each(['light', 'dark'])( - 'keeps future tokens and translations readable in the %s theme', + 'keeps future tokens readable and translations subordinate in the %s theme', (type) => { const accessibleTheme = createTheme({ palette: { type } }) const background = accessibleTheme.palette.background.default @@ -930,7 +961,6 @@ describe('', () => { }) const { rerenderPanel } = renderPanel(propsAt(0.25), accessibleTheme) - const group = screen.getByTestId('lyrics-line-group') const translation = screen .getByText('Readable translation') .closest('[data-tokenized]') @@ -945,33 +975,23 @@ describe('', () => { (match) => match[0], ) const futureColor = gradientColors.at(-1) + const translationColors = translationColorsFor(accessibleTheme) - expect( - group.style.getPropertyValue('--lyrics-translation-idle-color'), - ).toBe(accessibleTheme.palette.text.secondary) - expect( - group.style.getPropertyValue('--lyrics-translation-active-color'), - ).toBe(accessibleTheme.palette.text.secondary) expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA) expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5) expect( - contrastRatio(accessibleTheme.palette.text.secondary, background), + contrastRatio(translationColors.active, background), ).toBeGreaterThanOrEqual(4.5) - expect( - contrastRatio(accessibleTheme.palette.text.secondary, background), - ).toBeLessThan( - contrastRatio(accessibleTheme.palette.text.primary, background), + expect(contrastRatio(translationColors.idle, background)).toBeLessThan( + contrastRatio(translationColors.active, background), ) rerenderPanel(propsAt(1.25)) - expect(group).toHaveAttribute('data-active', 'false') + expect(screen.getByTestId('lyrics-line-group')).toHaveAttribute( + 'data-active', + 'false', + ) expect(window.getComputedStyle(translation).opacity).toBe('1') - expect( - group.style.getPropertyValue('--lyrics-translation-idle-color'), - ).toBe(accessibleTheme.palette.text.secondary) - expect( - group.style.getPropertyValue('--lyrics-translation-active-color'), - ).toBe(accessibleTheme.palette.text.secondary) }, ) }) diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js index 28dc1b917..ff4983cbd 100644 --- a/ui/src/audioplayer/lyricsKaraokeConstants.js +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -1,7 +1,7 @@ export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140 export const KARAOKE_HIGHLIGHT_LEAD_MS = 120 export const KARAOKE_ANIMATION_MS = 500 -export const KARAOKE_LINE_OPACITY_MS = 200 +export const KARAOKE_LINE_OPACITY_MS = 300 export const KARAOKE_SCROLLBAR_VISIBLE_MS = 1400 export const KARAOKE_MANUAL_SCROLL_PAUSE_MS = 2200 export const KARAOKE_SCROLL_ANIMATION_MS = 300 From c69161288b023220c68e6dc22cf3e980151b2fcf Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 23 Aug 2026 09:02:28 +0300 Subject: [PATCH 12/16] fix(ui): explain temporary lyrics cooldowns --- core/lyrics/lyrics.go | 6 +- core/lyrics/lyrics_test.go | 12 ++++ ui/src/audioplayer/LyricsPanel.jsx | 3 +- ui/src/audioplayer/LyricsPanel.test.jsx | 12 ++++ ui/src/audioplayer/LyricsSidebar.jsx | 2 + ui/src/audioplayer/useEnhancedLyrics.js | 61 +++++++++++++++++++- ui/src/audioplayer/useEnhancedLyrics.test.js | 28 +++++++++ ui/src/audioplayer/usePlayerLyrics.jsx | 11 +++- ui/src/i18n/en.json | 1 + ui/src/subsonic/index.js | 15 ++++- ui/src/subsonic/index.test.js | 19 ++++++ 11 files changed, 164 insertions(+), 6 deletions(-) diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index b9fb8cb74..8dd570b78 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -87,6 +87,7 @@ func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions } func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { + var pluginErr error for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) if pattern == "" { @@ -101,6 +102,9 @@ func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles [ lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) if err != nil { log.Error(ctx, "error getting lyrics", "source", pattern, err) + if pattern != "embedded" && !strings.HasPrefix(pattern, ".") { + pluginErr = err + } continue } @@ -110,7 +114,7 @@ func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles [ } } - return nil, nil + return nil, pluginErr } func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index b00bcd576..fbe85914d 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -365,6 +365,18 @@ var _ = Describe("Lyrics", func() { Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded }) + + It("should return a plugin error when no later source has lyrics", func() { + conf.Server.LyricsPriority = "test-lyrics-plugin,embedded" + mf.Lyrics = "" + mockLoader.err = fmt.Errorf("remote lyrics cooldown active for 30s") + svc := lyrics.NewLyrics(nil, mockLoader) + + list, err := svc.GetLyrics(ctx, &mf) + + Expect(list).To(BeEmpty()) + Expect(err).To(MatchError("remote lyrics cooldown active for 30s")) + }) }) var _ = Describe("GetLyricsByArtistTitle", func() { diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx index 9b2a45f57..838645ac2 100644 --- a/ui/src/audioplayer/LyricsPanel.jsx +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -417,6 +417,7 @@ const LyricsPanel = ({ inline = false, loading = false, error = null, + errorMessage = null, labels = {}, }) => { const classes = useStyles() @@ -675,7 +676,7 @@ const LyricsPanel = ({ const message = loading ? labels.loading || 'Loading lyrics' : error - ? labels.unavailable || 'Lyrics unavailable' + ? errorMessage || labels.unavailable || 'Lyrics unavailable' : labels.empty || 'No lyrics available' return (
    ', () => { ) }) + it('explains a temporary lyrics cooldown', () => { + renderPanel({ + mainLyric: null, + error: new Error('rate limited'), + errorMessage: 'Remote lyrics are rate-limited. Retrying in 30 seconds.', + }) + + expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent( + 'Remote lyrics are rate-limited. Retrying in 30 seconds.', + ) + }) + it.each(['light', 'dark'])( 'keeps future tokens readable and translations subordinate in the %s theme', (type) => { diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx index cb4aa5867..ae4995f32 100644 --- a/ui/src/audioplayer/LyricsSidebar.jsx +++ b/ui/src/audioplayer/LyricsSidebar.jsx @@ -102,6 +102,7 @@ const LyricsSidebar = ({ audioInstance, loading = false, error = null, + errorMessage = null, labels = {}, returnFocusRef, }) => { @@ -291,6 +292,7 @@ const LyricsSidebar = ({ audioInstance={audioInstance} loading={loading} error={error} + errorMessage={errorMessage} labels={labels} />
    diff --git a/ui/src/audioplayer/useEnhancedLyrics.js b/ui/src/audioplayer/useEnhancedLyrics.js index 735922583..8577e9280 100644 --- a/ui/src/audioplayer/useEnhancedLyrics.js +++ b/ui/src/audioplayer/useEnhancedLyrics.js @@ -10,6 +10,7 @@ export const emptyLyricLayers = Object.freeze({ const MAX_LYRIC_CACHE_ENTRIES = 75 const NEGATIVE_CACHE_TTL_MS = 30_000 +const COOLDOWN_PATTERN = /(?:cooldown active for|retry after)\s+(\d+)s/gi const cache = new Map() const inFlight = new Map() @@ -23,6 +24,15 @@ const normalizeLyricLayers = (layers) => ({ const readStructuredLyrics = (response) => response?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || [] +const retryDelayFromError = (error) => { + const serverMessage = + error?.body?.['subsonic-response']?.error?.message || error?.message || '' + const matches = [...String(serverMessage).matchAll(COOLDOWN_PATTERN)] + if (matches.length === 0) return null + const seconds = Math.max(...matches.map((match) => Number(match[1]))) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null +} + const buildCacheKey = ({ trackId, preferredLanguage, updatedAt }) => [trackId || '', updatedAt || '', preferredLanguage || ''].join('\u0000') @@ -117,8 +127,10 @@ const useEnhancedLyrics = ({ layers: emptyLyricLayers, loading: false, error: null, + retryAt: null, })) const [requestGeneration, setRequestGeneration] = useState(0) + const [, setCountdownTick] = useState(0) const retry = useCallback(() => { cache.delete(cacheKey) @@ -132,13 +144,20 @@ const useEnhancedLyrics = ({ layers: emptyLyricLayers, loading: false, error: null, + retryAt: null, }) return undefined } const cached = readCachedLyrics(cacheKey) if (cached) { - setState({ cacheKey, layers: cached, loading: false, error: null }) + setState({ + cacheKey, + layers: cached, + loading: false, + error: null, + retryAt: null, + }) return undefined } @@ -153,12 +172,19 @@ const useEnhancedLyrics = ({ layers: emptyLyricLayers, loading: true, error: null, + retryAt: null, }) request.promise .then((layers) => { if (!active) return - setState({ cacheKey, layers, loading: false, error: null }) + setState({ + cacheKey, + layers, + loading: false, + error: null, + retryAt: null, + }) }) .catch((error) => { if (!active || error?.name === 'AbortError') return @@ -168,6 +194,10 @@ const useEnhancedLyrics = ({ layers: emptyLyricLayers, loading: false, error, + retryAt: (() => { + const delay = retryDelayFromError(error) + return delay == null ? null : Date.now() + delay + })(), }) }) @@ -184,11 +214,34 @@ const useEnhancedLyrics = ({ trackId, ]) + useEffect(() => { + if ( + state.cacheKey !== cacheKey || + state.retryAt == null || + disabled || + !requested + ) { + return undefined + } + + const retryDelay = Math.max(0, state.retryAt - Date.now()) + const retryTimer = window.setTimeout(retry, retryDelay) + const countdownTimer = window.setInterval( + () => setCountdownTick((current) => current + 1), + 1_000, + ) + return () => { + window.clearTimeout(retryTimer) + window.clearInterval(countdownTimer) + } + }, [cacheKey, disabled, requested, retry, state.cacheKey, state.retryAt]) + if (state.cacheKey !== cacheKey) { return { layers: emptyLyricLayers, loading: Boolean(trackId && !disabled && requested), error: null, + retryAfterSeconds: null, retry, } } @@ -196,6 +249,10 @@ const useEnhancedLyrics = ({ layers: state.layers, loading: state.loading, error: state.error, + retryAfterSeconds: + state.retryAt == null + ? null + : Math.max(1, Math.ceil((state.retryAt - Date.now()) / 1_000)), retry, } } diff --git a/ui/src/audioplayer/useEnhancedLyrics.test.js b/ui/src/audioplayer/useEnhancedLyrics.test.js index a1e26c43d..9ad835cc2 100644 --- a/ui/src/audioplayer/useEnhancedLyrics.test.js +++ b/ui/src/audioplayer/useEnhancedLyrics.test.js @@ -54,6 +54,7 @@ describe('useEnhancedLyrics', () => { }) afterEach(() => { + vi.useRealTimers() localStorage.clear() clearEnhancedLyricsCache() }) @@ -130,6 +131,33 @@ describe('useEnhancedLyrics', () => { expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) }) + it('reports a cooldown countdown and retries the current song when it expires', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-23T06:00:00Z')) + const cooldownError = new Error('better lyrics API cooldown active for 2s') + subsonic.getLyricsBySongId + .mockRejectedValueOnce(cooldownError) + .mockResolvedValueOnce(responseFor('Recovered after cooldown')) + + const { result } = renderLyrics({ trackId: 'rate-limited-song' }) + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.error).toBe(cooldownError) + expect(result.current.retryAfterSeconds).toBe(2) + + await act(async () => { + vi.advanceTimersByTime(2_000) + await Promise.resolve() + }) + + expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) + expect(result.current.layers.main?.line[0].value).toBe( + 'Recovered after cooldown', + ) + }) + it('keeps cached lyrics separate by preferred language', async () => { subsonic.getLyricsBySongId .mockResolvedValueOnce(responseFor('English lyrics', 'en')) diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx index edbb6ae3d..9c09e8cb2 100644 --- a/ui/src/audioplayer/usePlayerLyrics.jsx +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -27,6 +27,7 @@ const usePlayerLyrics = ({ layers: lyricLayers, loading: lyricsLoading, error: lyricsError, + retryAfterSeconds: lyricsRetryAfterSeconds, retry: retryLyrics, } = useEnhancedLyrics({ trackId, @@ -56,6 +57,12 @@ const usePlayerLyrics = ({ toggle: translate('player.toggleLyricText'), loading: translate('player.lyricsLoadingText'), unavailable: translate('player.lyricsUnavailableText'), + rateLimited: + lyricsRetryAfterSeconds == null + ? null + : translate('player.lyricsRateLimitedText', { + smart_count: lyricsRetryAfterSeconds, + }), empty: translate('player.emptyLyricText'), resize: translate('player.resizeLyricsSidebarText'), showTranslation: translate('player.showLyricsTranslationText'), @@ -63,7 +70,7 @@ const usePlayerLyrics = ({ showPronunciation: translate('player.showLyricsPronunciationText'), hidePronunciation: translate('player.hideLyricsPronunciationText'), }), - [translate], + [lyricsRetryAfterSeconds, translate], ) const toggleLyrics = useCallback(() => { @@ -118,6 +125,7 @@ const usePlayerLyrics = ({ audioInstance, loading: lyricsLoading, error: lyricsError, + errorMessage: labels.rateLimited, labels, obscuredByQueue, returnFocusRef: lyricsToggleRef, @@ -160,6 +168,7 @@ const usePlayerLyrics = ({ audioInstance={audioInstance} loading={lyricsLoading} error={lyricsError} + errorMessage={labels.rateLimited} labels={labels} inline /> diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 119347441..46c4a82aa 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -658,6 +658,7 @@ "lyricsTitleText": "Lyrics", "lyricsLoadingText": "Loading lyrics", "lyricsUnavailableText": "Lyrics unavailable", + "lyricsRateLimitedText": "Remote lyrics are rate-limited. Retrying in %{smart_count} second. |||| Remote lyrics are rate-limited. Retrying in %{smart_count} seconds.", "resizeLyricsSidebarText": "Resize lyrics sidebar", "showLyricsTranslationText": "Show translation", "hideLyricsTranslationText": "Hide translation", diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 63a56f647..8fb0be528 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -131,11 +131,24 @@ const getTopSongs = (artist, count = 50) => { return httpClient(url('getTopSongs', null, { artist, count })) } +const requireSuccessfulLyricsResponse = (response) => { + const subsonicResponse = response?.json?.['subsonic-response'] + if (subsonicResponse?.status !== 'failed') return response + + const error = new Error( + subsonicResponse.error?.message || 'Lyrics request failed', + ) + error.body = response.json + error.code = subsonicResponse.error?.code + throw error +} + const getLyricsBySongId = (id, requestOptions) => { const requestUrl = url('getLyricsBySongId', id, { enhanced: true }) - return requestOptions + const request = requestOptions ? httpClient(requestUrl, requestOptions) : httpClient(requestUrl) + return request.then(requireSuccessfulLyricsResponse) } const streamUrl = (id, options) => { diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 4e163b5e2..d50e686b8 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -207,6 +207,25 @@ describe('getLyricsBySongId', () => { expect(requestedUrl).toContain('id=song-123') expect(requestedUrl).toContain('enhanced=true') }) + + it('rejects a failed Subsonic lyrics response with its server message', async () => { + httpClient.mockResolvedValueOnce({ + json: { + 'subsonic-response': { + status: 'failed', + error: { + code: 0, + message: + 'Internal Server Error: better lyrics API cooldown active for 30s', + }, + }, + }, + }) + + await expect(subsonic.getLyricsBySongId('song-123')).rejects.toThrow( + 'better lyrics API cooldown active for 30s', + ) + }) }) describe('getDiscCoverArtUrl', () => { From 5fafda1544332dee80c21af740041c2c74fd589c Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 26 Aug 2026 12:04:02 +0300 Subject: [PATCH 13/16] fix(ui): match ISO 639 lyric languages --- ui/src/audioplayer/lyrics.js | 12 ++++++++++-- ui/src/audioplayer/lyrics.test.js | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js index ec93cc6f0..05193d271 100644 --- a/ui/src/audioplayer/lyrics.js +++ b/ui/src/audioplayer/lyrics.js @@ -1,5 +1,13 @@ -const normalizeLanguageTag = (language) => - (language || '').toLowerCase().replace(/_/g, '-') +const normalizeLanguageTag = (language) => { + const normalized = (language || '').trim().replace(/_/g, '-') + if (!normalized) return '' + + try { + return Intl.getCanonicalLocales(normalized)[0].toLowerCase() + } catch { + return normalized.toLowerCase() + } +} const LYRIC_KIND_MAIN = 'main' const LYRIC_KIND_TRANSLATION = 'translation' diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js index c5ef6d736..208d82ac6 100644 --- a/ui/src/audioplayer/lyrics.test.js +++ b/ui/src/audioplayer/lyrics.test.js @@ -101,6 +101,21 @@ describe('lyrics helpers', () => { expect(layers.main.line[0].value).toBe('繁體') }) + it('matches ISO 639-2 lyric languages to regional browser locales', () => { + const lyrics = [ + structured('main', 'spa', 'Español'), + structured('main', 'eng', 'English'), + structured('main', 'por', 'Português'), + ] + + expect(selectLyricLayers(lyrics, 'en-US').main.line[0].value).toBe( + 'English', + ) + expect(selectLyricLayers(lyrics, 'pt-BR').main.line[0].value).toBe( + 'Português', + ) + }) + it('resolves UTF-8 byte ranges without confusing repeated words', () => { const text = 'caf\u00e9 caf\u00e9' From e90d94ff06795a9df8e43e28091f0d14195a5c62 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 23 Aug 2026 05:41:50 +0300 Subject: [PATCH 14/16] feat(lyrics): expose source provenance Signed-off-by: ranokay --- core/lyrics/lyrics_test.go | 63 ++++++++++++++----- core/lyrics/sources.go | 45 ++++++++++++- core/lyrics/sources_test.go | 11 ++++ model/lyrics.go | 34 +++++++--- plugins/capabilities/lyrics.go | 12 +++- plugins/capabilities/lyrics.yaml | 14 ++++- plugins/lyrics_adapter.go | 21 ++++++- plugins/lyrics_adapter_test.go | 28 +++++++++ plugins/pdk/go/lyrics/lyrics.go | 12 +++- plugins/pdk/go/lyrics/lyrics_stub.go | 12 +++- .../rust/nd-pdk-capabilities/src/lyrics.rs | 14 ++++- plugins/testdata/test-lyrics/main.go | 9 +++ server/subsonic/lyrics.go | 8 +++ server/subsonic/lyrics_test.go | 9 +++ server/subsonic/responses/responses.go | 26 +++++--- 15 files changed, 271 insertions(+), 47 deletions(-) diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index fbe85914d..e76cc5469 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -21,6 +21,17 @@ var _ = Describe("Lyrics", func() { var mf model.MediaFile var ctx context.Context + expectLyricsWithSource := func(actual, expected model.LyricList, source model.LyricsSource) { + GinkgoHelper() + withoutSource := make(model.LyricList, len(actual)) + copy(withoutSource, actual) + for i := range withoutSource { + Expect(withoutSource[i].Source).To(Equal(&source)) + withoutSource[i].Source = nil + } + Expect(withoutSource).To(Equal(expected)) + } + embeddedLyrics := model.LyricList{ model.Lyrics{ Lang: "xxx", @@ -162,19 +173,19 @@ var _ = Describe("Lyrics", func() { ctx = GinkgoT().Context() }) - DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { + DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList, source model.LyricsSource) { conf.Server.LyricsPriority = priority svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(expected)) + expectLyricsWithSource(list, expected, source) }, - Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics), - Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics), - Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics), - Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics), - Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics), - Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics)) + Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics, model.LyricsSource{Type: model.LyricsSourceEmbedded}), + Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "lrc"}), + Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "elrc"}), + Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "srt"}), + Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "txt"}), + Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "ttml"})) It("resolves source priority across duplicate media files", func() { conf.Server.LyricsPriority = ".ttml,embedded" @@ -196,7 +207,10 @@ var _ = Describe("Lyrics", func() { list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") Expect(err).To(BeNil()) - Expect(list).To(Equal(ttmlLyrics)) + expectLyricsWithSource(list, ttmlLyrics, model.LyricsSource{ + Type: model.LyricsSourceSidecar, + Format: "ttml", + }) }) It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() { @@ -289,7 +303,9 @@ var _ = Describe("Lyrics", func() { svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) + expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{ + Type: model.LyricsSourceEmbedded, + }) }) It("should return nothing if error happens when trying to parse file", func() { @@ -316,7 +332,10 @@ var _ = Describe("Lyrics", func() { svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(unsyncedLyrics)) + expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "test-lyrics-plugin", + }) }) It("should try plugin after embedded returns nothing", func() { @@ -326,7 +345,10 @@ var _ = Describe("Lyrics", func() { svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(unsyncedLyrics)) + expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "test-lyrics-plugin", + }) }) It("should skip plugin if embedded has lyrics", func() { @@ -335,7 +357,9 @@ var _ = Describe("Lyrics", func() { svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) // embedded wins + expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{ + Type: model.LyricsSourceEmbedded, + }) }) It("should skip unknown plugin names gracefully", func() { @@ -344,7 +368,9 @@ var _ = Describe("Lyrics", func() { svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded + expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{ + Type: model.LyricsSourceEmbedded, + }) }) It("should preserve plugin name case from config", func() { @@ -354,7 +380,10 @@ var _ = Describe("Lyrics", func() { svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(unsyncedLyrics)) + expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "MyLyricsPlugin", + }) }) It("should handle plugin error gracefully", func() { @@ -363,7 +392,9 @@ var _ = Describe("Lyrics", func() { svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded + expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{ + Type: model.LyricsSourceEmbedded, + }) }) It("should return a plugin error when no later source has lyrics", func() { diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 23c20122d..23535cbf5 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "path" + "strings" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" @@ -17,7 +18,8 @@ import ( func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { if mf.Lyrics != "" { log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title) - return mf.StructuredLyrics() + list, err := mf.StructuredLyrics() + return withLyricsSource(list, model.LyricsSource{Type: model.LyricsSourceEmbedded}), err } log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title) @@ -65,7 +67,10 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( } log.Trace(ctx, "retrieved lyrics from external file") - return list, nil + return withLyricsSource(list, model.LyricsSource{ + Type: model.LyricsSourceSidecar, + Format: strings.ToLower(strings.TrimPrefix(suffix, ".")), + }), nil } // fromPlugin attempts to load lyrics from a plugin with the given name. @@ -89,5 +94,39 @@ func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, plu if len(lyricsList) > 0 { log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList)) } - return lyricsList, nil + return withLyricsSource(lyricsList, model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: pluginName, + }), nil +} + +// withLyricsSource copies the lyric entries before adding defaults so resolver +// metadata does not mutate cached embedded lyrics or a provider-owned result. +func withLyricsSource(list model.LyricList, defaults model.LyricsSource) model.LyricList { + if len(list) == 0 { + return list + } + + result := make(model.LyricList, len(list)) + copy(result, list) + for i := range result { + source := defaults + if result[i].Source != nil { + source = *result[i].Source + if source.Type == "" { + source.Type = defaults.Type + } + if source.Name == "" { + source.Name = defaults.Name + } + if source.Provider == "" { + source.Provider = defaults.Provider + } + if source.Format == "" { + source.Format = defaults.Format + } + } + result[i].Source = &source + } + return result } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 7c7922bfd..de128d8b5 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -47,6 +47,13 @@ var _ = Describe("sources", func() { lyrics, err := fromEmbedded(ctx, &mf) Expect(err).To(BeNil()) Expect(lyrics).ToNot(BeNil()) + Expect(lyrics).To(HaveLen(len(expectedList))) + for i := range lyrics { + Expect(lyrics[i].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourceEmbedded, + })) + lyrics[i].Source = nil + } Expect(lyrics).To(Equal(expectedList)) }) @@ -91,6 +98,10 @@ var _ = Describe("sources", func() { Expect(lyrics).ToNot(BeEmpty()) Expect(lyrics[0].Line).ToNot(BeEmpty()) Expect(lyrics[0].Synced).To(Equal(expectSynced)) + Expect(lyrics[0].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourceSidecar, + Format: suffix[1:], + })) }, Entry(".lrc synced", "test.mp3", ".lrc", true), Entry(".elrc enhanced", "test.mp3", ".elrc", true), diff --git a/model/lyrics.go b/model/lyrics.go index 111b1c2a9..24c810059 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -20,6 +20,23 @@ type Agent struct { Name string `structs:"name,omitempty" json:"name,omitempty"` } +type LyricsSourceType string + +const ( + LyricsSourceEmbedded LyricsSourceType = "embedded" + LyricsSourceSidecar LyricsSourceType = "sidecar" + LyricsSourcePlugin LyricsSourceType = "plugin" +) + +// LyricsSource describes where Navidrome resolved a lyric. Name identifies a +// plugin, while Provider identifies an optional upstream used by that plugin. +type LyricsSource struct { + Type LyricsSourceType `structs:"type" json:"type"` + Name string `structs:"name,omitempty" json:"name,omitempty"` + Provider string `structs:"provider,omitempty" json:"provider,omitempty"` + Format string `structs:"format,omitempty" json:"format,omitempty"` +} + type Line struct { Start *int64 `structs:"start,omitempty" json:"start,omitempty"` End *int64 `structs:"end,omitempty" json:"end,omitempty"` @@ -28,14 +45,15 @@ type Line struct { } type Lyrics struct { - DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` - Kind string `structs:"kind,omitempty" json:"kind,omitempty"` - Lang string `structs:"lang" json:"lang"` - Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` - Line []Line `structs:"line" json:"line"` - Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` - Synced bool `structs:"synced" json:"synced"` + DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` + Kind string `structs:"kind,omitempty" json:"kind,omitempty"` + Lang string `structs:"lang" json:"lang"` + Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` + Line []Line `structs:"line" json:"line"` + Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` + Source *LyricsSource `structs:"source,omitempty" json:"source,omitempty"` + Synced bool `structs:"synced" json:"synced"` } // Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go index 6f6d19177..8352c4fc3 100644 --- a/plugins/capabilities/lyrics.go +++ b/plugins/capabilities/lyrics.go @@ -15,12 +15,20 @@ type GetLyricsRequest struct { // GetLyricsResponse contains the lyrics returned by the plugin. type GetLyricsResponse struct { - Lyrics []LyricsText `json:"lyrics"` + Lyrics []LyricsText `json:"lyrics"` + Source *LyricsSource `json:"source,omitempty"` } // LyricsText represents a single set of lyrics in raw text format. -// Text can be plain text or LRC format — Navidrome will parse it. +// Navidrome content-sniffs and parses the returned text. type LyricsText struct { Lang string `json:"lang,omitempty"` Text string `json:"text"` } + +// LyricsSource identifies the optional upstream provider and format selected +// by a plugin. Navidrome supplies the plugin name and source type itself. +type LyricsSource struct { + Provider string `json:"provider,omitempty"` + Format string `json:"format,omitempty"` +} diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 7336124fc..1b8d9da97 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -23,12 +23,24 @@ components: type: array items: $ref: '#/components/schemas/LyricsText' + source: + $ref: '#/components/schemas/LyricsSource' + nullable: true required: - lyrics + LyricsSource: + description: |- + LyricsSource identifies the optional upstream provider and format selected + by a plugin. Navidrome supplies the plugin name and source type itself. + properties: + provider: + type: string + format: + type: string LyricsText: description: |- LyricsText represents a single set of lyrics in raw text format. - Text can be plain text or LRC format — Navidrome will parse it. + Navidrome content-sniffs and parses the returned text. properties: lang: type: string diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 9e02115e7..f5578bcf3 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "strings" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -26,13 +27,18 @@ func init() { } func newLyricsPlugin(p *plugin) *LyricsPlugin { - return &LyricsPlugin{name: p.name, plugin: p} + displayName := p.name + if p.manifest != nil && strings.TrimSpace(p.manifest.Name) != "" { + displayName = strings.TrimSpace(p.manifest.Name) + } + return &LyricsPlugin{name: p.name, displayName: displayName, plugin: p} } // LyricsPlugin adapts a WASM plugin with the Lyrics capability. type LyricsPlugin struct { - name string - plugin *plugin + name string + displayName string + plugin *plugin } // GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response @@ -57,6 +63,14 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode // The lyric text comes from the plugin, not the media file's own tags, so // attribute logs to both the plugin and the track it was fetched for. ctx = log.NewContext(ctx, "plugin", l.name, "file", mf.Path) + source := &model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: l.displayName, + } + if resp.Source != nil { + source.Provider = strings.TrimSpace(resp.Source.Provider) + source.Format = strings.ToLower(strings.TrimSpace(resp.Source.Format)) + } var result model.LyricList for _, lt := range resp.Lyrics { @@ -71,6 +85,7 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode } for _, lyric := range parsed { if !lyric.IsEmpty() { + lyric.Source = source result = append(result, lyric) } } diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index d110665f5..b77480c73 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -56,6 +56,10 @@ var _ = Describe("LyricsPlugin", Ordered, func() { Expect(result).To(HaveLen(1)) Expect(result[0].Line).ToNot(BeEmpty()) Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song")) + Expect(result[0].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "Test Lyrics", + })) }) It("defaults language to 'xxx' when plugin does not provide one", func() { @@ -73,6 +77,30 @@ var _ = Describe("LyricsPlugin", Ordered, func() { Expect(result[0].Lang).To(Equal("xxx")) }) + It("preserves optional provider provenance from the plugin", func() { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": { + "source_provider": "unison", + "source_format": "ttml", + }, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + result, err := p.GetLyrics(GinkgoT().Context(), &model.MediaFile{ + ID: "track-1", Title: "Test Song", Artist: "Test Artist", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "Test Lyrics", + Provider: "unison", + Format: "ttml", + })) + }) + It("blocks new calls while the per-plugin concurrency cap is saturated", func() { sem := provider.plugin.lyricsSem for range cap(sem) { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index b3c7f29ac..cd43a07df 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -22,11 +22,19 @@ type GetLyricsRequest struct { // GetLyricsResponse contains the lyrics returned by the plugin. type GetLyricsResponse struct { - Lyrics []LyricsText `json:"lyrics"` + Lyrics []LyricsText `json:"lyrics"` + Source *LyricsSource `json:"source,omitempty"` +} + +// LyricsSource identifies the optional upstream provider and format selected +// by a plugin. Navidrome supplies the plugin name and source type itself. +type LyricsSource struct { + Provider string `json:"provider,omitempty"` + Format string `json:"format,omitempty"` } // LyricsText represents a single set of lyrics in raw text format. -// Text can be plain text or LRC format — Navidrome will parse it. +// Navidrome content-sniffs and parses the returned text. type LyricsText struct { Lang string `json:"lang,omitempty"` Text string `json:"text"` diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 76c54d2e5..59eb15852 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -20,11 +20,19 @@ type GetLyricsRequest struct { // GetLyricsResponse contains the lyrics returned by the plugin. type GetLyricsResponse struct { - Lyrics []LyricsText `json:"lyrics"` + Lyrics []LyricsText `json:"lyrics"` + Source *LyricsSource `json:"source,omitempty"` +} + +// LyricsSource identifies the optional upstream provider and format selected +// by a plugin. Navidrome supplies the plugin name and source type itself. +type LyricsSource struct { + Provider string `json:"provider,omitempty"` + Format string `json:"format,omitempty"` } // LyricsText represents a single set of lyrics in raw text format. -// Text can be plain text or LRC format — Navidrome will parse it. +// Navidrome content-sniffs and parses the returned text. type LyricsText struct { Lang string `json:"lang,omitempty"` Text string `json:"text"` diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index a8449a5ec..15438d60e 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -34,9 +34,21 @@ pub struct GetLyricsRequest { pub struct GetLyricsResponse { #[serde(default)] pub lyrics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} +/// LyricsSource identifies the optional upstream provider and format selected +/// by a plugin. Navidrome supplies the plugin name and source type itself. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LyricsSource { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub provider: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub format: String, } /// LyricsText represents a single set of lyrics in raw text format. -/// Text can be plain text or LRC format — Navidrome will parse it. +/// Navidrome content-sniffs and parses the returned text. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LyricsText { diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 2ee2dabbf..10cc06a55 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -20,6 +20,13 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) } + var source *lyrics.LyricsSource + provider, hasProvider := pdk.GetConfig("source_provider") + sourceFormat, hasSourceFormat := pdk.GetConfig("source_format") + if hasProvider || hasSourceFormat { + source = &lyrics.LyricsSource{Provider: provider, Format: sourceFormat} + } + // Config-selected format lets tests exercise the adapter's content-sniffing per format. format, hasFormat := pdk.GetConfig("format") if hasFormat { @@ -52,6 +59,7 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR if text != "" { return lyrics.GetLyricsResponse{ Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}}, + Source: source, }, nil } } @@ -65,6 +73,7 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR // Return test lyrics based on track info return lyrics.GetLyricsResponse{ + Source: source, Lyrics: []lyrics.LyricsText{ { Lang: lang, diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go index bfe1f1899..ce437095e 100644 --- a/server/subsonic/lyrics.go +++ b/server/subsonic/lyrics.go @@ -56,6 +56,14 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo if enhanced { structured.Kind = lyrics.EffectiveKind() + if lyrics.Source != nil && lyrics.Source.Type != "" { + structured.Source = &responses.LyricsSource{ + Type: string(lyrics.Source.Type), + Name: lyrics.Source.Name, + Provider: lyrics.Source.Provider, + Format: lyrics.Source.Format, + } + } if len(cueLines) > 0 && len(agents.response) > 0 { structured.Agents = agents.response } diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index 8713b7a3b..8d97aa5f3 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -218,6 +218,9 @@ var _ = Describe("GetLyricsBySongId", func() { response, err := router.GetLyricsBySongId(r) Expect(err).ToNot(HaveOccurred()) + for _, lyric := range response.LyricsList.StructuredLyrics { + Expect(lyric.Source).To(BeNil()) + } porTime := int64(18800) ttmlTime := int64(22800) @@ -274,6 +277,12 @@ var _ = Describe("GetLyricsBySongId", func() { response, err := router.GetLyricsBySongId(r) Expect(err).ToNot(HaveOccurred()) + for _, lyric := range response.LyricsList.StructuredLyrics { + Expect(lyric.Source).To(Equal(&responses.LyricsSource{ + Type: "sidecar", + Format: "ttml", + })) + } mainStartA := int64(1000) mainStartB := int64(2000) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 252eee4c6..d7004505d 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -572,16 +572,24 @@ type CueLine struct { Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"` } +type LyricsSource struct { + Type string `xml:"type,attr" json:"type"` + Name string `xml:"name,attr,omitempty" json:"name,omitempty"` + Provider string `xml:"provider,attr,omitempty" json:"provider,omitempty"` + Format string `xml:"format,attr,omitempty" json:"format,omitempty"` +} + type StructuredLyric struct { - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` - Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` - Lang string `xml:"lang,attr" json:"lang"` - Line []Line `xml:"line" json:"line"` - Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` - CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` - Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` - Synced bool `xml:"synced,attr" json:"synced"` + DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` + Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` + Lang string `xml:"lang,attr" json:"lang"` + Line []Line `xml:"line" json:"line"` + Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` + Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Source *LyricsSource `xml:"source,omitempty" json:"source,omitempty"` + Synced bool `xml:"synced,attr" json:"synced"` } type StructuredLyrics []StructuredLyric From 6017eaa00478b92bf00231f6c54852491b517009 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 23 Aug 2026 05:42:04 +0300 Subject: [PATCH 15/16] feat(ui): show lyrics source details Signed-off-by: ranokay --- ui/src/audioplayer/LyricsLayerControls.jsx | 149 +++++++++++++++++- .../audioplayer/LyricsLayerControls.test.jsx | 89 +++++++++++ ui/src/audioplayer/LyricsSidebar.jsx | 2 + ui/src/audioplayer/useEnhancedLyrics.test.js | 20 ++- ui/src/audioplayer/usePlayerLyrics.jsx | 16 ++ ui/src/i18n/en.json | 7 + 6 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 ui/src/audioplayer/LyricsLayerControls.test.jsx diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx index 62c43f37a..bcb396e44 100644 --- a/ui/src/audioplayer/LyricsLayerControls.jsx +++ b/ui/src/audioplayer/LyricsLayerControls.jsx @@ -1,10 +1,47 @@ import IconButton from '@material-ui/core/IconButton' +import Popover from '@material-ui/core/Popover' import Tooltip from '@material-ui/core/Tooltip' +import Typography from '@material-ui/core/Typography' import { alpha, makeStyles } from '@material-ui/core/styles' +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver' import TranslateIcon from '@material-ui/icons/Translate' import clsx from 'clsx' -import React from 'react' +import React, { useEffect, useState } from 'react' + +const UPPERCASE_SOURCE_VALUES = new Set([ + 'elrc', + 'lrc', + 'srt', + 'ttml', + 'txt', + 'yaml', +]) + +const formatSourceValue = (value) => { + const normalized = typeof value === 'string' ? value.trim() : '' + if (!normalized) return '' + if (UPPERCASE_SOURCE_VALUES.has(normalized.toLowerCase())) { + return normalized.toUpperCase() + } + if (normalized.toLowerCase() === 'plain') return 'Plain text' + return normalized + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()) +} + +const getSourceName = (source, labels) => { + switch (source?.type) { + case 'embedded': + return labels.embeddedSource || 'Embedded lyrics' + case 'sidecar': + return labels.sidecarSource || 'Sidecar file' + case 'plugin': + return source.name || labels.pluginSource || 'Lyrics plugin' + default: + return source?.name || formatSourceValue(source?.type) || 'Lyrics' + } +} const useStyles = makeStyles( (theme) => ({ @@ -66,6 +103,36 @@ const useStyles = makeStyles( }, }, controlActive: {}, + sourcePopover: { + minWidth: 200, + maxWidth: 280, + padding: theme.spacing(1.5, 2), + }, + sourceHeading: { + display: 'block', + color: alpha(theme.palette.text.primary, 0.58), + lineHeight: 1.4, + }, + sourceName: { + marginTop: theme.spacing(0.25), + color: theme.palette.text.primary, + overflowWrap: 'anywhere', + }, + sourceDetails: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr)', + columnGap: theme.spacing(1.5), + rowGap: theme.spacing(0.5), + marginTop: theme.spacing(1), + }, + sourceDetailLabel: { + color: alpha(theme.palette.text.primary, 0.58), + }, + sourceDetailValue: { + color: theme.palette.text.primary, + textAlign: 'right', + overflowWrap: 'anywhere', + }, }), { name: 'NDLyricsLayerControls' }, ) @@ -108,10 +175,28 @@ const LyricsLayerControls = ({ pronunciationEnabled, onToggleTranslation, onTogglePronunciation, + source, labels = {}, testId = 'lyrics-layer-controls', }) => { const classes = useStyles() + const [sourceAnchor, setSourceAnchor] = useState(null) + const sourcePopoverId = `${testId}-source-popover` + const sourceOpen = Boolean(source && sourceAnchor) + const sourceDetails = [ + { + label: labels.provider || 'Provider', + value: formatSourceValue(source?.provider), + }, + { + label: labels.format || 'Format', + value: formatSourceValue(source?.format), + }, + ].filter((detail) => detail.value) + + useEffect(() => { + if (!source) setSourceAnchor(null) + }, [source]) return (
    event.stopPropagation()} > + {source && ( + <> + + setSourceAnchor(event.currentTarget)} + aria-label={labels.viewSource || 'View lyrics source'} + aria-haspopup="dialog" + aria-expanded={sourceOpen} + aria-controls={sourceOpen ? sourcePopoverId : undefined} + data-testid="lyrics-source-button" + className={clsx(classes.controlButton, { + [classes.controlActive]: sourceOpen, + })} + > + + + + setSourceAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'right' }} + PaperProps={{ className: classes.sourcePopover }} + > +
    event.stopPropagation()} + > + + {labels.sourceTitle || 'Lyrics source'} + + + {getSourceName(source, labels)} + + {sourceDetails.length > 0 && ( +
    + {sourceDetails.map((detail) => ( + + + {detail.label} + + + {detail.value} + + + ))} +
    + )} +
    +
    + + )} + render( + {}} + onTogglePronunciation={() => {}} + labels={labels} + source={source} + />, + ) + +describe(' source details', () => { + afterEach(cleanup) + + it('omits the source control when provenance is unavailable', () => { + renderControls(null) + + expect( + screen.queryByRole('button', { name: 'View lyrics source' }), + ).not.toBeInTheDocument() + }) + + it.each([ + [{ type: 'embedded' }, 'Embedded lyrics', null], + [{ type: 'sidecar', format: 'lrc' }, 'Sidecar file', 'LRC'], + ])('shows built-in source details', (source, name, format) => { + renderControls(source) + + fireEvent.click(screen.getByRole('button', { name: 'View lyrics source' })) + + expect(screen.getByText(name)).toBeVisible() + if (format) expect(screen.getByText(format)).toBeVisible() + }) + + it('shows plugin, provider, and format without exposing extra metadata', async () => { + renderControls({ + type: 'plugin', + name: 'Better Lyrics', + provider: 'unison', + format: 'ttml', + path: '/music/private/song.ttml', + }) + + const button = screen.getByRole('button', { name: 'View lyrics source' }) + fireEvent.click(button) + + expect(button).toHaveAttribute('aria-expanded', 'true') + const dialog = screen.getByRole('dialog', { name: 'Lyrics source' }) + expect(dialog).toBeVisible() + expect(screen.getByText('Better Lyrics')).toBeVisible() + expect(screen.getByText('Unison')).toBeVisible() + expect(screen.getByText('TTML')).toBeVisible() + expect( + screen.queryByText('/music/private/song.ttml'), + ).not.toBeInTheDocument() + + fireEvent.keyDown(dialog.closest('[role="presentation"]'), { + key: 'Escape', + code: 'Escape', + }) + await waitFor(() => + expect(button).toHaveAttribute('aria-expanded', 'false'), + ) + }) +}) diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx index ae4995f32..42a54029b 100644 --- a/ui/src/audioplayer/LyricsSidebar.jsx +++ b/ui/src/audioplayer/LyricsSidebar.jsx @@ -97,6 +97,7 @@ const LyricsSidebar = ({ showPronunciation, translationEnabled, pronunciationEnabled, + source, onToggleTranslation, onTogglePronunciation, audioInstance, @@ -278,6 +279,7 @@ const LyricsSidebar = ({ translationEnabled={translationEnabled} onTogglePronunciation={onTogglePronunciation} onToggleTranslation={onToggleTranslation} + source={source} labels={labels} testId="lyrics-sidebar-floating-controls" /> diff --git a/ui/src/audioplayer/useEnhancedLyrics.test.js b/ui/src/audioplayer/useEnhancedLyrics.test.js index 9ad835cc2..9e871c873 100644 --- a/ui/src/audioplayer/useEnhancedLyrics.test.js +++ b/ui/src/audioplayer/useEnhancedLyrics.test.js @@ -13,7 +13,7 @@ vi.mock('../subsonic', () => ({ }, })) -const responseFor = (value, lang = 'en') => ({ +const responseFor = (value, lang = 'en', source) => ({ json: { 'subsonic-response': { lyricsList: { @@ -23,6 +23,7 @@ const responseFor = (value, lang = 'en') => ({ lang, synced: true, line: [{ start: 0, value }], + ...(source ? { source } : {}), }, ], }, @@ -80,6 +81,23 @@ describe('useEnhancedLyrics', () => { expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2) }) + it('preserves source provenance on the selected lyric layer', async () => { + const source = { + type: 'plugin', + name: 'Better Lyrics', + provider: 'kugou', + format: 'lrc', + } + subsonic.getLyricsBySongId.mockResolvedValueOnce( + responseFor('Sourced lyrics', 'en', source), + ) + + const { result } = renderLyrics({ trackId: 'song-source' }) + + await expectLyric(result, 'Sourced lyrics') + expect(result.current.layers.main.source).toEqual(source) + }) + it('clears previous lyrics while loading an uncached track', async () => { const nextRequest = createDeferred() subsonic.getLyricsBySongId diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx index 9c09e8cb2..32669facf 100644 --- a/ui/src/audioplayer/usePlayerLyrics.jsx +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -40,6 +40,11 @@ const usePlayerLyrics = ({ const hasPronunciationLyric = hasStructuredLyricContent( lyricLayers.pronunciation, ) + const lyricsSource = + lyricLayers.main?.source || + lyricLayers.translation?.source || + lyricLayers.pronunciation?.source || + null const { lyricsVisible, showTranslation, showPronunciation } = resolveLyricsSidebarState({ lyricsVisiblePreference, @@ -69,6 +74,13 @@ const usePlayerLyrics = ({ hideTranslation: translate('player.hideLyricsTranslationText'), showPronunciation: translate('player.showLyricsPronunciationText'), hidePronunciation: translate('player.hideLyricsPronunciationText'), + viewSource: translate('player.viewLyricsSourceText'), + sourceTitle: translate('player.lyricsSourceText'), + embeddedSource: translate('player.embeddedLyricsSourceText'), + sidecarSource: translate('player.sidecarLyricsSourceText'), + pluginSource: translate('player.pluginLyricsSourceText'), + provider: translate('player.lyricsProviderText'), + format: translate('player.lyricsFormatText'), }), [lyricsRetryAfterSeconds, translate], ) @@ -120,6 +132,7 @@ const usePlayerLyrics = ({ showPronunciation, translationEnabled: hasTranslationLyric, pronunciationEnabled: hasPronunciationLyric, + source: lyricsSource, onToggleTranslation: toggleTranslation, onTogglePronunciation: togglePronunciation, audioInstance, @@ -141,6 +154,7 @@ const usePlayerLyrics = ({ lyricLayers.translation, lyricsError, lyricsLoading, + lyricsSource, lyricsVisible, obscuredByQueue, showPronunciation, @@ -180,6 +194,7 @@ const usePlayerLyrics = ({ pronunciationEnabled={hasPronunciationLyric} onToggleTranslation={toggleTranslation} onTogglePronunciation={togglePronunciation} + source={lyricsSource} labels={labels} testId="lyrics-mobile-layer-controls" /> @@ -194,6 +209,7 @@ const usePlayerLyrics = ({ lyricLayers.translation, lyricsError, lyricsLoading, + lyricsSource, labels, obscuredByQueue, showPronunciation, diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 46c4a82aa..01ae30d1a 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -664,6 +664,13 @@ "hideLyricsTranslationText": "Hide translation", "showLyricsPronunciationText": "Show pronunciation", "hideLyricsPronunciationText": "Hide pronunciation", + "viewLyricsSourceText": "View lyrics source", + "lyricsSourceText": "Lyrics source", + "embeddedLyricsSourceText": "Embedded lyrics", + "sidecarLyricsSourceText": "Sidecar file", + "pluginLyricsSourceText": "Lyrics plugin", + "lyricsProviderText": "Provider", + "lyricsFormatText": "Format", "toggleMiniModeText": "Minimize", "destroyText": "Destroy", "downloadText": "Download", From aa89f0c9327cfecba4e537b38d26d512a2d2f296 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 23 Aug 2026 05:53:39 +0300 Subject: [PATCH 16/16] fix(ui): close source popover with lyrics surface Signed-off-by: ranokay --- ui/src/audioplayer/LyricsLayerControls.jsx | 7 ++-- ui/src/audioplayer/LyricsSidebar.jsx | 1 + ui/src/audioplayer/LyricsSidebar.test.jsx | 21 ++++++++++++ .../MobileKaraokeLyricsPortal.test.jsx | 33 +++++++++++++++++++ ui/src/audioplayer/usePlayerLyrics.jsx | 1 + 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx index bcb396e44..449a704d9 100644 --- a/ui/src/audioplayer/LyricsLayerControls.jsx +++ b/ui/src/audioplayer/LyricsLayerControls.jsx @@ -176,13 +176,14 @@ const LyricsLayerControls = ({ onToggleTranslation, onTogglePronunciation, source, + active = true, labels = {}, testId = 'lyrics-layer-controls', }) => { const classes = useStyles() const [sourceAnchor, setSourceAnchor] = useState(null) const sourcePopoverId = `${testId}-source-popover` - const sourceOpen = Boolean(source && sourceAnchor) + const sourceOpen = Boolean(active && source && sourceAnchor) const sourceDetails = [ { label: labels.provider || 'Provider', @@ -195,8 +196,8 @@ const LyricsLayerControls = ({ ].filter((detail) => detail.value) useEffect(() => { - if (!source) setSourceAnchor(null) - }, [source]) + if (!active || !source) setSourceAnchor(null) + }, [active, source]) return (
    diff --git a/ui/src/audioplayer/LyricsSidebar.test.jsx b/ui/src/audioplayer/LyricsSidebar.test.jsx index b5023ca4a..0bafd3bcc 100644 --- a/ui/src/audioplayer/LyricsSidebar.test.jsx +++ b/ui/src/audioplayer/LyricsSidebar.test.jsx @@ -31,6 +31,12 @@ const lyric = { synced: true, line: [{ start: 0, end: 1000, value: 'Main line' }], } +const source = { + type: 'plugin', + name: 'Better Lyrics', + provider: 'ttml', + format: 'ttml', +} const originalPointerEvent = window.PointerEvent const ControlledLyricsSidebar = ({ @@ -184,6 +190,21 @@ describe('', () => { expect(sidebar).not.toHaveAttribute('inert') }) + it('closes the source popover when the queue obscures the sidebar', async () => { + const { rerender } = renderSidebar({ source }) + + fireEvent.click(screen.getByRole('button', { name: 'View lyrics source' })) + expect(screen.getByRole('dialog', { name: 'Lyrics source' })).toBeVisible() + + rerender(sidebarView({ source, obscuredByQueue: true })) + + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: 'Lyrics source' }), + ).not.toBeInTheDocument(), + ) + }) + it('clamps controlled keyboard resizing to the available width', () => { const onWidthChange = vi.fn() renderSidebar({ initialWidth: 999, maxWidth: 435, onWidthChange }) diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx index 2676db7f5..eb2748684 100644 --- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx @@ -38,6 +38,22 @@ const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => { } }) +const sourcedLyricsResponse = { + ...defaultLyricsResponse, + layers: { + ...defaultLyricsResponse.layers, + main: { + ...defaultLyricsResponse.layers.main, + source: { + type: 'plugin', + name: 'Better Lyrics', + provider: 'ttml', + format: 'ttml', + }, + }, + }, +} + vi.mock('./useEnhancedLyrics', () => ({ default: useEnhancedLyricsMock, })) @@ -198,6 +214,23 @@ describe('', () => { expect(layer).toHaveStyle({ pointerEvents: 'auto' }) }) + it('closes the source popover when the queue obscures mobile lyrics', async () => { + useEnhancedLyricsMock.mockImplementation(() => sourcedLyricsResponse) + const host = createHost() + const { rerender } = render() + + fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' })) + const sourceButton = await within(host).findByTestId('lyrics-source-button') + fireEvent.click(sourceButton) + expect(screen.getByRole('dialog')).toBeVisible() + + rerender() + + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ) + }) + it('attaches when the mobile cover host appears after activation', async () => { vi.useFakeTimers() diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx index 32669facf..de8f2114c 100644 --- a/ui/src/audioplayer/usePlayerLyrics.jsx +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -195,6 +195,7 @@ const usePlayerLyrics = ({ onToggleTranslation={toggleTranslation} onTogglePronunciation={togglePronunciation} source={lyricsSource} + active={useInlineMobileLyrics && !obscuredByQueue} labels={labels} testId="lyrics-mobile-layer-controls" />