@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.
- package/README.md +29 -22
- package/dist/audio/loudness-analysis.d.ts +30 -0
- package/dist/audio/volume-normalizer.d.ts +4 -2
- package/dist/index.js +841 -623
- package/dist/react/hooks/use-volume-normalizer.d.ts +5 -1
- package/dist/react/video-player.d.ts +11 -7
- package/dist/volume-normalizer-worklet.js +1190 -78
- package/package.json +1 -1
- package/src/lib/audio/loudness-analysis.ts +274 -0
- package/src/lib/audio/volume-normalizer-worklet.d.ts +49 -0
- package/src/lib/audio/volume-normalizer-worklet.js +1190 -78
- package/src/lib/audio/volume-normalizer.ts +5 -3
- package/src/lib/react/components/chrome.tsx +31 -6
- package/src/lib/react/components/control-bar.tsx +1 -0
- package/src/lib/react/hooks/use-volume-normalizer.ts +37 -2
- package/src/lib/react/video-player.tsx +39 -11
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createMediaElementSource } from '@banou/ponyfill/web-audio'
|
|
2
2
|
|
|
3
|
-
/**
|
|
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
|
|
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
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
|
|
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 = (
|
|
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.
|
|
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:
|
|
180
|
-
*
|
|
181
|
-
* clicks; nothing is saved, so an app that wants it to stick saves what
|
|
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(
|
|
303
|
-
useEffect(() => { setNormalizeVolume(
|
|
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
|
-
|
|
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
|
? {
|