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:
Kendall Garner
2025-12-24 05:27:47 +00:00
committed by GitHub
parent 936ba73fe4
commit 04fbf5d3d2
5 changed files with 259 additions and 81 deletions
+5
View File
@@ -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,9 +48,11 @@ export const LyricsActions = ({
const isDesktop = isElectron(); const isDesktop = isElectron();
return ( return (
<>
<div style={{ position: 'relative', width: '100%' }}> <div style={{ position: 'relative', width: '100%' }}>
{languages.length > 0 && (
<Center pb="md">
{languages.length > 1 && ( {languages.length > 1 && (
<Center>
<Select <Select
clearable={false} clearable={false}
data={languages} data={languages}
@@ -56,6 +60,10 @@ export const LyricsActions = ({
style={{ bottom: 30, position: 'absolute' }} style={{ bottom: 30, position: 'absolute' }}
value={index.toString()} value={index.toString()}
/> />
)}
<Button onClick={onExportLyrics} uppercase variant="subtle">
{t('form.lyricsExport.export', { postProcess: 'sentenceCase ' })}
</Button>
</Center> </Center>
)} )}
@@ -133,5 +141,6 @@ export const LyricsActions = ({
) : null} ) : null}
</div> </div>
</div> </div>
</>
); );
}; };
+12
View File
@@ -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={