@banou/media-player 0.10.9 → 0.10.10

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.
@@ -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 })