mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
fix: queue auto-scroll to current track and fix re-renders
* stop queue panel from constant rerendering * add queueHeader type * fix(queue): move hook before early return, fix auto-scroll target - usePlayerStore must be called before conditional return (Rules of Hooks) - Auto-scroll to songs[queueIndex] not [queueIndex+1] — current track, not the next one Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Psychotoxical <dev@psysonic.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import LyricsPane from './LyricsPane';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -153,13 +154,66 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
|
||||
);
|
||||
}
|
||||
|
||||
interface QueueHeaderProps {
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
showRemainingTime: boolean;
|
||||
setShowRemainingTime: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
activePlaylist: { id: string; name: string } | null;
|
||||
t: TFunction;
|
||||
}
|
||||
function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTime, activePlaylist, t }: QueueHeaderProps) {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
|
||||
if (queue.length === 0) return null;
|
||||
const totalSecs = queue.reduce((acc: number, t: any) => acc + (t.duration || 0), 0);
|
||||
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + queue.slice(queueIndex + 1).reduce((acc: number, t: any) => acc + (t.duration || 0), 0));
|
||||
|
||||
const fmt = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
|
||||
|
||||
return (
|
||||
<div className="queue-header">
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</h2>
|
||||
<span
|
||||
onClick={() => setShowRemainingTime((v: boolean) => !v)}
|
||||
data-tooltip={showRemainingTime ? t("queue.showTotal") : t("queue.showRemaining")}
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--accent)",
|
||||
whiteSpace: "nowrap",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{queue.length} {queue.length === 1 ? t("queue.trackSingular") : t("queue.trackPlural")} · {dur}
|
||||
</span>
|
||||
</div>
|
||||
{activePlaylist && (
|
||||
<div className="truncate" style={{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px", display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<ListMusic size={10} style={{ flexShrink: 0 }} />
|
||||
<span className="truncate">{activePlaylist.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const currentCoverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
@@ -275,6 +329,15 @@ export default function QueuePanel() {
|
||||
return () => aside.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [enqueueAt]);
|
||||
|
||||
useEffect(function queueAutoScroll() {
|
||||
if (!queueListRef.current || queueIndex < 0) return;
|
||||
if (activeTab !== 'queue') return;
|
||||
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
const nextSong = songs[queueIndex];
|
||||
if (!nextSong) return;
|
||||
nextSong.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
}, [currentTrack, activeTab]);
|
||||
|
||||
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -333,47 +396,17 @@ export default function QueuePanel() {
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (() => {
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const remainingSecs = Math.max(0,
|
||||
(queue[queueIndex]?.duration ?? 0) - currentTime
|
||||
+ queue.slice(queueIndex + 1).reduce((acc, t) => acc + (t.duration || 0), 0)
|
||||
);
|
||||
const fmt = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
|
||||
return (
|
||||
<span
|
||||
onClick={() => setShowRemainingTime(v => !v)}
|
||||
data-tooltip={showRemainingTime ? t('queue.showTotal') : t('queue.showRemaining')}
|
||||
style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{activePlaylist && (
|
||||
<div className="truncate" style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<ListMusic size={10} style={{ flexShrink: 0 }} />
|
||||
<span className="truncate">{activePlaylist.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QueueHeader
|
||||
queue={queue}
|
||||
queueIndex={queueIndex}
|
||||
showRemainingTime={showRemainingTime}
|
||||
setShowRemainingTime={setShowRemainingTime}
|
||||
activePlaylist={activePlaylist}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
|
||||
Reference in New Issue
Block a user