mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat: v1.34.1 — fullscreen lyrics overlay, FsArt crossfade, lyrics toggle, contributor updates
- FullscreenPlayer: synced lyrics overlay (5-line rail, mask fade, click-to-seek) - FullscreenPlayer: FsArt crossfade component with onLoad pattern, 300px art, next-track pre-fetch - FullscreenPlayer: MicVocal lyrics toggle button next to heart (persisted in authStore) - FullscreenPlayer: idle system — only close button auto-hides, cluster+seekbar always visible - LyricsPane: refactored to use shared useLyrics hook (no double-fetch with FS overlay) - authStore: showFullscreenLyrics field (default true) - i18n: fsLyricsToggle key added to all 7 locales; ru2.ts removed (merged into ru.ts) - Settings: Contributors updated (nisrael, cucadmuh, kilyabin); logout moved to Server tab as btn-danger - theme.css: btn-danger style added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
export type LyricsSource = 'server' | 'lrclib';
|
||||
|
||||
export interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
plainLyrics: string | null;
|
||||
source: LyricsSource | null;
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
// Session-level cache — survives tab switches and component unmount/remount.
|
||||
export const lyricsCache = new Map<string, CachedLyrics>();
|
||||
|
||||
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
|
||||
export function parseStructuredLyrics(
|
||||
lyrics: SubsonicStructuredLyrics,
|
||||
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
|
||||
if (lyrics.issynced && lyrics.line.length > 0) {
|
||||
const lines: LrcLine[] = lyrics.line
|
||||
.filter(l => l.start !== undefined)
|
||||
.map(l => ({ time: l.start! / 1000, text: l.value.trim() }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
if (lines.length > 0) return { syncedLines: lines, plainLyrics: null };
|
||||
}
|
||||
const plain = lyrics.line.map(l => l.value).join('\n').trim();
|
||||
return { syncedLines: null, plainLyrics: plain || null };
|
||||
}
|
||||
|
||||
export interface UseLyricsResult {
|
||||
syncedLines: LrcLine[] | null;
|
||||
plainLyrics: string | null;
|
||||
source: LyricsSource | null;
|
||||
loading: boolean;
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
||||
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
|
||||
const [source, setSource] = useState<LyricsSource | null>(cached?.source ?? null);
|
||||
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
|
||||
const hit = lyricsCache.get(currentTrack.id);
|
||||
if (hit) {
|
||||
setSyncedLines(hit.syncedLines);
|
||||
setPlainLyrics(hit.plainLyrics);
|
||||
setSource(hit.source);
|
||||
setNotFound(hit.notFound);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSyncedLines(null);
|
||||
setPlainLyrics(null);
|
||||
setSource(null);
|
||||
setNotFound(false);
|
||||
setLoading(true);
|
||||
|
||||
const store = (entry: CachedLyrics) => {
|
||||
if (cancelled) return;
|
||||
lyricsCache.set(currentTrack.id, entry);
|
||||
setSyncedLines(entry.syncedLines);
|
||||
setPlainLyrics(entry.plainLyrics);
|
||||
setSource(entry.source);
|
||||
setNotFound(entry.notFound);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchServer = async (): Promise<boolean> => {
|
||||
const structured = await getLyricsBySongId(currentTrack.id);
|
||||
if (!structured) return false;
|
||||
const parsed = parseStructuredLyrics(structured);
|
||||
if (!parsed.syncedLines && !parsed.plainLyrics) return false;
|
||||
store({ ...parsed, source: 'server', notFound: false });
|
||||
return true;
|
||||
};
|
||||
|
||||
const fetchLrclibFn = async (): Promise<boolean> => {
|
||||
try {
|
||||
const result = await fetchLyrics(
|
||||
currentTrack.artist ?? '',
|
||||
currentTrack.title,
|
||||
currentTrack.album ?? '',
|
||||
currentTrack.duration ?? 0,
|
||||
);
|
||||
if (!result || (!result.syncedLyrics && !result.plainLyrics)) return false;
|
||||
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
|
||||
const synced = lines && lines.length > 0 ? lines : null;
|
||||
store({ syncedLines: synced, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const [first, second] = lyricsServerFirst
|
||||
? [fetchServer, fetchLrclibFn]
|
||||
: [fetchLrclibFn, fetchServer];
|
||||
|
||||
if (cancelled) return;
|
||||
if (await first()) return;
|
||||
if (cancelled) return;
|
||||
if (await second()) return;
|
||||
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { syncedLines, plainLyrics, source, loading, notFound };
|
||||
}
|
||||
Reference in New Issue
Block a user