@banou/media-player 0.10.20 → 0.10.21

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,24 +1,38 @@
1
1
  import type { VolumeNormalizer } from '../../audio/volume-normalizer'
2
+ import type { LoudnessSource } from '../../audio/loudness-analysis'
2
3
 
3
4
  import { useEffect, useRef, useState } from 'react'
4
5
 
5
6
  import { attachVolumeNormalizer } from '../../audio/volume-normalizer'
7
+ import { startLoudnessAnalysis } from '../../audio/loudness-analysis'
6
8
 
7
9
  /**
8
10
  * Normalizes the element's volume while `enabled`. Nothing is built until it first is, and turning it off
9
11
  * afterwards bypasses rather than unroutes, since an element cannot be handed back. `refused` is true once the
10
12
  * engine turned the element down, which leaves it playing exactly as before.
13
+ *
14
+ * 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).
11
16
  */
12
- export const useVolumeNormalizer = (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean) => {
17
+ export const useVolumeNormalizer = (
18
+ video: HTMLVideoElement | null,
19
+ workletUrl: string | undefined,
20
+ enabled: boolean,
21
+ source?: LoudnessSource,
22
+ ranges?: [number, number][],
23
+ ) => {
13
24
  const handle = useRef<VolumeNormalizer | null>(null)
14
25
  const [refused, setRefused] = useState(false)
26
+ const [node, setNode] = useState<AudioWorkletNode | null>(null)
15
27
 
16
28
  useEffect(() => {
17
29
  if (!video || !workletUrl) return
18
30
  if (enabled && !handle.current) {
19
31
  const attached = attachVolumeNormalizer(video, workletUrl, { enabled })
20
32
  handle.current = attached
21
- attached.routed.catch((error) => {
33
+ attached.routed.then((routed) => {
34
+ if (handle.current === attached) setNode(routed)
35
+ }, (error) => {
22
36
  // released first, which closes a context that had not run yet: nothing was refused
23
37
  if (handle.current !== attached) return
24
38
  console.warn('[media-player] the volume normalizer is unavailable:', error)
@@ -31,7 +45,28 @@ export const useVolumeNormalizer = (video: HTMLVideoElement | null, workletUrl:
31
45
  useEffect(() => () => {
32
46
  handle.current?.release()
33
47
  handle.current = null
48
+ setNode(null)
34
49
  }, [video])
35
50
 
51
+ const rangesRef = useRef(ranges)
52
+ rangesRef.current = ranges
53
+ const analysis = useRef<ReturnType<typeof startLoudnessAnalysis> | null>(null)
54
+ const sourceRef = useRef(source)
55
+ sourceRef.current = source
56
+ const { length, publicPath, libavWorkerUrl, audioStreamIndex } = source ?? {}
57
+ useEffect(() => {
58
+ const current = sourceRef.current
59
+ if (!node || !video || !workletUrl || !enabled || !current) return
60
+ const started = startLoudnessAnalysis({ node, element: video, scriptUrl: workletUrl, source: current })
61
+ started.update(rangesRef.current)
62
+ analysis.current = started
63
+ return () => {
64
+ started.stop()
65
+ analysis.current = null
66
+ }
67
+ }, [node, video, workletUrl, enabled, length, publicPath, libavWorkerUrl, audioStreamIndex])
68
+
69
+ useEffect(() => { analysis.current?.update(ranges) }, [ranges])
70
+
36
71
  return { refused }
37
72
  }
@@ -156,11 +156,12 @@ export type MediaPlayerLocalOptions =
156
156
  downloadedRanges?: DownloadedRange[]
157
157
 
158
158
  /**
159
- * Reader for the thumbnail engine, when it should differ from playback's.
159
+ * Reader for the thumbnail engine and the volume normalizer's analysis, when it should differ from playback's.
160
160
  *
161
161
  * They are the same by default so generation shares playback's fetch order. A consumer whose
162
162
  * 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.
163
+ * 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.
164
165
  */
165
166
  thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
166
167
  /** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
@@ -176,10 +177,13 @@ export type MediaPlayerLocalOptions =
176
177
  */
177
178
  normalizerWorkletUrl?: string
178
179
  /**
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.
180
+ * The settings menu's "Normalize volume" switch: keeps the file's normal level and turns down what goes well
181
+ * over it, a loud burst before it starts. On by default; pass false for off. The player keeps the switch's
182
+ * state from here and from the viewer's clicks; nothing is saved, so an app that wants it to stick saves what
183
+ * `onNormalizeVolumeChange` reports and passes it back.
184
+ *
185
+ * 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.
183
187
  *
184
188
  * The first time it is on, the element is routed through Web Audio for the rest of its life (off then
185
189
  * bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
@@ -188,7 +192,7 @@ export type MediaPlayerLocalOptions =
188
192
  * the element is never routed.
189
193
  */
190
194
  normalizeVolume?: boolean
191
- /** The viewer flipped the switch. */
195
+ /** The viewer flipped the switch, to `normalizeVolume`: what an app saves to pass back as `normalizeVolume`. */
192
196
  onNormalizeVolumeChange?: (normalizeVolume: boolean) => void
193
197
  }
194
198
 
@@ -298,13 +302,37 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
298
302
  // so there is nothing for them to attach to and nothing to guard at the call site.
299
303
  usePlayback(video, subtitleLayer, local)
300
304
 
301
- const normalizeVolumeProp = local?.normalizeVolume
302
- const [normalizeVolume, setNormalizeVolume] = useState(!!normalizeVolumeProp)
303
- useEffect(() => { setNormalizeVolume(!!normalizeVolumeProp) }, [normalizeVolumeProp])
305
+ const normalizeVolumeProp = local?.normalizeVolume ?? true
306
+ const [normalizeVolume, setNormalizeVolume] = useState(normalizeVolumeProp)
307
+ useEffect(() => { setNormalizeVolume(normalizeVolumeProp) }, [normalizeVolumeProp])
304
308
  const onNormalizeVolumeChange = useRef(local?.onNormalizeVolumeChange)
305
309
  onNormalizeVolumeChange.current = local?.onNormalizeVolumeChange
306
310
  const normalizerWorkletUrl = local?.normalizerWorkletUrl
307
- const { refused: normalizerRefused } = useVolumeNormalizer(video, normalizerWorkletUrl, normalizeVolume)
311
+ // the background reader, through a ref: a streaming consumer passes a fresh closure several times a second
312
+ const analysisRead = useRef(thumbnailRead ?? read)
313
+ analysisRead.current = thumbnailRead ?? read
314
+ const playingAudio = usePlayer((state) => state.selectedAudioTrack)
315
+ const hasRead = !!read
316
+ const normalizerSource = useMemo(
317
+ () => 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
+ }
325
+ : undefined,
326
+ [hasRead, size, publicPath, libavWorkerUrl, playingAudio],
327
+ )
328
+ const normalizerRanges = useMemo(
329
+ () => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
330
+ // a streaming consumer changes the array identity every tick, so compare contents
331
+ [downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}-${endByteOffset}`).join(',')],
332
+ )
333
+ const { refused: normalizerRefused } = useVolumeNormalizer(
334
+ video, normalizerWorkletUrl, normalizeVolume, normalizerSource, normalizerRanges,
335
+ )
308
336
  const volumeNormalizer = useMemo<VolumeNormalizerControl | null>(
309
337
  () => normalizerWorkletUrl && typeof AudioWorkletNode === 'function'
310
338
  ? {