mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-08 05:12:57 +02:00
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import clsx from 'clsx';
|
|
import { ComponentPropsWithoutRef, memo, useMemo } from 'react';
|
|
|
|
import styles from './karaoke-lyric-line.module.css';
|
|
|
|
import { testRtl } from '/@/renderer/features/lyrics/api/lyrics-rtl';
|
|
import { splitWordCues } from '/@/renderer/features/lyrics/api/split-word-cue';
|
|
import { sanitize } from '/@/renderer/utils/sanitize';
|
|
import { Box } from '/@/shared/components/box/box';
|
|
import { Stack } from '/@/shared/components/stack/stack';
|
|
import { LyricAgent, SyncedCueLine } from '/@/shared/types/domain-types';
|
|
|
|
const LONG_WORD_THRESHOLD_MS = 1500;
|
|
const FURIGANA_HTML_RE = /<ruby[\s>]/i;
|
|
|
|
const hasFuriganaHtml = (text: string): boolean => FURIGANA_HTML_RE.test(text);
|
|
|
|
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' | '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,
|
|
lineIndex: number,
|
|
cueLineIndex: number,
|
|
isBackground: boolean,
|
|
variant: WordSpanVariant = 'main',
|
|
) => {
|
|
const isOverlay = variant !== 'main';
|
|
const idPrefix = getWordSpanIdPrefix(variant);
|
|
|
|
if (!cueLine.words.length) {
|
|
return (
|
|
<span
|
|
className={getWordSpanClasses(variant, {
|
|
hasFurigana: false,
|
|
isBackground,
|
|
isRtl: false,
|
|
isZeroDuration: false,
|
|
})}
|
|
dangerouslySetInnerHTML={{ __html: sanitize(cueLine.value) }}
|
|
data-lyric-time={cueLine.startMs}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const splitWords =
|
|
isOverlay || cueLine.words.some((word) => hasFuriganaHtml(word.text))
|
|
? cueLine.words
|
|
: splitWordCues(cueLine.words);
|
|
let wordCounter = 0;
|
|
|
|
return splitWords.map((word) => {
|
|
const durationMs = word.endMs - word.startMs;
|
|
const durationSec = durationMs / 1000;
|
|
const timeSec = word.startMs / 1000;
|
|
const isRtl = testRtl(word.text);
|
|
const isZeroDuration = durationMs <= 0;
|
|
const hasFurigana = !isOverlay && hasFuriganaHtml(word.text);
|
|
const sanitizedHtml = sanitize(word.text);
|
|
const currentWordIndex = wordCounter;
|
|
wordCounter += 1;
|
|
|
|
const wordClassName = getWordSpanClasses(variant, {
|
|
hasFurigana,
|
|
isBackground,
|
|
isRtl,
|
|
isZeroDuration,
|
|
});
|
|
|
|
const wordKey = `${word.startMs}-${currentWordIndex}`;
|
|
const wordProps = {
|
|
className: wordClassName,
|
|
'data-duration': String(durationSec),
|
|
'data-lyric-time': cueLine.startMs,
|
|
'data-time': String(timeSec),
|
|
'data-word-start': word.startMs,
|
|
id: `${idPrefix}-${lineIndex}-cue-${cueLineIndex}-word-${currentWordIndex}`,
|
|
style: {
|
|
'--karaoke-duration': `${durationMs}ms`,
|
|
} as React.CSSProperties,
|
|
...(durationMs > LONG_WORD_THRESHOLD_MS ? { 'data-long-word': true as const } : {}),
|
|
};
|
|
|
|
return (
|
|
<span key={wordKey} {...wordProps}>
|
|
<span
|
|
className="karaoke-word-text"
|
|
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
|
/>
|
|
<span
|
|
aria-hidden
|
|
className="karaoke-word-highlight"
|
|
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
|
/>
|
|
</span>
|
|
);
|
|
});
|
|
};
|
|
|
|
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,
|
|
text,
|
|
translatedText,
|
|
translationCueLines,
|
|
...props
|
|
}: KaraokeLyricLineProps) => {
|
|
const style = useMemo(
|
|
() => ({
|
|
fontSize,
|
|
textAlign: alignment,
|
|
}),
|
|
[fontSize, alignment],
|
|
);
|
|
|
|
const hasSyncedRomajiOverlay = romajiCueLines !== undefined;
|
|
const hasSyncedPronunciation = hasSyncedOverlayCueLines(pronunciationCueLines);
|
|
const hasSyncedTranslation = hasSyncedOverlayCueLines(translationCueLines);
|
|
const pronunciationFallbackText =
|
|
!hasSyncedRomajiOverlay && !hasSyncedPronunciation
|
|
? (pronunciationText ?? romajiText)
|
|
: null;
|
|
|
|
return (
|
|
<Box
|
|
className={clsx(styles.karaokeLine, 'karaoke-line', className)}
|
|
style={style}
|
|
{...props}
|
|
>
|
|
<Stack align="stretch" gap={0} w="100%">
|
|
{cueLines.map((cueLine, cueLineIndex) => {
|
|
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
|
|
className={styles.agentLine}
|
|
data-agent-role={agent?.role}
|
|
key={`${cueLine.index}-${cueLineIndex}`}
|
|
>
|
|
<span
|
|
className={styles.agentText}
|
|
id={
|
|
cueLineIndex === 0
|
|
? `karaoke-anchor-${lineIndex}`
|
|
: undefined
|
|
}
|
|
>
|
|
{renderWordSpans(
|
|
cueLine,
|
|
lineIndex,
|
|
cueLineIndex,
|
|
isBackground,
|
|
'main',
|
|
)}
|
|
</span>
|
|
{romajiCueLine && (
|
|
<span className={styles.overlayLine}>
|
|
{renderWordSpans(
|
|
romajiCueLine,
|
|
lineIndex,
|
|
cueLineIndex,
|
|
false,
|
|
'overlay-pronunciation',
|
|
)}
|
|
</span>
|
|
)}
|
|
{!romajiCueLine &&
|
|
pronunciationCueLine &&
|
|
renderOverlayCueLineRow(
|
|
pronunciationCueLine,
|
|
lineIndex,
|
|
cueLineIndex,
|
|
'overlay-pronunciation',
|
|
styles.overlayLine,
|
|
)}
|
|
{translationCueLine &&
|
|
translationCueLine.value !== cueLine.value &&
|
|
renderOverlayCueLineRow(
|
|
translationCueLine,
|
|
lineIndex,
|
|
cueLineIndex,
|
|
'overlay-translation',
|
|
styles.translationLine,
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{pronunciationFallbackText && (
|
|
<span
|
|
className={styles.overlayLine}
|
|
dangerouslySetInnerHTML={{
|
|
__html: sanitize(pronunciationFallbackText),
|
|
}}
|
|
/>
|
|
)}
|
|
{!hasSyncedTranslation && translatedText && translatedText !== text && (
|
|
<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>
|
|
);
|
|
},
|
|
);
|
|
|
|
KaraokeLyricLine.displayName = 'KaraokeLyricLine';
|