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:
@@ -19,8 +19,11 @@ const track = (id: string): Track => ({
|
||||
|
||||
describe('AudioEngine', () => {
|
||||
beforeEach(() => {
|
||||
advancePastUnplayableVibeTrack.mockResolvedValue(undefined);
|
||||
reportVibeEvent.mockResolvedValue(undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
|
||||
useVibeStore.getState().reset();
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
|
||||
usePlaybackStore.setState({
|
||||
@@ -31,6 +34,85 @@ describe('AudioEngine', () => {
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** jsdom media elements report no duration and never really play. */
|
||||
const fakeMedia = (audio: HTMLAudioElement, currentTime: number, duration = 180) => {
|
||||
Object.defineProperty(audio, 'duration', { value: duration, configurable: true });
|
||||
Object.defineProperty(audio, 'currentTime', { value: currentTime, writable: true, configurable: true });
|
||||
Object.defineProperty(audio, 'paused', { value: false, configurable: true });
|
||||
Object.defineProperty(audio, 'ended', { value: false, configurable: true });
|
||||
};
|
||||
|
||||
it('buffers the next queued track into the idle element before the current one ends', () => {
|
||||
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, 170);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
expect(active.src).toContain('/tracks/song/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,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 500,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
|
||||
fakeMedia(active, 179.8);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
});
|
||||
|
||||
it('joins gaplessly with crossfade off: resolves early, starts the next track when the tail ends', () => {
|
||||
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'));
|
||||
const idlePlay = vi.spyOn(idle, 'play').mockResolvedValue(undefined);
|
||||
|
||||
fakeMedia(active, 179);
|
||||
active.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
// The store moved on so Vibe can resolve, but the tail keeps the audio.
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
|
||||
expect(idle.src).toContain('/tracks/next/stream');
|
||||
expect(idlePlay).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(active, 'ended', { value: true, configurable: true });
|
||||
active.dispatchEvent(new Event('ended'));
|
||||
|
||||
expect(idlePlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not resume the finished element while the next track is still being resolved', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song')], currentIndex: 0, isPlaying: true,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, crossfadeMs: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active] = Array.from(container.querySelectorAll('audio'));
|
||||
const play = vi.spyOn(active, 'play').mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(active, 'ended', { value: true, configurable: true });
|
||||
Object.defineProperty(active, 'paused', { value: true, configurable: true });
|
||||
active.dispatchEvent(new Event('ended'));
|
||||
// A queue/plan write during the Vibe handshake must not restart the tail.
|
||||
usePlaybackStore.getState().setQueue([track('song')]);
|
||||
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the durable unplayable advancement when a Vibe stream errors after metadata resolved', async () => {
|
||||
const { container } = render(<AudioEngine />);
|
||||
const audio = container.querySelector('audio')!;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
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 type { Track } from '../types';
|
||||
|
||||
// Threshold (seconds) above which a store position change is treated as a user
|
||||
@@ -17,6 +18,18 @@ const COMPLETION_THRESHOLD = 0.95;
|
||||
// Relative seek increment (seconds) for MediaSession seekforward/seekbackward.
|
||||
const SEEK_INCREMENT = 10;
|
||||
|
||||
// Fade granularity. Fine enough to be inaudible, coarse enough to be cheap.
|
||||
const FADE_TICK_MS = 40;
|
||||
|
||||
// With crossfade off there is no overlap to hide Vibe's replan round-trip, so
|
||||
// the handover still starts this early — the next element just waits, silent,
|
||||
// until the current one actually ends.
|
||||
const HANDOFF_LEAD_MS = 1200;
|
||||
|
||||
// If the outgoing element never reports `ended` (a stalled or broken stream),
|
||||
// start the waiting track anyway this long after its lead began.
|
||||
const JOIN_TIMEOUT_MS = HANDOFF_LEAD_MS + 2000;
|
||||
|
||||
/** Build the artwork URLs for MediaSession metadata (OS media controls). */
|
||||
function buildArtwork(track: Track): MediaImage[] {
|
||||
const sizes = [96, 128, 192, 256, 384, 512];
|
||||
@@ -29,13 +42,50 @@ function buildArtwork(track: Track): MediaImage[] {
|
||||
return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' }));
|
||||
}
|
||||
|
||||
// Headless audio engine: one shared <audio> element driven by the playback store.
|
||||
// State -> DOM via store subscriptions; DOM -> state via media events.
|
||||
export const AudioEngine = () => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
/**
|
||||
* The id of the track ordinary queue navigation will play next, or null when it
|
||||
* cannot be known ahead of time (shuffle) or there is nothing after this one.
|
||||
* Vibe's next track comes from the server, so this is only a prediction there —
|
||||
* used to warm the stream, never to decide what actually plays.
|
||||
*/
|
||||
function predictNextTrackId(): string | null {
|
||||
const { queue, currentTrack, currentIndex, shuffle, repeat } = usePlaybackStore.getState();
|
||||
if (shuffle || repeat === 'one' || queue.length === 0) return null;
|
||||
const idx =
|
||||
currentIndex >= 0 && queue[currentIndex]?.id === currentTrack?.id
|
||||
? currentIndex
|
||||
: currentTrack
|
||||
? queue.findIndex((t) => t.id === currentTrack.id)
|
||||
: -1;
|
||||
if (idx < 0) return null;
|
||||
if (idx + 1 < queue.length) return queue[idx + 1].id;
|
||||
if (repeat === 'all') return queue[0].id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track which id is currently loaded into the element, and whether it ended
|
||||
// naturally (so we record COMPLETED, not skip, on the resulting track change).
|
||||
// Headless audio engine: two <audio> elements driven by the playback store, so
|
||||
// the next track can buffer (and fade in) while the current one is still
|
||||
// playing. State -> DOM via store subscriptions; DOM -> state via media events.
|
||||
export const AudioEngine = () => {
|
||||
const audioARef = useRef<HTMLAudioElement>(null);
|
||||
const audioBRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
// Index of the element the store's currentTrack is playing on; the other one
|
||||
// is idle, prefetching, or fading out after being retired.
|
||||
const activeIdxRef = useRef(0);
|
||||
// Per-element fade multiplier applied on top of the store's master volume.
|
||||
const gainRef = useRef([1, 1]);
|
||||
const fadeTimerRef = useRef<Array<ReturnType<typeof setInterval> | null>>([null, null]);
|
||||
// The idle element holds this track id, loaded but never yet played.
|
||||
const preparedRef = useRef<{ id: string; idx: number } | null>(null);
|
||||
// Set when the handover window triggered the track change before `ended`, so
|
||||
// the ending element's own event does not advance a second time.
|
||||
const earlyAdvanceRef = useRef(false);
|
||||
// Cancels a pending gapless join (next track loaded, waiting for the tail).
|
||||
const cancelJoinRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Track which id is currently loaded into the active element, and whether it
|
||||
// ended naturally (so we record COMPLETED, not skip, on the track change).
|
||||
const loadedIdRef = useRef<string | null>(null);
|
||||
const endedNaturallyRef = useRef(false);
|
||||
// Track whether the current track has crossed the completion threshold.
|
||||
@@ -43,14 +93,162 @@ export const AudioEngine = () => {
|
||||
const lastProgressSecondRef = useRef(-1);
|
||||
const streamErrorTrackIdRef = useRef<string | null>(null);
|
||||
|
||||
const elements = useCallback((): HTMLAudioElement[] => {
|
||||
const a = audioARef.current;
|
||||
const b = audioBRef.current;
|
||||
return a && b ? [a, b] : [];
|
||||
}, []);
|
||||
|
||||
const applyGain = useCallback((idx: number) => {
|
||||
const els = elements();
|
||||
if (!els[idx]) return;
|
||||
const master = usePlaybackStore.getState().volume;
|
||||
els[idx].volume = Math.min(1, Math.max(0, master * gainRef.current[idx]));
|
||||
}, [elements]);
|
||||
|
||||
const stopFade = useCallback((idx: number) => {
|
||||
const timer = fadeTimerRef.current[idx];
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
fadeTimerRef.current[idx] = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setGain = useCallback((idx: number, gain: number) => {
|
||||
stopFade(idx);
|
||||
gainRef.current[idx] = Math.min(1, Math.max(0, gain));
|
||||
applyGain(idx);
|
||||
}, [applyGain, stopFade]);
|
||||
|
||||
/** Ramp one element's gain, then run `onDone`. Instant when ms <= 0. */
|
||||
const fadeTo = useCallback((idx: number, target: number, ms: number, onDone?: () => void) => {
|
||||
stopFade(idx);
|
||||
if (ms <= 0) {
|
||||
setGain(idx, target);
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const from = gainRef.current[idx];
|
||||
const steps = Math.max(1, Math.round(ms / FADE_TICK_MS));
|
||||
let step = 0;
|
||||
fadeTimerRef.current[idx] = setInterval(() => {
|
||||
step += 1;
|
||||
gainRef.current[idx] = from + ((target - from) * step) / steps;
|
||||
applyGain(idx);
|
||||
if (step >= steps) {
|
||||
stopFade(idx);
|
||||
setGain(idx, target);
|
||||
onDone?.();
|
||||
}
|
||||
}, FADE_TICK_MS);
|
||||
}, [applyGain, setGain, stopFade]);
|
||||
|
||||
/** Take an element out of service: silence it and drop its stream. */
|
||||
const retire = useCallback((idx: number) => {
|
||||
const els = elements();
|
||||
const el = els[idx];
|
||||
if (!el) return;
|
||||
stopFade(idx);
|
||||
el.pause();
|
||||
el.removeAttribute('src');
|
||||
el.load();
|
||||
setGain(idx, 1);
|
||||
}, [elements, setGain, stopFade]);
|
||||
|
||||
const cancelJoin = useCallback(() => {
|
||||
const cancel = cancelJoinRef.current;
|
||||
cancelJoinRef.current = null;
|
||||
cancel?.();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Crossfade-off handover: the next track is already loaded and the store has
|
||||
* already moved on, but the outgoing element still has its tail to play.
|
||||
* Start the new one the moment that tail ends, so the join has no gap and no
|
||||
* overlap.
|
||||
*/
|
||||
const scheduleJoin = useCallback((oldIdx: number, nextIdx: number) => {
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
const oldEl = els[oldIdx];
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
oldEl.removeEventListener('ended', join);
|
||||
oldEl.removeEventListener('error', join);
|
||||
};
|
||||
|
||||
function join() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
cancelJoinRef.current = null;
|
||||
retire(oldIdx);
|
||||
// A newer track change may already own the pipeline.
|
||||
if (activeIdxRef.current !== nextIdx) return;
|
||||
if (!usePlaybackStore.getState().isPlaying) return;
|
||||
setGain(nextIdx, 1);
|
||||
void els[nextIdx].play().catch(() => {});
|
||||
}
|
||||
|
||||
const timer = setTimeout(join, JOIN_TIMEOUT_MS);
|
||||
oldEl.addEventListener('ended', join);
|
||||
oldEl.addEventListener('error', join);
|
||||
cancelJoinRef.current = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
retire(oldIdx);
|
||||
};
|
||||
}, [elements, retire, setGain]);
|
||||
|
||||
// --- DOM -> store: media events -----------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const store = usePlaybackStore.getState;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
/** Warm the predicted next track into the idle element. */
|
||||
const prefetch = () => {
|
||||
const playback = store();
|
||||
if (!playback.prefetchNext || preparedRef.current) return;
|
||||
const nextId = predictNextTrackId();
|
||||
if (!nextId || nextId === loadedIdRef.current) return;
|
||||
const idleIdx = 1 - activeIdxRef.current;
|
||||
const idle = els[idleIdx];
|
||||
setGain(idleIdx, 1);
|
||||
idle.preload = 'auto';
|
||||
idle.src = trackService.getStreamUrl(nextId);
|
||||
idle.load();
|
||||
preparedRef.current = { id: nextId, idx: idleIdx };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hand over to the next track before `ended`, so Vibe's feedback/replan
|
||||
* round-trip happens while the tail of this one is still playing. With a
|
||||
* crossfade the tail fades under the new track; without one the new track
|
||||
* waits, silent, for the tail to finish.
|
||||
*/
|
||||
const maybeAdvanceEarly = (audio: HTMLAudioElement) => {
|
||||
const playback = store();
|
||||
const leadMs = playback.crossfadeMs > 0 ? playback.crossfadeMs : HANDOFF_LEAD_MS;
|
||||
if (earlyAdvanceRef.current || !playback.isPlaying) return;
|
||||
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
|
||||
if (audio.duration * 1000 <= leadMs * 2) return;
|
||||
if (audio.duration - audio.currentTime > leadMs / 1000) return;
|
||||
const vibeOwned = playback.queueOwner === 'vibe' && !!playback.vibeAdvanceHandler;
|
||||
// Ordinary playback must have somewhere to go; otherwise let the element
|
||||
// finish on its own so end-of-queue still stops cleanly.
|
||||
if (!vibeOwned && !predictNextTrackId()) return;
|
||||
earlyAdvanceRef.current = true;
|
||||
endedNaturallyRef.current = true;
|
||||
store().nextWithReason('completed');
|
||||
};
|
||||
|
||||
const onTimeUpdate = (idx: number, audio: HTMLAudioElement) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
store().setPosition(audio.currentTime);
|
||||
// Mark as effectively completed if we cross the threshold.
|
||||
if (
|
||||
@@ -67,26 +265,47 @@ 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) {
|
||||
prefetch();
|
||||
}
|
||||
maybeAdvanceEarly(audio);
|
||||
};
|
||||
const onLoadedMetadata = () => {
|
||||
const onLoadedMetadata = (idx: number, audio: HTMLAudioElement) => {
|
||||
// The idle element's metadata says nothing about what is playing.
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
|
||||
};
|
||||
const onPlay = () => {
|
||||
const onPlay = (idx: number) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
if (!store().isPlaying) store().play();
|
||||
};
|
||||
const onPause = () => {
|
||||
const onPause = (idx: number, audio: HTMLAudioElement) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
// Ignore the pause that fires as part of ending a track.
|
||||
if (audio.ended) return;
|
||||
if (store().isPlaying) store().pause();
|
||||
};
|
||||
const onEnded = () => {
|
||||
const onEnded = (idx: number, audio: HTMLAudioElement) => {
|
||||
// A retired element reaching its end is expected during a crossfade.
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
// The crossfade window already handed over; do not advance twice.
|
||||
if (earlyAdvanceRef.current) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
// Just flag it — applyTrack (below) is the single place that sends
|
||||
// feedback, on the resulting track-change, so completion is recorded
|
||||
// exactly once per track.
|
||||
endedNaturallyRef.current = true;
|
||||
// Vibe's durable feedback/replan handshake can take longer than the
|
||||
// browser's end transition. Explicitly pause the ended element so it
|
||||
// cannot auto-resume from its final buffered samples while that work is
|
||||
// in flight. applyTrack will start the next source when ready.
|
||||
audio.pause();
|
||||
store().nextWithReason('completed');
|
||||
};
|
||||
const onError = () => {
|
||||
const onError = (idx: number) => {
|
||||
if (idx !== activeIdxRef.current) return;
|
||||
const playback = store();
|
||||
const track = playback.currentTrack;
|
||||
const vibe = useVibeStore.getState();
|
||||
@@ -100,30 +319,33 @@ export const AudioEngine = () => {
|
||||
.finally(() => { streamErrorTrackIdRef.current = null; });
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
audio.addEventListener('error', onError);
|
||||
const teardown = els.map((audio, idx) => {
|
||||
const listeners: Array<[string, () => void]> = [
|
||||
['timeupdate', () => onTimeUpdate(idx, audio)],
|
||||
['loadedmetadata', () => onLoadedMetadata(idx, audio)],
|
||||
['play', () => onPlay(idx)],
|
||||
['pause', () => onPause(idx, audio)],
|
||||
['ended', () => onEnded(idx, audio)],
|
||||
['error', () => onError(idx)],
|
||||
];
|
||||
for (const [event, handler] of listeners) audio.addEventListener(event, handler);
|
||||
return () => {
|
||||
for (const [event, handler] of listeners) audio.removeEventListener(event, handler);
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
audio.removeEventListener('error', onError);
|
||||
};
|
||||
}, []);
|
||||
return () => { for (const off of teardown) off(); };
|
||||
}, [elements, setGain]);
|
||||
|
||||
// --- store -> DOM: react to currentTrack changes ------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const applyTrack = (id: string | null) => {
|
||||
if (id === loadedIdRef.current) return;
|
||||
// A newer track change supersedes any tail still waiting to hand over.
|
||||
cancelJoin();
|
||||
|
||||
// The durable Vibe controller owns normal next/ended navigation. It
|
||||
// records the outcome, receives a new plan revision, then calls the raw
|
||||
@@ -135,22 +357,60 @@ export const AudioEngine = () => {
|
||||
if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
|
||||
void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
|
||||
}
|
||||
const wasEarlyAdvance = earlyAdvanceRef.current;
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
earlyAdvanceRef.current = false;
|
||||
lastProgressSecondRef.current = -1;
|
||||
loadedIdRef.current = id;
|
||||
|
||||
const oldIdx = activeIdxRef.current;
|
||||
const oldEl = els[oldIdx];
|
||||
|
||||
if (!id) {
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
retire(oldIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.src = trackService.getStreamUrl(id);
|
||||
audio.load();
|
||||
// Reuse the prefetched element when it holds exactly this track; that
|
||||
// stream is already buffered, so playback starts without a fetch. With
|
||||
// nothing playing yet there is no tail to preserve, so load in place.
|
||||
const prepared = preparedRef.current;
|
||||
const reusePrefetched = prepared?.id === id && prepared.idx !== oldIdx;
|
||||
const nextIdx = reusePrefetched ? prepared!.idx : prevId ? 1 - oldIdx : oldIdx;
|
||||
const nextEl = els[nextIdx];
|
||||
preparedRef.current = null;
|
||||
|
||||
if (!reusePrefetched) {
|
||||
nextEl.src = trackService.getStreamUrl(id);
|
||||
nextEl.load();
|
||||
} else if (nextEl.currentTime > 0) {
|
||||
nextEl.currentTime = 0;
|
||||
}
|
||||
activeIdxRef.current = nextIdx;
|
||||
|
||||
// Only an early handover leaves a tail to deal with; an element that
|
||||
// already ended has nothing left to play.
|
||||
const crossfadeMs = playback.crossfadeMs;
|
||||
const hasTail = wasEarlyAdvance && nextIdx !== oldIdx && !oldEl.paused && !oldEl.ended;
|
||||
const fadeMs = hasTail ? crossfadeMs : 0;
|
||||
// Tail with no crossfade: hold the new track until the tail is done.
|
||||
const deferStart = hasTail && crossfadeMs <= 0;
|
||||
if (nextIdx !== oldIdx) {
|
||||
if (fadeMs > 0) fadeTo(oldIdx, 0, fadeMs, () => retire(oldIdx));
|
||||
else if (deferStart) scheduleJoin(oldIdx, nextIdx);
|
||||
else retire(oldIdx);
|
||||
}
|
||||
|
||||
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
|
||||
|
||||
setGain(nextIdx, fadeMs > 0 ? 0 : 1);
|
||||
if (deferStart) return;
|
||||
if (usePlaybackStore.getState().isPlaying) {
|
||||
void audio.play().catch(() => {});
|
||||
void nextEl.play().catch(() => {});
|
||||
if (fadeMs > 0) fadeTo(nextIdx, 1, fadeMs);
|
||||
} else if (fadeMs > 0) {
|
||||
setGain(nextIdx, 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,51 +420,79 @@ export const AudioEngine = () => {
|
||||
applyTrack(state.currentTrack?.id ?? null);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
}, [cancelJoin, elements, fadeTo, retire, scheduleJoin, setGain]);
|
||||
|
||||
// --- store -> DOM: isPlaying ------------------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const apply = (isPlaying: boolean) => {
|
||||
const audio = els[activeIdxRef.current];
|
||||
if (isPlaying) {
|
||||
// Never resume a finished element. Between `ended` and the next source
|
||||
// being loaded, isPlaying is still true, and resuming here replays the
|
||||
// final buffered milliseconds of the track that just finished.
|
||||
if (audio.ended) return;
|
||||
if (audio.paused) void audio.play().catch(() => {});
|
||||
} else {
|
||||
if (!audio.paused) audio.pause();
|
||||
// Pausing ends the handover: the tail is dropped and the waiting track
|
||||
// becomes the one that resumes.
|
||||
cancelJoin();
|
||||
for (const el of els) if (!el.paused) el.pause();
|
||||
}
|
||||
};
|
||||
|
||||
apply(usePlaybackStore.getState().isPlaying);
|
||||
return usePlaybackStore.subscribe((state) => apply(state.isPlaying));
|
||||
}, []);
|
||||
// Gate on an actual change: the subscription is unselected, so it fires on
|
||||
// every store write, including the queue/plan writes that happen while
|
||||
// Vibe's advance handshake is in flight.
|
||||
let lastIsPlaying = usePlaybackStore.getState().isPlaying;
|
||||
apply(lastIsPlaying);
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
if (state.isPlaying === lastIsPlaying) return;
|
||||
lastIsPlaying = state.isPlaying;
|
||||
apply(lastIsPlaying);
|
||||
});
|
||||
}, [cancelJoin, elements]);
|
||||
|
||||
// --- store -> DOM: volume --------------------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const apply = (volume: number) => {
|
||||
audio.volume = Math.min(1, Math.max(0, volume));
|
||||
};
|
||||
// Master volume is scaled by each element's fade gain.
|
||||
const apply = () => els.forEach((_, idx) => applyGain(idx));
|
||||
|
||||
apply(usePlaybackStore.getState().volume);
|
||||
return usePlaybackStore.subscribe((state) => apply(state.volume));
|
||||
}, []);
|
||||
let lastVolume = usePlaybackStore.getState().volume;
|
||||
apply();
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
if (state.volume === lastVolume) return;
|
||||
lastVolume = state.volume;
|
||||
apply();
|
||||
});
|
||||
}, [applyGain, elements]);
|
||||
|
||||
// --- store -> DOM: external seeks (user scrubbing) -------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const els = elements();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const apply = (position: number) => {
|
||||
const audio = els[activeIdxRef.current];
|
||||
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
|
||||
audio.currentTime = position;
|
||||
}
|
||||
};
|
||||
|
||||
return usePlaybackStore.subscribe((state) => apply(state.position));
|
||||
}, []);
|
||||
// Only real position changes are seeks; other store writes must not move
|
||||
// the playhead of a track that is mid-transition.
|
||||
let lastPosition = usePlaybackStore.getState().position;
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
if (state.position === lastPosition) return;
|
||||
lastPosition = state.position;
|
||||
apply(lastPosition);
|
||||
});
|
||||
}, [elements]);
|
||||
|
||||
// --- MediaSession: hardware media keys + OS media controls ------------------
|
||||
//
|
||||
@@ -217,6 +505,7 @@ export const AudioEngine = () => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
const store = usePlaybackStore.getState;
|
||||
const active = () => elements()[activeIdxRef.current] ?? null;
|
||||
|
||||
const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
|
||||
play: () => store().play(),
|
||||
@@ -224,19 +513,19 @@ export const AudioEngine = () => {
|
||||
previoustrack: () => store().prev(),
|
||||
nexttrack: () => store().next(),
|
||||
seekbackward: (details) => {
|
||||
const audio = audioRef.current;
|
||||
const audio = active();
|
||||
if (!audio) return;
|
||||
const delta = details.seekOffset ?? SEEK_INCREMENT;
|
||||
audio.currentTime = Math.max(0, audio.currentTime - delta);
|
||||
},
|
||||
seekforward: (details) => {
|
||||
const audio = audioRef.current;
|
||||
const audio = active();
|
||||
if (!audio) return;
|
||||
const delta = details.seekOffset ?? SEEK_INCREMENT;
|
||||
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + delta);
|
||||
},
|
||||
seekto: (details) => {
|
||||
const audio = audioRef.current;
|
||||
const audio = active();
|
||||
if (!audio || details.seekTime == null) return;
|
||||
audio.currentTime = details.seekTime;
|
||||
},
|
||||
@@ -270,7 +559,7 @@ export const AudioEngine = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [elements]);
|
||||
|
||||
// --- MediaSession: publish metadata + playback state ------------------------
|
||||
useEffect(() => {
|
||||
@@ -297,5 +586,17 @@ export const AudioEngine = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <audio ref={audioRef} hidden />;
|
||||
// Drop in-flight fades and any pending handover when the engine unmounts.
|
||||
useEffect(() => () => {
|
||||
for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
|
||||
cancelJoinRef.current?.();
|
||||
cancelJoinRef.current = null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio ref={audioARef} hidden />
|
||||
<audio ref={audioBRef} hidden />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Local playback preferences (this browser only), read synchronously at store
|
||||
* creation so the audio engine never runs a frame with the wrong values.
|
||||
*/
|
||||
|
||||
export const PLAYBACK_PREF_KEYS = {
|
||||
prefetchNext: 'muzick.settings.prefetchNext',
|
||||
crossfadeMs: 'muzick.settings.crossfadeMs',
|
||||
} as const;
|
||||
|
||||
/** Longest fade the UI offers. Also the longest early-advance lead. */
|
||||
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;
|
||||
|
||||
export const PLAYBACK_PREF_DEFAULTS = {
|
||||
prefetchNext: true,
|
||||
/** Short by default: enough to hide the Vibe replan round-trip, short enough
|
||||
* that it reads as a join rather than a mix. */
|
||||
crossfadeMs: 400,
|
||||
} as const;
|
||||
|
||||
export function readStoredPrefetchNext(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.prefetchNext);
|
||||
if (stored === 'true') return true;
|
||||
if (stored === 'false') return false;
|
||||
} catch { /* ignore */ }
|
||||
return PLAYBACK_PREF_DEFAULTS.prefetchNext;
|
||||
}
|
||||
|
||||
export function readStoredCrossfadeMs(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.crossfadeMs);
|
||||
if (stored !== null) {
|
||||
const parsed = Number(stored);
|
||||
if (Number.isFinite(parsed)) return clampCrossfadeMs(parsed);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return PLAYBACK_PREF_DEFAULTS.crossfadeMs;
|
||||
}
|
||||
|
||||
export function clampCrossfadeMs(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(MAX_CROSSFADE_MS, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function storePrefetchNext(value: boolean): void {
|
||||
try { localStorage.setItem(PLAYBACK_PREF_KEYS.prefetchNext, String(value)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function storeCrossfadeMs(value: number): void {
|
||||
try { localStorage.setItem(PLAYBACK_PREF_KEYS.crossfadeMs, String(value)); } catch { /* ignore */ }
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight, Trash2, Users, Sparkles } from 'lucide-react';
|
||||
import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight, Trash2, Users, Sparkles, Radio } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import api from '../services/api';
|
||||
import { settingsService, type EnrichSettingKey, type EnrichSettings } from '../services/settingsService';
|
||||
import { STORAGE_KEYS, readStoredVolume } from '../lib/theme';
|
||||
import { MAX_CROSSFADE_MS, PREFETCH_LEAD_SECONDS } from '../lib/playbackPrefs';
|
||||
import { toast } from '../store/useToastStore';
|
||||
import type { Track } from '../types';
|
||||
|
||||
@@ -278,6 +279,10 @@ function DuplicatesSection() {
|
||||
export default function Settings() {
|
||||
const volume = usePlaybackStore((s) => s.volume);
|
||||
const setVolume = usePlaybackStore((s) => s.setVolume);
|
||||
const prefetchNext = usePlaybackStore((s) => s.prefetchNext);
|
||||
const setPrefetchNext = usePlaybackStore((s) => s.setPrefetchNext);
|
||||
const crossfadeMs = usePlaybackStore((s) => s.crossfadeMs);
|
||||
const setCrossfadeMs = usePlaybackStore((s) => s.setCrossfadeMs);
|
||||
|
||||
useEffect(() => {
|
||||
setVolume(readStoredVolume(volume));
|
||||
@@ -306,6 +311,39 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Radio size={20} className="text-accent" />Transitions</h2>
|
||||
|
||||
<button type="button" onClick={() => setPrefetchNext(!prefetchNext)}
|
||||
role="switch" aria-checked={prefetchNext}
|
||||
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border/70 px-4 py-3 text-left transition-colors hover:bg-surface1">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text">Preload next track</div>
|
||||
<div className="text-xs text-muted">Buffers the upcoming track {PREFETCH_LEAD_SECONDS}s before the current one ends</div>
|
||||
</div>
|
||||
<div className={`shrink-0 relative w-10 h-5 rounded-full transition-colors ${prefetchNext ? 'bg-accent' : 'bg-surface2'}`}>
|
||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm transition-transform ${prefetchNext ? 'bg-on-accent translate-x-5' : 'bg-secondary'}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="crossfade" className="text-sm font-medium text-text">Crossfade</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input id="crossfade" type="range" min={0} max={MAX_CROSSFADE_MS} step={100} value={crossfadeMs}
|
||||
onChange={(e) => setCrossfadeMs(Number(e.target.value))}
|
||||
className="flex-1" />
|
||||
<span className="w-12 text-right text-sm tabular-nums text-text">
|
||||
{crossfadeMs === 0 ? 'Off' : `${(crossfadeMs / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted/70">
|
||||
Overlaps the end of one track with the start of the next. Off still
|
||||
joins tracks without a pause — the next one is resolved and loaded
|
||||
during the last second, then starts the moment this one ends.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Scan size={20} className="text-accent" />Library</h2>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user