feat(vibe): reconcile mutable session previews in playback

This commit is contained in:
kami
2026-08-01 23:47:02 +04:00
parent 51ef7c84db
commit 57df1cfe9f
20 changed files with 1311 additions and 312 deletions
+44
View File
@@ -2,6 +2,8 @@ import { create } from 'zustand';
import type { Track } from '../types';
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
@@ -22,12 +24,22 @@ interface PlaybackState {
repeat: RepeatMode;
/** 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;
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;
@@ -71,6 +83,8 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
shuffle: false,
repeat: 'none',
shufflePlayed: new Set<string>(),
vibeAdvanceHandler: null,
queueOwner: 'ordinary',
setQueue: (queue) =>
set((state) => ({
@@ -78,6 +92,18 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
// 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) =>
@@ -94,6 +120,19 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
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 });
@@ -169,6 +208,11 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
}
},
setVibeAdvanceHandler: (vibeAdvanceHandler) => set((state) => ({
vibeAdvanceHandler,
queueOwner: vibeAdvanceHandler ? 'vibe' : state.queueOwner,
})),
prev: () => {
const { queue, currentTrack, currentIndex } = get();
if (queue.length === 0) return;