mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-08 13:23:16 +02:00
support additional lyric layers (#2211)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user