@scarlett-player/embed 0.5.1 → 0.5.3

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
@@ -123,12 +123,44 @@ function setupHlsEventHandlers(hls, api, callbacks) {
123
123
  addHandler("hlsLevelLoaded", (_event, data) => {
124
124
  if (data.details?.live !== void 0) {
125
125
  api.setState("live", data.details.live);
126
+ if (data.details.live) {
127
+ const video = hls.media;
128
+ if (video && video.seekable && video.seekable.length > 0) {
129
+ const start = video.seekable.start(0);
130
+ const end = video.seekable.end(video.seekable.length - 1);
131
+ api.setState("seekableRange", { start, end });
132
+ const threshold = (data.details.targetduration ?? 3) * 3;
133
+ const isAtLiveEdge = end - video.currentTime < threshold;
134
+ api.setState("liveEdge", isAtLiveEdge);
135
+ const latency = end - video.currentTime;
136
+ api.setState("liveLatency", Math.max(0, latency));
137
+ }
138
+ }
126
139
  callbacks.onLiveUpdate?.();
127
140
  }
128
141
  });
129
142
  addHandler("hlsError", (_event, data) => {
130
143
  const error = parseHlsError(data);
131
- api.logger.warn("HLS error", { error });
144
+ const isBufferHoleSeek = !error.fatal && (error.details?.includes("bufferStalledError") || data.reason?.includes("buffer holes"));
145
+ if (isBufferHoleSeek) {
146
+ api.logger.debug(`HLS buffer recovery: ${error.reason || error.details}`, {
147
+ details: error.details,
148
+ reason: error.reason
149
+ });
150
+ } else if (error.fatal) {
151
+ api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
152
+ type: error.type,
153
+ details: error.details,
154
+ url: error.url
155
+ });
156
+ } else {
157
+ api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
158
+ type: error.type,
159
+ details: error.details,
160
+ fatal: error.fatal,
161
+ url: error.url
162
+ });
163
+ }
132
164
  callbacks.onError?.(error);
133
165
  });
