@banou/media-player 0.8.7 → 0.8.9

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-B6flRjLd.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-BwK7ycv0.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 };
@@ -201,7 +201,12 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
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
- N.push(() => URL.revokeObjectURL(w)), n.src = w;
204
+ N.push(() => URL.revokeObjectURL(w)), n.src = w, N.push(() => {
205
+ try {
206
+ n.pause();
207
+ } catch {}
208
+ n.removeAttribute("src"), n.load();
209
+ });
205
210
  let T = await new Promise((t, n) => {
206
211
  let r = setTimeout(() => {
207
212
  i(), n(/* @__PURE__ */ Error("The MediaSource never opened"));
@@ -248,8 +253,8 @@ var a = (e) => Array(e.buffered.length).fill(void 0).map((t, n) => ({
248
253
  V = !0, G = null, K = 0, te = e;
249
254
  let t = ++W;
250
255
  try {
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;
256
+ let { data: n, pts: r, subtitles: i, finished: a } = await M.seek(e);
257
+ if (P || t !== W || (a && (H = !0), i?.length && o.pushFragments(i), await R(r), P || t !== W)) return;
253
258
  n.byteLength && (await I(n), Q());
254
259
  } catch (e) {
255
260
  Y(e, P || t !== W);
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-B6flRjLd.js";
1
+ import { a as e, c as t, n, r, s as i, t as a } from "./engine-BwK7ycv0.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.7",
3
+ "version": "0.8.9",
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",
@@ -130,6 +130,28 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
130
130
  const mediaSourceUrl = URL.createObjectURL(mediaSource)
131
131
  teardown.push(() => URL.revokeObjectURL(mediaSourceUrl))
132
132
  videoElement.src = mediaSourceUrl
133
+ /**
134
+ * Give the element back, before the url it is holding is revoked.
135
+ *
136
+ * Revoking alone detaches nothing: `src` still names the dead MediaSource, the old SourceBuffer
137
+ * is still attached to it, and the decoder keeps whatever state it was in. That matters because
138
+ * this pipeline is REBUILT in place, on the same element, whenever the audio track changes, and
139
+ * the rebuild awaits a wasm load plus a header read before it assigns a new `src`. On a torrent
140
+ * that is seconds of the element sitting on a corpse.
141
+ *
142
+ * `load()` is the only thing that actually resets it: the media element load algorithm aborts
143
+ * the current resource and clears the decoder. Without it the element goes from an ended source
144
+ * straight into fresh appends with no seek in between, which is exactly the sequence that makes
145
+ * firefox hand a real packet to a drained decoder (`avcodec_send_packet error: End of file`).
146
+ * Pushed AFTER the revoke so the reversed teardown runs it FIRST.
147
+ */
148
+ teardown.push(() => {
149
+ try { videoElement.pause() } catch {}
150
+ videoElement.removeAttribute('src')
151
+ // no src and no <source> children means the load algorithm goes straight to NETWORK_EMPTY
152
+ // without firing `error`, so this cannot manufacture the failure it exists to prevent
153
+ videoElement.load()
154
+ })
133
155
 
134
156
  const sourceBuffer = await new Promise<SourceBuffer>((resolve, reject) => {
135
157
  const timeout = setTimeout(() => { cleanup(); reject(new Error('The MediaSource never opened')) }, SOURCE_OPEN_TIMEOUT)
@@ -230,8 +252,13 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
230
252
  lastSeekPosition = time
231
253
  const generation = ++seekGeneration
232
254
  try {
233
- const { data, pts, subtitles: fragments } = await remuxer.seek(time)
255
+ // `seek` ends by reading, and that read can hit the end of the file, which finalizes the
256
+ // muxer: libav writes the mp4 trailer and cannot be read from again. Dropping the flag left
257
+ // this side believing the stream was still open, so the next pump read straight back into a
258
+ // context that had already been torn down.
259
+ const { data, pts, subtitles: fragments, finished: done } = await remuxer.seek(time)
234
260
  if (destroyed || generation !== seekGeneration) return
261
+ if (done) finished = true
235
262
  if (fragments?.length) subtitles.pushFragments(fragments)
236
263
  await updateTimestampOffset(pts)
237
264
  if (destroyed || generation !== seekGeneration) return
@@ -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 })