mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
7263d93d42
Incorporates PRs #38 (nisarg-78), #42 #43 #44 (JulianNymark) with fixes: Audio / Playback - Artist Radio starts immediately from fast getTopSongs (local library); getSimilarSongs2 (Last.fm) enriches queue in background — no more wait - Fix seek audio glitch: EqualPowerFadeIn only resets to zero-gain on seeks to track start (<100 ms); all other seeks resume at unity gain - OGG/Vorbis container support via symphonia-format-ogg (PR #42) - Human-readable audio error messages in SizedDecoder (PR #44) - Audio playback errors shown as themed toast notifications (PR #43) Queue / Radio - Infinite Queue: proactive load of 5 tracks (was 25) when ≤2 remain - Radio: proactive reload at ≤2 remaining tracks, independent of Infinite Queue setting — radio no longer stops at last track - Fix: clicking Start Radio multiple times no longer stacks duplicates - Fix: Start Radio on artist keeps current song playing - Manual tracks always appear before Radio, Radio before Auto-added - Queue separators: "— Radio —" and "— Auto —" dividers - Fix: radio proactive load now works even when songs lack artistId (uses currentRadioArtistId module var as fallback) UI / UX - Click synced lyrics lines to seek (PR #38) - Volume scroll wheel on volume slider (PR #38) - Lyrics: active / completed / upcoming visual states (PR #38) - Shared toast utility (src/utils/toast.ts) replaces inline toast fn - Auto-updater: relaunch_after_update Rust command exits first to release single-instance lock before spawning new process About / Credits - nisarg-78 and JulianNymark added to contributors (since v1.29.0) - netherguy4 added as Special Thanks for feature ideas and feedback - i18n: aboutSpecialThanksLabel in all 5 languages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { Track } from '../store/playerStore';
|
|
|
|
interface Props {
|
|
currentTrack: Track | null;
|
|
}
|
|
|
|
interface CachedLyrics {
|
|
syncedLines: LrcLine[] | null;
|
|
plainLyrics: string | null;
|
|
notFound: boolean;
|
|
}
|
|
|
|
// Session-level cache — survives tab switches (component unmount/remount).
|
|
// Cleared implicitly when the app restarts.
|
|
const lyricsCache = new Map<string, CachedLyrics>();
|
|
|
|
export default function LyricsPane({ currentTrack }: Props) {
|
|
const { t } = useTranslation();
|
|
|
|
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
|
|
|
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 [notFound, setNotFound] = useState(cached?.notFound ?? false);
|
|
|
|
const hasSynced = syncedLines !== null && syncedLines.length > 0;
|
|
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
|
|
const seek = usePlayerStore(s => s.seek);
|
|
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
|
|
|
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const prevActive = useRef(-1);
|
|
|
|
useEffect(() => {
|
|
if (!currentTrack) return;
|
|
|
|
// Serve from cache if available
|
|
const hit = lyricsCache.get(currentTrack.id);
|
|
if (hit) {
|
|
setSyncedLines(hit.syncedLines);
|
|
setPlainLyrics(hit.plainLyrics);
|
|
setNotFound(hit.notFound);
|
|
setLoading(false);
|
|
lineRefs.current = [];
|
|
prevActive.current = -1;
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setSyncedLines(null);
|
|
setPlainLyrics(null);
|
|
setNotFound(false);
|
|
setLoading(true);
|
|
lineRefs.current = [];
|
|
prevActive.current = -1;
|
|
|
|
fetchLyrics(
|
|
currentTrack.artist ?? '',
|
|
currentTrack.title,
|
|
currentTrack.album ?? '',
|
|
currentTrack.duration ?? 0,
|
|
).then(result => {
|
|
if (cancelled) return;
|
|
setLoading(false);
|
|
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
|
|
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
|
|
setNotFound(true);
|
|
return;
|
|
}
|
|
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
|
|
const synced = lines && lines.length > 0 ? lines : null;
|
|
lyricsCache.set(currentTrack.id, { syncedLines: synced, plainLyrics: result.plainLyrics, notFound: false });
|
|
setSyncedLines(synced);
|
|
setPlainLyrics(result.plainLyrics);
|
|
}).catch(() => {
|
|
if (!cancelled) {
|
|
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
|
|
setLoading(false);
|
|
setNotFound(true);
|
|
}
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const activeIdx = hasSynced
|
|
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
|
|
: -1;
|
|
|
|
useEffect(() => {
|
|
if (activeIdx < 0 || activeIdx === prevActive.current) return;
|
|
prevActive.current = activeIdx;
|
|
lineRefs.current[activeIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}, [activeIdx]);
|
|
|
|
if (!currentTrack) {
|
|
return (
|
|
<div className="lyrics-pane-empty">
|
|
<p className="lyrics-status">{t('player.lyricsNotFound')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const getLyricLineClass = (i: number, active: number) => {
|
|
const base = 'lyrics-line';
|
|
if (i > active) return base;
|
|
if (i < active) return `${base} completed`;
|
|
return `${base} active`;
|
|
};
|
|
|
|
return (
|
|
<div className="lyrics-pane">
|
|
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
|
|
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
|
|
{hasSynced && (
|
|
<div className="lyrics-synced">
|
|
{syncedLines!.map((line, i) => (
|
|
<div
|
|
key={i}
|
|
ref={el => { lineRefs.current[i] = el; }}
|
|
className={getLyricLineClass(i, activeIdx)}
|
|
onClick={() => { if (duration > 0) seek(line.time / duration); }}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{line.text || '\u00A0'}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!hasSynced && plainLyrics && (
|
|
<div className="lyrics-plain">
|
|
{plainLyrics.split('\n').map((line, i) => (
|
|
<p key={i} className="lyrics-plain-line">{line || '\u00A0'}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|