Lint all files

This commit is contained in:
jeffvli 2023-07-01 19:10:05 -07:00
parent 22af76b4d6
commit 30e52ebb54
334 changed files with 76519 additions and 75932 deletions

View file

@ -6,170 +6,170 @@ import { openModal } from '@mantine/modals';
import orderBy from 'lodash/orderBy';
import styled from 'styled-components';
import {
InternetProviderLyricSearchResponse,
LyricSource,
LyricsOverride,
InternetProviderLyricSearchResponse,
LyricSource,
LyricsOverride,
} from '../../../api/types';
import { useLyricSearch } from '../queries/lyric-search-query';
import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components';
const SearchItem = styled.button`
all: unset;
box-sizing: border-box !important;
padding: 0.5rem;
border-radius: 5px;
cursor: pointer;
all: unset;
box-sizing: border-box !important;
padding: 0.5rem;
border-radius: 5px;
cursor: pointer;
&:hover,
&:focus-visible {
color: var(--btn-default-fg-hover);
background: var(--btn-default-bg-hover);
}
&:hover,
&:focus-visible {
color: var(--btn-default-fg-hover);
background: var(--btn-default-bg-hover);
}
`;
interface SearchResultProps {
data: InternetProviderLyricSearchResponse;
onClick?: () => void;
data: InternetProviderLyricSearchResponse;
onClick?: () => void;
}
const SearchResult = ({ data, onClick }: SearchResultProps) => {
const { artist, name, source, score, id } = data;
const { artist, name, source, score, id } = data;
const percentageScore = useMemo(() => {
if (!score) return 0;
return ((1 - score) * 100).toFixed(2);
}, [score]);
const percentageScore = useMemo(() => {
if (!score) return 0;
return ((1 - score) * 100).toFixed(2);
}, [score]);
const cleanId =
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
const cleanId =
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
return (
<SearchItem onClick={onClick}>
<Group
noWrap
position="apart"
>
<Stack
maw="65%"
spacing={0}
>
<Text
size="md"
weight={600}
>
{name}
</Text>
<Text $secondary>{artist}</Text>
<Group
noWrap
spacing="sm"
>
<Text
$secondary
size="sm"
return (
<SearchItem onClick={onClick}>
<Group
noWrap
position="apart"
>
{[source, cleanId].join(' — ')}
</Text>
</Group>
</Stack>
<Text>{percentageScore}%</Text>
</Group>
</SearchItem>
);
<Stack
maw="65%"
spacing={0}
>
<Text
size="md"
weight={600}
>
{name}
</Text>
<Text $secondary>{artist}</Text>
<Group
noWrap
spacing="sm"
>
<Text
$secondary
size="sm"
>
{[source, cleanId].join(' — ')}
</Text>
</Group>
</Stack>
<Text>{percentageScore}%</Text>
</Group>
</SearchItem>
);
};
interface LyricSearchFormProps {
artist?: string;
name?: string;
onSearchOverride?: (params: LyricsOverride) => void;
artist?: string;
name?: string;
onSearchOverride?: (params: LyricsOverride) => void;
}
export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
const form = useForm({
initialValues: {
artist: artist || '',
name: name || '',
},
});
const [debouncedArtist] = useDebouncedValue(form.values.artist, 500);
const [debouncedName] = useDebouncedValue(form.values.name, 500);
const { data, isInitialLoading } = useLyricSearch({
query: { artist: debouncedArtist, name: debouncedName },
});
const searchResults = useMemo(() => {
if (!data) return [];
const results: InternetProviderLyricSearchResponse[] = [];
Object.keys(data).forEach((key) => {
(data[key as keyof typeof data] || []).forEach((result) => results.push(result));
const form = useForm({
initialValues: {
artist: artist || '',
name: name || '',
},
});
const scoredResults = orderBy(results, ['score'], ['asc']);
const [debouncedArtist] = useDebouncedValue(form.values.artist, 500);
const [debouncedName] = useDebouncedValue(form.values.name, 500);
return scoredResults;
}, [data]);
const { data, isInitialLoading } = useLyricSearch({
query: { artist: debouncedArtist, name: debouncedName },
});
return (
<Stack w="100%">
<form>
<Group grow>
<TextInput
data-autofocus
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Artist"
{...form.getInputProps('artist')}
/>
</Group>
</form>
<Divider />
{isInitialLoading ? (
<Spinner container />
) : (
<ScrollArea
offsetScrollbars
h={400}
pr="1rem"
type="auto"
w="100%"
>
<Stack spacing="md">
{searchResults.map((result) => (
<SearchResult
key={`${result.source}-${result.id}`}
data={result}
onClick={() => {
onSearchOverride?.({
artist: result.artist,
id: result.id,
name: result.name,
remote: true,
source: result.source as LyricSource,
});
}}
/>
))}
</Stack>
</ScrollArea>
)}
</Stack>
);
const searchResults = useMemo(() => {
if (!data) return [];
const results: InternetProviderLyricSearchResponse[] = [];
Object.keys(data).forEach((key) => {
(data[key as keyof typeof data] || []).forEach((result) => results.push(result));
});
const scoredResults = orderBy(results, ['score'], ['asc']);
return scoredResults;
}, [data]);
return (
<Stack w="100%">
<form>
<Group grow>
<TextInput
data-autofocus
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Artist"
{...form.getInputProps('artist')}
/>
</Group>
</form>
<Divider />
{isInitialLoading ? (
<Spinner container />
) : (
<ScrollArea
offsetScrollbars
h={400}
pr="1rem"
type="auto"
w="100%"
>
<Stack spacing="md">
{searchResults.map((result) => (
<SearchResult
key={`${result.source}-${result.id}`}
data={result}
onClick={() => {
onSearchOverride?.({
artist: result.artist,
id: result.id,
name: result.name,
remote: true,
source: result.source as LyricSource,
});
}}
/>
))}
</Stack>
</ScrollArea>
)}
</Stack>
);
};
export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
openModal({
children: (
<LyricsSearchForm
artist={artist}
name={name}
onSearchOverride={onSearchOverride}
/>
),
size: 'lg',
title: 'Lyrics Search',
});
openModal({
children: (
<LyricsSearchForm
artist={artist}
name={name}
onSearchOverride={onSearchOverride}
/>
),
size: 'lg',
title: 'Lyrics Search',
});
};

