@scarlett-player/native 1.8.0 → 1.9.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/README.md CHANGED
@@ -25,7 +25,7 @@ const player = await createPlayer({
25
25
 
26
26
  Video:
27
27
 
28
- - **MP4** - H.264/AAC (most common)
28
+ - **MP4 / M4V** - H.264/AAC (most common)
29
29
  - **WebM** - VP8/VP9/Opus
30
30
  - **MOV** - QuickTime (H.264/AAC)
31
31
  - **MKV** - Matroska (browser support varies)
@@ -33,15 +33,20 @@ Video:
33
33
 
34
34
  Audio:
35
35
 
36
- - **MP3**, **WAV**, **OGG**, **FLAC**, **AAC**, **M4A**, **Opus** (pairs with
37
- `@scarlett-player/audio-ui` and `@scarlett-player/media-session` for a full
38
- audio player)
36
+ - **MP3**, **WAV**, **OGG**, **FLAC**, **AAC**, **M4A**, **Opus**, **WebA**
37
+ (pairs with `@scarlett-player/audio-ui` and `@scarlett-player/media-session`
38
+ for a full audio player)
39
+
40
+ The plugin claims a source by extension, then asks the browser whether it can
41
+ play that MIME type, so an `.mkv` in a browser without Matroska support is
42
+ declined rather than played into a black frame.
39
43
 
40
44
  ## Configuration
41
45
 
42
46
  ```typescript
43
47
  createNativePlugin({
44
- preload: 'metadata', // 'none' | 'metadata' | 'auto'
48
+ preload: 'metadata', // 'none' | 'metadata' | 'auto'
49
+ loadTimeoutMs: 30000, // Load watchdog; 0 disables
45
50
  });
46
51
  ```
47
52
 
package/dist/index.cjs CHANGED
@@ -27,12 +27,18 @@ module.exports = __toCommonJS(index_exports);
27
27
  var import_core = require("@scarlett-player/core");
28
28
 
29
29
  // src/version.ts
30
- var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
30
+ var PKG_VERSION = true ? "1.9.0" : "0.0.0-dev";
31
31
 
32
32
  // src/index.ts
33
33
  var VIDEO_EXTENSIONS = ["mp4", "webm", "mov", "mkv", "ogv", "m4v"];
34
34
  var AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "weba"];
35
35
  var SUPPORTED_EXTENSIONS = [...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS];
36
+ var MEDIA_ERR = {
37
+ ABORTED: 1,
38
+ NETWORK: 2,
39
+ DECODE: 3,
40
+ SRC_NOT_SUPPORTED: 4
41
+ };
36
42
  var MIME_TYPES = {
37
43
  // Video
38
44
  mp4: "video/mp4",
@@ -59,6 +65,10 @@ function createNativePlugin(config) {
59
65
  let cleanupEvents = null;
60
66
  let derived_title = null;
61
67
  let is_audio_source = false;
68
+ let isCorePlayRequested = false;
69
+ let loadSession = 0;
70
+ let abortPendingLoad = null;
71
+ let has_parsed_source = false;
62
72
  const getExtension = (src) => {
63
73
  try {
64
74
  const url = new URL(src, window.location.href);
@@ -106,11 +116,33 @@ function createNativePlugin(config) {
106
116
  api?.container.appendChild(video);
107
117
  return video;
108
118
  };
119
+ const classifyMediaError = (error) => {
120
+ switch (error?.code) {
121
+ case MEDIA_ERR.ABORTED:
122
+ return { code: import_core.ErrorCode.PLAYBACK_FAILED, message: "Playback aborted" };
123
+ case MEDIA_ERR.NETWORK:
124
+ return { code: import_core.ErrorCode.MEDIA_NETWORK_ERROR, message: "Network error" };
125
+ case MEDIA_ERR.DECODE:
126
+ return {
127
+ code: import_core.ErrorCode.MEDIA_DECODE_ERROR,
128
+ message: "Decode error - format may not be supported"
129
+ };
130
+ case MEDIA_ERR.SRC_NOT_SUPPORTED:
131
+ return has_parsed_source ? { code: import_core.ErrorCode.MEDIA_NETWORK_ERROR, message: "Network error" } : { code: import_core.ErrorCode.SOURCE_LOAD_FAILED, message: "Format not supported" };
132
+ default:
133
+ return { code: import_core.ErrorCode.PLAYBACK_FAILED, message: "Unknown video error" };
134
+ }
135
+ };
109
136
  const setupEventListeners = (videoEl) => {
110
137
  const handlers = [];
138
+ const session = loadSession;
111
139
  const on = (event, handler) => {
112
- videoEl.addEventListener(event, handler);
113
- handlers.push([event, handler]);
140
+ const guarded = (event_object) => {
141
+ if (session !== loadSession) return;
142
+ handler(event_object);
143
+ };
144
+ videoEl.addEventListener(event, guarded);
145
+ handlers.push([event, guarded]);
114
146
  };
115
147
  const syncEndedFromElement = () => {
116
148
  if (videoEl.ended || !api?.getState("ended")) return;
@@ -125,14 +157,18 @@ function createNativePlugin(config) {
125
157
  api?.setState("playing", true);
126
158
  api?.setState("paused", false);
127
159
  api?.setState("playbackState", "playing");
128
- api?.emit("playback:play", void 0);
129
160
  syncEndedFromElement();
161
+ if (isCorePlayRequested) {
162
+ isCorePlayRequested = false;
163
+ } else {
164
+ api?.emit("playback:play", void 0);
165
+ }
130
166
  });
131
167
  on("pause", () => {
168
+ isCorePlayRequested = false;
132
169
  api?.setState("playing", false);
133
170
  api?.setState("paused", true);
134
171
  api?.setState("playbackState", "paused");
135
- api?.emit("playback:pause", void 0);
136
172
  });
137
173
  on("ended", () => {
138
174
  api?.setState("playing", false);
@@ -201,26 +237,10 @@ function createNativePlugin(config) {
201
237
  });
202
238
  on("error", () => {
203
239
  const error = videoEl.error;
204
- let message = "Unknown video error";
205
- if (error) {
206
- switch (error.code) {
207
- case MediaError.MEDIA_ERR_ABORTED:
208
- message = "Playback aborted";
209
- break;
210
- case MediaError.MEDIA_ERR_NETWORK:
211
- message = "Network error";
212
- break;
213
- case MediaError.MEDIA_ERR_DECODE:
214
- message = "Decode error - format may not be supported";
215
- break;
216
- case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
217
- message = "Format not supported";
218
- break;
219
- }
220
- }
221
- api?.logger.error("Video error", { code: error?.code, message });
240
+ const { code, message } = classifyMediaError(error);
241
+ api?.logger.error("Video error", { mediaErrorCode: error?.code, code, message });
222
242
  api?.emit("error", {
223
- code: import_core.ErrorCode.PLAYBACK_FAILED,
243
+ code,
224
244
  message,
225
245
  fatal: true,
226
246
  timestamp: Date.now()
@@ -283,18 +303,26 @@ function createNativePlugin(config) {
283
303
  api = pluginApi;
284
304
  api.logger.info("Native video plugin initialized");
285
305
  const unsubPlay = api.on("playback:play", async () => {
306
+ if (api?.getState("chromecastActive")) return;
286
307
  if (!video) return;
308
+ if (!video.paused) return;
287
309
  try {
310
+ isCorePlayRequested = true;
288
311
  await video.play();
289
312
  } catch (e) {
313
+ isCorePlayRequested = false;
290
314
  api?.logger.error("Play failed", e);
291
315
  }
292
316
  });
293
317
  const unsubPause = api.on("playback:pause", () => {
318
+ if (api?.getState("chromecastActive")) return;
319
+ isCorePlayRequested = false;
294
320
  video?.pause();
295
321
  });
296
322
  const unsubSeek = api.on("playback:seeking", ({ time }) => {
323
+ if (api?.getState("chromecastActive")) return;
297
324
  if (!video) return;
325
+ if (!Number.isFinite(time)) return;
298
326
  const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
299
327
  video.currentTime = clampedTime;
300
328
  });
@@ -325,6 +353,10 @@ function createNativePlugin(config) {
325
353
  },
326
354
  async destroy() {
327
355
  api?.logger.info("Native video plugin destroying");
356
+ loadSession++;
357
+ const pending = abortPendingLoad;
358
+ abortPendingLoad = null;
359
+ pending?.(new Error("Player destroyed during load"));
328
360
  cleanup();
329
361
  if (video?.parentNode) {
330
362
  video.parentNode.removeChild(video);
@@ -333,6 +365,7 @@ function createNativePlugin(config) {
333
365
  api = null;
334
366
  derived_title = null;
335
367
  is_audio_source = false;
368
+ has_parsed_source = false;
336
369
  },
337
370
  async loadSource(src) {
338
371
  if (!api) throw new Error("Plugin not initialized");
@@ -340,7 +373,12 @@ function createNativePlugin(config) {
340
373
  const mimeType = getMimeType(ext);
341
374
  const isAudio = isAudioExtension(ext);
342
375
  is_audio_source = isAudio;
343
- api.logger.info("Loading native media source", { src, mimeType, isAudio });
376
+ api.logger.info("Loading native media source", { src: (0, import_core.sanitizeUrl)(src), mimeType, isAudio });
377
+ const session = ++loadSession;
378
+ const superseded = abortPendingLoad;
379
+ abortPendingLoad = null;
380
+ superseded?.(new Error("Load superseded by a newer source"));
381
+ has_parsed_source = false;
344
382
  cleanup();
345
383
  api.setState("playbackState", "loading");
346
384
  api.setState("buffering", true);
@@ -375,9 +413,19 @@ function createNativePlugin(config) {
375
413
  clearTimeout(watchdog);
376
414
  watchdog = null;
377
415
  }
416
+ if (abortPendingLoad === abort) {
417
+ abortPendingLoad = null;
418
+ }
419
+ };
420
+ const abort = (reason) => {
421
+ settle();
422
+ reject(reason);
378
423
  };
424
+ abortPendingLoad = abort;
379
425
  const onLoaded = () => {
426
+ if (session !== loadSession) return;
380
427
  settle();
428
+ has_parsed_source = true;
381
429
  const muted = api?.getState("muted");
382
430
  const volume = api?.getState("volume");
383
431
  if (muted !== void 0) videoEl.muted = muted;
@@ -389,12 +437,14 @@ function createNativePlugin(config) {
389
437
  resolve();
390
438
  };
391
439
  const onError = () => {
440
+ if (session !== loadSession) return;
392
441
  settle();
393
442
  const error = videoEl.error;
394
443
  reject(new Error(error?.message || "Failed to load video source"));
395
444
  };
396
445
  if (load_timeout_ms > 0) {
397
446
  watchdog = setTimeout(() => {
447
+ if (session !== loadSession) return;
398
448
  settle();
399
449
  reject(new Error("Video took too long to load (network timeout)"));
400
450
  }, load_timeout_ms);
package/dist/index.js CHANGED
@@ -1,13 +1,19 @@
1
1
  // src/index.ts
2
- import { ErrorCode } from "@scarlett-player/core";
2
+ import { ErrorCode, sanitizeUrl } from "@scarlett-player/core";
3
3
 
4
4
  // src/version.ts
5
- var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
5
+ var PKG_VERSION = true ? "1.9.0" : "0.0.0-dev";
6
6
 
7
7
  // src/index.ts
8
8
  var VIDEO_EXTENSIONS = ["mp4", "webm", "mov", "mkv", "ogv", "m4v"];
9
9
  var AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "weba"];
10
10
  var SUPPORTED_EXTENSIONS = [...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS];
11
+ var MEDIA_ERR = {
12
+ ABORTED: 1,
13
+ NETWORK: 2,
14
+ DECODE: 3,
15
+ SRC_NOT_SUPPORTED: 4
16
+ };
11
17
  var MIME_TYPES = {
12
18
  // Video
13
19
  mp4: "video/mp4",
@@ -34,6 +40,10 @@ function createNativePlugin(config) {
34
40
  let cleanupEvents = null;
35
41
  let derived_title = null;
36
42
  let is_audio_source = false;
43
+ let isCorePlayRequested = false;
44
+ let loadSession = 0;
45
+ let abortPendingLoad = null;
46
+ let has_parsed_source = false;
37
47
  const getExtension = (src) => {
38
48
  try {
39
49
  const url = new URL(src, window.location.href);
@@ -81,11 +91,33 @@ function createNativePlugin(config) {
81
91
  api?.container.appendChild(video);
82
92
  return video;
83
93
  };
94
+ const classifyMediaError = (error) => {
95
+ switch (error?.code) {
96
+ case MEDIA_ERR.ABORTED:
97
+ return { code: ErrorCode.PLAYBACK_FAILED, message: "Playback aborted" };
98
+ case MEDIA_ERR.NETWORK:
99
+ return { code: ErrorCode.MEDIA_NETWORK_ERROR, message: "Network error" };
100
+ case MEDIA_ERR.DECODE:
101
+ return {
102
+ code: ErrorCode.MEDIA_DECODE_ERROR,
103
+ message: "Decode error - format may not be supported"
104
+ };
105
+ case MEDIA_ERR.SRC_NOT_SUPPORTED:
106
+ return has_parsed_source ? { code: ErrorCode.MEDIA_NETWORK_ERROR, message: "Network error" } : { code: ErrorCode.SOURCE_LOAD_FAILED, message: "Format not supported" };
107
+ default:
108
+ return { code: ErrorCode.PLAYBACK_FAILED, message: "Unknown video error" };
109
+ }
110
+ };
84
111
  const setupEventListeners = (videoEl) => {
85
112
  const handlers = [];
113
+ const session = loadSession;
86
114
  const on = (event, handler) => {
87
- videoEl.addEventListener(event, handler);
88
- handlers.push([event, handler]);
115
+ const guarded = (event_object) => {
116
+ if (session !== loadSession) return;
117
+ handler(event_object);
118
+ };
119
+ videoEl.addEventListener(event, guarded);
120
+ handlers.push([event, guarded]);
89
121
  };
90
122
  const syncEndedFromElement = () => {
91
123
  if (videoEl.ended || !api?.getState("ended")) return;
@@ -100,14 +132,18 @@ function createNativePlugin(config) {
100
132
  api?.setState("playing", true);
101
133
  api?.setState("paused", false);
102
134
  api?.setState("playbackState", "playing");
103
- api?.emit("playback:play", void 0);
104
135
  syncEndedFromElement();
136
+ if (isCorePlayRequested) {
137
+ isCorePlayRequested = false;
138
+ } else {
139
+ api?.emit("playback:play", void 0);
140
+ }
105
141
  });
106
142
  on("pause", () => {
143
+ isCorePlayRequested = false;
107
144
  api?.setState("playing", false);
108
145
  api?.setState("paused", true);
109
146
  api?.setState("playbackState", "paused");
110
- api?.emit("playback:pause", void 0);
111
147
  });
112
148
  on("ended", () => {
113
149
  api?.setState("playing", false);
@@ -176,26 +212,10 @@ function createNativePlugin(config) {
176
212
  });
177
213
  on("error", () => {
178
214
  const error = videoEl.error;
179
- let message = "Unknown video error";
180
- if (error) {
181
- switch (error.code) {
182
- case MediaError.MEDIA_ERR_ABORTED:
183
- message = "Playback aborted";
184
- break;
185
- case MediaError.MEDIA_ERR_NETWORK:
186
- message = "Network error";
187
- break;
188
- case MediaError.MEDIA_ERR_DECODE:
189
- message = "Decode error - format may not be supported";
190
- break;
191
- case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
192
- message = "Format not supported";
193
- break;
194
- }
195
- }
196
- api?.logger.error("Video error", { code: error?.code, message });
215
+ const { code, message } = classifyMediaError(error);
216
+ api?.logger.error("Video error", { mediaErrorCode: error?.code, code, message });
197
217
  api?.emit("error", {
198
- code: ErrorCode.PLAYBACK_FAILED,
218
+ code,
199
219
  message,
200
220
  fatal: true,
201
221
  timestamp: Date.now()
@@ -258,18 +278,26 @@ function createNativePlugin(config) {
258
278
  api = pluginApi;
259
279
  api.logger.info("Native video plugin initialized");
260
280
  const unsubPlay = api.on("playback:play", async () => {
281
+ if (api?.getState("chromecastActive")) return;
261
282
  if (!video) return;
283
+ if (!video.paused) return;
262
284
  try {
285
+ isCorePlayRequested = true;
263
286
  await video.play();
264
287
  } catch (e) {
288
+ isCorePlayRequested = false;
265
289
  api?.logger.error("Play failed", e);
266
290
  }
267
291
  });
268
292
  const unsubPause = api.on("playback:pause", () => {
293
+ if (api?.getState("chromecastActive")) return;
294
+ isCorePlayRequested = false;
269
295
  video?.pause();
270
296
  });
271
297
  const unsubSeek = api.on("playback:seeking", ({ time }) => {
298
+ if (api?.getState("chromecastActive")) return;
272
299
  if (!video) return;
300
+ if (!Number.isFinite(time)) return;
273
301
  const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
274
302
  video.currentTime = clampedTime;
275
303
  });
@@ -300,6 +328,10 @@ function createNativePlugin(config) {
300
328
  },
301
329
  async destroy() {
302
330
  api?.logger.info("Native video plugin destroying");
331
+ loadSession++;
332
+ const pending = abortPendingLoad;
333
+ abortPendingLoad = null;
334
+ pending?.(new Error("Player destroyed during load"));
303
335
  cleanup();
304
336
  if (video?.parentNode) {
305
337
  video.parentNode.removeChild(video);
@@ -308,6 +340,7 @@ function createNativePlugin(config) {
308
340
  api = null;
309
341
  derived_title = null;
310
342
  is_audio_source = false;
343
+ has_parsed_source = false;
311
344
  },
312
345
  async loadSource(src) {
313
346
  if (!api) throw new Error("Plugin not initialized");
@@ -315,7 +348,12 @@ function createNativePlugin(config) {
315
348
  const mimeType = getMimeType(ext);
316
349
  const isAudio = isAudioExtension(ext);
317
350
  is_audio_source = isAudio;
318
- api.logger.info("Loading native media source", { src, mimeType, isAudio });
351
+ api.logger.info("Loading native media source", { src: sanitizeUrl(src), mimeType, isAudio });
352
+ const session = ++loadSession;
353
+ const superseded = abortPendingLoad;
354
+ abortPendingLoad = null;
355
+ superseded?.(new Error("Load superseded by a newer source"));
356
+ has_parsed_source = false;
319
357
  cleanup();
320
358
  api.setState("playbackState", "loading");
321
359
  api.setState("buffering", true);
@@ -350,9 +388,19 @@ function createNativePlugin(config) {
350
388
  clearTimeout(watchdog);
351
389
  watchdog = null;
352
390
  }
391
+ if (abortPendingLoad === abort) {
392
+ abortPendingLoad = null;
393
+ }
394
+ };
395
+ const abort = (reason) => {
396
+ settle();
397
+ reject(reason);
353
398
  };
399
+ abortPendingLoad = abort;
354
400
  const onLoaded = () => {
401
+ if (session !== loadSession) return;
355
402
  settle();
403
+ has_parsed_source = true;
356
404
  const muted = api?.getState("muted");
357
405
  const volume = api?.getState("volume");
358
406
  if (muted !== void 0) videoEl.muted = muted;
@@ -364,12 +412,14 @@ function createNativePlugin(config) {
364
412
  resolve();
365
413
  };
366
414
  const onError = () => {
415
+ if (session !== loadSession) return;
367
416
  settle();
368
417
  const error = videoEl.error;
369
418
  reject(new Error(error?.message || "Failed to load video source"));
370
419
  };
371
420
  if (load_timeout_ms > 0) {
372
421
  watchdog = setTimeout(() => {
422
+ if (session !== loadSession) return;
373
423
  settle();
374
424
  reject(new Error("Video took too long to load (network timeout)"));
375
425
  }, load_timeout_ms);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/native",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Native Video Provider Plugin for Scarlett Player (MP4, WebM, MOV, MKV)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -17,13 +17,13 @@
17
17
  "dist"
18
18
  ],
19
19
  "peerDependencies": {
20
- "@scarlett-player/core": "^1.7.0"
20
+ "@scarlett-player/core": "^1.8.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "typescript": "^5.3.0",
24
24
  "tsup": "^8.0.0",
25
25
  "vitest": "^1.6.0",
26
- "@scarlett-player/core": "1.8.0"
26
+ "@scarlett-player/core": "1.9.0"
27
27
  },
28
28
  "keywords": [
29
29
  "video",