clean up animations

This commit is contained in:
jeffvli
2026-07-09 14:08:23 -07:00
parent c0c03bc414
commit 63f6a6620d
8 changed files with 104 additions and 254 deletions
@@ -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 =
@@ -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<AnimEngineState>;
@@ -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);
};
@@ -99,29 +99,18 @@ const renderWordSpans = (
...(durationMs > LONG_WORD_THRESHOLD_MS ? { 'data-long-word': true as const } : {}),
};
if (hasFurigana) {
return (
<span key={wordKey} {...wordProps}>
<span
className="karaoke-word-text"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
<span
aria-hidden
className="karaoke-word-highlight"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
</span>
);
}
return (
<span
key={wordKey}
{...wordProps}
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
data-content={word.text}
/>
<span key={wordKey} {...wordProps}>
<span
className="karaoke-word-text"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
<span
aria-hidden
className="karaoke-word-highlight"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
</span>
);
});
};
@@ -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));
}
}
@@ -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;
}
@@ -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,
@@ -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,