Files
feishin/src/renderer/features/lyrics/components/lyrics-search-form.tsx
T
2026-01-18 15:47:07 -08:00

336 lines
13 KiB
TypeScript

import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import clsx from 'clsx';
import orderBy from 'lodash/orderBy';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './lyrics-search-form.module.css';
import i18n from '/@/i18n/i18n';
import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
import {
SynchronizedLyrics,
SynchronizedLyricsProps,
} from '/@/renderer/features/lyrics/synchronized-lyrics';
import {
UnsynchronizedLyrics,
UnsynchronizedLyricsProps,
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { usePlayerSong } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useForm } from '/@/shared/hooks/use-form';
import {
FullLyricsMetadata,
InternetProviderLyricSearchResponse,
LyricSource,
LyricsOverride,
} from '/@/shared/types/domain-types';
interface SearchResultProps {
data: InternetProviderLyricSearchResponse;
isSelected?: boolean;
onClick?: () => void;
}
const SearchResult = ({ data, isSelected, onClick }: SearchResultProps) => {
const { t } = useTranslation();
const { artist, id, isSync, name, score, source } = data;
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 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 (
<button
className={clsx(styles.searchItem, {
[styles.selected]: isSelected,
})}
onClick={onClick}
>
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} maw="65%">
<Text fw={600} size="md">
{name}
</Text>
<Text isMuted>{artist}</Text>
<Group gap="sm" wrap="nowrap">
<Text isMuted size="sm">
{[source, cleanId, syncStatus].join(' — ')}
</Text>
</Group>
</Stack>
<Text>{percentageScore}%</Text>
</Group>
</button>
);
};
interface LyricSearchFormProps {
artist?: string;
name?: string;
onSearchOverride?: (params: LyricsOverride) => void;
}
export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
const { t } = useTranslation();
const currentSong = usePlayerSong();
const [selectedResult, setSelectedResult] =
useState<InternetProviderLyricSearchResponse | null>(null);
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 } = useQuery(
lyricsQueries.search({
query: { artist: debouncedArtist, name: debouncedName },
}),
);
const { data: previewData, isInitialLoading: isPreviewLoading } = useQuery(
lyricsQueries.songLyricsByRemoteId({
options: {
enabled: !!selectedResult,
},
query: {
remoteSongId: selectedResult?.id,
remoteSource: selectedResult?.source as LyricSource | undefined,
song: currentSong,
},
serverId: currentSong?._serverId || '',
}),
);
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]);
const handleApply = () => {
if (selectedResult && onSearchOverride) {
onSearchOverride({
artist: selectedResult.artist,
id: selectedResult.id,
name: selectedResult.name,
remote: true,
source: selectedResult.source as LyricSource,
});
closeAllModals();
}
};
const handleExport = () => {
if (selectedResult && previewData) {
const lyricsMetadata: FullLyricsMetadata = {
artist: selectedResult.artist,
lyrics: previewData,
name: selectedResult.name,
offsetMs: 0,
remote: true,
source: selectedResult.source,
};
const synced = Array.isArray(previewData);
openLyricsExportModal({ lyrics: lyricsMetadata, offsetMs: 0, synced });
}
};
return (
<Stack h="100%" w="100%">
<form>
<Group grow>
<TextInput
data-autofocus
label={t('form.lyricSearch.input', {
context: 'name',
postProcess: 'titleCase',
})}
rightSection={
form.values.name ? (
<ActionIcon
icon="x"
onClick={() => form.setFieldValue('name', '')}
size="sm"
variant="transparent"
/>
) : null
}
{...form.getInputProps('name')}
/>
<TextInput
label={t('form.lyricSearch.input', {
context: 'artist',
postProcess: 'titleCase',
})}
rightSection={
form.values.artist ? (
<ActionIcon
icon="x"
onClick={() => form.setFieldValue('artist', '')}
size="sm"
variant="transparent"
/>
) : null
}
{...form.getInputProps('artist')}
/>
</Group>
</form>
<Divider />
<Group align="flex-start" grow style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>
<ScrollArea
style={{
height: '100%',
paddingRight: '1rem',
}}
>
{isInitialLoading ? (
<Spinner container />
) : (
<Stack gap="md">
{searchResults.map((result) => (
<SearchResult
data={result}
isSelected={
selectedResult?.id === result.id &&
selectedResult?.source === result.source
}
key={`${result.source}-${result.id}`}
onClick={() => setSelectedResult(result)}
/>
))}
</Stack>
)}
</ScrollArea>
</Stack>
{selectedResult && (
<Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>
<ScrollArea
className={styles['lyrics-preview']}
style={{
height: '100%',
paddingRight: '1rem',
}}
>
{isPreviewLoading ? (
<Spinner container />
) : previewData ? (
<div
className={styles['lyrics-content-wrapper']}
style={{ width: '100%' }}
>
{Array.isArray(previewData) ? (
<SynchronizedLyrics
style={{ padding: 0 }}
{...({
artist: selectedResult.artist,
lyrics: previewData,
name: selectedResult.name,
remote: true,
source: selectedResult.source,
} as SynchronizedLyricsProps)}
/>
) : (
<UnsynchronizedLyrics
{...({
artist: selectedResult.artist,
lyrics: previewData,
name: selectedResult.name,
remote: true,
source: selectedResult.source,
} as UnsynchronizedLyricsProps)}
/>
)}
</div>
) : (
<Center>
<Text isMuted>
{t('page.fullscreenPlayer.noLyrics', {
postProcess: 'sentenceCase',
})}
</Text>
</Center>
)}
</ScrollArea>
</Stack>
)}
</Group>
<Divider />
<Group justify="flex-end">
<Button onClick={() => closeAllModals()} variant="default">
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={!selectedResult || !previewData}
onClick={handleExport}
variant="default"
>
{t('form.lyricsExport.export', { postProcess: 'titleCase' })}
</Button>
<Button disabled={!selectedResult} onClick={handleApply} variant="filled">
{t('common.confirm', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
);
};
export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
openModal({
children: (
<LyricsSearchForm artist={artist} name={name} onSearchOverride={onSearchOverride} />
),
size: 'xl',
styles: {
body: {
height: '600px',
},
},
title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,
});
};