@scarlett-player/media-session 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.cjs +289 -0
- package/dist/index.d.cts +142 -0
- package/dist/index.d.ts +142 -0
- package/dist/index.js +264 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Hackney Enterprises Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createMediaSessionPlugin: () => createMediaSessionPlugin,
|
|
24
|
+
default: () => index_default
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
var DEFAULT_CONFIG = {
|
|
28
|
+
enablePlayPause: true,
|
|
29
|
+
enableSeek: true,
|
|
30
|
+
enableTrackNavigation: true,
|
|
31
|
+
seekOffset: 10,
|
|
32
|
+
updatePositionState: true
|
|
33
|
+
};
|
|
34
|
+
function isMediaSessionSupported() {
|
|
35
|
+
return typeof navigator !== "undefined" && "mediaSession" in navigator;
|
|
36
|
+
}
|
|
37
|
+
function createMediaSessionPlugin(config) {
|
|
38
|
+
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
39
|
+
let api = null;
|
|
40
|
+
let currentMetadata = {};
|
|
41
|
+
const updateMetadata = () => {
|
|
42
|
+
if (!isMediaSessionSupported()) return;
|
|
43
|
+
const artwork = [];
|
|
44
|
+
const artworkSources = currentMetadata.artwork || mergedConfig.defaultArtwork || [];
|
|
45
|
+
for (const art of artworkSources) {
|
|
46
|
+
artwork.push({
|
|
47
|
+
src: art.src,
|
|
48
|
+
sizes: art.sizes || "512x512",
|
|
49
|
+
type: art.type || "image/png"
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
navigator.mediaSession.metadata = new MediaMetadata({
|
|
54
|
+
title: currentMetadata.title || "Unknown",
|
|
55
|
+
artist: currentMetadata.artist || "",
|
|
56
|
+
album: currentMetadata.album || "",
|
|
57
|
+
artwork
|
|
58
|
+
});
|
|
59
|
+
} catch (e) {
|
|
60
|
+
api?.logger.warn("Failed to set media session metadata", e);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const updatePositionState = () => {
|
|
64
|
+
if (!isMediaSessionSupported() || !mergedConfig.updatePositionState) return;
|
|
65
|
+
const duration = api?.getState("duration") || 0;
|
|
66
|
+
const position = api?.getState("currentTime") || 0;
|
|
67
|
+
const playbackRate = api?.getState("playbackRate") || 1;
|
|
68
|
+
if (duration > 0 && isFinite(duration)) {
|
|
69
|
+
try {
|
|
70
|
+
navigator.mediaSession.setPositionState({
|
|
71
|
+
duration,
|
|
72
|
+
position: Math.min(position, duration),
|
|
73
|
+
playbackRate
|
|
74
|
+
});
|
|
75
|
+
} catch (e) {
|
|
76
|
+
api?.logger.debug("Failed to set position state", e);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const setupActionHandlers = () => {
|
|
81
|
+
if (!isMediaSessionSupported()) return;
|
|
82
|
+
const seekOffset = mergedConfig.seekOffset || 10;
|
|
83
|
+
if (mergedConfig.enablePlayPause) {
|
|
84
|
+
try {
|
|
85
|
+
navigator.mediaSession.setActionHandler("play", () => {
|
|
86
|
+
api?.logger.debug("Media session: play");
|
|
87
|
+
api?.emit("playback:play", void 0);
|
|
88
|
+
});
|
|
89
|
+
navigator.mediaSession.setActionHandler("pause", () => {
|
|
90
|
+
api?.logger.debug("Media session: pause");
|
|
91
|
+
api?.emit("playback:pause", void 0);
|
|
92
|
+
});
|
|
93
|
+
navigator.mediaSession.setActionHandler("stop", () => {
|
|
94
|
+
api?.logger.debug("Media session: stop");
|
|
95
|
+
api?.emit("playback:pause", void 0);
|
|
96
|
+
api?.emit("playback:seeking", { time: 0 });
|
|
97
|
+
});
|
|
98
|
+
} catch (e) {
|
|
99
|
+
api?.logger.debug("Some play/pause actions not supported", e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (mergedConfig.enableSeek) {
|
|
103
|
+
try {
|
|
104
|
+
navigator.mediaSession.setActionHandler("seekbackward", (details) => {
|
|
105
|
+
const offset = details.seekOffset || seekOffset;
|
|
106
|
+
const currentTime = api?.getState("currentTime") || 0;
|
|
107
|
+
const newTime = Math.max(0, currentTime - offset);
|
|
108
|
+
api?.logger.debug("Media session: seekbackward", { offset, newTime });
|
|
109
|
+
api?.emit("playback:seeking", { time: newTime });
|
|
110
|
+
});
|
|
111
|
+
navigator.mediaSession.setActionHandler("seekforward", (details) => {
|
|
112
|
+
const offset = details.seekOffset || seekOffset;
|
|
113
|
+
const currentTime = api?.getState("currentTime") || 0;
|
|
114
|
+
const duration = api?.getState("duration") || 0;
|
|
115
|
+
const newTime = Math.min(duration, currentTime + offset);
|
|
116
|
+
api?.logger.debug("Media session: seekforward", { offset, newTime });
|
|
117
|
+
api?.emit("playback:seeking", { time: newTime });
|
|
118
|
+
});
|
|
119
|
+
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
|
120
|
+
if (details.seekTime !== void 0) {
|
|
121
|
+
api?.logger.debug("Media session: seekto", { time: details.seekTime });
|
|
122
|
+
api?.emit("playback:seeking", { time: details.seekTime });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
} catch (e) {
|
|
126
|
+
api?.logger.debug("Some seek actions not supported", e);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (mergedConfig.enableTrackNavigation) {
|
|
130
|
+
try {
|
|
131
|
+
navigator.mediaSession.setActionHandler("previoustrack", () => {
|
|
132
|
+
api?.logger.debug("Media session: previoustrack");
|
|
133
|
+
const playlist = api?.getPlugin("playlist");
|
|
134
|
+
if (playlist) {
|
|
135
|
+
playlist.previous();
|
|
136
|
+
} else {
|
|
137
|
+
api?.emit("playback:seeking", { time: 0 });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
|
141
|
+
api?.logger.debug("Media session: nexttrack");
|
|
142
|
+
const playlist = api?.getPlugin("playlist");
|
|
143
|
+
if (playlist) {
|
|
144
|
+
playlist.next();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
} catch (e) {
|
|
148
|
+
api?.logger.debug("Track navigation not supported", e);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const clearActionHandlers = () => {
|
|
153
|
+
if (!isMediaSessionSupported()) return;
|
|
154
|
+
const actions = [
|
|
155
|
+
"play",
|
|
156
|
+
"pause",
|
|
157
|
+
"stop",
|
|
158
|
+
"seekbackward",
|
|
159
|
+
"seekforward",
|
|
160
|
+
"seekto",
|
|
161
|
+
"previoustrack",
|
|
162
|
+
"nexttrack"
|
|
163
|
+
];
|
|
164
|
+
for (const action of actions) {
|
|
165
|
+
try {
|
|
166
|
+
navigator.mediaSession.setActionHandler(action, null);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const plugin = {
|
|
172
|
+
id: "media-session",
|
|
173
|
+
name: "Media Session",
|
|
174
|
+
version: "1.0.0",
|
|
175
|
+
type: "feature",
|
|
176
|
+
description: "Media Session API integration for system-level media controls",
|
|
177
|
+
async init(pluginApi) {
|
|
178
|
+
api = pluginApi;
|
|
179
|
+
if (!isMediaSessionSupported()) {
|
|
180
|
+
api.logger.info("Media Session API not supported in this browser");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
api.logger.info("Media Session plugin initialized");
|
|
184
|
+
setupActionHandlers();
|
|
185
|
+
const unsubPlay = api.on("playback:play", () => {
|
|
186
|
+
if (isMediaSessionSupported()) {
|
|
187
|
+
navigator.mediaSession.playbackState = "playing";
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
const unsubPause = api.on("playback:pause", () => {
|
|
191
|
+
if (isMediaSessionSupported()) {
|
|
192
|
+
navigator.mediaSession.playbackState = "paused";
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const unsubEnded = api.on("playback:ended", () => {
|
|
196
|
+
if (isMediaSessionSupported()) {
|
|
197
|
+
navigator.mediaSession.playbackState = "none";
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
let lastPositionUpdate = 0;
|
|
201
|
+
const unsubTimeUpdate = api.on("playback:timeupdate", () => {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
if (now - lastPositionUpdate >= 1e3) {
|
|
204
|
+
lastPositionUpdate = now;
|
|
205
|
+
updatePositionState();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
const unsubMetadata = api.on("media:loadedmetadata", () => {
|
|
209
|
+
updatePositionState();
|
|
210
|
+
});
|
|
211
|
+
const unsubState = api.subscribeToState((event) => {
|
|
212
|
+
if (event.key === "title" && typeof event.value === "string") {
|
|
213
|
+
currentMetadata.title = event.value;
|
|
214
|
+
updateMetadata();
|
|
215
|
+
} else if (event.key === "poster" && typeof event.value === "string") {
|
|
216
|
+
currentMetadata.artwork = [{ src: event.value, sizes: "512x512" }];
|
|
217
|
+
updateMetadata();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
const unsubPlaylist = api.on("playlist:change", (payload) => {
|
|
221
|
+
if (payload?.track) {
|
|
222
|
+
const track = payload.track;
|
|
223
|
+
currentMetadata = {
|
|
224
|
+
title: track.title,
|
|
225
|
+
artist: track.artist,
|
|
226
|
+
album: track.album,
|
|
227
|
+
artwork: track.artwork ? [{ src: track.artwork, sizes: "512x512" }] : void 0
|
|
228
|
+
};
|
|
229
|
+
updateMetadata();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
api.onDestroy(() => {
|
|
233
|
+
unsubPlay();
|
|
234
|
+
unsubPause();
|
|
235
|
+
unsubEnded();
|
|
236
|
+
unsubTimeUpdate();
|
|
237
|
+
unsubMetadata();
|
|
238
|
+
unsubState();
|
|
239
|
+
unsubPlaylist();
|
|
240
|
+
clearActionHandlers();
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
async destroy() {
|
|
244
|
+
api?.logger.info("Media Session plugin destroying");
|
|
245
|
+
clearActionHandlers();
|
|
246
|
+
if (isMediaSessionSupported()) {
|
|
247
|
+
navigator.mediaSession.metadata = null;
|
|
248
|
+
navigator.mediaSession.playbackState = "none";
|
|
249
|
+
}
|
|
250
|
+
api = null;
|
|
251
|
+
},
|
|
252
|
+
isSupported() {
|
|
253
|
+
return isMediaSessionSupported();
|
|
254
|
+
},
|
|
255
|
+
setMetadata(metadata) {
|
|
256
|
+
currentMetadata = { ...currentMetadata, ...metadata };
|
|
257
|
+
updateMetadata();
|
|
258
|
+
},
|
|
259
|
+
setPlaybackState(state) {
|
|
260
|
+
if (isMediaSessionSupported()) {
|
|
261
|
+
navigator.mediaSession.playbackState = state;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
setPositionState(state) {
|
|
265
|
+
if (isMediaSessionSupported()) {
|
|
266
|
+
try {
|
|
267
|
+
navigator.mediaSession.setPositionState(state);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
api?.logger.debug("Failed to set position state", e);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
setActionHandler(action, handler) {
|
|
274
|
+
if (isMediaSessionSupported()) {
|
|
275
|
+
try {
|
|
276
|
+
navigator.mediaSession.setActionHandler(action, handler);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
api?.logger.debug(`Action ${action} not supported`, e);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
return plugin;
|
|
284
|
+
}
|
|
285
|
+
var index_default = createMediaSessionPlugin;
|
|
286
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
287
|
+
0 && (module.exports = {
|
|
288
|
+
createMediaSessionPlugin
|
|
289
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Plugin } from '@scarlett-player/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Media Session Plugin Types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Media metadata for the session
|
|
9
|
+
*/
|
|
10
|
+
interface MediaSessionMetadata {
|
|
11
|
+
/** Track/video title */
|
|
12
|
+
title?: string;
|
|
13
|
+
/** Artist name */
|
|
14
|
+
artist?: string;
|
|
15
|
+
/** Album name */
|
|
16
|
+
album?: string;
|
|
17
|
+
/** Artwork URLs (multiple sizes for different displays) */
|
|
18
|
+
artwork?: MediaSessionArtwork[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Artwork definition
|
|
22
|
+
*/
|
|
23
|
+
interface MediaSessionArtwork {
|
|
24
|
+
/** Image URL */
|
|
25
|
+
src: string;
|
|
26
|
+
/** Image size (e.g., '96x96', '256x256', '512x512') */
|
|
27
|
+
sizes?: string;
|
|
28
|
+
/** MIME type */
|
|
29
|
+
type?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Media session action handlers
|
|
33
|
+
*/
|
|
34
|
+
interface MediaSessionActions {
|
|
35
|
+
/** Play action */
|
|
36
|
+
play?: () => void;
|
|
37
|
+
/** Pause action */
|
|
38
|
+
pause?: () => void;
|
|
39
|
+
/** Stop action */
|
|
40
|
+
stop?: () => void;
|
|
41
|
+
/** Seek backward action */
|
|
42
|
+
seekbackward?: (details: {
|
|
43
|
+
seekOffset?: number;
|
|
44
|
+
}) => void;
|
|
45
|
+
/** Seek forward action */
|
|
46
|
+
seekforward?: (details: {
|
|
47
|
+
seekOffset?: number;
|
|
48
|
+
}) => void;
|
|
49
|
+
/** Seek to position action */
|
|
50
|
+
seekto?: (details: {
|
|
51
|
+
seekTime: number;
|
|
52
|
+
fastSeek?: boolean;
|
|
53
|
+
}) => void;
|
|
54
|
+
/** Previous track action */
|
|
55
|
+
previoustrack?: () => void;
|
|
56
|
+
/** Next track action */
|
|
57
|
+
nexttrack?: () => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Media session plugin configuration
|
|
61
|
+
*/
|
|
62
|
+
interface MediaSessionPluginConfig {
|
|
63
|
+
/** Enable play/pause actions (default: true) */
|
|
64
|
+
enablePlayPause?: boolean;
|
|
65
|
+
/** Enable seek actions (default: true) */
|
|
66
|
+
enableSeek?: boolean;
|
|
67
|
+
/** Enable previous/next track actions (default: true) */
|
|
68
|
+
enableTrackNavigation?: boolean;
|
|
69
|
+
/** Seek offset in seconds for seekbackward/seekforward (default: 10) */
|
|
70
|
+
seekOffset?: number;
|
|
71
|
+
/** Default artwork to use when none provided */
|
|
72
|
+
defaultArtwork?: MediaSessionArtwork[];
|
|
73
|
+
/** Update position state on timeupdate (default: true) */
|
|
74
|
+
updatePositionState?: boolean;
|
|
75
|
+
/** Index signature for PluginConfig compatibility */
|
|
76
|
+
[key: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Media session plugin interface
|
|
80
|
+
*/
|
|
81
|
+
interface IMediaSessionPlugin extends Plugin<MediaSessionPluginConfig> {
|
|
82
|
+
/**
|
|
83
|
+
* Check if Media Session API is supported
|
|
84
|
+
*/
|
|
85
|
+
isSupported(): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Update media metadata
|
|
88
|
+
*/
|
|
89
|
+
setMetadata(metadata: MediaSessionMetadata): void;
|
|
90
|
+
/**
|
|
91
|
+
* Update playback state
|
|
92
|
+
*/
|
|
93
|
+
setPlaybackState(state: 'none' | 'paused' | 'playing'): void;
|
|
94
|
+
/**
|
|
95
|
+
* Update position state (for seek bar in system UI)
|
|
96
|
+
*/
|
|
97
|
+
setPositionState(state: {
|
|
98
|
+
duration: number;
|
|
99
|
+
position: number;
|
|
100
|
+
playbackRate: number;
|
|
101
|
+
}): void;
|
|
102
|
+
/**
|
|
103
|
+
* Set a custom action handler
|
|
104
|
+
*/
|
|
105
|
+
setActionHandler(action: keyof MediaSessionActions, handler: (() => void) | null): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Media Session Plugin for Scarlett Player
|
|
110
|
+
*
|
|
111
|
+
* Integrates with the browser's Media Session API for:
|
|
112
|
+
* - Lock screen controls on mobile devices
|
|
113
|
+
* - Notification center playback controls
|
|
114
|
+
* - Hardware media key support (keyboard play/pause/next/prev)
|
|
115
|
+
* - Album art and track info in system media UI
|
|
116
|
+
* - Seek bar with position state
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a Media Session Plugin instance.
|
|
121
|
+
*
|
|
122
|
+
* @param config - Plugin configuration
|
|
123
|
+
* @returns Media Session Plugin instance
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { createMediaSessionPlugin } from '@scarlett-player/media-session';
|
|
128
|
+
*
|
|
129
|
+
* const player = new ScarlettPlayer({
|
|
130
|
+
* container: document.getElementById('player'),
|
|
131
|
+
* plugins: [
|
|
132
|
+
* createMediaSessionPlugin({
|
|
133
|
+
* seekOffset: 15,
|
|
134
|
+
* defaultArtwork: [{ src: '/default-artwork.png', sizes: '512x512' }],
|
|
135
|
+
* }),
|
|
136
|
+
* ],
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
declare function createMediaSessionPlugin(config?: Partial<MediaSessionPluginConfig>): IMediaSessionPlugin;
|
|
141
|
+
|
|
142
|
+
export { type IMediaSessionPlugin, type MediaSessionArtwork, type MediaSessionMetadata, type MediaSessionPluginConfig, createMediaSessionPlugin, createMediaSessionPlugin as default };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Plugin } from '@scarlett-player/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Media Session Plugin Types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Media metadata for the session
|
|
9
|
+
*/
|
|
10
|
+
interface MediaSessionMetadata {
|
|
11
|
+
/** Track/video title */
|
|
12
|
+
title?: string;
|
|
13
|
+
/** Artist name */
|
|
14
|
+
artist?: string;
|
|
15
|
+
/** Album name */
|
|
16
|
+
album?: string;
|
|
17
|
+
/** Artwork URLs (multiple sizes for different displays) */
|
|
18
|
+
artwork?: MediaSessionArtwork[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Artwork definition
|
|
22
|
+
*/
|
|
23
|
+
interface MediaSessionArtwork {
|
|
24
|
+
/** Image URL */
|
|
25
|
+
src: string;
|
|
26
|
+
/** Image size (e.g., '96x96', '256x256', '512x512') */
|
|
27
|
+
sizes?: string;
|
|
28
|
+
/** MIME type */
|
|
29
|
+
type?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Media session action handlers
|
|
33
|
+
*/
|
|
34
|
+
interface MediaSessionActions {
|
|
35
|
+
/** Play action */
|
|
36
|
+
play?: () => void;
|
|
37
|
+
/** Pause action */
|
|
38
|
+
pause?: () => void;
|
|
39
|
+
/** Stop action */
|
|
40
|
+
stop?: () => void;
|
|
41
|
+
/** Seek backward action */
|
|
42
|
+
seekbackward?: (details: {
|
|
43
|
+
seekOffset?: number;
|
|
44
|
+
}) => void;
|
|
45
|
+
/** Seek forward action */
|
|
46
|
+
seekforward?: (details: {
|
|
47
|
+
seekOffset?: number;
|
|
48
|
+
}) => void;
|
|
49
|
+
/** Seek to position action */
|
|
50
|
+
seekto?: (details: {
|
|
51
|
+
seekTime: number;
|
|
52
|
+
fastSeek?: boolean;
|
|
53
|
+
}) => void;
|
|
54
|
+
/** Previous track action */
|
|
55
|
+
previoustrack?: () => void;
|
|
56
|
+
/** Next track action */
|
|
57
|
+
nexttrack?: () => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Media session plugin configuration
|
|
61
|
+
*/
|
|
62
|
+
interface MediaSessionPluginConfig {
|
|
63
|
+
/** Enable play/pause actions (default: true) */
|
|
64
|
+
enablePlayPause?: boolean;
|
|
65
|
+
/** Enable seek actions (default: true) */
|
|
66
|
+
enableSeek?: boolean;
|
|
67
|
+
/** Enable previous/next track actions (default: true) */
|
|
68
|
+
enableTrackNavigation?: boolean;
|
|
69
|
+
/** Seek offset in seconds for seekbackward/seekforward (default: 10) */
|
|
70
|
+
seekOffset?: number;
|
|
71
|
+
/** Default artwork to use when none provided */
|
|
72
|
+
defaultArtwork?: MediaSessionArtwork[];
|
|
73
|
+
/** Update position state on timeupdate (default: true) */
|
|
74
|
+
updatePositionState?: boolean;
|
|
75
|
+
/** Index signature for PluginConfig compatibility */
|
|
76
|
+
[key: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Media session plugin interface
|
|
80
|
+
*/
|
|
81
|
+
interface IMediaSessionPlugin extends Plugin<MediaSessionPluginConfig> {
|
|
82
|
+
/**
|
|
83
|
+
* Check if Media Session API is supported
|
|
84
|
+
*/
|
|
85
|
+
isSupported(): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Update media metadata
|
|
88
|
+
*/
|
|
89
|
+
setMetadata(metadata: MediaSessionMetadata): void;
|
|
90
|
+
/**
|
|
91
|
+
* Update playback state
|
|
92
|
+
*/
|
|
93
|
+
setPlaybackState(state: 'none' | 'paused' | 'playing'): void;
|
|
94
|
+
/**
|
|
95
|
+
* Update position state (for seek bar in system UI)
|
|
96
|
+
*/
|
|
97
|
+
setPositionState(state: {
|
|
98
|
+
duration: number;
|
|
99
|
+
position: number;
|
|
100
|
+
playbackRate: number;
|
|
101
|
+
}): void;
|
|
102
|
+
/**
|
|
103
|
+
* Set a custom action handler
|
|
104
|
+
*/
|
|
105
|
+
setActionHandler(action: keyof MediaSessionActions, handler: (() => void) | null): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Media Session Plugin for Scarlett Player
|
|
110
|
+
*
|
|
111
|
+
* Integrates with the browser's Media Session API for:
|
|
112
|
+
* - Lock screen controls on mobile devices
|
|
113
|
+
* - Notification center playback controls
|
|
114
|
+
* - Hardware media key support (keyboard play/pause/next/prev)
|
|
115
|
+
* - Album art and track info in system media UI
|
|
116
|
+
* - Seek bar with position state
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a Media Session Plugin instance.
|
|
121
|
+
*
|
|
122
|
+
* @param config - Plugin configuration
|
|
123
|
+
* @returns Media Session Plugin instance
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { createMediaSessionPlugin } from '@scarlett-player/media-session';
|
|
128
|
+
*
|
|
129
|
+
* const player = new ScarlettPlayer({
|
|
130
|
+
* container: document.getElementById('player'),
|
|
131
|
+
* plugins: [
|
|
132
|
+
* createMediaSessionPlugin({
|
|
133
|
+
* seekOffset: 15,
|
|
134
|
+
* defaultArtwork: [{ src: '/default-artwork.png', sizes: '512x512' }],
|
|
135
|
+
* }),
|
|
136
|
+
* ],
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
declare function createMediaSessionPlugin(config?: Partial<MediaSessionPluginConfig>): IMediaSessionPlugin;
|
|
141
|
+
|
|
142
|
+
export { type IMediaSessionPlugin, type MediaSessionArtwork, type MediaSessionMetadata, type MediaSessionPluginConfig, createMediaSessionPlugin, createMediaSessionPlugin as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var DEFAULT_CONFIG = {
|
|
3
|
+
enablePlayPause: true,
|
|
4
|
+
enableSeek: true,
|
|
5
|
+
enableTrackNavigation: true,
|
|
6
|
+
seekOffset: 10,
|
|
7
|
+
updatePositionState: true
|
|
8
|
+
};
|
|
9
|
+
function isMediaSessionSupported() {
|
|
10
|
+
return typeof navigator !== "undefined" && "mediaSession" in navigator;
|
|
11
|
+
}
|
|
12
|
+
function createMediaSessionPlugin(config) {
|
|
13
|
+
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
14
|
+
let api = null;
|
|
15
|
+
let currentMetadata = {};
|
|
16
|
+
const updateMetadata = () => {
|
|
17
|
+
if (!isMediaSessionSupported()) return;
|
|
18
|
+
const artwork = [];
|
|
19
|
+
const artworkSources = currentMetadata.artwork || mergedConfig.defaultArtwork || [];
|
|
20
|
+
for (const art of artworkSources) {
|
|
21
|
+
artwork.push({
|
|
22
|
+
src: art.src,
|
|
23
|
+
sizes: art.sizes || "512x512",
|
|
24
|
+
type: art.type || "image/png"
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
navigator.mediaSession.metadata = new MediaMetadata({
|
|
29
|
+
title: currentMetadata.title || "Unknown",
|
|
30
|
+
artist: currentMetadata.artist || "",
|
|
31
|
+
album: currentMetadata.album || "",
|
|
32
|
+
artwork
|
|
33
|
+
});
|
|
34
|
+
} catch (e) {
|
|
35
|
+
api?.logger.warn("Failed to set media session metadata", e);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const updatePositionState = () => {
|
|
39
|
+
if (!isMediaSessionSupported() || !mergedConfig.updatePositionState) return;
|
|
40
|
+
const duration = api?.getState("duration") || 0;
|
|
41
|
+
const position = api?.getState("currentTime") || 0;
|
|
42
|
+
const playbackRate = api?.getState("playbackRate") || 1;
|
|
43
|
+
if (duration > 0 && isFinite(duration)) {
|
|
44
|
+
try {
|
|
45
|
+
navigator.mediaSession.setPositionState({
|
|
46
|
+
duration,
|
|
47
|
+
position: Math.min(position, duration),
|
|
48
|
+
playbackRate
|
|
49
|
+
});
|
|
50
|
+
} catch (e) {
|
|
51
|
+
api?.logger.debug("Failed to set position state", e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const setupActionHandlers = () => {
|
|
56
|
+
if (!isMediaSessionSupported()) return;
|
|
57
|
+
const seekOffset = mergedConfig.seekOffset || 10;
|
|
58
|
+
if (mergedConfig.enablePlayPause) {
|
|
59
|
+
try {
|
|
60
|
+
navigator.mediaSession.setActionHandler("play", () => {
|
|
61
|
+
api?.logger.debug("Media session: play");
|
|
62
|
+
api?.emit("playback:play", void 0);
|
|
63
|
+
});
|
|
64
|
+
navigator.mediaSession.setActionHandler("pause", () => {
|
|
65
|
+
api?.logger.debug("Media session: pause");
|
|
66
|
+
api?.emit("playback:pause", void 0);
|
|
67
|
+
});
|
|
68
|
+
navigator.mediaSession.setActionHandler("stop", () => {
|
|
69
|
+
api?.logger.debug("Media session: stop");
|
|
70
|
+
api?.emit("playback:pause", void 0);
|
|
71
|
+
api?.emit("playback:seeking", { time: 0 });
|
|
72
|
+
});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
api?.logger.debug("Some play/pause actions not supported", e);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (mergedConfig.enableSeek) {
|
|
78
|
+
try {
|
|
79
|
+
navigator.mediaSession.setActionHandler("seekbackward", (details) => {
|
|
80
|
+
const offset = details.seekOffset || seekOffset;
|
|
81
|
+
const currentTime = api?.getState("currentTime") || 0;
|
|
82
|
+
const newTime = Math.max(0, currentTime - offset);
|
|
83
|
+
api?.logger.debug("Media session: seekbackward", { offset, newTime });
|
|
84
|
+
api?.emit("playback:seeking", { time: newTime });
|
|
85
|
+
});
|
|
86
|
+
navigator.mediaSession.setActionHandler("seekforward", (details) => {
|
|
87
|
+
const offset = details.seekOffset || seekOffset;
|
|
88
|
+
const currentTime = api?.getState("currentTime") || 0;
|
|
89
|
+
const duration = api?.getState("duration") || 0;
|
|
90
|
+
const newTime = Math.min(duration, currentTime + offset);
|
|
91
|
+
api?.logger.debug("Media session: seekforward", { offset, newTime });
|
|
92
|
+
api?.emit("playback:seeking", { time: newTime });
|
|
93
|
+
});
|
|
94
|
+
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
|
95
|
+
if (details.seekTime !== void 0) {
|
|
96
|
+
api?.logger.debug("Media session: seekto", { time: details.seekTime });
|
|
97
|
+
api?.emit("playback:seeking", { time: details.seekTime });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
} catch (e) {
|
|
101
|
+
api?.logger.debug("Some seek actions not supported", e);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (mergedConfig.enableTrackNavigation) {
|
|
105
|
+
try {
|
|
106
|
+
navigator.mediaSession.setActionHandler("previoustrack", () => {
|
|
107
|
+
api?.logger.debug("Media session: previoustrack");
|
|
108
|
+
const playlist = api?.getPlugin("playlist");
|
|
109
|
+
if (playlist) {
|
|
110
|
+
playlist.previous();
|
|
111
|
+
} else {
|
|
112
|
+
api?.emit("playback:seeking", { time: 0 });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
|
116
|
+
api?.logger.debug("Media session: nexttrack");
|
|
117
|
+
const playlist = api?.getPlugin("playlist");
|
|
118
|
+
if (playlist) {
|
|
119
|
+
playlist.next();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
} catch (e) {
|
|
123
|
+
api?.logger.debug("Track navigation not supported", e);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const clearActionHandlers = () => {
|
|
128
|
+
if (!isMediaSessionSupported()) return;
|
|
129
|
+
const actions = [
|
|
130
|
+
"play",
|
|
131
|
+
"pause",
|
|
132
|
+
"stop",
|
|
133
|
+
"seekbackward",
|
|
134
|
+
"seekforward",
|
|
135
|
+
"seekto",
|
|
136
|
+
"previoustrack",
|
|
137
|
+
"nexttrack"
|
|
138
|
+
];
|
|
139
|
+
for (const action of actions) {
|
|
140
|
+
try {
|
|
141
|
+
navigator.mediaSession.setActionHandler(action, null);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const plugin = {
|
|
147
|
+
id: "media-session",
|
|
148
|
+
name: "Media Session",
|
|
149
|
+
version: "1.0.0",
|
|
150
|
+
type: "feature",
|
|
151
|
+
description: "Media Session API integration for system-level media controls",
|
|
152
|
+
async init(pluginApi) {
|
|
153
|
+
api = pluginApi;
|
|
154
|
+
if (!isMediaSessionSupported()) {
|
|
155
|
+
api.logger.info("Media Session API not supported in this browser");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
api.logger.info("Media Session plugin initialized");
|
|
159
|
+
setupActionHandlers();
|
|
160
|
+
const unsubPlay = api.on("playback:play", () => {
|
|
161
|
+
if (isMediaSessionSupported()) {
|
|
162
|
+
navigator.mediaSession.playbackState = "playing";
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
const unsubPause = api.on("playback:pause", () => {
|
|
166
|
+
if (isMediaSessionSupported()) {
|
|
167
|
+
navigator.mediaSession.playbackState = "paused";
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const unsubEnded = api.on("playback:ended", () => {
|
|
171
|
+
if (isMediaSessionSupported()) {
|
|
172
|
+
navigator.mediaSession.playbackState = "none";
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
let lastPositionUpdate = 0;
|
|
176
|
+
const unsubTimeUpdate = api.on("playback:timeupdate", () => {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
if (now - lastPositionUpdate >= 1e3) {
|
|
179
|
+
lastPositionUpdate = now;
|
|
180
|
+
updatePositionState();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const unsubMetadata = api.on("media:loadedmetadata", () => {
|
|
184
|
+
updatePositionState();
|
|
185
|
+
});
|
|
186
|
+
const unsubState = api.subscribeToState((event) => {
|
|
187
|
+
if (event.key === "title" && typeof event.value === "string") {
|
|
188
|
+
currentMetadata.title = event.value;
|
|
189
|
+
updateMetadata();
|
|
190
|
+
} else if (event.key === "poster" && typeof event.value === "string") {
|
|
191
|
+
currentMetadata.artwork = [{ src: event.value, sizes: "512x512" }];
|
|
192
|
+
updateMetadata();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const unsubPlaylist = api.on("playlist:change", (payload) => {
|
|
196
|
+
if (payload?.track) {
|
|
197
|
+
const track = payload.track;
|
|
198
|
+
currentMetadata = {
|
|
199
|
+
title: track.title,
|
|
200
|
+
artist: track.artist,
|
|
201
|
+
album: track.album,
|
|
202
|
+
artwork: track.artwork ? [{ src: track.artwork, sizes: "512x512" }] : void 0
|
|
203
|
+
};
|
|
204
|
+
updateMetadata();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
api.onDestroy(() => {
|
|
208
|
+
unsubPlay();
|
|
209
|
+
unsubPause();
|
|
210
|
+
unsubEnded();
|
|
211
|
+
unsubTimeUpdate();
|
|
212
|
+
unsubMetadata();
|
|
213
|
+
unsubState();
|
|
214
|
+
unsubPlaylist();
|
|
215
|
+
clearActionHandlers();
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
async destroy() {
|
|
219
|
+
api?.logger.info("Media Session plugin destroying");
|
|
220
|
+
clearActionHandlers();
|
|
221
|
+
if (isMediaSessionSupported()) {
|
|
222
|
+
navigator.mediaSession.metadata = null;
|
|
223
|
+
navigator.mediaSession.playbackState = "none";
|
|
224
|
+
}
|
|
225
|
+
api = null;
|
|
226
|
+
},
|
|
227
|
+
isSupported() {
|
|
228
|
+
return isMediaSessionSupported();
|
|
229
|
+
},
|
|
230
|
+
setMetadata(metadata) {
|
|
231
|
+
currentMetadata = { ...currentMetadata, ...metadata };
|
|
232
|
+
updateMetadata();
|
|
233
|
+
},
|
|
234
|
+
setPlaybackState(state) {
|
|
235
|
+
if (isMediaSessionSupported()) {
|
|
236
|
+
navigator.mediaSession.playbackState = state;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
setPositionState(state) {
|
|
240
|
+
if (isMediaSessionSupported()) {
|
|
241
|
+
try {
|
|
242
|
+
navigator.mediaSession.setPositionState(state);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
api?.logger.debug("Failed to set position state", e);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
setActionHandler(action, handler) {
|
|
249
|
+
if (isMediaSessionSupported()) {
|
|
250
|
+
try {
|
|
251
|
+
navigator.mediaSession.setActionHandler(action, handler);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
api?.logger.debug(`Action ${action} not supported`, e);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
return plugin;
|
|
259
|
+
}
|
|
260
|
+
var index_default = createMediaSessionPlugin;
|
|
261
|
+
export {
|
|
262
|
+
createMediaSessionPlugin,
|
|
263
|
+
index_default as default
|
|
264
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scarlett-player/media-session",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Media Session Plugin for Scarlett Player - Lock screen controls, media keys, and system media UI integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@scarlett-player/core": "^0.2.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"tsup": "^8.0.0",
|
|
29
|
+
"typescript": "^5.3.0",
|
|
30
|
+
"vitest": "^1.6.0",
|
|
31
|
+
"jsdom": "^24.0.0",
|
|
32
|
+
"@scarlett-player/core": "0.2.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"video",
|
|
36
|
+
"audio",
|
|
37
|
+
"player",
|
|
38
|
+
"media-session",
|
|
39
|
+
"lock-screen",
|
|
40
|
+
"media-keys",
|
|
41
|
+
"notifications",
|
|
42
|
+
"scarlett"
|
|
43
|
+
],
|
|
44
|
+
"author": "The Stream Platform",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/Hackney-Enterprises-Inc/scarlett-player.git",
|
|
49
|
+
"directory": "packages/plugins/media-session"
|
|
50
|
+
},
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/Hackney-Enterprises-Inc/scarlett-player/issues"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://scarlettplayer.com",
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
57
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
58
|
+
"test": "vitest --run",
|
|
59
|
+
"test:watch": "vitest",
|
|
60
|
+
"typecheck": "tsc --noEmit"
|
|
61
|
+
}
|
|
62
|
+
}
|