@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.
@@ -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 = {
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":
@@ -3513,6 +3616,336 @@ function uiPlugin(config = {}) {
3513
3616
  }
3514
3617
  };
3515
3618
  }
3619
+ var POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right", "center"];
3620
+ var POSITION_STYLES = {
3621
+ "top-left": "top:10px;left:10px;",
3622
+ "top-right": "top:10px;right:10px;",
3623
+ "bottom-left": "bottom:40px;left:10px;",
3624
+ "bottom-right": "bottom:40px;right:10px;",
3625
+ "center": "top:50%;left:50%;transform:translate(-50%,-50%);"
3626
+ };
3627
+ function createWatermarkPlugin(config = {}) {
3628
+ let api = null;
3629
+ let element = null;
3630
+ let dynamicTimer = null;
3631
+ let showDelayTimer = null;
3632
+ let currentPosition = config.position || "bottom-right";
3633
+ const opacity = config.opacity ?? 0.5;
3634
+ const fontSize = config.fontSize ?? 14;
3635
+ const dynamic = config.dynamic ?? false;
3636
+ const dynamicInterval = config.dynamicInterval ?? 1e4;
3637
+ const showDelay = config.showDelay ?? 0;
3638
+ const createElement2 = () => {
3639
+ const el = document.createElement("div");
3640
+ el.className = "sp-watermark sp-watermark--hidden";
3641
+ 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]}`;
3642
+ el.setAttribute("data-position", currentPosition);
3643
+ updateContent(el);
3644
+ return el;
3645
+ };
3646
+ const updateContent = (el, imageUrl, text) => {
3647
+ const img = imageUrl || config.imageUrl;
3648
+ const txt = text || config.text;
3649
+ el.innerHTML = "";
3650
+ if (img) {
3651
+ const imgEl = document.createElement("img");
3652
+ imgEl.src = img;
3653
+ imgEl.style.cssText = `max-height:${fontSize * 2}px;opacity:inherit;display:block;`;
3654
+ imgEl.alt = "";
3655
+ el.appendChild(imgEl);
3656
+ } else if (txt) {
3657
+ el.textContent = txt;
3658
+ }
3659
+ };
3660
+ const setPosition = (position) => {
3661
+ if (!element) return;
3662
+ currentPosition = position;
3663
+ element.style.top = "";
3664
+ element.style.right = "";
3665
+ element.style.bottom = "";
3666
+ element.style.left = "";
3667
+ element.style.transform = "";
3668
+ const styles2 = POSITION_STYLES[position];
3669
+ styles2.split(";").filter(Boolean).forEach((rule) => {
3670
+ const colonIdx = rule.indexOf(":");
3671
+ if (colonIdx === -1) return;
3672
+ const prop = rule.slice(0, colonIdx).trim();
3673
+ const val = rule.slice(colonIdx + 1).trim();
3674
+ if (prop && val) {
3675
+ element.style.setProperty(prop, val);
3676
+ }
3677
+ });
3678
+ element.setAttribute("data-position", position);
3679
+ const isVisible = element.classList.contains("sp-watermark--visible");
3680
+ const visClass = isVisible ? " sp-watermark--visible" : " sp-watermark--hidden";
3681
+ element.className = `sp-watermark sp-watermark--${position}${visClass}${dynamic ? " sp-watermark--dynamic" : ""}`;
3682
+ };
3683
+ const randomizePosition = () => {
3684
+ const available = POSITIONS.filter((p) => p !== currentPosition);
3685
+ const next = available[Math.floor(Math.random() * available.length)];
3686
+ setPosition(next);
3687
+ };
3688
+ const show = () => {
3689
+ if (!element) return;
3690
+ element.classList.remove("sp-watermark--hidden");
3691
+ element.classList.add("sp-watermark--visible");
3692
+ };
3693
+ const hide = () => {
3694
+ if (!element) return;
3695
+ element.classList.remove("sp-watermark--visible");
3696
+ element.classList.add("sp-watermark--hidden");
3697
+ };
3698
+ const startDynamic = () => {
3699
+ if (!dynamic || dynamicTimer) return;
3700
+ dynamicTimer = setInterval(randomizePosition, dynamicInterval);
3701
+ };
3702
+ const stopDynamic = () => {
3703
+ if (dynamicTimer) {
3704
+ clearInterval(dynamicTimer);
3705
+ dynamicTimer = null;
3706
+ }
3707
+ };
3708
+ const cleanup = () => {
3709
+ stopDynamic();
3710
+ if (showDelayTimer) {
3711
+ clearTimeout(showDelayTimer);
3712
+ showDelayTimer = null;
3713
+ }
3714
+ if (element?.parentNode) {
3715
+ element.parentNode.removeChild(element);
3716
+ }
3717
+ element = null;
3718
+ };
3719
+ return {
3720
+ id: "watermark",
3721
+ name: "Watermark",
3722
+ version: "1.0.0",
3723
+ type: "feature",
3724
+ description: "Anti-piracy watermark overlay with text/image support and dynamic repositioning",
3725
+ init(pluginApi) {
3726
+ api = pluginApi;
3727
+ api.logger.debug("Watermark plugin initialized");
3728
+ element = createElement2();
3729
+ api.container.appendChild(element);
3730
+ const unsubPlay = api.on("playback:play", () => {
3731
+ if (showDelay > 0) {
3732
+ showDelayTimer = setTimeout(() => {
3733
+ show();
3734
+ startDynamic();
3735
+ }, showDelay);
3736
+ } else {
3737
+ show();
3738
+ startDynamic();
3739
+ }
3740
+ });
3741
+ const unsubPause = api.on("playback:pause", () => {
3742
+ hide();
3743
+ stopDynamic();
3744
+ if (showDelayTimer) {
3745
+ clearTimeout(showDelayTimer);
3746
+ showDelayTimer = null;
3747
+ }
3748
+ });
3749
+ const unsubEnded = api.on("playback:ended", () => {
3750
+ hide();
3751
+ stopDynamic();
3752
+ });
3753
+ const unsubChange = api.on("playlist:change", ({ track }) => {
3754
+ if (!element || !track) return;
3755
+ const metadata = track.metadata;
3756
+ if (metadata) {
3757
+ const watermarkUrl = metadata.watermarkUrl;
3758
+ const watermarkText = metadata.watermarkText;
3759
+ if (watermarkUrl || watermarkText) {
3760
+ updateContent(element, watermarkUrl, watermarkText);
3761
+ }
3762
+ }
3763
+ });
3764
+ api.onDestroy(() => {
3765
+ unsubPlay();
3766
+ unsubPause();
3767
+ unsubEnded();
3768
+ unsubChange();
3769
+ cleanup();
3770
+ });
3771
+ },
3772
+ destroy() {
3773
+ api?.logger.debug("Watermark plugin destroyed");
3774
+ cleanup();
3775
+ api = null;
3776
+ },
3777
+ setText(text) {
3778
+ if (element) updateContent(element, void 0, text);
3779
+ },
3780
+ setImage(imageUrl) {
3781
+ if (element) updateContent(element, imageUrl);
3782
+ },
3783
+ setPosition,
3784
+ setOpacity(value) {
3785
+ if (element) element.style.opacity = String(Math.max(0, Math.min(1, value)));
3786
+ },
3787
+ show,
3788
+ hide,
3789
+ getConfig() {
3790
+ return { ...config, position: currentPosition, opacity: element ? parseFloat(element.style.opacity) || opacity : opacity };
3791
+ }
3792
+ };
3793
+ }
3794
+ function createCaptionsPlugin(config = {}) {
3795
+ let api = null;
3796
+ let video = null;
3797
+ let addedTrackElements = [];
3798
+ const extractFromHLS = config.extractFromHLS !== false;
3799
+ const autoSelect = config.autoSelect ?? false;
3800
+ const defaultLanguage = config.defaultLanguage ?? "en";
3801
+ const getVideo2 = () => {
3802
+ if (video) return video;
3803
+ video = api?.container.querySelector("video") ?? null;
3804
+ return video;
3805
+ };
3806
+ const cleanupTracks = () => {
3807
+ for (const trackEl of addedTrackElements) {
3808
+ trackEl.parentNode?.removeChild(trackEl);
3809
+ }
3810
+ addedTrackElements = [];
3811
+ api?.setState("textTracks", []);
3812
+ api?.setState("currentTextTrack", null);
3813
+ };
3814
+ const addTrackElement = (source) => {
3815
+ const videoEl = getVideo2();
3816
+ if (!videoEl) throw new Error("No video element");
3817
+ const trackEl = document.createElement("track");
3818
+ trackEl.kind = source.kind || "subtitles";
3819
+ trackEl.label = source.label;
3820
+ trackEl.srclang = source.language;
3821
+ trackEl.src = source.src;
3822
+ trackEl.default = false;
3823
+ videoEl.appendChild(trackEl);
3824
+ addedTrackElements.push(trackEl);
3825
+ if (trackEl.track) {
3826
+ trackEl.track.mode = "disabled";
3827
+ }
3828
+ return trackEl;
3829
+ };
3830
+ const syncTracksToState = () => {
3831
+ const videoEl = getVideo2();
3832
+ if (!videoEl) return;
3833
+ const tracks = [];
3834
+ let currentTrack = null;
3835
+ for (let i = 0; i < videoEl.textTracks.length; i++) {
3836
+ const track = videoEl.textTracks[i];
3837
+ if (track.kind !== "subtitles" && track.kind !== "captions") continue;
3838
+ const scarlettTrack = {
3839
+ id: `track-${i}`,
3840
+ label: track.label || `Track ${i + 1}`,
3841
+ language: track.language || "",
3842
+ kind: track.kind,
3843
+ active: track.mode === "showing"
3844
+ };
3845
+ tracks.push(scarlettTrack);
3846
+ if (track.mode === "showing") {
3847
+ currentTrack = scarlettTrack;
3848
+ }
3849
+ }
3850
+ api?.setState("textTracks", tracks);
3851
+ api?.setState("currentTextTrack", currentTrack);
3852
+ };
3853
+ const selectTrack = (trackId) => {
3854
+ const videoEl = getVideo2();
3855
+ if (!videoEl) return;
3856
+ for (let i = 0; i < videoEl.textTracks.length; i++) {
3857
+ const track = videoEl.textTracks[i];
3858
+ if (track.kind !== "subtitles" && track.kind !== "captions") continue;
3859
+ const id = `track-${i}`;
3860
+ if (trackId && id === trackId) {
3861
+ track.mode = "showing";
3862
+ } else {
3863
+ track.mode = "disabled";
3864
+ }
3865
+ }
3866
+ syncTracksToState();
3867
+ };
3868
+ const extractHlsSubtitles = () => {
3869
+ if (!extractFromHLS || !api) return;
3870
+ const hlsPlugin = api.getPlugin("hls-provider");
3871
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
3872
+ const hlsInstance = hlsPlugin.getHlsInstance();
3873
+ if (!hlsInstance?.subtitleTracks?.length) return;
3874
+ api.logger.debug("Extracting HLS subtitle tracks", {
3875
+ count: hlsInstance.subtitleTracks.length
3876
+ });
3877
+ for (const hlsTrack of hlsInstance.subtitleTracks) {
3878
+ addTrackElement({
3879
+ language: hlsTrack.lang || "unknown",
3880
+ label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
3881
+ src: hlsTrack.url,
3882
+ kind: "subtitles"
3883
+ });
3884
+ }
3885
+ syncTracksToState();
3886
+ if (autoSelect) {
3887
+ autoSelectTrack();
3888
+ }
3889
+ };
3890
+ const autoSelectTrack = () => {
3891
+ const tracks = api?.getState("textTracks") || [];
3892
+ const match = tracks.find((t) => t.language === defaultLanguage);
3893
+ if (match) {
3894
+ selectTrack(match.id);
3895
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
3896
+ }
3897
+ };
3898
+ const initSources = () => {
3899
+ if (!config.sources?.length) return;
3900
+ for (const source of config.sources) {
3901
+ addTrackElement(source);
3902
+ }
3903
+ syncTracksToState();
3904
+ if (autoSelect) {
3905
+ autoSelectTrack();
3906
+ }
3907
+ };
3908
+ return {
3909
+ id: "captions",
3910
+ name: "Captions",
3911
+ version: "1.0.0",
3912
+ type: "feature",
3913
+ description: "WebVTT subtitles and closed captions with HLS extraction",
3914
+ init(pluginApi) {
3915
+ api = pluginApi;
3916
+ api.logger.debug("Captions plugin initialized");
3917
+ api.setState("textTracks", []);
3918
+ api.setState("currentTextTrack", null);
3919
+ const unsubTrackText = api.on("track:text", ({ trackId }) => {
3920
+ selectTrack(trackId);
3921
+ });
3922
+ const unsubLoaded = api.on("media:loaded", () => {
3923
+ video = null;
3924
+ cleanupTracks();
3925
+ initSources();
3926
+ if (extractFromHLS) {
3927
+ setTimeout(extractHlsSubtitles, 500);
3928
+ }
3929
+ });
3930
+ const unsubLoadRequest = api.on("media:load-request", () => {
3931
+ video = null;
3932
+ cleanupTracks();
3933
+ });
3934
+ api.onDestroy(() => {
3935
+ unsubTrackText();
3936
+ unsubLoaded();
3937
+ unsubLoadRequest();
3938
+ cleanupTracks();
3939
+ });
3940
+ },
3941
+ destroy() {
3942
+ api?.logger.debug("Captions plugin destroyed");
3943
+ cleanupTracks();
3944
+ video = null;
3945
+ api = null;
3946
+ }
3947
+ };
3948
+ }
3516
3949
  class Signal {
3517
3950
  constructor(initialValue) {
3518
3951
  this.subscribers = /* @__PURE__ */ new Set();
@@ -5052,6 +5485,13 @@ class ScarlettPlayer {
5052
5485
  await this.pluginManager.initPlugin(id);
5053
5486
  }
5054
5487
  }
5488
+ this.eventBus.on("media:load-request", async ({ src, autoplay }) => {
5489
+ if (this.stateManager.getValue("chromecastActive")) return;
5490
+ await this.load(src);
5491
+ if (autoplay !== false) {
5492
+ await this.play();
5493
+ }
5494
+ });
5055
5495
  if (this.initialSrc) {
5056
5496
  await this.load(this.initialSrc);
5057
5497
  }
@@ -5259,8 +5699,9 @@ class ScarlettPlayer {
5259
5699
  */
5260
5700
  setPlaybackRate(rate) {
5261
5701
  this.checkDestroyed();
5262
- this.stateManager.set("playbackRate", rate);
5263
- this.eventBus.emit("playback:ratechange", { rate });
5702
+ const clampedRate = Math.max(0.0625, Math.min(16, rate));
5703
+ this.stateManager.set("playbackRate", clampedRate);
5704
+ this.eventBus.emit("playback:ratechange", { rate: clampedRate });
5264
5705
  }
5265
5706
  /**
5266
5707
  * Set autoplay state.
@@ -5389,6 +5830,13 @@ class ScarlettPlayer {
5389
5830
  }
5390
5831
  const provider = this._currentProvider;
5391
5832
  if (typeof provider.setLevel === "function") {
5833
+ if (index !== -1) {
5834
+ const levels = this.getQualities();
5835
+ if (levels.length > 0 && (index < 0 || index >= levels.length)) {
5836
+ this.logger.warn(`Invalid quality index: ${index} (available: ${levels.length})`);
5837
+ return;
5838
+ }
5839
+ }
5392
5840
  provider.setLevel(index);
5393
5841
  this.eventBus.emit("quality:change", {
5394
5842
  quality: index === -1 ? "auto" : `level-${index}`,
@@ -5629,18 +6077,40 @@ class ScarlettPlayer {
5629
6077
  * @private
5630
6078
  */
5631
6079
  detectMimeType(source) {
5632
- const ext = source.split(".").pop()?.toLowerCase();
6080
+ let path = source;
6081
+ try {
6082
+ path = new URL(source).pathname;
6083
+ } catch {
6084
+ const noQuery = source.split("?")[0] ?? source;
6085
+ path = noQuery.split("#")[0] ?? noQuery;
6086
+ }
6087
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
5633
6088
  switch (ext) {
5634
6089
  case "m3u8":
5635
6090
  return "application/x-mpegURL";
5636
6091
  case "mpd":
5637
6092
  return "application/dash+xml";
5638
6093
  case "mp4":
6094
+ case "m4v":
5639
6095
  return "video/mp4";
5640
6096
  case "webm":
5641
6097
  return "video/webm";
5642
6098
  case "ogg":
6099
+ case "ogv":
5643
6100
  return "video/ogg";
6101
+ case "mov":
6102
+ return "video/quicktime";
6103
+ case "mkv":
6104
+ return "video/x-matroska";
6105
+ case "mp3":
6106
+ return "audio/mpeg";
6107
+ case "wav":
6108
+ return "audio/wav";
6109
+ case "flac":
6110
+ return "audio/flac";
6111
+ case "aac":
6112
+ case "m4a":
6113
+ return "audio/mp4";
5644
6114
  default:
5645
6115
  return "video/mp4";
5646
6116
  }
@@ -5849,6 +6319,12 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
5849
6319
  artwork: config.artwork || config.poster || config.playlist?.[0]?.artwork
5850
6320
  }));
5851
6321
  }
6322
+ if (pluginCreators2.watermark && config.watermark) {
6323
+ plugins.push(pluginCreators2.watermark(config.watermark));
6324
+ }
6325
+ if (pluginCreators2.captions) {
6326
+ plugins.push(pluginCreators2.captions(config.captions || {}));
6327
+ }
5852
6328
  if (pluginCreators2.analytics && config.analytics?.beaconUrl) {
5853
6329
  plugins.push(pluginCreators2.analytics({
5854
6330
  beaconUrl: config.analytics.beaconUrl,
@@ -5966,11 +6442,13 @@ function setupAutoInit(pluginCreators2, availableTypes) {
5966
6442
  }
5967
6443
  }
5968
6444
  }
5969
- const VERSION = "0.3.0-video";
6445
+ const VERSION = "0.5.3-video";
5970
6446
  const AVAILABLE_TYPES = ["video"];
5971
6447
  const pluginCreators = {
5972
6448
  hls: createHLSPlugin,
5973
- videoUI: uiPlugin
6449
+ videoUI: uiPlugin,
6450
+ watermark: createWatermarkPlugin,
6451
+ captions: createCaptionsPlugin
5974
6452
  // Audio UI not available in this build
5975
6453
  // Analytics not available in this build
5976
6454
  // Playlist not available in this build