@banou/media-player 0.10.9 → 0.10.11
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 +37 -0
- package/dist/audio/volume-normalizer.d.ts +37 -0
- package/dist/engine/index.js +1 -1
- package/dist/{engine-C6PdWBah.js → engine-_RCEvBvl.js} +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +585 -441
- package/dist/react/hooks/use-volume-normalizer.d.ts +8 -0
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +11 -0
- package/dist/react/video-player.d.ts +24 -0
- package/dist/volume-normalizer-worklet.js +162 -0
- package/package.json +4 -2
- package/src/lib/audio/volume-normalizer-worklet.js +162 -0
- package/src/lib/audio/volume-normalizer.ts +147 -0
- package/src/lib/engine/playback.ts +6 -2
- package/src/lib/index.tsx +1 -1
- package/src/lib/react/components/settings.tsx +56 -0
- package/src/lib/react/hooks/use-volume-normalizer.ts +37 -0
- package/src/lib/react/source-feature.ts +12 -0
- package/src/lib/react/video-player.tsx +53 -2
|
@@ -28,6 +28,41 @@ position: static;
|
|
|
28
28
|
|
|
29
29
|
${popoverStyle}
|
|
30
30
|
|
|
31
|
+
.switch {
|
|
32
|
+
position: relative;
|
|
33
|
+
flex-shrink: 0;
|
|
34
|
+
width: 28px;
|
|
35
|
+
height: 16px;
|
|
36
|
+
margin-right: 6px;
|
|
37
|
+
border-radius: 8px;
|
|
38
|
+
background-color: rgba(255,255,255,.3);
|
|
39
|
+
transition: background-color 0.15s ease-in-out;
|
|
40
|
+
|
|
41
|
+
::after {
|
|
42
|
+
content: '';
|
|
43
|
+
position: absolute;
|
|
44
|
+
top: 2px;
|
|
45
|
+
left: 2px;
|
|
46
|
+
width: 12px;
|
|
47
|
+
height: 12px;
|
|
48
|
+
border-radius: 50%;
|
|
49
|
+
background-color: #fff;
|
|
50
|
+
transition: transform 0.15s ease-in-out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
&.on {
|
|
54
|
+
background-color: #fff;
|
|
55
|
+
::after {
|
|
56
|
+
background-color: #1c1c1c;
|
|
57
|
+
transform: translateX(12px);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.unavailable {
|
|
63
|
+
opacity: 0.5;
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
.playback-rate {
|
|
32
67
|
.slider {
|
|
33
68
|
width: 100%;
|
|
@@ -81,6 +116,7 @@ export const SettingsAction = () => {
|
|
|
81
116
|
const audioTracks = usePlayer((state) => state.audioTracks)
|
|
82
117
|
const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
|
|
83
118
|
const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
|
|
119
|
+
const volumeNormalizer = usePlayer((state) => state.volumeNormalizer)
|
|
84
120
|
|
|
85
121
|
const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
|
|
86
122
|
const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu({
|
|
@@ -156,6 +192,26 @@ export const SettingsAction = () => {
|
|
|
156
192
|
<ChevronRight />
|
|
157
193
|
</div>
|
|
158
194
|
</div>
|
|
195
|
+
{volumeNormalizer
|
|
196
|
+
? (
|
|
197
|
+
<div
|
|
198
|
+
role='switch'
|
|
199
|
+
aria-checked={volumeNormalizer.available && volumeNormalizer.enabled}
|
|
200
|
+
aria-disabled={!volumeNormalizer.available}
|
|
201
|
+
className={volumeNormalizer.available ? undefined : 'no-hover unavailable'}
|
|
202
|
+
onClick={() => {
|
|
203
|
+
if (volumeNormalizer.available) volumeNormalizer.setEnabled(!volumeNormalizer.enabled)
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
<div>Normalize volume</div>
|
|
207
|
+
<div>
|
|
208
|
+
{volumeNormalizer.available
|
|
209
|
+
? <span className={volumeNormalizer.enabled ? 'switch on' : 'switch'} />
|
|
210
|
+
: <span className='secondary'>Unavailable</span>}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
: null}
|
|
159
215
|
</div>
|
|
160
216
|
)
|
|
161
217
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { VolumeNormalizer } from '../../audio/volume-normalizer'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { attachVolumeNormalizer } from '../../audio/volume-normalizer'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes the element's volume while `enabled`. Nothing is built until it first is, and turning it off
|
|
9
|
+
* afterwards bypasses rather than unroutes, since an element cannot be handed back. `refused` is true once the
|
|
10
|
+
* engine turned the element down, which leaves it playing exactly as before.
|
|
11
|
+
*/
|
|
12
|
+
export const useVolumeNormalizer = (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean) => {
|
|
13
|
+
const handle = useRef<VolumeNormalizer | null>(null)
|
|
14
|
+
const [refused, setRefused] = useState(false)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!video || !workletUrl) return
|
|
18
|
+
if (enabled && !handle.current) {
|
|
19
|
+
const attached = attachVolumeNormalizer(video, workletUrl, { enabled })
|
|
20
|
+
handle.current = attached
|
|
21
|
+
attached.routed.catch((error) => {
|
|
22
|
+
// released first, which closes a context that had not run yet: nothing was refused
|
|
23
|
+
if (handle.current !== attached) return
|
|
24
|
+
console.warn('[media-player] the volume normalizer is unavailable:', error)
|
|
25
|
+
setRefused(true)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
handle.current?.setEnabled(enabled)
|
|
29
|
+
}, [video, workletUrl, enabled])
|
|
30
|
+
|
|
31
|
+
useEffect(() => () => {
|
|
32
|
+
handle.current?.release()
|
|
33
|
+
handle.current = null
|
|
34
|
+
}, [video])
|
|
35
|
+
|
|
36
|
+
return { refused }
|
|
37
|
+
}
|
|
@@ -98,6 +98,14 @@ export type PassThroughControl = {
|
|
|
98
98
|
exit: () => void
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** The settings menu's volume normalizer switch, for a player that offers one (see `normalizeVolume`). */
|
|
102
|
+
export type VolumeNormalizerControl = {
|
|
103
|
+
enabled: boolean
|
|
104
|
+
/** False once the engine refused to route the element, which then plays exactly as it did. */
|
|
105
|
+
available: boolean
|
|
106
|
+
setEnabled: (enabled: boolean) => void
|
|
107
|
+
}
|
|
108
|
+
|
|
101
109
|
/**
|
|
102
110
|
* Everything about the source that the chrome reads, carried on the player store next to the built-in
|
|
103
111
|
* playback state so a component never has to know which of two channels owns a field.
|
|
@@ -210,6 +218,9 @@ export type SourceState = {
|
|
|
210
218
|
/** Burn-in only. True while the composite is the picture on screen. */
|
|
211
219
|
burnedInSubtitles: boolean
|
|
212
220
|
|
|
221
|
+
/** null when the player offers no volume normalizer: a media it does not own, or no `normalizerWorkletUrl`. */
|
|
222
|
+
volumeNormalizer: VolumeNormalizerControl | null
|
|
223
|
+
|
|
213
224
|
/**
|
|
214
225
|
* Move the playhead, letting the pipeline get the data there first.
|
|
215
226
|
*
|
|
@@ -279,6 +290,7 @@ const initialState: SourceState = {
|
|
|
279
290
|
passThroughPictureInPicture: null,
|
|
280
291
|
pictureInPictureMode: null,
|
|
281
292
|
burnedInSubtitles: false,
|
|
293
|
+
volumeNormalizer: null,
|
|
282
294
|
playbackError: null,
|
|
283
295
|
playbackErrors: [],
|
|
284
296
|
ready: false,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
3
|
import type { MediaChapter } from '../engine'
|
|
4
|
-
import type { DownloadedRange } from './source-feature'
|
|
4
|
+
import type { DownloadedRange, VolumeNormalizerControl } from './source-feature'
|
|
5
5
|
import type { DelegatedTracks, ExternalThumbnails, PassThroughPictureInPicture, PlayerMedia } from './media'
|
|
6
6
|
import type { ExposePlayerOptions } from '../remote'
|
|
7
7
|
|
|
8
|
-
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
9
9
|
import { css } from '@emotion/react'
|
|
10
10
|
import { useContainerAttach, useMediaAttach } from '@videojs/react'
|
|
11
11
|
|
|
@@ -14,6 +14,7 @@ import { usePlayback } from './hooks/use-playback'
|
|
|
14
14
|
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
|
+
import { useVolumeNormalizer } from './hooks/use-volume-normalizer'
|
|
17
18
|
import Chrome from './components/chrome'
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -156,6 +157,31 @@ export type MediaPlayerLocalOptions =
|
|
|
156
157
|
thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
157
158
|
/** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
|
|
158
159
|
thumbnailsEnabled?: boolean
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Where this package's `volume-normalizer-worklet.js` is served, a plain script to copy or import as a url
|
|
163
|
+
* (`@banou/media-player/volume-normalizer-worklet.js?no-inline&url` with vite). Without it the settings menu
|
|
164
|
+
* offers no volume normalizer.
|
|
165
|
+
*
|
|
166
|
+
* A url and not a blob, because a page whose CSP says `script-src 'self'` refuses a worklet module from a
|
|
167
|
+
* `blob:` or `data:` url, and `build.lib` inlines an asset as `data:` unless told otherwise.
|
|
168
|
+
*/
|
|
169
|
+
normalizerWorkletUrl?: string
|
|
170
|
+
/**
|
|
171
|
+
* The settings menu's "Normalize volume" switch: holds the loudness steady, so a music cue is turned down to
|
|
172
|
+
* the dialogue around it. Off by default. The player keeps the switch's state from here and from the viewer's
|
|
173
|
+
* clicks; nothing is saved, so an app that wants it to stick saves what `onNormalizeVolumeChange` reports and
|
|
174
|
+
* passes it back.
|
|
175
|
+
*
|
|
176
|
+
* The first time it is on, the element is routed through Web Audio for the rest of its life (off then
|
|
177
|
+
* bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
|
|
178
|
+
* the video plays as before meanwhile. Where the engine refuses the routing (WebKit), it does so when routing
|
|
179
|
+
* would happen, once the switch is on and the context runs: the switch reads Unavailable from then on, and
|
|
180
|
+
* the element is never routed.
|
|
181
|
+
*/
|
|
182
|
+
normalizeVolume?: boolean
|
|
183
|
+
/** The viewer flipped the switch. */
|
|
184
|
+
onNormalizeVolumeChange?: (normalizeVolume: boolean) => void
|
|
159
185
|
}
|
|
160
186
|
|
|
161
187
|
/**
|
|
@@ -258,6 +284,27 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
258
284
|
// so there is nothing for them to attach to and nothing to guard at the call site.
|
|
259
285
|
usePlayback(video, subtitleLayer, local)
|
|
260
286
|
|
|
287
|
+
const normalizeVolumeProp = local?.normalizeVolume
|
|
288
|
+
const [normalizeVolume, setNormalizeVolume] = useState(!!normalizeVolumeProp)
|
|
289
|
+
useEffect(() => { setNormalizeVolume(!!normalizeVolumeProp) }, [normalizeVolumeProp])
|
|
290
|
+
const onNormalizeVolumeChange = useRef(local?.onNormalizeVolumeChange)
|
|
291
|
+
onNormalizeVolumeChange.current = local?.onNormalizeVolumeChange
|
|
292
|
+
const normalizerWorkletUrl = local?.normalizerWorkletUrl
|
|
293
|
+
const { refused: normalizerRefused } = useVolumeNormalizer(video, normalizerWorkletUrl, normalizeVolume)
|
|
294
|
+
const volumeNormalizer = useMemo<VolumeNormalizerControl | null>(
|
|
295
|
+
() => normalizerWorkletUrl && typeof AudioWorkletNode === 'function'
|
|
296
|
+
? {
|
|
297
|
+
enabled: normalizeVolume,
|
|
298
|
+
available: !normalizerRefused,
|
|
299
|
+
setEnabled: (enabled) => {
|
|
300
|
+
setNormalizeVolume(enabled)
|
|
301
|
+
onNormalizeVolumeChange.current?.(enabled)
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
: null,
|
|
305
|
+
[normalizerWorkletUrl, normalizeVolume, normalizerRefused],
|
|
306
|
+
)
|
|
307
|
+
|
|
261
308
|
const { thumbnails: generatedThumbnails, requestThumbnail } = useSeekThumbnails({
|
|
262
309
|
publicPath,
|
|
263
310
|
workerUrl: libavWorkerUrl,
|
|
@@ -306,6 +353,10 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
306
353
|
burnedInSubtitles,
|
|
307
354
|
])
|
|
308
355
|
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
setSourceState({ volumeNormalizer })
|
|
358
|
+
}, [setSourceState, volumeNormalizer])
|
|
359
|
+
|
|
309
360
|
// its own effect: it changes on every hover of the control, and nothing else here does
|
|
310
361
|
useEffect(() => {
|
|
311
362
|
setSourceState({ passThroughPictureInPicture })
|