@scarlett-player/embed 0.5.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/embed.js CHANGED
@@ -43,6 +43,18 @@ function mapLevels(levels, _currentLevel) {
43
43
  codec: level.codecSet
44
44
  }));
45
45
  }
46
+ function getInitialBandwidthEstimate(overrideBps) {
47
+ const HLS_DEFAULT_ESTIMATE = 5e5;
48
+ if (overrideBps !== void 0 && overrideBps > 0) {
49
+ return overrideBps;
50
+ }
51
+ const connection = navigator.connection;
52
+ if (connection?.downlink && connection.downlink > 0) {
53
+ const bps = connection.downlink * 1e6;
54
+ return Math.round(bps * 0.85);
55
+ }
56
+ return HLS_DEFAULT_ESTIMATE;
57
+ }
46
58
  var HLS_ERROR_TYPES = {
47
59
  NETWORK_ERROR: "networkError",
48
60
  MEDIA_ERROR: "mediaError",
@@ -113,6 +125,14 @@ function setupHlsEventHandlers(hls, api, callbacks) {
113
125
  });
114
126
  callbacks.onLevelSwitched?.(data.level);
115
127
  });
128
+ let lastBandwidthUpdate = 0;
129
+ addHandler("hlsFragLoaded", () => {
130
+ const now = Date.now();
131
+ if (now - lastBandwidthUpdate >= 2e3 && hls.bandwidthEstimate) {
132
+ lastBandwidthUpdate = now;
133
+ api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
134
+ }
135
+ });
116
136
  addHandler("hlsFragBuffered", () => {
117
137
  api.setState("buffering", false);
118
138
  callbacks.onBufferUpdate?.();
@@ -123,12 +143,44 @@ function setupHlsEventHandlers(hls, api, callbacks) {
123
143
  addHandler("hlsLevelLoaded", (_event, data) => {
124
144
  if (data.details?.live !== void 0) {
125
145
  api.setState("live", data.details.live);
146
+ if (data.details.live) {
147
+ const video = hls.media;
148
+ if (video && video.seekable && video.seekable.length > 0) {
149
+ const start = video.seekable.start(0);
150
+ const end = video.seekable.end(video.seekable.length - 1);
151
+ api.setState("seekableRange", { start, end });
152
+ const threshold = (data.details.targetduration ?? 3) * 3;
153
+ const isAtLiveEdge = end - video.currentTime < threshold;
154
+ api.setState("liveEdge", isAtLiveEdge);
155
+ const latency = end - video.currentTime;
156
+ api.setState("liveLatency", Math.max(0, latency));
157
+ }
158
+ }
126
159
  callbacks.onLiveUpdate?.();
127
160
  }
128
161
  });
129
162
  addHandler("hlsError", (_event, data) => {
130
163
  const error = parseHlsError(data);
131
- api.logger.warn("HLS error", { error });
164
+ const isBufferHoleSeek = !error.fatal && (error.details?.includes("bufferStalledError") || data.reason?.includes("buffer holes"));
165
+ if (isBufferHoleSeek) {
166
+ api.logger.debug(`HLS buffer recovery: ${error.reason || error.details}`, {
167
+ details: error.details,
168
+ reason: error.reason
169
+ });
170
+ } else if (error.fatal) {
171
+ api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
172
+ type: error.type,
173
+ details: error.details,
174
+ url: error.url
175
+ });
176
+ } else {
177
+ api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
178
+ type: error.type,
179
+ details: error.details,
180
+ fatal: error.fatal,
181
+ url: error.url
182
+ });
183
+ }
132
184
  callbacks.onError?.(error);
133
185
  });
