302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { 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 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;
|
|
|
|
/** 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' }));
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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).
|
|
const loadedIdRef = useRef<string | null>(null);
|
|
const endedNaturallyRef = useRef(false);
|
|
// Track whether the current track has crossed the completion threshold.
|
|
const crossedThresholdRef = useRef(false);
|
|
const lastProgressSecondRef = useRef(-1);
|
|
const streamErrorTrackIdRef = useRef<string | null>(null);
|
|
|
|
// --- DOM -> store: media events -----------------------------------------
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const store = usePlaybackStore.getState;
|
|
|
|
const onTimeUpdate = () => {
|
|
store().setPosition(audio.currentTime);
|
|
// Mark as effectively completed if we cross the threshold.
|
|
if (
|
|
!crossedThresholdRef.current &&
|
|
audio.duration &&
|
|
audio.currentTime / audio.duration >= COMPLETION_THRESHOLD
|
|
) {
|
|
crossedThresholdRef.current = true;
|
|
}
|
|
const vibe = useVibeStore.getState();
|
|
const track = store().currentTrack;
|
|
const elapsed = Math.floor(audio.currentTime);
|
|
if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) {
|
|
lastProgressSecondRef.current = elapsed;
|
|
void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
|
|
}
|
|
};
|
|
const onLoadedMetadata = () => {
|
|
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
|
|
};
|
|
const onPlay = () => {
|
|
if (!store().isPlaying) store().play();
|
|
};
|
|
const onPause = () => {
|
|
// Ignore the pause that fires as part of ending a track.
|
|
if (audio.ended) return;
|
|
if (store().isPlaying) store().pause();
|
|
};
|
|
const onEnded = () => {
|
|
// 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;
|
|
store().nextWithReason('completed');
|
|
};
|
|
const onError = () => {
|
|
const playback = store();
|
|
const track = playback.currentTrack;
|
|
const vibe = useVibeStore.getState();
|
|
// Metadata can be available while the stream itself is no longer
|
|
// readable. Vibe must advance that exact durable cursor, not fall back
|
|
// to ordinary queue navigation or feedback-driven replanning.
|
|
if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return;
|
|
streamErrorTrackIdRef.current = track.id;
|
|
void advancePastUnplayableVibeTrack(track.id)
|
|
.catch(() => undefined)
|
|
.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);
|
|
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
// --- store -> DOM: react to currentTrack changes ------------------------
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const applyTrack = (id: string | null) => {
|
|
if (id === loadedIdRef.current) return;
|
|
|
|
// The durable Vibe controller owns normal next/ended navigation. It
|
|
// records the outcome, receives a new plan revision, then calls the raw
|
|
// advance method. Do not emit a second event here after that transition.
|
|
const prevId = loadedIdRef.current;
|
|
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
|
|
const playback = usePlaybackStore.getState();
|
|
const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe';
|
|
if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
|
|
void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
|
|
}
|
|
endedNaturallyRef.current = false;
|
|
crossedThresholdRef.current = false;
|
|
lastProgressSecondRef.current = -1;
|
|
loadedIdRef.current = id;
|
|
|
|
if (!id) {
|
|
audio.removeAttribute('src');
|
|
audio.load();
|
|
return;
|
|
}
|
|
|
|
audio.src = trackService.getStreamUrl(id);
|
|
audio.load();
|
|
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
|
|
if (usePlaybackStore.getState().isPlaying) {
|
|
void audio.play().catch(() => {});
|
|
}
|
|
};
|
|
|
|
// Apply the current value immediately, then subscribe to future changes.
|
|
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
|
|
const unsub = usePlaybackStore.subscribe((state) => {
|
|
applyTrack(state.currentTrack?.id ?? null);
|
|
});
|
|
return unsub;
|
|
}, []);
|
|
|
|
// --- store -> DOM: isPlaying ------------------------------------------------
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const apply = (isPlaying: boolean) => {
|
|
if (isPlaying) {
|
|
if (audio.paused) void audio.play().catch(() => {});
|
|
} else {
|
|
if (!audio.paused) audio.pause();
|
|
}
|
|
};
|
|
|
|
apply(usePlaybackStore.getState().isPlaying);
|
|
return usePlaybackStore.subscribe((state) => apply(state.isPlaying));
|
|
}, []);
|
|
|
|
// --- store -> DOM: volume --------------------------------------------------
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const apply = (volume: number) => {
|
|
audio.volume = Math.min(1, Math.max(0, volume));
|
|
};
|
|
|
|
apply(usePlaybackStore.getState().volume);
|
|
return usePlaybackStore.subscribe((state) => apply(state.volume));
|
|
}, []);
|
|
|
|
// --- store -> DOM: external seeks (user scrubbing) -------------------------
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const apply = (position: number) => {
|
|
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
|
|
audio.currentTime = position;
|
|
}
|
|
};
|
|
|
|
return usePlaybackStore.subscribe((state) => apply(state.position));
|
|
}, []);
|
|
|
|
// --- MediaSession: hardware media keys + OS media controls ------------------
|
|
//
|
|
// Without this, the browser's default media-key handler toggles the <audio>
|
|
// element directly, bypassing the store — causing the UI and audio to
|
|
// desync. By registering action handlers we route all media-key input
|
|
// through the store, so isPlaying stays consistent. We also publish track
|
|
// metadata so the OS "now playing" widget shows title/artist/artwork.
|
|
useEffect(() => {
|
|
if (!('mediaSession' in navigator)) return;
|
|
|
|
const store = usePlaybackStore.getState;
|
|
|
|
const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
|
|
play: () => store().play(),
|
|
pause: () => store().pause(),
|
|
previoustrack: () => store().prev(),
|
|
nexttrack: () => store().next(),
|
|
seekbackward: (details) => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
const delta = details.seekOffset ?? SEEK_INCREMENT;
|
|
audio.currentTime = Math.max(0, audio.currentTime - delta);
|
|
},
|
|
seekforward: (details) => {
|
|
const audio = audioRef.current;
|
|
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;
|
|
if (!audio || details.seekTime == null) return;
|
|
audio.currentTime = details.seekTime;
|
|
},
|
|
stop: () => {
|
|
store().pause();
|
|
store().setPosition(0);
|
|
},
|
|
};
|
|
|
|
for (const [action, handler] of Object.entries(handlers)) {
|
|
try {
|
|
navigator.mediaSession.setActionHandler(
|
|
action as MediaSessionAction,
|
|
handler ?? null,
|
|
);
|
|
} catch {
|
|
// Some actions aren't supported on every browser/OS — ignore.
|
|
}
|
|
}
|
|
|
|
// Clean up handlers on unmount so they don't outlive the engine.
|
|
return () => {
|
|
for (const action of Object.keys(handlers)) {
|
|
try {
|
|
navigator.mediaSession.setActionHandler(
|
|
action as MediaSessionAction,
|
|
null,
|
|
);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// --- MediaSession: publish metadata + playback state ------------------------
|
|
useEffect(() => {
|
|
if (!('mediaSession' in navigator)) return;
|
|
|
|
const update = (track: Track | null, isPlaying: boolean) => {
|
|
if (track) {
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: track.title || 'Unknown',
|
|
artist: track.artist || 'Unknown',
|
|
album: '',
|
|
artwork: buildArtwork(track),
|
|
});
|
|
}
|
|
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
|
};
|
|
|
|
// Publish immediately for the current state.
|
|
update(usePlaybackStore.getState().currentTrack, usePlaybackStore.getState().isPlaying);
|
|
|
|
// Subscribe to future changes of currentTrack or isPlaying.
|
|
return usePlaybackStore.subscribe((state) => {
|
|
update(state.currentTrack, state.isPlaying);
|
|
});
|
|
}, []);
|
|
|
|
return <audio ref={audioRef} hidden />;
|
|
};
|