@banou/media-player 0.10.21 → 0.10.22

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,3 +1,4 @@
1
+ import type { PlaybackOutput } from '../../engine';
1
2
  import type { MediaPlayerLocalOptions } from '../video-player';
2
3
  /**
3
4
  * Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
@@ -5,4 +6,4 @@ import type { MediaPlayerLocalOptions } from '../video-player';
5
6
  */
6
7
  export declare const usePlayback: (video: HTMLVideoElement | null, subtitles: HTMLElement | null,
7
8
  /** null when the media is remote: there are no bytes, so there is no pipeline to run. */
8
- options: MediaPlayerLocalOptions | null) => void;
9
+ options: MediaPlayerLocalOptions | null, onOutput?: (output: PlaybackOutput) => void) => void;
@@ -1,12 +1,16 @@
1
1
  import type { LoudnessSource } from '../../audio/loudness-analysis';
2
+ import type { PlaybackTap } from '../../audio/playback-tap';
2
3
  /**
3
4
  * Normalizes the element's volume while `enabled`. Nothing is built until it first is, and turning it off
4
5
  * afterwards bypasses rather than unroutes, since an element cannot be handed back. `refused` is true once the
5
6
  * engine turned the element down, which leaves it playing exactly as before.
6
7
  *
7
8
  * With a `source`, the file is also analysed ahead of the playhead while it is on, reading only the byte `ranges`
8
- * given (all of them when undefined).
9
+ * given (all of them when undefined), or with a `tap`, from the audio playback remuxed.
9
10
  */
