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:
nisarg
2026-04-06 22:41:03 +05:30
committed by GitHub
parent 64e0948904
commit 13a8f696d0
+73 -40
View File
@@ -10,6 +10,7 @@ import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore'; import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane'; import LyricsPane from './LyricsPane';
import { TFunction } from 'i18next';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; 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() { export default function QueuePanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const queue = usePlayerStore(s => s.queue); const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const currentTime = usePlayerStore(s => s.currentTime);
const currentCoverFetchUrl = useMemo( const currentCoverFetchUrl = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt] [currentTrack?.coverArt]
@@ -275,6 +329,15 @@ export default function QueuePanel() {
return () => aside.removeEventListener('psy-drop', onPsyDrop); return () => aside.removeEventListener('psy-drop', onPsyDrop);
}, [enqueueAt]); }, [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 [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle'); const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [saveModalOpen, setSaveModalOpen] = useState(false); const [saveModalOpen, setSaveModalOpen] = useState(false);
@@ -333,47 +396,17 @@ export default function QueuePanel() {
} }
}} }}
style={{ style={{
borderLeftWidth: isQueueVisible ? 1 : 0 borderLeftWidth: isQueueVisible ? 1 : 0,
}} }}
> >
<div className="queue-header"> <QueueHeader
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}> queue={queue}
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}> queueIndex={queueIndex}
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2> showRemainingTime={showRemainingTime}
{queue.length > 0 && (() => { setShowRemainingTime={setShowRemainingTime}
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0); activePlaylist={activePlaylist}
const remainingSecs = Math.max(0, t={t}
(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>
{currentTrack && ( {currentTrack && (
<div className="queue-current-track"> <div className="queue-current-track">