From 7d3c1bee0c253a61972780cae0a39d3c5cab1f22 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sat, 17 Oct 2026 12:13:35 +0300 Subject: [PATCH] feat(ui): collect and select lyrics source variants Signed-off-by: ranokay --- ui/src/audioplayer/lyrics.js | 90 +++++++++++++++++-- ui/src/audioplayer/lyrics.test.js | 141 ++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 6 deletions(-) diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js index 05193d271..e618be92f 100644 --- a/ui/src/audioplayer/lyrics.js +++ b/ui/src/audioplayer/lyrics.js @@ -252,6 +252,66 @@ export const hasStructuredLyricContent = (structuredLyric) => hasCueTiming(structuredLyric)), ) +// variantKey identifies a lyrics source for selection and persistence. The +// plugin's provider label is the upstream identity; name and type cover +// non-plugin sources. +export const variantKey = (source) => { + const label = [source?.provider, source?.name, source?.type].find( + (value) => typeof value === 'string' && value.trim() !== '', + ) + return (label || 'unknown').trim().toLowerCase() +} + +// variantSync grades a variant for its badge: word timing from cue lines, +// line timing from synced line starts, plain otherwise. +export const variantSync = (lyric) => { + if (hasCueTiming(lyric)) return 'word' + if (hasTimedLines(lyric)) return 'line' + return 'plain' +} + +// mainKindEntries mirrors the contract's main rule: blank/unknown kinds count +// as main, and a response without any main entry falls back to every entry. +const mainKindEntries = (available) => { + const mains = available.filter( + (lyric) => normalizeLyricKind(lyric?.kind) === LYRIC_KIND_MAIN, + ) + return mains.length > 0 ? mains : available +} + +// groupLyricVariants groups entries by source, keeping first-appearance +// order. One source can emit several main entries (embedded multi-language +// tags), and they stay one variant. +const groupLyricVariants = (entries) => { + const variants = [] + const byKey = new Map() + for (const lyric of entries) { + const key = variantKey(lyric?.source) + let variant = byKey.get(key) + if (!variant) { + variant = { + key, + source: lyric?.source || null, + sync: variantSync(lyric), + lyric, + entries: [], + } + byKey.set(key, variant) + variants.push(variant) + } + variant.entries.push(lyric) + } + return variants +} + +export const collectLyricVariants = (structuredLyrics) => { + if (!Array.isArray(structuredLyrics)) return [] + const available = structuredLyrics.filter(hasStructuredLyricContent) + return groupLyricVariants(mainKindEntries(available)).map( + ({ entries, ...variant }) => variant, + ) +} + export const getPreferredLyricLanguage = () => { try { if (typeof window !== 'undefined' && window.localStorage) { @@ -267,7 +327,11 @@ export const getPreferredLyricLanguage = () => { return 'en' } -export const selectLyricLayers = (structuredLyrics, preferredLanguage) => { +export const selectLyricLayers = ( + structuredLyrics, + preferredLanguage, + selectedSourceKey = null, +) => { if (!Array.isArray(structuredLyrics)) { return { main: null, translation: null, pronunciation: null } } @@ -287,18 +351,32 @@ export const selectLyricLayers = (structuredLyrics, preferredLanguage) => { grouped[normalizeLyricKind(lyric?.kind)].push(lyric) } - const mainCandidates = grouped[LYRIC_KIND_MAIN].length - ? grouped[LYRIC_KIND_MAIN] - : available + const variants = groupLyricVariants(mainKindEntries(available)) + const selectedVariant = + variants.find((variant) => variant.key === selectedSourceKey) || + variants[0] || + null + const mainCandidates = selectedVariant ? selectedVariant.entries : [] + + // Translation/pronunciation entries travel with their own source when the + // response tags them; without a tagged match the global pick stands. + const scopedLayer = (kind) => { + const entries = grouped[kind] + if (!selectedVariant || entries.length === 0) return entries + const scoped = entries.filter( + (lyric) => variantKey(lyric?.source) === selectedVariant.key, + ) + return scoped.length > 0 ? scoped : entries + } return { main: pickPreferredLyric(mainCandidates, preferredLanguage), translation: pickPreferredLyric( - grouped[LYRIC_KIND_TRANSLATION], + scopedLayer(LYRIC_KIND_TRANSLATION), preferredLanguage, ), pronunciation: pickPreferredLyric( - grouped[LYRIC_KIND_PRONUNCIATION], + scopedLayer(LYRIC_KIND_PRONUNCIATION), preferredLanguage, ), } diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js index 208d82ac6..6f63b24f0 100644 --- a/ui/src/audioplayer/lyrics.test.js +++ b/ui/src/audioplayer/lyrics.test.js @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { buildKaraokeLines, + collectLyricVariants, getPreferredLyricLanguage, hasStructuredLyricContent, selectLyricLayers, @@ -360,3 +361,143 @@ describe('lyrics helpers', () => { ).toBe(false) }) }) + +describe('lyrics variants', () => { + it('groups main entries from the same source into one variant', () => { + const lyrics = [ + { + ...structured('main', 'eng', 'Hello'), + source: { type: 'embedded' }, + }, + { + ...structured('main', 'jpn', 'こんにちは'), + source: { type: 'embedded' }, + }, + ] + + const variants = collectLyricVariants(lyrics) + + expect(variants).toHaveLength(1) + expect(variants[0].key).toBe('embedded') + }) + + it('lists plugin variants in response order with their sync levels', () => { + const wordEntry = { + kind: 'main', + lang: 'eng', + synced: true, + line: [timed('word line', 0)], + cueLine: [ + cueLine(0, 'word line', 0, 1000, [{ start: 0, value: 'word line' }]), + ], + source: { + type: 'plugin', + name: 'Better Lyrics', + provider: 'NetEase', + format: 'elrc', + }, + } + const lineEntry = { + ...structured('main', 'eng', 'line line'), + source: { + type: 'plugin', + name: 'Better Lyrics', + provider: 'LRCLib', + format: 'lrc', + }, + } + const plainEntry = { + kind: 'main', + lang: 'eng', + synced: false, + line: [{ value: 'plain words' }], + source: { type: 'plugin', provider: 'Genius', format: 'plain' }, + } + + expect( + collectLyricVariants([wordEntry, lineEntry, plainEntry]).map((v) => [ + v.key, + v.sync, + ]), + ).toEqual([ + ['netease', 'word'], + ['lrclib', 'line'], + ['genius', 'plain'], + ]) + }) + + it('selects the requested variant and falls back to the winner', () => { + const netease = { + ...structured('main', 'eng', 'NetEase line'), + source: { provider: 'NetEase', format: 'elrc' }, + } + const lrclib = { + ...structured('main', 'eng', 'LRCLib line'), + source: { provider: 'LRCLib', format: 'lrc' }, + } + const lyrics = [netease, lrclib] + + expect(selectLyricLayers(lyrics, 'en').main.line[0].value).toBe( + 'NetEase line', + ) + expect(selectLyricLayers(lyrics, 'en', 'lrclib').main.line[0].value).toBe( + 'LRCLib line', + ) + expect(selectLyricLayers(lyrics, 'en', 'gone').main.line[0].value).toBe( + 'NetEase line', + ) + }) + + it('keeps the language pick inside the selected variant', () => { + const english = { + ...structured('main', 'eng', 'English words'), + source: { provider: 'NetEase' }, + } + const japanese = { + ...structured('main', 'jpn', '日本語の歌詞'), + source: { provider: 'NetEase' }, + } + + expect( + selectLyricLayers([english, japanese], 'ja').main.line[0].value, + ).toBe('日本語の歌詞') + expect( + selectLyricLayers([english, japanese], 'en').main.line[0].value, + ).toBe('English words') + }) + + it('scopes translation layers to the selected source with a global fallback', () => { + const neteaseMain = { + ...structured('main', 'eng', 'NetEase main'), + source: { provider: 'NetEase' }, + } + const geniusMain = { + ...structured('main', 'eng', 'Genius main'), + source: { provider: 'Genius' }, + } + const neteaseTranslation = { + ...structured('translation', 'es', 'Traducción NetEase'), + source: { provider: 'NetEase' }, + } + const geniusTranslation = { + ...structured('translation', 'fr', 'Traduction Genius'), + source: { provider: 'Genius' }, + } + + const scoped = selectLyricLayers( + [neteaseMain, geniusMain, neteaseTranslation, geniusTranslation], + 'en', + 'genius', + ) + expect(scoped.main.line[0].value).toBe('Genius main') + expect(scoped.translation.line[0].value).toBe('Traduction Genius') + + const fallback = selectLyricLayers( + [neteaseMain, geniusMain, geniusTranslation], + 'en', + 'netease', + ) + expect(fallback.main.line[0].value).toBe('NetEase main') + expect(fallback.translation.line[0].value).toBe('Traduction Genius') + }) +})