global player pt. 1
This commit is contained in:
189
src/lib/player/media-session.ts
Normal file
189
src/lib/player/media-session.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user