@banou/media-player 0.8.6 → 0.8.8

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.
@@ -1,2 +1,2 @@
1
- import { a as e, c as t, i as n, l as r, n as i, o as a, r as o, s, t as c, u as l } from "../engine--OlGG-Ve.js";
1
+ import { a as e, c as t, i as n, l as r, n as i, o as a, r as o, s, t as c, u as l } from "../engine-B6flRjLd.js";
2
2
  export { n as DEFAULT_BUFFER_SIZE, s as SUBTITLES_OFF, c as createPictureInPicture, t as createSubtitleRenderer, o as createThumbnailGenerator, r as getTimeRanges, i as pictureInPictureMode, e as startPlayback, a as terminateRemuxer, l as updateSourceBuffer };
@@ -168,7 +168,7 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
168
168
  clearTimeout(n), t.terminate();
169
169
  });
170
170
  }, T = async (t) => {
171
- let { videoElement: n, canvasElement: r, read: i, length: o, publicPath: c, libavWorkerUrl: l, jassubWorkerUrl: u, jassubWasmUrl: d, jassubLegacyWasmUrl: f, defaultFontUrl: p, bufferSize: T = C, audioStreamIndex: E, onReady: D, onError: O, onRecovered: k, onSeek: A, onSubtitleStreams: j, onAudioStreams: M } = t, N = await e({
171
+ let { videoElement: n, canvasElement: r, read: i, length: o, publicPath: c, libavWorkerUrl: l, jassubWorkerUrl: u, jassubWasmUrl: d, jassubLegacyWasmUrl: f, defaultFontUrl: p, bufferSize: T = C, audioStreamIndex: E, onReady: D, onError: O, onRecovered: k, onSeek: A, onSubtitleStreams: j, onAudioStreams: ee } = t, M = await e({
172
172
  publicPath: c,
173
173
  workerUrl: l,
174
174
  workerOptions: { type: "module" },
@@ -176,18 +176,18 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
176
176
  length: o,
177
177
  audioStreamIndex: E,
178
178
  read: i
179
- }), P = [], F = !1, I = () => {
180
- if (!F) {
181
- F = !0;
182
- for (let e of [...P].reverse()) try {
179
+ }), N = [], P = !1, F = () => {
180
+ if (!P) {
181
+ P = !0;
182
+ for (let e of [...N].reverse()) try {
183
183
  e();
184
184
  } catch {}
185
185
  }
186
186
  };
187
- P.push(() => w(N));
187
+ N.push(() => w(M));
188
188
  try {
189
- let e = await N.init(), t = e.audioStreams ?? [], i = t.some((e) => e.streamIndex === E) ? E : t[0]?.streamIndex ?? -1;
190
- M?.(t, i);
189
+ let e = await M.init(), t = e.audioStreams ?? [], i = t.some((e) => e.streamIndex === E) ? E : t[0]?.streamIndex ?? -1;
190
+ ee?.(t, i);
191
191
  let o = m({
192
192
  video: n,
193
193
  canvas: r,
@@ -197,11 +197,11 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
197
197
  defaultFontUrl: p,
198
198
  onStreams: j
199
199
  });
200
- if (P.push(() => o.destroy()), e.attachments?.length && o.pushAttachments(e.attachments), !e.info.output.videoMimeType) throw Error("The video codec in this file could not be identified");
200
+ if (N.push(() => o.destroy()), e.attachments?.length && o.pushAttachments(e.attachments), !e.info.output.videoMimeType) throw Error("The video codec in this file could not be identified");
201
201
  let c = [e.info.output.videoMimeType, e.info.output.audioMimeType].filter(Boolean).join(","), l = `video/mp4; codecs="${c}"`;
202
202
  if (!MediaSource.isTypeSupported(l)) throw Error(`This browser cannot play the codecs in this file: ${c}`);
203
203
  let C = new MediaSource(), w = URL.createObjectURL(C);
204
- P.push(() => URL.revokeObjectURL(w)), n.src = w;
204
+ N.push(() => URL.revokeObjectURL(w)), n.src = w;
205
205
  let T = await new Promise((t, n) => {
206
206
  let r = setTimeout(() => {
207
207
  i(), n(/* @__PURE__ */ Error("The MediaSource never opened"));
@@ -220,85 +220,92 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
220
220
  i(), n(/* @__PURE__ */ Error("The MediaSource closed before a SourceBuffer could be created"));
221
221
  };
222
222
  C.addEventListener("sourceopen", a, { once: !0 }), C.addEventListener("sourceclose", o, { once: !0 }), C.addEventListener("error", o, { once: !0 });
223
- }), { appendBuffer: L, unbufferRange: R, updateTimestampOffset: z, endOfStream: B } = s(T, C);
224
- await L(e.data), e.subtitles?.length && o.pushFragments(e.subtitles), D?.();
225
- let V = !1, H = !1, U = !1, W = 0, G = 0, K = null, q = 0, J = 0, Y = !1, X = 0, Z = (e, t) => {
223
+ }), { appendBuffer: I, unbufferRange: L, updateTimestampOffset: R, endOfStream: z } = s(T, C);
224
+ await I(e.data), e.subtitles?.length && o.pushFragments(e.subtitles), D?.();
225
+ let B = !1, V = !1, H = !1, U = 0, W = 0, G = null, K = 0, q = 0, J = !1, te = 0, Y = (e, t) => {
226
226
  let n = e?.message === "Cancelled";
227
- t && n || (console.error(e), !Y && (Y = !0, O?.(n ? Error("Reading the video file failed", { cause: e }) : e)));
228
- }, Q = async (e = !1) => {
227
+ t && n || (console.error(e), !J && (J = !0, O?.(n ? Error("Reading the video file failed", { cause: e }) : e)));
228
+ }, X = async (e = !1) => {
229
229
  let t = n.currentTime, r = e ? v : h, i = e ? y : g;
230
- for (let { start: e, end: n } of a(T)) e < t + r && await R(e, t + r), n > t + i && await R(t + i, n);
231
- }, ee = () => {
230
+ for (let { start: e, end: n } of a(T)) e < t + r && await L(e, t + r), n > t + i && await L(t + i, n);
231
+ }, ne = () => {
232
232
  let e = a(T);
233
233
  if (!e.length) return !0;
234
234
  let t = n.currentTime;
235
235
  if (Math.max(...e.map((e) => e.end)) >= t + g) return !1;
236
236
  let r = e.find((e) => e.start <= t + S && t < e.end);
237
237
  return !r || r.end < t + _;
238
- }, $ = () => {
238
+ }, Z = () => {
239
239
  let e = n.currentTime;
240
240
  return a(T).find((t) => t.start <= e + S && e < t.end);
241
- }, te = () => {
242
- let e = $();
243
- return !!e && e.end >= J - .1;
244
- }, ne = () => {
241
+ }, re = () => {
242
+ let e = Z();
243
+ return !!e && e.end >= q - .1;
244
+ }, Q = () => {
245
245
  let e = a(T).map((e) => e.end);
246
- e.length && (J = Math.max(J, ...e));
247
- }, re = async (e) => {
248
- H = !0, K = null, q = 0, X = e;
249
- let t = ++G;
246
+ e.length && (q = Math.max(q, ...e));
247
+ }, ie = async (e) => {
248
+ V = !0, G = null, K = 0, te = e;
249
+ let t = ++W;
250
250
  try {
251
- let { data: n, pts: r, subtitles: i } = await N.seek(e);
252
- if (F || t !== G || (i?.length && o.pushFragments(i), await z(r), F || t !== G)) return;
253
- n.byteLength && (await L(n), ne());
251
+ let { data: n, pts: r, subtitles: i } = await M.seek(e);
252
+ if (P || t !== W || (i?.length && o.pushFragments(i), await R(r), P || t !== W)) return;
253
+ n.byteLength && (await I(n), Q());
254
254
  } catch (e) {
255
- Z(e, F || t !== G);
255
+ Y(e, P || t !== W);
256
256
  } finally {
257
- t === G && (H = !1);
257
+ t === W && (V = !1);
258
258
  }
259
- }, ie = async () => {
260
- let e = K;
259
+ }, ae = async () => {
260
+ let e = G;
261
261
  if (e) try {
262
- await L(e), ne(), K === e && (K = null, q = 0);
262
+ await I(e), Q(), G === e && (G = null, K = 0);
263
263
  } catch (t) {
264
- if (K !== e) return;
264
+ if (G !== e) return;
265
265
  if (t?.name === "QuotaExceededError") {
266
- let t = await Q(!0).then(() => !0, () => !1);
267
- if (K !== e) return;
266
+ let t = await X(!0).then(() => !0, () => !1);
267
+ if (G !== e) return;
268
268
  if (t) {
269
- U = !1, await re($()?.end ?? n.currentTime);
269
+ H = !1, await ie(Z()?.end ?? n.currentTime);
270
270
  return;
271
271
  }
272
272
  }
273
- if (q += 1, q < b) return;
274
- throw K = null, t;
273
+ if (K += 1, K < b) return;
274
+ throw G = null, t;
275
275
  }
276
- }, ae = async () => {
277
- if (V || H || F || !K && (U || !ee())) return;
278
- let e = ++W, t = G, n = () => F || e !== W || t !== G;
279
- V = !0;
276
+ }, oe = async () => {
277
+ if (B || V || P || !G && (H || !ne())) return;
278
+ let e = ++U, t = W, n = () => P || e !== U || t !== W;
279
+ B = !0;
280
280
  try {
281
- if (!K) {
282
- let { data: e, subtitles: t, finished: r } = await N.read();
283
- if (n() || (r && (U = !0), t?.length && o.pushFragments(t), !e.byteLength)) return;
284
- K = e, q = 0;
281
+ if (!G) {
282
+ let { data: e, subtitles: t, finished: r } = await M.read();
283
+ if (n() || (r && (H = !0), t?.length && o.pushFragments(t), !e.byteLength)) return;
284
+ G = e, K = 0;
285
285
  }
286
- await ie(), !n() && Y && (Y = !1, k?.());
286
+ await ae(), !n() && J && (J = !1, k?.());
287
287
  } catch (e) {
288
- Z(e, n());
288
+ Y(e, n());
289
289
  } finally {
290
- e === W && (V = !1);
290
+ e === U && (B = !1);
291
291
  }
292
- }, oe = (e) => e >= X && !!$(), se = () => {
292
+ }, se = (e) => e >= te && !!Z(), ce = () => {
293
293
  let t = n.currentTime, r = e.info.input.duration || n.duration;
294
- r > 0 && A?.(Math.min(Math.max(t / r, 0), 1)), !oe(t) && (U = !1, re(t));
294
+ r > 0 && A?.(Math.min(Math.max(t / r, 0), 1)), !se(t) && (H = !1, ie(t));
295
+ };
296
+ n.addEventListener("seeking", ce), N.push(() => n.removeEventListener("seeking", ce));
297
+ let $ = !1, le = () => {
298
+ if ($) return;
299
+ $ = !0;
300
+ let e = n.error;
301
+ Y(Error(`the media element failed: ${e?.message ?? "unknown"}`, { cause: e }), !1);
295
302
  };
296
- n.addEventListener("seeking", se), P.push(() => n.removeEventListener("seeking", se));
297
- let ce = setInterval(() => {
298
- Q().catch(() => {}), ae().catch((e) => Z(e, !1)), U && !K && te() && C.readyState === "open" && B().catch(() => {});
303
+ n.addEventListener("error", le), N.push(() => n.removeEventListener("error", le));
304
+ let ue = setInterval(() => {
305
+ $ || n.error || (X().catch(() => {}), oe().catch((e) => Y(e, !1)), H && !G && re() && C.readyState === "open" && z().catch(() => {}));
299
306
  }, 100);
300
- return P.push(() => clearInterval(ce)), {
301
- destroy: I,
307
+ return N.push(() => clearInterval(ue)), {
308
+ destroy: F,
302
309
  selectSubtitleStream: (e) => o.selectStream(e),
303
310
  indexes: e.indexes ?? [],
304
311
  duration: e.info.input.duration,
@@ -306,9 +313,9 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
306
313
  audioMimeType: e.info.output.audioMimeType
307
314
  };
308
315
  } catch (e) {
309
- throw I(), e;
316
+ throw F(), e;
310
317
  }
311
- }, E = 5, D = 500, O = 320, k = 1e6, A = 3, j = 1e4, M = async (e) => {
318
+ }, E = 5, D = 500, O = 320, k = 1e6, A = 3, j = 1e4, ee = async (e) => {
312
319
  let { publicPath: n, workerUrl: r, length: i, read: a, onThumbnails: o, width: s = O } = e, c = await t({
313
320
  publicPath: n,
314
321
  workerUrl: r,
@@ -383,8 +390,8 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
383
390
  } catch (e) {
384
391
  throw w(c), e;
385
392
  }
386
- }, N = 1280, P = 1920, F = 250, I = 100, L = () => typeof HTMLCanvasElement < "u" && "captureStream" in HTMLCanvasElement.prototype, R = () => typeof document < "u" && typeof HTMLVideoElement < "u" && typeof HTMLVideoElement.prototype.requestPictureInPicture == "function" && !!document.pictureInPictureEnabled, z = () => typeof HTMLVideoElement < "u" && "mozCaptureStream" in HTMLVideoElement.prototype, B = () => L() ? R() ? "window" : z() ? "burn-in" : null : null, V = (e) => {
387
- let { video: t, canvas: n } = e, r = e.mode ?? B() ?? "window", i = e.maxWidth ?? (r === "burn-in" ? P : N), a, o, s, c = !1, l = !1, u = !1, d = !1, f, p = () => r === "burn-in" ? !!a : !!a && document.pictureInPictureElement === a.mirror, m = (e, r) => {
393
+ }, M = 1280, N = 1920, P = 250, F = 100, I = () => typeof HTMLCanvasElement < "u" && "captureStream" in HTMLCanvasElement.prototype, L = () => typeof document < "u" && typeof HTMLVideoElement < "u" && typeof HTMLVideoElement.prototype.requestPictureInPicture == "function" && !!document.pictureInPictureEnabled, R = () => typeof HTMLVideoElement < "u" && "mozCaptureStream" in HTMLVideoElement.prototype, z = () => I() ? L() ? "window" : R() ? "burn-in" : null : null, B = (e) => {
394
+ let { video: t, canvas: n } = e, r = e.mode ?? z() ?? "window", i = e.maxWidth ?? (r === "burn-in" ? N : M), a, o, s, c = !1, l = !1, u = !1, d = !1, f, p = () => r === "burn-in" ? !!a : !!a && document.pictureInPictureElement === a.mirror, m = (e, r) => {
388
395
  r.drawImage(t, 0, 0, e.width, e.height), n.width > 0 && n.height > 0 && r.drawImage(n, 0, 0, e.width, e.height);
389
396
  }, h = () => {
390
397
  a && m(a.composite, a.context);
@@ -411,11 +418,11 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
411
418
  l = !0;
412
419
  let { mirror: e } = a;
413
420
  h(), e.play().then(() => new Promise((e) => {
414
- setTimeout(e, I);
421
+ setTimeout(e, F);
415
422
  })).catch(() => {}).finally(() => {
416
423
  e.pause(), setTimeout(() => {
417
424
  l = !1;
418
- }, I);
425
+ }, F);
419
426
  });
420
427
  }, E = [["play", () => {
421
428
  t.play().catch(() => {});
@@ -452,7 +459,7 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
452
459
  };
453
460
  if (a = f, u.srcObject = f.stream, (e.container ?? t.parentElement ?? document.body).appendChild(u), v(), s = setInterval(() => {
454
461
  t.paused && h();
455
- }, F), u.play().catch(() => {}), u.readyState === 0 && await new Promise((e) => {
462
+ }, P), u.play().catch(() => {}), u.readyState === 0 && await new Promise((e) => {
456
463
  let t = () => {
457
464
  clearTimeout(n), e();
458
465
  }, n = setTimeout(t, 1e3);
@@ -498,4 +505,4 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
498
505
  };
499
506
  };
500
507
  //#endregion
501
- export { T as a, m as c, C as i, a as l, B as n, w as o, M as r, c as s, V as t, s as u };
508
+ export { T as a, m as c, C as i, a as l, z as n, w as o, ee as r, c as s, B as t, s as u };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, c as t, n, r, s as i, t as a } from "./engine--OlGG-Ve.js";
1
+ import { a as e, c as t, n, r, s as i, t as a } from "./engine-B6flRjLd.js";
2
2
  import { Children as o, Fragment as s, isValidElement as c, useCallback as l, useEffect as u, useMemo as d, useRef as f, useState as p } from "react";
3
3
  import { css as m, keyframes as h } from "@emotion/react";
4
4
  import { createPlayer as g, useContainerAttach as _, useMediaAttach as v } from "@videojs/react";
@@ -61,7 +61,7 @@ var re = {
61
61
  id: e.streamIndex,
62
62
  label: t
63
63
  })), oe = (t, n, r) => {
64
- let i = F(), { read: a, size: o, publicPath: s = "", libavWorkerUrl: c = "", jassubWorkerUrl: d = "", jassubWasmUrl: m = "", jassubLegacyWasmUrl: h, defaultFontUrl: g, bufferSize: _, autoplay: v = !1 } = r ?? {}, [y, b] = p(void 0), x = f(null), S = f(0), C = f(!1), w = f(a);
64
+ let i = F(), { read: a, size: o, publicPath: s = "", libavWorkerUrl: c = "", jassubWorkerUrl: d = "", jassubWasmUrl: m = "", jassubLegacyWasmUrl: h, defaultFontUrl: g, bufferSize: _, autoplay: v = !1 } = r ?? {}, [y, b] = p(void 0), x = f(null), S = f(null), C = f(!1), w = f(a);
65
65
  w.current = a;
66
66
  let T = f(r?.onSeek);
67
67
  T.current = r?.onSeek;
@@ -108,7 +108,10 @@ var re = {
108
108
  bufferSize: _,
109
109
  audioStreamIndex: y,
110
110
  onReady: () => {
111
- r || (i.setSourceState({ ready: !0 }), S.current > 0 && (t.currentTime = S.current, S.current = 0), v && t.play().catch(() => {}));
111
+ if (r) return;
112
+ i.setSourceState({ ready: !0 });
113
+ let e = S.current;
114
+ S.current = null, e && e.size === o && e.time > 0 && (t.currentTime = e.time), v && t.play().catch(() => {});
112
115
  },
113
116
  onError: l,
114
117
  onRecovered: () => {
@@ -139,7 +142,10 @@ var re = {
139
142
  l(e);
140
143
  }
141
144
  })(), () => {
142
- r = !0, S.current = t.currentTime, x.current?.destroy(), x.current = null, i.setSourceState({ ready: !1 });
145
+ r = !0, S.current = {
146
+ time: t.currentTime,
147
+ size: o
148
+ }, x.current?.destroy(), x.current = null, i.setSourceState({ ready: !1 });
143
149
  };
144
150
  }, [
145
151
  i,
@@ -1088,6 +1094,8 @@ ${Y}
1088
1094
  className: "settings",
1089
1095
  type: "button",
1090
1096
  onClick: c,
1097
+ "aria-label": "Settings",
1098
+ "aria-expanded": s,
1091
1099
  children: /* @__PURE__ */ S(j, {})
1092
1100
  }),
1093
1101
  toolTipText: /* @__PURE__ */ S("span", { children: "Settings" })
@@ -1170,7 +1178,9 @@ position: static;
1170
1178
  ${Y}
1171
1179
  `, Oe = () => {
1172
1180
  let e = F((e) => e.subtitleTracks), t = F((e) => e.selectedSubtitleTrack), n = F((e) => e.selectSubtitleTrack), r = F((e) => e.subtitleOffLabel), { open: i, toggle: a, containerRef: o, pending: s, failed: c, runSelect: l } = Z();
1173
- return e.length === 0 ? null : /* @__PURE__ */ C("div", {
1181
+ if (e.length === 0) return null;
1182
+ let u = t !== void 0;
1183
+ return /* @__PURE__ */ C("div", {
1174
1184
  css: De,
1175
1185
  ref: o,
1176
1186
  children: [/* @__PURE__ */ S(K, {
@@ -1180,7 +1190,10 @@ ${Y}
1180
1190
  className: "subtitles",
1181
1191
  type: "button",
1182
1192
  onClick: a,
1183
- children: t === void 0 ? /* @__PURE__ */ S(xe, { className: "captions-off" }) : /* @__PURE__ */ S(be, { className: "captions" })
1193
+ "aria-label": "Subtitles",
1194
+ "aria-expanded": i,
1195
+ "aria-pressed": u,
1196
+ children: u ? /* @__PURE__ */ S(be, { className: "captions" }) : /* @__PURE__ */ S(xe, { className: "captions-off" })
1184
1197
  }),
1185
1198
  toolTipText: /* @__PURE__ */ S("span", { children: "Subtitles" })
1186
1199
  }), i && /* @__PURE__ */ S(X, {
@@ -1356,6 +1369,8 @@ ${Y}
1356
1369
  type: "button",
1357
1370
  onClick: () => t.toggleMuted(),
1358
1371
  ref: e,
1372
+ "aria-label": "Mute",
1373
+ "aria-pressed": r || n === 0,
1359
1374
  children: S(r || n === 0 ? te : n <= .5 ? M : ee, {
1360
1375
  size: 18,
1361
1376
  color: "#fff"
@@ -1583,6 +1598,7 @@ ${Y}
1583
1598
  className: "play",
1584
1599
  type: "button",
1585
1600
  onClick: () => e.togglePaused(),
1601
+ "aria-label": _ ? "Replay" : t ? "Play" : "Pause",
1586
1602
  children: S(_ ? A : t ? k : O, {})
1587
1603
  }),
1588
1604
  toolTipText: /* @__PURE__ */ S("span", { children: _ ? "Replay (k)" : t ? "Play (k)" : "Pause (k)" })
@@ -1631,6 +1647,8 @@ ${Y}
1631
1647
  className: "full-screen",
1632
1648
  type: "button",
1633
1649
  onClick: () => e.toggleFullscreen(),
1650
+ "aria-label": "Full screen",
1651
+ "aria-pressed": i,
1634
1652
  children: S(i ? D : E, {})
1635
1653
  }),
1636
1654
  toolTipText: /* @__PURE__ */ S("span", { children: i ? "Exit full screen (f)" : "Full screen (f)" })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -329,7 +329,35 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
329
329
  videoElement.addEventListener('seeking', onSeeking)
330
330
  teardown.push(() => videoElement.removeEventListener('seeking', onSeeking))
331
331
 
332
+ /**
333
+ * A terminal failure of the media element, which NOTHING here used to observe.
334
+ *
335
+ * `MediaError` is set once and the element never plays again, but the pump below kept running,
336
+ * and every `appendBuffer` after it throws `InvalidStateError: The HTMLMediaElement.error
337
+ * attribute is not null`. `updateSourceBuffer`'s chain deliberately survives a rejection, so that
338
+ * repeated forever, and `flushPending` swallows four attempts and rethrows the fifth. What
339
+ * reached the viewer was therefore the FIFTH `InvalidStateError` rather than the real cause, and
340
+ * the real cause never appeared anywhere at all.
341
+ *
342
+ * That cost a session: a decode failure presented as an append bug, which is a completely
343
+ * different place to look. Report what actually happened, then stop.
344
+ */
345
+ let elementFailed = false
346
+ const onElementError = () => {
347
+ if (elementFailed) return
348
+ elementFailed = true
349
+ const error = videoElement.error
350
+ reportError(
351
+ new Error(`the media element failed: ${error?.message ?? 'unknown'}`, { cause: error }),
352
+ false,
353
+ )
354
+ }
355
+ videoElement.addEventListener('error', onElementError)
356
+ teardown.push(() => videoElement.removeEventListener('error', onElementError))
357
+
332
358
  const interval = setInterval(() => {
359
+ // Nothing below can succeed against a failed element, and retrying only buries the real error.
360
+ if (elementFailed || videoElement.error) return
333
361
  evict().catch(() => {})
334
362
  pump().catch((error) => reportError(error, false))
335
363
  // every remove() puts the MediaSource back to 'open', so the end has to be re-armed
@@ -276,6 +276,9 @@ export const ControlBar = () => {
276
276
  className='play'
277
277
  type='button'
278
278
  onClick={() => player.togglePaused()}
279
+ // This one genuinely changes what it does, so the name changes with it rather than
280
+ // carrying a pressed state. Replay is a third action, not an on or off.
281
+ aria-label={ended ? 'Replay' : paused ? 'Play' : 'Pause'}
279
282
  >
280
283
  {
281
284
  ended
@@ -363,6 +366,8 @@ export const ControlBar = () => {
363
366
  className='full-screen'
364
367
  type='button'
365
368
  onClick={() => player.toggleFullscreen()}
369
+ aria-label='Full screen'
370
+ aria-pressed={fullscreen}
366
371
  >
367
372
  {
368
373
  fullscreen
@@ -112,6 +112,8 @@ export const SettingsAction = () => {
112
112
  className='settings'
113
113
  type='button'
114
114
  onClick={toggle}
115
+ aria-label='Settings'
116
+ aria-expanded={open}
115
117
  >
116
118
  <Settings />
117
119
  </button>
@@ -105,6 +105,10 @@ export const Sound = ({ ref }: SoundProps) => {
105
105
  type='button'
106
106
  onClick={() => player.toggleMuted()}
107
107
  ref={ref}
108
+ // A static name with the state on `aria-pressed`. Renaming a control when it is activated
109
+ // announces the state twice and breaks the expectation that a control keeps its name.
110
+ aria-label='Mute'
111
+ aria-pressed={muted || volume === 0}
108
112
  >
109
113
  {muted || volume === 0
110
114
  ? <VolumeX size={18} color='#fff' />
@@ -54,7 +54,14 @@ export const SubtitlesAction = () => {
54
54
  id='subtitles'
55
55
  disabled={open}
56
56
  text={
57
- <button className='subtitles' type='button' onClick={toggle}>
57
+ <button
58
+ className='subtitles'
59
+ type='button'
60
+ onClick={toggle}
61
+ aria-label='Subtitles'
62
+ aria-expanded={open}
63
+ aria-pressed={on}
64
+ >
58
65
  {on ? <Captions className='captions' /> : <CaptionsOff className='captions-off' />}
59
66
  </button>
60
67
  }
@@ -30,7 +30,18 @@ export const usePlayback = (
30
30
  const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
31
31
 
32
32
  const controllerRef = useRef<PlaybackController | null>(null)
33
- const resumeTimeRef = useRef(0)
33
+ /**
34
+ * Where to pick playback back up, and WHICH media that position belongs to.
35
+ *
36
+ * The position exists so a rebuild of the pipeline is invisible: switching audio track tears the
37
+ * whole thing down and starts again, and dropping the viewer back at zero for that would be absurd.
38
+ * But the same rebuild happens when the media itself changes, and then the position is meaningless:
39
+ * dropping a second file in while the first was playing used to open it 40 minutes in.
40
+ *
41
+ * `size` is the identity, because it is the one piece of the source in the effect's deps. `read` is
42
+ * deliberately not, since a streaming consumer passes a fresh closure several times a second.
43
+ */
44
+ const resumeRef = useRef<{ time: number, size: number } | null>(null)
34
45
  // The renderer turns the first track on by itself, so the menu has to mirror that or it shows
35
46
  // "Disable" ticked over subtitles that are visibly on screen.
36
47
  const subtitleChoiceMade = useRef(false)
@@ -94,9 +105,11 @@ export const usePlayback = (
94
105
  onReady: () => {
95
106
  if (cancelled) return
96
107
  player.setSourceState({ ready: true })
97
- if (resumeTimeRef.current > 0) {
98
- video.currentTime = resumeTimeRef.current
99
- resumeTimeRef.current = 0
108
+ const resume = resumeRef.current
109
+ resumeRef.current = null
110
+ // only a position belonging to THIS media, or a new file opens where the last one stopped
111
+ if (resume && resume.size === size && resume.time > 0) {
112
+ video.currentTime = resume.time
100
113
  }
101
114
  if (autoplay) video.play().catch(() => {})
102
115
  },
@@ -130,7 +143,7 @@ export const usePlayback = (
130
143
  })()
131
144
  return () => {
132
145
  cancelled = true
133
- resumeTimeRef.current = video.currentTime
146
+ resumeRef.current = { time: video.currentTime, size }
134
147
  controllerRef.current?.destroy()
135
148
  controllerRef.current = null
136
149
  player.setSourceState({ ready: false })