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