diff --git a/src/components/NowPlayingDropdown.tsx b/src/components/NowPlayingDropdown.tsx index f58d98b8..770ecec2 100644 --- a/src/components/NowPlayingDropdown.tsx +++ b/src/components/NowPlayingDropdown.tsx @@ -4,9 +4,11 @@ import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subs import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; export default function NowPlayingDropdown() { const { t } = useTranslation(); + const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); const [nowPlaying, setNowPlaying] = useState([]); const isPlaying = usePlayerStore(s => s.isPlaying); @@ -121,7 +123,11 @@ export default function NowPlayingDropdown() { ) : (
{visible.map((stream, idx) => ( -
+
{ if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }} + style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }} + >
{stream.coverArt ? ( Cover diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 73f2829c..772dabeb 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -132,6 +132,8 @@ export default function QueuePanel() { const draggedIdxRef = useRef(null); const dragOverIdxRef = useRef(null); + const queueListRef = useRef(null); + const [saveModalOpen, setSaveModalOpen] = useState(false); const [loadModalOpen, setLoadModalOpen] = useState(false); @@ -171,47 +173,48 @@ export default function QueuePanel() { }; const onDragEnd = () => { - // Reset visual state immediately. setDraggedIdx(null); setDragOverIdx(null); - // Delay clearing refs so onDropQueue can read them first. - // On macOS WKWebView and Windows WebView2, dragend fires before drop - // (spec violation), so synchronously clearing refs here loses the - // drag source/destination indices before onDropQueue runs. - setTimeout(() => { - isDraggingInternalRef.current = false; - draggedIdxRef.current = null; - dragOverIdxRef.current = null; - }, 200); + isDraggingInternalRef.current = false; + draggedIdxRef.current = null; + dragOverIdxRef.current = null; }; const onDropQueue = async (e: React.DragEvent) => { e.preventDefault(); - // Refs are still valid here — onDragEnd delays clearing them so they survive - // the macOS/WebView2 dragend-before-drop race condition. - const fromIdx = draggedIdxRef.current; - const toIdx = dragOverIdxRef.current ?? queue.length; - - // Cancel the pending timeout cleanup and clear refs immediately. + // Clear visual state immediately isDraggingInternalRef.current = false; draggedIdxRef.current = null; dragOverIdxRef.current = null; setDraggedIdx(null); setDragOverIdx(null); - // Read dataTransfer — set during dragstart, survives dragend on all platforms. - // Used as fallback for fromIdx in case refs somehow weren't set. let parsedData: any = null; try { const raw = e.dataTransfer.getData('text/plain'); if (raw) parsedData = JSON.parse(raw); } catch { /* ignore */ } - // Internal reorder: refs are the primary source; dataTransfer is the fallback. - const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null); - if (reorderFrom !== null) { - if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx); + if (parsedData?.type === 'queue_reorder') { + // fromIdx: always reliable from dataTransfer (set during dragstart) + const fromIdx: number = parsedData.index; + + // toIdx: calculate from drop coordinates — avoids all ref timing issues. + // Works even when dragend fires before drop (macOS WKWebView / Windows WebView2). + let toIdx = queue.length; + if (queueListRef.current) { + const items = queueListRef.current.querySelectorAll('[data-queue-idx]'); + for (let i = 0; i < items.length; i++) { + const rect = items[i].getBoundingClientRect(); + if (e.clientY < rect.top + rect.height / 2) { + toIdx = parseInt(items[i].dataset.queueIdx!); + break; + } + } + } + + if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx); return; } @@ -300,7 +303,7 @@ export default function QueuePanel() { {currentTrack && queue.length > 0 &&
{t('queue.nextTracks')}
} -
+
{queue.length === 0 ? (
{t('queue.emptyQueue')} @@ -324,8 +327,9 @@ export default function QueuePanel() { } return ( -
playTrack(track, queue)} onContextMenu={(e) => { diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index ae35b980..c4c561ac 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -32,7 +32,6 @@ interface PlayerState { howl: Howl | null; scrobbled: boolean; - // Actions playTrack: (track: Track, queue?: Track[]) => void; pause: () => void; resume: () => void; @@ -51,36 +50,44 @@ interface PlayerState { isFullscreenOpen: boolean; toggleFullscreen: () => void; - + repeatMode: 'off' | 'all' | 'one'; toggleRepeat: () => void; reorderQueue: (startIndex: number, endIndex: number) => void; removeTrack: (index: number) => void; - + initializeFromServerQueue: () => Promise; - // Context Menu Global State contextMenu: { isOpen: boolean; x: number; y: number; item: any; type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null; - queueIndex?: number; // Only for 'queue-item' + queueIndex?: number; }; openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void; closeContextMenu: () => void; } +// ─── Module-level playback primitives ───────────────────────────────────────── +// +// Kept outside Zustand to avoid stale-closure / React re-render races. +// +// activeHowl – the one and only live Howl; all event handlers reference this. +// playGeneration – monotonically incremented on every playTrack() call. +// Every Howl event callback captures its own `gen` value at creation time +// and bails out immediately if playGeneration has moved on. This prevents +// stale onend / onplay callbacks from a superseded Howl from affecting state. + +let activeHowl: Howl | null = null; +let playGeneration = 0; let progressInterval: ReturnType | null = null; let seekDebounce: ReturnType | null = null; -let gstSeeking = false; // true while GStreamer is processing a seek -let pendingSeekTime: number | null = null; // queue at most one seek -let gstSeekWatchdog: ReturnType | null = null; // safety release if onseek never fires -let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here -let hangLastTime = -1; // last observed currentTime for hang detection -let hangStallTime = 0; // Date.now() when currentTime last moved +let resumeFromTime: number | null = null; // cold-start resume position (app relaunch) +let lastSeekAt = 0; // timestamp (ms) of the most recent seek — used to ignore spurious 'ended' events +let togglePlayLock = false; // prevents rapid double-click from sending pause→play before GStreamer settles function clearProgress() { if (progressInterval) { @@ -89,31 +96,27 @@ function clearProgress() { } } -function armGstWatchdog(cb: () => void) { - if (gstSeekWatchdog) clearTimeout(gstSeekWatchdog); - gstSeekWatchdog = setTimeout(() => { - gstSeekWatchdog = null; - cb(); - }, 2000); +// Remove all Howler-level listeners BEFORE stopping/unloading. +// This is the critical step that prevents stale `onend` callbacks from firing +// on a superseded Howl and triggering an unwanted next() / skip. +function destroyHowl(howl: Howl | null) { + if (!howl) return; + howl.off(); // remove all Howler event listeners + howl.stop(); // stop any playing sound + howl.unload(); // release the