feat(playback): buffer the next track early, look further ahead less
The served Vibe preview was eight items deep, and every one of them cost a track fetch on each advance while buying nothing but a longer Up next list. Three is enough to show where the stream is going. The audio prefetch was the opposite problem: it only began twenty seconds before the end, so a phone that lost signal in that window arrived at the handover with nothing buffered. It now starts fifteen seconds into the current track, which gives the rest of the song to pull the next one down. Buffering that early means a replan can change the answer, so the idle element is re-pointed when it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(<AudioEngine />);
|
||||
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(<AudioEngine />);
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user