View file

@ -3,29 +3,29 @@ import { TextTitle } from '/@/renderer/components/text-title';
import styled from 'styled-components';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
text: string;
text: string;
}
const StyledText = styled(TextTitle)`
color: var(--main-fg);
font-weight: 400;
font-size: 2.5vmax;
transform: scale(0.95);
opacity: 0.5;
color: var(--main-fg);
font-weight: 400;
font-size: 2.5vmax;
transform: scale(0.95);
opacity: 0.5;
&.active {
font-weight: 800;
transform: scale(1);
opacity: 1;
}
&.active {
font-weight: 800;
transform: scale(1);
opacity: 1;
}
&.active.unsynchronized {
opacity: 0.8;
}
&.active.unsynchronized {
opacity: 0.8;
}
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
`;
export const LyricLine = ({ text, ...props }: LyricLineProps) => {
return <StyledText {...props}>{text}</StyledText>;
return <StyledText {...props}>{text}</StyledText>;
};

View file

@ -4,88 +4,88 @@ import { LyricsOverride } from '/@/renderer/api/types';
import { Button, NumberInput, Tooltip } from '/@/renderer/components';
import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';
import {
useCurrentSong,
useLyricsSettings,
useSettingsStore,
useSettingsStoreActions,
useCurrentSong,
useLyricsSettings,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store';
interface LyricsActionsProps {
onRemoveLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void;
onRemoveLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void;
}
export const LyricsActions = ({ onRemoveLyric, onSearchOverride }: LyricsActionsProps) => {
const currentSong = useCurrentSong();
const { setSettings } = useSettingsStoreActions();
const { delayMs, sources } = useLyricsSettings();
const currentSong = useCurrentSong();
const { setSettings } = useSettingsStoreActions();
const { delayMs, sources } = useLyricsSettings();
const handleLyricOffset = (e: number) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
delayMs: e,
},
});
};
const handleLyricOffset = (e: number) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
delayMs: e,
},
});
};
const isActionsDisabled = !currentSong;
const isDesktop = isElectron();
const isActionsDisabled = !currentSong;
const isDesktop = isElectron();
return (
<>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={() =>
openLyricSearchModal({
artist: currentSong?.artistName,
name: currentSong?.name,
onSearchOverride,
})
}
>
Search
</Button>
) : null}
<Button
aria-label="Decrease lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs - 50)}
>
<RiSubtractFill />
</Button>
<Tooltip
label="Offset (ms)"
openDelay={500}
>
<NumberInput
aria-label="Lyric offset"
styles={{ input: { textAlign: 'center' } }}
value={delayMs || 0}
width={55}
onChange={handleLyricOffset}
/>
</Tooltip>
<Button
aria-label="Increase lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs + 50)}
>
<RiAddFill />
</Button>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={onRemoveLyric}
>
Clear
</Button>
) : null}
</>
);
return (
<>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={() =>
openLyricSearchModal({
artist: currentSong?.artistName,
name: currentSong?.name,
onSearchOverride,
})
}
>
Search
</Button>
) : null}
<Button
aria-label="Decrease lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs - 50)}
>
<RiSubtractFill />
</Button>
<Tooltip
label="Offset (ms)"
openDelay={500}
>
<NumberInput
aria-label="Lyric offset"
styles={{ input: { textAlign: 'center' } }}
value={delayMs || 0}
width={55}
onChange={handleLyricOffset}
/>
</Tooltip>
<Button
aria-label="Increase lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs + 50)}
>
<RiAddFill />
</Button>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={onRemoveLyric}
>
Clear
</Button>
) : null}
</>
);
};

