From 04fbf5d3d2f6f749813aab1231fd1326e4d8b4ec Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 24 Dec 2025 05:27:47 +0000 Subject: [PATCH] Export lyrics (#1383) * add export button to the lyrics actions * add export button to the lyrics search modal --------- Co-authored-by: jeffvli --- src/i18n/locales/en.json | 5 + .../lyrics/components/lyrics-export-form.tsx | 128 +++++++++++++ .../lyrics/components/lyrics-search-form.tsx | 24 +++ .../features/lyrics/lyrics-actions.tsx | 171 +++++++++--------- src/renderer/features/lyrics/lyrics.tsx | 12 ++ 5 files changed, 259 insertions(+), 81 deletions(-) create mode 100644 src/renderer/features/lyrics/components/lyrics-export-form.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d31b1760c..8d4ac0f52 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -355,6 +355,11 @@ "success": "$t(entity.playlist_one) updated successfully", "title": "edit $t(entity.playlist_one)" }, + "lyricsExport": { + "export": "export lyrics", + "input_synced": "export synced lyrics", + "input_offset": "$t(setting.lyricOffset)" + }, "lyricSearch": { "input_artist": "$t(entity.artist_one)", "input_name": "$t(common.name)", diff --git a/src/renderer/features/lyrics/components/lyrics-export-form.tsx b/src/renderer/features/lyrics/components/lyrics-export-form.tsx new file mode 100644 index 000000000..b99f77405 --- /dev/null +++ b/src/renderer/features/lyrics/components/lyrics-export-form.tsx @@ -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 ( + + {synced && ( +
+ + + + +
+ )} + {displayedLyrics} + + + + + +
+ ); +}; + +export const openLyricsExportModal = ({ lyrics, offsetMs, synced }: LyricsExportFormProps) => { + openModal({ + children: , + size: 'xl', + styles: { + body: { + height: '600px', + }, + }, + title: i18n.t('form.lyricSearch.title', { postProcess: 'titleCase' }) as string, + }); +}; diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.tsx b/src/renderer/features/lyrics/components/lyrics-search-form.tsx index b90d0ca3a..1475473ef 100644 --- a/src/renderer/features/lyrics/components/lyrics-search-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-search-form.tsx @@ -9,6 +9,7 @@ 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, @@ -30,6 +31,7 @@ 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, @@ -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 (
@@ -237,6 +254,13 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch + diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index 2fbb53081..52222ef46 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -16,6 +16,7 @@ interface LyricsActionsProps { index: number; languages: { label: string; value: string }[]; offsetMs: number; + onExportLyrics: () => void; onRemoveLyric: () => void; onSearchOverride: (params: LyricsOverride) => void; onTranslateLyric?: () => void; @@ -28,6 +29,7 @@ export const LyricsActions = ({ index, languages, offsetMs, + onExportLyrics, onRemoveLyric, onSearchOverride, onTranslateLyric, @@ -46,92 +48,99 @@ export const LyricsActions = ({ const isDesktop = isElectron(); return ( -
- {languages.length > 1 && ( -
- setIndex(parseInt(value!, 10))} + style={{ bottom: 30, position: 'absolute' }} + value={index.toString()} + /> + )} + +
+ )} - - {isDesktop && sources.length ? ( - + ) : null} + handleLyricOffset(offsetMs - 50)} + tooltip={{ + label: t('common.slower', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} variant="subtle" - > - {t('common.search', { postProcess: 'titleCase' })} - - ) : null} - handleLyricOffset(offsetMs - 50)} - tooltip={{ - label: t('common.slower', { postProcess: 'sentenceCase' }), - openDelay: 0, - }} - variant="subtle" - /> - - - - handleLyricOffset(offsetMs + 50)} - tooltip={{ - label: t('common.faster', { postProcess: 'sentenceCase' }), - openDelay: 0, - }} - variant="subtle" - /> - {isDesktop && sources.length ? ( - - ) : null} - + + + handleLyricOffset(offsetMs + 50)} + tooltip={{ + label: t('common.faster', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="subtle" + /> + {isDesktop && sources.length ? ( + + ) : null} + -
- {isDesktop && sources.length && onTranslateLyric ? ( - - ) : null} +
+ {isDesktop && sources.length && onTranslateLyric ? ( + + ) : null} +
-
+ ); }; diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index 3aeabe5c5..84f61d96e 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -8,6 +8,7 @@ import styles from './lyrics.module.css'; import { queryKeys } from '/@/renderer/api/query-keys'; import { translateLyrics } from '/@/renderer/features/lyrics/api/lyric-translate'; 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 { SynchronizedLyrics, @@ -258,6 +259,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true }: LyricsProps) => { const languages = useMemo(() => { if (Array.isArray(data)) { 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 []; }, [data]); @@ -290,6 +295,12 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true }: LyricsProps) => { return undefined; }, [isLoadingLyrics, hasNoLyrics, fadeOutNoLyricsMessage]); + const handleExportLyrics = useCallback(() => { + if (lyrics) { + openLyricsExportModal({ lyrics, offsetMs: currentOffsetMs, synced }); + } + }, [currentOffsetMs, lyrics, synced]); + return (
@@ -341,6 +352,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true }: LyricsProps) => { index={index} languages={languages} offsetMs={currentOffsetMs} + onExportLyrics={handleExportLyrics} onRemoveLyric={handleOnRemoveLyric} onSearchOverride={handleOnSearchOverride} onTranslateLyric={