134
166
  return () => {
@@ -150,6 +182,8 @@ function setupVideoEventHandlers(video, api) {
150
182
  addHandler("playing", () => {
151
183
  api.setState("playing", true);
152
184
  api.setState("paused", false);
185
+ api.setState("waiting", false);
186
+ api.setState("buffering", false);
153
187
  api.setState("playbackState", "playing");
154
188
  });
155
189
  addHandler("pause", () => {
@@ -166,6 +200,15 @@ function setupVideoEventHandlers(video, api) {
166
200
  addHandler("timeupdate", () => {
167
201
  api.setState("currentTime", video.currentTime);
168
202
  api.emit("playback:timeupdate", { currentTime: video.currentTime });
203
+ const isLive = api.getState("live");
204
+ if (isLive && video.seekable && video.seekable.length > 0) {
205
+ const start = video.seekable.start(0);
206
+ const end = video.seekable.end(video.seekable.length - 1);
207
+ api.setState("seekableRange", { start, end });
208
+ const isAtLiveEdge = end - video.currentTime < 10;
209
+ api.setState("liveEdge", isAtLiveEdge);
210
+ api.setState("liveLatency", Math.max(0, end - video.currentTime));
211
+ }
169
212
  });
170
213
  addHandler("durationchange", () => {
171
214
  api.setState("duration", video.duration || 0);
@@ -1007,6 +1050,8 @@ var styles = `
1007
1050
  border-radius: 4px;
1008
1051
  transition: color 0.15s ease, transform 0.15s ease, background 0.15s ease;
1009
1052
  flex-shrink: 0;
1053
+ min-width: 44px;
1054
+ min-height: 44px;
1010
1055
  }
1011
1056
 
1012
1057
  @media (hover: hover) {
@@ -1945,6 +1990,9 @@ var ProgressBar = class {
1945
1990
  this.el.setAttribute("role", "slider");
1946
1991
  this.el.setAttribute("aria-label", "Seek");
1947
1992
  this.el.setAttribute("aria-valuemin", "0");
1993
+ this.el.setAttribute("aria-valuemax", "0");
1994
+ this.el.setAttribute("aria-valuenow", "0");
1995
+ this.el.setAttribute("aria-valuetext", "0:00");
1948
1996
  this.el.setAttribute("tabindex", "0");
1949
1997
  this.wrapper.addEventListener("mousedown", this.onMouseDown);
1950
1998
  this.wrapper.addEventListener("mousemove", this.onMouseMove);
@@ -2203,7 +2251,9 @@ var VolumeControl = class {
2203
2251
  this.btn.setAttribute("aria-label", label);
2204
2252
  const displayVolume = muted ? 0 : volume;
2205
2253
  this.level.style.width = `${displayVolume * 100}%`;
2206
- this.slider.setAttribute("aria-valuenow", String(Math.round(displayVolume * 100)));
2254
+ const volumePercent = Math.round(displayVolume * 100);
2255
+ this.slider.setAttribute("aria-valuenow", String(volumePercent));
2256
+ this.slider.setAttribute("aria-valuetext", `${volumePercent}%`);
2207
2257
  }
2208
2258
  toggleMute() {
2209
2259
  const video = getVideo(this.api.container);
@@ -2254,7 +2304,7 @@ var LiveIndicator = class {
2254
2304
  this.el.appendChild(this.dot);
2255
2305
  this.el.appendChild(this.label);
2256
2306
  this.el.setAttribute("role", "button");
2257
- this.el.setAttribute("aria-label", "Seek to live");
2307
+ this.el.setAttribute("aria-label", "Live broadcast - currently at live edge");
2258
2308
  this.el.setAttribute("tabindex", "0");
2259
2309
  this.el.addEventListener("click", this.handleClick);
2260
2310
  this.el.addEventListener("keydown", this.handleKeyDown);
@@ -2269,11 +2319,13 @@ var LiveIndicator = class {
2269
2319
  if (liveEdge) {
2270
2320
  this.el.classList.remove("sp-live--behind");
2271
2321
  this.label.textContent = "LIVE";
2272
- this.el.setAttribute("aria-label", "At live edge");
2322
+ this.dot.setAttribute("aria-hidden", "true");
2323
+ this.el.setAttribute("aria-label", "Live broadcast - currently at live edge");
2273
2324
  } else {
2274
2325
  this.el.classList.add("sp-live--behind");
2275
2326
  this.label.textContent = "GO LIVE";
2276
- this.el.setAttribute("aria-label", "Seek to live");
2327
+ this.dot.setAttribute("aria-hidden", "true");
2328
+ this.el.setAttribute("aria-label", "Live broadcast - behind live edge, click to seek to live");
2277
2329
  }
2278
2330
  }
2279
2331
  seekToLive() {
@@ -2751,6 +2803,18 @@ var SettingsMenu = class {
2751
2803
  this.close();
2752
2804
  this.btn.focus();
2753
2805
  }
2806
+ return;
2807
+ }
2808
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
2809
+ e.preventDefault();
2810
+ e.stopPropagation();
2811
+ this.navigateItems(e.key === "ArrowDown" ? 1 : -1);
2812
+ return;
2813
+ }
2814
+ if (e.key === "Tab") {
2815
+ e.preventDefault();
2816
+ e.stopPropagation();
2817
+ this.navigateItems(e.shiftKey ? -1 : 1);
2754
2818
  }
2755
2819
  };
2756
2820
  document.addEventListener("keydown", this.keyHandler);
@@ -2786,6 +2850,7 @@ var SettingsMenu = class {
2786
2850
  this.renderMainPanel();
2787
2851
  this.panel.classList.add("sp-settings-panel--open");
2788
2852
  this.btn.setAttribute("aria-expanded", "true");
2853
+ this.focusFirstItem();
2789
2854
  }
2790
2855
  close() {
2791
2856
  this.isOpen = false;
@@ -2809,6 +2874,7 @@ var SettingsMenu = class {
2809
2874
  this.renderCaptionsPanel();
2810
2875
  break;
2811
2876
  }
2877
+ this.focusFirstItem();
2812
2878
  }
2813
2879
  renderMainPanel() {
2814
2880
  this.panel.innerHTML = "";
@@ -3027,6 +3093,32 @@ var SettingsMenu = class {
3027
3093
  );
3028
3094
  });
3029
3095
  }
3096
+ getFocusableItems() {
3097
+ return Array.from(
3098
+ this.panel.querySelectorAll('[role="menuitem"]')
3099
+ );
3100
+ }
3101
+ focusFirstItem() {
3102
+ requestAnimationFrame(() => {
3103
+ const items = this.getFocusableItems();
3104
+ if (items.length > 0) {
3105
+ items[0].focus();
3106
+ }
3107
+ });
3108
+ }
3109
+ navigateItems(direction) {
3110
+ const items = this.getFocusableItems();
3111
+ if (items.length === 0) return;
3112
+ const active = document.activeElement;
3113
+ const currentIndex = items.indexOf(active);
3114
+ let nextIndex;
3115
+ if (currentIndex === -1) {
3116
+ nextIndex = direction === 1 ? 0 : items.length - 1;
3117
+ } else {
3118
+ nextIndex = (currentIndex + direction + items.length) % items.length;
3119
+ }
3120
+ items[nextIndex].focus();
3121
+ }
3030
3122
  getPanel() {
3031
3123
  return this.currentPanel;
3032
3124
  }
@@ -3690,6 +3782,8 @@ function createStyles(prefix, theme) {
3690
3782
  align-items: center;
3691
3783
  justify-content: center;
3692
3784
  transition: background 0.2s, transform 0.1s;
3785
+ min-width: 44px;
3786
+ min-height: 44px;
3693
3787
  }
3694
3788
 
3695
3789
  .${prefix}__btn:hover {
@@ -3839,6 +3933,8 @@ function createAudioUIPlugin(config) {
3839
3933
  document.head.appendChild(styleElement);
3840
3934
  container = document.createElement("div");
3841
3935
  container.className = `${prefix} ${prefix}--${layout}`;
3936
+ container.setAttribute("role", "region");
3937
+ container.setAttribute("aria-label", "Audio player");
3842
3938
  if (layout === "full") {
3843
3939
  container.innerHTML = buildFullLayout();
3844
3940
  } else if (layout === "compact") {
@@ -3873,24 +3969,24 @@ function createAudioUIPlugin(config) {
3873
3969
  ${mergedConfig.showArtist ? `<div class="${prefix}__artist">-</div>` : ""}
3874
3970
  </div>
3875
3971
  <div class="${prefix}__progress">
3876
- ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--current">0:00</span>` : ""}
3877
- <div class="${prefix}__progress-bar">
3972
+ ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--current" aria-label="Current time">0:00</span>` : ""}
3973
+ <div class="${prefix}__progress-bar" role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0" aria-valuetext="0:00" tabindex="0">
3878
3974
  <div class="${prefix}__progress-fill" style="transform: scaleX(0)"></div>
3879
3975
  </div>
3880
- ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--duration">0:00</span>` : ""}
3976
+ ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--duration" aria-label="Duration">0:00</span>` : ""}
3881
3977
  </div>
3882
- <div class="${prefix}__controls">
3883
- ${mergedConfig.showShuffle ? `<button class="${prefix}__btn ${prefix}__btn--shuffle" title="Shuffle">${ICONS.shuffle}</button>` : ""}
3884
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous">${ICONS.previous}</button>` : ""}
3885
- <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play">${ICONS.play}</button>
3886
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next">${ICONS.next}</button>` : ""}
3887
- ${mergedConfig.showRepeat ? `<button class="${prefix}__btn ${prefix}__btn--repeat" title="Repeat">${ICONS.repeatOff}</button>` : ""}
3978
+ <div class="${prefix}__controls" role="group" aria-label="Playback controls">
3979
+ ${mergedConfig.showShuffle ? `<button class="${prefix}__btn ${prefix}__btn--shuffle" title="Shuffle" aria-label="Shuffle" aria-pressed="false">${ICONS.shuffle}</button>` : ""}
3980
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous" aria-label="Previous track">${ICONS.previous}</button>` : ""}
3981
+ <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play" aria-label="Play">${ICONS.play}</button>
3982
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next" aria-label="Next track">${ICONS.next}</button>` : ""}
3983
+ ${mergedConfig.showRepeat ? `<button class="${prefix}__btn ${prefix}__btn--repeat" title="Repeat" aria-label="Repeat" aria-pressed="false">${ICONS.repeatOff}</button>` : ""}
3888
3984
  </div>
3889
3985
  ${mergedConfig.showVolume ? `
3890
3986
  <div class="${prefix}__secondary-controls">
3891
- <div class="${prefix}__volume">
3892
- <button class="${prefix}__btn ${prefix}__btn--volume" title="Volume">${ICONS.volumeHigh}</button>
3893
- <div class="${prefix}__volume-slider">
3987
+ <div class="${prefix}__volume" role="group" aria-label="Volume controls">
3988
+ <button class="${prefix}__btn ${prefix}__btn--volume" title="Volume" aria-label="Mute">${ICONS.volumeHigh}</button>
3989
+ <div class="${prefix}__volume-slider" role="slider" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" aria-valuetext="100%" tabindex="0">
3894
3990
  <div class="${prefix}__volume-fill" style="width: 100%"></div>
3895
3991
  </div>
3896
3992
  </div>
@@ -3909,21 +4005,21 @@ function createAudioUIPlugin(config) {
3909
4005
  ${mergedConfig.showTitle ? `<div class="${prefix}__title">-</div>` : ""}
3910
4006
  ${mergedConfig.showArtist ? `<div class="${prefix}__artist">-</div>` : ""}
3911
4007
  <div class="${prefix}__progress">
3912
- <div class="${prefix}__progress-bar">
4008
+ <div class="${prefix}__progress-bar" role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0" aria-valuetext="0:00" tabindex="0">
3913
4009
  <div class="${prefix}__progress-fill" style="transform: scaleX(0)"></div>
3914
4010
  </div>
3915
4011
  </div>
3916
4012
  </div>
3917
- <div class="${prefix}__controls">
3918
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous">${ICONS.previous}</button>` : ""}
3919
- <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play">${ICONS.play}</button>
3920
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next">${ICONS.next}</button>` : ""}
4013
+ <div class="${prefix}__controls" role="group" aria-label="Playback controls">
4014
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous" aria-label="Previous track">${ICONS.previous}</button>` : ""}
4015
+ <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play" aria-label="Play">${ICONS.play}</button>
4016
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next" aria-label="Next track">${ICONS.next}</button>` : ""}
3921
4017
  </div>
3922
4018
  `;
3923
4019
  };
3924
4020
  const buildMiniLayout = () => {
3925
4021
  return `
3926
- <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play">${ICONS.play}</button>
4022
+ <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play" aria-label="Play">${ICONS.play}</button>
3927
4023
  ${mergedConfig.showArtwork ? `
3928
4024
  <div class="${prefix}__artwork">
3929
4025
  <img src="${mergedConfig.defaultArtwork || ""}" alt="Album art" />
@@ -3932,7 +4028,7 @@ function createAudioUIPlugin(config) {
3932
4028
  <div class="${prefix}__info">
3933
4029
  ${mergedConfig.showTitle ? `<div class="${prefix}__title-wrapper"><div class="${prefix}__title">-</div></div>` : ""}
3934
4030
  <div class="${prefix}__progress">
3935
- <div class="${prefix}__progress-bar">
4031
+ <div class="${prefix}__progress-bar" role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0" aria-valuetext="0:00" tabindex="0">
3936
4032
  <div class="${prefix}__progress-fill" style="transform: scaleX(0)"></div>
3937
4033
  </div>
3938
4034
  </div>
@@ -3998,6 +4094,7 @@ function createAudioUIPlugin(config) {
3998
4094
  if (playPauseBtn) {
3999
4095
  playPauseBtn.innerHTML = playing ? ICONS.pause : ICONS.play;
4000
4096
  playPauseBtn.title = playing ? "Pause" : "Play";
4097
+ playPauseBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
4001
4098
  }
4002
4099
  const currentTime = api.getState("currentTime") || 0;
4003
4100
  const duration = api.getState("duration") || 0;
@@ -4020,6 +4117,12 @@ function createAudioUIPlugin(config) {
4020
4117
  if (durationEl) {
4021
4118
  durationEl.textContent = formatTime(duration);
4022
4119
  }
4120
+ const progressBar = container?.querySelector(`.${prefix}__progress-bar`);
4121
+ if (progressBar) {
4122
+ progressBar.setAttribute("aria-valuemax", String(Math.floor(duration)));
4123
+ progressBar.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
4124
+ progressBar.setAttribute("aria-valuetext", formatTime(currentTime));
4125
+ }
4023
4126
  const title = api.getState("title");
4024
4127
  const poster = api.getState("poster");
4025
4128
  if (titleEl && title) {
@@ -4044,21 +4147,34 @@ function createAudioUIPlugin(config) {
4044
4147
  }
4045
4148
  if (volumeBtn) {
4046
4149
  volumeBtn.innerHTML = muted || volume === 0 ? ICONS.volumeMuted : ICONS.volumeHigh;
4150
+ volumeBtn.setAttribute("aria-label", muted || volume === 0 ? "Unmute" : "Mute");
4151
+ }
4152
+ const volumeSlider = container?.querySelector(`.${prefix}__volume-slider`);
4153
+ if (volumeSlider) {
4154
+ const displayVolume = Math.round((muted ? 0 : volume) * 100);
4155
+ volumeSlider.setAttribute("aria-valuenow", String(displayVolume));
4156
+ volumeSlider.setAttribute("aria-valuetext", `${displayVolume}%`);
4047
4157
  }
4048
4158
  const playlist = api.getPlugin("playlist");
4049
4159
  if (playlist) {
4050
4160
  const state = playlist.getState();
4051
4161
  if (shuffleBtn) {
4052
4162
  shuffleBtn.classList.toggle(`${prefix}__btn--active`, state.shuffle);
4163
+ shuffleBtn.setAttribute("aria-pressed", String(state.shuffle));
4164
+ shuffleBtn.setAttribute("aria-label", state.shuffle ? "Shuffle on" : "Shuffle off");
4053
4165
  }
4054
4166
  if (repeatBtn) {
4055
4167
  repeatBtn.classList.toggle(`${prefix}__btn--active`, state.repeat !== "none");
4168
+ repeatBtn.setAttribute("aria-pressed", String(state.repeat !== "none"));
4056
4169
  if (state.repeat === "one") {
4057
4170
  repeatBtn.innerHTML = ICONS.repeatOne;
4171
+ repeatBtn.setAttribute("aria-label", "Repeat one");
4058
4172
  } else if (state.repeat === "all") {
4059
4173
  repeatBtn.innerHTML = ICONS.repeatAll;
4174
+ repeatBtn.setAttribute("aria-label", "Repeat all");
4060
4175
  } else {
4061
4176
  repeatBtn.innerHTML = ICONS.repeatOff;
4177
+ repeatBtn.setAttribute("aria-label", "Repeat off");
4062
4178
  }
4063
4179
  }
4064
4180
  }
@@ -4491,7 +4607,8 @@ function createAnalyticsPlugin(config) {
4491
4607
  const duration = nextTime - b.time;
4492
4608
  return sum + b.bitrate * duration;
4493
4609
  }, 0);
4494
- session.avgBitrate = Math.round(totalBitrateTime / session.watchTime);
4610
+ const timeSpan = now - session.bitrateHistory[0].time;
4611
+ session.avgBitrate = timeSpan > 0 ? Math.round(totalBitrateTime / timeSpan) : 0;
4495
4612
  }
4496
4613
  const state = {
4497
4614
  currentTime: api.getState("currentTime"),
@@ -4620,6 +4737,9 @@ function createAnalyticsPlugin(config) {
4620
4737
  fatal: error.fatal ?? false
4621
4738
  };
4622
4739
  session.errors.push(errorEvent);
4740
+ if (session.errors.length > 100) {
4741
+ session.errors = session.errors.slice(-100);
4742
+ }
4623
4743
  sendBeacon("error", {
4624
4744
  errorType: errorEvent.type,
4625
4745
  errorMessage: errorEvent.message,
@@ -4646,6 +4766,9 @@ function createAnalyticsPlugin(config) {
4646
4766
  height: currentQuality.height
4647
4767
  };
4648
4768
  session.bitrateHistory.push(bitrateChange);
4769
+ if (session.bitrateHistory.length > 500) {
4770
+ session.bitrateHistory = session.bitrateHistory.slice(-500);
4771
+ }
4649
4772
  if (currentQuality.bitrate > session.maxBitrate) {
4650
4773
  session.maxBitrate = currentQuality.bitrate;
4651
4774
  }
@@ -6853,6 +6976,7 @@ class ScarlettPlayer {
6853
6976
  this.destroyed = false;
6854
6977
  this.seekingWhilePlaying = false;
6855
6978
  this.seekResumeTimeout = null;
6979
+ this.loadGeneration = 0;
6856
6980
  if (typeof options.container === "string") {
6857
6981
  const el = document.querySelector(options.container);
6858
6982
  if (!el || !(el instanceof HTMLElement)) {
@@ -6926,6 +7050,7 @@ class ScarlettPlayer {
6926
7050
  */
6927
7051
  async load(source) {
6928
7052
  this.checkDestroyed();
7053
+ const generation = ++this.loadGeneration;
6929
7054
  try {
6930
7055
  this.logger.info("Loading source", { source });
6931
7056
  this.stateManager.update({
@@ -6944,6 +7069,10 @@ class ScarlettPlayer {
6944
7069
  await this.pluginManager.destroyPlugin(previousProviderId);
6945
7070
  this._currentProvider = null;
6946
7071
  }
7072
+ if (generation !== this.loadGeneration) {
7073
+ this.logger.info("Load superseded by newer load call", { source });
7074
+ return;
7075
+ }
6947
7076
  const provider = this.pluginManager.selectProvider(source);
6948
7077
  if (!provider) {
6949
7078
  this.errorHandler.throw(
@@ -6959,18 +7088,28 @@ class ScarlettPlayer {
6959
7088
  this._currentProvider = provider;
6960
7089
  this.logger.info("Provider selected", { provider: provider.id });
6961
7090
  await this.pluginManager.initPlugin(provider.id);
7091
+ if (generation !== this.loadGeneration) {
7092
+ this.logger.info("Load superseded by newer load call", { source });
7093
+ return;
7094
+ }
6962
7095
  this.stateManager.set("source", { src: source, type: this.detectMimeType(source) });
6963
7096
  if (typeof provider.loadSource === "function") {
6964
7097
  await provider.loadSource(source);
6965
7098
  }
7099
+ if (generation !== this.loadGeneration) {
7100
+ this.logger.info("Load superseded by newer load call", { source });
7101
+ return;
7102
+ }
6966
7103
  if (this.stateManager.getValue("autoplay")) {
6967
7104
  await this.play();
6968
7105
  }
6969
7106
  } catch (error) {
6970
- this.errorHandler.handle(error, {
6971
- operation: "load",
6972
- source
6973
- });
7107
+ if (generation === this.loadGeneration) {
7108
+ this.errorHandler.handle(error, {
7109
+ operation: "load",
7110
+ source
7111
+ });
7112
+ }
6974
7113
  }
6975
7114
  }
6976
7115
  /**
@@ -7098,8 +7237,9 @@ class ScarlettPlayer {
7098
7237
  */
7099
7238
  setPlaybackRate(rate) {
7100
7239
  this.checkDestroyed();
7101
- this.stateManager.set("playbackRate", rate);
7102
- this.eventBus.emit("playback:ratechange", { rate });
7240
+ const clampedRate = Math.max(0.0625, Math.min(16, rate));
7241
+ this.stateManager.set("playbackRate", clampedRate);
7242
+ this.eventBus.emit("playback:ratechange", { rate: clampedRate });
7103
7243
  }
7104
7244
  /**
7105
7245
  * Set autoplay state.
@@ -7228,6 +7368,13 @@ class ScarlettPlayer {
7228
7368
  }
7229
7369
  const provider = this._currentProvider;
7230
7370
  if (typeof provider.setLevel === "function") {
7371
+ if (index !== -1) {
7372
+ const levels = this.getQualities();
7373
+ if (levels.length > 0 && (index < 0 || index >= levels.length)) {
7374
+ this.logger.warn(`Invalid quality index: ${index} (available: ${levels.length})`);
7375
+ return;
7376
+ }
7377
+ }
7231
7378
  provider.setLevel(index);
7232
7379
  this.eventBus.emit("quality:change", {
7233
7380
  quality: index === -1 ? "auto" : `level-${index}`,
@@ -7468,18 +7615,40 @@ class ScarlettPlayer {
7468
7615
  * @private
7469
7616
  */
7470
7617
  detectMimeType(source) {
7471
- const ext = source.split(".").pop()?.toLowerCase();
7618
+ let path = source;
7619
+ try {
7620
+ path = new URL(source).pathname;
7621
+ } catch {
7622
+ const noQuery = source.split("?")[0] ?? source;
7623
+ path = noQuery.split("#")[0] ?? noQuery;
7624
+ }
7625
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
7472
7626
  switch (ext) {
7473
7627
  case "m3u8":
7474
7628
  return "application/x-mpegURL";
7475
7629
  case "mpd":
7476
7630
  return "application/dash+xml";
7477
7631
  case "mp4":
7632
+ case "m4v":
7478
7633
  return "video/mp4";
7479
7634
  case "webm":
7480
7635
  return "video/webm";
7481
7636
  case "ogg":
7637
+ case "ogv":
7482
7638
  return "video/ogg";
7639
+ case "mov":
7640
+ return "video/quicktime";
7641
+ case "mkv":
7642
+ return "video/x-matroska";
7643
+ case "mp3":
7644
+ return "audio/mpeg";
7645
+ case "wav":
7646
+ return "audio/wav";
7647
+ case "flac":
7648
+ return "audio/flac";
7649
+ case "aac":
7650
+ case "m4a":
7651
+ return "audio/mp4";
7483
7652
  default:
7484
7653
  return "video/mp4";
7485
7654
  }