View file

@ -11,196 +11,198 @@ import { ErrorFallback } from '/@/renderer/features/action-required';
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { getServerById, useCurrentSong, usePlayerStore } from '/@/renderer/store';
import {
FullLyricsMetadata,
LyricsOverride,
SynchronizedLyricMetadata,
UnsynchronizedLyricMetadata,
FullLyricsMetadata,
LyricsOverride,
SynchronizedLyricMetadata,
UnsynchronizedLyricMetadata,
} from '/@/renderer/api/types';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
const ActionsContainer = styled.div`
position: absolute;
bottom: 0;
left: 0;
z-index: 50;
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
width: 100%;
opacity: 0;
transition: opacity 0.2s ease-in-out;
position: absolute;
bottom: 0;
left: 0;
z-index: 50;
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
width: 100%;
opacity: 0;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 1 !important;
}
&:hover {
opacity: 1 !important;
}
&:focus-within {
opacity: 1 !important;
}
&:focus-within {
opacity: 1 !important;
}
`;
const LyricsContainer = styled.div`
position: relative;
display: flex;
width: 100%;
height: 100%;
position: relative;
display: flex;
width: 100%;
height: 100%;
&:hover {
${ActionsContainer} {
opacity: 0.6;
&:hover {
${ActionsContainer} {
opacity: 0.6;
}
}
}
`;
const ScrollContainer = styled(motion.div)`
position: relative;
z-index: 1;
width: 100%;
height: 100%;
text-align: center;
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
&.mantine-ScrollArea-root {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
}
text-align: center;
& .mantine-ScrollArea-viewport {
height: 100% !important;
}
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
& .mantine-ScrollArea-viewport > div {
height: 100%;
}
&.mantine-ScrollArea-root {
width: 100%;
height: 100%;
}
& .mantine-ScrollArea-viewport {
height: 100% !important;
}
& .mantine-ScrollArea-viewport > div {
height: 100%;
}
`;
function isSynchronized(
data: Partial<FullLyricsMetadata> | undefined,
data: Partial<FullLyricsMetadata> | undefined,
): data is SynchronizedLyricMetadata {
// Type magic. The only difference between Synchronized and Unsynchhronized is
// the datatype of lyrics. This makes Typescript happier later...
if (!data) return false;
return Array.isArray(data.lyrics);
// Type magic. The only difference between Synchronized and Unsynchhronized is
// the datatype of lyrics. This makes Typescript happier later...
if (!data) return false;
return Array.isArray(data.lyrics);
}
export const Lyrics = () => {
const currentSong = useCurrentSong();
const currentServer = getServerById(currentSong?.serverId);
const currentSong = useCurrentSong();
const currentServer = getServerById(currentSong?.serverId);
const [clear, setClear] = useState(false);
const [clear, setClear] = useState(false);
const { data, isInitialLoading } = useSongLyricsBySong(
{
query: { songId: currentSong?.id || '' },
serverId: currentServer?.id,
},
currentSong,
);
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
setOverride(params);
setClear(false);
}, []);
const { data: overrideLyrics, isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({
options: {
enabled: !!override,
},
query: {
remoteSongId: override?.id,
remoteSource: override?.source,
},
serverId: currentServer?.id,
});
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => state.current.song,
() => {
setOverride(undefined);
setClear(false);
},
{ equalityFn: (a, b) => a?.id === b?.id },
const { data, isInitialLoading } = useSongLyricsBySong(
{
query: { songId: currentSong?.id || '' },
serverId: currentServer?.id,
},
currentSong,
);
return () => {
unsubSongChange();
};
}, []);
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
const isLoadingLyrics = isInitialLoading || isOverrideLoading;
const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
setOverride(params);
setClear(false);
}, []);
const hasNoLyrics = (!data?.lyrics && !overrideLyrics) || clear;
const { data: overrideLyrics, isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({
options: {
enabled: !!override,
},
query: {
remoteSongId: override?.id,
remoteSource: override?.source,
},
serverId: currentServer?.id,
});
const lyricsMetadata:
| Partial<SynchronizedLyricMetadata>
| Partial<UnsynchronizedLyricMetadata>
| undefined = overrideLyrics
? {
artist: override?.artist,
lyrics: overrideLyrics,
name: override?.name,
remote: true,
source: override?.source,
}
: data;
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => state.current.song,
() => {
setOverride(undefined);
setClear(false);
},
{ equalityFn: (a, b) => a?.id === b?.id },
);
const isSynchronizedLyrics = isSynchronized(lyricsMetadata);
return () => {
unsubSongChange();
};
}, []);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<LyricsContainer>
{isLoadingLyrics ? (
<Spinner
container
size={25}
/>
) : (
<AnimatePresence mode="sync">
{hasNoLyrics ? (
<Center w="100%">
<Group>
<RiInformationFill size="2rem" />
<TextTitle
order={3}
weight={700}
>
No lyrics found
</TextTitle>
</Group>
</Center>
) : (
<ScrollContainer
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{isSynchronizedLyrics ? (
<SynchronizedLyrics {...lyricsMetadata} />
const isLoadingLyrics = isInitialLoading || isOverrideLoading;
const hasNoLyrics = (!data?.lyrics && !overrideLyrics) || clear;
const lyricsMetadata:
| Partial<SynchronizedLyricMetadata>
| Partial<UnsynchronizedLyricMetadata>
| undefined = overrideLyrics
? {
artist: override?.artist,
lyrics: overrideLyrics,
name: override?.name,
remote: true,
source: override?.source,
}
: data;
const isSynchronizedLyrics = isSynchronized(lyricsMetadata);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<LyricsContainer>
{isLoadingLyrics ? (
<Spinner
container
size={25}
/>
) : (
<UnsynchronizedLyrics {...(lyricsMetadata as UnsynchronizedLyricMetadata)} />
<AnimatePresence mode="sync">
{hasNoLyrics ? (
<Center w="100%">
<Group>
<RiInformationFill size="2rem" />
<TextTitle
order={3}
weight={700}
>
No lyrics found
</TextTitle>
</Group>
</Center>
) : (
<ScrollContainer
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{isSynchronizedLyrics ? (
<SynchronizedLyrics {...lyricsMetadata} />
) : (
<UnsynchronizedLyrics
{...(lyricsMetadata as UnsynchronizedLyricMetadata)}
/>
)}
</ScrollContainer>
)}
</AnimatePresence>
)}
</ScrollContainer>
)}
</AnimatePresence>
)}
<ActionsContainer>
<LyricsActions
onRemoveLyric={() => setClear(true)}
onSearchOverride={handleOnSearchOverride}
/>
</ActionsContainer>
</LyricsContainer>
</ErrorBoundary>
);
<ActionsContainer>
<LyricsActions
onRemoveLyric={() => setClear(true)}
onSearchOverride={handleOnSearchOverride}
/>
</ActionsContainer>
</LyricsContainer>
</ErrorBoundary>
);
};

