feat(ui): collect and select lyrics source variants

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-10-17 12:13:35 +03:00
parent 13e3d53b9a
commit 7d3c1bee0c
No known key found for this signature in database
2 changed files with 225 additions and 6 deletions

View File

@ -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,
),
}

View File

@ -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')
})
})