fix(playback): remove the stutter and gap between tracks

The isPlaying subscriber is unselected. Every store write during Vibe's
feedback/replan handshake called play() on the element that had just ended.
That replayed its final buffered milliseconds until the next source loaded.
Gate that subscriber and the seek subscriber on an actual value change, and
never resume a finished element.

Then close the gap the handshake leaves behind. The engine now drives two
<audio> elements. The next track buffers into the idle one 20s early. The
handover starts before `ended`, so the round-trip happens under the outgoing
tail. With a crossfade, that tail fades out under the new track. With crossfade
off, the new track waits in silence and starts the moment the tail ends.

Both are configurable under Settings -> Transitions and persist to
localStorage. Preload is on and crossfade is 400ms by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-05 21:18:28 +04:00
parent ce16bb94f8
commit 8f33744f8c
5 changed files with 562 additions and 62 deletions
+24
View File
@@ -1,5 +1,12 @@
import { create } from 'zustand';
import type { Track } from '../types';
import {
clampCrossfadeMs,
readStoredCrossfadeMs,
readStoredPrefetchNext,
storeCrossfadeMs,
storePrefetchNext,
} from '../lib/playbackPrefs';
export type RepeatMode = 'none' | 'all' | 'one';
export type VibeAdvanceReason = 'skipped' | 'completed' | 'disliked';
@@ -22,6 +29,10 @@ interface PlaybackState {
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. */
@@ -44,6 +55,8 @@ interface PlaybackState {
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;
toggleShuffle: () => void;
cycleRepeat: () => void;
@@ -82,6 +95,8 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
volume: 1,
shuffle: false,
repeat: 'none',
prefetchNext: readStoredPrefetchNext(),
crossfadeMs: readStoredCrossfadeMs(),
shufflePlayed: new Set<string>(),
vibeAdvanceHandler: null,
queueOwner: 'ordinary',
@@ -237,6 +252,15 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
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,