@banou/media-player 0.10.20 → 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,8 +1,10 @@
1
1
  import { createMediaElementSource } from '@banou/ponyfill/web-audio'
2
2
 
3
- /** The worklet's processorOptions, read once when the node is made; its header has the defaults. */
3
+ /**
4
+ * The worklet's processorOptions, read once when the node is made: `enabled` false starts it bypassed, and
5
+ * `inputGainDb` is the gain already applied upstream (the element's volume), which loudness is judged without.
6
+ */
4
7
  export type VolumeNormalizerOptions = {
5
- targetLufs?: number
6
8
  enabled?: boolean
7
9
  inputGainDb?: number
8
10
  }
@@ -106,7 +108,7 @@ export const attachVolumeNormalizer = (
106
108
  created.port.postMessage({ enabled, inputGainDb: inputGainDb() })
107
109
  source.connect(created).connect(context.destination)
108
110
  element.addEventListener('volumechange', () => created.port.postMessage({ inputGainDb: inputGainDb() }))
109
- // the 3 s window describes where the playhead was, and a new file owes nothing to the last one's cut
111
+ // the live detector describes where the playhead was, and a new file owes nothing to the last one's level
110
112
  element.addEventListener('seeking', () => created.port.postMessage({ reset: 'seek' }))
111
113
  element.addEventListener('emptied', () => created.port.postMessage({ reset: 'source' }))
112
114
  node = created
@@ -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,24 +1,39 @@
1
1
  import type { VolumeNormalizer } from '../../audio/volume-normalizer'
2
+ import type { LoudnessSource } from '../../audio/loudness-analysis'
3
+ import type { PlaybackTap } from '../../audio/playback-tap'
2
4
 
3
5
  import { useEffect, useRef, useState } from 'react'
4
6
 
5
7
  import { attachVolumeNormalizer } from '../../audio/volume-normalizer'
8
+ import { startLoudnessAnalysis } from '../../audio/loudness-analysis'
6
9
 
7
10
  /**
8
11
  * Normalizes the element's volume while `enabled`. Nothing is built until it first is, and turning it off
9
12
  * afterwards bypasses rather than unroutes, since an element cannot be handed back. `refused` is true once the
10
13
  * engine turned the element down, which leaves it playing exactly as before.
14
+ *
15
+ * With a `source`, the file is also analysed ahead of the playhead while it is on, reading only the byte `ranges`
16
+ * given (all of them when undefined), or with a `tap`, from the audio playback remuxed.
11
17
  */
12
- export const useVolumeNormalizer = (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean) => {
18
+ export const useVolumeNormalizer = (
19
+ video: HTMLVideoElement | null,
20
+ workletUrl: string | undefined,
21
+ enabled: boolean,
22
+ source?: LoudnessSource | { tap: PlaybackTap, audioStreamIndex: number },
23
+ ranges?: [number, number][],
24
+ ) => {
13
25
  const handle = useRef<VolumeNormalizer | null>(null)
14
26
  const [refused, setRefused] = useState(false)
27
+ const [node, setNode] = useState<AudioWorkletNode | null>(null)
15
28
 
16
29
  useEffect(() => {
17
30
  if (!video || !workletUrl) return
18
31
  if (enabled && !handle.current) {
19
32
  const attached = attachVolumeNormalizer(video, workletUrl, { enabled })
20
33
  handle.current = attached
21
- attached.routed.catch((error) => {
34
+ attached.routed.then((routed) => {
35
+ if (handle.current === attached) setNode(routed)
36
+ }, (error) => {
22
37
  // released first, which closes a context that had not run yet: nothing was refused
23
38
  if (handle.current !== attached) return
24
39
  console.warn('[media-player] the volume normalizer is unavailable:', error)
@@ -31,7 +46,30 @@ export const useVolumeNormalizer = (video: HTMLVideoElement | null, workletUrl:
31
46
  useEffect(() => () => {
32
47
  handle.current?.release()
33
48
  handle.current = null
49
+ setNode(null)
34
50
  }, [video])
35
51
 
52
+ const rangesRef = useRef(ranges)
53
+ rangesRef.current = ranges
54
+ const analysis = useRef<ReturnType<typeof startLoudnessAnalysis> | null>(null)
55
+ const sourceRef = useRef(source)
56
+ sourceRef.current = 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
60
+ useEffect(() => {
61
+ const current = sourceRef.current
62
+ if (!node || !video || !workletUrl || !enabled || !current) return
63
+ const started = startLoudnessAnalysis({ node, element: video, scriptUrl: workletUrl, source: current })
64
+ started.update(rangesRef.current)
65
+ analysis.current = started
66
+ return () => {
67
+ started.stop()
68
+ analysis.current = null
69
+ }
70
+ }, [node, video, workletUrl, enabled, length, publicPath, libavWorkerUrl, audioStreamIndex, tap])
71
+
72
+ useEffect(() => { analysis.current?.update(ranges) }, [ranges])
73
+
36
74
  return { refused }
37
75
  }
@@ -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 = {
@@ -156,11 +161,13 @@ export type MediaPlayerLocalOptions =
156
161
  downloadedRanges?: DownloadedRange[]
157
162
 
158
163
  /**
159
- * Reader for the thumbnail engine, when it should differ from playback's.
164
+ * Reader for the thumbnail engine and the volume normalizer's analysis, when it should differ from playback's.
160
165
  *
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
- * so generating previews cannot steal download order from the bytes playback is blocked on.
168
+ * so generating previews cannot steal download order from the bytes playback is blocked on. The
169
+ * analysis asks it only for bytes inside `downloadedRanges`, with none given for the whole file, and for
170
+ * nothing when the source `readsDownload`.
164
171
  */
165
172
  thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
166
173
  /** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
@@ -176,10 +183,14 @@ export type MediaPlayerLocalOptions =
176
183
  */
177
184
  normalizerWorkletUrl?: string
178
185
  /**
179
- * The settings menu's "Normalize volume" switch: holds the loudness steady, so a music cue is turned down to
180
- * the dialogue around it. Off by default. The player keeps the switch's state from here and from the viewer's
181
- * clicks; nothing is saved, so an app that wants it to stick saves what `onNormalizeVolumeChange` reports and
182
- * passes it back.
186
+ * The settings menu's "Normalize volume" switch: keeps the file's normal level and turns down what goes well
187
+ * over it, a loud burst before it starts. On by default; pass false for off. The player keeps the switch's
188
+ * state from here and from the viewer's clicks; nothing is saved, so an app that wants it to stick saves what
189
+ * `onNormalizeVolumeChange` reports and passes it back.
190
+ *
191
+ * While it is on the file is read a second time, in a worker, as far as `downloadedRanges` allows, to find the
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.
183
194
  *
184
195
  * The first time it is on, the element is routed through Web Audio for the rest of its life (off then
185
196
  * bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
@@ -188,7 +199,7 @@ export type MediaPlayerLocalOptions =
188
199
  * the element is never routed.
189
200
  */
190
201
  normalizeVolume?: boolean
191
- /** The viewer flipped the switch. */
202
+ /** The viewer flipped the switch, to `normalizeVolume`: what an app saves to pass back as `normalizeVolume`. */
192
203
  onNormalizeVolumeChange?: (normalizeVolume: boolean) => void
193
204
  }
194
205
 
@@ -246,7 +257,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
246
257
  // local arm and is absent when the media is remote.
247
258
  const { title, chapters } = options
248
259
  const {
249
- size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled,
260
+ size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled, readsDownload,
250
261
  } = local ?? ({} as Partial<MediaPlayerLocalOptions>)
251
262
 
252
263
  const setMedia = useMediaAttach()
@@ -294,17 +305,46 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
294
305
  return () => { dropped = true; stop?.() }
295
306
  }, [media, !!expose, exposeId, exposeOrigin, exposeChannel])
296
307
 
308
+ // one per file, as `size` is the source's identity everywhere here
309
+ const tap = useMemo(() => createPlaybackTap(), [size])
310
+
297
311
  // Each of these no-ops on null inputs, which is what a remote arm supplies: it renders no <video>,
298
312
  // so there is nothing for them to attach to and nothing to guard at the call site.
299
- usePlayback(video, subtitleLayer, local)
313
+ usePlayback(video, subtitleLayer, local, readsDownload ? tap.push : undefined)
300
314
 
301
- const normalizeVolumeProp = local?.normalizeVolume
302
- const [normalizeVolume, setNormalizeVolume] = useState(!!normalizeVolumeProp)
303
- useEffect(() => { setNormalizeVolume(!!normalizeVolumeProp) }, [normalizeVolumeProp])
315
+ const normalizeVolumeProp = local?.normalizeVolume ?? true
316
+ const [normalizeVolume, setNormalizeVolume] = useState(normalizeVolumeProp)
317
+ useEffect(() => { setNormalizeVolume(normalizeVolumeProp) }, [normalizeVolumeProp])
304
318
  const onNormalizeVolumeChange = useRef(local?.onNormalizeVolumeChange)
305
319
  onNormalizeVolumeChange.current = local?.onNormalizeVolumeChange
306
320
  const normalizerWorkletUrl = local?.normalizerWorkletUrl
307
- const { refused: normalizerRefused } = useVolumeNormalizer(video, normalizerWorkletUrl, normalizeVolume)
321
+ // the background reader, through a ref: a streaming consumer passes a fresh closure several times a second
322
+ const analysisRead = useRef(thumbnailRead ?? read)
323
+ analysisRead.current = thumbnailRead ?? read
324
+ const playingAudio = usePlayer((state) => state.selectedAudioTrack)
325
+ const hasRead = !!read
326
+ const normalizerSource = useMemo(
327
+ () => hasRead && size && publicPath && libavWorkerUrl && typeof playingAudio === 'number' && playingAudio >= 0
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
+ }
337
+ : undefined,
338
+ [hasRead, size, publicPath, libavWorkerUrl, playingAudio, readsDownload, tap],
339
+ )
340
+ const normalizerRanges = useMemo(
341
+ () => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
342
+ // a streaming consumer changes the array identity every tick, so compare contents
343
+ [downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}-${endByteOffset}`).join(',')],
344
+ )
345
+ const { refused: normalizerRefused } = useVolumeNormalizer(
346
+ video, normalizerWorkletUrl, normalizeVolume, normalizerSource, normalizerRanges,
347
+ )
308
348
  const volumeNormalizer = useMemo<VolumeNormalizerControl | null>(
309
349
  () => normalizerWorkletUrl && typeof AudioWorkletNode === 'function'
310
350
  ? {
@@ -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 (