Ctrl K
ComunidadGuías…

src/renderer/features/lyrics/lyrics.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useMemo, useState } from 'react';

import styles from './lyrics.module.css';

import { translateLyrics } from '/@/renderer/features/lyrics/api/lyric-translate';
import { clearLyricsCacheForSong, lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';
import { LyricsContextMenu } from '/@/renderer/features/lyrics/lyrics-context-menu';
import { SynchronizedLyrics } from '/@/renderer/features/lyrics/synchronized-lyrics';
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open-lyrics-settings-modal';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
import { useLyricsSettings, useOfflineMode, usePlayerSong } from '/@/renderer/store';
import {
    lyricsKey,
    useLyricsOverrideEntry,
    useLyricsOverridesActions,
} from '/@/renderer/store/lyrics-overrides.store';
import { usePlaybackSource } from '/@/renderer/store/playback-owner.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Spinner } from '/@/shared/components/spinner/spinner';
import {
    FullLyricsMetadata,
    LyricSource,
    LyricsOverride,
    StructuredLyric,
    SynchronizedLyricsArray,
} from '/@/shared/types/domain-types';

type LyricsProps = {
    settingsKey?: string;
};

type ResolvedLyrics = {
    isOverride: boolean;
    languageIndex: number;
    languages: null | { label: string; value: string }[];
    lines: null | SynchronizedLyricsArray;
    metadata: FullLyricsMetadata | null;
    offsetMs: number;
    plain: null | string;
};

const empty: ResolvedLyrics = {
    isOverride: false,
    languageIndex: 0,
    languages: null,
    lines: null,
    metadata: null,
    offsetMs: 0,
    plain: null,
};

export const Lyrics = ({ settingsKey = 'default' }: LyricsProps) => {
    const currentSong = usePlayerSong();
    const isRadioActive = useIsRadioActive();
    const playbackSource = usePlaybackSource();
    const offlineMode = useOfflineMode();
    const settings = useLyricsSettings();

    const isLyricsDisabled =
        isRadioActive || playbackSource === 'audiobook' || playbackSource === 'podcast';

    const songKey = lyricsKey(currentSong?._serverId, currentSong?.id);
    const overrideEntry = useLyricsOverrideEntry(songKey);
    const overrideActions = useLyricsOverridesActions();

    const [translatedLyrics, setTranslatedLyrics] = useState<null | string>(null);
    const [showTranslation, setShowTranslation] = useState(false);

    const { data, isLoading } = useQuery(
        lyricsQueries.songLyrics(
            {
                options: {
                    enabled: !!currentSong?.id && !isLyricsDisabled && !overrideEntry?.suppressed,
                },
                query: { songId: currentSong?.id || '' },
                serverId: currentSong?._serverId || '',
            },
            currentSong,
        ),
    );

    const overrideRef = overrideEntry?.override;
    const { data: overrideData, isLoading: overrideLoading } = useQuery(
        lyricsQueries.songLyricsByRemoteId({
            options: {
                enabled: !!overrideRef && !isLyricsDisabled && !!currentSong?.id,
            },
            query: {
                remoteSongId: overrideRef?.id,
                remoteSource: overrideRef?.source as LyricSource | undefined,
                song: currentSong,
            },
            serverId: currentSong?._serverId || '',
        }),
    );

    const resolved = useMemo<ResolvedLyrics>(() => {
        if (isLyricsDisabled || overrideEntry?.suppressed) return empty;

        const fallbackOffset = overrideEntry?.offsetMs ?? settings.delayMs ?? 0;

        // 1. User-picked override lyrics
        if (overrideRef && overrideData) {
            const meta: FullLyricsMetadata = {
                artist: overrideRef.artist,
                lyrics: overrideData,
                name: overrideRef.name,
                offsetMs: 0,
                remote: true,
                source: overrideRef.source,
            };
            return {
                isOverride: true,
                languageIndex: 0,
                languages: null,
                lines: Array.isArray(overrideData) ? overrideData : null,
                metadata: meta,
                offsetMs: fallbackOffset,
                plain: typeof overrideData === 'string' ? overrideData : null,
            };
        }

        if (!data) return empty;

        const pickStructured = (local: StructuredLyric[]): ResolvedLyrics => {
            const idx = Math.min(
                Math.max(0, overrideEntry?.structuredIndex ?? 0),
                local.length - 1,
            );
            const item = local[idx];
            return {
                isOverride: false,
                languageIndex: idx,
                languages: local.map((l, i) => ({
                    label: l.lang || `Language ${i + 1}`,
                    value: i.toString(),
                })),
                lines: item.synced ? item.lyrics : null,
                metadata: item,
                offsetMs: overrideEntry?.offsetMs ?? item.offsetMs ?? settings.delayMs ?? 0,
                plain: item.synced ? null : item.lyrics,
            };
        };

        const pickSingleLocal = (local: FullLyricsMetadata): ResolvedLyrics => ({
            isOverride: false,
            languageIndex: 0,
            languages: null,
            lines: Array.isArray(local.lyrics) ? local.lyrics : null,
            metadata: local,
            offsetMs: overrideEntry?.offsetMs ?? local.offsetMs ?? settings.delayMs ?? 0,
            plain: typeof local.lyrics === 'string' ? local.lyrics : null,
        });

        const pickRemote = (remote: FullLyricsMetadata): ResolvedLyrics => ({
            isOverride: false,
            languageIndex: 0,
            languages: null,
            lines: Array.isArray(remote.lyrics) ? remote.lyrics : null,
            metadata: remote,
            offsetMs: overrideEntry?.offsetMs ?? remote.offsetMs ?? settings.delayMs ?? 0,
            plain: typeof remote.lyrics === 'string' ? remote.lyrics : null,
        });

        const localStructured =
            Array.isArray(data.local) && data.local.length > 0 ? data.local : null;
        const localSingle =
            data.local && !Array.isArray(data.local) && 'lyrics' in data.local ? data.local : null;
        const remote = data.remoteAuto;

        if (settings.preferLocalLyrics) {
            if (localStructured) return pickStructured(localStructured);
            if (localSingle) return pickSingleLocal(localSingle);
            if (remote) return pickRemote(remote);
        } else {
            if (remote) return pickRemote(remote);
            if (localStructured) return pickStructured(localStructured);
            if (localSingle) return pickSingleLocal(localSingle);
        }

        return empty;
    }, [
        data,
        isLyricsDisabled,
        overrideData,
        overrideEntry?.offsetMs,
        overrideEntry?.structuredIndex,
        overrideEntry?.suppressed,
        overrideRef,
        settings.delayMs,
        settings.preferLocalLyrics,
    ]);

    // Reset translation cache when the song changes
    usePlayerEvents(
        {
            onCurrentSongChange: () => {
                setShowTranslation(false);
                setTranslatedLyrics(null);
            },
        },
        [],
    );

    const fetchTranslation = useCallback(async () => {
        if (!resolved.metadata || !resolved.lines || isLyricsDisabled || offlineMode) return;
        const original = resolved.lines.map(([, line]) => line).join('\n');
        const translated = await translateLyrics(
            original,
            settings.translationApiKey,
            settings.translationApiProvider,
            settings.translationTargetLanguage,
        );
        setTranslatedLyrics(translated);
        setShowTranslation(true);
    }, [
        isLyricsDisabled,
        offlineMode,
        resolved.lines,
        resolved.metadata,
        settings.translationApiKey,
        settings.translationApiProvider,
        settings.translationTargetLanguage,
    ]);

    useEffect(() => {
        if (resolved.lines && !translatedLyrics && settings.enableAutoTranslation) {
            fetchTranslation();
        }
    }, [resolved.lines, translatedLyrics, settings.enableAutoTranslation, fetchTranslation]);

    const handleAdjustOffset = useCallback(
        (deltaMs: number) => {
            if (!songKey) return;
            const next = resolved.offsetMs + deltaMs;
            overrideActions.setOffset(songKey, next);
        },
        [overrideActions, resolved.offsetMs, songKey],
    );

    const handleResetOffset = useCallback(() => {
        if (!songKey) return;
        overrideActions.clearOffset(songKey);
    }, [overrideActions, songKey]);

    const handleSearchOverride = useCallback(() => {
        if (!currentSong) return;
        openLyricSearchModal({
            artist: currentSong.artistName,
            name: currentSong.name,
            onSearchOverride: (params: LyricsOverride) => {
                if (!songKey) return;
                overrideActions.setOverride(songKey, params);
            },
        });
    }, [currentSong, overrideActions, songKey]);

    const handleClearOverride = useCallback(() => {
        if (!songKey) return;
        overrideActions.clearOverride(songKey);
    }, [overrideActions, songKey]);

    const handleSuppress = useCallback(async () => {
        if (!songKey || !currentSong) return;
        overrideActions.suppress(songKey);
        await clearLyricsCacheForSong(currentSong);
    }, [currentSong, overrideActions, songKey]);

    const handlePickLanguage = useCallback(
        (idx: number) => {
            if (!songKey) return;
            overrideActions.setStructuredIndex(songKey, idx);
        },
        [overrideActions, songKey],
    );

    const handleToggleTranslation = useCallback(async () => {
        if (translatedLyrics) {
            setShowTranslation((v) => !v);
            return;
        }
        await fetchTranslation();
    }, [translatedLyrics, fetchTranslation]);

    const handleExport = useCallback(() => {
        if (!resolved.metadata) return;
        openLyricsExportModal({
            lyrics: resolved.metadata,
            offsetMs: resolved.offsetMs,
            synced: !!resolved.lines,
        });
    }, [resolved]);

    const handleOpenSettings = () => openLyricsSettingsModal(settingsKey);

    const isWaitingForOverride = !!overrideRef && overrideLoading;
    const isStillLoading = (isLoading || isWaitingForOverride) && !isLyricsDisabled;
    const hasContent = !!resolved.lines || !!resolved.plain;

    return (
        <ComponentErrorBoundary>
            <div className={styles.lyricsContainer}>
                <ActionIcon
                    className={styles.settingsIcon}
                    icon="settings2"
                    iconProps={{ size: 'lg' }}
                    onClick={handleOpenSettings}
                    pos="absolute"
                    right={0}
                    top={0}
                    variant="subtle"
                />
                {isStillLoading ? (
                    <Spinner container />
                ) : (
                    <AnimatePresence mode="sync">
                        {hasContent && resolved.metadata && (
                            <motion.div
                                animate={{ opacity: 1 }}
                                className={styles.scrollContainer}
                                initial={{ opacity: 0 }}
                                key={`${currentSong?.id ?? 'none'}:${resolved.languageIndex}:${resolved.isOverride ? 'override' : 'auto'}`}
                                transition={{ duration: 0.4 }}
                            >
                                <LyricsContextMenu
                                    canExport={hasContent}
                                    canSearch={!offlineMode}
                                    canTranslate={
                                        !!settings.translationApiKey &&
                                        !!settings.translationApiProvider &&
                                        !!resolved.lines &&
                                        !offlineMode
                                    }
                                    hasOffset={resolved.offsetMs !== 0}
                                    hasOverride={resolved.isOverride}
                                    isShowingTranslation={showTranslation && !!translatedLyrics}
                                    languages={resolved.languages}
                                    onAdjustOffset={handleAdjustOffset}
                                    onClearOverride={handleClearOverride}
                                    onExport={handleExport}
                                    onPickLanguage={handlePickLanguage}
                                    onResetOffset={handleResetOffset}
                                    onSearchOverride={handleSearchOverride}
                                    onSuppress={handleSuppress}
                                    onToggleTranslation={handleToggleTranslation}
                                    selectedLanguage={resolved.languageIndex}
                                >
                                    {resolved.lines ? (
                                        <SynchronizedLyrics
                                            artist={resolved.metadata.artist}
                                            lyrics={resolved.lines}
                                            name={resolved.metadata.name}
                                            offsetMs={resolved.offsetMs}
                                            remote={resolved.metadata.remote}
                                            settingsKey={settingsKey}
                                            source={resolved.metadata.source}
                                            translatedLyrics={
                                                showTranslation ? translatedLyrics : null
                                            }
                                        />
                                    ) : (
                                        <UnsynchronizedLyrics
                                            artist={resolved.metadata.artist}
                                            lyrics={resolved.plain ?? ''}
                                            name={resolved.metadata.name}
                                            remote={resolved.metadata.remote}
                                            settingsKey={settingsKey}
                                            source={resolved.metadata.source}
                                            translatedLyrics={
                                                showTranslation ? translatedLyrics : null
                                            }
                                        />
                                    )}
                                </LyricsContextMenu>
                            </motion.div>
                        )}
                    </AnimatePresence>
                )}
            </div>
        </ComponentErrorBoundary>
    );
};