@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);
@@ -1006,6 +1049,8 @@ function createStyles(prefix, theme) {
1006
1049
  align-items: center;
1007
1050
  justify-content: center;
1008
1051
  transition: background 0.2s, transform 0.1s;
1052
+ min-width: 44px;
1053
+ min-height: 44px;
1009
1054
  }
1010
1055
 
1011
1056
  .${prefix}__btn:hover {
@@ -1155,6 +1200,8 @@ function createAudioUIPlugin(config) {
1155
1200
  document.head.appendChild(styleElement);
1156
1201
  container = document.createElement("div");
1157
1202
  container.className = `${prefix} ${prefix}--${layout}`;
1203
+ container.setAttribute("role", "region");
1204
+ container.setAttribute("aria-label", "Audio player");
1158
1205
  if (layout === "full") {
1159
1206
  container.innerHTML = buildFullLayout();
1160
1207
  } else if (layout === "compact") {
@@ -1189,24 +1236,24 @@ function createAudioUIPlugin(config) {
1189
1236
  ${mergedConfig.showArtist ? `<div class="${prefix}__artist">-</div>` : ""}
1190
1237
  </div>
1191
1238
  <div class="${prefix}__progress">
1192
- ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--current">0:00</span>` : ""}
1193
- <div class="${prefix}__progress-bar">
1239
+ ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--current" aria-label="Current time">0:00</span>` : ""}
1240
+ <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">
1194
1241
  <div class="${prefix}__progress-fill" style="transform: scaleX(0)"></div>
1195
1242
  </div>
1196
- ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--duration">0:00</span>` : ""}
1243
+ ${mergedConfig.showTime ? `<span class="${prefix}__time ${prefix}__time--duration" aria-label="Duration">0:00</span>` : ""}
1197
1244
  </div>
1198
- <div class="${prefix}__controls">
1199
- ${mergedConfig.showShuffle ? `<button class="${prefix}__btn ${prefix}__btn--shuffle" title="Shuffle">${ICONS.shuffle}</button>` : ""}
1200
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous">${ICONS.previous}</button>` : ""}
1201
- <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play">${ICONS.play}</button>
1202
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next">${ICONS.next}</button>` : ""}
1203
- ${mergedConfig.showRepeat ? `<button class="${prefix}__btn ${prefix}__btn--repeat" title="Repeat">${ICONS.repeatOff}</button>` : ""}
1245
+ <div class="${prefix}__controls" role="group" aria-label="Playback controls">
1246
+ ${mergedConfig.showShuffle ? `<button class="${prefix}__btn ${prefix}__btn--shuffle" title="Shuffle" aria-label="Shuffle" aria-pressed="false">${ICONS.shuffle}</button>` : ""}
1247
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous" aria-label="Previous track">${ICONS.previous}</button>` : ""}
1248
+ <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play" aria-label="Play">${ICONS.play}</button>
1249
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next" aria-label="Next track">${ICONS.next}</button>` : ""}
1250
+ ${mergedConfig.showRepeat ? `<button class="${prefix}__btn ${prefix}__btn--repeat" title="Repeat" aria-label="Repeat" aria-pressed="false">${ICONS.repeatOff}</button>` : ""}
1204
1251
  </div>
1205
1252
  ${mergedConfig.showVolume ? `
1206
1253
  <div class="${prefix}__secondary-controls">
1207
- <div class="${prefix}__volume">
1208
- <button class="${prefix}__btn ${prefix}__btn--volume" title="Volume">${ICONS.volumeHigh}</button>
1209
- <div class="${prefix}__volume-slider">
1254
+ <div class="${prefix}__volume" role="group" aria-label="Volume controls">
1255
+ <button class="${prefix}__btn ${prefix}__btn--volume" title="Volume" aria-label="Mute">${ICONS.volumeHigh}</button>
1256
+ <div class="${prefix}__volume-slider" role="slider" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" aria-valuetext="100%" tabindex="0">
1210
1257
  <div class="${prefix}__volume-fill" style="width: 100%"></div>
1211
1258
  </div>
1212
1259
  </div>
@@ -1225,21 +1272,21 @@ function createAudioUIPlugin(config) {
1225
1272
  ${mergedConfig.showTitle ? `<div class="${prefix}__title">-</div>` : ""}
1226
1273
  ${mergedConfig.showArtist ? `<div class="${prefix}__artist">-</div>` : ""}
1227
1274
  <div class="${prefix}__progress">
1228
- <div class="${prefix}__progress-bar">
1275
+ <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">
1229
1276
  <div class="${prefix}__progress-fill" style="transform: scaleX(0)"></div>
1230
1277
  </div>
1231
1278
  </div>
1232
1279
  </div>
1233
- <div class="${prefix}__controls">
1234
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous">${ICONS.previous}</button>` : ""}
1235
- <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play">${ICONS.play}</button>
1236
- ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next">${ICONS.next}</button>` : ""}
1280
+ <div class="${prefix}__controls" role="group" aria-label="Playback controls">
1281
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--prev" title="Previous" aria-label="Previous track">${ICONS.previous}</button>` : ""}
1282
+ <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play" aria-label="Play">${ICONS.play}</button>
1283
+ ${mergedConfig.showNavigation ? `<button class="${prefix}__btn ${prefix}__btn--next" title="Next" aria-label="Next track">${ICONS.next}</button>` : ""}
1237
1284
  </div>
1238
1285
  `;
1239
1286
  };
1240
1287
  const buildMiniLayout = () => {
1241
1288
  return `
1242
- <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play">${ICONS.play}</button>
1289
+ <button class="${prefix}__btn ${prefix}__btn--primary ${prefix}__btn--play" title="Play" aria-label="Play">${ICONS.play}</button>
1243
1290
  ${mergedConfig.showArtwork ? `
1244
1291
  <div class="${prefix}__artwork">
1245
1292
  <img src="${mergedConfig.defaultArtwork || ""}" alt="Album art" />
@@ -1248,7 +1295,7 @@ function createAudioUIPlugin(config) {
1248
1295
  <div class="${prefix}__info">
1249
1296
  ${mergedConfig.showTitle ? `<div class="${prefix}__title-wrapper"><div class="${prefix}__title">-</div></div>` : ""}
1250
1297
  <div class="${prefix}__progress">
1251
- <div class="${prefix}__progress-bar">
1298
+ <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">
1252
1299
  <div class="${prefix}__progress-fill" style="transform: scaleX(0)"></div>
1253
1300
  </div>
1254
1301
  </div>
@@ -1314,6 +1361,7 @@ function createAudioUIPlugin(config) {
1314
1361
  if (playPauseBtn) {
1315
1362
  playPauseBtn.innerHTML = playing ? ICONS.pause : ICONS.play;
1316
1363
  playPauseBtn.title = playing ? "Pause" : "Play";
1364
+ playPauseBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
1317
1365
  }
1318
1366
  const currentTime = api.getState("currentTime") || 0;
1319
1367
  const duration = api.getState("duration") || 0;
@@ -1336,6 +1384,12 @@ function createAudioUIPlugin(config) {
1336
1384
  if (durationEl) {
1337
1385
  durationEl.textContent = formatTime(duration);
1338
1386
  }
1387
+ const progressBar = container?.querySelector(`.${prefix}__progress-bar`);
1388
+ if (progressBar) {
1389
+ progressBar.setAttribute("aria-valuemax", String(Math.floor(duration)));
1390
+ progressBar.setAttribute("aria-valuenow", String(Math.floor(currentTime)));
1391
+ progressBar.setAttribute("aria-valuetext", formatTime(currentTime));
1392
+ }
1339
1393
  const title = api.getState("title");
1340
1394
  const poster = api.getState("poster");
1341
1395
  if (titleEl && title) {
@@ -1360,21 +1414,34 @@ function createAudioUIPlugin(config) {
1360
1414
  }
1361
1415
  if (volumeBtn) {
1362
1416
  volumeBtn.innerHTML = muted || volume === 0 ? ICONS.volumeMuted : ICONS.volumeHigh;
1417
+ volumeBtn.setAttribute("aria-label", muted || volume === 0 ? "Unmute" : "Mute");
1418
+ }
1419
+ const volumeSlider = container?.querySelector(`.${prefix}__volume-slider`);
1420
+ if (volumeSlider) {
1421
+ const displayVolume = Math.round((muted ? 0 : volume) * 100);
1422
+ volumeSlider.setAttribute("aria-valuenow", String(displayVolume));
1423
+ volumeSlider.setAttribute("aria-valuetext", `${displayVolume}%`);
1363
1424
  }
1364
1425
  const playlist = api.getPlugin("playlist");
1365
1426
  if (playlist) {
1366
1427
  const state = playlist.getState();
1367
1428
  if (shuffleBtn) {
1368
1429
  shuffleBtn.classList.toggle(`${prefix}__btn--active`, state.shuffle);
1430
+ shuffleBtn.setAttribute("aria-pressed", String(state.shuffle));
1431
+ shuffleBtn.setAttribute("aria-label", state.shuffle ? "Shuffle on" : "Shuffle off");
1369
1432
  }
1370
1433
  if (repeatBtn) {
1371
1434
  repeatBtn.classList.toggle(`${prefix}__btn--active`, state.repeat !== "none");
1435
+ repeatBtn.setAttribute("aria-pressed", String(state.repeat !== "none"));
1372
1436
  if (state.repeat === "one") {
1373
1437
  repeatBtn.innerHTML = ICONS.repeatOne;
1438
+ repeatBtn.setAttribute("aria-label", "Repeat one");
1374
1439
  } else if (state.repeat === "all") {
1375
1440
  repeatBtn.innerHTML = ICONS.repeatAll;
1441
+ repeatBtn.setAttribute("aria-label", "Repeat all");
1376
1442
  } else {
1377
1443
  repeatBtn.innerHTML = ICONS.repeatOff;
1444
+ repeatBtn.setAttribute("aria-label", "Repeat off");
1378
1445
  }
1379
1446
  }
1380
1447
  }
@@ -3595,6 +3662,7 @@ class ScarlettPlayer {
3595
3662
  this.destroyed = false;
3596
3663
  this.seekingWhilePlaying = false;
3597
3664
  this.seekResumeTimeout = null;
3665
+ this.loadGeneration = 0;
3598
3666
  if (typeof options.container === "string") {
3599
3667
  const el = document.querySelector(options.container);
3600
3668
  if (!el || !(el instanceof HTMLElement)) {
@@ -3668,6 +3736,7 @@ class ScarlettPlayer {
3668
3736
  */
3669
3737
  async load(source) {
3670
3738
  this.checkDestroyed();
3739
+ const generation = ++this.loadGeneration;
3671
3740
  try {
3672
3741
  this.logger.info("Loading source", { source });
3673
3742
  this.stateManager.update({
@@ -3686,6 +3755,10 @@ class ScarlettPlayer {
3686
3755
  await this.pluginManager.destroyPlugin(previousProviderId);
3687
3756
  this._currentProvider = null;
3688
3757
  }
3758
+ if (generation !== this.loadGeneration) {
3759
+ this.logger.info("Load superseded by newer load call", { source });
3760
+ return;
3761
+ }
3689
3762
  const provider = this.pluginManager.selectProvider(source);
3690
3763
  if (!provider) {
3691
3764
  this.errorHandler.throw(
@@ -3701,18 +3774,28 @@ class ScarlettPlayer {
3701
3774
  this._currentProvider = provider;
3702
3775
  this.logger.info("Provider selected", { provider: provider.id });
3703
3776
  await this.pluginManager.initPlugin(provider.id);
3777
+ if (generation !== this.loadGeneration) {
3778
+ this.logger.info("Load superseded by newer load call", { source });
3779
+ return;
3780
+ }
3704
3781
  this.stateManager.set("source", { src: source, type: this.detectMimeType(source) });
3705
3782
  if (typeof provider.loadSource === "function") {
3706
3783
  await provider.loadSource(source);
3707
3784
  }
3785
+ if (generation !== this.loadGeneration) {
3786
+ this.logger.info("Load superseded by newer load call", { source });
3787
+ return;
3788
+ }
3708
3789
  if (this.stateManager.getValue("autoplay")) {
3709
3790
  await this.play();
3710
3791
  }
3711
3792
  } catch (error) {
3712
- this.errorHandler.handle(error, {
3713
- operation: "load",
3714
- source
3715
- });
3793
+ if (generation === this.loadGeneration) {
3794
+ this.errorHandler.handle(error, {
3795
+ operation: "load",
3796
+ source
3797
+ });
3798
+ }
3716
3799
  }
3717
3800
  }
3718
3801
  /**
@@ -3840,8 +3923,9 @@ class ScarlettPlayer {
3840
3923
  */
3841
3924
  setPlaybackRate(rate) {
3842
3925
  this.checkDestroyed();
3843
- this.stateManager.set("playbackRate", rate);
3844
- this.eventBus.emit("playback:ratechange", { rate });
3926
+ const clampedRate = Math.max(0.0625, Math.min(16, rate));
3927
+ this.stateManager.set("playbackRate", clampedRate);
3928
+ this.eventBus.emit("playback:ratechange", { rate: clampedRate });
3845
3929
  }
3846
3930
  /**
3847
3931
  * Set autoplay state.
@@ -3970,6 +4054,13 @@ class ScarlettPlayer {
3970
4054
  }
3971
4055
  const provider = this._currentProvider;
3972
4056
  if (typeof provider.setLevel === "function") {
4057
+ if (index !== -1) {
4058
+ const levels = this.getQualities();
4059
+ if (levels.length > 0 && (index < 0 || index >= levels.length)) {
4060
+ this.logger.warn(`Invalid quality index: ${index} (available: ${levels.length})`);
4061
+ return;
4062
+ }
4063
+ }
3973
4064
  provider.setLevel(index);
3974
4065
  this.eventBus.emit("quality:change", {
3975
4066
  quality: index === -1 ? "auto" : `level-${index}`,
@@ -4210,18 +4301,40 @@ class ScarlettPlayer {
4210
4301
  * @private
4211
4302
  */
4212
4303
  detectMimeType(source) {
4213
- const ext = source.split(".").pop()?.toLowerCase();
4304
+ let path = source;
4305
+ try {
4306
+ path = new URL(source).pathname;
4307
+ } catch {
4308
+ const noQuery = source.split("?")[0] ?? source;
4309
+ path = noQuery.split("#")[0] ?? noQuery;
4310
+ }
4311
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
4214
4312
  switch (ext) {
4215
4313
  case "m3u8":
4216
4314
  return "application/x-mpegURL";
4217
4315
  case "mpd":
4218
4316
  return "application/dash+xml";
4219
4317
  case "mp4":
4318
+ case "m4v":
4220
4319
  return "video/mp4";
4221
4320
  case "webm":
4222
4321
  return "video/webm";
4223
4322
  case "ogg":
4323
+ case "ogv":
4224
4324
  return "video/ogg";
4325
+ case "mov":
4326
+ return "video/quicktime";
4327
+ case "mkv":
4328
+ return "video/x-matroska";
4329
+ case "mp3":
4330
+ return "audio/mpeg";
4331
+ case "wav":
4332
+ return "audio/wav";
4333
+ case "flac":
4334
+ return "audio/flac";
4335
+ case "aac":
4336
+ case "m4a":
4337
+ return "audio/mp4";
4225
4338
  default:
4226
4339
  return "video/mp4";
4227
4340
  }