'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(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( () => (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(null); const [isPlaying, setIsPlaying] = useState(false); const [shuffle, setShuffle] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const audioRef = useRef(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 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( () => ({ 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 ( {children} ); }