@scarlett-player/embed 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -148,6 +148,7 @@ class StateManager {
148
148
  constructor(initialState) {
149
149
  this.signals = /* @__PURE__ */ new Map();
150
150
  this.changeSubscribers = /* @__PURE__ */ new Set();
151
+ this.definedDefaults = /* @__PURE__ */ new Map();
151
152
  this.initializeSignals(initialState);
152
153
  }
153
154
  /**
@@ -157,13 +158,51 @@ class StateManager {
157
158
  initializeSignals(overrides) {
158
159
  const initialState = { ...DEFAULT_STATE, ...overrides };
159
160
  for (const [key, value] of Object.entries(initialState)) {
160
- const stateKey = key;
161
- const stateSignal = signal(value);
162
- stateSignal.subscribe(() => {
163
- this.notifyChangeSubscribers(stateKey);
164
- });
165
- this.signals.set(stateKey, stateSignal);
161
+ this.createSignal(key, value);
162
+ }
163
+ }
164
+ /**
165
+ * Create and register a signal, wired to the global change subscribers.
166
+ *
167
+ * Shared by initializeSignals() and define() so a plugin-defined key behaves
168
+ * exactly like a built-in one and the two paths cannot drift apart.
169
+ *
170
+ * @private
171
+ */
172
+ createSignal(key, value) {
173
+ const stateSignal = signal(value);
174
+ stateSignal.subscribe(() => {
175
+ this.notifyChangeSubscribers(key);
176
+ });
177
+ this.signals.set(key, stateSignal);
178
+ }
179
+ /**
180
+ * Register a state key at runtime, for state a plugin owns.
181
+ *
182
+ * Core cannot know every plugin's keys, and {@link get} deliberately throws
183
+ * for unregistered ones — that throw is a useful typo-catcher and is worth
184
+ * keeping — so a plugin declares its keys before first use.
185
+ *
186
+ * Idempotent by design: re-defining an existing key leaves the current value
187
+ * untouched. Plugins commonly re-run setup after a source change, and that
188
+ * must not reset state that is already live.
189
+ *
190
+ * Namespace plugin keys with the plugin's own name to avoid collisions.
191
+ *
192
+ * @param key - State property key
193
+ * @param initialValue - Value used only when the key is new
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * state.define('highlightSelection', null);
198
+ * ```
199
+ */
200
+ define(key, initialValue) {
201
+ if (this.signals.has(key)) {
202
+ return;
166
203
  }
204
+ this.definedDefaults.set(key, initialValue);
205
+ this.createSignal(key, initialValue);
167
206
  }
168
207
  /**
169
208
  * Get the signal for a state property.
@@ -307,7 +346,10 @@ class StateManager {
307
346
  * ```
308
347
  */
309
348
  reset() {
310
- this.update(DEFAULT_STATE);
349
+ this.update({
350
+ ...DEFAULT_STATE,
351
+ ...Object.fromEntries(this.definedDefaults)
352
+ });
311
353
  }
312
354
  /**
313
355
  * Reset a specific state property to its default value.
@@ -320,7 +362,7 @@ class StateManager {
320
362
  * ```
321
363
  */
322
364
  resetKey(key) {
323
- const defaultValue = DEFAULT_STATE[key];
365
+ const defaultValue = key in DEFAULT_STATE ? DEFAULT_STATE[key] : this.definedDefaults.get(key);
324
366
  this.set(key, defaultValue);
325
367
  }
326
368
  /**
@@ -1214,6 +1256,18 @@ class PluginAPI {
1214
1256
  setState(key, value) {
1215
1257
  this.stateManager.set(key, value);
1216
1258
  }
1259
+ /**
1260
+ * Register a state key this plugin owns, before first use.
1261
+ *
1262
+ * Idempotent — re-defining an existing key keeps its current value.
1263
+ * See {@link IPluginAPI.defineState}.
1264
+ *
1265
+ * @param key - State property key
1266
+ * @param initialValue - Value used only when the key is new
1267
+ */
1268
+ defineState(key, initialValue) {
1269
+ this.stateManager.define(key, initialValue);
1270
+ }
1217
1271
  /**
1218
1272
  * Subscribe to an event.
1219
1273
  *
@@ -5968,6 +6022,17 @@ var BandwidthIndicator = class {
5968
6022
  this.el.remove();
5969
6023
  }
5970
6024
  };
6025
+ var registry = /* @__PURE__ */ new Map();
6026
+ var listeners = /* @__PURE__ */ new Set();
6027
+ function getControlFactory(id) {
6028
+ return registry.get(id) ?? null;
6029
+ }
6030
+ function onControlRegistered(listener) {
6031
+ listeners.add(listener);
6032
+ return () => {
6033
+ listeners.delete(listener);
6034
+ };
6035
+ }
5971
6036
  var DEFAULT_LAYOUT = [
5972
6037
  "play",
5973
6038
  "skip-backward",
@@ -5996,6 +6061,7 @@ function uiPlugin(config = {}) {
5996
6061
  let controls = [];
5997
6062
  let hideTimeout = null;
5998
6063
  let stateUnsubscribe = null;
6064
+ let controlRegistryUnsubscribe = null;
5999
6065
  let errorUnsubscribe = null;
6000
6066
  let reconnectingUnsubscribe = null;
6001
6067
  let recoveredUnsubscribe = null;
@@ -6037,10 +6103,43 @@ function uiPlugin(config = {}) {
6037
6103
  return new FullscreenButton(api);
6038
6104
  case "spacer":
6039
6105
  return new Spacer();
6040
- default:
6106
+ default: {
6107
+ const factory = getControlFactory(slot);
6108
+ if (factory) {
6109
+ try {
6110
+ return factory(api);
6111
+ } catch (error) {
6112
+ api.logger.error(`Control factory for "${slot}" threw`, { error });
6113
+ return null;
6114
+ }
6115
+ }
6116
+ api.logger.warn(`Unknown control slot: ${slot}`);
6041
6117
  return null;
6118
+ }
6042
6119
  }
6043
6120
  };
6121
+ const populateControlBar = () => {
6122
+ if (!controlBar) {
6123
+ return;
6124
+ }
6125
+ for (const slot of layout) {
6126
+ const control = createControl(slot);
6127
+ if (control) {
6128
+ controls.push(control);
6129
+ controlBar.appendChild(control.render());
6130
+ }
6131
+ }
6132
+ };
6133
+ const rebuildControlBar = () => {
6134
+ if (!controlBar) {
6135
+ return;
6136
+ }
6137
+ controls.forEach((c) => c.destroy());
6138
+ controls = [];
6139
+ controlBar.replaceChildren();
6140
+ populateControlBar();
6141
+ updateControls();
6142
+ };
6044
6143
  const updateControls = () => {
6045
6144
  controls.forEach((c) => c.update());
6046
6145
  progressBar?.update();
@@ -6213,14 +6312,15 @@ function uiPlugin(config = {}) {
6213
6312
  controlBar.className = isPlaying ? "sp-controls sp-controls--hidden" : "sp-controls sp-controls--visible";
6214
6313
  controlBar.setAttribute("role", "toolbar");
6215
6314
  controlBar.setAttribute("aria-label", "Video controls");
6216
- for (const slot of layout) {
6217
- const control = createControl(slot);
6218
- if (control) {
6219
- controls.push(control);
6220
- controlBar.appendChild(control.render());
6221
- }
6222
- }
6315
+ populateControlBar();
6223
6316
  container.appendChild(controlBar);
6317
+ controlRegistryUnsubscribe = onControlRegistered((id) => {
6318
+ if (!layout.includes(id)) {
6319
+ return;
6320
+ }
6321
+ api.logger.debug(`Control "${id}" registered after init, rebuilding control bar`);
6322
+ rebuildControlBar();
6323
+ });
6224
6324
  container.addEventListener("mousemove", handleInteraction);
6225
6325
  container.addEventListener("mouseenter", handleInteraction);
6226
6326
  container.addEventListener("mouseleave", handleMouseLeave);
@@ -6266,6 +6366,8 @@ function uiPlugin(config = {}) {
6266
6366
  }
6267
6367
  document.removeEventListener("keydown", handleKeyDown);
6268
6368
  document.removeEventListener("fullscreenchange", scheduleUpdate);
6369
+ controlRegistryUnsubscribe?.();
6370
+ controlRegistryUnsubscribe = null;
6269
6371
  controls.forEach((c) => c.destroy());
6270
6372
  controls = [];
6271
6373
  progressBar?.destroy();
@@ -6511,10 +6613,18 @@ function createWatermarkPlugin(config = {}) {
6511
6613
  }
6512
6614
  };
6513
6615
  }
6616
+ var HLS_SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated";
6617
+ var HLS_INSTANCE_RETRY_MS = 500;
6514
6618
  function createCaptionsPlugin(config = {}) {
6515
6619
  let api = null;
6516
6620
  let video = null;
6517
6621
  let addedTrackElements = [];
6622
+ let hlsTrackElements = [];
6623
+ let hlsSubtitleHandler = null;
6624
+ let observedTextTracks = null;
6625
+ let hlsRetryTimer = null;
6626
+ let hlsRetryUsed = false;
6627
+ let hasAutoSelected = false;
6518
6628
  const extractFromHLS = config.extractFromHLS !== false;
6519
6629
  const autoSelect = config.autoSelect ?? false;
6520
6630
  const defaultLanguage = config.defaultLanguage ?? "en";
@@ -6528,10 +6638,18 @@ function createCaptionsPlugin(config = {}) {
6528
6638
  trackEl.parentNode?.removeChild(trackEl);
6529
6639
  }
6530
6640
  addedTrackElements = [];
6641
+ hlsTrackElements = [];
6531
6642
  api?.setState("textTracks", []);
6532
6643
  api?.setState("currentTextTrack", null);
6533
6644
  };
6534
- const addTrackElement = (source) => {
6645
+ const removeHlsTrackElements = () => {
6646
+ for (const trackEl of hlsTrackElements) {
6647
+ trackEl.parentNode?.removeChild(trackEl);
6648
+ addedTrackElements = addedTrackElements.filter((el) => el !== trackEl);
6649
+ }
6650
+ hlsTrackElements = [];
6651
+ };
6652
+ const addTrackElement = (source, origin = "config") => {
6535
6653
  const videoEl = getVideo2();
6536
6654
  if (!videoEl) throw new Error("No video element");
6537
6655
  const trackEl = document.createElement("track");
@@ -6542,6 +6660,9 @@ function createCaptionsPlugin(config = {}) {
6542
6660
  trackEl.default = false;
6543
6661
  videoEl.appendChild(trackEl);
6544
6662
  addedTrackElements.push(trackEl);
6663
+ if (origin === "hls") {
6664
+ hlsTrackElements.push(trackEl);
6665
+ }
6545
6666
  if (trackEl.track) {
6546
6667
  trackEl.track.mode = "disabled";
6547
6668
  }
@@ -6573,6 +6694,7 @@ function createCaptionsPlugin(config = {}) {
6573
6694
  const selectTrack = (trackId) => {
6574
6695
  const videoEl = getVideo2();
6575
6696
  if (!videoEl) return;
6697
+ hasAutoSelected = true;
6576
6698
  for (let i = 0; i < videoEl.textTracks.length; i++) {
6577
6699
  const track = videoEl.textTracks[i];
6578
6700
  if (track.kind !== "subtitles" && track.kind !== "captions") continue;
@@ -6585,6 +6707,43 @@ function createCaptionsPlugin(config = {}) {
6585
6707
  }
6586
6708
  syncTracksToState();
6587
6709
  };
6710
+ const maybeAutoSelect = () => {
6711
+ if (!autoSelect || hasAutoSelected) return;
6712
+ if (api?.getState("currentTextTrack")) {
6713
+ hasAutoSelected = true;
6714
+ return;
6715
+ }
6716
+ const tracks = api?.getState("textTracks") || [];
6717
+ const match = tracks.find((t) => t.language === defaultLanguage);
6718
+ if (!match) return;
6719
+ selectTrack(match.id);
6720
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
6721
+ };
6722
+ const handleTextTracksChanged = () => {
6723
+ syncTracksToState();
6724
+ maybeAutoSelect();
6725
+ };
6726
+ const observeTextTracks = () => {
6727
+ const videoEl = getVideo2();
6728
+ if (!videoEl || observedTextTracks === videoEl.textTracks) return;
6729
+ unobserveTextTracks();
6730
+ const list = videoEl.textTracks;
6731
+ if (typeof list?.addEventListener !== "function") return;
6732
+ observedTextTracks = list;
6733
+ observedTextTracks.addEventListener("addtrack", handleTextTracksChanged);
6734
+ observedTextTracks.addEventListener("removetrack", handleTextTracksChanged);
6735
+ observedTextTracks.addEventListener("change", handleTextTracksChanged);
6736
+ };
6737
+ const unobserveTextTracks = () => {
6738
+ if (typeof observedTextTracks?.removeEventListener !== "function") {
6739
+ observedTextTracks = null;
6740
+ return;
6741
+ }
6742
+ observedTextTracks.removeEventListener("addtrack", handleTextTracksChanged);
6743
+ observedTextTracks.removeEventListener("removetrack", handleTextTracksChanged);
6744
+ observedTextTracks.removeEventListener("change", handleTextTracksChanged);
6745
+ observedTextTracks = null;
6746
+ };
6588
6747
  const extractHlsSubtitles = () => {
6589
6748
  if (!extractFromHLS || !api) return;
6590
6749
  const hlsPlugin = api.getPlugin("hls-provider");
@@ -6594,35 +6753,55 @@ function createCaptionsPlugin(config = {}) {
6594
6753
  api.logger.debug("Extracting HLS subtitle tracks", {
6595
6754
  count: hlsInstance.subtitleTracks.length
6596
6755
  });
6756
+ removeHlsTrackElements();
6597
6757
  for (const hlsTrack of hlsInstance.subtitleTracks) {
6598
- addTrackElement({
6599
- language: hlsTrack.lang || "unknown",
6600
- label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
6601
- src: hlsTrack.url,
6602
- kind: "subtitles"
6603
- });
6758
+ addTrackElement(
6759
+ {
6760
+ language: hlsTrack.lang || "unknown",
6761
+ label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
6762
+ src: hlsTrack.url,
6763
+ kind: "subtitles"
6764
+ },
6765
+ "hls"
6766
+ );
6604
6767
  }
6605
6768
  syncTracksToState();
6606
- if (autoSelect) {
6607
- autoSelectTrack();
6608
- }
6769
+ maybeAutoSelect();
6609
6770
  };
6610
- const autoSelectTrack = () => {
6611
- const tracks = api?.getState("textTracks") || [];
6612
- const match = tracks.find((t) => t.language === defaultLanguage);
6613
- if (match) {
6614
- selectTrack(match.id);
6615
- api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
6771
+ const unsubscribeFromHls = () => {
6772
+ if (hlsRetryTimer) {
6773
+ clearTimeout(hlsRetryTimer);
6774
+ hlsRetryTimer = null;
6775
+ }
6776
+ if (!hlsSubtitleHandler) return;
6777
+ const hlsInstance = api?.getPlugin("hls-provider")?.getHlsInstance();
6778
+ hlsInstance?.off(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
6779
+ hlsSubtitleHandler = null;
6780
+ };
6781
+ const syncFromHls = () => {
6782
+ if (!extractFromHLS || !api) return;
6783
+ const hlsPlugin = api.getPlugin("hls-provider");
6784
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
6785
+ const hlsInstance = hlsPlugin.getHlsInstance();
6786
+ if (!hlsInstance) {
6787
+ if (!hlsRetryUsed) {
6788
+ hlsRetryUsed = true;
6789
+ hlsRetryTimer = setTimeout(() => {
6790
+ hlsRetryTimer = null;
6791
+ syncFromHls();
6792
+ }, HLS_INSTANCE_RETRY_MS);
6793
+ }
6794
+ return;
6616
6795
  }
6796
+ unsubscribeFromHls();
6797
+ hlsSubtitleHandler = () => extractHlsSubtitles();
6798
+ hlsInstance.on(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
6799
+ extractHlsSubtitles();
6617
6800
  };
6618
6801
  const initSources = () => {
6619
6802
  if (!config.sources?.length) return;
6620
6803
  for (const source of config.sources) {
6621
- addTrackElement(source);
6622
- }
6623
- syncTracksToState();
6624
- if (autoSelect) {
6625
- autoSelectTrack();
6804
+ addTrackElement(source, "config");
6626
6805
  }
6627
6806
  };
6628
6807
  return {
@@ -6641,25 +6820,36 @@ function createCaptionsPlugin(config = {}) {
6641
6820
  });
6642
6821
  const unsubLoaded = api.on("media:loaded", () => {
6643
6822
  video = null;
6823
+ hasAutoSelected = false;
6824
+ hlsRetryUsed = false;
6644
6825
  cleanupTracks();
6645
6826
  initSources();
6646
- if (extractFromHLS) {
6647
- setTimeout(extractHlsSubtitles, 500);
6648
- }
6827
+ observeTextTracks();
6828
+ syncTracksToState();
6829
+ maybeAutoSelect();
6830
+ syncFromHls();
6649
6831
  });
6650
6832
  const unsubLoadRequest = api.on("media:load-request", () => {
6651
6833
  video = null;
6834
+ hasAutoSelected = false;
6835
+ hlsRetryUsed = false;
6836
+ unsubscribeFromHls();
6837
+ unobserveTextTracks();
6652
6838
  cleanupTracks();
6653
6839
  });
6654
6840
  api.onDestroy(() => {
6655
6841
  unsubTrackText();
6656
6842
  unsubLoaded();
6657
6843
  unsubLoadRequest();
6844
+ unsubscribeFromHls();
6845
+ unobserveTextTracks();
6658
6846
  cleanupTracks();
6659
6847
  });
6660
6848
  },
6661
6849
  destroy() {
6662
6850
  api?.logger.debug("Captions plugin destroyed");
6851
+ unsubscribeFromHls();
6852
+ unobserveTextTracks();
6663
6853
  cleanupTracks();
6664
6854
  video = null;
6665
6855
  api = null;