View file

@ -1,11 +1,11 @@
import { UseQueryResult, useQuery } from '@tanstack/react-query';
import {
LyricsQuery,
QueueSong,
SynchronizedLyricsArray,
InternetProviderLyricResponse,
FullLyricsMetadata,
LyricGetQuery,
LyricsQuery,
QueueSong,
SynchronizedLyricsArray,
InternetProviderLyricResponse,
FullLyricsMetadata,
LyricGetQuery,
} from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById, useLyricsSettings } from '/@/renderer/store';
@ -25,142 +25,144 @@ const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)\n/g;
const alternateTimeExp = /\[(\d*),(\d*)]([^\n]+)\n/g;
const formatLyrics = (lyrics: string) => {
const synchronizedLines = lyrics.matchAll(timeExp);
const formattedLyrics: SynchronizedLyricsArray = [];
const synchronizedLines = lyrics.matchAll(timeExp);
const formattedLyrics: 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;
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;
const timeInMilis = (minutes * 60 + seconds) * 1000 + milis;
formattedLyrics.push([timeInMilis, text]);
}
formattedLyrics.push([timeInMilis, text]);
}
if (formattedLyrics.length > 0) return formattedLyrics;
if (formattedLyrics.length > 0) return formattedLyrics;
const alternateSynchronizedLines = lyrics.matchAll(alternateTimeExp);
for (const line of alternateSynchronizedLines) {
const [, timeInMilis, , text] = line;
const cleanText = text
.replaceAll(/\(\d+,\d+\)/g, '')
.replaceAll(/\s,/g, ',')
.replaceAll(/\s\./g, '.');
formattedLyrics.push([Number(timeInMilis), cleanText]);
}
const alternateSynchronizedLines = lyrics.matchAll(alternateTimeExp);
for (const line of alternateSynchronizedLines) {
const [, timeInMilis, , text] = line;
const cleanText = text
.replaceAll(/\(\d+,\d+\)/g, '')
.replaceAll(/\s,/g, ',')
.replaceAll(/\s\./g, '.');
formattedLyrics.push([Number(timeInMilis), cleanText]);
}
if (formattedLyrics.length > 0) return formattedLyrics;
if (formattedLyrics.length > 0) return formattedLyrics;
// If no synchronized lyrics were found, return the original lyrics
return lyrics;
// If no synchronized lyrics were found, return the original lyrics
return lyrics;
};
export const useServerLyrics = (
args: QueryHookArgs<LyricsQuery>,
args: QueryHookArgs<LyricsQuery>,
): UseQueryResult<string | null> => {
const { query, serverId } = args;
const server = getServerById(serverId);
const { query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
// Note: This currently fetches for every song, even if it shouldn't have
// lyrics, because for some reason HasLyrics is not exposed. Thus, ignore the error
onError: () => {},
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
// This should only be called for Jellyfin. Return null to ignore errors
if (server.type !== ServerType.JELLYFIN) return null;
return api.controller.getLyrics({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
});
return useQuery({
// Note: This currently fetches for every song, even if it shouldn't have
// lyrics, because for some reason HasLyrics is not exposed. Thus, ignore the error
onError: () => {},
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
// This should only be called for Jellyfin. Return null to ignore errors
if (server.type !== ServerType.JELLYFIN) return null;
return api.controller.getLyrics({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
});
};
export const useSongLyricsBySong = (
args: QueryHookArgs<LyricsQuery>,
song: QueueSong | undefined,
args: QueryHookArgs<LyricsQuery>,
song: QueueSong | undefined,
): UseQueryResult<FullLyricsMetadata> => {
const { query } = args;
const { fetch } = useLyricsSettings();
const server = getServerById(song?.serverId);
const { query } = args;
const { fetch } = useLyricsSettings();
const server = getServerById(song?.serverId);
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!song && !!server,
onError: () => {},
queryFn: async ({ signal }) => {
if (!server) throw new Error('Server not found');
if (!song) return null;
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!song && !!server,
onError: () => {},
queryFn: async ({ signal }) => {
if (!server) throw new Error('Server not found');
if (!song) return null;
if (song.lyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: formatLyrics(song.lyrics),
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
if (song.lyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: formatLyrics(song.lyrics),
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
if (server.type === ServerType.JELLYFIN) {
const jfLyrics = await api.controller
.getLyrics({
apiClientProps: { server, signal },
query: { songId: song.id },
})
.catch((err) => console.log(err));
if (server.type === ServerType.JELLYFIN) {
const jfLyrics = await api.controller
.getLyrics({
apiClientProps: { server, signal },
query: { songId: song.id },
})
.catch((err) => console.log(err));
if (jfLyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: jfLyrics,
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
}
if (jfLyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: jfLyrics,
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
}
if (fetch) {
const remoteLyricsResult: InternetProviderLyricResponse | null =
await lyricsIpc?.getRemoteLyricsBySong(song);
if (fetch) {
const remoteLyricsResult: InternetProviderLyricResponse | null =
await lyricsIpc?.getRemoteLyricsBySong(song);
if (remoteLyricsResult) {
return {
...remoteLyricsResult,
lyrics: formatLyrics(remoteLyricsResult.lyrics),
remote: true,
};
}
}
if (remoteLyricsResult) {
return {
...remoteLyricsResult,
lyrics: formatLyrics(remoteLyricsResult.lyrics),
remote: true,
};
}
}
return null;
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
staleTime: 1000 * 60 * 2,
});
return null;
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
staleTime: 1000 * 60 * 2,
});
};
export const useSongLyricsByRemoteId = (
args: QueryHookArgs<Partial<LyricGetQuery>>,
args: QueryHookArgs<Partial<LyricGetQuery>>,
): UseQueryResult<string | null> => {
const { query } = args;
const { query } = args;
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!query.remoteSongId && !!query.remoteSource,
onError: () => {},
queryFn: async () => {
const remoteLyricsResult: string | null = await lyricsIpc?.getRemoteLyricsByRemoteId(query);
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!query.remoteSongId && !!query.remoteSource,
onError: () => {},
queryFn: async () => {
const remoteLyricsResult: string | null = await lyricsIpc?.getRemoteLyricsByRemoteId(
query,
);
if (remoteLyricsResult) {
return formatLyrics(remoteLyricsResult);
}
if (remoteLyricsResult) {
return formatLyrics(remoteLyricsResult);
}
return null;
},
queryKey: queryKeys.songs.lyricsByRemoteId(query),
staleTime: 1000 * 60 * 5,
});
return null;
},
queryKey: queryKeys.songs.lyricsByRemoteId(query),
staleTime: 1000 * 60 * 5,
});
};

View file

@ -2,23 +2,23 @@ import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
} from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
const lyricsIpc = isElectron() ? window.electron.lyrics : null;
export const useLyricSearch = (args: Omit<QueryHookArgs<LyricSearchQuery>, 'serverId'>) => {
const { options, query } = args;
const { options, query } = args;
return useQuery<Record<LyricSource, InternetProviderLyricSearchResponse[]>>({
cacheTime: 1000 * 60 * 1,
enabled: !!query.artist || !!query.name,
queryFn: () => lyricsIpc?.searchRemoteLyrics(query),
queryKey: queryKeys.songs.lyricsSearch(query),
staleTime: 1000 * 60 * 1,
...options,
});
return useQuery<Record<LyricSource, InternetProviderLyricSearchResponse[]>>({
cacheTime: 1000 * 60 * 1,
enabled: !!query.artist || !!query.name,
queryFn: () => lyricsIpc?.searchRemoteLyrics(query),
queryKey: queryKeys.songs.lyricsSearch(query),
staleTime: 1000 * 60 * 1,
...options,
});
};

