import { browser } from "$app/environment"; import type { Track } from "./types"; export type MediaSessionHandlers = { play: () => void; pause: () => void; next: () => void; prev: () => void; seekTo?: (timeSeconds: number) => void; seekBy?: (deltaSeconds: number) => void; }; export type MediaSessionBindings = { /** * Call whenever the current track changes. */ setTrack: (track: Track | null) => void; /** * Call on play/pause changes if you want to keep Media Session "active" state * aligned with your app (handlers still work regardless). */ setPlaybackState: (state: "none" | "paused" | "playing") => void; /** * Call reasonably often (e.g. on `timeupdate`, `loadedmetadata`, `ratechange`) * to keep lockscreen / OS UI in sync. */ updatePositionState: (args: { duration: number; position: number; playbackRate?: number; }) => void; /** * Unregisters handlers. Optional — layout-scoped players typically never unmount. */ destroy: () => void; }; function canUseMediaSession() { return ( browser && typeof navigator !== "undefined" && "mediaSession" in navigator && typeof (navigator as Navigator).mediaSession !== "undefined" ); } function canUseMetadata() { return typeof MediaMetadata !== "undefined"; } export function createMediaSessionBindings( handlers: MediaSessionHandlers, ): MediaSessionBindings { const mediaSession = canUseMediaSession() ? (navigator as Navigator).mediaSession : null; const setActionHandler = ( action: MediaSessionAction, handler: MediaSessionActionHandler | null, ) => { if (!mediaSession) return; try { mediaSession.setActionHandler(action, handler); } catch { // Some browsers throw for unsupported actions; ignore. } }; const safeNumber = (n: number) => (Number.isFinite(n) ? n : 0); const setTrack = (track: Track | null) => { if (!mediaSession) return; if (!canUseMetadata()) return; if (!track) { // Keep it simple: clear metadata. mediaSession.metadata = null; return; } function typeNumberLabel(t: Track) { const type = t.type; const n = Number(t.number ?? 0); let typeLabel: string | null = null; if (typeof type === "number") { if (type === 1) typeLabel = "OP"; else if (type === 2) typeLabel = "ED"; else if (type === 3) typeLabel = "INS"; else typeLabel = `T${type}`; } if (!typeLabel) return null; return `${typeLabel}${n ? String(n) : ""}`; } mediaSession.metadata = new MediaMetadata({ title: `${track.animeName} (${typeNumberLabel(track)}) — ${track.title}`, artist: track.artist, album: track.album, // You can add artwork later if/when you have it: // artwork: [{ src: "/some.png", sizes: "512x512", type: "image/png" }] }); }; const setPlaybackState = (state: "none" | "paused" | "playing") => { if (!mediaSession) return; try { mediaSession.playbackState = state; } catch { // Some browsers may not implement playbackState; ignore. } }; const updatePositionState = (args: { duration: number; position: number; playbackRate?: number; }) => { if (!mediaSession) return; const anySession = mediaSession as unknown as { setPositionState?: (state: { duration: number; playbackRate?: number; position: number; }) => void; }; if (typeof anySession.setPositionState !== "function") return; const duration = Math.max(0, safeNumber(args.duration)); const position = Math.max(0, safeNumber(args.position)); const playbackRate = args.playbackRate ?? 1; try { anySession.setPositionState({ duration, position: Math.min(position, duration || position), playbackRate, }); } catch { // iOS Safari and some Chromium variants can throw on invalid values. } }; const installHandlers = () => { if (!mediaSession) return; setActionHandler("play", () => handlers.play()); setActionHandler("pause", () => handlers.pause()); setActionHandler("previoustrack", () => handlers.prev()); setActionHandler("nexttrack", () => handlers.next()); // Seeking (optional) setActionHandler("seekto", (details) => { if (!handlers.seekTo) return; const d = details as MediaSessionActionDetails & { seekTime?: number }; if (typeof d.seekTime !== "number") return; handlers.seekTo(d.seekTime); }); setActionHandler("seekbackward", (details) => { const d = details as MediaSessionActionDetails & { seekOffset?: number }; const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10; if (handlers.seekBy) handlers.seekBy(-offset); else if (handlers.seekTo) handlers.seekTo(0); // fallback-ish }); setActionHandler("seekforward", (details) => { const d = details as MediaSessionActionDetails & { seekOffset?: number }; const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10; if (handlers.seekBy) handlers.seekBy(offset); }); // Stop isn't as universally supported; map to pause. setActionHandler("stop", () => handlers.pause()); }; const destroy = () => { if (!mediaSession) return; // Clear handlers we set. setActionHandler("play", null); setActionHandler("pause", null); setActionHandler("previoustrack", null); setActionHandler("nexttrack", null); setActionHandler("seekto", null); setActionHandler("seekbackward", null); setActionHandler("seekforward", null); setActionHandler("stop", null); }; installHandlers(); return { setTrack, setPlaybackState, updatePositionState, destroy, }; }