Improved lyric syncing, fetch

- uses a somewhat more sane way to parse lyrics and teardown timeouts
- adds 'seeked' to setCurrentTime to make detecting seeks in lyric much easier
- adds ability to fetch lyrics from genius/netease (desktop only)
This commit is contained in:
Kendall Garner 2023-05-28 14:31:49 -07:00 committed by Jeff
parent 23f9bd4e9f
commit 85d2576bdc
25 changed files with 907 additions and 118 deletions

View file

@ -10,7 +10,7 @@ interface SelectProps extends MantineSelectProps {
width?: number | string;
}
interface MultiSelectProps extends MantineMultiSelectProps {
export interface MultiSelectProps extends MantineMultiSelectProps {
maxWidth?: number | string;
width?: number | string;
}

View file

@ -2,7 +2,11 @@ import type { ChangeEvent } from 'react';
import { MultiSelect } from '/@/renderer/components/select';
import { Slider } from '/@/renderer/components/slider';
import { Switch } from '/@/renderer/components/switch';
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
import {
useSettingsStoreActions,
useSettingsStore,
useLyricsSettings,
} from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types';
import { Option } from '/@/renderer/components/option';
@ -82,6 +86,7 @@ interface TableConfigDropdownProps {
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const { setSettings } = useSettingsStoreActions();
const tableConfig = useSettingsStore((state) => state.tables);
const lyricConfig = useLyricsSettings();
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
const existingColumns = tableConfig[type].columns;
@ -166,6 +171,15 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
});
};
const handleLyricFollow = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
follow: e.currentTarget.checked,
},
});
};
return (
<>
<Option>
@ -186,6 +200,15 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Follow current lyrics</Option.Label>
<Option.Control>
<Switch
defaultChecked={lyricConfig.follow}
onChange={handleLyricFollow}
/>
</Option.Control>
</Option>
<Option>
<Option.Control>
<Slider

View file

@ -1,20 +1,24 @@
import { ComponentPropsWithoutRef } from 'react';
import { TextTitle } from '/@/renderer/components/text-title';
import styled from 'styled-components';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
active: boolean;
lyric: string;
text: 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>
);
const StyledText = styled(TextTitle)`
font-size: 2rem;
font-weight: 100;
line-height: 3.5rem;
&.active,
&.credit {
font-size: 2.5rem;
font-weight: 800;
line-height: 4rem;
}
`;
export const LyricLine = ({ text, ...props }: LyricLineProps) => {
return <StyledText {...props}>{text}</StyledText>;
};

View file

