fix: Playback stability, seek stop bug, and UI polish

- playerStore: Guard onend against spurious 'ended' events fired by
  WebKit/GStreamer immediately after a direct audioNode.currentTime seek.
  Root cause of the deterministic "second click on progress bar stops song"
  bug: a lastSeekAt timestamp + position check now silently drops any
  'ended' event that fires within 1 s of a seek if the playhead isn't
  actually near the track end.

- playerStore: Debounce togglePlay with a 300 ms lock to prevent rapid
  double-clicks from issuing pause→play before GStreamer has finished its
  state transition, which caused a multi-second pipeline hang.

- NowPlayingDropdown: Clicking a Live entry now navigates to the album page.

- QueuePanel: DnD drop target index now calculated from clientY position
  at drop time instead of refs, eliminating the dragend-before-drop race
  on macOS WKWebView and Windows WebView2.

- styles: Increased Hero background blur for a more immersive look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-14 14:41:02 +01:00
parent 1e599d9636
commit baa701dd74
4 changed files with 359 additions and 332 deletions
+7 -1
View File
@@ -4,9 +4,11 @@ import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subs
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function NowPlayingDropdown() { export default function NowPlayingDropdown() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]); const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -121,7 +123,11 @@ export default function NowPlayingDropdown() {
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{visible.map((stream, idx) => ( {visible.map((stream, idx) => (
<div key={`${stream.id}-${idx}`} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px' }}> <div
key={`${stream.id}-${idx}`}
onClick={() => { 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' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}> <div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
{stream.coverArt ? ( {stream.coverArt ? (
<img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
+29 -25
View File
@@ -132,6 +132,8 @@ export default function QueuePanel() {
const draggedIdxRef = useRef<number | null>(null); const draggedIdxRef = useRef<number | null>(null);
const dragOverIdxRef = useRef<number | null>(null); const dragOverIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null);
const [saveModalOpen, setSaveModalOpen] = useState(false); const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false); const [loadModalOpen, setLoadModalOpen] = useState(false);
@@ -171,47 +173,48 @@ export default function QueuePanel() {
}; };
const onDragEnd = () => { const onDragEnd = () => {
// Reset visual state immediately.
setDraggedIdx(null); setDraggedIdx(null);
setDragOverIdx(null); setDragOverIdx(null);
// Delay clearing refs so onDropQueue can read them first. isDraggingInternalRef.current = false;
// On macOS WKWebView and Windows WebView2, dragend fires before drop draggedIdxRef.current = null;
// (spec violation), so synchronously clearing refs here loses the dragOverIdxRef.current = null;
// drag source/destination indices before onDropQueue runs.
setTimeout(() => {
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
}, 200);
}; };
const onDropQueue = async (e: React.DragEvent) => { const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
// Refs are still valid here — onDragEnd delays clearing them so they survive // Clear visual state immediately
// 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.
isDraggingInternalRef.current = false; isDraggingInternalRef.current = false;
draggedIdxRef.current = null; draggedIdxRef.current = null;
dragOverIdxRef.current = null; dragOverIdxRef.current = null;
setDraggedIdx(null); setDraggedIdx(null);
setDragOverIdx(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; let parsedData: any = null;
try { try {
const raw = e.dataTransfer.getData('text/plain'); const raw = e.dataTransfer.getData('text/plain');
if (raw) parsedData = JSON.parse(raw); if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ } } catch { /* ignore */ }
// Internal reorder: refs are the primary source; dataTransfer is the fallback. if (parsedData?.type === 'queue_reorder') {
const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null); // fromIdx: always reliable from dataTransfer (set during dragstart)
if (reorderFrom !== null) { const fromIdx: number = parsedData.index;
if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx);
// 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<HTMLElement>('[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; return;
} }
@@ -300,7 +303,7 @@ export default function QueuePanel() {
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>} {currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
<div className="queue-list"> <div className="queue-list" ref={queueListRef}>
{queue.length === 0 ? ( {queue.length === 0 ? (
<div className="queue-empty"> <div className="queue-empty">
{t('queue.emptyQueue')} {t('queue.emptyQueue')}
@@ -324,8 +327,9 @@ export default function QueuePanel() {
} }
return ( return (
<div <div
key={`${track.id}-${idx}`} key={`${track.id}-${idx}`}
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''}`} className={`queue-item ${isPlaying ? 'active' : ''}`}
onClick={() => playTrack(track, queue)} onClick={() => playTrack(track, queue)}
onContextMenu={(e) => { onContextMenu={(e) => {
+321 -306
View File
@@ -32,7 +32,6 @@ interface PlayerState {
howl: Howl | null; howl: Howl | null;
scrobbled: boolean; scrobbled: boolean;
// Actions
playTrack: (track: Track, queue?: Track[]) => void; playTrack: (track: Track, queue?: Track[]) => void;
pause: () => void; pause: () => void;
resume: () => void; resume: () => void;
@@ -51,36 +50,44 @@ interface PlayerState {
isFullscreenOpen: boolean; isFullscreenOpen: boolean;
toggleFullscreen: () => void; toggleFullscreen: () => void;
repeatMode: 'off' | 'all' | 'one'; repeatMode: 'off' | 'all' | 'one';
toggleRepeat: () => void; toggleRepeat: () => void;
reorderQueue: (startIndex: number, endIndex: number) => void; reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void; removeTrack: (index: number) => void;
initializeFromServerQueue: () => Promise<void>; initializeFromServerQueue: () => Promise<void>;
// Context Menu Global State
contextMenu: { contextMenu: {
isOpen: boolean; isOpen: boolean;
x: number; x: number;
y: number; y: number;
item: any; item: any;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null; 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; openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
closeContextMenu: () => 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<typeof setInterval> | null = null; let progressInterval: ReturnType<typeof setInterval> | null = null;
let seekDebounce: ReturnType<typeof setTimeout> | null = null; let seekDebounce: ReturnType<typeof setTimeout> | null = null;
let gstSeeking = false; // true while GStreamer is processing a seek let resumeFromTime: number | null = null; // cold-start resume position (app relaunch)
let pendingSeekTime: number | null = null; // queue at most one seek let lastSeekAt = 0; // timestamp (ms) of the most recent seek — used to ignore spurious 'ended' events
let gstSeekWatchdog: ReturnType<typeof setTimeout> | null = null; // safety release if onseek never fires let togglePlayLock = false; // prevents rapid double-click from sending pause→play before GStreamer settles
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
function clearProgress() { function clearProgress() {
if (progressInterval) { if (progressInterval) {
@@ -89,31 +96,27 @@ function clearProgress() {
} }
} }
function armGstWatchdog(cb: () => void) { // Remove all Howler-level listeners BEFORE stopping/unloading.
if (gstSeekWatchdog) clearTimeout(gstSeekWatchdog); // This is the critical step that prevents stale `onend` callbacks from firing
gstSeekWatchdog = setTimeout(() => { // on a superseded Howl and triggering an unwanted next() / skip.
gstSeekWatchdog = null; function destroyHowl(howl: Howl | null) {
cb(); if (!howl) return;
}, 2000); howl.off(); // remove all Howler event listeners
howl.stop(); // stop any playing sound
howl.unload(); // release the <audio> element and all resources
} }
function disarmGstWatchdog() { // ─── Server queue sync ─────────────────────────────────────────────────────────
if (gstSeekWatchdog) { clearTimeout(gstSeekWatchdog); gstSeekWatchdog = null; }
}
// Helper to debounce or fire queue syncs
let syncTimeout: ReturnType<typeof setTimeout> | null = null; let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) { function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
if (syncTimeout) clearTimeout(syncTimeout); if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => { syncTimeout = setTimeout(() => {
// Collect up to 1000 track IDs just in case it's huge
const ids = queue.slice(0, 1000).map(t => t.id); const ids = queue.slice(0, 1000).map(t => t.id);
// Convert currentTime (seconds) to expected format (milliseconds)
const pos = Math.floor(currentTime * 1000); const pos = Math.floor(currentTime * 1000);
savePlayQueue(ids, currentTrack?.id, pos).catch(err => { savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
console.error('Failed to sync play queue to server', err); console.error('Failed to sync play queue to server', err);
}); });
}, 1500); // 1.5s debounce }, 1500);
} }
export const usePlayerStore = create<PlayerState>()( export const usePlayerStore = create<PlayerState>()(
@@ -135,308 +138,320 @@ export const usePlayerStore = create<PlayerState>()(
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null }, contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
openContextMenu: (x, y, item, type, queueIndex) => set({ openContextMenu: (x, y, item, type, queueIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex } contextMenu: { isOpen: true, x, y, item, type, queueIndex },
}), }),
closeContextMenu: () => set(state => ({ closeContextMenu: () => set(state => ({
contextMenu: { ...state.contextMenu, isOpen: false } contextMenu: { ...state.contextMenu, isOpen: false },
})), })),
toggleQueue: () => set((state) => ({ isQueueVisible: !state.isQueueVisible })), toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
toggleFullscreen: () => set((state) => ({ isFullscreenOpen: !state.isFullscreenOpen })), toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleRepeat: () => set((state) => { toggleRepeat: () => set(state => {
const modes = ['off', 'all', 'one'] as const; const modes = ['off', 'all', 'one'] as const;
const nextIdx = (modes.indexOf(state.repeatMode) + 1) % modes.length; return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] };
return { repeatMode: modes[nextIdx] }; }),
}),
stop: () => { // ── stop ────────────────────────────────────────────────────────────────
get().howl?.stop(); stop: () => {
get().howl?.seek(0); destroyHowl(activeHowl);
clearProgress(); activeHowl = null;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); clearProgress();
}, if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
},
playTrack: (track, queue) => { // ── playTrack ────────────────────────────────────────────────────────────
const state = get(); playTrack: (track, queue) => {
// Stop current // Claim a new generation. Every callback created below captures `gen`.
state.howl?.unload(); // If playTrack() is called again before these callbacks fire, gen will
clearProgress(); // no longer match playGeneration and the callbacks silently return.
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } const gen = ++playGeneration;
disarmGstWatchdog();
gstSeeking = false;
pendingSeekTime = null;
hangLastTime = -1;
hangStallTime = 0;
const newQueue = queue ?? state.queue; // Fully destroy the previous Howl — listeners first, then audio resources.
const idx = newQueue.findIndex(t => t.id === track.id); destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
const howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume }); const state = get();
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
howl.on('play', () => { const howl = new Howl({
set({ isPlaying: true }); src: [buildStreamUrl(track.id)],
reportNowPlaying(track.id); html5: true,
volume: state.volume,
});
activeHowl = howl;
// If recovering from a pipeline hang, seek to the saved position // Commit state BEFORE howl.play() so queueIndex / currentTrack are
if (hangRecoveryPos !== null) { // already correct when the onplay / onend callbacks fire.
const pos = hangRecoveryPos; set({
hangRecoveryPos = null; currentTrack: track,
gstSeeking = true; queue: newQueue,
armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; }); queueIndex: idx >= 0 ? idx : 0,
setTimeout(() => { howl.seek(pos); }, 50); howl,
} progress: 0,
buffered: 0,
currentTime: 0,
scrobbled: false,
});
set({ scrobbled: false }); howl.on('play', () => {
hangStallTime = Date.now(); if (playGeneration !== gen) return;
hangLastTime = -1; set({ isPlaying: true });
reportNowPlaying(track.id);
progressInterval = setInterval(() => { // Cold-start resume: seek to the position that was saved before the
const h = get().howl; // app was closed. A short delay lets the audio pipeline stabilise.
if (!h) return; if (resumeFromTime !== null) {
const s = h.seek(); const t = resumeFromTime;
const cur = typeof s === 'number' ? s : 0; resumeFromTime = null;
const dur = h.duration() || 1; setTimeout(() => {
const prog = cur / dur; if (playGeneration === gen) activeHowl?.seek(t);
}, 80);
// Read buffered ranges from the underlying <audio> element
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
if (audioNode?.buffered && audioNode.duration > 0) {
let totalBuf = 0;
for (let i = 0; i < audioNode.buffered.length; i++) {
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
} }
set({ currentTime: cur, progress: prog, buffered: Math.min(1, totalBuf / audioNode.duration) });
} else {
set({ currentTime: cur, progress: prog });
}
// Hang detection: if playing but currentTime hasn't moved in 5s, recover clearProgress(); // guard against duplicate onplay
if (Math.abs(cur - hangLastTime) > 0.05) { progressInterval = setInterval(() => {
hangLastTime = cur; // Bail out if this interval belongs to a superseded generation
hangStallTime = Date.now(); if (playGeneration !== gen) { clearProgress(); return; }
} else if (get().isPlaying && Date.now() - hangStallTime > 5000) { const h = activeHowl;
const { currentTrack: ct, queue: q } = get(); if (!h) return;
if (ct) {
hangRecoveryPos = cur; const raw = h.seek();
hangStallTime = Date.now(); // prevent re-trigger while recovering const cur = typeof raw === 'number' ? raw : 0;
get().playTrack(ct, q); const dur = h.duration() || 1;
// Buffered indicator via underlying <audio> element
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
if (audioNode?.buffered && audioNode.duration > 0) {
let totalBuf = 0;
for (let i = 0; i < audioNode.buffered.length; i++) {
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
}
set({ currentTime: cur, progress: cur / dur, buffered: Math.min(1, totalBuf / audioNode.duration) });
} else {
set({ currentTime: cur, progress: cur / dur });
}
// Scrobble at 50%
if (cur / dur >= 0.5 && !get().scrobbled) {
set({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
}
}, 500);
});
howl.on('end', () => {
if (playGeneration !== gen) return;
// WebKit (and GStreamer on Linux) can fire spurious 'ended' events
// immediately after a direct audioNode.currentTime seek. Guard: if we
// are within 1 s of the last seek AND the playhead is not actually near
// the track end, treat this as a false alarm and ignore it.
if (Date.now() - lastSeekAt < 1000) {
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
const pos = audioNode ? audioNode.currentTime : (typeof activeHowl?.seek() === 'number' ? activeHowl.seek() as number : 0);
const dur = activeHowl?.duration() ?? 0;
if (dur > 0 && pos < dur - 1) return;
} }
clearProgress();
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
const { repeatMode, currentTrack, queue: q } = get();
if (repeatMode === 'one' && currentTrack) {
get().playTrack(currentTrack, q);
} else {
get().next();
}
});
howl.on('playerror', (_, err) => {
if (playGeneration !== gen) return;
console.error('Howl play error:', err);
clearProgress();
set({ isPlaying: false });
});
howl.play();
syncQueueToServer(newQueue, track, 0);
},
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
activeHowl?.pause();
clearProgress();
set({ isPlaying: false });
},
resume: () => {
const { currentTrack, queue, currentTime } = get();
if (!currentTrack) return;
if (activeHowl) {
activeHowl.play();
set({ isPlaying: true });
return; return;
} }
// Cold start after app relaunch — Howl was not persisted.
// Scrobble at 50% resumeFromTime = currentTime > 0 ? currentTime : null;
if (prog >= 0.5 && !get().scrobbled) {
set({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
}
}, 500);
});
howl.on('end', () => {
clearProgress();
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
const { repeatMode, currentTrack, queue } = get();
if (repeatMode === 'one' && currentTrack) {
get().playTrack(currentTrack, queue); get().playTrack(currentTrack, queue);
} else { },
get().next();
}
});
howl.on('stop', () => { togglePlay: () => {
clearProgress(); // Guard: rapid double-clicks send pause→play (or play→pause) before
set({ isPlaying: false }); // GStreamer/WebKit has finished the previous state transition, causing
}); // the audio pipeline to hang for several seconds. Ignore the second
// click if it arrives within 300 ms of the first.
if (togglePlayLock) return;
togglePlayLock = true;
setTimeout(() => { togglePlayLock = false; }, 300);
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
howl.on('seek', () => { // ── next / previous ──────────────────────────────────────────────────────
disarmGstWatchdog(); next: () => {
gstSeeking = false; const { queue, queueIndex, repeatMode } = get();
hangLastTime = -1; const nextIdx = queueIndex + 1;
hangStallTime = Date.now(); if (nextIdx < queue.length) {
if (pendingSeekTime !== null) { get().playTrack(queue[nextIdx], queue);
const t = pendingSeekTime; } else if (repeatMode === 'all' && queue.length > 0) {
pendingSeekTime = null; get().playTrack(queue[0], queue);
gstSeeking = true; } else {
armGstWatchdog(() => { // End of queue — clean stop without destroying currentTrack metadata
gstSeeking = false; destroyHowl(activeHowl);
pendingSeekTime = null; activeHowl = null;
const { currentTrack: ct, queue: q } = get(); clearProgress();
if (ct) { hangRecoveryPos = t; get().playTrack(ct, q); } set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
});
get().howl?.seek(t);
}
});
howl.play(); // for gapless: resumes from paused state, onplay fires and seeks to 0 via hangRecoveryPos
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer(newQueue, track, 0);
},
pause: () => {
get().howl?.pause();
clearProgress();
set({ isPlaying: false });
},
resume: () => {
const { howl, currentTrack, queue, currentTime } = get();
if (!currentTrack) return;
if (!howl) {
// Cold start from restored state (e.g. app relaunch) — resume from saved position
if (currentTime > 0) hangRecoveryPos = currentTime;
get().playTrack(currentTrack, queue);
return;
}
howl.play();
set({ isPlaying: true });
},
togglePlay: () => {
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
next: () => {
const { queue, queueIndex, repeatMode } = get();
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue);
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
}
},
previous: () => {
const { howl, queue, queueIndex, currentTime } = get();
if (currentTime > 3) {
howl?.seek(0);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
},
seek: (progress) => {
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration);
set({ progress, currentTime: time });
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
if (gstSeeking) {
// GStreamer busy — queue this position; onseek will send it when ready
pendingSeekTime = time;
return;
}
gstSeeking = true;
const seekTarget = time;
armGstWatchdog(() => {
gstSeeking = false;
pendingSeekTime = null;
const { currentTrack: ct, queue: q } = get();
if (ct) { hangRecoveryPos = seekTarget; get().playTrack(ct, q); }
});
get().howl?.seek(time);
}, 100);
},
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
get().howl?.volume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
enqueue: (tracks) => {
set(state => {
const newQueue = [...state.queue, ...tracks];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
});
},
clearQueue: () => {
get().howl?.unload();
clearProgress();
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
syncQueueToServer([], null, 0);
},
// Playlist management
reorderQueue: (startIndex: number, endIndex: number) => {
const { queue, queueIndex, currentTrack } = get();
const result = Array.from(queue);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
// Update queueIndex if the currently playing track moved
let newIndex = queueIndex;
if (currentTrack) {
newIndex = result.findIndex(t => t.id === currentTrack.id);
}
set({ queue: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index: number) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
newQueue.splice(index, 1);
// If we removed the currently playing track, stop playback?
// Usually wait until it finishes or user skips. We'll just update state.
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
},
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) {
currentTrack = mappedTracks[idx];
queueIndex = idx;
}
} }
},
set({
queue: mappedTracks, previous: () => {
queueIndex, const { queue, queueIndex, currentTime } = get();
currentTrack, if (currentTime > 3) {
// Convert position from ms to s activeHowl?.seek(0);
currentTime: q.position ? q.position / 1000 : 0 set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
},
// ── seek ─────────────────────────────────────────────────────────────────
// Debounced 100 ms to collapse rapid slider drags into one actual seek.
// We bypass Howler's seek() entirely and set currentTime directly on the
// underlying <audio> element. Howler's seek() internally calls pause() +
// play() which can fire spurious ended/stop events on some WebKit versions,
// especially on the second consecutive seek.
seek: (progress) => {
const { currentTrack } = get();
if (!activeHowl || !currentTrack) return;
const dur = activeHowl.duration() || currentTrack.duration;
if (!dur || !isFinite(dur)) return;
// Clamp slightly before end to prevent accidentally triggering 'ended'
const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
set({ progress: time / dur, currentTime: time });
lastSeekAt = Date.now();
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
if (audioNode && isFinite(time)) {
audioNode.currentTime = time;
} else {
activeHowl?.seek(time);
}
}, 100);
},
// ── volume ───────────────────────────────────────────────────────────────
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
activeHowl?.volume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
// ── queue management ─────────────────────────────────────────────────────
enqueue: (tracks) => {
set(state => {
const newQueue = [...state.queue, ...tracks];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
}); });
} },
} catch (e) {
console.error('Failed to initialize queue from server', e); clearQueue: () => {
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
syncQueueToServer([], null, 0);
},
reorderQueue: (startIndex, endIndex) => {
const { queue, queueIndex, currentTrack } = get();
const result = Array.from(queue);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
let newIndex = queueIndex;
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id);
set({ queue: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
newQueue.splice(index, 1);
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
},
// ── server queue restore ─────────────────────────────────────────────────
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
set({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: q.position ? q.position / 1000 : 0,
});
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
},
}),
{
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
} as Partial<PlayerState>),
} }
}, )
);
}), {
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
} as Partial<PlayerState>),
}));
+2
View File
@@ -23,7 +23,9 @@
background-position: center; background-position: center;
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease; transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
transform: scale(1.05); transform: scale(1.05);
filter: blur(12px);
} }
.hero:hover .hero-bg { filter: blur(8px); }
.hero:hover .hero-bg { transform: scale(1); } .hero:hover .hero-bg { transform: scale(1); }
.hero-dots { .hero-dots {