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 // scrub and applied to the audio element. Keeps the timeupdate -> setPosition -> // effect loop from fighting itself. const SEEK_THRESHOLD = 1; // If a track reaches this fraction of its duration, treat it as "effectively // completed" even if the user clicks Next before the very end. 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]; const artwork = track.artwork_id; if (!artwork) return []; // artwork_id is either a full URL (external) or a relative path served by us. const url = artwork.startsWith('http') ? artwork : `${window.location.origin}${artwork}`; return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' })); } /** * 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; } // Headless audio engine: two