View file

@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef } from 'react';
import {
useCurrentStatus,
useCurrentTime,
useLyricsSettings,
usePlayerType,
useSeeked,
useCurrentStatus,
useCurrentTime,
useLyricsSettings,
usePlayerType,
useSeeked,
} from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
@ -16,303 +16,310 @@ import styled from 'styled-components';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const SynchronizedLyricsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
`;
interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray;
lyrics: SynchronizedLyricsArray;
}
export const SynchronizedLyrics = ({
artist,
lyrics,
name,
remote,
source,
artist,
lyrics,
name,
remote,
source,
}: SynchronizedLyricsProps) => {
const playersRef = PlayersRef;
const status = useCurrentStatus();
const playerType = usePlayerType();
const now = useCurrentTime();
const settings = useLyricsSettings();
const playersRef = PlayersRef;
const status = useCurrentStatus();
const playerType = usePlayerType();
const now = useCurrentTime();
const settings = useLyricsSettings();
const seeked = useSeeked();
const seeked = useSeeked();
// A reference to the timeout handler
const lyricTimer = useRef<ReturnType<typeof setTimeout>>();
// A reference to the timeout handler
const lyricTimer = useRef<ReturnType<typeof setTimeout>>();
// 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 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);
// A constantly increasing value, used to tell timers that may be out of date
// whether to proceed or stop
const timerEpoch = useRef(0);
const delayMsRef = useRef(settings.delayMs);
const followRef = useRef(settings.follow);
const delayMsRef = useRef(settings.delayMs);
const followRef = useRef(settings.follow);
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;
}
}
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 activeLyrics.length - 1;
}
return -1;
};
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 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 doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement;
const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 ?? 0;
if (currentLyric === null) {
lyricRef.current = undefined;
return;
}
currentLyric.classList.add('active');
if (followRef.current) {
doc?.scroll({ behavior: 'smooth', top: offsetTop });
}
if (index !== lyricRef.current!.length - 1) {
const nextTime = lyricRef.current![index + 1][0];
const elapsed = performance.now() - start;
lyricTimer.current = setTimeout(() => {
setCurrentLyric(nextTime, nextEpoch, index + 1);
}, nextTime - timeInMs - elapsed);
}
}, []);
useEffect(() => {
// Copy the follow settings into a ref that can be accessed in the timeout
followRef.current = settings.follow;
}, [settings.follow]);
useEffect(() => {
// This handler is used to handle when lyrics change. It is in some sense the
// 'primary' handler for parsing lyrics, as unlike the other callbacks, it will
// ALSO remove listeners on close. Use the promisified getCurrentTime(), because
// we don't want to be dependent on npw, which may not be precise
lyricRef.current = lyrics;
if (status === PlayerStatus.PLAYING) {
let rejected = false;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
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.
// Do NOT do this for other cleanup functions, as it should only be done
// when switching to a new song (or an empty one)
if (lyricTimer.current) clearTimeout(lyricTimer.current);
};
}
return () => {};
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
useEffect(() => {
// This handler is used to deal with changes to the current delay. If the offset
// changes, we should immediately stop the current listening set and calculate
// the correct one using the new offset. Afterwards, timing can be calculated like normal
const changed = delayMsRef.current !== settings.delayMs;
if (!changed) {
return () => {};
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
let rejected = false;
delayMsRef.current = settings.delayMs;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
return activeLyrics.length - 1;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
return true;
})
.catch(console.error);
return () => {
// In the event this ends earlier, just kill the promise. Cleanup of
// timeouts is otherwise handled by another handler
rejected = true;
return -1;
};
}, [getCurrentTime, setCurrentLyric, settings.delayMs]);
useEffect(() => {
// This handler is used specifically for dealing with seeking. In this case,
// we assume that now is the accurate time
if (status !== PlayerStatus.PLAYING) {
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
const getCurrentTime = useCallback(async () => {
if (isElectron() && playerType !== PlaybackType.WEB) {
if (mpvPlayer) {
return mpvPlayer.getCurrentTime();
}
return 0;
}
return;
}
if (!seeked) {
return;
}
if (playersRef.current === undefined) {
return 0;
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
const player = (
playersRef.current.player1 ?? playersRef.current.player2
).getInternalPlayer();
setCurrentLyric(now * 1000 - delayMsRef.current);
}, [now, seeked, setCurrentLyric, status]);
// 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;
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);
}
return player.currentTime;
}, [playerType, playersRef]);
timerEpoch.current += 1;
}, []);
const setCurrentLyric = useCallback(
(timeInMs: number, epoch?: number, targetIndex?: number) => {
const start = performance.now();
let nextEpoch: number;
const hideScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.add('hide-scrollbar');
};
if (epoch === undefined) {
timerEpoch.current = (timerEpoch.current + 1) % 10000;
nextEpoch = timerEpoch.current;
} else if (epoch !== timerEpoch.current) {
return;
} else {
nextEpoch = epoch;
}
const showScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.remove('hide-scrollbar');
};
let index: number;
return (
<SynchronizedLyricsContainer
className="synchronized-lyrics overlay-scrollbar"
id="sychronized-lyrics-scroll-container"
onMouseEnter={showScrollbar}
onMouseLeave={hideScrollbar}
>
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lyrics.map(([, text], idx) => (
<LyricLine
key={idx}
className="lyric-line synchronized"
id={`lyric-${idx}`}
text={text}
/>
))}
</SynchronizedLyricsContainer>
);
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 doc = document.getElementById(
'sychronized-lyrics-scroll-container',
) as HTMLElement;
const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement;
const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 ?? 0;
if (currentLyric === null) {
lyricRef.current = undefined;
return;
}
currentLyric.classList.add('active');
if (followRef.current) {
doc?.scroll({ behavior: 'smooth', top: offsetTop });
}
if (index !== lyricRef.current!.length - 1) {
const nextTime = lyricRef.current![index + 1][0];
const elapsed = performance.now() - start;
lyricTimer.current = setTimeout(() => {
setCurrentLyric(nextTime, nextEpoch, index + 1);
}, nextTime - timeInMs - elapsed);
}
},
[],
);
useEffect(() => {
// Copy the follow settings into a ref that can be accessed in the timeout
followRef.current = settings.follow;
}, [settings.follow]);
useEffect(() => {
// This handler is used to handle when lyrics change. It is in some sense the
// 'primary' handler for parsing lyrics, as unlike the other callbacks, it will
// ALSO remove listeners on close. Use the promisified getCurrentTime(), because
// we don't want to be dependent on npw, which may not be precise
lyricRef.current = lyrics;
if (status === PlayerStatus.PLAYING) {
let rejected = false;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
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.
// Do NOT do this for other cleanup functions, as it should only be done
// when switching to a new song (or an empty one)
if (lyricTimer.current) clearTimeout(lyricTimer.current);
};
}
return () => {};
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
useEffect(() => {
// This handler is used to deal with changes to the current delay. If the offset
// changes, we should immediately stop the current listening set and calculate
// the correct one using the new offset. Afterwards, timing can be calculated like normal
const changed = delayMsRef.current !== settings.delayMs;
if (!changed) {
return () => {};
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
let rejected = false;
delayMsRef.current = settings.delayMs;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
return true;
})
.catch(console.error);
return () => {
// In the event this ends earlier, just kill the promise. Cleanup of
// timeouts is otherwise handled by another handler
rejected = true;
};
}, [getCurrentTime, setCurrentLyric, settings.delayMs]);
useEffect(() => {
// This handler is used specifically for dealing with seeking. In this case,
// we assume that now is the accurate time
if (status !== PlayerStatus.PLAYING) {
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
return;
}
if (!seeked) {
return;
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
setCurrentLyric(now * 1000 - delayMsRef.current);
}, [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;
}, []);
const hideScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.add('hide-scrollbar');
};
const showScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.remove('hide-scrollbar');
};
return (
<SynchronizedLyricsContainer
className="synchronized-lyrics overlay-scrollbar"
id="sychronized-lyrics-scroll-container"
onMouseEnter={showScrollbar}
onMouseLeave={hideScrollbar}
>
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lyrics.map(([, text], idx) => (
<LyricLine
key={idx}
className="lyric-line synchronized"
id={`lyric-${idx}`}
text={text}
/>
))}
</SynchronizedLyricsContainer>
);
};

View file

@ -4,65 +4,65 @@ import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { FullLyricsMetadata } from '/@/renderer/api/types';
interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
lyrics: string;
}
const UnsynchronizedLyricsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
`;
export const UnsynchronizedLyrics = ({
artist,
lyrics,
name,
remote,
source,
artist,
lyrics,
name,
remote,
source,
}: UnsynchronizedLyricsProps) => {
const lines = useMemo(() => {
return lyrics.split('\n');
}, [lyrics]);
const lines = useMemo(() => {
return lyrics.split('\n');
}, [lyrics]);
return (
<UnsynchronizedLyricsContainer className="unsynchronized-lyrics">
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lines.map((text, idx) => (
<LyricLine
key={idx}
className="lyric-line"
id={`lyric-${idx}`}
text={text}
/>
))}
</UnsynchronizedLyricsContainer>
);
return (
<UnsynchronizedLyricsContainer className="unsynchronized-lyrics">
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lines.map((text, idx) => (
<LyricLine
key={idx}
className="lyric-line"
id={`lyric-${idx}`}
text={text}
/>
))}
</UnsynchronizedLyricsContainer>
);
};