mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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' }} />
|
||||||
|
|||||||
@@ -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.
|
|
||||||
// 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;
|
isDraggingInternalRef.current = false;
|
||||||
draggedIdxRef.current = null;
|
draggedIdxRef.current = null;
|
||||||
dragOverIdxRef.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')}
|
||||||
@@ -326,6 +329,7 @@ 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) => {
|
||||||
|
|||||||
+172
-157
@@ -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;
|
||||||
@@ -60,27 +59,35 @@ interface PlayerState {
|
|||||||
|
|
||||||
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,170 +138,179 @@ 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: () => {
|
stop: () => {
|
||||||
get().howl?.stop();
|
destroyHowl(activeHowl);
|
||||||
get().howl?.seek(0);
|
activeHowl = null;
|
||||||
clearProgress();
|
|
||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
|
||||||
},
|
|
||||||
|
|
||||||
playTrack: (track, queue) => {
|
|
||||||
const state = get();
|
|
||||||
// Stop current
|
|
||||||
state.howl?.unload();
|
|
||||||
clearProgress();
|
clearProgress();
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||||
disarmGstWatchdog();
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
||||||
gstSeeking = false;
|
},
|
||||||
pendingSeekTime = null;
|
|
||||||
hangLastTime = -1;
|
|
||||||
hangStallTime = 0;
|
|
||||||
|
|
||||||
|
// ── playTrack ────────────────────────────────────────────────────────────
|
||||||
|
playTrack: (track, queue) => {
|
||||||
|
// Claim a new generation. Every callback created below captures `gen`.
|
||||||
|
// If playTrack() is called again before these callbacks fire, gen will
|
||||||
|
// no longer match playGeneration and the callbacks silently return.
|
||||||
|
const gen = ++playGeneration;
|
||||||
|
|
||||||
|
// Fully destroy the previous Howl — listeners first, then audio resources.
|
||||||
|
destroyHowl(activeHowl);
|
||||||
|
activeHowl = null;
|
||||||
|
clearProgress();
|
||||||
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||||
|
|
||||||
|
const state = get();
|
||||||
const newQueue = queue ?? state.queue;
|
const newQueue = queue ?? state.queue;
|
||||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||||
|
|
||||||
const howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume });
|
const howl = new Howl({
|
||||||
|
src: [buildStreamUrl(track.id)],
|
||||||
|
html5: true,
|
||||||
|
volume: state.volume,
|
||||||
|
});
|
||||||
|
activeHowl = howl;
|
||||||
|
|
||||||
|
// Commit state BEFORE howl.play() so queueIndex / currentTrack are
|
||||||
|
// already correct when the onplay / onend callbacks fire.
|
||||||
|
set({
|
||||||
|
currentTrack: track,
|
||||||
|
queue: newQueue,
|
||||||
|
queueIndex: idx >= 0 ? idx : 0,
|
||||||
|
howl,
|
||||||
|
progress: 0,
|
||||||
|
buffered: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
scrobbled: false,
|
||||||
|
});
|
||||||
|
|
||||||
howl.on('play', () => {
|
howl.on('play', () => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
reportNowPlaying(track.id);
|
reportNowPlaying(track.id);
|
||||||
|
|
||||||
// If recovering from a pipeline hang, seek to the saved position
|
// Cold-start resume: seek to the position that was saved before the
|
||||||
if (hangRecoveryPos !== null) {
|
// app was closed. A short delay lets the audio pipeline stabilise.
|
||||||
const pos = hangRecoveryPos;
|
if (resumeFromTime !== null) {
|
||||||
hangRecoveryPos = null;
|
const t = resumeFromTime;
|
||||||
gstSeeking = true;
|
resumeFromTime = null;
|
||||||
armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; });
|
setTimeout(() => {
|
||||||
setTimeout(() => { howl.seek(pos); }, 50);
|
if (playGeneration === gen) activeHowl?.seek(t);
|
||||||
|
}, 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ scrobbled: false });
|
clearProgress(); // guard against duplicate onplay
|
||||||
hangStallTime = Date.now();
|
|
||||||
hangLastTime = -1;
|
|
||||||
|
|
||||||
progressInterval = setInterval(() => {
|
progressInterval = setInterval(() => {
|
||||||
const h = get().howl;
|
// Bail out if this interval belongs to a superseded generation
|
||||||
|
if (playGeneration !== gen) { clearProgress(); return; }
|
||||||
|
const h = activeHowl;
|
||||||
if (!h) return;
|
if (!h) return;
|
||||||
const s = h.seek();
|
|
||||||
const cur = typeof s === 'number' ? s : 0;
|
|
||||||
const dur = h.duration() || 1;
|
|
||||||
const prog = cur / dur;
|
|
||||||
|
|
||||||
// Read buffered ranges from the underlying <audio> element
|
const raw = h.seek();
|
||||||
|
const cur = typeof raw === 'number' ? raw : 0;
|
||||||
|
const dur = h.duration() || 1;
|
||||||
|
|
||||||
|
// Buffered indicator via underlying <audio> element
|
||||||
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
||||||
if (audioNode?.buffered && audioNode.duration > 0) {
|
if (audioNode?.buffered && audioNode.duration > 0) {
|
||||||
let totalBuf = 0;
|
let totalBuf = 0;
|
||||||
for (let i = 0; i < audioNode.buffered.length; i++) {
|
for (let i = 0; i < audioNode.buffered.length; i++) {
|
||||||
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
|
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
|
||||||
}
|
}
|
||||||
set({ currentTime: cur, progress: prog, buffered: Math.min(1, totalBuf / audioNode.duration) });
|
set({ currentTime: cur, progress: cur / dur, buffered: Math.min(1, totalBuf / audioNode.duration) });
|
||||||
} else {
|
} else {
|
||||||
set({ currentTime: cur, progress: prog });
|
set({ currentTime: cur, progress: cur / dur });
|
||||||
}
|
|
||||||
|
|
||||||
// Hang detection: if playing but currentTime hasn't moved in 5s, recover
|
|
||||||
if (Math.abs(cur - hangLastTime) > 0.05) {
|
|
||||||
hangLastTime = cur;
|
|
||||||
hangStallTime = Date.now();
|
|
||||||
} else if (get().isPlaying && Date.now() - hangStallTime > 5000) {
|
|
||||||
const { currentTrack: ct, queue: q } = get();
|
|
||||||
if (ct) {
|
|
||||||
hangRecoveryPos = cur;
|
|
||||||
hangStallTime = Date.now(); // prevent re-trigger while recovering
|
|
||||||
get().playTrack(ct, q);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrobble at 50%
|
// Scrobble at 50%
|
||||||
if (prog >= 0.5 && !get().scrobbled) {
|
if (cur / dur >= 0.5 && !get().scrobbled) {
|
||||||
set({ scrobbled: true });
|
set({ scrobbled: true });
|
||||||
const { scrobblingEnabled } = useAuthStore.getState();
|
const { scrobblingEnabled } = useAuthStore.getState();
|
||||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
howl.on('end', () => {
|
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();
|
clearProgress();
|
||||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
const { repeatMode, currentTrack, queue } = get();
|
const { repeatMode, currentTrack, queue: q } = get();
|
||||||
if (repeatMode === 'one' && currentTrack) {
|
if (repeatMode === 'one' && currentTrack) {
|
||||||
get().playTrack(currentTrack, queue);
|
get().playTrack(currentTrack, q);
|
||||||
} else {
|
} else {
|
||||||
get().next();
|
get().next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
howl.on('stop', () => {
|
howl.on('playerror', (_, err) => {
|
||||||
|
if (playGeneration !== gen) return;
|
||||||
|
console.error('Howl play error:', err);
|
||||||
clearProgress();
|
clearProgress();
|
||||||
set({ isPlaying: false });
|
set({ isPlaying: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
howl.on('seek', () => {
|
howl.play();
|
||||||
disarmGstWatchdog();
|
|
||||||
gstSeeking = false;
|
|
||||||
hangLastTime = -1;
|
|
||||||
hangStallTime = Date.now();
|
|
||||||
if (pendingSeekTime !== null) {
|
|
||||||
const t = pendingSeekTime;
|
|
||||||
pendingSeekTime = null;
|
|
||||||
gstSeeking = true;
|
|
||||||
armGstWatchdog(() => {
|
|
||||||
gstSeeking = false;
|
|
||||||
pendingSeekTime = null;
|
|
||||||
const { currentTrack: ct, queue: q } = get();
|
|
||||||
if (ct) { hangRecoveryPos = t; get().playTrack(ct, q); }
|
|
||||||
});
|
|
||||||
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);
|
syncQueueToServer(newQueue, track, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||||
pause: () => {
|
pause: () => {
|
||||||
get().howl?.pause();
|
activeHowl?.pause();
|
||||||
clearProgress();
|
clearProgress();
|
||||||
set({ isPlaying: false });
|
set({ isPlaying: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
resume: () => {
|
resume: () => {
|
||||||
const { howl, currentTrack, queue, currentTime } = get();
|
const { currentTrack, queue, currentTime } = get();
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
if (!howl) {
|
if (activeHowl) {
|
||||||
// Cold start from restored state (e.g. app relaunch) — resume from saved position
|
activeHowl.play();
|
||||||
if (currentTime > 0) hangRecoveryPos = currentTime;
|
set({ isPlaying: true });
|
||||||
get().playTrack(currentTrack, queue);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
howl.play();
|
// Cold start after app relaunch — Howl was not persisted.
|
||||||
set({ isPlaying: true });
|
resumeFromTime = currentTime > 0 ? currentTime : null;
|
||||||
|
get().playTrack(currentTrack, queue);
|
||||||
},
|
},
|
||||||
|
|
||||||
togglePlay: () => {
|
togglePlay: () => {
|
||||||
|
// Guard: rapid double-clicks send pause→play (or play→pause) before
|
||||||
|
// 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();
|
const { isPlaying } = get();
|
||||||
isPlaying ? get().pause() : get().resume();
|
isPlaying ? get().pause() : get().resume();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── next / previous ──────────────────────────────────────────────────────
|
||||||
next: () => {
|
next: () => {
|
||||||
const { queue, queueIndex, repeatMode } = get();
|
const { queue, queueIndex, repeatMode } = get();
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
@@ -306,13 +318,19 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
get().playTrack(queue[nextIdx], queue);
|
get().playTrack(queue[nextIdx], queue);
|
||||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||||
get().playTrack(queue[0], queue);
|
get().playTrack(queue[0], queue);
|
||||||
|
} else {
|
||||||
|
// End of queue — clean stop without destroying currentTrack metadata
|
||||||
|
destroyHowl(activeHowl);
|
||||||
|
activeHowl = null;
|
||||||
|
clearProgress();
|
||||||
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
previous: () => {
|
previous: () => {
|
||||||
const { howl, queue, queueIndex, currentTime } = get();
|
const { queue, queueIndex, currentTime } = get();
|
||||||
if (currentTime > 3) {
|
if (currentTime > 3) {
|
||||||
howl?.seek(0);
|
activeHowl?.seek(0);
|
||||||
set({ progress: 0, currentTime: 0 });
|
set({ progress: 0, currentTime: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -320,34 +338,37 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
|
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) => {
|
seek: (progress) => {
|
||||||
const { howl, currentTrack } = get();
|
const { currentTrack } = get();
|
||||||
if (!howl || !currentTrack) return;
|
if (!activeHowl || !currentTrack) return;
|
||||||
const time = progress * (howl.duration() || currentTrack.duration);
|
const dur = activeHowl.duration() || currentTrack.duration;
|
||||||
set({ progress, currentTime: time });
|
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);
|
if (seekDebounce) clearTimeout(seekDebounce);
|
||||||
seekDebounce = setTimeout(() => {
|
seekDebounce = setTimeout(() => {
|
||||||
seekDebounce = null;
|
seekDebounce = null;
|
||||||
if (gstSeeking) {
|
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
||||||
// GStreamer busy — queue this position; onseek will send it when ready
|
if (audioNode && isFinite(time)) {
|
||||||
pendingSeekTime = time;
|
audioNode.currentTime = time;
|
||||||
return;
|
} else {
|
||||||
|
activeHowl?.seek(time);
|
||||||
}
|
}
|
||||||
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);
|
}, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── volume ───────────────────────────────────────────────────────────────
|
||||||
setVolume: (v) => {
|
setVolume: (v) => {
|
||||||
const clamped = Math.max(0, Math.min(1, v));
|
const clamped = Math.max(0, Math.min(1, v));
|
||||||
get().howl?.volume(clamped);
|
activeHowl?.volume(clamped);
|
||||||
set({ volume: clamped });
|
set({ volume: clamped });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -355,6 +376,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
|
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── queue management ─────────────────────────────────────────────────────
|
||||||
enqueue: (tracks) => {
|
enqueue: (tracks) => {
|
||||||
set(state => {
|
set(state => {
|
||||||
const newQueue = [...state.queue, ...tracks];
|
const newQueue = [...state.queue, ...tracks];
|
||||||
@@ -364,46 +386,43 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearQueue: () => {
|
clearQueue: () => {
|
||||||
get().howl?.unload();
|
destroyHowl(activeHowl);
|
||||||
|
activeHowl = null;
|
||||||
clearProgress();
|
clearProgress();
|
||||||
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Playlist management
|
reorderQueue: (startIndex, endIndex) => {
|
||||||
reorderQueue: (startIndex: number, endIndex: number) => {
|
|
||||||
const { queue, queueIndex, currentTrack } = get();
|
const { queue, queueIndex, currentTrack } = get();
|
||||||
const result = Array.from(queue);
|
const result = Array.from(queue);
|
||||||
const [removed] = result.splice(startIndex, 1);
|
const [removed] = result.splice(startIndex, 1);
|
||||||
result.splice(endIndex, 0, removed);
|
result.splice(endIndex, 0, removed);
|
||||||
|
|
||||||
// Update queueIndex if the currently playing track moved
|
|
||||||
let newIndex = queueIndex;
|
let newIndex = queueIndex;
|
||||||
if (currentTrack) {
|
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id);
|
||||||
newIndex = result.findIndex(t => t.id === currentTrack.id);
|
|
||||||
}
|
|
||||||
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
||||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
removeTrack: (index: number) => {
|
removeTrack: (index) => {
|
||||||
const { queue, queueIndex } = get();
|
const { queue, queueIndex } = get();
|
||||||
const newQueue = [...queue];
|
const newQueue = [...queue];
|
||||||
newQueue.splice(index, 1);
|
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) });
|
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
|
||||||
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
|
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── server queue restore ─────────────────────────────────────────────────
|
||||||
initializeFromServerQueue: async () => {
|
initializeFromServerQueue: async () => {
|
||||||
try {
|
try {
|
||||||
const q = await getPlayQueue();
|
const q = await getPlayQueue();
|
||||||
if (q.songs.length > 0) {
|
if (q.songs.length > 0) {
|
||||||
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
|
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
|
||||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
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,
|
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
|
||||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
coverArt: s.coverArt, track: s.track, year: s.year,
|
||||||
|
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let currentTrack = mappedTracks[0];
|
let currentTrack = mappedTracks[0];
|
||||||
@@ -411,32 +430,28 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
|
|
||||||
if (q.current) {
|
if (q.current) {
|
||||||
const idx = mappedTracks.findIndex(t => t.id === q.current);
|
const idx = mappedTracks.findIndex(t => t.id === q.current);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
|
||||||
currentTrack = mappedTracks[idx];
|
|
||||||
queueIndex = idx;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
queue: mappedTracks,
|
queue: mappedTracks,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
// Convert position from ms to s
|
currentTime: q.position ? q.position / 1000 : 0,
|
||||||
currentTime: q.position ? q.position / 1000 : 0
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to initialize queue from server', e);
|
console.error('Failed to initialize queue from server', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
|
||||||
}), {
|
|
||||||
name: 'psysonic-player',
|
name: 'psysonic-player',
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
volume: state.volume,
|
volume: state.volume,
|
||||||
repeatMode: state.repeatMode,
|
repeatMode: state.repeatMode,
|
||||||
} as Partial<PlayerState>),
|
} as Partial<PlayerState>),
|
||||||
}));
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user