add sync status to lrclib lyrics (#1568)

This commit is contained in:
jeffvli
2026-01-18 15:47:07 -08:00
parent c3d4f6cacd
commit 7f1c4a4d18
7 changed files with 184 additions and 46 deletions
+1
View File
@@ -150,6 +150,7 @@ export async function getSearchResults(
return { return {
artist: song.artist_names, artist: song.artist_names,
id: song.url, id: song.url,
isSync: null,
name: song.full_title, name: song.full_title,
source: LyricSource.GENIUS, source: LyricSource.GENIUS,
}; };
+5
View File
@@ -17,8 +17,12 @@ const TIMEOUT_MS = 5000;
export interface LrcLibSearchResponse { export interface LrcLibSearchResponse {
albumName: string; albumName: string;
artistName: string; artistName: string;
duration?: number;
id: number; id: number;
instrumental?: boolean;
name: string; name: string;
plainLyrics: null | string;
syncedLyrics: null | string;
} }
export interface LrcLibTrackResponse { export interface LrcLibTrackResponse {
@@ -75,6 +79,7 @@ export async function getSearchResults(
return { return {
artist: song.artistName, artist: song.artistName,
id: String(song.id), id: String(song.id),
isSync: song.syncedLyrics ? true : false,
name: song.name, name: song.name,
source: LyricSource.LRCLIB, source: LyricSource.LRCLIB,
}; };
+1
View File
@@ -128,6 +128,7 @@ export async function getSearchResults(
return { return {
artist, artist,
id: String(song.id), id: String(song.id),
isSync: null,
name: song.name, name: song.name,
source: LyricSource.NETEASE, source: LyricSource.NETEASE,
}; };
+67 -6
View File
@@ -1,4 +1,4 @@
import Fuse, { IFuseOptions } from 'fuse.js'; import Fuse, { FuseResult, IFuseOptions } from 'fuse.js';
import { import {
InternetProviderLyricSearchResponse, InternetProviderLyricSearchResponse,
@@ -15,20 +15,81 @@ export const orderSearchResults = (args: {
fieldNormWeight: 1, fieldNormWeight: 1,
includeScore: true, includeScore: true,
keys: [ keys: [
{ getFn: (song) => song.name, name: 'name', weight: 3 }, { getFn: (song) => song.name, name: 'name', weight: 2 },
{ getFn: (song) => song.artist, name: 'artist' }, { getFn: (song) => song.artist, name: 'artist', weight: 2 },
], ],
threshold: 1.0, threshold: 0.6,
}; };
const fuse = new Fuse(results, options); const fuse = new Fuse(results, options);
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({ let searchResults: Array<FuseResult<InternetProviderLyricSearchResponse>>;
if (params.artist && params.name) {
const artistFuse = new Fuse(results, {
includeScore: true,
keys: [{ getFn: (song) => song.artist, name: 'artist' }],
threshold: 0.6,
});
const nameFuse = new Fuse(results, {
includeScore: true,
keys: [{ getFn: (song) => song.name, name: 'name' }],
threshold: 0.6,
});
const artistResults = artistFuse.search(params.artist);
const nameResults = nameFuse.search(params.name);
const artistScores = new Map(artistResults.map((r) => [r.item.id, r.score ?? 1]));
const nameScores = new Map(nameResults.map((r) => [r.item.id, r.score ?? 1]));
const combinedResults = new Map<string, FuseResult<InternetProviderLyricSearchResponse>>();
artistResults.forEach((result) => {
const nameScore = nameScores.get(result.item.id);
if (nameScore !== undefined) {
const combinedScore = Math.max(result.score ?? 1, nameScore);
combinedResults.set(result.item.id, {
...result,
score: combinedScore,
});
}
});
nameResults.forEach((result) => {
if (!combinedResults.has(result.item.id)) {
const artistScore = artistScores.get(result.item.id);
if (artistScore !== undefined) {
const combinedScore = Math.max(result.score ?? 1, artistScore);
combinedResults.set(result.item.id, {
...result,
score: combinedScore,
});
}
}
});
searchResults = Array.from(combinedResults.values());
} else {
searchResults = fuse.search<InternetProviderLyricSearchResponse>({
...(params.artist && { artist: params.artist }), ...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }), ...(params.name && { name: params.name }),
}); });
}
return searchResults.map((result) => ({ const sortedResults = searchResults.sort((a, b) => {
const aIsSync = a.item.isSync === true ? 1 : 0;
const bIsSync = b.item.isSync === true ? 1 : 0;
if (aIsSync !== bIsSync) {
return bIsSync - aIsSync;
}
return (a.score || 0) - (b.score || 0);
});
return sortedResults.map((result) => ({
...result.item, ...result.item,
score: result.score, score: result.score,
})); }));
@@ -33,3 +33,22 @@
} }
} }
} }
.lyrics-preview {
:global(.synchronized-lyrics) {
height: auto !important;
padding: 1rem 0 !important;
overflow: visible !important;
transform: none !important;
}
}
.lyrics-content-wrapper {
:global(> div) {
height: auto !important;
max-height: none !important;
padding: 1rem 0 !important;
overflow: visible !important;
transform: none !important;
}
}
@@ -19,6 +19,7 @@ import {
UnsynchronizedLyricsProps, UnsynchronizedLyricsProps,
} from '/@/renderer/features/lyrics/unsynchronized-lyrics'; } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { usePlayerSong } from '/@/renderer/store'; import { usePlayerSong } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
@@ -43,7 +44,8 @@ interface SearchResultProps {
onClick?: () => void; onClick?: () => void;
} }
const SearchResult = ({ data, isSelected, onClick }: SearchResultProps) => { const SearchResult = ({ data, isSelected, onClick }: SearchResultProps) => {
const { artist, id, name, score, source } = data; const { t } = useTranslation();
const { artist, id, isSync, name, score, source } = data;
const percentageScore = useMemo(() => { const percentageScore = useMemo(() => {
if (!score) return 0; if (!score) return 0;
@@ -53,6 +55,21 @@ const SearchResult = ({ data, isSelected, onClick }: SearchResultProps) => {
const cleanId = const cleanId =
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id; source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
const syncStatus = useMemo(() => {
if (isSync === true) {
return t('page.fullscreenPlayer.config.synchronized', {
postProcess: 'sentenceCase',
});
}
if (isSync === false) {
return t('page.fullscreenPlayer.config.unsynchronized', {
postProcess: 'sentenceCase',
});
}
return t('common.unknown', { postProcess: 'titleCase' });
}, [isSync, t]);
return ( return (
<button <button
className={clsx(styles.searchItem, { className={clsx(styles.searchItem, {
@@ -68,7 +85,7 @@ const SearchResult = ({ data, isSelected, onClick }: SearchResultProps) => {
<Text isMuted>{artist}</Text> <Text isMuted>{artist}</Text>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Text isMuted size="sm"> <Text isMuted size="sm">
{[source, cleanId].join(' — ')} {[source, cleanId, syncStatus].join(' — ')}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>
@@ -171,6 +188,16 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
context: 'name', context: 'name',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
rightSection={
form.values.name ? (
<ActionIcon
icon="x"
onClick={() => form.setFieldValue('name', '')}
size="sm"
variant="transparent"
/>
) : null
}
{...form.getInputProps('name')} {...form.getInputProps('name')}
/> />
<TextInput <TextInput
@@ -178,13 +205,23 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
context: 'artist', context: 'artist',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
rightSection={
form.values.artist ? (
<ActionIcon
icon="x"
onClick={() => form.setFieldValue('artist', '')}
size="sm"
variant="transparent"
/>
) : null
}
{...form.getInputProps('artist')} {...form.getInputProps('artist')}
/> />
</Group> </Group>
</form> </form>
<Divider /> <Divider />
<Group align="flex-start" grow style={{ flex: 1, minHeight: 0 }}> <Group align="flex-start" grow style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Stack style={{ flex: 1, height: '100%', minHeight: 0 }}> <Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>
<ScrollArea <ScrollArea
style={{ style={{
height: '100%', height: '100%',
@@ -211,11 +248,22 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
</ScrollArea> </ScrollArea>
</Stack> </Stack>
{selectedResult && ( {selectedResult && (
<Stack style={{ flex: 1, height: '100%', minHeight: 0 }}> <Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>
<ScrollArea
className={styles['lyrics-preview']}
style={{
height: '100%',
paddingRight: '1rem',
}}
>
{isPreviewLoading ? ( {isPreviewLoading ? (
<Spinner container /> <Spinner container />
) : previewData ? ( ) : previewData ? (
Array.isArray(previewData) ? ( <div
className={styles['lyrics-content-wrapper']}
style={{ width: '100%' }}
>
{Array.isArray(previewData) ? (
<SynchronizedLyrics <SynchronizedLyrics
style={{ padding: 0 }} style={{ padding: 0 }}
{...({ {...({
@@ -236,7 +284,8 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
source: selectedResult.source, source: selectedResult.source,
} as UnsynchronizedLyricsProps)} } as UnsynchronizedLyricsProps)}
/> />
) )}
</div>
) : ( ) : (
<Center> <Center>
<Text isMuted> <Text isMuted>
@@ -246,6 +295,7 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
</Text> </Text>
</Center> </Center>
)} )}
</ScrollArea>
</Stack> </Stack>
)} )}
</Group> </Group>
+1
View File
@@ -1200,6 +1200,7 @@ export type InternetProviderLyricResponse = {
export type InternetProviderLyricSearchResponse = { export type InternetProviderLyricSearchResponse = {
artist: string; artist: string;
id: string; id: string;
isSync: boolean | null;
name: string; name: string;
score?: number; score?: number;
source: LyricSource; source: LyricSource;