global player pt. 1

This commit is contained in:
2026-02-06 01:29:12 -08:00
parent b7f92e2355
commit 6ec6f7c0ed
10 changed files with 1991 additions and 50 deletions

View File

@@ -0,0 +1,189 @@
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;
}
mediaSession.metadata = new MediaMetadata({
title: 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,
};
}