10
- export declare const useVolumeNormalizer: (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean, source?: LoudnessSource, ranges?: [number, number][]) => {
11
+ export declare const useVolumeNormalizer: (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean, source?: LoudnessSource | {
12
+ tap: PlaybackTap;
13
+ audioStreamIndex: number;
14
+ }, ranges?: [number, number][]) => {
11
15
  refused: boolean;
12
16
  };
@@ -6,13 +6,19 @@ import type { ExposePlayerOptions } from '../remote';
6
6
  /**
7
7
  * The source, read a range at a time: the player never downloads the whole file. Both fields travel
8
8
  * together, so passing one without the other is a type error.
9
+ *
10
+ * `readsDownload` marks a source each read of which is a download, a file over HTTP, so the volume normalizer
11
+ * analyses the audio playback fetched instead of reading the file again. Without it, and with no
12
+ * `downloadedRanges`, every byte is taken to be readable for free, as a local file's are.
9
13
  */
10
14
  export type MediaPlayerSource = {
11
15
  read: (offset: number, size: number) => Promise<ArrayBuffer>;
12
16
  size: number;
17
+ readsDownload?: boolean;
13
18
  } | {
14
19
  read?: undefined;
15
20
  size?: undefined;
21
+ readsDownload?: undefined;
16
22
  };
17
23
  /** Shared by both arms: nothing here depends on who owns the media. */
18
24
  type CommonOptions = {
@@ -139,7 +145,8 @@ export type MediaPlayerLocalOptions = CommonOptions & MediaPlayerSource & {
139
145
  * They are the same by default so generation shares playback's fetch order. A consumer whose
140
146
  * reads are not free wants them apart: a torrent hands this a non-prioritising, fail-fast reader
141
147
  * so generating previews cannot steal download order from the bytes playback is blocked on. The
142
- * analysis asks it only for bytes inside `downloadedRanges`, and with none given, for the whole file.
148
+ * analysis asks it only for bytes inside `downloadedRanges`, with none given for the whole file, and for
149
+ * nothing when the source `readsDownload`.
143
150
  */
144
151
  thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>;
145
152
  /** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
@@ -160,7 +167,8 @@ export type MediaPlayerLocalOptions = CommonOptions & MediaPlayerSource & {
160
167
  * `onNormalizeVolumeChange` reports and passes it back.
161
168
  *
162
169
  * While it is on the file is read a second time, in a worker, as far as `downloadedRanges` allows, to find the
163
- * loud parts ahead of the playhead. The sound plays 10 ms behind its input, on or off, once routed.
170
+ * loud parts ahead of the playhead; a source that `readsDownload` is not read again, and its loud parts are found
171
+ * in what playback fetched. The sound plays 10 ms behind its input, on or off, once routed.
164
172
  *
165
173
  * The first time it is on, the element is routed through Web Audio for the rest of its life (off then
166
174
  * bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
@@ -15,12 +15,17 @@ declare const fromUrl: ({ url, length: _length, name, credentials }: {
15
15
  length: number;
16
16
  name: string | undefined;
17
17
  read: (offset: number, size: number) => Promise<ArrayBuffer>;
18
+ readsDownload: boolean;
18
19
  }>;
19
- /** A source resolved down to what the remuxer needs: a total length and a byte-range reader. */
20
+ /**
21
+ * A source resolved down to what the remuxer needs: a total length and a byte-range reader, and whether each read
22
+ * is a download, which the player's `readsDownload` takes.
23
+ */
20
24
  export type RemuxerInput = {
21
25
  length: number;
22
26
  name?: string;
23
27
  read: (offset: number, size: number) => Promise<ArrayBuffer>;
28
+ readsDownload?: boolean;
24
29
  };
25
30
  export declare const inputToRemuxerInput: (params: Parameters<typeof fromBlob | typeof fromUrl>[0] | {
26
31
  length: number;
@@ -629,12 +629,15 @@ export const createAnalysis = ({ toWorklet, toPage = () => {} }) => {
629
629
  }
630
630
 
631
631
  /*
632
- * The worker's side: fragments from a second libav remuxer reading the same file, decoded with WebCodecs.
632
+ * The worker's side: fragments from a second libav remuxer reading the same file, or from playback's own, decoded with
633
+ * WebCodecs.
633
634
  *
634
635
  * { type: 'port', port } the worklet's end of a MessageChannel, for the blocks
635
636
  * { type: 'init', data, codec } the init segment and the codec string MediaSource was given
636
637
  * { type: 'run', run, pts, start } a run of fragments starts, `pts` added to their times as the SourceBuffer's
637
- * timestampOffset adds it for playback; `start` for the one from the start
638
+ * timestampOffset adds it for playback; `start` for the one from the start,
639
+ * and `join: true` for one whose first fragments went by unseen, which starts
640
+ * at its first sample
638
641
  * { type: 'fragment', run, data } answered { type: 'ack', run, spans, end, normal } once its audio is queued
639
642
  * for decoding: the analysed seconds, the file's end once known, and the
640
643
  * level learned so far in LUFS
@@ -651,6 +654,7 @@ const serve = (scope) => {
651
654
  let decoder
652
655
  let runId = -1
653
656
  let pts = 0
657
+ let joining = false
654
658
  const analysis = createAnalysis({
655
659
  toWorklet: (message, transfer) => port?.postMessage(message, transfer),
656
660
  toPage: (message) => scope.postMessage(message),
@@ -707,10 +711,16 @@ const serve = (scope) => {
707
711
  runId = data.run
708
712
  pts = data.pts
709
713
  open(runId)
710
- analysis.startRun(runId, pts, data.start)
714
+ joining = !!data.join
715
+ if (!joining) analysis.startRun(runId, pts, data.start)
711
716
  } else if (data.type === 'fragment') {
712
717
  if (data.run !== runId || decoder.state !== 'configured') return
713
- for (const sample of audioSamplesOf(data.data, track)) {
718
+ const samples = audioSamplesOf(data.data, track)
719
+ if (joining && samples.length) {
720
+ joining = false
721
+ analysis.startRun(runId, pts + samples[0].time / track.timescale)
722
+ }
723
+ for (const sample of samples) {
714
724
  const time = pts + sample.time / track.timescale
715
725
  stamps.push(time)
716
726
  decoder.decode(new EncodedAudioChunk({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
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",
@@ -34,12 +34,12 @@
34
34
  "types": "tsc -p tsconfig.lib.json",
35
35
  "copy-assets": "shx mkdir -p public && npm run copy-libav",
36
36
  "copy-libav": "shx cp node_modules/libav-wasm/build/worker.js public/libav-worker.js && shx cp node_modules/libav-wasm/build/libav.wasm public/libav.wasm && shx cp node_modules/libav-wasm/build/libav-jspi.wasm public/libav-jspi.wasm",
37
- "test": "npm run fixtures && vp test run",
37
+ "test": "npm run copy-assets && npm run fixtures && vp test run",
38
38
  "fixtures": "node scripts/fixture.mjs"
39
39
  },
40
40
  "author": "Banou26",
41
41
  "dependencies": {
42
- "@banou/ponyfill": "^0.0.10",
42
+ "@banou/ponyfill": "^0.0.12",
43
43
  "@videojs/core": "10.0.0-beta.26",
44
44
  "@videojs/react": "10.0.0-beta.26",
45
45
  "ass-compiler": "^0.1.16",
@@ -1,3 +1,5 @@
1
+ import type { PlaybackTap } from './playback-tap'
2
+
1
3
  import { makeRemuxer } from 'libav-wasm'
2
4
 
3
5
  import { terminateRemuxer } from '../engine/playback'
@@ -37,12 +39,16 @@ const CLOCK_MS = 50
37
39
  * nearest the playhead first, then the rest of the file, and drops nothing it learned on a seek. Until a stretch is
38
40
  * analysed, and for the whole file where this browser's WebCodecs cannot decode the track, the worklet's live stage
39
41
  * plays.
42
+ *
43
+ * Given a `tap` instead, it reads nothing and analyses the audio playback remuxed for `audioStreamIndex`, which runs
44
+ * as far ahead of the playhead as playback buffers. It starts at the next fragment playback fetches, so what was
45
+ * buffered before it attached is not analysed.
40
46
  */
41
47
  export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
42
48
  node: AudioWorkletNode
43
49
  element: HTMLMediaElement
44
50
  scriptUrl: string
45
- source: LoudnessSource
51
+ source: LoudnessSource | { tap: PlaybackTap, audioStreamIndex: number }
46
52
  }): LoudnessAnalysis => {
47
53
  let stopped = false
48
54
  let ranges: [number, number][] | undefined
@@ -54,14 +60,13 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
54
60
  }
55
61
  const changed = () => new Promise<void>((resolve) => wakers.add(resolve))
56
62
 
57
- const readable = (offset: number, size: number) =>
58
- !ranges || ranges.some(([from, to]) => from <= offset && Math.min(offset + size, source.length) <= to)
59
- const read = async (offset: number, size: number) => {
60
- while (!readable(offset, size)) {
63
+ const read = async (reader: LoudnessSource, offset: number, size: number) => {
64
+ const end = Math.min(offset + size, reader.length)
65
+ while (ranges && !ranges.some(([from, to]) => from <= offset && end <= to)) {
61
66
  // given up once the task waiting on it has been aborted, or the analysis stopped
62
67
  if (stopped || await new Promise<boolean>((resolve) => waiting.add(resolve))) throw new Error('Cancelled')
63
68
  }
64
- return source.read(offset, size)
69
+ return reader.read(offset, size)
65
70
  }
66
71
  const release = (abandon: boolean) => {
67
72
  for (const resolve of [...waiting]) resolve(abandon)
@@ -180,14 +185,14 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
180
185
  await acked
181
186
  }
182
187
 
183
- const loop = async () => {
188
+ const loop = async (reader: LoudnessSource) => {
184
189
  remuxer = await makeRemuxer({
185
- publicPath: source.publicPath,
186
- workerUrl: source.libavWorkerUrl,
190
+ publicPath: reader.publicPath,
191
+ workerUrl: reader.libavWorkerUrl,
187
192
  workerOptions: { type: 'module' },
188
- length: source.length,
189
- read,
190
- audioStreamIndex: source.audioStreamIndex,
193
+ length: reader.length,
194
+ read: (offset, size) => read(reader, offset, size),
195
+ audioStreamIndex: reader.audioStreamIndex,
191
196
  })
192
197
  // stopped while it was being made, which stop() could not end
193
198
  if (stopped) return terminateRemuxer(remuxer)
@@ -240,6 +245,30 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
240
245
  remuxer = undefined
241
246
  }
242
247
 
248
+ let detach = () => {}
249
+ const follow = (tap: PlaybackTap, audioStreamIndex: number) => {
250
+ let open = false
251
+ const end = (finished: boolean) => {
252
+ if (open) worker.postMessage({ type: 'end', run, finished })
253
+ open = false
254
+ }
255
+ detach = tap.attach(audioStreamIndex, (output, join) => {
256
+ if (output.type === 'init') {
257
+ end(false)
258
+ const data = output.data.slice(0)
259
+ worker.postMessage({ type: 'init', data, codec: output.audioMimeType }, [data])
260
+ } else if (output.type === 'run') {
261
+ end(false)
262
+ open = true
263
+ worker.postMessage({ type: 'run', run: ++run, pts: output.pts, start: !join && output.pts <= 0, join })
264
+ } else if (output.type === 'end') end(true)
265
+ else if (open) {
266
+ const data = output.data.slice(0)
267
+ worker.postMessage({ type: 'fragment', run, data }, [data])
268
+ }
269
+ })
270
+ }
271
+
243
272
  const stop = () => {
244
273
  if (stopped) return
245
274
  stopped = true
@@ -250,6 +279,7 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
250
279
  element.removeEventListener('seeking', onSeeking)
251
280
  element.removeEventListener('seeked', onTime)
252
281
  element.removeEventListener('timeupdate', onTime)
282
+ detach()
253
283
  if (remuxer) terminateRemuxer(remuxer)
254
284
  worker.terminate()
255
285
  node.port.postMessage({ analysis: null })
@@ -257,11 +287,14 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
257
287
  replies.clear()
258
288
  }
259
289
 
260
- loop().catch((error) => {
261
- if (stopped) return
262
- console.warn('[media-player] the volume normalizer cannot analyse ahead:', error)
263
- stop()
264
- })
290
+ if ('tap' in source) follow(source.tap, source.audioStreamIndex)
291
+ else {
292
+ loop(source).catch((error) => {
293
+ if (stopped) return
294
+ console.warn('[media-player] the volume normalizer cannot analyse ahead:', error)
295
+ stop()
296
+ })
297
+ }
265
298
  playhead()
266
299
 
267
300
  return {
@@ -0,0 +1,37 @@
1
+ import type { PlaybackOutput } from '../engine/playback'
2
+
3
+ /**
4
+ * Playback's output, for an analysis that reads nothing of its own: `push` takes what playback's remuxer produced,
5
+ * and `attach` hands a listener what follows for one input audio stream, starting with the last init segment and the
6
+ * run in progress. `join` marks a run some of whose fragments went by before the listener was attached.
7
+ */
8
+ export type PlaybackTap = {
9
+ push: (output: PlaybackOutput) => void
10
+ attach: (audioStreamIndex: number, listener: (output: PlaybackOutput, join?: boolean) => void) => () => void
11
+ }
12
+
13
+ export const createPlaybackTap = (): PlaybackTap => {
14
+ let init: Extract<PlaybackOutput, { type: 'init' }> | undefined
15
+ let run: { pts: number, begun: boolean } | undefined
16
+ let listener: { audioStreamIndex: number, take: (output: PlaybackOutput, join?: boolean) => void } | undefined
17
+ const live = () => listener && init?.audioStreamIndex === listener.audioStreamIndex ? listener : undefined
18
+ return {
19
+ push: (output) => {
20
+ if (output.type === 'init') {
21
+ init = output
22
+ run = undefined
23
+ } else if (output.type === 'run') run = { pts: output.pts, begun: false }
24
+ else if (output.type === 'fragment' && run) run.begun = true
25
+ live()?.take(output)
26
+ },
27
+ attach: (audioStreamIndex, take) => {
28
+ const mine = { audioStreamIndex, take }
29
+ listener = mine
30
+ if (live() && init) {
31
+ take(init)
32
+ if (run) take({ type: 'run', pts: run.pts }, run.begun)
33
+ }
34
+ return () => { if (listener === mine) listener = undefined }
35
+ },
36
+ }
37
+ }
@@ -629,12 +629,15 @@ export const createAnalysis = ({ toWorklet, toPage = () => {} }) => {
629
629
  }
630
630
 
631
631
  /*
632
- * The worker's side: fragments from a second libav remuxer reading the same file, decoded with WebCodecs.
632
+ * The worker's side: fragments from a second libav remuxer reading the same file, or from playback's own, decoded with
633
+ * WebCodecs.
633
634
  *
634
635
  * { type: 'port', port } the worklet's end of a MessageChannel, for the blocks
635
636
  * { type: 'init', data, codec } the init segment and the codec string MediaSource was given
636
637
  * { type: 'run', run, pts, start } a run of fragments starts, `pts` added to their times as the SourceBuffer's
637
- * timestampOffset adds it for playback; `start` for the one from the start
638
+ * timestampOffset adds it for playback; `start` for the one from the start,
639
+ * and `join: true` for one whose first fragments went by unseen, which starts
640
+ * at its first sample
638
641
  * { type: 'fragment', run, data } answered { type: 'ack', run, spans, end, normal } once its audio is queued
639
642
  * for decoding: the analysed seconds, the file's end once known, and the
640
643
  * level learned so far in LUFS
@@ -651,6 +654,7 @@ const serve = (scope) => {
651
654
  let decoder
652
655
  let runId = -1
653
656
  let pts = 0
657
+ let joining = false
654
658
  const analysis = createAnalysis({
655
659
  toWorklet: (message, transfer) => port?.postMessage(message, transfer),
656
660
  toPage: (message) => scope.postMessage(message),
@@ -707,10 +711,16 @@ const serve = (scope) => {
707
711
  runId = data.run
708
712
  pts = data.pts
709
713
  open(runId)
710
- analysis.startRun(runId, pts, data.start)
714
+ joining = !!data.join
715
+ if (!joining) analysis.startRun(runId, pts, data.start)
711
716
  } else if (data.type === 'fragment') {
712
717
  if (data.run !== runId || decoder.state !== 'configured') return
713
- for (const sample of audioSamplesOf(data.data, track)) {
718
+ const samples = audioSamplesOf(data.data, track)
719
+ if (joining && samples.length) {
720
+ joining = false
721
+ analysis.startRun(runId, pts + samples[0].time / track.timescale)
722
+ }
723
+ for (const sample of samples) {
714
724
  const time = pts + sample.time / track.timescale
715
725
  stamps.push(time)
716
726
  decoder.decode(new EncodedAudioChunk({
@@ -1,5 +1,5 @@
1
1
  export { startPlayback, terminateRemuxer, MediaElementError, isMediaElementError, DEFAULT_BUFFER_SIZE } from './playback'
2
- export type { PlaybackOptions, PlaybackController, MediaIndex, MediaChapter, AudioStream } from './playback'
2
+ export type { PlaybackOptions, PlaybackController, PlaybackOutput, MediaIndex, MediaChapter, AudioStream } from './playback'
3
3
 
4
4
  export { createSubtitleRenderer, SUBTITLES_OFF } from './subtitles'
5
5
  export type { SubtitleRenderer, SubtitleRendererOptions, SubtitleStream } from './subtitles'
@@ -23,6 +23,18 @@ export type MediaIndex = { pos: number, timestamp: number }
23
23
  */
24
24
  export type MediaChapter = { start: number, end: number, title: string }
25
25
 
26
+ /**
27
+ * What the remuxer produced, in the order it was appended: the init segment and the input audio stream it carries,
28
+ * a run at load and at every remuxer seek (`pts` is the timestampOffset its fragments are appended at), each
29
+ * fragment, and the end of the file once read. The buffers are playback's own and may be appended again, so a
30
+ * consumer copies what it keeps.
31
+ */
32
+ export type PlaybackOutput =
33
+ | { type: 'init', data: ArrayBuffer, audioMimeType: string, audioStreamIndex: number }
34
+ | { type: 'run', pts: number }
35
+ | { type: 'fragment', data: ArrayBuffer }
36
+ | { type: 'end' }
37
+
26
38
  export type PlaybackOptions = {
27
39
  videoElement: HTMLVideoElement
28
40
  /**
@@ -51,6 +63,7 @@ export type PlaybackOptions = {
51
63
  onSeek?: (fraction: number) => void
52
64
  onSubtitleStreams?: (streams: SubtitleStream[]) => void
53
65
  onAudioStreams?: (streams: AudioStream[], selected: number) => void
66
+ onOutput?: (output: PlaybackOutput) => void
54
67
  }
55
68
 
56
69
  export type PlaybackController = {
@@ -146,7 +159,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
146
159
  videoElement, subtitleContainer, read, length, publicPath, libavWorkerUrl,
147
160
  jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl, bufferSize = DEFAULT_BUFFER_SIZE,
148
161
  audioStreamIndex, onReady, onError, onRecovered, onSeek, onSubtitleStreams,
149
- onAudioStreams,
162
+ onAudioStreams, onOutput,
150
163
  } = options
151
164
 
152
165
  const remuxer = await makeRemuxer({
@@ -253,6 +266,8 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
253
266
 
254
267
  const { appendBuffer, unbufferRange, updateTimestampOffset, endOfStream } = updateSourceBuffer(sourceBuffer, mediaSource)
255
268
  await appendBuffer(metadata.data)
269
+ onOutput?.({ type: 'init', data: metadata.data, audioMimeType: metadata.info.output.audioMimeType, audioStreamIndex: selectedAudio })
270
+ onOutput?.({ type: 'run', pts: 0 })
256
271
  if (metadata.subtitles?.length) subtitles.pushFragments(metadata.subtitles)
257
272
  onReady?.()
258
273
 
@@ -400,6 +415,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
400
415
  if (fragments?.length) subtitles.pushFragments(fragments)
401
416
  await updateTimestampOffset(pts)
402
417
  if (destroyed || generation !== seekGeneration) return
418
+ onOutput?.({ type: 'run', pts })
419
+ if (data.byteLength) onOutput?.({ type: 'fragment', data })
420
+ if (done) onOutput?.({ type: 'end' })
403
421
  if (data.byteLength) {
404
422
  await appendBuffer(data)
405
423
  trackAppendedEnd()
@@ -458,6 +476,8 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
458
476
  if (stale()) return
459
477
  if (done) finished = true
460
478
  if (fragments?.length) subtitles.pushFragments(fragments)
479
+ if (data.byteLength) onOutput?.({ type: 'fragment', data })
480
+ if (done) onOutput?.({ type: 'end' })
461
481
  if (!data.byteLength) return
462
482
  pending = data
463
483
  pendingAttempts = 0
package/src/lib/index.tsx CHANGED
@@ -43,6 +43,7 @@ export type {
43
43
  PictureInPictureController,
44
44
  PlaybackController,
45
45
  PlaybackOptions,
46
+ PlaybackOutput,
46
47
  SubtitleStream,
47
48
  ThumbnailImage,
48
49
  } from './engine'
@@ -1,4 +1,4 @@
1
- import type { PlaybackController } from '../../engine'
1
+ import type { PlaybackController, PlaybackOutput } from '../../engine'
2
2
  import type { PlaybackErrorEntry } from '../source-feature'
3
3
  import type { MediaPlayerLocalOptions } from '../video-player'
4
4
 
@@ -74,6 +74,7 @@ export const usePlayback = (
74
74
  subtitles: HTMLElement | null,
75
75
  /** null when the media is remote: there are no bytes, so there is no pipeline to run. */
76
76
  options: MediaPlayerLocalOptions | null,
77
+ onOutput?: (output: PlaybackOutput) => void,
77
78
  ) => {
78
79
  const player = usePlayer()
79
80
  // The four asset urls are required on the local arm, so a default only ever applies when options is
@@ -147,6 +148,8 @@ export const usePlayback = (
147
148
  onSeekRef.current = options?.onSeek
148
149
  const onPlaybackErrorRef = useRef(options?.onPlaybackError)
149
150
  onPlaybackErrorRef.current = options?.onPlaybackError
151
+ const onOutputRef = useRef(onOutput)
152
+ onOutputRef.current = onOutput
150
153
 
151
154
  // The store's ids are opaque, but everything this hook writes into it came from libav, so every id
152
155
  // that comes back is one of its own stream indices.
@@ -355,6 +358,7 @@ export const usePlayback = (
355
358
  onError: fail,
356
359
  onRecovered: () => { if (!cancelled) player.setSourceState({ playbackError: null }) },
357
360
  onSeek: (fraction) => onSeekRef.current?.(fraction),
361
+ onOutput: (output) => onOutputRef.current?.(output),
358
362
  onSubtitleStreams: (streams) => {
359
363
  if (cancelled) return
360
364
  player.setSourceState({
@@ -1,5 +1,6 @@
1
1
  import type { VolumeNormalizer } from '../../audio/volume-normalizer'
2
2
  import type { LoudnessSource } from '../../audio/loudness-analysis'
3
+ import type { PlaybackTap } from '../../audio/playback-tap'
3
4
 
4
5
  import { useEffect, useRef, useState } from 'react'
5
6
 
@@ -12,13 +13,13 @@ import { startLoudnessAnalysis } from '../../audio/loudness-analysis'
12
13
  * engine turned the element down, which leaves it playing exactly as before.
13
14
  *
14
15
  * With a `source`, the file is also analysed ahead of the playhead while it is on, reading only the byte `ranges`
15
- * given (all of them when undefined).
16
+ * given (all of them when undefined), or with a `tap`, from the audio playback remuxed.
16
17
  */
17
18
  export const useVolumeNormalizer = (
18
19
  video: HTMLVideoElement | null,
19
20
  workletUrl: string | undefined,
20
21
  enabled: boolean,
21
- source?: LoudnessSource,
22
+ source?: LoudnessSource | { tap: PlaybackTap, audioStreamIndex: number },
22
23
  ranges?: [number, number][],
23
24
  ) => {
24
25
  const handle = useRef<VolumeNormalizer | null>(null)
@@ -53,7 +54,9 @@ export const useVolumeNormalizer = (
53
54
  const analysis = useRef<ReturnType<typeof startLoudnessAnalysis> | null>(null)
54
55
  const sourceRef = useRef(source)
55
56
  sourceRef.current = source
56
- const { length, publicPath, libavWorkerUrl, audioStreamIndex } = source ?? {}
57
+ const { length, publicPath, libavWorkerUrl } = source && 'read' in source ? source : {}
58
+ const tap = source && 'tap' in source ? source.tap : undefined
59
+ const audioStreamIndex = source?.audioStreamIndex
57
60
  useEffect(() => {
58
61
  const current = sourceRef.current
59
62
  if (!node || !video || !workletUrl || !enabled || !current) return
@@ -64,7 +67,7 @@ export const useVolumeNormalizer = (
64
67
  started.stop()
65
68
  analysis.current = null
66
69
  }
67
- }, [node, video, workletUrl, enabled, length, publicPath, libavWorkerUrl, audioStreamIndex])
70
+ }, [node, video, workletUrl, enabled, length, publicPath, libavWorkerUrl, audioStreamIndex, tap])
68
71
 
69
72
  useEffect(() => { analysis.current?.update(ranges) }, [ranges])
70
73
 
@@ -15,15 +15,20 @@ import { useSeekThumbnails } from './hooks/use-thumbnails'
15
15
  import { usePictureInPicture } from './hooks/use-picture-in-picture'
16
16
  import { usePassThroughPictureInPicture } from './hooks/use-pass-through-picture-in-picture'
17
17
  import { useVolumeNormalizer } from './hooks/use-volume-normalizer'
18
+ import { createPlaybackTap } from '../audio/playback-tap'
18
19
  import Chrome from './components/chrome'
19
20
 
20
21
  /**
21
22
  * The source, read a range at a time: the player never downloads the whole file. Both fields travel
22
23
  * together, so passing one without the other is a type error.
24
+ *
25
+ * `readsDownload` marks a source each read of which is a download, a file over HTTP, so the volume normalizer
26
+ * analyses the audio playback fetched instead of reading the file again. Without it, and with no
27
+ * `downloadedRanges`, every byte is taken to be readable for free, as a local file's are.
23
28
  */
24
29
  export type MediaPlayerSource =
25
- | { read: (offset: number, size: number) => Promise<ArrayBuffer>, size: number }
26
- | { read?: undefined, size?: undefined }
30
+ | { read: (offset: number, size: number) => Promise<ArrayBuffer>, size: number, readsDownload?: boolean }
31
+ | { read?: undefined, size?: undefined, readsDownload?: undefined }
27
32
 
28
33
  /** Shared by both arms: nothing here depends on who owns the media. */
29
34
  type CommonOptions = {
@@ -161,7 +166,8 @@ export type MediaPlayerLocalOptions =
161
166
  * They are the same by default so generation shares playback's fetch order. A consumer whose
162
167
  * reads are not free wants them apart: a torrent hands this a non-prioritising, fail-fast reader
163
168
  * so generating previews cannot steal download order from the bytes playback is blocked on. The
164
- * analysis asks it only for bytes inside `downloadedRanges`, and with none given, for the whole file.
169
+ * analysis asks it only for bytes inside `downloadedRanges`, with none given for the whole file, and for
170
+ * nothing when the source `readsDownload`.
165
171
  */
166
172
  thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
167
173
  /** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
@@ -183,7 +189,8 @@ export type MediaPlayerLocalOptions =
183
189
  * `onNormalizeVolumeChange` reports and passes it back.
184
190
  *
185
191
  * While it is on the file is read a second time, in a worker, as far as `downloadedRanges` allows, to find the
186
- * loud parts ahead of the playhead. The sound plays 10 ms behind its input, on or off, once routed.
192
+ * loud parts ahead of the playhead; a source that `readsDownload` is not read again, and its loud parts are found
193
+ * in what playback fetched. The sound plays 10 ms behind its input, on or off, once routed.
187
194
  *
188
195
  * The first time it is on, the element is routed through Web Audio for the rest of its life (off then
189
196
  * bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
@@ -250,7 +257,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
250
257
  // local arm and is absent when the media is remote.
251
258
  const { title, chapters } = options
252
259
  const {
253
- size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled,
260
+ size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled, readsDownload,
254
261
  } = local ?? ({} as Partial<MediaPlayerLocalOptions>)
255
262
 
256
263
  const setMedia = useMediaAttach()
@@ -298,9 +305,12 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
298
305
  return () => { dropped = true; stop?.() }
299
306
  }, [media, !!expose, exposeId, exposeOrigin, exposeChannel])
300
307
 
308
+ // one per file, as `size` is the source's identity everywhere here
309
+ const tap = useMemo(() => createPlaybackTap(), [size])
310
+
301
311
  // Each of these no-ops on null inputs, which is what a remote arm supplies: it renders no <video>,
302
312
  // so there is nothing for them to attach to and nothing to guard at the call site.
303
- usePlayback(video, subtitleLayer, local)
313
+ usePlayback(video, subtitleLayer, local, readsDownload ? tap.push : undefined)
304
314
 
305
315
  const normalizeVolumeProp = local?.normalizeVolume ?? true
306
316
  const [normalizeVolume, setNormalizeVolume] = useState(normalizeVolumeProp)
@@ -315,15 +325,17 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
315
325
  const hasRead = !!read
316
326
  const normalizerSource = useMemo(
317
327
  () => hasRead && size && publicPath && libavWorkerUrl && typeof playingAudio === 'number' && playingAudio >= 0
318
- ? {
319
- read: (offset: number, length: number) => analysisRead.current!(offset, length),
320
- length: size,
321
- publicPath,
322
- libavWorkerUrl,
323
- audioStreamIndex: playingAudio,
324
- }
328
+ ? readsDownload
329
+ ? { tap, audioStreamIndex: playingAudio }
330
+ : {
331
+ read: (offset: number, length: number) => analysisRead.current!(offset, length),
332
+ length: size,
333
+ publicPath,
334
+ libavWorkerUrl,
335
+ audioStreamIndex: playingAudio,
336
+ }
325
337
  : undefined,
326
- [hasRead, size, publicPath, libavWorkerUrl, playingAudio],
338
+ [hasRead, size, publicPath, libavWorkerUrl, playingAudio, readsDownload, tap],
327
339
  )
328
340
  const normalizerRanges = useMemo(
329
341
  () => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
@@ -42,14 +42,18 @@ const fromUrl = async (
42
42
  if (!response.ok) throw new Error(`The source could not be read: HTTP ${response.status}`)
43
43
  return response.arrayBuffer()
44
44
  }
45
- return { length, name, read }
45
+ return { length, name, read, readsDownload: true }
46
46
  }
47
47
 
48
- /** A source resolved down to what the remuxer needs: a total length and a byte-range reader. */
48
+ /**
49
+ * A source resolved down to what the remuxer needs: a total length and a byte-range reader, and whether each read
50
+ * is a download, which the player's `readsDownload` takes.
51
+ */
49
52
  export type RemuxerInput = {
50
53
  length: number
51
54
  name?: string
52
55
  read: (offset: number, size: number) => Promise<ArrayBuffer>
56
+ readsDownload?: boolean
53
57
  }
54
58
 
55
59
  export const inputToRemuxerInput = async (