import { create } from 'zustand'; import type { Track } from '../types'; import { clampCrossfadeMs, readStoredCrossfadeMs, readStoredLastTrack, readStoredPrefetchNext, storeCrossfadeMs, storeLastTrack, storePrefetchNext, } from '../lib/playbackPrefs'; export type RepeatMode = 'none' | 'all' | 'one'; export type VibeAdvanceReason = 'skipped' | 'completed' | 'disliked'; export type PlaybackOwner = 'ordinary' | 'vibe'; /** * How many already-played tracks to keep behind the cursor. Bounds queue growth * from the Vibe prefetch loop while still leaving real history for prev(). */ const MAX_HISTORY = 50; interface PlaybackState { currentTrack: Track | null; queue: Track[]; /** Cursor into `queue` for the current track, or -1 when the current track is not queued. */ currentIndex: number; isPlaying: boolean; position: number; duration: number; volume: number; shuffle: boolean; repeat: RepeatMode; /** Warm the next track's stream into the idle audio element before this one ends. */ prefetchNext: boolean; /** Overlap between tracks, in milliseconds. 0 disables the fade. */ crossfadeMs: number; /** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */ shufflePlayed: Set; /** Installed only while a durable Vibe session owns the queue. */ vibeAdvanceHandler: ((reason: VibeAdvanceReason) => void) | null; /** Vibe must opt in explicitly; ordinary browsing always owns itself. */ queueOwner: PlaybackOwner; /** * True while another device holds the audio and this one is only showing what * it plays. The engine loads no stream in that state, so a phone watching the * desktop stops pulling megabytes of audio it will never play. */ audioElsewhere: boolean; setQueue: (queue: Track[]) => void; /** Vibe-only queue replacement. Do not use for library browsing. */ setVibeQueue: (queue: Track[]) => void; playTrack: (track: Track) => void; play: () => void; pause: () => void; next: () => void; nextWithReason: (reason: VibeAdvanceReason) => void; /** Bypass the Vibe controller after it has prepared the next committed track. */ advance: () => void; setVibeAdvanceHandler: (handler: ((reason: VibeAdvanceReason) => void) | null) => void; prev: () => void; setPosition: (position: number) => void; setDuration: (duration: number) => void; setVolume: (volume: number) => void; setPrefetchNext: (prefetchNext: boolean) => void; setCrossfadeMs: (crossfadeMs: number) => void; setCurrentTrack: (track: Track | null) => void; setAudioElsewhere: (audioElsewhere: boolean) => void; toggleShuffle: () => void; cycleRepeat: () => void; } /** * Move to `index` in `queue`, trimming stale history so the queue stays bounded. * Returns the state patch (queue may be re-sliced, so the index is adjusted). */ function advanceTo(queue: Track[], index: number) { let nextQueue = queue; let nextIndex = index; if (index > MAX_HISTORY) { const drop = index - MAX_HISTORY; nextQueue = queue.slice(drop); nextIndex = index - drop; } const track = nextQueue[nextIndex]; return { queue: nextQueue, currentIndex: nextIndex, currentTrack: track, position: 0, duration: track?.duration ?? 0, isPlaying: true, }; } // A fresh tab opens on the last track it played, paused at zero — an empty // player bar told the listener nothing about where they were. const restoredTrack = readStoredLastTrack(); export const usePlaybackStore = create((set, get) => ({ currentTrack: restoredTrack, queue: restoredTrack ? [restoredTrack] : [], currentIndex: restoredTrack ? 0 : -1, isPlaying: false, position: 0, duration: restoredTrack?.duration ?? 0, volume: 1, shuffle: false, repeat: 'none', prefetchNext: readStoredPrefetchNext(), crossfadeMs: readStoredCrossfadeMs(), shufflePlayed: new Set(), vibeAdvanceHandler: null, queueOwner: 'ordinary', audioElsewhere: false, setQueue: (queue) => set((state) => ({ queue, // Keep the cursor pointing at whatever is playing, if it is still queued. currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1, shufflePlayed: new Set(), // Every ordinary queue operation is an explicit ownership handoff. This // prevents a stale Vibe session from intercepting browser/UI next. queueOwner: 'ordinary', vibeAdvanceHandler: null, })), setVibeQueue: (queue) => set((state) => ({ queue, currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1, shufflePlayed: new Set(), queueOwner: 'vibe', })), playTrack: (track) => set((state) => ({ currentTrack: track, currentIndex: state.queue.findIndex((t) => t.id === track.id), isPlaying: true, position: 0, duration: track.duration ?? 0, shufflePlayed: new Set(), })), play: () => set({ isPlaying: true }), pause: () => set({ isPlaying: false }), next: () => { get().nextWithReason('skipped'); }, nextWithReason: (reason) => { const { vibeAdvanceHandler: handler, queueOwner } = get(); if (queueOwner === 'vibe' && handler) { handler(reason); return; } get().advance(); }, advance: () => { const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get(); if (queue.length === 0) { set({ isPlaying: false, position: 0 }); return; } // Prefer the tracked cursor; fall back to a lookup if it has drifted. const tracked = get().currentIndex; const idx = tracked >= 0 && queue[tracked]?.id === currentTrack?.id ? tracked : currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; // Repeat one: replay current track if (repeat === 'one' && currentTrack) { set({ position: 0, isPlaying: true }); return; } if (shuffle) { // Shuffle: pick a random track not yet played this lap (excludes current), // so repeat-all doesn't bounce between the same few tracks. const played = new Set(shufflePlayed); if (currentTrack) played.add(currentTrack.id); const indices = queue.map((_, i) => i); let remaining = indices.filter((i) => !played.has(queue[i].id)); if (remaining.length === 0) { if (repeat !== 'all') { set({ isPlaying: false, position: 0 }); return; } // Lap complete — start a fresh one. played.clear(); if (currentTrack) played.add(currentTrack.id); remaining = indices.filter((i) => queue[i].id !== currentTrack?.id); if (remaining.length === 0) { set({ isPlaying: false, position: 0 }); return; } } const pickIdx = remaining[Math.floor(Math.random() * remaining.length)]; played.add(queue[pickIdx].id); const pick = queue[pickIdx]; set({ currentTrack: pick, currentIndex: pickIdx, shufflePlayed: played, position: 0, duration: pick.duration ?? 0, isPlaying: true, }); return; } // Sequential — keep the queue intact and move the cursor, so prev() has // history and repeat-all restarts from the true first track. if (idx < 0) { // Nothing playing (or the current track left the queue) — start at the top. set(advanceTo(queue, 0)); } else if (idx + 1 < queue.length) { set(advanceTo(queue, idx + 1)); } else if (repeat === 'all' && queue.length > 0) { set(advanceTo(queue, 0)); } else { // End of queue with no looping — stop playback so the store, DOM audio // element, and MediaSession all agree nothing is playing. Without this // the browser's now-playing widget can auto-resume the finished track // from its near-end position, causing a tight "play last second → end → // auto-resume → play last second → …" loop that also degrades performance. set({ isPlaying: false, position: 0 }); } }, setVibeAdvanceHandler: (vibeAdvanceHandler) => set((state) => ({ vibeAdvanceHandler, queueOwner: vibeAdvanceHandler ? 'vibe' : state.queueOwner, })), prev: () => { const { queue, currentTrack, currentIndex } = get(); if (queue.length === 0) return; const idx = currentIndex >= 0 && queue[currentIndex]?.id === currentTrack?.id ? currentIndex : currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; if (idx > 0) { const prevTrack = queue[idx - 1]; set({ currentTrack: prevTrack, currentIndex: idx - 1, position: 0, duration: prevTrack.duration ?? 0, isPlaying: true, }); } }, setPosition: (position) => set({ position }), setDuration: (duration) => set({ duration }), setVolume: (volume) => set({ volume }), setPrefetchNext: (prefetchNext) => { storePrefetchNext(prefetchNext); set({ prefetchNext }); }, setCrossfadeMs: (value) => { const crossfadeMs = clampCrossfadeMs(value); storeCrossfadeMs(crossfadeMs); set({ crossfadeMs }); }, setCurrentTrack: (currentTrack) => set((state) => ({ currentTrack, currentIndex: currentTrack ? state.queue.findIndex((t) => t.id === currentTrack.id) : -1, })), setAudioElsewhere: (audioElsewhere) => set({ audioElsewhere }), toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })), cycleRepeat: () => set((state) => { const modes: RepeatMode[] = ['none', 'all', 'one']; const next = modes[(modes.indexOf(state.repeat) + 1) % modes.length]; return { repeat: next }; }), })); // One subscription instead of a write in playTrack, advance, prev and the // shuffle pick — every path that changes the track goes through here. let lastPersistedTrackId = restoredTrack?.id ?? null; usePlaybackStore.subscribe((state) => { const id = state.currentTrack?.id ?? null; if (id === lastPersistedTrackId) return; lastPersistedTrackId = id; storeLastTrack(state.currentTrack); });