initial implementation for lyrics

This commit is contained in:
Kendall Garner 2023-05-22 17:38:31 -07:00 committed by Jeff
parent 8eb0029bb8
commit 23f9bd4e9f
9 changed files with 223 additions and 11 deletions

View file

@ -0,0 +1,20 @@
import { ComponentPropsWithoutRef } from 'react';
import { TextTitle } from '/@/renderer/components/text-title';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
active: boolean;
lyric: string;
}
export const LyricLine = ({ lyric: text, active, ...props }: LyricLineProps) => {
return (
<TextTitle
lh={active ? '4rem' : '3.5rem'}
sx={{ fontSize: active ? '2.5rem' : '2rem' }}
weight={active ? 800 : 100}
{...props}
>
{text}
</TextTitle>
);
};

View file

@ -0,0 +1,52 @@
import { useMemo } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '/@/renderer/features/action-required';
import { useCurrentSong } from '/@/renderer/store';
import { SynchronizedLyricsArray, SynchronizedLyrics } from './synchronized-lyrics';
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
// use by https://github.com/ustbhuangyi/lyric-parser
const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)\n/g;
export const Lyrics = () => {
const currentSong = useCurrentSong();
const lyrics = useMemo(() => {
if (currentSong?.lyrics) {
const originalText = currentSong.lyrics;
console.log(originalText);
const synchronizedLines = originalText.matchAll(timeExp);
const synchronizedTimes: SynchronizedLyricsArray = [];
for (const line of synchronizedLines) {
const [, minute, sec, ms, text] = line;
const minutes = parseInt(minute, 10);
const seconds = parseInt(sec, 10);
const milis = ms.length === 3 ? parseInt(ms, 10) : parseInt(ms, 10) * 10;
const timeInMilis = (minutes * 60 + seconds) * 1000 + milis;
synchronizedTimes.push([timeInMilis, text]);
}
if (synchronizedTimes.length === 0) {
return originalText;
}
return synchronizedTimes;
}
return null;
}, [currentSong?.lyrics]);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
{lyrics &&
(Array.isArray(lyrics) ? (
<SynchronizedLyrics lyrics={lyrics} />
) : (
<UnsynchronizedLyrics lyrics={lyrics} />
))}
</ErrorBoundary>
);
};

View file

@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCurrentStatus, useCurrentTime } from '/@/renderer/store';
import { PlayerStatus } from '/@/renderer/types';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
export type SynchronizedLyricsArray = Array<[number, string]>;
interface SynchronizedLyricsProps {
lyrics: SynchronizedLyricsArray;
}
const CLOSE_ENOUGH_TIME_DIFF_SEC = 0.2;
export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
const [index, setIndex] = useState(-1);
const status = useCurrentStatus();
const lastTimeUpdate = useRef<number>(Infinity);
const previousTimestamp = useRef<number>(0);
const now = useCurrentTime();
const timeout = useRef<ReturnType<typeof setTimeout>>();
const estimateElapsedTime = useCallback(() => {
const now = new Date().getTime();
return (now - previousTimestamp.current) / 1000;
}, []);
const getCurrentLyric = useCallback(
(timeInMs: number) => {
for (let idx = 0; idx < lyrics.length; idx += 1) {
if (timeInMs <= lyrics[idx][0]) {
return idx === 0 ? idx : idx - 1;
}
}
return lyrics.length - 1;
},
[lyrics],
);
const doSetNextTimeout = useCallback(
(idx: number, currentTimeMs: number) => {
if (timeout.current) {
clearTimeout(timeout.current);
}
document
.querySelector(`#lyric-${idx}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
setIndex(idx);
if (idx !== lyrics.length - 1) {
const nextTimeMs = lyrics[idx + 1][0];
const nextTime = nextTimeMs - currentTimeMs;
timeout.current = setTimeout(() => {
doSetNextTimeout(idx + 1, nextTimeMs);
}, nextTime);
} else {
timeout.current = undefined;
}
},
[lyrics],
);
const handleTimeChange = useCallback(() => {
const elapsedJs = estimateElapsedTime();
const elapsedPlayer = now - lastTimeUpdate.current;
lastTimeUpdate.current = now;
previousTimestamp.current = new Date().getTime();
if (Math.abs(elapsedJs - elapsedPlayer) >= CLOSE_ENOUGH_TIME_DIFF_SEC) {
if (timeout.current) {
clearTimeout(timeout.current);
}
const currentTimeMs = now * 1000;
const idx = getCurrentLyric(currentTimeMs);
doSetNextTimeout(idx, currentTimeMs);
}
}, [doSetNextTimeout, estimateElapsedTime, getCurrentLyric, now]);
useEffect(() => {
if (status !== PlayerStatus.PLAYING) {
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = undefined;
}
return () => {};
}
const changeTimeout = setTimeout(() => {
handleTimeChange();
}, 100);
return () => clearTimeout(changeTimeout);
}, [handleTimeChange, status]);
return (
<div>
{lyrics.map(([, text], idx) => (
<LyricLine
key={idx}
active={idx === index}
id={`lyric-${idx}`}
lyric={text}
/>
))}
</div>
);
};

View file

@ -0,0 +1,25 @@
import { useMemo } from 'react';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
interface UnsynchronizedLyricsProps {
lyrics: string;
}
export const UnsynchronizedLyrics = ({ lyrics }: UnsynchronizedLyricsProps) => {
const lines = useMemo(() => {
return lyrics.split('\n');
}, [lyrics]);
return (
<div>
{lines.map((text, idx) => (
<LyricLine
key={idx}
active={false}
id={`lyric-${idx}`}
lyric={text}
/>
))}
</div>
);
};