mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 02:16:39 +02:00
implement OS enhanced/karaoke lyrics
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
import {
|
||||
animateScrollTop,
|
||||
computeScrollDurationMs,
|
||||
} from '/@/renderer/features/lyrics/api/lyrics-scroll';
|
||||
import {
|
||||
getLineEndMs,
|
||||
getLyricLineStartMs,
|
||||
lyricsHasWordCues,
|
||||
} from '/@/renderer/features/lyrics/api/lyrics-utils';
|
||||
import { SynchronizedLyrics } from '/@/shared/types/domain-types';
|
||||
|
||||
const ANIMATING_CLASS = 'lyrics-word-animating';
|
||||
const PRE_ANIMATING_CLASS = 'lyrics-word-pre-animating';
|
||||
const PAUSED_CLASS = 'lyrics-word-paused';
|
||||
const LINE_ACTIVE_CLASS = 'lyrics-line-active';
|
||||
const LINE_ANIMATING_CLASS = 'lyrics-line-animating';
|
||||
const LINE_PRE_ANIMATING_CLASS = 'lyrics-line-pre-animating';
|
||||
|
||||
const TIME_JUMP_THRESHOLD = 0.5;
|
||||
const DEFAULT_ENDING_THRESHOLD = 0.5;
|
||||
const DEFAULT_LINE_LEAD_TIME_MS = 800;
|
||||
const DEFAULT_SCROLL_POS_RATIO = 0.37;
|
||||
const RICHSYNC_TIMING_OFFSET_MS = 150;
|
||||
const SYNC_TIMING_OFFSET_MS = 115;
|
||||
const GLOW_DURATION_MULTIPLIER = 1.6;
|
||||
|
||||
export interface AnimEngineState {
|
||||
lastActiveElements: LineData[];
|
||||
lastEventCreationTime: number;
|
||||
lastPlayState: boolean;
|
||||
lastTime: number;
|
||||
scroll: ScrollState;
|
||||
selectedElementIndex: number;
|
||||
}
|
||||
|
||||
export interface BuildLyricsDataOptions {
|
||||
container: HTMLElement;
|
||||
lineIdPrefix: 'karaoke-line' | 'lyric';
|
||||
lyrics: SynchronizedLyrics;
|
||||
}
|
||||
|
||||
export interface LineData {
|
||||
accumulatedOffsetMs: number;
|
||||
animationStartTimeMs: number;
|
||||
duration: number;
|
||||
element: HTMLElement;
|
||||
hasWordCues: boolean;
|
||||
height: number;
|
||||
isAnimating: boolean;
|
||||
isAnimationPlayStatePlaying: boolean;
|
||||
isScrolled: boolean;
|
||||
isSelected: boolean;
|
||||
lastAnimSetupAt: number;
|
||||
parts: PartData[];
|
||||
position: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface LyricsData {
|
||||
container: HTMLElement;
|
||||
lines: LineData[];
|
||||
syncType: LyricsSyncType;
|
||||
}
|
||||
|
||||
export type LyricsSyncType = 'none' | 'richsync' | 'synced';
|
||||
|
||||
export interface PartData {
|
||||
animationStartTimeMs: number;
|
||||
duration: number;
|
||||
element: HTMLElement;
|
||||
isAnimating: boolean;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface ScrollState {
|
||||
cancelScroll: (() => void) | null;
|
||||
doneFirstInstantScroll: boolean;
|
||||
nextScrollAllowedTime: number;
|
||||
pendingScroll: boolean;
|
||||
programmaticScrollUntil: number;
|
||||
queuedScroll: boolean;
|
||||
scrollPos: number;
|
||||
scrollResumeTime: number;
|
||||
skipScrolls: number;
|
||||
skipScrollsDecayTimes: number[];
|
||||
wasUserScrolling: boolean;
|
||||
}
|
||||
|
||||
export interface TickOptions {
|
||||
currentTimeMs: number;
|
||||
eventCreationTime: number;
|
||||
follow?: boolean;
|
||||
forceResync?: boolean;
|
||||
isPlaying: boolean;
|
||||
lineLeadTimeMs?: number;
|
||||
lyricsData: LyricsData;
|
||||
onLineActive?: (lineIndex: number) => void;
|
||||
scrollContainer: HTMLElement;
|
||||
smoothScroll?: boolean;
|
||||
}
|
||||
|
||||
const reflow = (element: HTMLElement): void => {
|
||||
void element.offsetHeight;
|
||||
};
|
||||
|
||||
export const createScrollState = (): ScrollState => ({
|
||||
cancelScroll: null,
|
||||
doneFirstInstantScroll: false,
|
||||
nextScrollAllowedTime: 0,
|
||||
pendingScroll: false,
|
||||
programmaticScrollUntil: 0,
|
||||
queuedScroll: false,
|
||||
scrollPos: -1,
|
||||
scrollResumeTime: 0,
|
||||
skipScrolls: 0,
|
||||
skipScrollsDecayTimes: [],
|
||||
wasUserScrolling: false,
|
||||
});
|
||||
|
||||
export const createAnimEngineState = (): AnimEngineState => ({
|
||||
lastActiveElements: [],
|
||||
lastEventCreationTime: 0,
|
||||
lastPlayState: false,
|
||||
lastTime: 0,
|
||||
scroll: createScrollState(),
|
||||
selectedElementIndex: 0,
|
||||
});
|
||||
|
||||
export const resetAnimEngine = (state: AnimEngineState): void => {
|
||||
cancelActiveLyricsScroll(state.scroll);
|
||||
state.lastActiveElements = [];
|
||||
state.scroll.skipScrollsDecayTimes = [];
|
||||
state.scroll.doneFirstInstantScroll = false;
|
||||
state.scroll.pendingScroll = false;
|
||||
state.scroll.queuedScroll = false;
|
||||
};
|
||||
|
||||
const collectLineParts = (lineElement: HTMLElement): PartData[] => {
|
||||
const words = lineElement.querySelectorAll<HTMLElement>(
|
||||
'.karaoke-word[data-duration]:not(.karaoke-romaji-word)',
|
||||
);
|
||||
|
||||
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 getRomajiCounterparts = (mainElement: HTMLElement): HTMLElement[] => {
|
||||
const line = mainElement.closest('.karaoke-line');
|
||||
if (!line) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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<HTMLElement>('.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;
|
||||
});
|
||||
};
|
||||
|
||||
const cancelSungAnimationCleanup = (element: HTMLElement): void => {
|
||||
const existingTimeout = Number(element.dataset.sungCleanupTimeout);
|
||||
if (existingTimeout) {
|
||||
window.clearTimeout(existingTimeout);
|
||||
delete element.dataset.sungCleanupTimeout;
|
||||
}
|
||||
};
|
||||
|
||||
const completeSungVisualCleanup = (element: HTMLElement): void => {
|
||||
cancelSungAnimationCleanup(element);
|
||||
element.style.removeProperty('--karaoke-swipe-delay');
|
||||
element.style.removeProperty('--karaoke-anim-delay');
|
||||
element.classList.remove(ANIMATING_CLASS);
|
||||
element.classList.remove(PRE_ANIMATING_CLASS);
|
||||
element.classList.remove(PAUSED_CLASS);
|
||||
};
|
||||
|
||||
const getRemainingGlowDurationSec = (
|
||||
durationSec: number,
|
||||
startTimeSec: number,
|
||||
interpolatedTimeSec: number,
|
||||
): number => {
|
||||
const glowDurationSec = durationSec * GLOW_DURATION_MULTIPLIER;
|
||||
const elapsedSec = Math.max(0, interpolatedTimeSec - startTimeSec);
|
||||
return Math.max(0, glowDurationSec - elapsedSec);
|
||||
};
|
||||
|
||||
const scheduleSungAnimationCleanup = (
|
||||
element: HTMLElement,
|
||||
durationSec: number,
|
||||
startTimeSec: number,
|
||||
interpolatedTimeSec: number,
|
||||
): void => {
|
||||
const remainingSec = getRemainingGlowDurationSec(
|
||||
durationSec,
|
||||
startTimeSec,
|
||||
interpolatedTimeSec,
|
||||
);
|
||||
|
||||
if (remainingSec <= 0.05) {
|
||||
completeSungVisualCleanup(element);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelSungAnimationCleanup(element);
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
delete element.dataset.sungCleanupTimeout;
|
||||
completeSungVisualCleanup(element);
|
||||
}, remainingSec * 1000);
|
||||
|
||||
element.dataset.sungCleanupTimeout = String(timeoutId);
|
||||
};
|
||||
|
||||
const clearElementAnimation = (element: HTMLElement): void => {
|
||||
cancelSungAnimationCleanup(element);
|
||||
element.style.removeProperty('--karaoke-swipe-delay');
|
||||
element.style.removeProperty('--karaoke-anim-delay');
|
||||
element.classList.remove(ANIMATING_CLASS);
|
||||
element.classList.remove(PRE_ANIMATING_CLASS);
|
||||
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 syncRomajiAnimation = (
|
||||
mainElement: HTMLElement,
|
||||
interpolatedTimeSec: number,
|
||||
action: 'clear' | 'clearSung' | 'pause' | 'resume' | 'setup' | 'sung',
|
||||
): 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearLineRomajiState = (lineElement: HTMLElement): void => {
|
||||
for (const element of lineElement.querySelectorAll<HTMLElement>('.karaoke-romaji-word')) {
|
||||
clearElementAnimation(element);
|
||||
element.classList.remove('sung');
|
||||
}
|
||||
};
|
||||
|
||||
export const buildLyricsDataFromDom = ({
|
||||
container,
|
||||
lineIdPrefix,
|
||||
lyrics,
|
||||
}: BuildLyricsDataOptions): LyricsData | null => {
|
||||
const hasWordCues = lyricsHasWordCues(lyrics);
|
||||
const lines: LineData[] = [];
|
||||
|
||||
for (let index = 0; index < lyrics.length; index += 1) {
|
||||
const lineElement = document.getElementById(`${lineIdPrefix}-${index}`);
|
||||
if (!lineElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = lyrics[index];
|
||||
const startMs = getLyricLineStartMs(line);
|
||||
const endMs = getLineEndMs(line);
|
||||
const durationMs = Math.max(0, endMs - startMs);
|
||||
const parts = hasWordCues && line.cueLines?.length ? collectLineParts(lineElement) : [];
|
||||
|
||||
lines.push({
|
||||
accumulatedOffsetMs: 0,
|
||||
animationStartTimeMs: Number.POSITIVE_INFINITY,
|
||||
duration: durationMs / 1000,
|
||||
element: lineElement,
|
||||
hasWordCues: parts.length > 0,
|
||||
height: -1,
|
||||
isAnimating: false,
|
||||
isAnimationPlayStatePlaying: false,
|
||||
isScrolled: false,
|
||||
isSelected: false,
|
||||
lastAnimSetupAt: 0,
|
||||
parts,
|
||||
position: -1,
|
||||
time: startMs / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!lines.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allZero = lines.every((line) => line.time === 0);
|
||||
let syncType: LyricsSyncType = allZero ? 'none' : 'synced';
|
||||
if (hasWordCues) {
|
||||
syncType = 'richsync';
|
||||
}
|
||||
|
||||
return {
|
||||
container,
|
||||
lines,
|
||||
syncType,
|
||||
};
|
||||
};
|
||||
|
||||
export const recalculateLinePositions = (lyricsData: LyricsData): void => {
|
||||
const { container, lines } = lyricsData;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
for (const line of lines) {
|
||||
const rect = line.element.getBoundingClientRect();
|
||||
line.position = rect.top - containerRect.top + container.scrollTop;
|
||||
line.height = rect.height;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPartAnimation = (part: PartData): void => {
|
||||
cancelSungAnimationCleanup(part.element);
|
||||
part.element.style.removeProperty('--karaoke-swipe-delay');
|
||||
part.element.style.removeProperty('--karaoke-anim-delay');
|
||||
part.element.classList.remove(ANIMATING_CLASS);
|
||||
part.element.classList.remove(PRE_ANIMATING_CLASS);
|
||||
part.element.classList.remove(PAUSED_CLASS);
|
||||
part.animationStartTimeMs = Number.POSITIVE_INFINITY;
|
||||
part.isAnimating = false;
|
||||
};
|
||||
|
||||
const clearLineAnimation = (line: LineData): void => {
|
||||
line.element.style.removeProperty('--karaoke-swipe-delay');
|
||||
line.element.style.removeProperty('--karaoke-anim-delay');
|
||||
line.element.classList.remove(LINE_ANIMATING_CLASS);
|
||||
line.element.classList.remove(LINE_PRE_ANIMATING_CLASS);
|
||||
line.element.classList.remove(PAUSED_CLASS);
|
||||
line.animationStartTimeMs = Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
const clearWordSungState = (part: PartData): void => {
|
||||
part.element.classList.remove('sung');
|
||||
syncRomajiAnimation(part.element, 0, 'clearSung');
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: number): void => {
|
||||
const timeDelta = interpolatedTimeSec - line.time;
|
||||
|
||||
line.element.classList.remove(LINE_ANIMATING_CLASS);
|
||||
line.element.classList.remove(PAUSED_CLASS);
|
||||
line.element.style.setProperty('--karaoke-swipe-delay', `${-timeDelta - line.duration * 0.1}s`);
|
||||
line.element.style.setProperty('--karaoke-anim-delay', `${-timeDelta}s`);
|
||||
line.element.classList.add(LINE_PRE_ANIMATING_CLASS);
|
||||
|
||||
reflow(line.element);
|
||||
|
||||
line.element.classList.add(LINE_ANIMATING_CLASS);
|
||||
line.animationStartTimeMs = now - timeDelta * 1000;
|
||||
line.isAnimating = true;
|
||||
line.lastAnimSetupAt = now;
|
||||
line.isAnimationPlayStatePlaying = true;
|
||||
line.accumulatedOffsetMs = 0;
|
||||
};
|
||||
|
||||
const decaySkipScrolls = (state: ScrollState, now: number): void => {
|
||||
let decayCount = 0;
|
||||
|
||||
for (const decayTime of state.skipScrollsDecayTimes) {
|
||||
if (decayTime > now) {
|
||||
break;
|
||||
}
|
||||
decayCount += 1;
|
||||
}
|
||||
|
||||
if (decayCount > 0) {
|
||||
state.skipScrollsDecayTimes = state.skipScrollsDecayTimes.slice(decayCount);
|
||||
state.skipScrolls = Math.max(0, state.skipScrolls - decayCount);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelActiveLyricsScroll = (ss: ScrollState): void => {
|
||||
if (ss.cancelScroll) {
|
||||
ss.cancelScroll();
|
||||
ss.cancelScroll = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const animateLyricsScrollTo = (
|
||||
state: AnimEngineState,
|
||||
scrollContainer: HTMLElement,
|
||||
scrollPos: number,
|
||||
smoothScroll = true,
|
||||
): number => {
|
||||
const ss = state.scroll;
|
||||
const now = Date.now();
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
|
||||
cancelActiveLyricsScroll(ss);
|
||||
|
||||
let useSmoothScroll = smoothScroll;
|
||||
if (scrollTop === 0 && !ss.doneFirstInstantScroll) {
|
||||
useSmoothScroll = false;
|
||||
ss.doneFirstInstantScroll = true;
|
||||
ss.nextScrollAllowedTime = 0;
|
||||
}
|
||||
|
||||
const delta = scrollPos - scrollTop;
|
||||
|
||||
if (useSmoothScroll && Math.abs(delta) > 2) {
|
||||
const durationMs = computeScrollDurationMs(delta);
|
||||
ss.cancelScroll = animateScrollTop(scrollContainer, scrollPos);
|
||||
ss.nextScrollAllowedTime = durationMs + now + 20;
|
||||
ss.programmaticScrollUntil = now + durationMs + 50;
|
||||
ss.scrollPos = scrollPos;
|
||||
ss.skipScrolls += 1;
|
||||
ss.skipScrollsDecayTimes.push(now + 2000);
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
scrollContainer.scrollTop = scrollPos;
|
||||
ss.programmaticScrollUntil = now + 100;
|
||||
ss.scrollPos = scrollPos;
|
||||
ss.skipScrolls += 1;
|
||||
ss.skipScrollsDecayTimes.push(now + 2000);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const scrollToPosition = (
|
||||
state: AnimEngineState,
|
||||
scrollContainer: HTMLElement,
|
||||
scrollPos: number,
|
||||
smoothScroll: boolean,
|
||||
): void => {
|
||||
animateLyricsScrollTo(state, scrollContainer, scrollPos, smoothScroll);
|
||||
};
|
||||
|
||||
export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): number => {
|
||||
const now = Date.now();
|
||||
const {
|
||||
currentTimeMs,
|
||||
follow = true,
|
||||
forceResync = false,
|
||||
isPlaying,
|
||||
smoothScroll = true,
|
||||
} = opts;
|
||||
const { eventCreationTime, lyricsData, onLineActive, scrollContainer } = opts;
|
||||
|
||||
if (currentTimeMs === 0 && !isPlaying) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const currentTimeSec = currentTimeMs / 1000;
|
||||
const timeJumped =
|
||||
forceResync ||
|
||||
Math.abs(
|
||||
currentTimeSec -
|
||||
state.lastTime -
|
||||
(eventCreationTime - state.lastEventCreationTime) / 1000,
|
||||
) > TIME_JUMP_THRESHOLD;
|
||||
|
||||
if (timeJumped) {
|
||||
cancelActiveLyricsScroll(state.scroll);
|
||||
|
||||
for (const lineData of lyricsData.lines) {
|
||||
lineData.isAnimating = false;
|
||||
lineData.isSelected = false;
|
||||
lineData.isAnimationPlayStatePlaying = false;
|
||||
|
||||
if (lineData.isScrolled) {
|
||||
lineData.element.classList.remove(LINE_ACTIVE_CLASS);
|
||||
lineData.isScrolled = false;
|
||||
}
|
||||
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
}
|
||||
clearLineRomajiState(lineData.element);
|
||||
} else {
|
||||
clearLineAnimation(lineData);
|
||||
}
|
||||
}
|
||||
|
||||
state.scroll.pendingScroll = true;
|
||||
}
|
||||
|
||||
state.lastTime = currentTimeSec;
|
||||
state.lastPlayState = isPlaying;
|
||||
state.lastEventCreationTime = eventCreationTime;
|
||||
|
||||
const timeOffsetSec = isPlaying ? (now - eventCreationTime) / 1000 : 0;
|
||||
const playbackTimeSec = currentTimeSec + timeOffsetSec;
|
||||
|
||||
const timingOffsetMs =
|
||||
lyricsData.syncType === 'richsync' ? RICHSYNC_TIMING_OFFSET_MS : SYNC_TIMING_OFFSET_MS;
|
||||
const interpolatedTimeSec = playbackTimeSec + timingOffsetMs / 1000;
|
||||
const leadTimeSec = (opts.lineLeadTimeMs ?? DEFAULT_LINE_LEAD_TIME_MS) / 1000;
|
||||
|
||||
const { lines, syncType } = lyricsData;
|
||||
if (syncType === 'none') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const scrollHeight = scrollContainer.getBoundingClientRect().height;
|
||||
const activeElems: LineData[] = [];
|
||||
let newLyricSelected = timeJumped;
|
||||
let activeLineIndex = -1;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const lineData = lines[index];
|
||||
const time = lineData.time;
|
||||
const nextTime =
|
||||
index + 1 < lines.length ? lines[index + 1].time : Number.POSITIVE_INFINITY;
|
||||
|
||||
const isUpcoming =
|
||||
playbackTimeSec >= time - leadTimeSec &&
|
||||
(playbackTimeSec < nextTime || playbackTimeSec < time + lineData.duration);
|
||||
|
||||
if (isUpcoming) {
|
||||
activeElems.push(lineData);
|
||||
state.selectedElementIndex = index;
|
||||
activeLineIndex = index;
|
||||
|
||||
if (!lineData.isScrolled) {
|
||||
newLyricSelected = true;
|
||||
state.scroll.pendingScroll = true;
|
||||
lineData.element.classList.add(LINE_ACTIVE_CLASS);
|
||||
lineData.isScrolled = true;
|
||||
onLineActive?.(index);
|
||||
}
|
||||
} else if (lineData.isScrolled) {
|
||||
lineData.element.classList.remove(LINE_ACTIVE_CLASS);
|
||||
lineData.isScrolled = false;
|
||||
}
|
||||
|
||||
const setUpEarly = isPlaying ? 2 : 0;
|
||||
const effectiveEnd = Math.max(nextTime, time + lineData.duration + 0.05);
|
||||
const isLineInRange =
|
||||
interpolatedTimeSec + setUpEarly >= time && interpolatedTimeSec < effectiveEnd;
|
||||
|
||||
if (isLineInRange) {
|
||||
lineData.isSelected = true;
|
||||
|
||||
if (!isPlaying) {
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
if (part.isAnimating) {
|
||||
part.element.classList.add(PAUSED_CLASS);
|
||||
syncRomajiAnimation(part.element, interpolatedTimeSec, 'pause');
|
||||
}
|
||||
}
|
||||
} else if (lineData.isAnimating) {
|
||||
lineData.element.classList.add(PAUSED_CLASS);
|
||||
}
|
||||
|
||||
lineData.isAnimationPlayStatePlaying = false;
|
||||
} else {
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
part.element.classList.remove(PAUSED_CLASS);
|
||||
syncRomajiAnimation(part.element, interpolatedTimeSec, 'resume');
|
||||
}
|
||||
} else {
|
||||
lineData.element.classList.remove(PAUSED_CLASS);
|
||||
}
|
||||
|
||||
lineData.isAnimationPlayStatePlaying = true;
|
||||
|
||||
if (!lineData.hasWordCues && !lineData.isAnimating) {
|
||||
setupLineAnimation(lineData, interpolatedTimeSec, now);
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
const partEnd = part.time + part.duration;
|
||||
|
||||
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 (isPlaying && !part.isAnimating) {
|
||||
setupPartAnimation(part, 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');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearLineAnimation(lineData);
|
||||
}
|
||||
|
||||
lineData.isSelected = false;
|
||||
lineData.isAnimating = false;
|
||||
lineData.isAnimationPlayStatePlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
const ss = state.scroll;
|
||||
const scrollPausedByUser = ss.scrollResumeTime >= now;
|
||||
const canAutoscroll =
|
||||
follow && (!scrollPausedByUser || ss.pendingScroll || ss.scrollPos === -1);
|
||||
|
||||
if (canAutoscroll) {
|
||||
if (activeElems.length === 0 && lines.length > 0) {
|
||||
activeElems.push(lines[0]);
|
||||
}
|
||||
|
||||
state.lastActiveElements = activeElems.filter((entry) => playbackTimeSec >= entry.time);
|
||||
|
||||
if (activeElems.length > 0) {
|
||||
const scrollPosOffset = scrollHeight * DEFAULT_SCROLL_POS_RATIO;
|
||||
const lastActive = activeElems[activeElems.length - 1];
|
||||
const useLastActiveOnly = newLyricSelected || activeElems.length > 1;
|
||||
const scrollLines = useLastActiveOnly ? [lastActive] : activeElems;
|
||||
const positions = scrollLines
|
||||
.filter(
|
||||
(lineData, index) =>
|
||||
playbackTimeSec <
|
||||
lineData.time + lineData.duration - DEFAULT_ENDING_THRESHOLD ||
|
||||
index === scrollLines.length - 1,
|
||||
)
|
||||
.map((lineData) => lineData.position + lineData.height / 2);
|
||||
|
||||
if (positions.length > 0 && positions.every((pos) => pos >= 0)) {
|
||||
const avgPos = positions.reduce((sum, pos) => sum + pos, 0) / positions.length;
|
||||
let scrollPos = avgPos - scrollPosOffset;
|
||||
scrollPos = Math.min(scrollPos, scrollLines[0].position);
|
||||
scrollPos = Math.max(
|
||||
scrollPos,
|
||||
lastActive.position - scrollHeight + lastActive.height,
|
||||
);
|
||||
scrollPos = Math.min(scrollPos, lastActive.position);
|
||||
scrollPos = Math.max(0, scrollPos);
|
||||
|
||||
const shouldScroll =
|
||||
ss.wasUserScrolling || newLyricSelected || ss.queuedScroll || ss.pendingScroll;
|
||||
const canScrollNow = now > ss.nextScrollAllowedTime;
|
||||
|
||||
if (shouldScroll) {
|
||||
if (canScrollNow) {
|
||||
ss.queuedScroll = false;
|
||||
ss.pendingScroll = false;
|
||||
scrollToPosition(state, scrollContainer, scrollPos, smoothScroll);
|
||||
} else {
|
||||
ss.queuedScroll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decaySkipScrolls(ss, now);
|
||||
|
||||
if (ss.wasUserScrolling && ss.scrollResumeTime < now) {
|
||||
ss.wasUserScrolling = false;
|
||||
}
|
||||
|
||||
return activeLineIndex;
|
||||
};
|
||||
|
||||
export const handleLyricsUserScroll = (state: AnimEngineState, pauseDurationMs = 3000): void => {
|
||||
state.scroll.wasUserScrolling = true;
|
||||
state.scroll.scrollResumeTime = Date.now() + pauseDurationMs;
|
||||
};
|
||||
|
||||
export const resumeLyricsAutoscroll = (state: AnimEngineState): void => {
|
||||
state.scroll.wasUserScrolling = false;
|
||||
state.scroll.scrollResumeTime = 0;
|
||||
};
|
||||
|
||||
export const shouldSkipLyricsScrollEvent = (state: AnimEngineState): boolean => {
|
||||
if (Date.now() < state.scroll.programmaticScrollUntil) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state.scroll.skipScrolls > 0) {
|
||||
state.scroll.skipScrolls -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const resetLyricsAnimationDom = (lyricsData: LyricsData | null): void => {
|
||||
if (!lyricsData) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of lyricsData.lines) {
|
||||
line.element.classList.remove(
|
||||
LINE_ACTIVE_CLASS,
|
||||
LINE_ANIMATING_CLASS,
|
||||
LINE_PRE_ANIMATING_CLASS,
|
||||
'singing',
|
||||
'complete',
|
||||
);
|
||||
line.isScrolled = false;
|
||||
line.isSelected = false;
|
||||
line.isAnimating = false;
|
||||
|
||||
for (const part of line.parts) {
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
}
|
||||
|
||||
clearLineRomajiState(line.element);
|
||||
clearLineAnimation(line);
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,78 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import isElectron from 'is-electron';
|
||||
|
||||
import { LyricsResponse, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
alignFuriganaToWordCues,
|
||||
alignRomajiTokensToWordCues,
|
||||
LyricTextToken,
|
||||
RomajiToken,
|
||||
} from '/@/renderer/features/lyrics/api/lyric-conversion';
|
||||
import { LyricsResponse, SyncedCueLine, SynchronizedLyrics } from '/@/shared/types/domain-types';
|
||||
|
||||
const lyricsApi = isElectron() ? window.api.lyrics : null;
|
||||
|
||||
const convertSyncedLyricsFurigana = async (
|
||||
lyrics: SynchronizedLyrics,
|
||||
): Promise<SynchronizedLyrics> => {
|
||||
if (!lyricsApi) {
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
lyrics.map(async (line) => ({
|
||||
...line,
|
||||
cueLines: line.cueLines
|
||||
? await Promise.all(
|
||||
line.cueLines.map(async (cueLine) => {
|
||||
const tokens = (await lyricsApi.parseLyricsTextTokens(
|
||||
cueLine.value,
|
||||
)) as LyricTextToken[];
|
||||
const alignedWords = cueLine.words.length
|
||||
? await alignFuriganaToWordCues(
|
||||
cueLine.value,
|
||||
cueLine.words,
|
||||
tokens,
|
||||
(text) => lyricsApi.convertFuriganaFragment(text),
|
||||
)
|
||||
: cueLine.words;
|
||||
return {
|
||||
...cueLine,
|
||||
value: await lyricsApi.convertFurigana(cueLine.value),
|
||||
words: alignedWords ?? cueLine.words,
|
||||
};
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
text: await lyricsApi.convertFurigana(line.text),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const convertSyncedLyricsRomaji = async (
|
||||
lyrics: SynchronizedLyrics,
|
||||
convert: (text: string) => Promise<string>,
|
||||
): Promise<SynchronizedLyrics> =>
|
||||
Promise.all(
|
||||
lyrics.map(async (line) => ({
|
||||
...line,
|
||||
cueLines: line.cueLines
|
||||
? await Promise.all(
|
||||
line.cueLines.map(async (cueLine) => ({
|
||||
...cueLine,
|
||||
value: await convert(cueLine.value),
|
||||
words: await Promise.all(
|
||||
cueLine.words.map(async (word) => ({
|
||||
...word,
|
||||
text: await convert(word.text),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
)
|
||||
: undefined,
|
||||
text: await convert(line.text),
|
||||
})),
|
||||
);
|
||||
|
||||
export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
|
||||
return useQuery({
|
||||
enabled: enabled && !!lyrics && !!lyricsApi,
|
||||
@@ -13,15 +81,12 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
|
||||
|
||||
if (typeof lyrics === 'string') {
|
||||
return await lyricsApi.convertFurigana(lyrics);
|
||||
} else if (Array.isArray(lyrics)) {
|
||||
const converted = await Promise.all(
|
||||
lyrics.map(async ([time, line]) => [
|
||||
time,
|
||||
await lyricsApi.convertFurigana(line),
|
||||
]),
|
||||
);
|
||||
return converted as SynchronizedLyricsArray;
|
||||
}
|
||||
|
||||
if (Array.isArray(lyrics)) {
|
||||
return convertSyncedLyricsFurigana(lyrics);
|
||||
}
|
||||
|
||||
return lyrics;
|
||||
},
|
||||
queryKey: ['furigana', lyrics],
|
||||
@@ -37,15 +102,80 @@ export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabl
|
||||
|
||||
if (typeof lyrics === 'string') {
|
||||
return await lyricsApi.convertRomaji(lyrics);
|
||||
} else if (Array.isArray(lyrics)) {
|
||||
const converted = await Promise.all(
|
||||
lyrics.map(async ([time, line]) => [time, await lyricsApi.convertRomaji(line)]),
|
||||
);
|
||||
return converted as SynchronizedLyricsArray;
|
||||
}
|
||||
|
||||
if (Array.isArray(lyrics)) {
|
||||
return convertSyncedLyricsRomaji(lyrics, (text) => lyricsApi.convertRomaji(text));
|
||||
}
|
||||
|
||||
return lyrics;
|
||||
},
|
||||
queryKey: ['romaji', lyrics],
|
||||
staleTime: Infinity,
|
||||
});
|
||||
};
|
||||
|
||||
export type SyncedRomajiLyrics = (null | SyncedCueLine[])[];
|
||||
|
||||
const buildSyncedRomajiLine = async (
|
||||
cueLines: SyncedCueLine[],
|
||||
): Promise<null | SyncedCueLine[]> => {
|
||||
const romajiCueLines: SyncedCueLine[] = [];
|
||||
|
||||
for (const cueLine of cueLines) {
|
||||
if (!cueLine.words.length) {
|
||||
romajiCueLines.push({ ...cueLine });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lyricsApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = (await lyricsApi.convertRomajiTokens(cueLine.value)) as RomajiToken[];
|
||||
const alignedWords = alignRomajiTokensToWordCues(cueLine.value, cueLine.words, tokens);
|
||||
|
||||
if (!alignedWords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
romajiCueLines.push({
|
||||
...cueLine,
|
||||
words: alignedWords,
|
||||
});
|
||||
}
|
||||
|
||||
return romajiCueLines;
|
||||
};
|
||||
|
||||
export const useSyncedRomajiLyrics = (
|
||||
lyrics: null | SynchronizedLyrics | undefined,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
return useQuery({
|
||||
enabled: enabled && !!lyrics && !!lyricsApi,
|
||||
queryFn: async (): Promise<null | SyncedRomajiLyrics> => {
|
||||
if (!lyrics || !lyricsApi || !enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: SyncedRomajiLyrics = [];
|
||||
|
||||
for (const line of lyrics) {
|
||||
if (
|
||||
!line.cueLines?.length ||
|
||||
!line.cueLines.some((cueLine) => cueLine.words.length)
|
||||
) {
|
||||
result.push(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(await buildSyncedRomajiLine(line.cueLines));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
queryKey: ['romaji-synced', lyrics],
|
||||
staleTime: Infinity,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
AnimEngineState,
|
||||
buildLyricsDataFromDom,
|
||||
BuildLyricsDataOptions,
|
||||
createAnimEngineState,
|
||||
LyricsData,
|
||||
recalculateLinePositions,
|
||||
resetAnimEngine,
|
||||
resetLyricsAnimationDom,
|
||||
resumeLyricsAutoscroll,
|
||||
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>;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
enabled?: boolean;
|
||||
followRef?: React.RefObject<boolean>;
|
||||
lineIdPrefix: 'karaoke-line' | 'lyric';
|
||||
lineLeadTimeMsRef?: React.RefObject<number>;
|
||||
lyrics: SynchronizedLyrics;
|
||||
onLineActive?: (lineIndex: number) => void;
|
||||
scrollContainerId: string;
|
||||
}
|
||||
|
||||
export const useLyricsAnimationEngine = ({
|
||||
animStateRef: externalAnimStateRef,
|
||||
containerRef,
|
||||
enabled = true,
|
||||
followRef,
|
||||
lineIdPrefix,
|
||||
lineLeadTimeMsRef,
|
||||
lyrics,
|
||||
onLineActive,
|
||||
scrollContainerId,
|
||||
}: UseLyricsAnimationEngineOptions) => {
|
||||
const internalAnimStateRef = useRef<AnimEngineState>(createAnimEngineState());
|
||||
const animStateRef = externalAnimStateRef ?? internalAnimStateRef;
|
||||
const lyricsDataRef = useRef<LyricsData | null>(null);
|
||||
const onLineActiveRef = useRef(onLineActive);
|
||||
|
||||
useEffect(() => {
|
||||
onLineActiveRef.current = onLineActive;
|
||||
}, [onLineActive]);
|
||||
|
||||
const rebuildLyricsData = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !enabled) {
|
||||
lyricsDataRef.current = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const options: BuildLyricsDataOptions = {
|
||||
container,
|
||||
lineIdPrefix,
|
||||
lyrics,
|
||||
};
|
||||
|
||||
const data = buildLyricsDataFromDom(options);
|
||||
lyricsDataRef.current = data;
|
||||
|
||||
if (data) {
|
||||
recalculateLinePositions(data);
|
||||
animStateRef.current.scroll.wasUserScrolling = true;
|
||||
}
|
||||
|
||||
return data;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time
|
||||
}, [containerRef, enabled, lineIdPrefix, lyrics]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
resetAnimEngine(animStateRef.current);
|
||||
resetLyricsAnimationDom(lyricsDataRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time
|
||||
}, []);
|
||||
|
||||
const recalculatePositions = useCallback(() => {
|
||||
if (lyricsDataRef.current) {
|
||||
recalculateLinePositions(lyricsDataRef.current);
|
||||
animStateRef.current.scroll.wasUserScrolling = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time
|
||||
}, []);
|
||||
|
||||
const tick = useCallback(
|
||||
(
|
||||
currentTimeMs: number,
|
||||
isPlaying: boolean,
|
||||
options?: { eventCreationTime?: number; forceResync?: boolean },
|
||||
): number => {
|
||||
if (!enabled) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let lyricsData = lyricsDataRef.current;
|
||||
if (!lyricsData) {
|
||||
lyricsData = rebuildLyricsData();
|
||||
}
|
||||
|
||||
if (!lyricsData) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const scrollContainer =
|
||||
document.getElementById(scrollContainerId) ?? containerRef.current ?? undefined;
|
||||
|
||||
if (!scrollContainer) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return tickLyricsAnimation(animStateRef.current, {
|
||||
currentTimeMs,
|
||||
eventCreationTime: options?.eventCreationTime ?? Date.now(),
|
||||
follow: followRef?.current ?? true,
|
||||
forceResync: options?.forceResync ?? false,
|
||||
isPlaying,
|
||||
lineLeadTimeMs: lineLeadTimeMsRef?.current,
|
||||
lyricsData,
|
||||
onLineActive: onLineActiveRef.current,
|
||||
scrollContainer,
|
||||
smoothScroll: true,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef, containerRef, followRef, lineLeadTimeMsRef are read via .current at call time
|
||||
[enabled, rebuildLyricsData, scrollContainerId],
|
||||
);
|
||||
|
||||
const resumeAutoscroll = useCallback(() => {
|
||||
resumeLyricsAutoscroll(animStateRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
const frame = requestAnimationFrame(() => {
|
||||
rebuildLyricsData();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
reset();
|
||||
};
|
||||
}, [lyrics, enabled, rebuildLyricsData, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
recalculatePositions();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [containerRef, recalculatePositions]);
|
||||
|
||||
return {
|
||||
animStateRef,
|
||||
lyricsDataRef,
|
||||
rebuildLyricsData,
|
||||
recalculatePositions,
|
||||
reset,
|
||||
resumeAutoscroll,
|
||||
tick,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import {
|
||||
animateLyricsScrollTo,
|
||||
createAnimEngineState,
|
||||
handleLyricsUserScroll,
|
||||
resumeLyricsAutoscroll,
|
||||
shouldSkipLyricsScrollEvent,
|
||||
} from '/@/renderer/features/lyrics/hooks/lyrics-animation-engine';
|
||||
import {
|
||||
useLyricsDisplaySettings,
|
||||
useLyricsSettings,
|
||||
usePlaybackType,
|
||||
usePlayerActions,
|
||||
} from '/@/renderer/store';
|
||||
import { SynchronizedLyrics } from '/@/shared/types/domain-types';
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
|
||||
|
||||
export const LYRICS_SCROLL_CONTAINER_ID = 'sychronized-lyrics-scroll-container';
|
||||
|
||||
export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: number) => {
|
||||
const playbackType = usePlaybackType();
|
||||
const lyricsSettings = useLyricsSettings();
|
||||
const displaySettings = useLyricsDisplaySettings(settingsKey);
|
||||
const { mediaSeekToTimestamp } = usePlayerActions();
|
||||
|
||||
const settings = useMemo(
|
||||
() => ({
|
||||
...lyricsSettings,
|
||||
fontSize:
|
||||
displaySettings.fontSize && displaySettings.fontSize !== 0
|
||||
? displaySettings.fontSize
|
||||
: 24,
|
||||
gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24,
|
||||
opacityNonActive: displaySettings.opacityNonActive,
|
||||
scaleNonActive:
|
||||
displaySettings.scaleNonActive && displaySettings.scaleNonActive !== 0
|
||||
? displaySettings.scaleNonActive
|
||||
: 0.95,
|
||||
}),
|
||||
[displaySettings, lyricsSettings],
|
||||
);
|
||||
|
||||
const effectiveOffsetMs = offsetMs ?? 0;
|
||||
const delayMsRef = useRef(effectiveOffsetMs);
|
||||
const followRef = useRef(settings.follow);
|
||||
const lineLeadTimeMsRef = useRef(settings.lineLeadTimeMs);
|
||||
const userScrollingRef = useRef(false);
|
||||
const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const programmaticScrollRef = useRef(false);
|
||||
const programmaticScrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const lyricRef = useRef<null | SynchronizedLyrics>(null);
|
||||
const scrollAnimStateRef = useRef(createAnimEngineState());
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (playbackType === PlayerType.LOCAL && mpvPlayer) {
|
||||
mpvPlayer.seekTo(time);
|
||||
} else {
|
||||
mpris?.updateSeek(time);
|
||||
mediaSeekToTimestamp(time);
|
||||
}
|
||||
},
|
||||
[mediaSeekToTimestamp, playbackType],
|
||||
);
|
||||
|
||||
const handleLineClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (event.target as HTMLElement).closest('[data-lyric-time]');
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Number((target as HTMLElement).dataset.lyricTime);
|
||||
if (time >= 0 && Number.isFinite(time)) {
|
||||
handleSeek(time / 1000);
|
||||
}
|
||||
},
|
||||
[handleSeek],
|
||||
);
|
||||
|
||||
const scrollToLine = useCallback(
|
||||
(_index: number, lineSelector: string, anchorSelector?: string) => {
|
||||
const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement;
|
||||
const lineElement = document.querySelector(lineSelector) as HTMLElement | null;
|
||||
const anchorElement = anchorSelector
|
||||
? (document.querySelector(anchorSelector) as HTMLElement | null)
|
||||
: null;
|
||||
const scrollTarget = anchorElement ?? lineElement;
|
||||
|
||||
if (!followRef.current || userScrollingRef.current || !scrollTarget || !doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = doc.getBoundingClientRect();
|
||||
const targetRect = scrollTarget.getBoundingClientRect();
|
||||
const targetCenterY = targetRect.top + targetRect.height / 2;
|
||||
const containerCenterY = containerRect.top + containerRect.height / 2;
|
||||
const scrollTop = doc.scrollTop + targetCenterY - containerCenterY;
|
||||
|
||||
programmaticScrollRef.current = true;
|
||||
const durationMs = animateLyricsScrollTo(scrollAnimStateRef.current, doc, scrollTop);
|
||||
|
||||
if (programmaticScrollTimeoutRef.current) {
|
||||
clearTimeout(programmaticScrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
programmaticScrollTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
programmaticScrollRef.current = false;
|
||||
},
|
||||
durationMs > 0 ? durationMs + 50 : 150,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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(
|
||||
() =>
|
||||
({
|
||||
'--lyric-opacity': settings.opacityNonActive,
|
||||
'--lyric-scale': settings.scaleNonActive,
|
||||
'--lyric-scale-origin': settings.alignment,
|
||||
gap: `${settings.gap}px`,
|
||||
}) as React.CSSProperties,
|
||||
[settings.alignment, settings.gap, settings.opacityNonActive, settings.scaleNonActive],
|
||||
);
|
||||
|
||||
const hideScrollbar = useCallback(() => {
|
||||
const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement;
|
||||
doc?.classList.add('hide-scrollbar');
|
||||
}, []);
|
||||
|
||||
const showScrollbar = useCallback(() => {
|
||||
const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement;
|
||||
doc?.classList.remove('hide-scrollbar');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
followRef.current = settings.follow;
|
||||
}, [settings.follow]);
|
||||
|
||||
useEffect(() => {
|
||||
lineLeadTimeMsRef.current = settings.lineLeadTimeMs;
|
||||
}, [settings.lineLeadTimeMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const newOffset = offsetMs ?? 0;
|
||||
if (delayMsRef.current === newOffset) {
|
||||
return;
|
||||
}
|
||||
|
||||
delayMsRef.current = newOffset;
|
||||
}, [offsetMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const container =
|
||||
containerRef.current ||
|
||||
(document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement);
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (shouldSkipLyricsScrollEvent(scrollAnimStateRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (programmaticScrollRef.current) {
|
||||
if (programmaticScrollTimeoutRef.current) {
|
||||
clearTimeout(programmaticScrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
programmaticScrollTimeoutRef.current = setTimeout(() => {
|
||||
programmaticScrollRef.current = false;
|
||||
}, 150);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
userScrollingRef.current = true;
|
||||
handleLyricsUserScroll(scrollAnimStateRef.current);
|
||||
container.classList.add('lyrics-user-scrolling');
|
||||
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
userScrollingRef.current = false;
|
||||
container.classList.remove('lyrics-user-scrolling');
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (programmaticScrollTimeoutRef.current) {
|
||||
clearTimeout(programmaticScrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
containerStyle,
|
||||
delayMsRef,
|
||||
followRef,
|
||||
handleLineClick,
|
||||
handleSeek,
|
||||
hideScrollbar,
|
||||
lineLeadTimeMsRef,
|
||||
lyricRef,
|
||||
resumeAutoscroll,
|
||||
scrollAnimStateRef,
|
||||
scrollToLine,
|
||||
settings,
|
||||
showScrollbar,
|
||||
userScrollingRef,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user