@f8team/reel-core 1.0.4 → 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.d.cts CHANGED
@@ -132,12 +132,12 @@ interface ThumbnailsDescriptor {
132
132
  /**
133
133
  * The kind of source. `auto` lets the registry pick.
134
134
  *
135
- * - `hls` — HLS m3u8 manifest, dispatched to `hls.js` (or native on Safari).
136
- * - `mp4` — Progressive MP4/WebM, dispatched to the native engine.
137
- * - `youtube` — YouTube IFrame API.
138
- * - `dash` — MPEG-DASH (reserved for a future dash provider).
139
- * - `native` — Force the native engine even if the URL looks like HLS (G4 Safari escape).
140
- * - `auto` — Detect from the URL via `looksLikeHls` / `detectSourceType`.
135
+ * - `hls` - HLS m3u8 manifest, dispatched to `hls.js` (or native on Safari).
136
+ * - `mp4` - Progressive MP4/WebM, dispatched to the native engine.
137
+ * - `youtube` - YouTube IFrame API.
138
+ * - `dash` - MPEG-DASH (reserved for a future dash provider).
139
+ * - `native` - Force the native engine even if the URL looks like HLS (G4 Safari escape).
140
+ * - `auto` - Detect from the URL via `looksLikeHls` / `detectSourceType`.
141
141
  */
142
142
  type SourceType = "auto" | "hls" | "mp4" | "youtube" | "dash" | "native";
143
143
  /**
@@ -154,8 +154,8 @@ interface SourceDescriptor {
154
154
  /**
155
155
  * Cookie / credentials policy for HLS XHRs.
156
156
  *
157
- * - `boolean` — sent for every request.
158
- * - `(url) => boolean` — predicate (e.g. allowlist `https://api-gateway*`, see G7, G13).
157
+ * - `boolean` - sent for every request.
158
+ * - `(url) => boolean` - predicate scoped to a trusted API origin/path (see G7, G13).
159
159
  */
160
160
  withCredentials?: boolean | ((url: string) => boolean);
161
161
  /** Subtitle tracks to attach (G1, G15). */
@@ -222,9 +222,9 @@ interface SourceProvider {
222
222
  /**
223
223
  * Decide whether this provider can handle the given source.
224
224
  *
225
- * - `true` — definite match.
226
- * - `"maybe"` — handle if no `true` match exists (used by the native fallback).
227
- * - `false` — skip.
225
+ * - `true` - definite match.
226
+ * - `"maybe"` - handle if no `true` match exists (used by the native fallback).
227
+ * - `false` - skip.
228
228
  */
229
229
  canHandle(source: SourceDescriptor): boolean | "maybe";
230
230
  /** Construct a fresh loader. Called once per source attach. */
@@ -469,6 +469,13 @@ interface Player {
469
469
  dispose(): void;
470
470
  }
471
471
 
472
+ /** Synchronous source normalization hook applied before provider resolution. */
473
+ type SourceTransform = (source: SourceDescriptor) => SourceDescriptor;
474
+ /** Media element and container owned by one player attachment. */
475
+ interface PluginMediaAttachment {
476
+ video: HTMLVideoElement;
477
+ container: Element;
478
+ }
472
479
  /**
473
480
  * Command registry shared between the player and plugins. Commands are the
474
481
  * extension point for keyboard handlers, plugin contributions, and UI buttons.
@@ -505,6 +512,22 @@ interface PluginHost {
505
512
  };
506
513
  /** Shared command registry. Plugins MUST namespace their commands. */
507
514
  commands: CommandRegistry;
515
+ /**
516
+ * Register a synchronous source transform. Core applies transforms before
517
+ * the source is stored or passed to a provider, including the initial
518
+ * source supplied in `PlayerOptions`.
519
+ *
520
+ * Optional for compatibility with custom test hosts built against earlier
521
+ * versions of the contract.
522
+ */
523
+ sources?: {
524
+ transform(transform: SourceTransform): () => void;
525
+ };
526
+ /** Player-scoped media ownership and attach/detach lifecycle. */
527
+ media?: {
528
+ get(): PluginMediaAttachment | null;
529
+ subscribe(listener: (attachment: PluginMediaAttachment | null) => void): () => void;
530
+ };
508
531
  /** Read-only state. */
509
532
  store: ReadableStore<PlayerState>;
510
533
  /** Emit a custom plugin event. Use the `${plugin}:${verb}` namespace. */
@@ -810,9 +833,13 @@ declare function toSeconds(input: number | string): number;
810
833
 
811
834
  /**
812
835
  * URL helpers used by the source registry to dispatch a source descriptor to
813
- * the right provider. Pure string predicates — no DOM, no I/O.
836
+ * the right provider. Pure string predicates - no DOM, no I/O.
814
837
  */
815
838
 
839
+ /** Compare the descriptor fields that affect provider loading or media UI. */
840
+ declare function areSourceDescriptorsEquivalent(previous: SourceDescriptor | null, next: SourceDescriptor | null): boolean;
841
+ /** Whether a media/resource URL is safe to hand to browser media APIs. */
842
+ declare function isSafeMediaUrl(input: string | undefined | null): boolean;
816
843
  /**
817
844
  * Heuristic for HLS m3u8 URLs.
818
845
  *
@@ -843,4 +870,4 @@ declare function looksLikeProgressive(input: string | undefined | null): boolean
843
870
  */
844
871
  declare function detectSourceType(source: SourceDescriptor): SourceType;
845
872
 
846
- export { type AutoplayMode, type BufferedRange, type CommandRegistry, DEFAULT_TOKENS, type Disposer, type KeyboardScope, type MutableStore, type Player, type PlayerError, type PlayerErrorCode, type PlayerEvents, type PlayerHooks, type PlayerOptions, type PlayerState, type PlayerStatus, type PluginHost, type PluginInstance, type QualityLevel, type ReadableStore, type SourceDescriptor, type SourceLoader, type SourceProvider, type SourceType, type SubtitleTrack, THEME_PRESETS, TOKEN_TO_CSS_VAR, type ThemeName, type ThemeTokens, type ThumbnailsDescriptor, type UnauthorizedEvent, announce, applyTheme, createFocusTrap, createPlayer, definePlugin, detectSourceType, formatTime, looksLikeHls, looksLikeProgressive, looksLikeYouTube, parseTime, resolveTokens, toSeconds };
873
+ export { type AutoplayMode, type BufferedRange, type CommandRegistry, DEFAULT_TOKENS, type Disposer, type KeyboardScope, type MutableStore, type Player, type PlayerError, type PlayerErrorCode, type PlayerEvents, type PlayerHooks, type PlayerOptions, type PlayerState, type PlayerStatus, type PluginHost, type PluginInstance, type PluginMediaAttachment, type QualityLevel, type ReadableStore, type SourceDescriptor, type SourceLoader, type SourceProvider, type SourceTransform, type SourceType, type SubtitleTrack, THEME_PRESETS, TOKEN_TO_CSS_VAR, type ThemeName, type ThemeTokens, type ThumbnailsDescriptor, type UnauthorizedEvent, announce, applyTheme, areSourceDescriptorsEquivalent, createFocusTrap, createPlayer, definePlugin, detectSourceType, formatTime, isSafeMediaUrl, looksLikeHls, looksLikeProgressive, looksLikeYouTube, parseTime, resolveTokens, toSeconds };
package/dist/index.d.ts CHANGED
@@ -132,12 +132,12 @@ interface ThumbnailsDescriptor {
132
132
  /**
133
133
  * The kind of source. `auto` lets the registry pick.
134
134
  *
135
- * - `hls` — HLS m3u8 manifest, dispatched to `hls.js` (or native on Safari).
136
- * - `mp4` — Progressive MP4/WebM, dispatched to the native engine.
137
- * - `youtube` — YouTube IFrame API.
138
- * - `dash` — MPEG-DASH (reserved for a future dash provider).
139
- * - `native` — Force the native engine even if the URL looks like HLS (G4 Safari escape).
140
- * - `auto` — Detect from the URL via `looksLikeHls` / `detectSourceType`.
135
+ * - `hls` - HLS m3u8 manifest, dispatched to `hls.js` (or native on Safari).
136
+ * - `mp4` - Progressive MP4/WebM, dispatched to the native engine.
137
+ * - `youtube` - YouTube IFrame API.
138
+ * - `dash` - MPEG-DASH (reserved for a future dash provider).
139
+ * - `native` - Force the native engine even if the URL looks like HLS (G4 Safari escape).
140
+ * - `auto` - Detect from the URL via `looksLikeHls` / `detectSourceType`.
141
141
  */
142
142
  type SourceType = "auto" | "hls" | "mp4" | "youtube" | "dash" | "native";
143
143
  /**
@@ -154,8 +154,8 @@ interface SourceDescriptor {
154
154
  /**
155
155
  * Cookie / credentials policy for HLS XHRs.
156
156
  *
157
- * - `boolean` — sent for every request.
158
- * - `(url) => boolean` — predicate (e.g. allowlist `https://api-gateway*`, see G7, G13).
157
+ * - `boolean` - sent for every request.
158
+ * - `(url) => boolean` - predicate scoped to a trusted API origin/path (see G7, G13).
159
159
  */
160
160
  withCredentials?: boolean | ((url: string) => boolean);
161
161
  /** Subtitle tracks to attach (G1, G15). */
@@ -222,9 +222,9 @@ interface SourceProvider {
222
222
  /**
223
223
  * Decide whether this provider can handle the given source.
224
224
  *
225
- * - `true` — definite match.
226
- * - `"maybe"` — handle if no `true` match exists (used by the native fallback).
227
- * - `false` — skip.
225
+ * - `true` - definite match.
226
+ * - `"maybe"` - handle if no `true` match exists (used by the native fallback).
227
+ * - `false` - skip.
228
228
  */
229
229
  canHandle(source: SourceDescriptor): boolean | "maybe";
230
230
  /** Construct a fresh loader. Called once per source attach. */
@@ -469,6 +469,13 @@ interface Player {
469
469
  dispose(): void;
470
470
  }
471
471
 
472
+ /** Synchronous source normalization hook applied before provider resolution. */
473
+ type SourceTransform = (source: SourceDescriptor) => SourceDescriptor;
474
+ /** Media element and container owned by one player attachment. */
475
+ interface PluginMediaAttachment {
476
+ video: HTMLVideoElement;
477
+ container: Element;
478
+ }
472
479
  /**
473
480
  * Command registry shared between the player and plugins. Commands are the
474
481
  * extension point for keyboard handlers, plugin contributions, and UI buttons.
@@ -505,6 +512,22 @@ interface PluginHost {
505
512
  };
506
513
  /** Shared command registry. Plugins MUST namespace their commands. */
507
514
  commands: CommandRegistry;
515
+ /**
516
+ * Register a synchronous source transform. Core applies transforms before
517
+ * the source is stored or passed to a provider, including the initial
518
+ * source supplied in `PlayerOptions`.
519
+ *
520
+ * Optional for compatibility with custom test hosts built against earlier
521
+ * versions of the contract.
522
+ */
523
+ sources?: {
524
+ transform(transform: SourceTransform): () => void;
525
+ };
526
+ /** Player-scoped media ownership and attach/detach lifecycle. */
527
+ media?: {
528
+ get(): PluginMediaAttachment | null;
529
+ subscribe(listener: (attachment: PluginMediaAttachment | null) => void): () => void;
530
+ };
508
531
  /** Read-only state. */
509
532
  store: ReadableStore<PlayerState>;
510
533
  /** Emit a custom plugin event. Use the `${plugin}:${verb}` namespace. */
@@ -810,9 +833,13 @@ declare function toSeconds(input: number | string): number;
810
833
 
811
834
  /**
812
835
  * URL helpers used by the source registry to dispatch a source descriptor to
813
- * the right provider. Pure string predicates — no DOM, no I/O.
836
+ * the right provider. Pure string predicates - no DOM, no I/O.
814
837
  */
815
838
 
839
+ /** Compare the descriptor fields that affect provider loading or media UI. */
840
+ declare function areSourceDescriptorsEquivalent(previous: SourceDescriptor | null, next: SourceDescriptor | null): boolean;
841
+ /** Whether a media/resource URL is safe to hand to browser media APIs. */
842
+ declare function isSafeMediaUrl(input: string | undefined | null): boolean;
816
843
  /**
817
844
  * Heuristic for HLS m3u8 URLs.
818
845
  *
@@ -843,4 +870,4 @@ declare function looksLikeProgressive(input: string | undefined | null): boolean
843
870
  */
844
871
  declare function detectSourceType(source: SourceDescriptor): SourceType;
845
872
 
846
- export { type AutoplayMode, type BufferedRange, type CommandRegistry, DEFAULT_TOKENS, type Disposer, type KeyboardScope, type MutableStore, type Player, type PlayerError, type PlayerErrorCode, type PlayerEvents, type PlayerHooks, type PlayerOptions, type PlayerState, type PlayerStatus, type PluginHost, type PluginInstance, type QualityLevel, type ReadableStore, type SourceDescriptor, type SourceLoader, type SourceProvider, type SourceType, type SubtitleTrack, THEME_PRESETS, TOKEN_TO_CSS_VAR, type ThemeName, type ThemeTokens, type ThumbnailsDescriptor, type UnauthorizedEvent, announce, applyTheme, createFocusTrap, createPlayer, definePlugin, detectSourceType, formatTime, looksLikeHls, looksLikeProgressive, looksLikeYouTube, parseTime, resolveTokens, toSeconds };
873
+ export { type AutoplayMode, type BufferedRange, type CommandRegistry, DEFAULT_TOKENS, type Disposer, type KeyboardScope, type MutableStore, type Player, type PlayerError, type PlayerErrorCode, type PlayerEvents, type PlayerHooks, type PlayerOptions, type PlayerState, type PlayerStatus, type PluginHost, type PluginInstance, type PluginMediaAttachment, type QualityLevel, type ReadableStore, type SourceDescriptor, type SourceLoader, type SourceProvider, type SourceTransform, type SourceType, type SubtitleTrack, THEME_PRESETS, TOKEN_TO_CSS_VAR, type ThemeName, type ThemeTokens, type ThumbnailsDescriptor, type UnauthorizedEvent, announce, applyTheme, areSourceDescriptorsEquivalent, createFocusTrap, createPlayer, definePlugin, detectSourceType, formatTime, isSafeMediaUrl, looksLikeHls, looksLikeProgressive, looksLikeYouTube, parseTime, resolveTokens, toSeconds };
package/dist/index.js CHANGED
@@ -212,12 +212,58 @@ function createControlsRegistry() {
212
212
  }
213
213
  return { contribute, snapshot, clear };
214
214
  }
215
- 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
+ }) {
216
255
  return {
217
256
  controls: {
218
- contribute: (slot, render) => controls.contribute(slot, render)
257
+ contribute: controls.contribute
219
258
  },
220
259
  commands,
260
+ sources: {
261
+ transform: sourceTransforms.register
262
+ },
263
+ media: {
264
+ get: mediaAttachments.get,
265
+ subscribe: mediaAttachments.subscribe
266
+ },
221
267
  store,
222
268
  emit: (event, payload) => {
223
269
  bus.emit(event, payload);
@@ -226,6 +272,21 @@ function createPluginHost({ commands, controls, store, bus }) {
226
272
  }
227
273
 
228
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
+ }
229
290
  function looksLikeHls(input) {
230
291
  if (!input) return false;
231
292
  const lower = input.toLowerCase();
@@ -854,6 +915,7 @@ var NativeLoader = class {
854
915
  constructor() {
855
916
  this.video = null;
856
917
  this.cleanups = [];
918
+ this.rejectPending = null;
857
919
  }
858
920
  async attach(video, source) {
859
921
  this.detach();
@@ -861,12 +923,14 @@ var NativeLoader = class {
861
923
  return new Promise((resolve, reject) => {
862
924
  const onMeta = () => {
863
925
  cleanupListeners();
926
+ this.rejectPending = null;
864
927
  resolve();
865
928
  };
866
929
  const onError = () => {
867
930
  cleanupListeners();
868
931
  const err = video.error;
869
932
  const message = err ? `MediaError code=${err.code} message=${err.message}` : "Native loader failed";
933
+ this.rejectPending = null;
870
934
  reject(new Error(message));
871
935
  };
872
936
  const cleanupListeners = () => {
@@ -876,17 +940,28 @@ var NativeLoader = class {
876
940
  video.addEventListener("loadedmetadata", onMeta);
877
941
  video.addEventListener("error", onError);
878
942
  this.cleanups.push(cleanupListeners);
943
+ this.rejectPending = reject;
879
944
  video.src = source.src;
880
945
  video.load();
881
946
  });
882
947
  }
883
- detach() {
948
+ abort() {
949
+ const reject = this.rejectPending;
950
+ this.rejectPending = null;
884
951
  while (this.cleanups.length) {
885
952
  try {
886
953
  this.cleanups.pop()?.();
887
954
  } catch {
888
955
  }
889
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();
890
965
  if (this.video) {
891
966
  try {
892
967
  this.video.removeAttribute("src");
@@ -1020,7 +1095,9 @@ var YouTubeLoader = class {
1020
1095
  }
1021
1096
  const host = document.createElement("div");
1022
1097
  host.setAttribute("data-reel-yt-host", "");
1023
- 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);
1024
1101
  parent.appendChild(host);
1025
1102
  this.host = host;
1026
1103
  video.setAttribute(YT_HIDDEN_ATTR, "");
@@ -1033,7 +1110,7 @@ var YouTubeLoader = class {
1033
1110
  run();
1034
1111
  };
1035
1112
  try {
1036
- this.yt = new YT.Player(host, {
1113
+ this.yt = new YT.Player(mount, {
1037
1114
  videoId: id,
1038
1115
  // 100% so the iframe fills the host div rather than using default px dimensions.
1039
1116
  width: "100%",
@@ -1049,7 +1126,7 @@ var YouTubeLoader = class {
1049
1126
  onReady: () => {
1050
1127
  const iframe = host.querySelector("iframe");
1051
1128
  if (iframe) {
1052
- iframe.style.cssText = "width:100%;height:100%;border:none;display:block;";
1129
+ iframe.style.cssText = "width:100%;height:100%;border:none;display:block";
1053
1130
  }
1054
1131
  try {
1055
1132
  this.duration = this.yt?.getDuration() ?? 0;
@@ -1381,21 +1458,6 @@ function createStore(initial, options = {}) {
1381
1458
  }
1382
1459
 
1383
1460
  // src/createPlayer.ts
1384
- var NATIVE_EVENTS = [
1385
- "loadedmetadata",
1386
- "durationchange",
1387
- "timeupdate",
1388
- "play",
1389
- "pause",
1390
- "ended",
1391
- "error",
1392
- "volumechange",
1393
- "ratechange",
1394
- "seeking",
1395
- "seeked",
1396
- "waiting",
1397
- "playing"
1398
- ];
1399
1461
  function makeInitialState(options) {
1400
1462
  return {
1401
1463
  status: "idle",
@@ -1428,13 +1490,42 @@ function createPlayer(options = {}, internal = {}) {
1428
1490
  const bus = createEventBus();
1429
1491
  const commands = createCommandRegistry();
1430
1492
  const controls = createControlsRegistry();
1493
+ const sourceTransforms = createSourceTransformRegistry();
1494
+ const mediaAttachments = createMediaAttachmentRegistry();
1431
1495
  let status = "idle";
1432
1496
  let video = null;
1433
1497
  let activeLoader = null;
1434
1498
  let pendingAttachAbort = null;
1499
+ let pendingSourceLoad = null;
1500
+ let latestSourceLoadPromise = null;
1435
1501
  let disposed = false;
1436
- let initialOptionsApplied = false;
1437
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
+ }
1438
1529
  const YT_PLAYER_STATE_PLAYING = 1;
1439
1530
  function getYouTubePlayer() {
1440
1531
  return activeLoader?._instance?.() ?? null;
@@ -1457,11 +1548,12 @@ function createPlayer(options = {}, internal = {}) {
1457
1548
  bus.emit("qualitychange", { quality, auto });
1458
1549
  }
1459
1550
  commands.add("hls:setQuality", (level) => {
1460
- if (level === null) {
1461
- bus.emit("qualitypreference", { qualityHeight: null });
1462
- } else if (level !== null && typeof level === "object" && typeof level.height === "number") {
1463
- bus.emit("qualitypreference", { qualityHeight: level.height });
1551
+ if (level !== null && (typeof level !== "object" || typeof level.height !== "number")) {
1552
+ return;
1464
1553
  }
1554
+ bus.emit("qualitypreference", {
1555
+ qualityHeight: level === null ? null : level.height
1556
+ });
1465
1557
  const loader = activeLoader;
1466
1558
  if (typeof loader?.setQuality === "function") {
1467
1559
  loader.setQuality(level === null ? "auto" : level);
@@ -1522,7 +1614,9 @@ function createPlayer(options = {}, internal = {}) {
1522
1614
  commands,
1523
1615
  controls,
1524
1616
  store: { getState: store.getState, subscribe: store.subscribe },
1525
- bus
1617
+ bus,
1618
+ sourceTransforms,
1619
+ mediaAttachments
1526
1620
  });
1527
1621
  const plugins = createPluginBus({ player: playerRef, host });
1528
1622
  function dispatch(event) {
@@ -1534,6 +1628,10 @@ function createPlayer(options = {}, internal = {}) {
1534
1628
  for (const effect of result.effects) applyEffect(effect);
1535
1629
  if (prev !== "ready" && status === "ready") {
1536
1630
  bus.emit("ready", { duration: store.getState().duration });
1631
+ resolvePendingSourceLoad();
1632
+ }
1633
+ if (event.type === "loadFailed") {
1634
+ rejectPendingSourceLoad(event.error);
1537
1635
  }
1538
1636
  }
1539
1637
  function applyEffect(effect) {
@@ -1577,15 +1675,29 @@ function createPlayer(options = {}, internal = {}) {
1577
1675
  activeQuality: null
1578
1676
  });
1579
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
+ }
1580
1689
  if (!video) return;
1690
+ const sourceLoad = createPendingSourceLoad();
1691
+ pendingSourceLoad = sourceLoad;
1581
1692
  const provider = registry.resolve(source);
1582
1693
  if (!provider) {
1694
+ const error = createPlayerError({
1695
+ code: "unsupported",
1696
+ message: `No source provider for ${source.src}`
1697
+ });
1583
1698
  dispatch({
1584
1699
  type: "loadFailed",
1585
- error: createPlayerError({
1586
- code: "unsupported",
1587
- message: `No source provider for ${source.src}`
1588
- })
1700
+ error
1589
1701
  });
1590
1702
  return;
1591
1703
  }
@@ -1596,8 +1708,13 @@ function createPlayer(options = {}, internal = {}) {
1596
1708
  aborted = true;
1597
1709
  try {
1598
1710
  loader.abort?.();
1599
- } catch (err) {
1600
- 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());
1601
1718
  }
1602
1719
  };
1603
1720
  loader.attach(video, source).then(() => {
@@ -1699,8 +1816,6 @@ function createPlayer(options = {}, internal = {}) {
1699
1816
  target.addEventListener(name, handler);
1700
1817
  nativeListeners.push(() => target.removeEventListener(name, handler));
1701
1818
  }
1702
- for (const _ of NATIVE_EVENTS) {
1703
- }
1704
1819
  on2("loadedmetadata", () => {
1705
1820
  store.setState({
1706
1821
  duration: Number.isFinite(target.duration) ? target.duration : 0,
@@ -1771,17 +1886,24 @@ function createPlayer(options = {}, internal = {}) {
1771
1886
  on2("playing", () => {
1772
1887
  bus.emit("buffering", { isBuffering: false });
1773
1888
  });
1774
- if (typeof document !== "undefined") {
1775
- const onFullscreenChange = () => {
1776
- const isFullscreen = !!document.fullscreenElement;
1777
- store.setState({ fullscreen: isFullscreen });
1778
- bus.emit("fullscreenchange", { fullscreen: isFullscreen });
1779
- };
1780
- document.addEventListener("fullscreenchange", onFullscreenChange);
1781
- nativeListeners.push(
1782
- () => document.removeEventListener("fullscreenchange", onFullscreenChange)
1783
- );
1784
- }
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
+ );
1785
1907
  }
1786
1908
  function unbindNativeEvents() {
1787
1909
  while (nativeListeners.length) {
@@ -1791,24 +1913,18 @@ function createPlayer(options = {}, internal = {}) {
1791
1913
  }
1792
1914
  }
1793
1915
  }
1794
- function applyInitialOptions() {
1795
- if (initialOptionsApplied || !video) return;
1796
- if (typeof options.volume === "number") video.volume = options.volume;
1797
- if (typeof options.muted === "boolean") video.muted = options.muted;
1798
- 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;
1799
1922
  if (options.poster) video.poster = options.poster;
1800
1923
  if (options.preload) video.preload = options.preload;
1801
- if (options.playsInline ?? true) {
1802
- video.playsInline = true;
1803
- video.setAttribute("webkit-playsinline", "");
1804
- }
1805
- if ("crossOrigin" in options) {
1806
- if (options.crossOrigin) video.crossOrigin = options.crossOrigin;
1807
- } else {
1808
- video.crossOrigin = "anonymous";
1809
- }
1810
- if (options.loop) video.loop = true;
1811
- 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;
1812
1928
  }
1813
1929
  const attachDisposers = [];
1814
1930
  function flushAttachDisposers() {
@@ -1832,12 +1948,21 @@ function createPlayer(options = {}, internal = {}) {
1832
1948
  }
1833
1949
  async function attach(el) {
1834
1950
  if (disposed) throw new Error("[@f8team/reel-core] cannot attach: player disposed");
1835
- if (video === el) return;
1951
+ if (video === el) {
1952
+ await latestSourceLoadPromise;
1953
+ return;
1954
+ }
1836
1955
  if (video) detach();
1837
1956
  video = el;
1957
+ mediaAttachments.set({
1958
+ video: el,
1959
+ container: el.closest("[data-reel]") ?? el.parentElement ?? el
1960
+ });
1838
1961
  bindNativeEvents(el);
1839
- applyInitialOptions();
1840
- 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;
1841
1966
  const wantStartTime = typeof options.startTime === "number" && options.startTime > 0;
1842
1967
  const wantAutoplay = !!options.autoplay && options.autoplay !== "off" && !!options.source;
1843
1968
  if (status === "ready") {
@@ -1859,6 +1984,7 @@ function createPlayer(options = {}, internal = {}) {
1859
1984
  attachDisposers.push(off2);
1860
1985
  }
1861
1986
  }
1987
+ await sourceLoadPromise;
1862
1988
  }
1863
1989
  function detach() {
1864
1990
  pendingAttachAbort?.();
@@ -1872,6 +1998,7 @@ function createPlayer(options = {}, internal = {}) {
1872
1998
  }
1873
1999
  activeLoader = null;
1874
2000
  }
2001
+ mediaAttachments.set(null);
1875
2002
  video = null;
1876
2003
  }
1877
2004
  function dispose() {
@@ -1886,7 +2013,8 @@ function createPlayer(options = {}, internal = {}) {
1886
2013
  }
1887
2014
  function setSource(source) {
1888
2015
  pendingPlay = false;
1889
- dispatch({ type: "setSource", source });
2016
+ latestSourceLoadPromise = null;
2017
+ dispatch({ type: "setSource", source: source ? sourceTransforms.apply(source) : null });
1890
2018
  }
1891
2019
  function getSource() {
1892
2020
  return store.getState().source;
@@ -1894,7 +2022,7 @@ function createPlayer(options = {}, internal = {}) {
1894
2022
  function retry() {
1895
2023
  const current = store.getState().source;
1896
2024
  if (!current) return false;
1897
- dispatch({ type: "setSource", source: current });
2025
+ setSource(current);
1898
2026
  return true;
1899
2027
  }
1900
2028
  async function play() {
@@ -2297,6 +2425,6 @@ function toSeconds(input) {
2297
2425
  return parseTime(input);
2298
2426
  }
2299
2427
 
2300
- 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 };
2301
2429
  //# sourceMappingURL=index.js.map
2302
2430
  //# sourceMappingURL=index.js.map