Files
muzick/frontend/src/components/AudioEngine.tsx
T

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 />;
};