mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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 { 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user