@f8team/reel-core 1.0.3 → 1.0.5

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/dist/index.cjs CHANGED
@@ -7,6 +7,7 @@ var RETRYABLE_BY_DEFAULT = {
7
7
  decode: false,
8
8
  unauthorized: false,
9
9
  unsupported: false,
10
+ "hls-fragment-loop": true,
10
11
  internal: false
11
12
  };
12
13
  function createPlayerError(input) {
@@ -213,12 +214,58 @@ function createControlsRegistry() {
213
214
  }
214
215
  return { contribute, snapshot, clear };
215
216
  }
216
- function createPluginHost({ commands, controls, store, bus }) {
217
+ function createSourceTransformRegistry() {
218
+ const transforms = /* @__PURE__ */ new Set();
219
+ function register(transform) {
220
+ transforms.add(transform);
221
+ return () => transforms.delete(transform);
222
+ }
223
+ function apply(source) {
224
+ let current = source;
225
+ for (const transform of transforms) {
226
+ current = transform(current);
227
+ }
228
+ return current;
229
+ }
230
+ return { register, apply };
231
+ }
232
+ function createMediaAttachmentRegistry() {
233
+ let current = null;
234
+ const listeners = /* @__PURE__ */ new Set();
235
+ function get() {
236
+ return current;
237
+ }
238
+ function set(attachment) {
239
+ current = attachment;
240
+ for (const listener of listeners) listener(current);
241
+ }
242
+ function subscribe(listener) {
243
+ listeners.add(listener);
244
+ if (current) listener(current);
245
+ return () => listeners.delete(listener);
246
+ }
247
+ return { get, set, subscribe };
248
+ }
249
+ function createPluginHost({
250
+ commands,
251
+ controls,
252
+ store,
253
+ bus,
254
+ sourceTransforms = createSourceTransformRegistry(),
255
+ mediaAttachments = createMediaAttachmentRegistry()
256
+ }) {
217
257
  return {
218
258
  controls: {
219
- contribute: (slot, render) => controls.contribute(slot, render)
259
+ contribute: controls.contribute
220
260
  },
221
261
  commands,
262
+ sources: {
263
+ transform: sourceTransforms.register
264
+ },
265
+ media: {
266
+ get: mediaAttachments.get,
267
+ subscribe: mediaAttachments.subscribe
268
+ },
222
269
  store,
223
270
  emit: (event, payload) => {
224
271
  bus.emit(event, payload);
@@ -227,6 +274,21 @@ function createPluginHost({ commands, controls, store, bus }) {
227
274
  }
228
275
 
229
276
  // src/util/url.ts
277
+ function areSourceDescriptorsEquivalent(previous, next) {
278
+ if (previous === next) return true;
279
+ if (!previous || !next) return false;
280
+ const previousThumbnails = previous.thumbnails;
281
+ const nextThumbnails = next.thumbnails;
282
+ return previous.src === next.src && (previous.type || "auto") === (next.type || "auto") && previous.withCredentials === next.withCredentials && previous.tracks === next.tracks && (previousThumbnails === nextThumbnails || previousThumbnails?.src === nextThumbnails?.src && previousThumbnails?.withCredentials === nextThumbnails?.withCredentials);
283
+ }
284
+ function isSafeMediaUrl(input) {
285
+ if (!input?.trim()) return false;
286
+ try {
287
+ return /^(https?|blob|data):$/.test(new URL(input, "https://reel.invalid").protocol);
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
230
292
  function looksLikeHls(input) {
231
293
  if (!input) return false;
232
294
  const lower = input.toLowerCase();
@@ -266,6 +328,54 @@ function resolveWithCredentials(policy, url) {
266
328
  }
267
329
 
268
330
  // src/sources/hls.ts
331
+ var REPEATED_FRAGMENT_LOAD_LIMIT = 4;
332
+ var MEDIA_RECOVERY_LIMIT = 1;
333
+ function makeLoadPolicy(options) {
334
+ return {
335
+ default: {
336
+ maxTimeToFirstByteMs: options.maxTimeToFirstByteMs,
337
+ maxLoadTimeMs: options.maxLoadTimeMs,
338
+ timeoutRetry: options.timeoutRetry,
339
+ errorRetry: options.errorRetry
340
+ }
341
+ };
342
+ }
343
+ function makeLinearRetry(maxNumRetry, retryDelayMs, maxRetryDelayMs) {
344
+ return {
345
+ maxNumRetry,
346
+ retryDelayMs,
347
+ maxRetryDelayMs,
348
+ backoff: "linear"
349
+ };
350
+ }
351
+ function makeHlsLoadPolicies() {
352
+ return {
353
+ manifestLoadPolicy: makeLoadPolicy({
354
+ maxTimeToFirstByteMs: 1e4,
355
+ maxLoadTimeMs: 2e4,
356
+ timeoutRetry: makeLinearRetry(1, 0, 0),
357
+ errorRetry: makeLinearRetry(1, 1e3, 8e3)
358
+ }),
359
+ playlistLoadPolicy: makeLoadPolicy({
360
+ maxTimeToFirstByteMs: 1e4,
361
+ maxLoadTimeMs: 2e4,
362
+ timeoutRetry: makeLinearRetry(1, 0, 0),
363
+ errorRetry: makeLinearRetry(2, 1e3, 8e3)
364
+ }),
365
+ keyLoadPolicy: makeLoadPolicy({
366
+ maxTimeToFirstByteMs: 8e3,
367
+ maxLoadTimeMs: 2e4,
368
+ timeoutRetry: makeLinearRetry(1, 1e3, 8e3),
369
+ errorRetry: makeLinearRetry(2, 1e3, 8e3)
370
+ }),
371
+ fragLoadPolicy: makeLoadPolicy({
372
+ maxTimeToFirstByteMs: 1e4,
373
+ maxLoadTimeMs: 3e4,
374
+ timeoutRetry: makeLinearRetry(1, 0, 0),
375
+ errorRetry: makeLinearRetry(2, 1e3, 4e3)
376
+ })
377
+ };
378
+ }
269
379
  var defaultLoad = () => {
270
380
  return import('hls.js').then((m) => m.default);
271
381
  };
@@ -290,13 +400,17 @@ var HlsLoader = class {
290
400
  this.rejectAttach = null;
291
401
  this.manifestTimeout = null;
292
402
  this.aborted = false;
403
+ this.lastLoadingFragmentKey = null;
404
+ this.repeatedFragmentLoadCount = 0;
405
+ this.repeatedFragmentErrorFired = false;
406
+ this.mediaRecoveryCount = 0;
293
407
  /** `false` idle; `'auto'` → first `LEVEL_SWITCHED` clears; otherwise await this level index. */
294
408
  this.qualityAwait = false;
295
409
  this.qualitySwitchTimer = null;
296
410
  /** Tracks consecutive menu selections so redundant "Auto → Auto" skips the spinner. */
297
411
  this.lastChosenQualityWasAuto = false;
298
412
  /**
299
- * `null` — user chose ABR auto (menu shows "Auto"). Real hls.js still exposes
413
+ * `null` - user chose ABR auto (menu shows "Auto"). Real hls.js still exposes
300
414
  * `currentLevel >= 0` for the playing rendition; we must not mirror that into UI state.
301
415
  */
302
416
  this.manualLevelIndex = null;
@@ -352,6 +466,7 @@ var HlsLoader = class {
352
466
  attachWithHlsJs(Hls, video, source) {
353
467
  const hls = new Hls({
354
468
  enableWorker: true,
469
+ ...makeHlsLoadPolicies(),
355
470
  xhrSetup: (xhr, url) => this.installXhrWatcher(xhr, url, source)
356
471
  });
357
472
  this.hls = hls;
@@ -372,27 +487,44 @@ var HlsLoader = class {
372
487
  this.onLevelEventuallyStabilized(...args);
373
488
  this.refreshActiveQuality();
374
489
  };
490
+ const onFragmentLoading = (...args) => {
491
+ this.onFragmentLoading(...args);
492
+ };
493
+ const onFragmentBuffered = (...args) => {
494
+ this.resetFragmentLoopGuardFromEvent(...args);
495
+ };
375
496
  const onError = (..._args) => {
376
- const data = _args[1] ?? {};
497
+ const data = this.getHlsEventData(_args);
377
498
  const status = data.response?.code;
499
+ if (this.tryRecoverMediaError(Hls, data)) {
500
+ return;
501
+ }
378
502
  const isHttpError = typeof status === "number" && status >= 400;
379
- if (data.fatal || isHttpError) {
380
- const message = data.fatal ? `HLS fatal error: ${data.type ?? "unknown"}` : `HLS load error: HTTP ${status}`;
503
+ const isTerminalHlsError = this.isTerminalHlsError(data);
504
+ if (data.fatal || isHttpError || isTerminalHlsError) {
505
+ const message = this.formatHlsErrorMessage(data, status);
506
+ this.stopHlsLoading();
381
507
  this.options.onError?.({
508
+ code: this.getHlsErrorCode(data, status),
382
509
  message,
383
510
  cause: data,
384
511
  status,
385
- url: data.response?.url ?? data.url
512
+ url: this.getErrorUrl(data),
513
+ retryable: this.isRetryableHlsError(data, status)
386
514
  });
387
515
  settle(() => reject(new Error(message)));
388
516
  }
389
517
  };
390
518
  hls.on(Hls.Events.MANIFEST_PARSED, onParsed);
391
519
  hls.on(Hls.Events.LEVEL_SWITCHED, onSwitched);
520
+ hls.on(Hls.Events.FRAG_LOADING, onFragmentLoading);
521
+ hls.on(Hls.Events.FRAG_BUFFERED, onFragmentBuffered);
392
522
  hls.on(Hls.Events.ERROR, onError);
393
523
  this.detachListeners.push(
394
524
  () => hls.off(Hls.Events.MANIFEST_PARSED, onParsed),
395
525
  () => hls.off(Hls.Events.LEVEL_SWITCHED, onSwitched),
526
+ () => hls.off(Hls.Events.FRAG_LOADING, onFragmentLoading),
527
+ () => hls.off(Hls.Events.FRAG_BUFFERED, onFragmentBuffered),
396
528
  () => hls.off(Hls.Events.ERROR, onError),
397
529
  () => settle(() => reject(makeAbortError()))
398
530
  );
@@ -400,6 +532,7 @@ var HlsLoader = class {
400
532
  if (timeoutMs > 0) {
401
533
  this.manifestTimeout = setTimeout(() => {
402
534
  const message = `HLS manifest timeout after ${timeoutMs}ms`;
535
+ this.stopHlsLoading();
403
536
  this.options.onError?.({ message, cause: { type: "timeout" }, url: source.src });
404
537
  settle(() => reject(new Error(message)));
405
538
  }, timeoutMs);
@@ -414,7 +547,7 @@ var HlsLoader = class {
414
547
  }
415
548
  /**
416
549
  * Wire the readystatechange watcher and remove it as soon as the XHR
417
- * completes — without this cleanup hls.js (which spawns one XHR per segment)
550
+ * completes - without this cleanup hls.js (which spawns one XHR per segment)
418
551
  * leaks a closure per request (A1).
419
552
  */
420
553
  installXhrWatcher(xhr, url, source) {
@@ -440,10 +573,149 @@ var HlsLoader = class {
440
573
  };
441
574
  xhr.addEventListener("readystatechange", watcher);
442
575
  }
443
- abort() {
444
- if (this.aborted) return;
445
- this.aborted = true;
446
- this.clearManifestTimeout();
576
+ getHlsEventData(args) {
577
+ const data = args[1] ?? args[0];
578
+ return data !== null && typeof data === "object" ? data : {};
579
+ }
580
+ getFragmentUrl(data) {
581
+ return data.frag?.url ?? data.frag?.relurl ?? data.response?.url ?? data.url;
582
+ }
583
+ getErrorUrl(data) {
584
+ return this.getFragmentUrl(data) ?? this.currentSource?.src;
585
+ }
586
+ getFragmentKey(data) {
587
+ const url = this.getFragmentUrl(data);
588
+ const frag = data.frag;
589
+ if (!frag) return url ?? null;
590
+ const type = frag.type ?? "main";
591
+ const level = frag.level ?? "unknown-level";
592
+ const sn = frag.sn ?? "unknown-sn";
593
+ const cc = frag.cc ?? "unknown-cc";
594
+ const start = frag.start ?? frag.startPTS ?? "unknown-start";
595
+ return `${url ?? "unknown-url"}:${type}:${level}:${sn}:${cc}:${start}`;
596
+ }
597
+ isTerminalHlsError(data) {
598
+ switch (data.details) {
599
+ case "manifestParsingError":
600
+ case "manifestIncompatibleCodecsError":
601
+ case "levelEmptyError":
602
+ case "levelParsingError":
603
+ case "fragParsingError":
604
+ case "fragDecryptError":
605
+ case "keyLoadError":
606
+ case "keyLoadTimeOut":
607
+ case "bufferFullError":
608
+ return true;
609
+ default:
610
+ return false;
611
+ }
612
+ }
613
+ formatHlsErrorMessage(data, status) {
614
+ if (data.fatal) {
615
+ const type2 = data.type ?? "unknown";
616
+ const details2 = data.details ? ` (${data.details})` : "";
617
+ return `HLS fatal error: ${type2}${details2}`;
618
+ }
619
+ if (typeof status === "number" && status >= 400) {
620
+ return `HLS load error: HTTP ${status}${data.details ? ` (${data.details})` : ""}`;
621
+ }
622
+ const type = data.type ?? "unknown";
623
+ const details = data.details ? ` (${data.details})` : "";
624
+ return `HLS error: ${type}${details}`;
625
+ }
626
+ getHlsErrorCode(data, status) {
627
+ if (status === 401 || status === 403) return "unauthorized";
628
+ switch (data.details) {
629
+ case "manifestParsingError":
630
+ case "manifestIncompatibleCodecsError":
631
+ case "levelParsingError":
632
+ case "fragParsingError":
633
+ case "fragDecryptError":
634
+ return "decode";
635
+ default:
636
+ return "network";
637
+ }
638
+ }
639
+ isRetryableHlsError(data, status) {
640
+ if (status === 401 || status === 403) return false;
641
+ if (typeof status === "number" && status >= 400 && status < 500) return false;
642
+ switch (data.details) {
643
+ case "manifestParsingError":
644
+ case "manifestIncompatibleCodecsError":
645
+ case "levelParsingError":
646
+ case "fragParsingError":
647
+ case "fragDecryptError":
648
+ return false;
649
+ default:
650
+ return true;
651
+ }
652
+ }
653
+ isRecoverableMediaError(Hls, data) {
654
+ if (data.type !== Hls.ErrorTypes.MEDIA_ERROR && data.type !== "mediaError") {
655
+ return false;
656
+ }
657
+ if (!data.fatal) return false;
658
+ if (this.mediaRecoveryCount >= MEDIA_RECOVERY_LIMIT) return false;
659
+ if (typeof this.hls?.recoverMediaError !== "function") return false;
660
+ switch (data.details) {
661
+ case void 0:
662
+ case "bufferAppendError":
663
+ case "bufferAppendingError":
664
+ case "bufferStalledError":
665
+ case "bufferSeekOverHole":
666
+ case "bufferNudgeOnStall":
667
+ case "bufferFullError":
668
+ return true;
669
+ default:
670
+ return false;
671
+ }
672
+ }
673
+ tryRecoverMediaError(Hls, data) {
674
+ if (!this.isRecoverableMediaError(Hls, data)) return false;
675
+ this.mediaRecoveryCount += 1;
676
+ this.resetFragmentLoopGuard();
677
+ this.completeQualityAwaitIfBusy();
678
+ try {
679
+ this.hls?.recoverMediaError?.();
680
+ return true;
681
+ } catch {
682
+ return false;
683
+ }
684
+ }
685
+ onFragmentLoading(...args) {
686
+ if (this.repeatedFragmentErrorFired) return;
687
+ const data = this.getHlsEventData(args);
688
+ const key = this.getFragmentKey(data);
689
+ if (!key) return;
690
+ if (key === this.lastLoadingFragmentKey) {
691
+ this.repeatedFragmentLoadCount += 1;
692
+ } else {
693
+ this.lastLoadingFragmentKey = key;
694
+ this.repeatedFragmentLoadCount = 1;
695
+ }
696
+ if (this.repeatedFragmentLoadCount < REPEATED_FRAGMENT_LOAD_LIMIT) return;
697
+ this.repeatedFragmentErrorFired = true;
698
+ this.stopHlsLoading();
699
+ const url = this.getFragmentUrl(data);
700
+ this.options.onError?.({
701
+ code: "hls-fragment-loop",
702
+ message: `HLS fragment repeatedly loading: ${url ?? key}`,
703
+ cause: data,
704
+ url
705
+ });
706
+ }
707
+ resetFragmentLoopGuardFromEvent(...args) {
708
+ const data = this.getHlsEventData(args);
709
+ if (this.getFragmentKey(data)) {
710
+ this.resetFragmentLoopGuard();
711
+ }
712
+ }
713
+ resetFragmentLoopGuard() {
714
+ this.lastLoadingFragmentKey = null;
715
+ this.repeatedFragmentLoadCount = 0;
716
+ this.repeatedFragmentErrorFired = false;
717
+ }
718
+ abortLiveXhrs() {
447
719
  for (const xhr of [...this.liveXhrs]) {
448
720
  try {
449
721
  xhr.abort();
@@ -451,6 +723,20 @@ var HlsLoader = class {
451
723
  }
452
724
  }
453
725
  this.liveXhrs.clear();
726
+ }
727
+ stopHlsLoading() {
728
+ this.completeQualityAwaitIfBusy();
729
+ try {
730
+ this.hls?.stopLoad?.();
731
+ } catch {
732
+ }
733
+ this.abortLiveXhrs();
734
+ }
735
+ abort() {
736
+ if (this.aborted) return;
737
+ this.aborted = true;
738
+ this.clearManifestTimeout();
739
+ this.abortLiveXhrs();
454
740
  const reject = this.rejectAttach;
455
741
  this.rejectAttach = null;
456
742
  reject?.(makeAbortError());
@@ -486,6 +772,8 @@ var HlsLoader = class {
486
772
  this.aborted = false;
487
773
  this.lastChosenQualityWasAuto = false;
488
774
  this.manualLevelIndex = null;
775
+ this.mediaRecoveryCount = 0;
776
+ this.resetFragmentLoopGuard();
489
777
  }
490
778
  clearQualitySwitchTimerOnly() {
491
779
  if (this.qualitySwitchTimer !== null) {
@@ -607,7 +895,7 @@ var HlsLoader = class {
607
895
  const locked = this.qualities[this.manualLevelIndex] ?? null;
608
896
  this.options.onActiveQuality?.(locked, false);
609
897
  }
610
- /** @internal — for tests. */
898
+ /** @internal - for tests. */
611
899
  _getInternalState() {
612
900
  return { source: this.currentSource, qualities: this.qualities };
613
901
  }
@@ -629,6 +917,7 @@ var NativeLoader = class {
629
917
  constructor() {
630
918
  this.video = null;
631
919
  this.cleanups = [];
920
+ this.rejectPending = null;
632
921
  }
633
922
  async attach(video, source) {
634
923
  this.detach();
@@ -636,12 +925,14 @@ var NativeLoader = class {
636
925
  return new Promise((resolve, reject) => {
637
926
  const onMeta = () => {
638
927
  cleanupListeners();
928
+ this.rejectPending = null;
639
929
  resolve();
640
930
  };
641
931
  const onError = () => {
642
932
  cleanupListeners();
643
933
  const err = video.error;
644
934
  const message = err ? `MediaError code=${err.code} message=${err.message}` : "Native loader failed";
935
+ this.rejectPending = null;
645
936
  reject(new Error(message));
646
937
  };
647
938
  const cleanupListeners = () => {
@@ -651,17 +942,28 @@ var NativeLoader = class {
651
942
  video.addEventListener("loadedmetadata", onMeta);
652
943
  video.addEventListener("error", onError);
653
944
  this.cleanups.push(cleanupListeners);
945
+ this.rejectPending = reject;
654
946
  video.src = source.src;
655
947
  video.load();
656
948
  });
657
949
  }
658
- detach() {
950
+ abort() {
951
+ const reject = this.rejectPending;
952
+ this.rejectPending = null;
659
953
  while (this.cleanups.length) {
660
954
  try {
661
955
  this.cleanups.pop()?.();
662
956
  } catch {
663
957
  }
664
958
  }
959
+ if (reject) {
960
+ const error = new Error("Native source load aborted");
961
+ error.name = "AbortError";
962
+ reject(error);
963
+ }
964
+ }
965
+ detach() {
966
+ this.abort();
665
967
  if (this.video) {
666
968
  try {
667
969
  this.video.removeAttribute("src");
@@ -795,7 +1097,9 @@ var YouTubeLoader = class {
795
1097
  }
796
1098
  const host = document.createElement("div");
797
1099
  host.setAttribute("data-reel-yt-host", "");
798
- host.style.cssText = "position:absolute;inset:0;width:100%;height:100%;overflow:hidden;background:#000;";
1100
+ host.style.cssText = "position:absolute;inset:0;overflow:hidden;background:#000";
1101
+ const mount = document.createElement("div");
1102
+ host.appendChild(mount);
799
1103
  parent.appendChild(host);
800
1104
  this.host = host;
801
1105
  video.setAttribute(YT_HIDDEN_ATTR, "");
@@ -808,7 +1112,7 @@ var YouTubeLoader = class {
808
1112
  run();
809
1113
  };
810
1114
  try {
811
- this.yt = new YT.Player(host, {
1115
+ this.yt = new YT.Player(mount, {
812
1116
  videoId: id,
813
1117
  // 100% so the iframe fills the host div rather than using default px dimensions.
814
1118
  width: "100%",
@@ -824,7 +1128,7 @@ var YouTubeLoader = class {
824
1128
  onReady: () => {
825
1129
  const iframe = host.querySelector("iframe");
826
1130
  if (iframe) {
827
- iframe.style.cssText = "width:100%;height:100%;border:none;display:block;";
1131
+ iframe.style.cssText = "width:100%;height:100%;border:none;display:block";
828
1132
  }
829
1133
  try {
830
1134
  this.duration = this.yt?.getDuration() ?? 0;
@@ -1156,21 +1460,6 @@ function createStore(initial, options = {}) {
1156
1460
  }
1157
1461
 
1158
1462
  // src/createPlayer.ts
1159
- var NATIVE_EVENTS = [
1160
- "loadedmetadata",
1161
- "durationchange",
1162
- "timeupdate",
1163
- "play",
1164
- "pause",
1165
- "ended",
1166
- "error",
1167
- "volumechange",
1168
- "ratechange",
1169
- "seeking",
1170
- "seeked",
1171
- "waiting",
1172
- "playing"
1173
- ];
1174
1463
  function makeInitialState(options) {
1175
1464
  return {
1176
1465
  status: "idle",
@@ -1203,13 +1492,42 @@ function createPlayer(options = {}, internal = {}) {
1203
1492
  const bus = createEventBus();
1204
1493
  const commands = createCommandRegistry();
1205
1494
  const controls = createControlsRegistry();
1495
+ const sourceTransforms = createSourceTransformRegistry();
1496
+ const mediaAttachments = createMediaAttachmentRegistry();
1206
1497
  let status = "idle";
1207
1498
  let video = null;
1208
1499
  let activeLoader = null;
1209
1500
  let pendingAttachAbort = null;
1501
+ let pendingSourceLoad = null;
1502
+ let latestSourceLoadPromise = null;
1210
1503
  let disposed = false;
1211
- let initialOptionsApplied = false;
1212
1504
  let pendingPlay = false;
1505
+ function createPendingSourceLoad() {
1506
+ let resolve;
1507
+ let reject;
1508
+ const promise = new Promise((onResolve, onReject) => {
1509
+ resolve = onResolve;
1510
+ reject = onReject;
1511
+ });
1512
+ void promise.catch(() => void 0);
1513
+ latestSourceLoadPromise = promise;
1514
+ return { promise, resolve, reject };
1515
+ }
1516
+ function resolvePendingSourceLoad() {
1517
+ const load = pendingSourceLoad;
1518
+ if (!load) return;
1519
+ pendingSourceLoad = null;
1520
+ load.resolve();
1521
+ }
1522
+ function rejectPendingSourceLoad(reason) {
1523
+ const load = pendingSourceLoad;
1524
+ if (!load) return;
1525
+ pendingSourceLoad = null;
1526
+ load.reject(reason);
1527
+ }
1528
+ function createAbortError() {
1529
+ return Object.assign(new Error("Aborted"), { name: "AbortError" });
1530
+ }
1213
1531
  const YT_PLAYER_STATE_PLAYING = 1;
1214
1532
  function getYouTubePlayer() {
1215
1533
  return activeLoader?._instance?.() ?? null;
@@ -1232,11 +1550,12 @@ function createPlayer(options = {}, internal = {}) {
1232
1550
  bus.emit("qualitychange", { quality, auto });
1233
1551
  }
1234
1552
  commands.add("hls:setQuality", (level) => {
1235
- if (level === null) {
1236
- bus.emit("qualitypreference", { qualityHeight: null });
1237
- } else if (level !== null && typeof level === "object" && typeof level.height === "number") {
1238
- bus.emit("qualitypreference", { qualityHeight: level.height });
1553
+ if (level !== null && (typeof level !== "object" || typeof level.height !== "number")) {
1554
+ return;
1239
1555
  }
1556
+ bus.emit("qualitypreference", {
1557
+ qualityHeight: level === null ? null : level.height
1558
+ });
1240
1559
  const loader = activeLoader;
1241
1560
  if (typeof loader?.setQuality === "function") {
1242
1561
  loader.setQuality(level === null ? "auto" : level);
@@ -1248,7 +1567,14 @@ function createPlayer(options = {}, internal = {}) {
1248
1567
  onUnauthorized: (e) => emitUnauthorized(e),
1249
1568
  onQualities: (q) => setQualities(q),
1250
1569
  onActiveQuality: (q, auto) => setActiveQuality(q, auto),
1251
- onError: (err) => applyError(createPlayerError({ ...err, code: "network" })),
1570
+ onError: (err) => {
1571
+ const playerError = createPlayerError({ ...err, code: err.code ?? "network" });
1572
+ if (status === "loading") {
1573
+ dispatch({ type: "loadFailed", error: playerError });
1574
+ return;
1575
+ }
1576
+ dispatch({ type: "runtimeError", error: playerError });
1577
+ },
1252
1578
  onQualitySwitch: (active) => bus.emit("qualityswitch", { active })
1253
1579
  })
1254
1580
  );
@@ -1290,7 +1616,9 @@ function createPlayer(options = {}, internal = {}) {
1290
1616
  commands,
1291
1617
  controls,
1292
1618
  store: { getState: store.getState, subscribe: store.subscribe },
1293
- bus
1619
+ bus,
1620
+ sourceTransforms,
1621
+ mediaAttachments
1294
1622
  });
1295
1623
  const plugins = createPluginBus({ player: playerRef, host });
1296
1624
  function dispatch(event) {
@@ -1302,6 +1630,10 @@ function createPlayer(options = {}, internal = {}) {
1302
1630
  for (const effect of result.effects) applyEffect(effect);
1303
1631
  if (prev !== "ready" && status === "ready") {
1304
1632
  bus.emit("ready", { duration: store.getState().duration });
1633
+ resolvePendingSourceLoad();
1634
+ }
1635
+ if (event.type === "loadFailed") {
1636
+ rejectPendingSourceLoad(event.error);
1305
1637
  }
1306
1638
  }
1307
1639
  function applyEffect(effect) {
@@ -1345,15 +1677,29 @@ function createPlayer(options = {}, internal = {}) {
1345
1677
  activeQuality: null
1346
1678
  });
1347
1679
  bus.emit("qualitychange", { quality: null, auto: true });
1680
+ if (!isSafeMediaUrl(source.src) || source.tracks?.some((track) => !isSafeMediaUrl(track.src)) || source.thumbnails && !isSafeMediaUrl(source.thumbnails.src)) {
1681
+ if (video) {
1682
+ pendingSourceLoad = createPendingSourceLoad();
1683
+ }
1684
+ const error = createPlayerError({
1685
+ code: "unsupported",
1686
+ message: "Invalid URL"
1687
+ });
1688
+ dispatch({ type: "loadFailed", error });
1689
+ return;
1690
+ }
1348
1691
  if (!video) return;
1692
+ const sourceLoad = createPendingSourceLoad();
1693
+ pendingSourceLoad = sourceLoad;
1349
1694
  const provider = registry.resolve(source);
1350
1695
  if (!provider) {
1696
+ const error = createPlayerError({
1697
+ code: "unsupported",
1698
+ message: `No source provider for ${source.src}`
1699
+ });
1351
1700
  dispatch({
1352
1701
  type: "loadFailed",
1353
- error: createPlayerError({
1354
- code: "unsupported",
1355
- message: `No source provider for ${source.src}`
1356
- })
1702
+ error
1357
1703
  });
1358
1704
  return;
1359
1705
  }
@@ -1364,8 +1710,13 @@ function createPlayer(options = {}, internal = {}) {
1364
1710
  aborted = true;
1365
1711
  try {
1366
1712
  loader.abort?.();
1367
- } catch (err) {
1368
- console.error("[@f8team/reel-core] loader.abort() threw:", err);
1713
+ } catch (error) {
1714
+ console.error(error);
1715
+ }
1716
+ if (pendingSourceLoad === sourceLoad) {
1717
+ rejectPendingSourceLoad(createAbortError());
1718
+ } else {
1719
+ sourceLoad.reject(createAbortError());
1369
1720
  }
1370
1721
  };
1371
1722
  loader.attach(video, source).then(() => {
@@ -1467,8 +1818,6 @@ function createPlayer(options = {}, internal = {}) {
1467
1818
  target.addEventListener(name, handler);
1468
1819
  nativeListeners.push(() => target.removeEventListener(name, handler));
1469
1820
  }
1470
- for (const _ of NATIVE_EVENTS) {
1471
- }
1472
1821
  on2("loadedmetadata", () => {
1473
1822
  store.setState({
1474
1823
  duration: Number.isFinite(target.duration) ? target.duration : 0,
@@ -1539,17 +1888,24 @@ function createPlayer(options = {}, internal = {}) {
1539
1888
  on2("playing", () => {
1540
1889
  bus.emit("buffering", { isBuffering: false });
1541
1890
  });
1542
- if (typeof document !== "undefined") {
1543
- const onFullscreenChange = () => {
1544
- const isFullscreen = !!document.fullscreenElement;
1545
- store.setState({ fullscreen: isFullscreen });
1546
- bus.emit("fullscreenchange", { fullscreen: isFullscreen });
1547
- };
1548
- document.addEventListener("fullscreenchange", onFullscreenChange);
1549
- nativeListeners.push(
1550
- () => document.removeEventListener("fullscreenchange", onFullscreenChange)
1551
- );
1552
- }
1891
+ on2("enterpictureinpicture", () => {
1892
+ store.setState({ pip: true });
1893
+ bus.emit("pipchange", { pip: true });
1894
+ });
1895
+ on2("leavepictureinpicture", () => {
1896
+ store.setState({ pip: false });
1897
+ bus.emit("pipchange", { pip: false });
1898
+ });
1899
+ const ownerDocument = target.ownerDocument;
1900
+ const onFullscreenChange = () => {
1901
+ const isFullscreen = !!ownerDocument.fullscreenElement?.contains(target);
1902
+ store.setState({ fullscreen: isFullscreen });
1903
+ bus.emit("fullscreenchange", { fullscreen: isFullscreen });
1904
+ };
1905
+ ownerDocument.addEventListener("fullscreenchange", onFullscreenChange);
1906
+ nativeListeners.push(
1907
+ () => ownerDocument.removeEventListener("fullscreenchange", onFullscreenChange)
1908
+ );
1553
1909
  }
1554
1910
  function unbindNativeEvents() {
1555
1911
  while (nativeListeners.length) {
@@ -1559,24 +1915,18 @@ function createPlayer(options = {}, internal = {}) {
1559
1915
  }
1560
1916
  }
1561
1917
  }
1562
- function applyInitialOptions() {
1563
- if (initialOptionsApplied || !video) return;
1564
- if (typeof options.volume === "number") video.volume = options.volume;
1565
- if (typeof options.muted === "boolean") video.muted = options.muted;
1566
- if (typeof options.playbackRate === "number") video.playbackRate = options.playbackRate;
1918
+ function applyAttachOptions() {
1919
+ if (!video) return;
1920
+ const state = store.getState();
1921
+ video.volume = state.volume;
1922
+ video.muted = state.muted;
1923
+ video.playbackRate = state.playbackRate;
1567
1924
  if (options.poster) video.poster = options.poster;
1568
1925
  if (options.preload) video.preload = options.preload;
1569
- if (options.playsInline ?? true) {
1570
- video.playsInline = true;
1571
- video.setAttribute("webkit-playsinline", "");
1572
- }
1573
- if ("crossOrigin" in options) {
1574
- if (options.crossOrigin) video.crossOrigin = options.crossOrigin;
1575
- } else {
1576
- video.crossOrigin = "anonymous";
1577
- }
1578
- if (options.loop) video.loop = true;
1579
- initialOptionsApplied = true;
1926
+ video.playsInline = options.playsInline ?? true;
1927
+ video.toggleAttribute("webkit-playsinline", video.playsInline);
1928
+ video.crossOrigin = "crossOrigin" in options ? options.crossOrigin ?? null : "anonymous";
1929
+ video.loop = !!options.loop;
1580
1930
  }
1581
1931
  const attachDisposers = [];
1582
1932
  function flushAttachDisposers() {
@@ -1600,12 +1950,21 @@ function createPlayer(options = {}, internal = {}) {
1600
1950
  }
1601
1951
  async function attach(el) {
1602
1952
  if (disposed) throw new Error("[@f8team/reel-core] cannot attach: player disposed");
1603
- if (video === el) return;
1953
+ if (video === el) {
1954
+ await latestSourceLoadPromise;
1955
+ return;
1956
+ }
1604
1957
  if (video) detach();
1605
1958
  video = el;
1959
+ mediaAttachments.set({
1960
+ video: el,
1961
+ container: el.closest("[data-reel]") ?? el.parentElement ?? el
1962
+ });
1606
1963
  bindNativeEvents(el);
1607
- applyInitialOptions();
1608
- if (options.source) dispatch({ type: "setSource", source: options.source });
1964
+ applyAttachOptions();
1965
+ const currentSource = store.getState().source;
1966
+ if (currentSource) setSource(currentSource);
1967
+ const sourceLoadPromise = latestSourceLoadPromise;
1609
1968
  const wantStartTime = typeof options.startTime === "number" && options.startTime > 0;
1610
1969
  const wantAutoplay = !!options.autoplay && options.autoplay !== "off" && !!options.source;
1611
1970
  if (status === "ready") {
@@ -1627,6 +1986,7 @@ function createPlayer(options = {}, internal = {}) {
1627
1986
  attachDisposers.push(off2);
1628
1987
  }
1629
1988
  }
1989
+ await sourceLoadPromise;
1630
1990
  }
1631
1991
  function detach() {
1632
1992
  pendingAttachAbort?.();
@@ -1640,6 +2000,7 @@ function createPlayer(options = {}, internal = {}) {
1640
2000
  }
1641
2001
  activeLoader = null;
1642
2002
  }
2003
+ mediaAttachments.set(null);
1643
2004
  video = null;
1644
2005
  }
1645
2006
  function dispose() {
@@ -1654,7 +2015,8 @@ function createPlayer(options = {}, internal = {}) {
1654
2015
  }
1655
2016
  function setSource(source) {
1656
2017
  pendingPlay = false;
1657
- dispatch({ type: "setSource", source });
2018
+ latestSourceLoadPromise = null;
2019
+ dispatch({ type: "setSource", source: source ? sourceTransforms.apply(source) : null });
1658
2020
  }
1659
2021
  function getSource() {
1660
2022
  return store.getState().source;
@@ -1662,7 +2024,7 @@ function createPlayer(options = {}, internal = {}) {
1662
2024
  function retry() {
1663
2025
  const current = store.getState().source;
1664
2026
  if (!current) return false;
1665
- dispatch({ type: "setSource", source: current });
2027
+ setSource(current);
1666
2028
  return true;
1667
2029
  }
1668
2030
  async function play() {
@@ -2070,11 +2432,13 @@ exports.THEME_PRESETS = THEME_PRESETS;
2070
2432
  exports.TOKEN_TO_CSS_VAR = TOKEN_TO_CSS_VAR;
2071
2433
  exports.announce = announce;
2072
2434
  exports.applyTheme = applyTheme;
2435
+ exports.areSourceDescriptorsEquivalent = areSourceDescriptorsEquivalent;
2073
2436
  exports.createFocusTrap = createFocusTrap;
2074
2437
  exports.createPlayer = createPlayer;
2075
2438
  exports.definePlugin = definePlugin;
2076
2439
  exports.detectSourceType = detectSourceType;
2077
2440
  exports.formatTime = formatTime;
2441
+ exports.isSafeMediaUrl = isSafeMediaUrl;
2078
2442
  exports.looksLikeHls = looksLikeHls;
2079
2443
  exports.looksLikeProgressive = looksLikeProgressive;
2080
2444
  exports.looksLikeYouTube = looksLikeYouTube;