initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
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);
|
||||
// Track whether we've already recorded a completed play for the current track
|
||||
// (to avoid double-recording when both threshold crossed AND ended fires).
|
||||
const recordedCompletedRef = useRef(false);
|
||||
|
||||
// --- 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 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 = () => {
|
||||
const trackId = loadedIdRef.current;
|
||||
if (trackId && !recordedCompletedRef.current) {
|
||||
endedNaturallyRef.current = true;
|
||||
recordedCompletedRef.current = true;
|
||||
try {
|
||||
void vibeService.feedback(trackId, 'completed').catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
store().next();
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- 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 previously loaded track is changing. If it didn't end naturally and
|
||||
// hadn't crossed the completion threshold, record a skip (best-effort).
|
||||
// If it crossed the threshold OR ended naturally, record as completed.
|
||||
const prevId = loadedIdRef.current;
|
||||
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
|
||||
if (prevId) {
|
||||
try {
|
||||
if (completed) {
|
||||
recordedCompletedRef.current = true;
|
||||
void vibeService.feedback(prevId, 'completed').catch(() => {});
|
||||
} else {
|
||||
void vibeService.feedback(prevId, 'skipped').catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
recordedCompletedRef.current = false;
|
||||
loadedIdRef.current = id;
|
||||
|
||||
if (!id) {
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
return;
|
||||
}
|
||||
|
||||
audio.src = trackService.getStreamUrl(id);
|
||||
audio.load();
|
||||
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 />;
|
||||
};
|
||||
Reference in New Issue
Block a user