315 lines
8.5 KiB
TypeScript
315 lines
8.5 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { trpc } from "~/app/_trpc/Client";
|
|
import MusicMiniPlayer from "./MusicMiniPlayer";
|
|
|
|
export type PlayerTrack = {
|
|
id: string;
|
|
title: string;
|
|
/** Streaming-friendly source actually played. */
|
|
src: string;
|
|
/** Original high-quality file for the download button. */
|
|
downloadUrl: string;
|
|
downloadName: string;
|
|
};
|
|
|
|
type MusicPlayerValue = {
|
|
tracks: PlayerTrack[];
|
|
currentId: string | null;
|
|
currentTrack: PlayerTrack | null;
|
|
isPlaying: boolean;
|
|
shuffle: boolean;
|
|
currentTime: number;
|
|
duration: number;
|
|
/** Play button on a track: start it, or toggle play/pause if already current. */
|
|
toggle: (id: string) => void;
|
|
/** Global play/pause — acts on the current track (or starts the first one). */
|
|
togglePlayCurrent: () => void;
|
|
next: () => void;
|
|
previous: () => void;
|
|
toggleShuffle: () => void;
|
|
seek: (seconds: number) => void;
|
|
/** Subscribe to playback time updates (for waveform cursors). */
|
|
subscribeTime: (cb: (time: number) => void) => () => void;
|
|
};
|
|
|
|
const MusicPlayerContext = createContext<MusicPlayerValue | null>(null);
|
|
|
|
export function useMusicPlayer() {
|
|
const ctx = useContext(MusicPlayerContext);
|
|
if (!ctx) throw new Error("useMusicPlayer must be used within a MusicPlayerProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function MusicPlayerProvider({ children }: { children: ReactNode }) {
|
|
// The provider owns the playlist so playback survives navigating away from
|
|
// the music page. The query is cached, so the music page shares this request.
|
|
const { data } = trpc.music.list.useQuery();
|
|
const tracks = useMemo<PlayerTrack[]>(
|
|
() =>
|
|
(data ?? []).map((t) => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
src: t.streamUrl ?? t.fileUrl,
|
|
downloadUrl: t.fileUrl,
|
|
downloadName: t.fileName,
|
|
})),
|
|
[data],
|
|
);
|
|
|
|
const [currentId, setCurrentId] = useState<string | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [shuffle, setShuffle] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const tracksRef = useRef(tracks);
|
|
tracksRef.current = tracks;
|
|
|
|
// Lightweight pub/sub so each waveform can follow playback time without every
|
|
// player re-rendering on the audio element's frequent timeupdate.
|
|
const timeSubs = useRef<Set<(t: number) => void>>(new Set());
|
|
const subscribeTime = useCallback((cb: (t: number) => void) => {
|
|
timeSubs.current.add(cb);
|
|
return () => {
|
|
timeSubs.current.delete(cb);
|
|
};
|
|
}, []);
|
|
const emitTime = useCallback((t: number) => {
|
|
timeSubs.current.forEach((cb) => cb(t));
|
|
}, []);
|
|
|
|
const currentTrack = useMemo(
|
|
() => tracks.find((t) => t.id === currentId) ?? null,
|
|
[tracks, currentId],
|
|
);
|
|
|
|
const toggle = useCallback((id: string) => {
|
|
setCurrentId((prev) => {
|
|
if (prev === id) {
|
|
setIsPlaying((p) => !p);
|
|
return prev;
|
|
}
|
|
setIsPlaying(true);
|
|
return id;
|
|
});
|
|
}, []);
|
|
|
|
const togglePlayCurrent = useCallback(() => {
|
|
setCurrentId((prev) => {
|
|
if (prev) {
|
|
setIsPlaying((p) => !p);
|
|
return prev;
|
|
}
|
|
const first = tracksRef.current[0]?.id ?? null;
|
|
if (first) setIsPlaying(true);
|
|
return first;
|
|
});
|
|
}, []);
|
|
|
|
const step = useCallback((dir: 1 | -1) => {
|
|
const ids = tracksRef.current.map((t) => t.id);
|
|
if (ids.length === 0) return;
|
|
setCurrentId((prev) => {
|
|
let nextId: string;
|
|
if (shuffle && dir === 1 && ids.length > 1) {
|
|
do {
|
|
nextId = ids[Math.floor(Math.random() * ids.length)]!;
|
|
} while (nextId === prev);
|
|
} else {
|
|
const idx = prev ? ids.indexOf(prev) : -1;
|
|
nextId = ids[(idx + dir + ids.length) % ids.length]!;
|
|
}
|
|
setIsPlaying(true);
|
|
return nextId;
|
|
});
|
|
}, [shuffle]);
|
|
const next = useCallback(() => step(1), [step]);
|
|
const previous = useCallback(() => step(-1), [step]);
|
|
const toggleShuffle = useCallback(() => setShuffle((s) => !s), []);
|
|
|
|
const stepRef = useRef(step);
|
|
stepRef.current = step;
|
|
|
|
const seek = useCallback(
|
|
(s: number) => {
|
|
if (audioRef.current) audioRef.current.currentTime = s;
|
|
setCurrentTime(s);
|
|
emitTime(s);
|
|
},
|
|
[emitTime],
|
|
);
|
|
|
|
// Persistent audio element + listeners, created once on the client.
|
|
useEffect(() => {
|
|
const audio = new Audio();
|
|
audio.preload = "metadata";
|
|
audioRef.current = audio;
|
|
const onTime = () => {
|
|
setCurrentTime(audio.currentTime);
|
|
emitTime(audio.currentTime);
|
|
};
|
|
const onMeta = () => setDuration(audio.duration || 0);
|
|
const onEnded = () => stepRef.current(1);
|
|
audio.addEventListener("timeupdate", onTime);
|
|
audio.addEventListener("loadedmetadata", onMeta);
|
|
audio.addEventListener("ended", onEnded);
|
|
return () => {
|
|
audio.pause();
|
|
audio.removeEventListener("timeupdate", onTime);
|
|
audio.removeEventListener("loadedmetadata", onMeta);
|
|
audio.removeEventListener("ended", onEnded);
|
|
audioRef.current = null;
|
|
};
|
|
}, [emitTime]);
|
|
|
|
// Swap the source when the current track changes.
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
if (!currentTrack) {
|
|
audio.removeAttribute("src");
|
|
audio.load();
|
|
return;
|
|
}
|
|
if (audio.src !== currentTrack.src) {
|
|
audio.src = currentTrack.src;
|
|
audio.load();
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
}
|
|
}, [currentTrack]);
|
|
|
|
// Reflect the desired play state onto the element.
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio || !currentTrack) return;
|
|
if (isPlaying) audio.play().catch(() => {});
|
|
else audio.pause();
|
|
}, [isPlaying, currentTrack]);
|
|
|
|
// OS-level media controls (lock screen, notification shade, media keys, etc.)
|
|
// via the Media Session API. Wire the transport actions to our state.
|
|
useEffect(() => {
|
|
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
const ms = navigator.mediaSession;
|
|
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
|
|
["play", () => setIsPlaying(true)],
|
|
["pause", () => setIsPlaying(false)],
|
|
["previoustrack", () => stepRef.current(-1)],
|
|
["nexttrack", () => stepRef.current(1)],
|
|
[
|
|
"seekto",
|
|
(details) => {
|
|
if (typeof details.seekTime === "number") seek(details.seekTime);
|
|
},
|
|
],
|
|
[
|
|
"seekbackward",
|
|
(details) => seek((audioRef.current?.currentTime ?? 0) - (details.seekOffset ?? 10)),
|
|
],
|
|
[
|
|
"seekforward",
|
|
(details) => seek((audioRef.current?.currentTime ?? 0) + (details.seekOffset ?? 10)),
|
|
],
|
|
];
|
|
for (const [action, handler] of handlers) {
|
|
try {
|
|
ms.setActionHandler(action, handler);
|
|
} catch {
|
|
// Action unsupported by this browser — ignore.
|
|
}
|
|
}
|
|
return () => {
|
|
for (const [action] of handlers) {
|
|
try {
|
|
ms.setActionHandler(action, null);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
};
|
|
}, [seek]);
|
|
|
|
// Keep the OS-visible metadata in sync with the current track.
|
|
useEffect(() => {
|
|
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
navigator.mediaSession.metadata = currentTrack
|
|
? new MediaMetadata({
|
|
title: currentTrack.title,
|
|
artist: "Gregor Lohaus",
|
|
})
|
|
: null;
|
|
}, [currentTrack]);
|
|
|
|
// Reflect play/pause state to the OS so the right button is shown.
|
|
useEffect(() => {
|
|
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
navigator.mediaSession.playbackState = currentTrack
|
|
? isPlaying
|
|
? "playing"
|
|
: "paused"
|
|
: "none";
|
|
}, [isPlaying, currentTrack]);
|
|
|
|
// Keep the scrubber position on the OS controls in sync.
|
|
useEffect(() => {
|
|
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
|
|
if (!("setPositionState" in navigator.mediaSession)) return;
|
|
try {
|
|
if (duration > 0 && Number.isFinite(duration)) {
|
|
navigator.mediaSession.setPositionState({
|
|
duration,
|
|
position: Math.min(currentTime, duration),
|
|
playbackRate: audioRef.current?.playbackRate ?? 1,
|
|
});
|
|
} else {
|
|
navigator.mediaSession.setPositionState();
|
|
}
|
|
} catch {
|
|
// Some browsers throw on invalid state — ignore.
|
|
}
|
|
}, [currentTime, duration]);
|
|
|
|
const value = useMemo<MusicPlayerValue>(
|
|
() => ({
|
|
tracks,
|
|
currentId,
|
|
currentTrack,
|
|
isPlaying,
|
|
shuffle,
|
|
currentTime,
|
|
duration,
|
|
toggle,
|
|
togglePlayCurrent,
|
|
next,
|
|
previous,
|
|
toggleShuffle,
|
|
seek,
|
|
subscribeTime,
|
|
}),
|
|
[
|
|
tracks, currentId, currentTrack, isPlaying, shuffle, currentTime, duration,
|
|
toggle, togglePlayCurrent, next, previous, toggleShuffle, seek, subscribeTime,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<MusicPlayerContext.Provider value={value}>
|
|
{children}
|
|
<MusicMiniPlayer />
|
|
</MusicPlayerContext.Provider>
|
|
);
|
|
}
|