205 lines
5.4 KiB
TypeScript
205 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|