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:
kami
2026-08-08 23:38:58 +04:00
parent 78f5feea11
commit 0749f6ad10
4 changed files with 62 additions and 4 deletions
@@ -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,
+12 -3
View File
@@ -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);