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