134
186
  return () => {
@@ -150,6 +202,8 @@ function setupVideoEventHandlers(video, api) {
150
202
  addHandler("playing", () => {
151
203
  api.setState("playing", true);
152
204
  api.setState("paused", false);
205
+ api.setState("waiting", false);
206
+ api.setState("buffering", false);
153
207
  api.setState("playbackState", "playing");
154
208
  });
155
209
  addHandler("pause", () => {
@@ -166,6 +220,15 @@ function setupVideoEventHandlers(video, api) {
166
220
  addHandler("timeupdate", () => {
167
221
  api.setState("currentTime", video.currentTime);
168
222
  api.emit("playback:timeupdate", { currentTime: video.currentTime });
223
+ const isLive = api.getState("live");
224
+ if (isLive && video.seekable && video.seekable.length > 0) {
225
+ const start = video.seekable.start(0);
226
+ const end = video.seekable.end(video.seekable.length - 1);
227
+ api.setState("seekableRange", { start, end });
228
+ const isAtLiveEdge = end - video.currentTime < 10;
229
+ api.setState("liveEdge", isAtLiveEdge);
230
+ api.setState("liveLatency", Math.max(0, end - video.currentTime));
231
+ }
169
232
  });
170
233
  addHandler("durationchange", () => {
171
234
  api.setState("duration", video.duration || 0);
@@ -312,6 +375,7 @@ var DEFAULT_CONFIG$4 = {
312
375
  maxMaxBufferLength: 600,
313
376
  backBufferLength: 30,
314
377
  enableWorker: true,
378
+ capLevelToPlayerSize: true,
315
379
  // Error recovery settings
316
380
  maxNetworkRetries: 3,
317
381
  maxMediaRetries: 2,
@@ -381,11 +445,13 @@ function createHLSPlugin(config) {
381
445
  startPosition: mergedConfig.startPosition,
382
446
  startLevel: -1,
383
447
  // Auto quality selection (ABR)
448
+ abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
384
449
  lowLatencyMode: mergedConfig.lowLatencyMode,
385
450
  maxBufferLength: mergedConfig.maxBufferLength,
386
451
  maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
387
452
  backBufferLength: mergedConfig.backBufferLength,
388
453
  enableWorker: mergedConfig.enableWorker,
454
+ capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
389
455
  // Minimize hls.js internal retries - we handle retries ourselves
390
456
  fragLoadingMaxRetry: 1,
391
457
  manifestLoadingMaxRetry: 1,
@@ -656,7 +722,10 @@ function createHLSPlugin(config) {
656
722
  currentSrc = src;
657
723
  api.setState("playbackState", "loading");
658
724
  api.setState("buffering", true);
659
- if (isHlsJsSupported()) {
725
+ if (api.getState("airplayActive") && supportsNativeHLS()) {
726
+ api.logger.info("Using native HLS (AirPlay active)");
727
+ await loadNative(src);
728
+ } else if (isHlsJsSupported()) {
660
729
  api.logger.info("Using hls.js for HLS playback");
661
730
  await loadWithHlsJs(src);
662
731
  } else if (supportsNativeHLS()) {
@@ -3198,6 +3267,37 @@ var CaptionsButton = class {
3198
3267
  this.el.remove();
3199
3268
  }
3200
3269
  };
3270
+ var ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/><line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" stroke-width="2"/></svg>`;
3271
+ var BandwidthIndicator = class {
3272
+ constructor(api) {
3273
+ this.api = api;
3274
+ this.el = createElement("div", { className: "sp-bandwidth-indicator" });
3275
+ this.el.innerHTML = ICON_SVG;
3276
+ this.el.setAttribute("aria-label", "Bandwidth is limiting video quality");
3277
+ this.el.setAttribute("title", "Bandwidth is limiting video quality");
3278
+ this.el.style.display = "none";
3279
+ }
3280
+ render() {
3281
+ return this.el;
3282
+ }
3283
+ update() {
3284
+ const bandwidth = this.api.getState("bandwidth");
3285
+ const qualities = this.api.getState("qualities");
3286
+ if (!bandwidth || !qualities || qualities.length === 0) {
3287
+ this.el.style.display = "none";
3288
+ return;
3289
+ }
3290
+ const highestBitrate = Math.max(...qualities.map((q) => q.bitrate));
3291
+ if (highestBitrate > 0 && bandwidth < highestBitrate) {
3292
+ this.el.style.display = "";
3293
+ } else {
3294
+ this.el.style.display = "none";
3295
+ }
3296
+ }
3297
+ destroy() {
3298
+ this.el.remove();
3299
+ }
3300
+ };
3201
3301
  var DEFAULT_LAYOUT = [
3202
3302
  "play",
3203
3303
  "skip-backward",
@@ -3205,6 +3305,7 @@ var DEFAULT_LAYOUT = [
3205
3305
  "volume",
3206
3306
  "time",
3207
3307
  "live-indicator",
3308
+ "bandwidth-indicator",
3208
3309
  "spacer",
3209
3310
  "settings",
3210
3311
  "captions",
@@ -3245,6 +3346,8 @@ function uiPlugin(config = {}) {
3245
3346
  return new TimeDisplay(api);
3246
3347
  case "live-indicator":
3247
3348
  return new LiveIndicator(api);
3349
+ case "bandwidth-indicator":
3350
+ return new BandwidthIndicator(api);
3248
3351
  case "quality":
3249
3352
  return new QualityMenu(api);
3250
3353
  case "settings":
@@ -4694,6 +4797,9 @@ function createAnalyticsPlugin(config) {
4694
4797
  fatal: error.fatal ?? false
4695
4798
  };
4696
4799
  session.errors.push(errorEvent);
4800
+ if (session.errors.length > 100) {
4801
+ session.errors = session.errors.slice(-100);
4802
+ }
4697
4803
  sendBeacon("error", {
4698
4804
  errorType: errorEvent.type,
4699
4805
  errorMessage: errorEvent.message,
@@ -4720,6 +4826,9 @@ function createAnalyticsPlugin(config) {
4720
4826
  height: currentQuality.height
4721
4827
  };
4722
4828
  session.bitrateHistory.push(bitrateChange);
4829
+ if (session.bitrateHistory.length > 500) {
4830
+ session.bitrateHistory = session.bitrateHistory.slice(-500);
4831
+ }
4723
4832
  if (currentQuality.bitrate > session.maxBitrate) {
4724
4833
  session.maxBitrate = currentQuality.bitrate;
4725
4834
  }
@@ -4826,7 +4935,9 @@ var DEFAULT_CONFIG$1 = {
4826
4935
  persist: false,
4827
4936
  persistKey: "scarlett-playlist",
4828
4937
  shuffle: false,
4829
- repeat: "none"
4938
+ repeat: "none",
4939
+ autoLoad: true,
4940
+ advanceDelay: 0
4830
4941
  };
4831
4942
  function generateId() {
4832
4943
  return `track-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
@@ -4970,6 +5081,9 @@ function createPlaylistPlugin(config) {
4970
5081
  }
4971
5082
  api?.setState("mediaType", track.type || "audio");
4972
5083
  emitChange();
5084
+ if (mergedConfig.autoLoad !== false && track.src) {
5085
+ api?.emit("media:load-request", { src: track.src, autoplay: true });
5086
+ }
4973
5087
  };
4974
5088
  const plugin = {
4975
5089
  id: "playlist",
@@ -4984,12 +5098,20 @@ function createPlaylistPlugin(config) {
4984
5098
  if (shuffle && tracks.length > 0) {
4985
5099
  generateShuffleOrder();
4986
5100
  }
5101
+ let advanceTimeout = null;
4987
5102
  const unsubEnded = api.on("playback:ended", () => {
4988
5103
  if (!mergedConfig.autoAdvance) return;
4989
5104
  const nextIdx = getNextIndex();
4990
5105
  if (nextIdx >= 0) {
4991
- api?.logger.debug("Auto-advancing to next track", { nextIdx });
4992
- setCurrentTrack(nextIdx);
5106
+ const advance = () => {
5107
+ api?.logger.debug("Auto-advancing to next track", { nextIdx });
5108
+ setCurrentTrack(nextIdx);
5109
+ };
5110
+ if (mergedConfig.advanceDelay) {
5111
+ advanceTimeout = setTimeout(advance, mergedConfig.advanceDelay);
5112
+ } else {
5113
+ advance();
5114
+ }
4993
5115
  } else {
4994
5116
  api?.logger.info("Playlist ended");
4995
5117
  api?.emit("playlist:ended", void 0);
@@ -4997,6 +5119,10 @@ function createPlaylistPlugin(config) {
4997
5119
  });
4998
5120
  api.onDestroy(() => {
4999
5121
  unsubEnded();
5122
+ if (advanceTimeout) {
5123
+ clearTimeout(advanceTimeout);
5124
+ advanceTimeout = null;
5125
+ }
5000
5126
  persistPlaylist();
5001
5127
  });
5002
5128
  },
@@ -5442,6 +5568,336 @@ function createMediaSessionPlugin(config) {
5442
5568
  };
5443
5569
  return plugin;
5444
5570
  }
5571
+ var POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right", "center"];
5572
+ var POSITION_STYLES = {
5573
+ "top-left": "top:10px;left:10px;",
5574
+ "top-right": "top:10px;right:10px;",
5575
+ "bottom-left": "bottom:40px;left:10px;",
5576
+ "bottom-right": "bottom:40px;right:10px;",
5577
+ "center": "top:50%;left:50%;transform:translate(-50%,-50%);"
5578
+ };
5579
+ function createWatermarkPlugin(config = {}) {
5580
+ let api = null;
5581
+ let element = null;
5582
+ let dynamicTimer = null;
5583
+ let showDelayTimer = null;
5584
+ let currentPosition = config.position || "bottom-right";
5585
+ const opacity = config.opacity ?? 0.5;
5586
+ const fontSize = config.fontSize ?? 14;
5587
+ const dynamic = config.dynamic ?? false;
5588
+ const dynamicInterval = config.dynamicInterval ?? 1e4;
5589
+ const showDelay = config.showDelay ?? 0;
5590
+ const createElement2 = () => {
5591
+ const el = document.createElement("div");
5592
+ el.className = "sp-watermark sp-watermark--hidden";
5593
+ el.style.cssText = `position:absolute;z-index:10;pointer-events:none;opacity:${opacity};font-size:${fontSize}px;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,0.6);font-family:sans-serif;transition:all 0.5s ease;${POSITION_STYLES[currentPosition]}`;
5594
+ el.setAttribute("data-position", currentPosition);
5595
+ updateContent(el);
5596
+ return el;
5597
+ };
5598
+ const updateContent = (el, imageUrl, text) => {
5599
+ const img = imageUrl || config.imageUrl;
5600
+ const txt = text || config.text;
5601
+ el.innerHTML = "";
5602
+ if (img) {
5603
+ const imgEl = document.createElement("img");
5604
+ imgEl.src = img;
5605
+ imgEl.style.cssText = `max-height:${fontSize * 2}px;opacity:inherit;display:block;`;
5606
+ imgEl.alt = "";
5607
+ el.appendChild(imgEl);
5608
+ } else if (txt) {
5609
+ el.textContent = txt;
5610
+ }
5611
+ };
5612
+ const setPosition = (position) => {
5613
+ if (!element) return;
5614
+ currentPosition = position;
5615
+ element.style.top = "";
5616
+ element.style.right = "";
5617
+ element.style.bottom = "";
5618
+ element.style.left = "";
5619
+ element.style.transform = "";
5620
+ const styles2 = POSITION_STYLES[position];
5621
+ styles2.split(";").filter(Boolean).forEach((rule) => {
5622
+ const colonIdx = rule.indexOf(":");
5623
+ if (colonIdx === -1) return;
5624
+ const prop = rule.slice(0, colonIdx).trim();
5625
+ const val = rule.slice(colonIdx + 1).trim();
5626
+ if (prop && val) {
5627
+ element.style.setProperty(prop, val);
5628
+ }
5629
+ });
5630
+ element.setAttribute("data-position", position);
5631
+ const isVisible = element.classList.contains("sp-watermark--visible");
5632
+ const visClass = isVisible ? " sp-watermark--visible" : " sp-watermark--hidden";
5633
+ element.className = `sp-watermark sp-watermark--${position}${visClass}${dynamic ? " sp-watermark--dynamic" : ""}`;
5634
+ };
5635
+ const randomizePosition = () => {
5636
+ const available = POSITIONS.filter((p) => p !== currentPosition);
5637
+ const next = available[Math.floor(Math.random() * available.length)];
5638
+ setPosition(next);
5639
+ };
5640
+ const show = () => {
5641
+ if (!element) return;
5642
+ element.classList.remove("sp-watermark--hidden");
5643
+ element.classList.add("sp-watermark--visible");
5644
+ };
5645
+ const hide = () => {
5646
+ if (!element) return;
5647
+ element.classList.remove("sp-watermark--visible");
5648
+ element.classList.add("sp-watermark--hidden");
5649
+ };
5650
+ const startDynamic = () => {
5651
+ if (!dynamic || dynamicTimer) return;
5652
+ dynamicTimer = setInterval(randomizePosition, dynamicInterval);
5653
+ };
5654
+ const stopDynamic = () => {
5655
+ if (dynamicTimer) {
5656
+ clearInterval(dynamicTimer);
5657
+ dynamicTimer = null;
5658
+ }
5659
+ };
5660
+ const cleanup = () => {
5661
+ stopDynamic();
5662
+ if (showDelayTimer) {
5663
+ clearTimeout(showDelayTimer);
5664
+ showDelayTimer = null;
5665
+ }
5666
+ if (element?.parentNode) {
5667
+ element.parentNode.removeChild(element);
5668
+ }
5669
+ element = null;
5670
+ };
5671
+ return {
5672
+ id: "watermark",
5673
+ name: "Watermark",
5674
+ version: "1.0.0",
5675
+ type: "feature",
5676
+ description: "Anti-piracy watermark overlay with text/image support and dynamic repositioning",
5677
+ init(pluginApi) {
5678
+ api = pluginApi;
5679
+ api.logger.debug("Watermark plugin initialized");
5680
+ element = createElement2();
5681
+ api.container.appendChild(element);
5682
+ const unsubPlay = api.on("playback:play", () => {
5683
+ if (showDelay > 0) {
5684
+ showDelayTimer = setTimeout(() => {
5685
+ show();
5686
+ startDynamic();
5687
+ }, showDelay);
5688
+ } else {
5689
+ show();
5690
+ startDynamic();
5691
+ }
5692
+ });
5693
+ const unsubPause = api.on("playback:pause", () => {
5694
+ hide();
5695
+ stopDynamic();
5696
+ if (showDelayTimer) {
5697
+ clearTimeout(showDelayTimer);
5698
+ showDelayTimer = null;
5699
+ }
5700
+ });
5701
+ const unsubEnded = api.on("playback:ended", () => {
5702
+ hide();
5703
+ stopDynamic();
5704
+ });
5705
+ const unsubChange = api.on("playlist:change", ({ track }) => {
5706
+ if (!element || !track) return;
5707
+ const metadata = track.metadata;
5708
+ if (metadata) {
5709
+ const watermarkUrl = metadata.watermarkUrl;
5710
+ const watermarkText = metadata.watermarkText;
5711
+ if (watermarkUrl || watermarkText) {
5712
+ updateContent(element, watermarkUrl, watermarkText);
5713
+ }
5714
+ }
5715
+ });
5716
+ api.onDestroy(() => {
5717
+ unsubPlay();
5718
+ unsubPause();
5719
+ unsubEnded();
5720
+ unsubChange();
5721
+ cleanup();
5722
+ });
5723
+ },
5724
+ destroy() {
5725
+ api?.logger.debug("Watermark plugin destroyed");
5726
+ cleanup();
5727
+ api = null;
5728
+ },
5729
+ setText(text) {
5730
+ if (element) updateContent(element, void 0, text);
5731
+ },
5732
+ setImage(imageUrl) {
5733
+ if (element) updateContent(element, imageUrl);
5734
+ },
5735
+ setPosition,
5736
+ setOpacity(value) {
5737
+ if (element) element.style.opacity = String(Math.max(0, Math.min(1, value)));
5738
+ },
5739
+ show,
5740
+ hide,
5741
+ getConfig() {
5742
+ return { ...config, position: currentPosition, opacity: element ? parseFloat(element.style.opacity) || opacity : opacity };
5743
+ }
5744
+ };
5745
+ }
5746
+ function createCaptionsPlugin(config = {}) {
5747
+ let api = null;
5748
+ let video = null;
5749
+ let addedTrackElements = [];
5750
+ const extractFromHLS = config.extractFromHLS !== false;
5751
+ const autoSelect = config.autoSelect ?? false;
5752
+ const defaultLanguage = config.defaultLanguage ?? "en";
5753
+ const getVideo2 = () => {
5754
+ if (video) return video;
5755
+ video = api?.container.querySelector("video") ?? null;
5756
+ return video;
5757
+ };
5758
+ const cleanupTracks = () => {
5759
+ for (const trackEl of addedTrackElements) {
5760
+ trackEl.parentNode?.removeChild(trackEl);
5761
+ }
5762
+ addedTrackElements = [];
5763
+ api?.setState("textTracks", []);
5764
+ api?.setState("currentTextTrack", null);
5765
+ };
5766
+ const addTrackElement = (source) => {
5767
+ const videoEl = getVideo2();
5768
+ if (!videoEl) throw new Error("No video element");
5769
+ const trackEl = document.createElement("track");
5770
+ trackEl.kind = source.kind || "subtitles";
5771
+ trackEl.label = source.label;
5772
+ trackEl.srclang = source.language;
5773
+ trackEl.src = source.src;
5774
+ trackEl.default = false;
5775
+ videoEl.appendChild(trackEl);
5776
+ addedTrackElements.push(trackEl);
5777
+ if (trackEl.track) {
5778
+ trackEl.track.mode = "disabled";
5779
+ }
5780
+ return trackEl;
5781
+ };
5782
+ const syncTracksToState = () => {
5783
+ const videoEl = getVideo2();
5784
+ if (!videoEl) return;
5785
+ const tracks = [];
5786
+ let currentTrack = null;
5787
+ for (let i = 0; i < videoEl.textTracks.length; i++) {
5788
+ const track = videoEl.textTracks[i];
5789
+ if (track.kind !== "subtitles" && track.kind !== "captions") continue;
5790
+ const scarlettTrack = {
5791
+ id: `track-${i}`,
5792
+ label: track.label || `Track ${i + 1}`,
5793
+ language: track.language || "",
5794
+ kind: track.kind,
5795
+ active: track.mode === "showing"
5796
+ };
5797
+ tracks.push(scarlettTrack);
5798
+ if (track.mode === "showing") {
5799
+ currentTrack = scarlettTrack;
5800
+ }
5801
+ }
5802
+ api?.setState("textTracks", tracks);
5803
+ api?.setState("currentTextTrack", currentTrack);
5804
+ };
5805
+ const selectTrack = (trackId) => {
5806
+ const videoEl = getVideo2();
5807
+ if (!videoEl) return;
5808
+ for (let i = 0; i < videoEl.textTracks.length; i++) {
5809
+ const track = videoEl.textTracks[i];
5810
+ if (track.kind !== "subtitles" && track.kind !== "captions") continue;
5811
+ const id = `track-${i}`;
5812
+ if (trackId && id === trackId) {
5813
+ track.mode = "showing";
5814
+ } else {
5815
+ track.mode = "disabled";
5816
+ }
5817
+ }
5818
+ syncTracksToState();
5819
+ };
5820
+ const extractHlsSubtitles = () => {
5821
+ if (!extractFromHLS || !api) return;
5822
+ const hlsPlugin = api.getPlugin("hls-provider");
5823
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
5824
+ const hlsInstance = hlsPlugin.getHlsInstance();
5825
+ if (!hlsInstance?.subtitleTracks?.length) return;
5826
+ api.logger.debug("Extracting HLS subtitle tracks", {
5827
+ count: hlsInstance.subtitleTracks.length
5828
+ });
5829
+ for (const hlsTrack of hlsInstance.subtitleTracks) {
5830
+ addTrackElement({
5831
+ language: hlsTrack.lang || "unknown",
5832
+ label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
5833
+ src: hlsTrack.url,
5834
+ kind: "subtitles"
5835
+ });
5836
+ }
5837
+ syncTracksToState();
5838
+ if (autoSelect) {
5839
+ autoSelectTrack();
5840
+ }
5841
+ };
5842
+ const autoSelectTrack = () => {
5843
+ const tracks = api?.getState("textTracks") || [];
5844
+ const match = tracks.find((t) => t.language === defaultLanguage);
5845
+ if (match) {
5846
+ selectTrack(match.id);
5847
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
5848
+ }
5849
+ };
5850
+ const initSources = () => {
5851
+ if (!config.sources?.length) return;
5852
+ for (const source of config.sources) {
5853
+ addTrackElement(source);
5854
+ }
5855
+ syncTracksToState();
5856
+ if (autoSelect) {
5857
+ autoSelectTrack();
5858
+ }
5859
+ };
5860
+ return {
5861
+ id: "captions",
5862
+ name: "Captions",
5863
+ version: "1.0.0",
5864
+ type: "feature",
5865
+ description: "WebVTT subtitles and closed captions with HLS extraction",
5866
+ init(pluginApi) {
5867
+ api = pluginApi;
5868
+ api.logger.debug("Captions plugin initialized");
5869
+ api.setState("textTracks", []);
5870
+ api.setState("currentTextTrack", null);
5871
+ const unsubTrackText = api.on("track:text", ({ trackId }) => {
5872
+ selectTrack(trackId);
5873
+ });
5874
+ const unsubLoaded = api.on("media:loaded", () => {
5875
+ video = null;
5876
+ cleanupTracks();
5877
+ initSources();
5878
+ if (extractFromHLS) {
5879
+ setTimeout(extractHlsSubtitles, 500);
5880
+ }
5881
+ });
5882
+ const unsubLoadRequest = api.on("media:load-request", () => {
5883
+ video = null;
5884
+ cleanupTracks();
5885
+ });
5886
+ api.onDestroy(() => {
5887
+ unsubTrackText();
5888
+ unsubLoaded();
5889
+ unsubLoadRequest();
5890
+ cleanupTracks();
5891
+ });
5892
+ },
5893
+ destroy() {
5894
+ api?.logger.debug("Captions plugin destroyed");
5895
+ cleanupTracks();
5896
+ video = null;
5897
+ api = null;
5898
+ }
5899
+ };
5900
+ }
5445
5901
  class Signal {
5446
5902
  constructor(initialValue) {
5447
5903
  this.subscribers = /* @__PURE__ */ new Set();
@@ -6981,6 +7437,13 @@ class ScarlettPlayer {
6981
7437
  await this.pluginManager.initPlugin(id);
6982
7438
  }
6983
7439
  }
7440
+ this.eventBus.on("media:load-request", async ({ src, autoplay }) => {
7441
+ if (this.stateManager.getValue("chromecastActive")) return;
7442
+ await this.load(src);
7443
+ if (autoplay !== false) {
7444
+ await this.play();
7445
+ }
7446
+ });
6984
7447
  if (this.initialSrc) {
6985
7448
  await this.load(this.initialSrc);
6986
7449
  }
@@ -7188,8 +7651,9 @@ class ScarlettPlayer {
7188
7651
  */
7189
7652
  setPlaybackRate(rate) {
7190
7653
  this.checkDestroyed();
7191
- this.stateManager.set("playbackRate", rate);
7192
- this.eventBus.emit("playback:ratechange", { rate });
7654
+ const clampedRate = Math.max(0.0625, Math.min(16, rate));
7655
+ this.stateManager.set("playbackRate", clampedRate);
7656
+ this.eventBus.emit("playback:ratechange", { rate: clampedRate });
7193
7657
  }
7194
7658
  /**
7195
7659
  * Set autoplay state.
@@ -7318,6 +7782,13 @@ class ScarlettPlayer {
7318
7782
  }
7319
7783
  const provider = this._currentProvider;
7320
7784
  if (typeof provider.setLevel === "function") {
7785
+ if (index !== -1) {
7786
+ const levels = this.getQualities();
7787
+ if (levels.length > 0 && (index < 0 || index >= levels.length)) {
7788
+ this.logger.warn(`Invalid quality index: ${index} (available: ${levels.length})`);
7789
+ return;
7790
+ }
7791
+ }
7321
7792
  provider.setLevel(index);
7322
7793
  this.eventBus.emit("quality:change", {
7323
7794
  quality: index === -1 ? "auto" : `level-${index}`,
@@ -7558,18 +8029,40 @@ class ScarlettPlayer {
7558
8029
  * @private
7559
8030
  */
7560
8031
  detectMimeType(source) {
7561
- const ext = source.split(".").pop()?.toLowerCase();
8032
+ let path = source;
8033
+ try {
8034
+ path = new URL(source).pathname;
8035
+ } catch {
8036
+ const noQuery = source.split("?")[0] ?? source;
8037
+ path = noQuery.split("#")[0] ?? noQuery;
8038
+ }
8039
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
7562
8040
  switch (ext) {
7563
8041
  case "m3u8":
7564
8042
  return "application/x-mpegURL";
7565
8043
  case "mpd":
7566
8044
  return "application/dash+xml";
7567
8045
  case "mp4":
8046
+ case "m4v":
7568
8047
  return "video/mp4";
7569
8048
  case "webm":
7570
8049
  return "video/webm";
7571
8050
  case "ogg":
8051
+ case "ogv":
7572
8052
  return "video/ogg";
8053
+ case "mov":
8054
+ return "video/quicktime";
8055
+ case "mkv":
8056
+ return "video/x-matroska";
8057
+ case "mp3":
8058
+ return "audio/mpeg";
8059
+ case "wav":
8060
+ return "audio/wav";
8061
+ case "flac":
8062
+ return "audio/flac";
8063
+ case "aac":
8064
+ case "m4a":
8065
+ return "audio/mp4";
7573
8066
  default:
7574
8067
  return "video/mp4";
7575
8068
  }
@@ -7778,6 +8271,12 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
7778
8271
  artwork: config.artwork || config.poster || config.playlist?.[0]?.artwork
7779
8272
  }));
7780
8273
  }
8274
+ if (pluginCreators2.watermark && config.watermark) {
8275
+ plugins.push(pluginCreators2.watermark(config.watermark));
8276
+ }
8277
+ if (pluginCreators2.captions) {
8278
+ plugins.push(pluginCreators2.captions(config.captions || {}));
8279
+ }
7781
8280
  if (pluginCreators2.analytics && config.analytics?.beaconUrl) {
7782
8281
  plugins.push(pluginCreators2.analytics({
7783
8282
  beaconUrl: config.analytics.beaconUrl,
@@ -7895,7 +8394,7 @@ function setupAutoInit(pluginCreators2, availableTypes) {
7895
8394
  }
7896
8395
  }
7897
8396
  }
7898
- const VERSION = "0.3.0";
8397
+ const VERSION = "0.5.3";
7899
8398
  const AVAILABLE_TYPES = ["video", "audio", "audio-mini"];
7900
8399
  const pluginCreators = {
7901
8400
  hls: createHLSPlugin,
@@ -7903,7 +8402,9 @@ const pluginCreators = {
7903
8402
  audioUI: createAudioUIPlugin,
7904
8403
  analytics: createAnalyticsPlugin,
7905
8404
  playlist: createPlaylistPlugin,
7906
- mediaSession: createMediaSessionPlugin
8405
+ mediaSession: createMediaSessionPlugin,
8406
+ watermark: createWatermarkPlugin,
8407
+ captions: createCaptionsPlugin
7907
8408
  };
7908
8409
  const ScarlettPlayerAPI = createScarlettPlayerAPI(
7909
8410
  pluginCreators,