@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.
@@ -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
  }
@@ -4949,6 +5041,7 @@ class ScarlettPlayer {
4949
5041
  this.destroyed = false;
4950
5042
  this.seekingWhilePlaying = false;
4951
5043
  this.seekResumeTimeout = null;
5044
+ this.loadGeneration = 0;
4952
5045
  if (typeof options.container === "string") {
4953
5046
  const el = document.querySelector(options.container);
4954
5047
  if (!el || !(el instanceof HTMLElement)) {
@@ -5022,6 +5115,7 @@ class ScarlettPlayer {
5022
5115
  */
5023
5116
  async load(source) {
5024
5117
  this.checkDestroyed();
5118
+ const generation = ++this.loadGeneration;
5025
5119
  try {
5026
5120
  this.logger.info("Loading source", { source });
5027
5121
  this.stateManager.update({
@@ -5040,6 +5134,10 @@ class ScarlettPlayer {
5040
5134
  await this.pluginManager.destroyPlugin(previousProviderId);
5041
5135
  this._currentProvider = null;
5042
5136
  }
5137
+ if (generation !== this.loadGeneration) {
5138
+ this.logger.info("Load superseded by newer load call", { source });
5139
+ return;
5140
+ }
5043
5141
  const provider = this.pluginManager.selectProvider(source);
5044
5142
  if (!provider) {
5045
5143
  this.errorHandler.throw(
@@ -5055,18 +5153,28 @@ class ScarlettPlayer {
5055
5153
  this._currentProvider = provider;
5056
5154
  this.logger.info("Provider selected", { provider: provider.id });
5057
5155
  await this.pluginManager.initPlugin(provider.id);
5156
+ if (generation !== this.loadGeneration) {
5157
+ this.logger.info("Load superseded by newer load call", { source });
5158
+ return;
5159
+ }
5058
5160
  this.stateManager.set("source", { src: source, type: this.detectMimeType(source) });
5059
5161
  if (typeof provider.loadSource === "function") {
5060
5162
  await provider.loadSource(source);
5061
5163
  }
5164
+ if (generation !== this.loadGeneration) {
5165
+ this.logger.info("Load superseded by newer load call", { source });
5166
+ return;
5167
+ }
5062
5168
  if (this.stateManager.getValue("autoplay")) {
5063
5169
  await this.play();
5064
5170
  }
5065
5171
  } catch (error) {
5066
- this.errorHandler.handle(error, {
5067
- operation: "load",
5068
- source
5069
- });
5172
+ if (generation === this.loadGeneration) {
5173
+ this.errorHandler.handle(error, {
5174
+ operation: "load",
5175
+ source
5176
+ });
5177
+ }
5070
5178
  }
5071
5179
  }
5072
5180
  /**
@@ -5194,8 +5302,9 @@ class ScarlettPlayer {
5194
5302
  */
5195
5303
  setPlaybackRate(rate) {
5196
5304
  this.checkDestroyed();
5197
- this.stateManager.set("playbackRate", rate);
5198
- this.eventBus.emit("playback:ratechange", { rate });
5305
+ const clampedRate = Math.max(0.0625, Math.min(16, rate));
5306
+ this.stateManager.set("playbackRate", clampedRate);
5307
+ this.eventBus.emit("playback:ratechange", { rate: clampedRate });
5199
5308
  }
5200
5309
  /**
5201
5310
  * Set autoplay state.
@@ -5324,6 +5433,13 @@ class ScarlettPlayer {
5324
5433
  }
5325
5434
  const provider = this._currentProvider;
5326
5435
  if (typeof provider.setLevel === "function") {
5436
+ if (index !== -1) {
5437
+ const levels = this.getQualities();
5438
+ if (levels.length > 0 && (index < 0 || index >= levels.length)) {
5439
+ this.logger.warn(`Invalid quality index: ${index} (available: ${levels.length})`);
5440
+ return;
5441
+ }
5442
+ }
5327
5443
  provider.setLevel(index);
5328
5444
  this.eventBus.emit("quality:change", {
5329
5445
  quality: index === -1 ? "auto" : `level-${index}`,
@@ -5564,18 +5680,40 @@ class ScarlettPlayer {
5564
5680
  * @private
5565
5681
  */
5566
5682
  detectMimeType(source) {
5567
- const ext = source.split(".").pop()?.toLowerCase();
5683
+ let path = source;
5684
+ try {
5685
+ path = new URL(source).pathname;
5686
+ } catch {
5687
+ const noQuery = source.split("?")[0] ?? source;
5688
+ path = noQuery.split("#")[0] ?? noQuery;
5689
+ }
5690
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
5568
5691
  switch (ext) {
5569
5692
  case "m3u8":
5570
5693
  return "application/x-mpegURL";
5571
5694
  case "mpd":
5572
5695
  return "application/dash+xml";
5573
5696
  case "mp4":
5697
+ case "m4v":
5574
5698
  return "video/mp4";
5575
5699
  case "webm":
5576
5700
  return "video/webm";
5577
5701
  case "ogg":
5702
+ case "ogv":
5578
5703
  return "video/ogg";
5704
+ case "mov":
5705
+ return "video/quicktime";
5706
+ case "mkv":
5707
+ return "video/x-matroska";
5708
+ case "mp3":
5709
+ return "audio/mpeg";
5710
+ case "wav":
5711
+ return "audio/wav";
5712
+ case "flac":
5713
+ return "audio/flac";
5714
+ case "aac":
5715
+ case "m4a":
5716
+ return "audio/mp4";
5579
5717
  default:
5580
5718
  return "video/mp4";
5581
5719
  }