diff --git a/frontend/src/components/AudioEngine.test.tsx b/frontend/src/components/AudioEngine.test.tsx index d37e2c4..2950bd2 100644 --- a/frontend/src/components/AudioEngine.test.tsx +++ b/frontend/src/components/AudioEngine.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); const audio = container.querySelector('audio')!; diff --git a/frontend/src/components/AudioEngine.tsx b/frontend/src/components/AudioEngine.tsx index e68ac84..9729440 100644 --- a/frontend/src/components/AudioEngine.tsx +++ b/frontend/src/components/AudioEngine.tsx @@ -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