support additional lyric layers (#2211)

This commit is contained in:
jeffvli
2026-07-10 15:12:17 -07:00
parent 908e83a2f8
commit b4e530ea07
12 changed files with 743 additions and 346 deletions
+4
View File
@@ -567,6 +567,10 @@
"useImageAspectRatio": "Use image aspect ratio"
},
"lyrics": "Lyrics",
"lyricLayers": "Lyric layers",
"lyricLanguage": "Language",
"showPronunciation": "Pronunciation",
"showTranslation": "Translation",
"related": "Related",
"upNext": "Up next",
"visualizer": "Visualizer",
@@ -100,14 +100,22 @@ export const getLineEndMs = (line: SynchronizedLyricLine): number => {
export const lyricsHasWordCues = (lyrics: SynchronizedLyrics): boolean =>
lyrics.some((line) => line.cueLines?.some((cueLine) => cueLine.words.length > 0));
export const findOverlayLineByTime = (
export const findOverlayLineMatchByTime = (
overlayLyrics: null | SynchronizedLyrics | undefined,
startMs: number,
): string | undefined => {
lineIndex?: number,
): SynchronizedLyricLine | undefined => {
if (!overlayLyrics?.length) {
return undefined;
}
if (lineIndex !== undefined && lineIndex >= 0 && lineIndex < overlayLyrics.length) {
const indexedLine = normalizeLyricsLine(overlayLyrics[lineIndex]);
if (indexedLine.startMs <= startMs) {
return indexedLine;
}
}
let match: SynchronizedLyricLine | undefined;
for (const rawLine of overlayLyrics) {
@@ -120,12 +128,36 @@ export const findOverlayLineByTime = (
break;
}
return match?.text;
return match;
};
export const findOverlayLineByTime = (
overlayLyrics: null | SynchronizedLyrics | undefined,
startMs: number,
lineIndex?: number,
): string | undefined => findOverlayLineMatchByTime(overlayLyrics, startMs, lineIndex)?.text;
export const overlayLineHasWordCues = (line: SynchronizedLyricLine | undefined): boolean =>
!!line?.cueLines?.some((cueLine) => cueLine.words.length > 0);
export const getOverlayCueLinesForLine = (
overlayLyrics: null | SynchronizedLyrics | undefined,
mainLineStartMs: number,
lineIndex?: number,
): null | SyncedCueLine[] => {
const match = findOverlayLineMatchByTime(overlayLyrics, mainLineStartMs, lineIndex);
if (!match?.cueLines?.length || !overlayLineHasWordCues(match)) {
return null;
}
return match.cueLines;
};
export type LyricsLayers = {
main: StructuredLyric[];
others: StructuredLyric[];
overlayLayers: StructuredLyric[];
pronunciation: null | StructuredLyric;
translation: null | StructuredLyric;
};
@@ -141,6 +173,7 @@ const getStructuredKind = (lyric: StructuredLyric): LyricsKind => {
export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => {
const main: StructuredLyric[] = [];
const others: StructuredLyric[] = [];
const overlayLayers: StructuredLyric[] = [];
let pronunciation: null | StructuredLyric = null;
let translation: null | StructuredLyric = null;
@@ -151,6 +184,10 @@ export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => {
main.push(lyric);
} else {
others.push(lyric);
if (lyric.synced) {
overlayLayers.push(lyric);
}
}
if (kind === 'translation' && !translation) {
@@ -162,7 +199,7 @@ export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => {
}
}
return { main, others, pronunciation, translation };
return { main, others, overlayLayers, pronunciation, translation };
};
export const getDefaultStructuredIndex = (local: StructuredLyric[]): number => {
@@ -173,6 +210,11 @@ export const getDefaultStructuredIndex = (local: StructuredLyric[]): number => {
return mainIndex >= 0 ? mainIndex : 0;
};
export const getOverlayLayerKey = (lyric: StructuredLyric): string => {
const kind = getStructuredKind(lyric);
return `${kind}:${lyric.lang}`;
};
export const formatStructuredLyricLabel = (lyric: StructuredLyric): string => {
const kind = getStructuredKind(lyric);
return kind === 'main' ? lyric.lang : `${lyric.lang} (${kind})`;
@@ -51,6 +51,7 @@ export interface LineData {
isScrolled: boolean;
isSelected: boolean;
lastAnimSetupAt: number;
overlayParts: PartData[];
parts: PartData[];
position: number;
time: number;
@@ -143,7 +144,7 @@ export const resetAnimEngine = (state: AnimEngineState): void => {
const collectLineParts = (lineElement: HTMLElement): PartData[] => {
const words = lineElement.querySelectorAll<HTMLElement>(
'.karaoke-word[data-duration]:not(.karaoke-romaji-word)',
'.karaoke-word[data-duration]:not(.karaoke-overlay-word)',
);
return Array.from(words).map((element) => ({
@@ -155,32 +156,18 @@ const collectLineParts = (lineElement: HTMLElement): PartData[] => {
}));
};
const getRomajiCounterparts = (mainElement: HTMLElement): HTMLElement[] => {
const line = mainElement.closest('.karaoke-line');
if (!line) {
return [];
}
const collectOverlayParts = (lineElement: HTMLElement): PartData[] => {
const words = lineElement.querySelectorAll<HTMLElement>(
'.karaoke-overlay-word[data-duration]',
);
const mainStart = Number.parseFloat(mainElement.dataset.time ?? 'NaN');
const mainDuration = Number.parseFloat(mainElement.dataset.duration ?? '0');
if (!Number.isFinite(mainStart)) {
return [];
}
const mainEnd = mainStart + mainDuration;
return Array.from(
line.querySelectorAll<HTMLElement>('.karaoke-romaji-word[data-time][data-duration]'),
).filter((element) => {
const start = Number.parseFloat(element.dataset.time ?? 'NaN');
const duration = Number.parseFloat(element.dataset.duration ?? '0');
if (!Number.isFinite(start)) {
return false;
}
const end = start + duration;
return start < mainEnd && mainStart < end;
});
return Array.from(words).map((element) => ({
animationStartTimeMs: Number.POSITIVE_INFINITY,
duration: Number.parseFloat(element.dataset.duration ?? '0'),
element,
isAnimating: false,
time: Number.parseFloat(element.dataset.time ?? '0'),
}));
};
const cancelSungAnimationCleanup = (element: HTMLElement): void => {
@@ -246,79 +233,78 @@ const clearElementAnimation = (element: HTMLElement): void => {
element.classList.remove(PAUSED_CLASS);
};
const setupElementAnimation = (
element: HTMLElement,
time: number,
duration: number,
interpolatedTimeSec: number,
): void => {
const partTimeDelta = interpolatedTimeSec - time;
element.classList.remove(ANIMATING_CLASS);
element.classList.remove(PAUSED_CLASS);
element.style.setProperty('--karaoke-swipe-delay', `${-partTimeDelta - duration * 0.1}s`);
element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`);
element.classList.add(PRE_ANIMATING_CLASS);
reflow(element);
element.classList.add(ANIMATING_CLASS);
const clearWordSungState = (part: PartData): void => {
part.element.classList.remove('sung');
};
const syncRomajiAnimation = (
mainElement: HTMLElement,
const markWordSung = (part: PartData, interpolatedTimeSec: number): void => {
part.animationStartTimeMs = Number.POSITIVE_INFINITY;
part.isAnimating = false;
part.element.classList.add('sung');
scheduleSungAnimationCleanup(part.element, part.duration, part.time, interpolatedTimeSec);
};
const setupPartAnimation = (part: PartData, interpolatedTimeSec: number, now: number): void => {
const partTimeDelta = interpolatedTimeSec - part.time;
part.element.classList.remove(ANIMATING_CLASS);
part.element.classList.remove(PAUSED_CLASS);
part.element.style.setProperty(
'--karaoke-swipe-delay',
`${-partTimeDelta - part.duration * 0.1}s`,
);
part.element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`);
part.element.classList.add(PRE_ANIMATING_CLASS);
reflow(part.element);
part.element.classList.add(ANIMATING_CLASS);
part.animationStartTimeMs = now - partTimeDelta * 1000;
part.isAnimating = true;
};
const animateWordParts = (
parts: PartData[],
interpolatedTimeSec: number,
action: 'clear' | 'clearSung' | 'pause' | 'resume' | 'setup' | 'sung',
now: number,
): void => {
for (const element of getRomajiCounterparts(mainElement)) {
const time = Number.parseFloat(element.dataset.time ?? '0');
const duration = Number.parseFloat(element.dataset.duration ?? '0');
const end = time + duration;
for (const part of parts) {
const partEnd = part.time + part.duration;
switch (action) {
case 'clear':
clearElementAnimation(element);
if (interpolatedTimeSec < time) {
element.classList.remove('sung');
}
break;
case 'clearSung':
element.classList.remove('sung');
break;
case 'pause':
if (element.classList.contains(ANIMATING_CLASS)) {
element.classList.add(PAUSED_CLASS);
}
break;
case 'resume':
element.classList.remove(PAUSED_CLASS);
break;
case 'setup':
if (
element.classList.contains(ANIMATING_CLASS) ||
element.classList.contains('sung') ||
interpolatedTimeSec < time ||
interpolatedTimeSec >= end
) {
break;
}
setupElementAnimation(element, time, duration, interpolatedTimeSec);
break;
case 'sung':
if (interpolatedTimeSec >= end) {
element.classList.add('sung');
scheduleSungAnimationCleanup(element, duration, time, interpolatedTimeSec);
}
break;
default:
break;
if (interpolatedTimeSec >= partEnd) {
if (!part.element.classList.contains('sung')) {
markWordSung(part, interpolatedTimeSec);
}
} else if (interpolatedTimeSec < part.time) {
if (part.isAnimating || part.element.classList.contains('sung')) {
clearPartAnimation(part);
clearWordSungState(part);
}
} else if (!part.isAnimating) {
setupPartAnimation(part, interpolatedTimeSec, now);
}
}
};
const clearLineRomajiState = (lineElement: HTMLElement): void => {
for (const element of lineElement.querySelectorAll<HTMLElement>('.karaoke-romaji-word')) {
const clearWordParts = (parts: PartData[], interpolatedTimeSec: number): void => {
for (const part of parts) {
if (interpolatedTimeSec >= part.time + part.duration) {
markWordSung(part, interpolatedTimeSec);
} else {
clearPartAnimation(part);
clearWordSungState(part);
}
}
};
const resumeWordParts = (parts: PartData[]): void => {
for (const part of parts) {
part.element.classList.remove(PAUSED_CLASS);
}
};
const clearLineOverlayState = (lineElement: HTMLElement): void => {
for (const element of lineElement.querySelectorAll<HTMLElement>('.karaoke-overlay-word')) {
clearElementAnimation(element);
element.classList.remove('sung');
}
@@ -343,6 +329,7 @@ export const buildLyricsDataFromDom = ({
const endMs = getLineEndMs(line);
const durationMs = Math.max(0, endMs - startMs);
const parts = hasWordCues && line.cueLines?.length ? collectLineParts(lineElement) : [];
const overlayParts = collectOverlayParts(lineElement);
lines.push({
accumulatedOffsetMs: 0,
@@ -356,6 +343,7 @@ export const buildLyricsDataFromDom = ({
isScrolled: false,
isSelected: false,
lastAnimSetupAt: 0,
overlayParts,
parts,
position: -1,
time: startMs / 1000,
@@ -411,37 +399,23 @@ const clearLineAnimation = (line: LineData): void => {
line.animationStartTimeMs = Number.POSITIVE_INFINITY;
};
const clearWordSungState = (part: PartData): void => {
part.element.classList.remove('sung');
syncRomajiAnimation(part.element, 0, 'clearSung');
};
const clearLineKaraokeHighlights = (lineData: LineData, interpolatedTimeSec: number): void => {
if (lineData.hasWordCues) {
for (const part of lineData.parts) {
clearPartAnimation(part);
clearWordSungState(part);
}
} else {
clearLineAnimation(lineData);
}
const markWordSung = (part: PartData, interpolatedTimeSec: number): void => {
part.animationStartTimeMs = Number.POSITIVE_INFINITY;
part.isAnimating = false;
part.element.classList.add('sung');
scheduleSungAnimationCleanup(part.element, part.duration, part.time, interpolatedTimeSec);
syncRomajiAnimation(part.element, interpolatedTimeSec, 'sung');
};
if (lineData.overlayParts.length) {
clearWordParts(lineData.overlayParts, interpolatedTimeSec);
}
const setupPartAnimation = (part: PartData, interpolatedTimeSec: number, now: number): void => {
const partTimeDelta = interpolatedTimeSec - part.time;
part.element.classList.remove(ANIMATING_CLASS);
part.element.classList.remove(PAUSED_CLASS);
part.element.style.setProperty(
'--karaoke-swipe-delay',
`${-partTimeDelta - part.duration * 0.1}s`,
);
part.element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`);
part.element.classList.add(PRE_ANIMATING_CLASS);
reflow(part.element);
part.element.classList.add(ANIMATING_CLASS);
part.animationStartTimeMs = now - partTimeDelta * 1000;
part.isAnimating = true;
syncRomajiAnimation(part.element, interpolatedTimeSec, 'setup');
lineData.isSelected = false;
lineData.isAnimating = false;
lineData.isAnimationPlayStatePlaying = false;
};
const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: number): void => {
@@ -463,22 +437,6 @@ const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: nu
line.accumulatedOffsetMs = 0;
};
const clearLineKaraokeHighlights = (lineData: LineData, interpolatedTimeSec: number): void => {
if (lineData.hasWordCues) {
for (const part of lineData.parts) {
clearPartAnimation(part);
clearWordSungState(part);
syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear');
}
} else {
clearLineAnimation(lineData);
}
lineData.isSelected = false;
lineData.isAnimating = false;
lineData.isAnimationPlayStatePlaying = false;
};
const decaySkipScrolls = (state: ScrollState, now: number): void => {
let decayCount = 0;
@@ -593,10 +551,16 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
clearPartAnimation(part);
clearWordSungState(part);
}
clearLineRomajiState(lineData.element);
} else {
clearLineAnimation(lineData);
}
if (lineData.overlayParts.length) {
for (const part of lineData.overlayParts) {
clearPartAnimation(part);
clearWordSungState(part);
}
}
}
state.scroll.pendingScroll = true;
@@ -698,14 +662,15 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
lineData.isSelected = true;
if (lineData.hasWordCues) {
for (const part of lineData.parts) {
part.element.classList.remove(PAUSED_CLASS);
syncRomajiAnimation(part.element, interpolatedTimeSec, 'resume');
}
resumeWordParts(lineData.parts);
} else {
lineData.element.classList.remove(PAUSED_CLASS);
}
if (lineData.overlayParts.length) {
resumeWordParts(lineData.overlayParts);
}
lineData.isAnimationPlayStatePlaying = true;
if (!lineData.hasWordCues && !lineData.isAnimating) {
@@ -713,39 +678,23 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
}
if (lineData.hasWordCues) {
for (const part of lineData.parts) {
const partEnd = part.time + part.duration;
animateWordParts(lineData.parts, interpolatedTimeSec, now);
}
if (interpolatedTimeSec >= partEnd) {
if (!part.element.classList.contains('sung')) {
markWordSung(part, interpolatedTimeSec);
}
} else if (interpolatedTimeSec < part.time) {
if (part.isAnimating || part.element.classList.contains('sung')) {
clearPartAnimation(part);
clearWordSungState(part);
syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear');
}
} else if (!part.isAnimating) {
setupPartAnimation(part, interpolatedTimeSec, now);
}
}
if (lineData.overlayParts.length) {
animateWordParts(lineData.overlayParts, interpolatedTimeSec, now);
}
} else if (lineData.isSelected) {
if (lineData.hasWordCues) {
for (const part of lineData.parts) {
if (interpolatedTimeSec >= part.time + part.duration) {
markWordSung(part, interpolatedTimeSec);
} else {
clearPartAnimation(part);
clearWordSungState(part);
syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear');
}
}
clearWordParts(lineData.parts, interpolatedTimeSec);
} else {
clearLineAnimation(lineData);
}
if (lineData.overlayParts.length) {
clearWordParts(lineData.overlayParts, interpolatedTimeSec);
}
lineData.isSelected = false;
lineData.isAnimating = false;
lineData.isAnimationPlayStatePlaying = false;
@@ -861,7 +810,12 @@ export const resetLyricsAnimationDom = (lyricsData: LyricsData | null): void =>
clearWordSungState(part);
}
clearLineRomajiState(line.element);
for (const part of line.overlayParts) {
clearPartAnimation(part);
clearWordSungState(part);
}
clearLineOverlayState(line.element);
clearLineAnimation(line);
}
};
@@ -35,7 +35,7 @@
display: inline;
}
.romaji-line {
.overlay-line {
display: block;
max-width: 100%;
font-size: 0.8em;
@@ -44,6 +44,15 @@
white-space: normal;
}
.translation-line {
display: block;
max-width: 100%;
font-size: 0.85em;
font-weight: 500;
overflow-wrap: break-word;
white-space: normal;
}
.agent-line {
width: 100%;
max-width: 100%;
@@ -19,15 +19,60 @@ interface KaraokeLyricLineProps extends ComponentPropsWithoutRef<'div'> {
agents?: LyricAgent[];
alignment: 'center' | 'left' | 'right';
cueLines: SyncedCueLine[];
extraOverlays?: Array<{
cueLines: null | SyncedCueLine[];
text?: null | string;
}>;
fontSize: number;
lineIndex: number;
pronunciationCueLines?: null | SyncedCueLine[];
pronunciationText?: null | string;
romajiCueLines?: (null | SyncedCueLine)[] | null;
romajiText?: null | string;
text?: string;
translatedText?: null | string;
translationCueLines?: null | SyncedCueLine[];
}
type WordSpanVariant = 'main' | 'romaji';
type WordSpanVariant = 'main' | 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation';
const getWordSpanClasses = (
variant: WordSpanVariant,
options: {
hasFurigana: boolean;
isBackground: boolean;
isRtl: boolean;
isZeroDuration: boolean;
},
) => {
const isOverlay = variant !== 'main';
return clsx(
styles.karaokeWord,
'karaoke-word',
isOverlay && 'karaoke-overlay-word',
variant === 'overlay-generic' && 'karaoke-overlay-generic',
variant === 'overlay-pronunciation' && 'karaoke-overlay-pronunciation',
variant === 'overlay-translation' && 'karaoke-overlay-translation',
options.isRtl && 'karaoke-rtl',
options.isBackground && 'karaoke-bg-vocal',
options.isZeroDuration && 'karaoke-zero-dur',
options.hasFurigana && 'karaoke-furigana-word',
);
};
const getWordSpanIdPrefix = (variant: WordSpanVariant): string => {
switch (variant) {
case 'overlay-generic':
return 'karaoke-overlay-generic';
case 'overlay-pronunciation':
return 'karaoke-overlay-pronunciation';
case 'overlay-translation':
return 'karaoke-overlay-translation';
default:
return 'karaoke';
}
};
const renderWordSpans = (
cueLine: SyncedCueLine,
@@ -36,13 +81,18 @@ const renderWordSpans = (
isBackground: boolean,
variant: WordSpanVariant = 'main',
) => {
const isRomaji = variant === 'romaji';
const idPrefix = isRomaji ? 'karaoke-romaji' : 'karaoke';
const isOverlay = variant !== 'main';
const idPrefix = getWordSpanIdPrefix(variant);
if (!cueLine.words.length) {
return (
<span
className={clsx(styles.karaokeWord, isRomaji && 'karaoke-romaji-word')}
className={getWordSpanClasses(variant, {
hasFurigana: false,
isBackground,
isRtl: false,
isZeroDuration: false,
})}
dangerouslySetInnerHTML={{ __html: sanitize(cueLine.value) }}
data-lyric-time={cueLine.startMs}
/>
@@ -50,7 +100,7 @@ const renderWordSpans = (
}
const splitWords =
isRomaji || cueLine.words.some((word) => hasFuriganaHtml(word.text))
isOverlay || cueLine.words.some((word) => hasFuriganaHtml(word.text))
? cueLine.words
: splitWordCues(cueLine.words);
let wordCounter = 0;
@@ -61,20 +111,17 @@ const renderWordSpans = (
const timeSec = word.startMs / 1000;
const isRtl = testRtl(word.text);
const isZeroDuration = durationMs <= 0;
const hasFurigana = !isRomaji && hasFuriganaHtml(word.text);
const hasFurigana = !isOverlay && hasFuriganaHtml(word.text);
const sanitizedHtml = sanitize(word.text);
const currentWordIndex = wordCounter;
wordCounter += 1;
const wordClassName = clsx(
styles.karaokeWord,
'karaoke-word',
isRomaji && 'karaoke-romaji-word',
isRtl && 'karaoke-rtl',
isBackground && 'karaoke-bg-vocal',
isZeroDuration && 'karaoke-zero-dur',
hasFurigana && 'karaoke-furigana-word',
);
const wordClassName = getWordSpanClasses(variant, {
hasFurigana,
isBackground,
isRtl,
isZeroDuration,
});
const wordKey = `${word.startMs}-${currentWordIndex}`;
const wordProps = {
@@ -106,17 +153,36 @@ const renderWordSpans = (
});
};
const renderOverlayCueLineRow = (
cueLine: SyncedCueLine,
lineIndex: number,
cueLineIndex: number,
variant: 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation',
lineClassName: string,
) => (
<span className={lineClassName}>
{renderWordSpans(cueLine, lineIndex, cueLineIndex, false, variant)}
</span>
);
const hasSyncedOverlayCueLines = (cueLines: null | SyncedCueLine[] | undefined): boolean =>
!!cueLines?.some((cueLine) => cueLine.words.length > 0);
export const KaraokeLyricLine = memo(
({
agents,
alignment,
className,
cueLines,
extraOverlays,
fontSize,
lineIndex,
pronunciationCueLines,
pronunciationText,
romajiCueLines,
romajiText,
translatedText,
translationCueLines,
...props
}: KaraokeLyricLineProps) => {
const style = useMemo(
@@ -128,6 +194,10 @@ export const KaraokeLyricLine = memo(
);
const hasSyncedRomaji = romajiCueLines != null;
const hasSyncedPronunciation = hasSyncedOverlayCueLines(pronunciationCueLines);
const hasSyncedTranslation = hasSyncedOverlayCueLines(translationCueLines);
const pronunciationFallbackText =
!hasSyncedRomaji && !hasSyncedPronunciation ? (pronunciationText ?? romajiText) : null;
return (
<Box
@@ -140,6 +210,8 @@ export const KaraokeLyricLine = memo(
const agent = agents?.find((entry) => entry.id === cueLine.agentId);
const isBackground = agent?.role === 'bg' || agent?.role === 'group';
const romajiCueLine = romajiCueLines?.[cueLineIndex];
const pronunciationCueLine = pronunciationCueLines?.[cueLineIndex];
const translationCueLine = translationCueLines?.[cueLineIndex];
return (
<div
@@ -164,28 +236,82 @@ export const KaraokeLyricLine = memo(
)}
</span>
{romajiCueLine && (
<span className={styles.romajiLine}>
<span className={styles.overlayLine}>
{renderWordSpans(
romajiCueLine,
lineIndex,
cueLineIndex,
false,
'romaji',
'overlay-pronunciation',
)}
</span>
)}
{!romajiCueLine &&
pronunciationCueLine &&
renderOverlayCueLineRow(
pronunciationCueLine,
lineIndex,
cueLineIndex,
'overlay-pronunciation',
styles.overlayLine,
)}
{translationCueLine &&
renderOverlayCueLineRow(
translationCueLine,
lineIndex,
cueLineIndex,
'overlay-translation',
styles.translationLine,
)}
</div>
);
})}
{!hasSyncedRomaji && romajiText && (
{pronunciationFallbackText && (
<span
className={styles.romajiLine}
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
className={styles.overlayLine}
dangerouslySetInnerHTML={{
__html: sanitize(pronunciationFallbackText),
}}
/>
)}
{translatedText && (
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
{!hasSyncedTranslation && translatedText && (
<span
className={styles.translationLine}
dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }}
/>
)}
{extraOverlays?.map((overlay, overlayIndex) => {
const hasSyncedExtra = hasSyncedOverlayCueLines(overlay.cueLines);
if (hasSyncedExtra && overlay.cueLines) {
return overlay.cueLines.map((cueLine, cueLineIndex) => (
<span
className={styles.overlayLine}
key={`extra-overlay-${overlayIndex}-cue-${cueLineIndex}`}
>
{renderWordSpans(
cueLine,
lineIndex,
cueLineIndex,
false,
'overlay-generic',
)}
</span>
));
}
if (overlay.text) {
return (
<span
className={styles.overlayLine}
dangerouslySetInnerHTML={{ __html: sanitize(overlay.text) }}
key={`extra-overlay-${overlayIndex}`}
/>
);
}
return null;
})}
</Stack>
</Box>
);
@@ -0,0 +1,39 @@
.root {
position: relative;
width: 100%;
}
.top-row,
.controls-row {
flex-wrap: wrap;
}
.top-row {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: var(--theme-spacing-md);
}
.controls-row {
display: flex;
align-items: center;
justify-content: center;
}
.overlay-toggle-active {
color: var(--theme-colors-primary) !important;
}
.layer-row {
display: flex;
gap: var(--theme-spacing-md);
align-items: center;
justify-content: space-between;
}
.layer-label {
flex: 1;
min-width: 0;
text-align: left;
}
+261 -123
View File
@@ -1,55 +1,85 @@
import isElectron from 'is-electron';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './lyrics-actions.module.css';
import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';
import { useLyricsSettings, usePlayerSong } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Group } from '/@/shared/components/group/group';
import { AppIcon } from '/@/shared/components/icon/icon';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Select } from '/@/shared/components/select/select';
import { Popover } from '/@/shared/components/popover/popover';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { LyricsOverride } from '/@/shared/types/domain-types';
import { LyricsKind, LyricsOverride } from '/@/shared/types/domain-types';
export type OverlayLayerToggle = {
key: string;
kind: LyricsKind;
label: string;
};
interface LyricsActionsProps {
hasLyrics: boolean;
hasPronunciationLayer?: boolean;
hasTranslationLayer?: boolean;
index: number;
languages: { label: string; value: string }[];
offsetMs: number;
onExportLyrics: () => void;
onRemoveLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void;
onTogglePronunciationLayer?: () => void;
onToggleTranslationLayer?: () => void;
onToggleOverlayLayer?: (key: string) => void;
onTranslateLyric?: () => void;
onUpdateOffset: (offsetMs: number) => void;
overlayLayers?: OverlayLayerToggle[];
setIndex: (idx: number) => void;
settingsKey?: string;
showPronunciationLayer?: boolean;
showTranslationLayer?: boolean;
synced?: boolean;
visibleOverlayKeys?: Set<string>;
}
const OVERLAY_KIND_ICONS: Partial<Record<LyricsKind, keyof typeof AppIcon>> = {
pronunciation: 'audioLines',
translation: 'languages',
};
const getOverlayTooltip = (
layer: OverlayLayerToggle,
t: (key: string) => string,
isActive: boolean,
): string => {
const action = isActive ? 'Hide' : 'Show';
if (layer.kind === 'pronunciation') {
return `${action} ${t('page.fullscreenPlayer.showPronunciation').toLowerCase()}`;
}
if (layer.kind === 'translation') {
return `${action} ${t('page.fullscreenPlayer.showTranslation').toLowerCase()}`;
}
return `${action} ${layer.label}`;
};
export const LyricsActions = ({
hasLyrics,
hasPronunciationLayer = false,
hasTranslationLayer = false,
index,
languages,
offsetMs,
onExportLyrics,
onRemoveLyric,
onSearchOverride,
onTogglePronunciationLayer,
onToggleTranslationLayer,
onToggleOverlayLayer,
onTranslateLyric,
onUpdateOffset,
overlayLayers = [],
setIndex,
showPronunciationLayer = false,
showTranslationLayer = false,
visibleOverlayKeys = new Set(),
}: LyricsActionsProps) => {
const { t } = useTranslation();
const currentSong = usePlayerSong();
@@ -61,21 +91,149 @@ export const LyricsActions = ({
const isActionsDisabled = !currentSong;
const isDesktop = isElectron();
const hasServerTranslationLayer = overlayLayers.some((layer) => layer.kind === 'translation');
const hasMultipleLanguages = languages.length > 1;
const selectedLanguage = useMemo(
() => languages.find((language) => language.value === index.toString()),
[index, languages],
);
const { extraOverlayLayers, quickOverlayLayers } = useMemo(() => {
const quick: OverlayLayerToggle[] = [];
const extra: OverlayLayerToggle[] = [];
for (const layer of overlayLayers) {
if (layer.kind === 'pronunciation' || layer.kind === 'translation') {
quick.push(layer);
continue;
}
extra.push(layer);
}
return {
extraOverlayLayers: extra,
quickOverlayLayers: quick,
};
}, [overlayLayers]);
const hasActiveExtraOverlay = extraOverlayLayers.some((layer) =>
visibleOverlayKeys.has(layer.key),
);
const languageTooltip = selectedLanguage
? `${t('page.fullscreenPlayer.lyricLanguage')}: ${selectedLanguage.label}`
: t('page.fullscreenPlayer.lyricLanguage');
const showTopRow =
hasLyrics ||
hasMultipleLanguages ||
quickOverlayLayers.length > 0 ||
extraOverlayLayers.length > 0;
const languageMenu = hasMultipleLanguages ? (
<DropdownMenu position="top">
<DropdownMenu.Target>
<ActionIcon
aria-label={languageTooltip}
disabled={isActionsDisabled}
icon="metadata"
iconProps={{ size: 'lg' }}
size="sm"
tooltip={{
label: languageTooltip,
openDelay: 0,
}}
variant="subtle"
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{languages.map((language) => (
<DropdownMenu.Item
isSelected={language.value === index.toString()}
key={language.value}
onClick={() => setIndex(parseInt(language.value, 10))}
>
{language.label}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
) : null;
const overlayToggleIcons = quickOverlayLayers.map((layer) => {
const isActive = visibleOverlayKeys.has(layer.key);
const icon = OVERLAY_KIND_ICONS[layer.kind] ?? 'list';
return onToggleOverlayLayer ? (
<ActionIcon
aria-label={getOverlayTooltip(layer, t, isActive)}
className={isActive ? styles.overlayToggleActive : undefined}
disabled={isActionsDisabled}
icon={icon}
iconProps={isActive ? { color: 'primary', size: 'lg' } : { size: 'lg' }}
key={layer.key}
onClick={() => onToggleOverlayLayer(layer.key)}
size="sm"
tooltip={{
label: getOverlayTooltip(layer, t, isActive),
openDelay: 0,
}}
variant="subtle"
/>
) : null;
});
const extraLayersPopover =
extraOverlayLayers.length > 0 && onToggleOverlayLayer ? (
<Popover position="top" withArrow>
<Popover.Target>
<ActionIcon
aria-label={t('page.fullscreenPlayer.lyricLayers')}
className={hasActiveExtraOverlay ? styles.overlayToggleActive : undefined}
disabled={isActionsDisabled}
icon="list"
iconProps={
hasActiveExtraOverlay
? { color: 'primary', size: 'lg' }
: { size: 'lg' }
}
size="sm"
tooltip={{
label: t('page.fullscreenPlayer.lyricLayers'),
openDelay: 0,
}}
variant="subtle"
/>
</Popover.Target>
<Popover.Dropdown maw={280} miw={220} onClick={(e) => e.stopPropagation()} p="sm">
<Stack gap="sm">
<Text fw={600} isNoSelect size="sm">
{t('page.fullscreenPlayer.lyricLayers')}
</Text>
{extraOverlayLayers.map((layer) => (
<div className={styles.layerRow} key={layer.key}>
<Text className={styles.layerLabel} isNoSelect size="sm">
{layer.label}
</Text>
<Switch
aria-label={layer.label}
checked={visibleOverlayKeys.has(layer.key)}
onChange={() => onToggleOverlayLayer(layer.key)}
/>
</div>
))}
</Stack>
</Popover.Dropdown>
</Popover>
) : null;
return (
<>
<div style={{ position: 'relative', width: '100%' }}>
{hasLyrics && (
<Center pb="md">
{languages.length > 1 && (
<Select
clearable={false}
data={languages}
onChange={(value) => setIndex(parseInt(value!, 10))}
style={{ bottom: 30, position: 'absolute' }}
value={index.toString()}
/>
)}
<div className={styles.root}>
{showTopRow ? (
<Group className={styles.topRow} gap="xs" justify="center">
{hasLyrics ? (
<Button
onClick={onExportLyrics}
size="compact-sm"
@@ -84,102 +242,82 @@ export const LyricsActions = ({
>
{t('form.lyricsExport.export')}
</Button>
</Center>
)}
<Group justify="center">
{isDesktop && sources.length ? (
<Button
disabled={isActionsDisabled}
onClick={() =>
openLyricSearchModal({
artist: currentSong?.artistName,
name: currentSong?.name,
onSearchOverride,
})
}
uppercase
variant="subtle"
>
{t('common.search')}
</Button>
) : null}
<ActionIcon
aria-label="Decrease lyric offset"
icon="minus"
onClick={() => handleLyricOffset(offsetMs - 50)}
tooltip={{
label: t('common.slower'),
openDelay: 0,
}}
variant="subtle"
/>
<Tooltip label={t('setting.lyricOffset')} openDelay={0}>
<NumberInput
aria-label="Lyric offset"
onChange={handleLyricOffset}
styles={{ input: { textAlign: 'center' } }}
value={offsetMs || 0}
width={70}
/>
</Tooltip>
<ActionIcon
aria-label="Increase lyric offset"
icon="plus"
onClick={() => handleLyricOffset(offsetMs + 50)}
tooltip={{
label: t('common.faster'),
openDelay: 0,
}}
variant="subtle"
/>
{isDesktop && sources.length ? (
<Button
disabled={isActionsDisabled}
onClick={onRemoveLyric}
uppercase
variant="subtle"
>
{hasLyrics ? t('common.clear') : t('common.refresh')}
</Button>
) : null}
{languageMenu}
{overlayToggleIcons}
{extraLayersPopover}
</Group>
<div style={{ position: 'absolute', right: 0, top: -50 }}>
<Group gap="xs">
{hasTranslationLayer && onToggleTranslationLayer ? (
<Button
disabled={isActionsDisabled}
onClick={onToggleTranslationLayer}
uppercase
variant={showTranslationLayer ? 'filled' : 'subtle'}
>
{t('common.translation')}
</Button>
) : null}
{hasPronunciationLayer && onTogglePronunciationLayer ? (
<Button
disabled={isActionsDisabled}
onClick={onTogglePronunciationLayer}
uppercase
variant={showPronunciationLayer ? 'filled' : 'subtle'}
>
Pronunciation
</Button>
) : null}
{isDesktop && sources.length && onTranslateLyric && !hasTranslationLayer ? (
<Button
disabled={isActionsDisabled}
onClick={onTranslateLyric}
uppercase
variant="subtle"
>
{t('common.translation')}
</Button>
) : null}
</Group>
</div>
</div>
</>
) : null}
<Group className={styles.controlsRow} gap="xs" justify="center">
{isDesktop && sources.length ? (
<Button
disabled={isActionsDisabled}
onClick={() =>
openLyricSearchModal({
artist: currentSong?.artistName,
name: currentSong?.name,
onSearchOverride,
})
}
uppercase
variant="subtle"
>
{t('common.search')}
</Button>
) : null}
<ActionIcon
aria-label="Decrease lyric offset"
disabled={isActionsDisabled}
icon="minus"
onClick={() => handleLyricOffset(offsetMs - 50)}
tooltip={{
label: t('common.slower'),
openDelay: 0,
}}
variant="subtle"
/>
<Tooltip label={t('setting.lyricOffset')} openDelay={0}>
<NumberInput
aria-label="Lyric offset"
disabled={isActionsDisabled}
onChange={handleLyricOffset}
styles={{ input: { textAlign: 'center' } }}
value={offsetMs || 0}
width={70}
/>
</Tooltip>
<ActionIcon
aria-label="Increase lyric offset"
disabled={isActionsDisabled}
icon="plus"
onClick={() => handleLyricOffset(offsetMs + 50)}
tooltip={{
label: t('common.faster'),
openDelay: 0,
}}
variant="subtle"
/>
{isDesktop && sources.length ? (
<Button
disabled={isActionsDisabled}
onClick={onRemoveLyric}
uppercase
variant="subtle"
>
{hasLyrics ? t('common.clear') : t('common.refresh')}
</Button>
) : null}
{isDesktop && sources.length && onTranslateLyric && !hasServerTranslationLayer ? (
<Button
disabled={isActionsDisabled}
onClick={onTranslateLyric}
uppercase
variant="subtle"
>
{t('common.translation')}
</Button>
) : null}
</Group>
</div>
);
};
+65 -26
View File
@@ -17,6 +17,7 @@ import {
formatStructuredLyricLabel,
getLyricLineText,
getLyricsLayers,
getOverlayLayerKey,
lyricsHasWordCues,
} from '/@/renderer/features/lyrics/api/lyrics-utils';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
@@ -72,8 +73,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const [index, setIndexState] = useState(0);
const [translatedLyrics, setTranslatedLyrics] = useState<null | string>(null);
const [showTranslation, setShowTranslation] = useState(false);
const [showTranslationLayer, setShowTranslationLayer] = useState(false);
const [showPronunciationLayer, setShowPronunciationLayer] = useState(false);
const [visibleOverlayKeys, setVisibleOverlayKeys] = useState<Set<string>>(new Set());
const [pendingSongId, setPendingSongId] = useState<string | undefined>(currentSong?.id);
const lyricsFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const previousSongIdRef = useRef<string | undefined>(currentSong?.id);
@@ -167,21 +167,53 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
return getLyricsLayers(data.local);
}, [data]);
const translationLyricsOverlay = useMemo(() => {
if (!showTranslationLayer || !layers?.translation?.synced) {
return null;
const overlayLayerToggles = useMemo(() => {
if (!layers?.overlayLayers.length) {
return [];
}
return layers.translation.lyrics;
}, [layers, showTranslationLayer]);
return layers.overlayLayers.map((layer) => ({
key: getOverlayLayerKey(layer),
kind: layer.synced ? (layer.kind ?? 'main') : 'main',
label: formatStructuredLyricLabel(layer),
}));
}, [layers]);
const visibleOverlayLayers = useMemo(() => {
if (!layers?.overlayLayers.length || !visibleOverlayKeys.size) {
return [];
}
return layers.overlayLayers.filter((layer) =>
visibleOverlayKeys.has(getOverlayLayerKey(layer)),
);
}, [layers, visibleOverlayKeys]);
const pronunciationLyricsOverlay = useMemo(() => {
if (!showPronunciationLayer || !layers?.pronunciation?.synced) {
return null;
}
const layer = visibleOverlayLayers.find(
(entry) => entry.synced && entry.kind === 'pronunciation',
);
return layers.pronunciation.lyrics;
}, [layers, showPronunciationLayer]);
return layer?.synced ? layer.lyrics : null;
}, [visibleOverlayLayers]);
const translationLyricsOverlay = useMemo(() => {
const layer = visibleOverlayLayers.find(
(entry) => entry.synced && entry.kind === 'translation',
);
return layer?.synced ? layer.lyrics : null;
}, [visibleOverlayLayers]);
const extraOverlayLyrics = useMemo(() => {
return visibleOverlayLayers
.filter(
(entry) =>
entry.synced && entry.kind !== 'pronunciation' && entry.kind !== 'translation',
)
.map((entry) => (entry.synced ? entry.lyrics : null))
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
}, [visibleOverlayLayers]);
const selectedAgents = useMemo(() => {
if (!lyrics || !('synced' in lyrics) || !lyrics.synced) {
@@ -217,6 +249,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
return {
...(displayLyrics as SynchronizedLyricsProps),
extraOverlayLyrics: isKaraoke ? extraOverlayLyrics : undefined,
offsetMs: displayOffsetMs,
pronunciationLyrics: pronunciationLyricsOverlay,
romajiLyrics:
@@ -226,19 +259,21 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
settingsKey,
syncedRomajiLyrics:
enableRomaji && !useServerPronunciation ? (syncedRomajiLyrics ?? null) : null,
translatedLyrics: showTranslation && !showTranslationLayer ? translatedLyrics : null,
translatedLyrics:
showTranslation && !translationLyricsOverlay ? translatedLyrics : null,
translationLyrics: translationLyricsOverlay,
};
}, [
displayLyrics,
displayOffsetMs,
enableRomaji,
extraOverlayLyrics,
isKaraoke,
pronunciationLyricsOverlay,
romajiConvertedLyrics,
syncedRomajiLyrics,
settingsKey,
showTranslation,
showTranslationLayer,
translatedLyrics,
translationLyricsOverlay,
useServerPronunciation,
@@ -349,13 +384,24 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
await fetchTranslation();
}, [translatedLyrics, showTranslation, fetchTranslation]);
const handleToggleOverlayLayer = useCallback((key: string) => {
setVisibleOverlayKeys((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
usePlayerEvents(
{
onCurrentSongChange: () => {
setIndexState(0);
setShowTranslation(false);
setShowTranslationLayer(false);
setShowPronunciationLayer(false);
setVisibleOverlayKeys(new Set());
setTranslatedLyrics(null);
},
},
@@ -483,30 +529,23 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
<div className={styles.actionsContainer}>
<LyricsActions
hasLyrics={!!displayLyrics}
hasPronunciationLayer={!!layers?.pronunciation?.synced}
hasTranslationLayer={!!layers?.translation?.synced}
index={indexToUse}
languages={languages}
offsetMs={displayOffsetMs}
onExportLyrics={handleExportLyrics}
onRemoveLyric={handleOnRemoveLyric}
onSearchOverride={handleOnSearchOverride}
onTogglePronunciationLayer={() =>
setShowPronunciationLayer((current) => !current)
}
onToggleTranslationLayer={() =>
setShowTranslationLayer((current) => !current)
}
onToggleOverlayLayer={handleToggleOverlayLayer}
onTranslateLyric={
translationApiProvider && translationApiKey
? handleOnTranslateLyric
: undefined
}
onUpdateOffset={handleUpdateOffset}
overlayLayers={overlayLayerToggles}
setIndex={setIndex}
settingsKey={settingsKey}
showPronunciationLayer={showPronunciationLayer}
showTranslationLayer={showTranslationLayer}
visibleOverlayKeys={visibleOverlayKeys}
/>
</div>
</div>
@@ -56,11 +56,26 @@
font-weight: 400;
}
.karaoke-word.karaoke-romaji-word {
.karaoke-word.karaoke-overlay-word {
font-size: inherit;
font-weight: 600;
}
.karaoke-word.karaoke-overlay-pronunciation {
font-size: 0.8em;
font-weight: 600;
}
.karaoke-word.karaoke-overlay-translation {
font-size: 0.85em;
font-weight: 500;
}
.karaoke-word.karaoke-overlay-generic {
font-size: 0.8em;
font-weight: 500;
}
.karaoke-word-text,
.karaoke-word-highlight {
grid-area: 1 / 1;
@@ -8,6 +8,7 @@ import {
findOverlayLineByTime,
getLyricLineStartMs,
getLyricLineText,
getOverlayCueLinesForLine,
normalizeLyrics,
} from '/@/renderer/features/lyrics/api/lyrics-utils';
import { LyricsScrollContent } from '/@/renderer/features/lyrics/components/lyrics-scroll-content';
@@ -30,6 +31,7 @@ import { PlayerStatus } from '/@/shared/types/types';
export interface SynchronizedKaraokeLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
agents?: LyricAgent[];
extraOverlayLyrics?: SynchronizedLyricsData[];
lyrics: SynchronizedLyricsData;
offsetMs?: number;
pronunciationLyrics?: null | SynchronizedLyricsData;
@@ -46,6 +48,7 @@ const SEEK_DETECT_THRESHOLD_MS = 500;
export const SynchronizedKaraokeLyrics = ({
agents,
artist,
extraOverlayLyrics,
lyrics,
name,
offsetMs,
@@ -278,10 +281,11 @@ export const SynchronizedKaraokeLyrics = ({
const getOverlayText = (
overlayLyrics: null | SynchronizedLyricsData | undefined,
startMs: number,
lineIndex: number,
fallback?: null | string,
) => {
if (overlayLyrics) {
return findOverlayLineByTime(overlayLyrics, startMs);
return findOverlayLineByTime(overlayLyrics, startMs, lineIndex);
}
return fallback;
@@ -320,16 +324,32 @@ export const SynchronizedKaraokeLyrics = ({
)}
{normalizedLyrics.map((rawLine, idx) => {
const lineStartMs = getLyricLineStartMs(rawLine);
const pronunciationCueLines = getOverlayCueLinesForLine(
pronunciationLyrics,
lineStartMs,
idx,
);
const translationCueLines = getOverlayCueLinesForLine(
translationLyrics,
lineStartMs,
idx,
);
const pronunciationText = getOverlayText(
pronunciationLyrics,
lineStartMs,
idx,
romajiLyrics?.[idx] ? getLyricLineText(romajiLyrics[idx]) : undefined,
);
const translationText = getOverlayText(
translationLyrics,
lineStartMs,
idx,
translatedLyrics?.split('\n')[idx],
);
const extraOverlays = extraOverlayLyrics?.map((overlayLyrics) => ({
cueLines: getOverlayCueLinesForLine(overlayLyrics, lineStartMs, idx),
text: getOverlayText(overlayLyrics, lineStartMs, idx),
}));
if (!rawLine.cueLines?.length) {
return (
@@ -354,13 +374,16 @@ export const SynchronizedKaraokeLyrics = ({
className="synchronized"
cueLines={rawLine.cueLines}
data-lyric-time={lineStartMs}
extraOverlays={extraOverlays}
fontSize={settings.fontSize}
id={`karaoke-line-${idx}`}
key={idx}
lineIndex={idx}
pronunciationCueLines={pronunciationCueLines}
pronunciationText={pronunciationText}
romajiCueLines={syncedRomajiLyrics?.[idx] ?? null}
romajiText={pronunciationText}
translatedText={translationText}
translationCueLines={translationCueLines}
/>
);
})}
@@ -26,6 +26,7 @@ import {
import { PlayerStatus } from '/@/shared/types/types';
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
extraOverlayLyrics?: SynchronizedLyricsData[];
lyrics: SynchronizedLyricsData;
offsetMs?: number;
pronunciationLyrics?: null | SynchronizedLyricsData;
@@ -223,10 +224,11 @@ export const SynchronizedLyrics = ({
const getOverlayText = (
overlayLyrics: null | SynchronizedLyricsData | undefined,
startMs: number,
lineIndex: number,
fallback?: null | string,
) => {
if (overlayLyrics) {
return findOverlayLineByTime(overlayLyrics, startMs);
return findOverlayLineByTime(overlayLyrics, startMs, lineIndex);
}
return fallback;
@@ -269,11 +271,13 @@ export const SynchronizedLyrics = ({
const pronunciationText = getOverlayText(
pronunciationLyrics,
lineStartMs,
idx,
romajiLyrics?.[idx] ? getLyricLineText(romajiLyrics[idx]) : undefined,
);
const translationText = getOverlayText(
translationLyrics,
lineStartMs,
idx,
translatedLyrics?.split('\n')[idx],
);
+4
View File
@@ -26,6 +26,7 @@ import {
LuArrowUpDown,
LuArrowUpNarrowWide,
LuArrowUpToLine,
LuAudioLines,
LuBookOpen,
LuBraces,
LuCamera,
@@ -69,6 +70,7 @@ import {
LuInfinity,
LuInfo,
LuKeyboard,
LuLanguages,
LuLayoutGrid,
LuLayoutList,
LuLibrary,
@@ -243,6 +245,7 @@ export const AppIcon = {
arrowUpS: LuChevronUp,
arrowUpToLine: LuArrowUpToLine,
artist: LuUserPen,
audioLines: LuAudioLines,
brandGitHub: LuGithub,
brandLastfm: LastfmLogoIcon,
brandListenBrainz: ListenBrainzLogoIcon,
@@ -290,6 +293,7 @@ export const AppIcon = {
itemSong: LuMusic,
json: LuBraces,
keyboard: LuKeyboard,
languages: LuLanguages,
lastPlayed: LuHeadphones,
layoutDetail: LuLayoutList,
layoutGrid: LuLayoutGrid,