diff --git a/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts b/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts index 85cedba46..74706673c 100644 --- a/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts +++ b/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts @@ -22,7 +22,7 @@ const DEFAULT_ENDING_THRESHOLD = 0.5; const DEFAULT_LINE_LEAD_TIME_MS = 800; const RICHSYNC_TIMING_OFFSET_MS = 150; const SYNC_TIMING_OFFSET_MS = 115; -const GLOW_DURATION_MULTIPLIER = 1.6; +const HIGHLIGHT_DURATION_MULTIPLIER = 1.6; export interface AnimEngineState { lastActiveElements: LineData[]; @@ -58,6 +58,7 @@ export interface LineData { export interface LyricsData { container: HTMLElement; + lineIdPrefix: 'karaoke-line' | 'lyric'; lines: LineData[]; syncType: LyricsSyncType; } @@ -199,14 +200,14 @@ const completeSungVisualCleanup = (element: HTMLElement): void => { element.classList.remove(PAUSED_CLASS); }; -const getRemainingGlowDurationSec = ( +const getRemainingHighlightDurationSec = ( durationSec: number, startTimeSec: number, interpolatedTimeSec: number, ): number => { - const glowDurationSec = durationSec * GLOW_DURATION_MULTIPLIER; + const highlightDurationSec = durationSec * HIGHLIGHT_DURATION_MULTIPLIER; const elapsedSec = Math.max(0, interpolatedTimeSec - startTimeSec); - return Math.max(0, glowDurationSec - elapsedSec); + return Math.max(0, highlightDurationSec - elapsedSec); }; const scheduleSungAnimationCleanup = ( @@ -215,7 +216,7 @@ const scheduleSungAnimationCleanup = ( startTimeSec: number, interpolatedTimeSec: number, ): void => { - const remainingSec = getRemainingGlowDurationSec( + const remainingSec = getRemainingHighlightDurationSec( durationSec, startTimeSec, interpolatedTimeSec, @@ -373,6 +374,7 @@ export const buildLyricsDataFromDom = ({ return { container, + lineIdPrefix, lines, syncType, }; @@ -612,7 +614,8 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): const interpolatedTimeSec = playbackTimeSec + timingOffsetMs / 1000; const leadTimeSec = (opts.lineLeadTimeMs ?? DEFAULT_LINE_LEAD_TIME_MS) / 1000; - const { lines, syncType } = lyricsData; + const { lineIdPrefix, lines, syncType } = lyricsData; + const isLineSyncMode = lineIdPrefix === 'lyric'; if (syncType === 'none') { return -1; } @@ -638,12 +641,16 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): const time = lineData.time; const nextTime = index + 1 < lines.length ? lines[index + 1].time : Number.POSITIVE_INFINITY; + const isInLineWindow = + playbackTimeSec < nextTime || playbackTimeSec < time + lineData.duration; - const isUpcoming = - playbackTimeSec >= time - leadTimeSec && - (playbackTimeSec < nextTime || playbackTimeSec < time + lineData.duration); + const isScrollCandidate = playbackTimeSec >= time - leadTimeSec && isInLineWindow; - if (isUpcoming) { + const isVisuallyActive = isLineSyncMode + ? playbackTimeSec >= time && isInLineWindow + : isScrollCandidate; + + if (isScrollCandidate) { activeElems.push(lineData); state.selectedElementIndex = index; activeLineIndex = index; @@ -651,19 +658,37 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): if (isPlaying && !lineData.isScrolled) { newLyricSelected = true; state.scroll.pendingScroll = true; - lineData.element.classList.add(LINE_ACTIVE_CLASS); lineData.isScrolled = true; onLineActive?.(index); + + if (!isLineSyncMode) { + lineData.element.classList.add(LINE_ACTIVE_CLASS); + } } } else if (lineData.isScrolled) { - lineData.element.classList.remove(LINE_ACTIVE_CLASS); lineData.isScrolled = false; + + if (!isLineSyncMode) { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + } + } + + if (isLineSyncMode) { + if (isVisuallyActive) { + lineData.element.classList.add(LINE_ACTIVE_CLASS); + } else { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + } } if (!isPlaying) { continue; } + if (isLineSyncMode && !lineData.hasWordCues) { + continue; + } + const setUpEarly = 2; const effectiveEnd = Math.max(nextTime, time + lineData.duration + 0.05); const isLineInRange = diff --git a/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts index 9d0aea64f..2ef116d3c 100644 --- a/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts +++ b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts @@ -13,7 +13,6 @@ import { tickLyricsAnimation, } from '/@/renderer/features/lyrics/hooks/lyrics-animation-engine'; import { SynchronizedLyrics } from '/@/shared/types/domain-types'; -import '/@/renderer/features/lyrics/styles/karaoke-animation.css'; export interface UseLyricsAnimationEngineOptions { animStateRef?: React.MutableRefObject; diff --git a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts index b4fcf12fe..e29c6a13d 100644 --- a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts +++ b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts @@ -127,9 +127,6 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu const resumeAutoscroll = useCallback(() => { userScrollingRef.current = false; resumeLyricsAutoscroll(scrollAnimStateRef.current); - - const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; - doc?.classList.remove('lyrics-user-scrolling'); }, []); const containerStyle = useMemo( @@ -198,7 +195,6 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu userScrollingRef.current = true; handleLyricsUserScroll(scrollAnimStateRef.current); - container.classList.add('lyrics-user-scrolling'); if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); @@ -206,7 +202,6 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu scrollTimeoutRef.current = setTimeout(() => { userScrollingRef.current = false; - container.classList.remove('lyrics-user-scrolling'); }, 3000); }; diff --git a/src/renderer/features/lyrics/karaoke-lyric-line.tsx b/src/renderer/features/lyrics/karaoke-lyric-line.tsx index 88af4f6ee..6d0e32cf0 100644 --- a/src/renderer/features/lyrics/karaoke-lyric-line.tsx +++ b/src/renderer/features/lyrics/karaoke-lyric-line.tsx @@ -99,29 +99,18 @@ const renderWordSpans = ( ...(durationMs > LONG_WORD_THRESHOLD_MS ? { 'data-long-word': true as const } : {}), }; - if (hasFurigana) { - return ( - - - - - ); - } - return ( - + + + + ); }); }; diff --git a/src/renderer/features/lyrics/styles/karaoke-animation.css b/src/renderer/features/lyrics/styles/karaoke-animation.css index e4add0fe1..e16681d1f 100644 --- a/src/renderer/features/lyrics/styles/karaoke-animation.css +++ b/src/renderer/features/lyrics/styles/karaoke-animation.css @@ -11,45 +11,39 @@ initial-value: 0; } -/* Container-level line context blur/fade (karaoke + line sync) */ -.synchronized-karaoke-lyrics:has(.lyrics-line-active):not(.lyrics-user-scrolling) - > :is(.karaoke-line, .lyric-line):not( +/* Container-level line context blur/fade (karaoke sync only) */ +.synchronized-karaoke-lyrics:has(.lyrics-line-active) + :is(.karaoke-line, .lyric-line):not( .lyrics-line-active, .lyrics-line-active ~ :is(.karaoke-line, .lyric-line) - ), -.synchronized-lyrics:has(.lyrics-line-active):not(.lyrics-user-scrolling) - > .lyric-line:not(.lyrics-line-active, .lyrics-line-active ~ .lyric-line) { - opacity: 0.33; + ) { + opacity: var(--lyric-opacity, 0.3); filter: blur(2.5px); } -.synchronized-karaoke-lyrics > :is(.karaoke-line, .lyric-line).lyrics-line-active, -.synchronized-lyrics > .lyric-line.lyrics-line-active { +.synchronized-karaoke-lyrics :is(.karaoke-line, .lyric-line).lyrics-line-active { opacity: 1 !important; filter: blur(0); } .synchronized-karaoke-lyrics:has(.lyrics-line-active) - > :is(.karaoke-line, .lyric-line).lyrics-line-active - ~ :is(.karaoke-line, .lyric-line), -.synchronized-lyrics:has(.lyrics-line-active) > .lyric-line.lyrics-line-active ~ .lyric-line { - opacity: 0.66; + :is(.karaoke-line, .lyric-line).lyrics-line-active + ~ :is(.karaoke-line, .lyric-line) { + opacity: var(--lyric-opacity, 0.3); filter: blur(0); } -.synchronized-karaoke-lyrics > :is(.karaoke-line, .lyric-line).lyrics-line-animating, -.synchronized-lyrics > .lyric-line.lyrics-line-animating { +.synchronized-karaoke-lyrics :is(.karaoke-line, .lyric-line).lyrics-line-animating { opacity: 1 !important; filter: blur(0) !important; } -.synchronized-karaoke-lyrics > :is(.karaoke-line, .lyric-line):hover, -.synchronized-lyrics > .lyric-line:hover { +.synchronized-karaoke-lyrics :is(.karaoke-line, .lyric-line):hover { opacity: 1 !important; filter: blur(0) !important; } -/* Word highlight via ::after pseudo-element gradient swipe */ +/* Word highlight via stacked text twin + gradient swipe */ .karaoke-word { position: relative; display: inline-block; @@ -57,129 +51,21 @@ color: rgb(from var(--theme-colors-foreground) r g b / var(--lyric-opacity, 0.3)); overflow-wrap: break-word; white-space: pre-wrap; - transform: translateY(0); +} + +.karaoke-word:has(.karaoke-word-highlight) { + display: inline-grid; + vertical-align: baseline; } .karaoke-word.sung { color: var(--theme-colors-foreground); } -.karaoke-line.lyrics-line-pre-animating, -.karaoke-line.lyrics-line-pre-animating .karaoke-word { +.karaoke-line.lyrics-line-pre-animating { will-change: transform; } -.karaoke-word.lyrics-word-animating { - animation: karaoke-wobble var(--karaoke-wobble-duration, 1s) forwards ease; - animation-delay: var(--karaoke-anim-delay, 0s); - animation-play-state: running; -} - -.karaoke-word.karaoke-furigana-word.lyrics-word-animating { - transform: none; - animation: none; -} - -.karaoke-word.lyrics-word-animating.lyrics-word-paused { - animation-play-state: paused; -} - -.karaoke-word::after { - position: absolute; - top: -2rem; - left: -2rem; - box-sizing: content-box; - width: 100%; - padding: 2rem; - color: transparent; - white-space: pre-wrap; - pointer-events: none; - content: attr(data-content); - background-image: linear-gradient( - 90deg, - var(--theme-colors-foreground) - calc( - 100% * var(--lyric-transition-amount-start) - 4rem * - var(--lyric-transition-amount-start) + 2rem - ), - #0000 - calc( - 100% * var(--lyric-transition-amount-end) - 4rem * - var(--lyric-transition-amount-end) + 2rem + 1px - ) - ); - background-clip: text; - opacity: 0; - transition: - --lyric-transition-amount-start 1s linear 1000s, - --lyric-transition-amount-end 1s linear 1000s, - opacity 0.5s ease; - - --lyric-transition-amount-start: -0.2; - --lyric-transition-amount-end: -0.1; - --karaoke-glow-color: color(display-p3 1 1 1 / 0.01%); -} - -.karaoke-word[data-long-word]::after { - --karaoke-glow-color: color(display-p3 1 1 1 / 100%); -} - -.karaoke-word.lyrics-word-pre-animating:not(.lyrics-word-animating)::after { - opacity: 0; - transition: none; - - --lyric-transition-amount-start: -0.2; - --lyric-transition-amount-end: -0.1; -} - -.karaoke-word.lyrics-word-pre-animating:not(.lyrics-word-animating, .karaoke-zero-dur)::after { - opacity: 1; -} - -.karaoke-word.lyrics-word-animating::after { - opacity: 1; - transition-delay: - var(--karaoke-swipe-delay, 0s), var(--karaoke-swipe-delay, 0s), - var(--karaoke-anim-delay, 0s); - transition-timing-function: linear, linear, ease; - transition-duration: - calc(var(--karaoke-duration, 1ms) * 1.6), calc(var(--karaoke-duration, 1ms) * 1.6), - var(--karaoke-highlight-fade-in-duration, 0.33s); - transition-property: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; - animation: karaoke-glow calc(var(--karaoke-duration, 1ms) * 1.6) forwards ease-out; - animation-delay: var(--karaoke-anim-delay, 0s); - animation-play-state: running; - will-change: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; - - --lyric-transition-amount-start: 1.4; - --lyric-transition-amount-end: 1.5; -} - -.karaoke-word.lyrics-word-animating.lyrics-word-paused::after { - opacity: 0.5; - transition-duration: 100000000s, 100000000s, 100000000s; - animation-play-state: paused; - - --lyric-transition-amount-start: 10; - --lyric-transition-amount-end: 10; -} - -.karaoke-word.karaoke-rtl::after { - background-image: linear-gradient( - 270deg, - var(--theme-colors-foreground) - calc( - 100% * var(--lyric-transition-amount-start) - 4rem * - var(--lyric-transition-amount-start) + 2rem - ), - #0000 - calc( - 100% * var(--lyric-transition-amount-end) - 4rem * - var(--lyric-transition-amount-end) + 2rem + 1px - ) - ); -} - .karaoke-word.karaoke-bg-vocal { font-size: 0.675em; font-weight: 400; @@ -190,62 +76,12 @@ font-weight: 600; } -.karaoke-word.karaoke-romaji-word::after { - top: -1.25em; - left: -1.25em; - padding: 1.25em; - background-image: linear-gradient( - 90deg, - var(--theme-colors-foreground) - calc( - 100% * var(--lyric-transition-amount-start) - 2.5em * - var(--lyric-transition-amount-start) + 1.25em - ), - #0000 - calc( - 100% * var(--lyric-transition-amount-end) - 2.5em * - var(--lyric-transition-amount-end) + 1.25em + 1px - ) - ); -} - -.karaoke-word.karaoke-romaji-word.karaoke-rtl::after { - background-image: linear-gradient( - 270deg, - var(--theme-colors-foreground) - calc( - 100% * var(--lyric-transition-amount-start) - 2.5em * - var(--lyric-transition-amount-start) + 1.25em - ), - #0000 - calc( - 100% * var(--lyric-transition-amount-end) - 2.5em * - var(--lyric-transition-amount-end) + 1.25em + 1px - ) - ); -} - -/* Furigana words use an HTML highlight twin instead of ::after */ -.karaoke-word:has(.karaoke-word-highlight) { - display: inline-block; -} - -.karaoke-word:has(.karaoke-word-highlight)::after { - display: none; - content: none; - opacity: 0; -} - -.karaoke-word-text { - display: inline; +.karaoke-word-text, +.karaoke-word-highlight { + grid-area: 1 / 1; } .karaoke-word-highlight { - position: absolute; - inset: 0; - box-sizing: border-box; - width: 100%; - padding: 0; color: transparent; overflow-wrap: break-word; white-space: pre-wrap; @@ -264,11 +100,6 @@ --lyric-transition-amount-start: -0.2; --lyric-transition-amount-end: -0.1; - --karaoke-glow-color: color(display-p3 1 1 1 / 0.01%); -} - -.karaoke-word[data-long-word] .karaoke-word-highlight { - --karaoke-glow-color: color(display-p3 1 1 1 / 100%); } .karaoke-word.lyrics-word-pre-animating:not(.lyrics-word-animating) .karaoke-word-highlight { @@ -294,9 +125,6 @@ calc(var(--karaoke-duration, 1ms) * 1.6), calc(var(--karaoke-duration, 1ms) * 1.6), var(--karaoke-highlight-fade-in-duration, 0.33s); transition-property: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; - animation: karaoke-glow calc(var(--karaoke-duration, 1ms) * 1.6) forwards ease-out; - animation-delay: var(--karaoke-anim-delay, 0s); - animation-play-state: running; will-change: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; --lyric-transition-amount-start: 1.4; @@ -306,7 +134,6 @@ .karaoke-word.lyrics-word-animating.lyrics-word-paused .karaoke-word-highlight { opacity: 0.5; transition-duration: 100000000s, 100000000s, 100000000s; - animation-play-state: paused; --lyric-transition-amount-start: 10; --lyric-transition-amount-end: 10; @@ -338,23 +165,3 @@ .karaoke-word ruby rp { display: none; } - -@keyframes karaoke-wobble { - 0% { - transform: translateY(0); - } - - 100% { - transform: translateY(-0.0375em); - } -} - -@keyframes karaoke-glow { - 0% { - filter: drop-shadow(0 0 0.8rem var(--karaoke-glow-color)); - } - - 100% { - filter: drop-shadow(0 0 0 var(--karaoke-glow-color)); - } -} diff --git a/src/renderer/features/lyrics/styles/synchronized-lyrics-animation.css b/src/renderer/features/lyrics/styles/synchronized-lyrics-animation.css new file mode 100644 index 000000000..f0e70ed55 --- /dev/null +++ b/src/renderer/features/lyrics/styles/synchronized-lyrics-animation.css @@ -0,0 +1,33 @@ +/* Container-level line context blur/fade (line-sync only) */ +.synchronized-lyrics .lyric-line { + transition: + filter 0.2s ease, + opacity 0.2s ease, + transform 0.2s ease; +} + +.synchronized-lyrics:has(.lyrics-line-active) + .lyric-line:not(.lyrics-line-active, .lyrics-line-active ~ .lyric-line) { + opacity: var(--lyric-opacity, 0.3); + filter: blur(2.5px); +} + +.synchronized-lyrics .lyric-line.lyrics-line-active { + opacity: 1 !important; + filter: blur(0); +} + +.synchronized-lyrics:has(.lyrics-line-active) .lyric-line.lyrics-line-active ~ .lyric-line { + opacity: var(--lyric-opacity, 0.3); + filter: blur(0); +} + +.synchronized-lyrics .lyric-line.lyrics-line-animating { + opacity: 1 !important; + filter: blur(0) !important; +} + +.synchronized-lyrics .lyric-line:hover { + opacity: 1 !important; + filter: blur(0) !important; +} diff --git a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx index 116119bae..317cb80c1 100644 --- a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import styles from './synchronized-karaoke-lyrics.module.css'; +import '/@/renderer/features/lyrics/styles/karaoke-animation.css'; import { findOverlayLineByTime, getLyricLineStartMs, diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 28d48180c..86e29a172 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import styles from './synchronized-lyrics.module.css'; +import '/@/renderer/features/lyrics/styles/synchronized-lyrics-animation.css'; import { findOverlayLineByTime, getLyricLineStartMs,