c41316ee99
Backend: - app.ts: switch shared pg.Client to pg.Pool with per-transaction clients (#205) - v2.routes.ts: replace plan instead of appending on replan, fixing self-duplication (#206) - session-director: populate recentExclusions, per-candidate ranking, batch repetition checks (#209/#211/#213/#215 + minor) - db.service.ts: claim-fusion watermark, legacy recommendation_batch engine removed (#216/#219/#232) - app.ts: drop test enqueue-job endpoint (#234) Frontend: - AudioEngine/Vibe/usePlaybackStore: dedupe completed feedback, gate feedback to vibe sessions, End Vibe stops playback, Keep toast, shuffle played-set (#207/#236/#237/#238/#239/#240) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.0 KiB
TypeScript
126 lines
4.0 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { Track } from '../types';
|
|
|
|
export type RepeatMode = 'none' | 'all' | 'one';
|
|
|
|
interface PlaybackState {
|
|
currentTrack: Track | null;
|
|
queue: Track[];
|
|
isPlaying: boolean;
|
|
position: number;
|
|
duration: number;
|
|
volume: number;
|
|
shuffle: boolean;
|
|
repeat: RepeatMode;
|
|
/** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */
|
|
shufflePlayed: Set<string>;
|
|
|
|
setQueue: (queue: Track[]) => void;
|
|
playTrack: (track: Track) => void;
|
|
play: () => void;
|
|
pause: () => void;
|
|
next: () => void;
|
|
prev: () => void;
|
|
setPosition: (position: number) => void;
|
|
setDuration: (duration: number) => void;
|
|
setVolume: (volume: number) => void;
|
|
setCurrentTrack: (track: Track | null) => void;
|
|
toggleShuffle: () => void;
|
|
cycleRepeat: () => void;
|
|
}
|
|
|
|
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
|
currentTrack: null,
|
|
queue: [],
|
|
isPlaying: false,
|
|
position: 0,
|
|
duration: 0,
|
|
volume: 1,
|
|
shuffle: false,
|
|
repeat: 'none',
|
|
shufflePlayed: new Set<string>(),
|
|
|
|
setQueue: (queue) => set({ queue, shufflePlayed: new Set() }),
|
|
|
|
playTrack: (track) =>
|
|
set({
|
|
currentTrack: track,
|
|
isPlaying: true,
|
|
position: 0,
|
|
duration: track.duration ?? 0,
|
|
shufflePlayed: new Set(),
|
|
}),
|
|
|
|
play: () => set({ isPlaying: true }),
|
|
pause: () => set({ isPlaying: false }),
|
|
|
|
next: () => {
|
|
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
|
|
if (queue.length === 0) return;
|
|
|
|
const idx = 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);
|
|
let remaining = queue.filter((t) => !played.has(t.id));
|
|
if (remaining.length === 0) {
|
|
if (repeat !== 'all') return;
|
|
// Lap complete — start a fresh one.
|
|
played.clear();
|
|
if (currentTrack) played.add(currentTrack.id);
|
|
remaining = queue.filter((t) => t.id !== currentTrack?.id);
|
|
if (remaining.length === 0) return;
|
|
}
|
|
const pick = remaining[Math.floor(Math.random() * remaining.length)];
|
|
played.add(pick.id);
|
|
set({ currentTrack: pick, shufflePlayed: played, position: 0, duration: pick.duration ?? 0, isPlaying: true });
|
|
return;
|
|
}
|
|
|
|
// Sequential
|
|
const nextTrack = idx >= 0 ? queue[idx + 1] : null;
|
|
if (nextTrack) {
|
|
// Trim played tracks from queue to prevent unbounded growth (Vibe prefetch leak)
|
|
const trimmed = queue.slice(idx + 1);
|
|
set({ queue: trimmed, currentTrack: nextTrack, position: 0, duration: nextTrack.duration ?? 0, isPlaying: true });
|
|
} else if (repeat === 'all') {
|
|
const first = queue[0];
|
|
if (first) {
|
|
set({ currentTrack: first, position: 0, duration: first.duration ?? 0, isPlaying: true });
|
|
}
|
|
}
|
|
},
|
|
|
|
prev: () => {
|
|
const { queue, currentTrack } = get();
|
|
if (queue.length === 0) return;
|
|
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
|
const prevTrack = idx > 0 ? queue[idx - 1] : null;
|
|
if (prevTrack) {
|
|
set({ currentTrack: prevTrack, position: 0, duration: prevTrack.duration ?? 0, isPlaying: true });
|
|
}
|
|
},
|
|
|
|
setPosition: (position) => set({ position }),
|
|
setDuration: (duration) => set({ duration }),
|
|
setVolume: (volume) => set({ volume }),
|
|
setCurrentTrack: (currentTrack) => set({ currentTrack }),
|
|
|
|
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 };
|
|
}),
|
|
}));
|