initial implementation for lyrics
This commit is contained in:
parent
8eb0029bb8
commit
23f9bd4e9f
9 changed files with 223 additions and 11 deletions
|
@ -155,6 +155,7 @@ const normalizeSong = (
|
||||||
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
|
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
lyrics: null,
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||||
|
|
|
@ -74,6 +74,7 @@ const normalizeSong = (
|
||||||
imageUrl,
|
imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||||
|
lyrics: item.lyrics ? item.lyrics : null,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
|
|
|
@ -81,6 +81,7 @@ const normalizeSong = (
|
||||||
imageUrl,
|
imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
lyrics: null,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
playCount: item?.playCount || 0,
|
playCount: item?.playCount || 0,
|
||||||
|
|
|
@ -187,6 +187,7 @@ export type Song = {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
itemType: LibraryItem.SONG;
|
itemType: LibraryItem.SONG;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
|
lyrics: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
playCount: number;
|
playCount: number;
|
||||||
|
|
20
src/renderer/features/lyrics/lyric-line.tsx
Normal file
20
src/renderer/features/lyrics/lyric-line.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
52
src/renderer/features/lyrics/lyrics.tsx
Normal file
52
src/renderer/features/lyrics/lyrics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
112
src/renderer/features/lyrics/synchronized-lyrics.tsx
Normal file
112
src/renderer/features/lyrics/synchronized-lyrics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
25
src/renderer/features/lyrics/unsynchronized-lyrics.tsx
Normal file
25
src/renderer/features/lyrics/unsynchronized-lyrics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -9,6 +9,7 @@ import {
|
||||||
useFullScreenPlayerStore,
|
useFullScreenPlayerStore,
|
||||||
useFullScreenPlayerStoreActions,
|
useFullScreenPlayerStoreActions,
|
||||||
} from '/@/renderer/store/full-screen-player.store';
|
} from '/@/renderer/store/full-screen-player.store';
|
||||||
|
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
|
||||||
|
|
||||||
const QueueContainer = styled.div`
|
const QueueContainer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -26,6 +27,12 @@ const QueueContainer = styled.div`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const LyricsContainer = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
overflow: scroll;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
const ActiveTabIndicator = styled(motion.div)`
|
const ActiveTabIndicator = styled(motion.div)`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
@ -115,17 +122,9 @@ export const FullScreenPlayerQueue = () => {
|
||||||
</Group>
|
</Group>
|
||||||
</Center>
|
</Center>
|
||||||
) : activeTab === 'lyrics' ? (
|
) : activeTab === 'lyrics' ? (
|
||||||
<Center>
|
<LyricsContainer>
|
||||||
<Group>
|
<Lyrics />
|
||||||
<RiInformationFill size="2rem" />
|
</LyricsContainer>
|
||||||
<TextTitle
|
|
||||||
order={3}
|
|
||||||
weight={700}
|
|
||||||
>
|
|
||||||
COMING SOON
|
|
||||||
</TextTitle>
|
|
||||||
</Group>
|
|
||||||
</Center>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
Reference in a new issue