@ -1,23 +1,66 @@
import { useMemo } from 'react';
import { useEffect, useRef, useState } from 'react';
import isElectron from 'is-electron';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '/@/renderer/features/action-required';
import { useCurrentSong } from '/@/renderer/store';
import { useCurrentServer, useCurrentSong } from '/@/renderer/store';
import { SynchronizedLyricsArray, SynchronizedLyrics } from './synchronized-lyrics';
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
const lyrics = isElectron() ? window.electron.lyrics : null;
const ipc = isElectron() ? window.electron.ipc : null;
// use by https://github.com/ustbhuangyi/lyric-parser
const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)\n/g;
export const Lyrics = () => {
const currentServer = useCurrentServer();
const currentSong = useCurrentSong();
const lyrics = useMemo(() => {
if (currentSong?.lyrics) {
const originalText = currentSong.lyrics;
console.log(originalText);
const [override, setOverride] = useState<string | null>(null);
const [source, setSource] = useState<string | null>(null);
const [songLyrics, setSongLyrics] = useState<SynchronizedLyricsArray | string | null>(null);
const synchronizedLines = originalText.matchAll(timeExp);
const songRef = useRef<string | null>(null);
useEffect(() => {
lyrics?.getLyrics((_event: any, songName: string, lyricSource: string, lyric: string) => {
if (songName === songRef.current) {
setSource(lyricSource);
setOverride(lyric);
}
});
return () => {
ipc?.removeAllListeners('lyric-get');
};
}, []);
useEffect(() => {
if (currentSong && !currentSong.lyrics) {
lyrics?.fetchLyrics(currentSong);
}
songRef.current = currentSong?.name ?? null;
setOverride(null);
setSource(null);
}, [currentSong]);
useEffect(() => {
let lyrics: string | null = null;
if (currentSong?.lyrics) {
lyrics = currentSong.lyrics;
setSource(currentServer?.name ?? 'music server');
} else if (override) {
lyrics = override;
}
if (lyrics) {
const synchronizedLines = lyrics.matchAll(timeExp);
const synchronizedTimes: SynchronizedLyricsArray = [];
@ -32,21 +75,30 @@ export const Lyrics = () => {
}
if (synchronizedTimes.length === 0) {
return originalText;
setSongLyrics(lyrics);
} else {
setSongLyrics(synchronizedTimes);
}
return synchronizedTimes;
} else {
setSongLyrics(null);
}
return null;
}, [currentSong?.lyrics]);
}, [currentServer?.name, currentSong, override]);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
{lyrics &&
(Array.isArray(lyrics) ? (
<SynchronizedLyrics lyrics={lyrics} />
{songLyrics &&
(Array.isArray(songLyrics) ? (
<SynchronizedLyrics lyrics={songLyrics} />
) : (
<UnsynchronizedLyrics lyrics={lyrics} />
<UnsynchronizedLyrics lyrics={songLyrics} />
))}
{source && (
<LyricLine
key="provided-by"
className="credit"
text={`Provided by: ${source}`}
/>
)}
</ErrorBoundary>
);
};

View file

@ -1,7 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCurrentStatus, useCurrentTime } from '/@/renderer/store';
import { PlayerStatus } from '/@/renderer/types';
import { useCallback, useEffect, useRef } from 'react';
import {
useCurrentStatus,
useCurrentTime,
useLyricsSettings,
usePlayerType,
useSeeked,
} from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import isElectron from 'is-electron';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
export type SynchronizedLyricsArray = Array<[number, string]>;
@ -9,102 +19,192 @@ interface SynchronizedLyricsProps {
lyrics: SynchronizedLyricsArray;
}
const CLOSE_ENOUGH_TIME_DIFF_SEC = 0.2;
export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
const [index, setIndex] = useState(-1);
const playersRef = PlayersRef;
const status = useCurrentStatus();
const lastTimeUpdate = useRef<number>(Infinity);
const previousTimestamp = useRef<number>(0);
const playerType = usePlayerType();
const now = useCurrentTime();
const settings = useLyricsSettings();
const timeout = useRef<ReturnType<typeof setTimeout>>();
const seeked = useSeeked();
const estimateElapsedTime = useCallback(() => {
const now = new Date().getTime();
return (now - previousTimestamp.current) / 1000;
}, []);
// A reference to the timeout handler
const lyricTimer = useRef<ReturnType<typeof setTimeout>>();
const getCurrentLyric = useCallback(
(timeInMs: number) => {
for (let idx = 0; idx < lyrics.length; idx += 1) {
if (timeInMs <= lyrics[idx][0]) {
// A reference to the lyrics. This is necessary for the
// timers, which are not part of react necessarily, to always
// have the most updated values
const lyricRef = useRef<SynchronizedLyricsArray>();
// A constantly increasing value, used to tell timers that may be out of date
// whether to proceed or stop
const timerEpoch = useRef(0);
const followRef = useRef<boolean>(settings.follow);
useEffect(() => {
// Copy the follow settings into a ref that can be accessed in the timeout
followRef.current = settings.follow;
}, [settings.follow]);
const getCurrentTime = useCallback(async () => {
if (isElectron() && playerType !== PlaybackType.WEB) {
if (mpvPlayer) {
return mpvPlayer.getCurrentTime();
}
return 0;
}
if (playersRef.current === undefined) {
return 0;
}
const player = (playersRef.current.player1 ?? playersRef.current.player2).getInternalPlayer();
// If it is null, this probably means we added a new song while the lyrics tab is open
// and the queue was previously empty
if (!player) return 0;
return player.currentTime;
}, [playerType, playersRef]);
const getCurrentLyric = (timeInMs: number) => {
if (lyricRef.current) {
const activeLyrics = lyricRef.current;
for (let idx = 0; idx < activeLyrics.length; idx += 1) {
if (timeInMs <= activeLyrics[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);
return activeLyrics.length - 1;
}
}, [doSetNextTimeout, estimateElapsedTime, getCurrentLyric, now]);
return -1;
};
const setCurrentLyric = useCallback((timeInMs: number, epoch?: number, targetIndex?: number) => {
const start = performance.now();
let nextEpoch: number;
if (epoch === undefined) {
timerEpoch.current = (timerEpoch.current + 1) % 10000;
nextEpoch = timerEpoch.current;
} else if (epoch !== timerEpoch.current) {
return;
} else {
nextEpoch = epoch;
}
let index: number;
if (targetIndex === undefined) {
index = getCurrentLyric(timeInMs);
} else {
index = targetIndex;
}
// Directly modify the dom instead of using react to prevent rerender
document
.querySelectorAll('.synchronized-lyrics .active')
.forEach((node) => node.classList.remove('active'));
if (index === -1) {
lyricRef.current = undefined;
return;
}
const currentLyric = document.querySelector(`#lyric-${index}`);
if (currentLyric === null) {
lyricRef.current = undefined;
return;
}
currentLyric.classList.add('active');
if (followRef.current) {
currentLyric.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (index !== lyricRef.current!.length - 1) {
const [nextTime] = lyricRef.current![index + 1];
const elapsed = performance.now() - start;
lyricTimer.current = setTimeout(() => {
setCurrentLyric(nextTime, nextEpoch, index + 1);
}, nextTime - timeInMs - elapsed);
}
}, []);
useEffect(() => {
lyricRef.current = lyrics;
if (status === PlayerStatus.PLAYING) {
let rejected = false;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000);
return true;
})
.catch(console.error);
return () => {
// Case 1: cleanup happens before we hear back from
// the main process. In this case, when the promise resolves, ignore the result
rejected = true;
// Case 2: Cleanup happens after we hear back from main process but
// (potentially) before the next lyric. In this case, clear the timer
if (lyricTimer.current) clearTimeout(lyricTimer.current);
};
}
return () => {};
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
useEffect(() => {
if (status !== PlayerStatus.PLAYING) {
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = undefined;
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
return () => {};
return;
}
if (!seeked) {
return;
}
const changeTimeout = setTimeout(() => {
handleTimeChange();
}, 100);
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
return () => clearTimeout(changeTimeout);
}, [handleTimeChange, status]);
setCurrentLyric(now * 1000);
}, [now, seeked, setCurrentLyric, status]);
useEffect(() => {
// Guaranteed cleanup; stop the timer, and just in case also increment
// the epoch to instruct any dangling timers to stop
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
timerEpoch.current += 1;
}, []);
return (
<div>
<div className="synchronized-lyrics">
{lyrics.map(([, text], idx) => (
<LyricLine
key={idx}
active={idx === index}
id={`lyric-${idx}`}
lyric={text}
text={text}
/>
))}
</div>

View file

@ -15,9 +15,8 @@ export const UnsynchronizedLyrics = ({ lyrics }: UnsynchronizedLyricsProps) => {
{lines.map((text, idx) => (
<LyricLine
key={idx}
active={false}
id={`lyric-${idx}`}
lyric={text}
text={text}
/>
))}
</div>

View file

@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback } from 'react';
import isElectron from 'is-electron';
import styled from 'styled-components';
import { useSettingsStore } from '/@/renderer/store/settings.store';
@ -16,6 +16,7 @@ import {
import { CenterControls } from './center-controls';
import { LeftControls } from './left-controls';
import { RightControls } from './right-controls';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
const PlayerbarContainer = styled.div`
width: 100%;
@ -56,7 +57,7 @@ const utils = isElectron() ? window.electron.utils : null;
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
export const Playerbar = () => {
const playersRef = useRef<any>();
const playersRef = PlayersRef;
const settings = useSettingsStore((state) => state.playback);
const volume = useVolume();
const player1 = usePlayer1Data();

View file

@ -60,8 +60,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
const resetNextPlayer = useCallback(() => {
currentPlayerRef.getInternalPlayer().volume = 0.1;
nextPlayerRef.getInternalPlayer().currentTime = 0;
nextPlayerRef.getInternalPlayer().pause();
const nextPlayer = nextPlayerRef.getInternalPlayer();
if (nextPlayer) {
nextPlayer.currentTime = 0;
nextPlayer.pause();
}
}, [currentPlayerRef, nextPlayerRef]);
const stopPlayback = useCallback(() => {
@ -380,7 +384,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
// Reset the current track more than 10 seconds have elapsed
if (currentTime >= 10) {
setCurrentTime(0);
setCurrentTime(0, true);
handleScrobbleFromSongRestart(currentTime);
mpris?.updateSeek(0);
if (isMpvPlayer) {
@ -509,7 +513,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
const evaluatedTime = currentTime - seconds;
const newTime = evaluatedTime < 0 ? 0 : evaluatedTime;
setCurrentTime(newTime);
setCurrentTime(newTime, true);
mpris?.updateSeek(newTime);
if (isMpvPlayer) {
@ -529,7 +533,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
const newTime = currentTime + seconds;
mpvPlayer.seek(seconds);
mpris?.updateSeek(newTime);
setCurrentTime(newTime);
setCurrentTime(newTime, true);
} else {
const checkNewTime = currentTime + seconds;
const songDuration = currentPlayerRef.player.player.duration;
@ -538,7 +542,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
mpris?.updateSeek(newTime);
resetNextPlayer();
setCurrentTime(newTime);
setCurrentTime(newTime, true);
currentPlayerRef.seekTo(newTime);
}
};
@ -553,7 +557,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleSeekSlider = useCallback(
(e: number | any) => {
setCurrentTime(e);
setCurrentTime(e, true);
handleScrobbleFromSeek(e);
debouncedSeek(e);

View file

@ -0,0 +1,3 @@
import { createRef } from 'react';
export const PlayersRef = createRef<any>();

View file

@ -0,0 +1,88 @@
import { Switch } from '@mantine/core';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useLyricsSettings, useSettingsStoreActions } from '/@/renderer/store';
import { MultiSelect, MultiSelectProps } from '/@/renderer/components';
import isElectron from 'is-electron';
import styled from 'styled-components';
import { LyricSource } from '/@/renderer/types';
const localSettings = isElectron() ? window.electron.localSettings : null;
const WorkingButtonSelect = styled(MultiSelect)<MultiSelectProps>`
& button {
padding: 0;
}
`;
export const LyricSettings = () => {
const settings = useLyricsSettings();
const { setSettings } = useSettingsStoreActions();
const lyricOptions: SettingOption[] = [
{
control: (
<Switch
aria-label="Follow lyrics"
defaultChecked={settings.follow}
onChange={(e) => {
setSettings({
lyrics: {
...settings,
follow: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Enable or disable following of current lyric',
title: 'Follow current lyric',
},
{
control: (
<Switch
aria-label="Enable fetching lyrics"
defaultChecked={settings.fetch}
onChange={(e) => {
setSettings({
lyrics: {
...settings,
fetch: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Enable or disable fetching lyrics for the current song',
isHidden: !isElectron(),
title: 'Fetch lyrics from the internet',
},
{
control: (
<WorkingButtonSelect
clearable
aria-label="Lyric providers"
data={Object.values(LyricSource)}
defaultValue={settings.sources}
width={300}
onChange={(e: LyricSource[]) => {
localSettings?.set('lyrics', e);
setSettings({
lyrics: {
...settings,
sources: e,
},
});
}}
/>
),
description: 'List of lyric fetchers (in order of preference)',
isHidden: !isElectron(),
title: 'Providers to fetch music',
},
];
return <SettingsSection options={lyricOptions} />;
};

View file

@ -3,6 +3,7 @@ import { Divider, Stack } from '@mantine/core';
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings';
import isElectron from 'is-electron';
import { LyricSettings } from '/@/renderer/features/settings/components/playback/lyric-settings';
const MpvSettings = lazy(() =>
import('/@/renderer/features/settings/components/playback/mpv-settings').then((module) => {
@ -17,6 +18,8 @@ export const PlaybackTab = () => {
<Suspense fallback={<></>}>{isElectron() && <MpvSettings />}</Suspense>
<Divider />
<ScrobbleSettings />
<Divider />
<LyricSettings />
</Stack>
);
};

View file

@ -1,5 +1,6 @@
import { IpcRendererEvent } from 'electron';
import { PlayerData, PlayerState } from './store';
import { QueueSong } from '/@/renderer/api/types';
declare global {
interface Window {
@ -8,8 +9,11 @@ declare global {
ipc: any;
ipcRenderer: {
APP_RESTART(): void;
LYRIC_FETCH(data: QueueSong): void;
LYRIC_GET(event: IpcRendererEvent, songName: string, source: string, lyric: string): void;
PLAYER_AUTO_NEXT(data: PlayerData): void;
PLAYER_CURRENT_TIME(): void;
PLAYER_GET_TIME(): number | undefined;
PLAYER_MEDIA_KEYS_DISABLE(): void;
PLAYER_MEDIA_KEYS_ENABLE(): void;
PLAYER_MUTE(): void;
@ -44,6 +48,7 @@ declare global {
windowUnmaximize(): void;
};
localSettings: any;
lyrics: any;
mpris: any;
mpvPlayer: any;
mpvPlayerListener: any;

View file

@ -15,6 +15,7 @@ export interface PlayerState {
nextIndex: number;
player: 1 | 2;
previousIndex: number;
seek: boolean;
shuffledIndex: number;
song?: QueueSong;
status: PlayerStatus;
@ -76,7 +77,7 @@ export interface PlayerSlice extends PlayerState {
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
restoreQueue: (data: Partial<PlayerState>) => PlayerData;
setCurrentIndex: (index: number) => PlayerData;
setCurrentTime: (time: number) => void;
setCurrentTime: (time: number, seek?: boolean) => void;
setCurrentTrack: (uniqueId: string) => PlayerData;
setFavorite: (ids: string[], favorite: boolean) => string[];
setMuted: (muted: boolean) => void;
@ -668,8 +669,9 @@ export const usePlayerStore = create<PlayerSlice>()(
return get().actions.getPlayerData();
},
setCurrentTime: (time) => {
setCurrentTime: (time, seek = false) => {
set((state) => {
state.current.seek = seek;
state.current.time = time;
});
},
@ -834,6 +836,7 @@ export const usePlayerStore = create<PlayerSlice>()(
nextIndex: 0,
player: 1,
previousIndex: 0,
seek: false,
shuffledIndex: 0,
song: {} as QueueSong,
status: PlayerStatus.PAUSED,
@ -944,6 +947,8 @@ export const useShuffleStatus = () => usePlayerStore((state) => state.shuffle);
export const useCurrentTime = () => usePlayerStore((state) => state.current.time);
export const useSeeked = () => usePlayerStore((state) => state.current.seek);
export const useVolume = () => usePlayerStore((state) => state.volume);
export const useMuted = () => usePlayerStore((state) => state.muted);

View file

@ -19,6 +19,7 @@ import {
PlaybackType,
TableType,
Platform,
LyricSource,
} from '/@/renderer/types';
export type SidebarItemType = {
@ -121,6 +122,11 @@ export interface SettingsState {
bindings: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>;
globalMediaHotkeys: boolean;
};
lyrics: {
fetch: boolean;
follow: boolean;
sources: LyricSource[];
};
playback: {
audioDeviceId?: string | null;
crossfadeDuration: number;
@ -202,6 +208,11 @@ const initialState: SettingsState = {
},
globalMediaHotkeys: true,
},
lyrics: {
fetch: false,
follow: true,
sources: [],
},
playback: {
audioDeviceId: undefined,
crossfadeDuration: 5,
@ -416,3 +427,5 @@ export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys
export const useMpvSettings = () =>
useSettingsStore((state) => state.playback.mpvProperties, shallow);
export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow);

View file

@ -176,3 +176,8 @@ export type GridCardData = {
playButtonBehavior: Play;
route: CardRoute;
};
export enum LyricSource {
GENIUS = 'genius',
NETEASE = 'netease',
}