diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts
index f01bd81..26fa3f4 100644
--- a/backend/src/services/vibe-session-coordinator.service.ts
+++ b/backend/src/services/vibe-session-coordinator.service.ts
@@ -48,6 +48,15 @@ export interface AdvanceUnplayableVibeItemInput {
eventId: string;
}
+/**
+ * How much of the plan a client is shown and holds ready. The plan itself is
+ * PLAN_SIZE long and stays that way; this is only the served window. Every
+ * preview item costs the client one track fetch on every advance, and a replan
+ * discards whatever is still unplayed, so a long window buys little beyond a
+ * longer Up next list.
+ */
+const PREVIEW_SIZE = 3;
+
export class VibeSessionNotFoundError extends Error {}
export class VibeSessionLifecycleError extends Error {}
export class VibePlanNotFoundError extends Error {}
@@ -339,7 +348,7 @@ export class VibeSessionCoordinator {
): VibeSessionResponse {
// A revision is immutable, but clients need a live future: already served
// rows stay in the ledger and are excluded from the replacement preview.
- const preview = plan?.items.filter((item) => !item.committed).slice(0, 8) ?? [];
+ const preview = plan?.items.filter((item) => !item.committed).slice(0, PREVIEW_SIZE) ?? [];
return {
session,
sessionId: session.id,
diff --git a/frontend/src/components/AudioEngine.test.tsx b/frontend/src/components/AudioEngine.test.tsx
index 2950bd2..846ebb7 100644
--- a/frontend/src/components/AudioEngine.test.tsx
+++ b/frontend/src/components/AudioEngine.test.tsx
@@ -57,6 +57,39 @@ describe('AudioEngine', () => {
expect(active.src).toContain('/tracks/song/stream');
});
+ it('starts buffering the next track early in the current one, not only near its end', () => {
+ usePlaybackStore.setState({
+ queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
+ queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
+ });
+ const { container } = render();
+ const [active, idle] = Array.from(container.querySelectorAll('audio'));
+
+ fakeMedia(active, 20);
+ active.dispatchEvent(new Event('timeupdate'));
+
+ expect(idle.src).toContain('/tracks/next/stream');
+ });
+
+ it('re-points the buffered element when a replan changes what plays next', () => {
+ usePlaybackStore.setState({
+ queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
+ queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
+ });
+ const { container } = render();
+ const [active, idle] = Array.from(container.querySelectorAll('audio'));
+
+ fakeMedia(active, 20);
+ active.dispatchEvent(new Event('timeupdate'));
+ expect(idle.src).toContain('/tracks/next/stream');
+
+ usePlaybackStore.setState({ queue: [track('song'), track('other')] });
+ fakeMedia(active, 30);
+ active.dispatchEvent(new Event('timeupdate'));
+
+ expect(idle.src).toContain('/tracks/other/stream');
+ });
+
it('hands over to the next track inside the crossfade window instead of waiting for ended', () => {
usePlaybackStore.setState({
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
diff --git a/frontend/src/components/AudioEngine.tsx b/frontend/src/components/AudioEngine.tsx
index 8499c30..baff593 100644
--- a/frontend/src/components/AudioEngine.tsx
+++ b/frontend/src/components/AudioEngine.tsx
@@ -3,7 +3,7 @@ import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { trackService } from '../services/trackService';
import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
-import { PREFETCH_LEAD_SECONDS } from '../lib/playbackPrefs';
+import { PREFETCH_LEAD_SECONDS, PREFETCH_START_SECONDS } from '../lib/playbackPrefs';
import type { Track } from '../types';
// Threshold (seconds) above which a store position change is treated as a user
@@ -213,11 +213,17 @@ export const AudioEngine = () => {
/** Warm the predicted next track into the idle element. */
const prefetch = () => {
const playback = store();
- if (!playback.prefetchNext || preparedRef.current) return;
+ if (!playback.prefetchNext) return;
const nextId = predictNextTrackId();
if (!nextId || nextId === loadedIdRef.current) return;
const idleIdx = 1 - activeIdxRef.current;
+ // Buffering starts early enough that a Vibe replan can change the answer
+ // underneath us. Re-point the idle element rather than arriving at the
+ // handover with the wrong track warmed; leave a tail still playing alone.
+ const prepared = preparedRef.current;
+ if (prepared && (prepared.id === nextId || prepared.idx !== idleIdx)) return;
const idle = els[idleIdx];
+ if (prepared && !idle.paused) return;
setGain(idleIdx, 1);
idle.preload = 'auto';
idle.src = trackService.getStreamUrl(nextId);
@@ -265,7 +271,10 @@ export const AudioEngine = () => {
lastProgressSecondRef.current = elapsed;
void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
}
- if (Number.isFinite(audio.duration) && audio.duration - audio.currentTime <= PREFETCH_LEAD_SECONDS) {
+ if (
+ audio.currentTime >= PREFETCH_START_SECONDS ||
+ (Number.isFinite(audio.duration) && audio.duration - audio.currentTime <= PREFETCH_LEAD_SECONDS)
+ ) {
prefetch();
}
maybeAdvanceEarly(audio);
diff --git a/frontend/src/lib/playbackPrefs.ts b/frontend/src/lib/playbackPrefs.ts
index 530ed2a..4f0e002 100644
--- a/frontend/src/lib/playbackPrefs.ts
+++ b/frontend/src/lib/playbackPrefs.ts
@@ -15,6 +15,13 @@ export const MAX_CROSSFADE_MS = 3000;
/** How far before the end of a track the next one starts buffering. */
export const PREFETCH_LEAD_SECONDS = 20;
+/**
+ * How far into a track the next one starts buffering, whichever comes first
+ * with PREFETCH_LEAD_SECONDS. A phone that sleeps mid-track gets the whole
+ * remaining song to pull the next one down instead of a 20 second window.
+ */
+export const PREFETCH_START_SECONDS = 15;
+
export const PLAYBACK_PREF_DEFAULTS = {
prefetchNext: true,
/** Short by default: enough to hide the Vibe replan round-trip, short enough