@banou/media-player 0.10.19 → 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
@@ -147,6 +147,8 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shor
147
147
  const lastPointerType = useRef<string>('mouse')
148
148
  // where the mouse last was, which is where it still is when nothing has moved
149
149
  const pointer = useRef({ x: -1, y: -1 })
150
+ // a mouse button held down with the mouse still is a drag on the seekbar or the volume, not yet over
151
+ const pressed = useRef(false)
150
152
  const root = useRef<HTMLDivElement | null>(null)
151
153
 
152
154
  useEffect(() => () => clearTimeout(autoHide.current), [])
@@ -189,9 +191,10 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shor
189
191
  * and a right click offers the browser's video menu rather than the control's. That is how a link
190
192
  * drawn in an overlay stops behaving like a link, which is what this exists to stop.
191
193
  *
192
- * Only what actually takes pointer events can be hit, so this needs no list of selectors: the
193
- * chrome's own layers opt out, an overlay item opts out until an app opts a child back in, and the
194
- * picture is excluded here because resting on the picture is exactly when hiding is right.
194
+ * Only what actually takes pointer events can be hit: the chrome's own layers opt out, and an
195
+ * overlay item opts out until an app opts a child back in. The picture and the control bar are
196
+ * excluded here because resting on them is exactly when hiding is right, unless a button is held
197
+ * on the bar or one of its menus is open, which are still in use.
195
198
  *
196
199
  * The mouse only. A finger has no resting position, and `elementFromPoint` on the last tap would
197
200
  * keep the controls up forever after one press.
@@ -205,7 +208,9 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shor
205
208
  const { x, y } = pointer.current
206
209
  if (!root.current || x < 0) return false
207
210
  const hit = document.elementFromPoint(x, y)
208
- return !!hit && root.current.contains(hit) && !hit.closest('.video')
211
+ if (!hit || !root.current.contains(hit) || hit.closest('.video')) return false
212
+ if (hit.closest('.control-bar')) return pressed.current || !!root.current.querySelector('.control-bar [aria-expanded="true"]')
213
+ return true
209
214
  }
210
215
 
211
216
  const hideUnlessTheMouseIsOnAControl = () => {
@@ -233,11 +238,20 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shor
233
238
 
234
239
  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
235
240
  lastPointerType.current = event.pointerType
241
+ pressed.current = true
236
242
  }
237
243
 
244
+ const onPointerUp = () => {
245
+ pressed.current = false
246
+ }
247
+
248
+ const pausedBeforeClick = useRef(true)
249
+
238
250
  // play/pause with a mouse, show or hide the controls with a finger, which has no other way back
239
- const onVideoClick = () => {
251
+ const onVideoClick = (event: React.MouseEvent) => {
240
252
  if (lastPointerType.current === 'mouse') {
253
+ if (event.detail > 1) return
254
+ pausedBeforeClick.current = player.paused
241
255
  player.togglePaused()
242
256
  return
243
257
  }
@@ -248,6 +262,15 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shor
248
262
  }
249
263
  }
250
264
 
265
+ // The first click toggled playback, so it is put back rather than toggled again: a remote media
266
+ // reports its pause late, and a second toggle would read it as still playing and pause it twice.
267
+ const onVideoDoubleClick = () => {
268
+ if (lastPointerType.current !== 'mouse') return
269
+ if (pausedBeforeClick.current) player.pause()
270
+ else void player.play().catch(() => {})
271
+ void player.toggleFullscreen()
272
+ }
273
+
251
274
  // relatedTarget alone is not enough: the browser fires mouseout with a null one while the pointer
252
275
  // still sits over the video, so a null is confirmed against the pointer's own coordinates.
253
276
  const onMouseOut: React.DOMAttributes<HTMLDivElement>['onMouseOut'] = (ev) => {
@@ -266,6 +289,8 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shor
266
289
  ref={setRoot}
267
290
  onPointerMove={onPointerMove}
268
291
  onPointerDown={onPointerDown}
292
+ onPointerUp={onPointerUp}
293
+ onPointerCancel={onPointerUp}
269
294
  onMouseOut={onMouseOut}
270
295
  className={hideUI ? 'hide' : ''}
271
296
  // focusable, so a click on the picture puts the focus, and with it the shortcuts, inside the player
@@ -294,7 +319,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shor
294
319
  />
295
320
  )
296
321
  : null}
297
- <div className="video" onClick={onVideoClick}>
322
+ <div className="video" onClick={onVideoClick} onDoubleClick={onVideoDoubleClick}>
298
323
  {onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
299
324
  {children}
300
325
  </div>
@@ -308,6 +308,7 @@ export const ControlBar = (
308
308
  // Armed, the whole bar takes no pointer events, or the rows around the control would still catch
309
309
  // the click at its box. The chrome's layer covers everything but the control meanwhile.
310
310
  <div
311
+ className='control-bar'
311
312
  css={style}
312
313
  style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : armed ? { pointerEvents: 'none' } : {} }}
313
314
  >
@@ -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
  ? {