mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
Export lyrics (#1383)
* add export button to the lyrics actions * add export button to the lyrics search modal --------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
@@ -355,6 +355,11 @@
|
|||||||
"success": "$t(entity.playlist_one) updated successfully",
|
"success": "$t(entity.playlist_one) updated successfully",
|
||||||
"title": "edit $t(entity.playlist_one)"
|
"title": "edit $t(entity.playlist_one)"
|
||||||
},
|
},
|
||||||
|
"lyricsExport": {
|
||||||
|
"export": "export lyrics",
|
||||||
|
"input_synced": "export synced lyrics",
|
||||||
|
"input_offset": "$t(setting.lyricOffset)"
|
||||||
|
},
|
||||||
"lyricSearch": {
|
"lyricSearch": {
|
||||||
"input_artist": "$t(entity.artist_one)",
|
"input_artist": "$t(entity.artist_one)",
|
||||||
"input_name": "$t(common.name)",
|
"input_name": "$t(common.name)",
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { closeAllModals, openModal } from '@mantine/modals';
|
||||||
|
import formatDuration from 'format-duration';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import i18n from '/@/i18n/i18n';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
|
import { Code } from '/@/shared/components/code/code';
|
||||||
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
|
import { useForm } from '/@/shared/hooks/use-form';
|
||||||
|
import { FullLyricsMetadata } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
interface LyricsExportFormProps {
|
||||||
|
lyrics: FullLyricsMetadata;
|
||||||
|
offsetMs: number;
|
||||||
|
synced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LyricsExportForm = ({ lyrics, offsetMs, synced }: LyricsExportFormProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
offsetMs,
|
||||||
|
synced,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayedLyrics = useMemo(() => {
|
||||||
|
if (form.values.synced && Array.isArray(lyrics.lyrics)) {
|
||||||
|
const contents = lyrics.lyrics
|
||||||
|
.map(
|
||||||
|
(lyric) =>
|
||||||
|
`[${formatDuration(lyric[0], { leading: true, ms: true })}]${lyric[1]}`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `[ar:${lyrics.artist}]
|
||||||
|
[ti:${lyrics.name}]
|
||||||
|
[offset:${form.values.offsetMs + (lyrics.offsetMs ?? 0)}]
|
||||||
|
${contents}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
if (Array.isArray(lyrics.lyrics)) {
|
||||||
|
return lyrics.lyrics.map((lyric) => lyric[1]).join('\n') + '\n';
|
||||||
|
}
|
||||||
|
return lyrics.lyrics;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
form.values.offsetMs,
|
||||||
|
form.values.synced,
|
||||||
|
lyrics.artist,
|
||||||
|
lyrics.lyrics,
|
||||||
|
lyrics.name,
|
||||||
|
lyrics.offsetMs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exportLyrics = useCallback(() => {
|
||||||
|
const extension = form.values.synced ? '.lrc' : '.txt';
|
||||||
|
const lyricFile = new File([displayedLyrics], lyrics.name + extension, {
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lyricsFileLink = document.createElement('a');
|
||||||
|
const lyricsFileUrl = URL.createObjectURL(lyricFile);
|
||||||
|
lyricsFileLink.href = lyricsFileUrl;
|
||||||
|
lyricsFileLink.download = lyricFile.name;
|
||||||
|
lyricsFileLink.click();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(lyricsFileUrl);
|
||||||
|
|
||||||
|
closeAllModals();
|
||||||
|
}, [displayedLyrics, form.values.synced, lyrics.name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack h="100%" w="100%">
|
||||||
|
{synced && (
|
||||||
|
<form>
|
||||||
|
<Group grow>
|
||||||
|
<Checkbox
|
||||||
|
data-autofocus
|
||||||
|
label={t('form.lyricsExport.input', {
|
||||||
|
context: 'synced',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('synced', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
data-autofocus
|
||||||
|
label={t('form.lyricsExport.input', {
|
||||||
|
context: 'offset',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('offsetMs')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<Code block>{displayedLyrics}</Code>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button onClick={() => closeAllModals()} variant="default">
|
||||||
|
{t('common.close', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={exportLyrics} variant="filled">
|
||||||
|
{t('form.lyricsExport.export', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openLyricsExportModal = ({ lyrics, offsetMs, synced }: LyricsExportFormProps) => {
|
||||||
|
openModal({
|
||||||
|
children: <LyricsExportForm lyrics={lyrics} offsetMs={offsetMs} synced={synced} />,
|
||||||
|
size: 'xl',
|
||||||
|
styles: {
|
||||||
|
body: {
|
||||||
|
height: '600px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import styles from './lyrics-search-form.module.css';
|
|||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';
|
import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';
|
||||||
|
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
|
||||||
import {
|
import {
|
||||||
SynchronizedLyrics,
|
SynchronizedLyrics,
|
||||||
SynchronizedLyricsProps,
|
SynchronizedLyricsProps,
|
||||||
@@ -30,6 +31,7 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||||
import { useForm } from '/@/shared/hooks/use-form';
|
import { useForm } from '/@/shared/hooks/use-form';
|
||||||
import {
|
import {
|
||||||
|
FullLyricsMetadata,
|
||||||
InternetProviderLyricSearchResponse,
|
InternetProviderLyricSearchResponse,
|
||||||
LyricSource,
|
LyricSource,
|
||||||
LyricsOverride,
|
LyricsOverride,
|
||||||
@@ -144,6 +146,21 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Stack h="100%" w="100%">
|
<Stack h="100%" w="100%">
|
||||||
<form>
|
<form>
|
||||||
@@ -237,6 +254,13 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
|
|||||||
<Button onClick={() => closeAllModals()} variant="default">
|
<Button onClick={() => closeAllModals()} variant="default">
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedResult || !previewData}
|
||||||
|
onClick={handleExport}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{t('form.lyricsExport.export', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
<Button disabled={!selectedResult} onClick={handleApply} variant="filled">
|
<Button disabled={!selectedResult} onClick={handleApply} variant="filled">
|
||||||
{t('common.confirm', { postProcess: 'titleCase' })}
|
{t('common.confirm', { postProcess: 'titleCase' })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface LyricsActionsProps {
|
|||||||
index: number;
|
index: number;
|
||||||
languages: { label: string; value: string }[];
|
languages: { label: string; value: string }[];
|
||||||
offsetMs: number;
|
offsetMs: number;
|
||||||
|
onExportLyrics: () => void;
|
||||||
onRemoveLyric: () => void;
|
onRemoveLyric: () => void;
|
||||||
onSearchOverride: (params: LyricsOverride) => void;
|
onSearchOverride: (params: LyricsOverride) => void;
|
||||||
onTranslateLyric?: () => void;
|
onTranslateLyric?: () => void;
|
||||||
@@ -28,6 +29,7 @@ export const LyricsActions = ({
|
|||||||
index,
|
index,
|
||||||
languages,
|
languages,
|
||||||
offsetMs,
|
offsetMs,
|
||||||
|
onExportLyrics,
|
||||||
onRemoveLyric,
|
onRemoveLyric,
|
||||||
onSearchOverride,
|
onSearchOverride,
|
||||||
onTranslateLyric,
|
onTranslateLyric,
|
||||||
@@ -46,92 +48,99 @@ export const LyricsActions = ({
|
|||||||
const isDesktop = isElectron();
|
const isDesktop = isElectron();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%' }}>
|
<>
|
||||||
{languages.length > 1 && (
|
<div style={{ position: 'relative', width: '100%' }}>
|
||||||
<Center>
|
{languages.length > 0 && (
|
||||||
<Select
|
<Center pb="md">
|
||||||
clearable={false}
|
{languages.length > 1 && (
|
||||||
data={languages}
|
<Select
|
||||||
onChange={(value) => setIndex(parseInt(value!, 10))}
|
clearable={false}
|
||||||
style={{ bottom: 30, position: 'absolute' }}
|
data={languages}
|
||||||
value={index.toString()}
|
onChange={(value) => setIndex(parseInt(value!, 10))}
|
||||||
/>
|
style={{ bottom: 30, position: 'absolute' }}
|
||||||
</Center>
|
value={index.toString()}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
<Button onClick={onExportLyrics} uppercase variant="subtle">
|
||||||
|
{t('form.lyricsExport.export', { postProcess: 'sentenceCase ' })}
|
||||||
|
</Button>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
{isDesktop && sources.length ? (
|
{isDesktop && sources.length ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={isActionsDisabled}
|
disabled={isActionsDisabled}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openLyricSearchModal({
|
openLyricSearchModal({
|
||||||
artist: currentSong?.artistName,
|
artist: currentSong?.artistName,
|
||||||
name: currentSong?.name,
|
name: currentSong?.name,
|
||||||
onSearchOverride,
|
onSearchOverride,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
uppercase
|
uppercase
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{t('common.search', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<ActionIcon
|
||||||
|
aria-label="Decrease lyric offset"
|
||||||
|
icon="minus"
|
||||||
|
onClick={() => handleLyricOffset(offsetMs - 50)}
|
||||||
|
tooltip={{
|
||||||
|
label: t('common.slower', { postProcess: 'sentenceCase' }),
|
||||||
|
openDelay: 0,
|
||||||
|
}}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
>
|
|
||||||
{t('common.search', { postProcess: 'titleCase' })}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<ActionIcon
|
|
||||||
aria-label="Decrease lyric offset"
|
|
||||||
icon="minus"
|
|
||||||
onClick={() => handleLyricOffset(offsetMs - 50)}
|
|
||||||
tooltip={{
|
|
||||||
label: t('common.slower', { postProcess: 'sentenceCase' }),
|
|
||||||
openDelay: 0,
|
|
||||||
}}
|
|
||||||
variant="subtle"
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })}
|
|
||||||
openDelay={0}
|
|
||||||
>
|
|
||||||
<NumberInput
|
|
||||||
aria-label="Lyric offset"
|
|
||||||
onChange={handleLyricOffset}
|
|
||||||
styles={{ input: { textAlign: 'center' } }}
|
|
||||||
value={offsetMs || 0}
|
|
||||||
width={70}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
<Tooltip
|
||||||
<ActionIcon
|
label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })}
|
||||||
aria-label="Increase lyric offset"
|
openDelay={0}
|
||||||
icon="plus"
|
|
||||||
onClick={() => handleLyricOffset(offsetMs + 50)}
|
|
||||||
tooltip={{
|
|
||||||
label: t('common.faster', { postProcess: 'sentenceCase' }),
|
|
||||||
openDelay: 0,
|
|
||||||
}}
|
|
||||||
variant="subtle"
|
|
||||||
/>
|
|
||||||
{isDesktop && sources.length ? (
|
|
||||||
<Button
|
|
||||||
disabled={isActionsDisabled}
|
|
||||||
onClick={onRemoveLyric}
|
|
||||||
uppercase
|
|
||||||
variant="subtle"
|
|
||||||
>
|
>
|
||||||
{t('common.clear', { postProcess: 'sentenceCase' })}
|
<NumberInput
|
||||||
</Button>
|
aria-label="Lyric offset"
|
||||||
) : null}
|
onChange={handleLyricOffset}
|
||||||
</Group>
|
styles={{ input: { textAlign: 'center' } }}
|
||||||
|
value={offsetMs || 0}
|
||||||
|
width={70}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<ActionIcon
|
||||||
|
aria-label="Increase lyric offset"
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => handleLyricOffset(offsetMs + 50)}
|
||||||
|
tooltip={{
|
||||||
|
label: t('common.faster', { postProcess: 'sentenceCase' }),
|
||||||
|
openDelay: 0,
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
{isDesktop && sources.length ? (
|
||||||
|
<Button
|
||||||
|
disabled={isActionsDisabled}
|
||||||
|
onClick={onRemoveLyric}
|
||||||
|
uppercase
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{t('common.clear', { postProcess: 'sentenceCase' })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div style={{ position: 'absolute', right: 0, top: -50 }}>
|
<div style={{ position: 'absolute', right: 0, top: -50 }}>
|
||||||
{isDesktop && sources.length && onTranslateLyric ? (
|
{isDesktop && sources.length && onTranslateLyric ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={isActionsDisabled}
|
disabled={isActionsDisabled}
|
||||||
onClick={onTranslateLyric}
|
onClick={onTranslateLyric}
|
||||||
uppercase
|
uppercase
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
>
|
>
|
||||||
{t('common.translation', { postProcess: 'sentenceCase' })}
|
{t('common.translation', { postProcess: 'sentenceCase' })}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import styles from './lyrics.module.css';
|
|||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { translateLyrics } from '/@/renderer/features/lyrics/api/lyric-translate';
|
import { translateLyrics } from '/@/renderer/features/lyrics/api/lyric-translate';
|
||||||
import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';
|
import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';
|
||||||
|
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
|
||||||
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
|
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
|
||||||
import {
|
import {
|
||||||
SynchronizedLyrics,
|
SynchronizedLyrics,
|
||||||
@@ -258,6 +259,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true }: LyricsProps) => {
|
|||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
|
return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
|
||||||
|
} else if (data?.lyrics) {
|
||||||
|
// xxx denotes undefined lyrics language. If it's a single lyric (from a remote source)
|
||||||
|
// the language is most likely not available, so leave it undefined
|
||||||
|
return [{ label: 'xxx', value: '0' }];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -290,6 +295,12 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true }: LyricsProps) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]);
|
}, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]);
|
||||||
|
|
||||||
|
const handleExportLyrics = useCallback(() => {
|
||||||
|
if (lyrics) {
|
||||||
|
openLyricsExportModal({ lyrics, offsetMs: currentOffsetMs, synced });
|
||||||
|
}
|
||||||
|
}, [currentOffsetMs, lyrics, synced]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComponentErrorBoundary>
|
<ComponentErrorBoundary>
|
||||||
<div className={styles.lyricsContainer}>
|
<div className={styles.lyricsContainer}>
|
||||||
@@ -341,6 +352,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true }: LyricsProps) => {
|
|||||||
index={index}
|
index={index}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
offsetMs={currentOffsetMs}
|
offsetMs={currentOffsetMs}
|
||||||
|
onExportLyrics={handleExportLyrics}
|
||||||
onRemoveLyric={handleOnRemoveLyric}
|
onRemoveLyric={handleOnRemoveLyric}
|
||||||
onSearchOverride={handleOnSearchOverride}
|
onSearchOverride={handleOnSearchOverride}
|
||||||
onTranslateLyric={
|
onTranslateLyric={
|
||||||
|
|||||||
Reference in New Issue
Block a user