diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0409b859b..79358c089 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/features/lyrics/api/lyrics-utils.ts b/src/renderer/features/lyrics/api/lyrics-utils.ts index 6ab2e61ee..210a7df37 100644 --- a/src/renderer/features/lyrics/api/lyrics-utils.ts +++ b/src/renderer/features/lyrics/api/lyrics-utils.ts @@ -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})`; diff --git a/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts b/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts index 74706673c..8032d37dd 100644 --- a/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts +++ b/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts @@ -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( - '.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( + '.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('.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('.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('.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); } }; diff --git a/src/renderer/features/lyrics/karaoke-lyric-line.module.css b/src/renderer/features/lyrics/karaoke-lyric-line.module.css index 8df0b8263..cd7942f3d 100644 --- a/src/renderer/features/lyrics/karaoke-lyric-line.module.css +++ b/src/renderer/features/lyrics/karaoke-lyric-line.module.css @@ -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%; diff --git a/src/renderer/features/lyrics/karaoke-lyric-line.tsx b/src/renderer/features/lyrics/karaoke-lyric-line.tsx index 4eac9f020..c104b0342 100644 --- a/src/renderer/features/lyrics/karaoke-lyric-line.tsx +++ b/src/renderer/features/lyrics/karaoke-lyric-line.tsx @@ -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 ( @@ -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, +) => ( + + {renderWordSpans(cueLine, lineIndex, cueLineIndex, false, variant)} + +); + +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 ( 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 (
{romajiCueLine && ( - + {renderWordSpans( romajiCueLine, lineIndex, cueLineIndex, false, - 'romaji', + 'overlay-pronunciation', )} )} + {!romajiCueLine && + pronunciationCueLine && + renderOverlayCueLineRow( + pronunciationCueLine, + lineIndex, + cueLineIndex, + 'overlay-pronunciation', + styles.overlayLine, + )} + {translationCueLine && + renderOverlayCueLineRow( + translationCueLine, + lineIndex, + cueLineIndex, + 'overlay-translation', + styles.translationLine, + )}
); })} - {!hasSyncedRomaji && romajiText && ( + {pronunciationFallbackText && ( )} - {translatedText && ( - + {!hasSyncedTranslation && translatedText && ( + )} + {extraOverlays?.map((overlay, overlayIndex) => { + const hasSyncedExtra = hasSyncedOverlayCueLines(overlay.cueLines); + + if (hasSyncedExtra && overlay.cueLines) { + return overlay.cueLines.map((cueLine, cueLineIndex) => ( + + {renderWordSpans( + cueLine, + lineIndex, + cueLineIndex, + false, + 'overlay-generic', + )} + + )); + } + + if (overlay.text) { + return ( + + ); + } + + return null; + })}
); diff --git a/src/renderer/features/lyrics/lyrics-actions.module.css b/src/renderer/features/lyrics/lyrics-actions.module.css new file mode 100644 index 000000000..e18013f73 --- /dev/null +++ b/src/renderer/features/lyrics/lyrics-actions.module.css @@ -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; +} diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index 9f75dd96e..a39f5068a 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -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; } +const OVERLAY_KIND_ICONS: Partial> = { + 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 ? ( + + + + + + {languages.map((language) => ( + setIndex(parseInt(language.value, 10))} + > + {language.label} + + ))} + + + ) : null; + + const overlayToggleIcons = quickOverlayLayers.map((layer) => { + const isActive = visibleOverlayKeys.has(layer.key); + const icon = OVERLAY_KIND_ICONS[layer.kind] ?? 'list'; + + return onToggleOverlayLayer ? ( + onToggleOverlayLayer(layer.key)} + size="sm" + tooltip={{ + label: getOverlayTooltip(layer, t, isActive), + openDelay: 0, + }} + variant="subtle" + /> + ) : null; + }); + + const extraLayersPopover = + extraOverlayLayers.length > 0 && onToggleOverlayLayer ? ( + + + + + e.stopPropagation()} p="sm"> + + + {t('page.fullscreenPlayer.lyricLayers')} + + {extraOverlayLayers.map((layer) => ( +
+ + {layer.label} + + onToggleOverlayLayer(layer.key)} + /> +
+ ))} +
+
+
+ ) : null; return ( - <> -
- {hasLyrics && ( -
- {languages.length > 1 && ( -