57120b872d
Six bugs in the single transport and in how a browser registers as a device, then the feature the fourth one was hiding. A watching device keeps its own audio paused, so every control that read `isPlaying` from the store drew a Play button while the desktop played — and sent `play` when it was pressed. The transport now carries one `playing` value: the remote state while presses are being forwarded, the local one otherwise. Two tabs of one browser shared a stored device id, which made them one device that ran every command twice and played two copies of the audio. A device id is now held by whichever stream has it open: registration refuses to hand back a busy id, and each tab keeps its own in `sessionStorage`. A device that was only showing what another one plays still pointed an audio element at the stream, downloading tracks it would never play. It now loads nothing while the audio is elsewhere, and reloads the moment it comes back. Commands were accepted for an owner with no stream to receive them on, so a killed tab answered a press with a success it never got. Ownership outlives a closed stream deliberately; delivery does not. The event stream never called `reply.hijack()`, leaving Fastify waiting on a handler that resolves with nothing. And the Vibe: `setQueue` is an ownership handoff, so a snapshot from another device dropped the advance handler that asks the server for the next track. A session moved to a phone became a fixed list of the hundred tracks that happened to be synced. Vibe control now follows the audio — the snapshot carries the session id, the device losing the audio stops driving, and the one gaining it resumes the durable session and takes over replanning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
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<string>;
|
|
/** 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<Track>();
|
|
|
|
export const usePlaybackStore = create<PlaybackState>((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<string>(),
|
|
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);
|
|
});
|