@banou/media-player 0.6.2 → 0.8.0

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.
Files changed (123) hide show
  1. package/README.md +149 -3
  2. package/dist/engine/index.d.ts +10 -0
  3. package/dist/engine/index.js +2 -0
  4. package/dist/engine/picture-in-picture.d.ts +27 -0
  5. package/dist/engine/playback.d.ts +45 -0
  6. package/dist/engine/source-buffer.d.ts +12 -0
  7. package/dist/engine/subtitles.d.ts +36 -0
  8. package/dist/engine/thumbnails.d.ts +22 -0
  9. package/dist/engine-Dmc7JSd9.js +470 -0
  10. package/dist/index.d.ts +14 -0
  11. package/dist/index.js +1610 -0
  12. package/dist/react/components/chrome.d.ts +12 -0
  13. package/dist/react/components/control-bar.d.ts +2 -0
  14. package/dist/react/components/overlay.d.ts +4 -0
  15. package/dist/react/components/playback-slider.d.ts +2 -0
  16. package/dist/react/components/progress-bar.d.ts +2 -0
  17. package/dist/react/components/settings.d.ts +2 -0
  18. package/dist/react/components/sound.d.ts +7 -0
  19. package/{build → dist/react}/components/tooltip-display.d.ts +8 -8
  20. package/dist/react/components/volume-slider.d.ts +6 -0
  21. package/dist/react/hooks/use-drag-value.d.ts +23 -0
  22. package/dist/react/hooks/use-picture-in-picture.d.ts +2 -0
  23. package/dist/react/hooks/use-playback.d.ts +8 -0
  24. package/dist/react/hooks/use-thumbnails.d.ts +12 -0
  25. package/dist/react/media.d.ts +98 -0
  26. package/dist/react/player.d.ts +26 -0
  27. package/dist/react/source-feature.d.ts +106 -0
  28. package/dist/react/video-player.d.ts +83 -0
  29. package/dist/utils/source.d.ts +30 -0
  30. package/dist/utils/track-label.d.ts +31 -0
  31. package/dist/utils/volume-utils.d.ts +6 -0
  32. package/package.json +58 -37
  33. package/src/lib/engine/index.ts +14 -0
  34. package/src/lib/engine/picture-in-picture.ts +250 -0
  35. package/src/lib/engine/playback.ts +352 -0
  36. package/src/lib/engine/source-buffer.ts +61 -0
  37. package/src/lib/engine/subtitles.ts +210 -0
  38. package/src/lib/engine/thumbnails.ts +137 -0
  39. package/src/lib/globals.d.ts +8 -0
  40. package/src/lib/index.tsx +51 -0
  41. package/src/lib/react/components/chrome.tsx +130 -0
  42. package/src/{components → lib/react/components}/control-bar.tsx +74 -96
  43. package/src/lib/react/components/overlay.tsx +125 -0
  44. package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
  45. package/src/lib/react/components/progress-bar.tsx +341 -0
  46. package/src/lib/react/components/settings.tsx +384 -0
  47. package/src/lib/react/components/sound.tsx +133 -0
  48. package/src/{components → lib/react/components}/tooltip-display.tsx +15 -12
  49. package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
  50. package/src/lib/react/hooks/use-drag-value.ts +73 -0
  51. package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
  52. package/src/lib/react/hooks/use-playback.ts +146 -0
  53. package/src/lib/react/hooks/use-thumbnails.ts +78 -0
  54. package/src/lib/react/media.ts +124 -0
  55. package/src/lib/react/player.ts +20 -0
  56. package/src/lib/react/source-feature.ts +108 -0
  57. package/src/lib/react/video-player.tsx +223 -0
  58. package/src/lib/utils/fonts.ts +160 -0
  59. package/src/lib/utils/source.ts +60 -0
  60. package/src/lib/utils/track-label.ts +51 -0
  61. package/src/lib/utils/volume-utils.ts +13 -0
  62. package/build/components/chrome.d.ts +0 -7
  63. package/build/components/control-bar.d.ts +0 -6
  64. package/build/components/overlay.d.ts +0 -4
  65. package/build/components/playback-slider.d.ts +0 -2
  66. package/build/components/progress-bar.d.ts +0 -1
  67. package/build/components/settings.d.ts +0 -2
  68. package/build/components/sound.d.ts +0 -4
  69. package/build/components/volume-slider.d.ts +0 -6
  70. package/build/index.d.ts +0 -26
  71. package/build/index.js +0 -3416
  72. package/build/main.d.ts +0 -1
  73. package/build/state-machines/data-source.d.ts +0 -27
  74. package/build/state-machines/index.d.ts +0 -34038
  75. package/build/state-machines/media-properties.d.ts +0 -45
  76. package/build/state-machines/media-source.d.ts +0 -34
  77. package/build/state-machines/media.d.ts +0 -8558
  78. package/build/state-machines/subtitles.d.ts +0 -56
  79. package/build/state-machines/thumbnails.d.ts +0 -24
  80. package/build/state-machines/utils.d.ts +0 -26
  81. package/build/utils/actor-utils.d.ts +0 -2
  82. package/build/utils/context.d.ts +0 -14
  83. package/build/utils/index.d.ts +0 -4
  84. package/build/utils/languages.d.ts +0 -260
  85. package/build/utils/mp4box.d.ts +0 -61
  86. package/build/utils/use-local-storage.d.ts +0 -3
  87. package/build/utils/use-scrub.d.ts +0 -11
  88. package/build/utils/volume-utils.d.ts +0 -17
  89. package/build/utils/window-height.d.ts +0 -2
  90. package/src/components/chrome.tsx +0 -95
  91. package/src/components/overlay.tsx +0 -91
  92. package/src/components/progress-bar.tsx +0 -251
  93. package/src/components/settings.tsx +0 -322
  94. package/src/components/sound.tsx +0 -101
  95. package/src/index.tsx +0 -195
  96. package/src/main.tsx +0 -169
  97. package/src/state-machines/data-source.ts +0 -84
  98. package/src/state-machines/index.ts +0 -5
  99. package/src/state-machines/media-properties.ts +0 -82
  100. package/src/state-machines/media-source.ts +0 -129
  101. package/src/state-machines/media.ts +0 -255
  102. package/src/state-machines/subtitles.ts +0 -285
  103. package/src/state-machines/thumbnails.ts +0 -123
  104. package/src/state-machines/utils.ts +0 -94
  105. package/src/utils/actor-utils.ts +0 -19
  106. package/src/utils/context.ts +0 -23
  107. package/src/utils/fonts.ts +0 -160
  108. package/src/utils/index.ts +0 -229
  109. package/src/utils/languages.ts +0 -262
  110. package/src/utils/mp4box.ts +0 -74
  111. package/src/utils/use-local-storage.ts +0 -30
  112. package/src/utils/use-scrub.ts +0 -40
  113. package/src/utils/volume-utils.ts +0 -39
  114. package/src/utils/window-height.ts +0 -19
  115. package/src/vite-env.d.ts +0 -1
  116. package/tsconfig.json +0 -28
  117. package/tsconfig.node.json +0 -9
  118. /package/{build → dist}/utils/colors.d.ts +0 -0
  119. /package/{build → dist}/utils/fonts.d.ts +0 -0
  120. /package/{build → dist}/utils/time.d.ts +0 -0
  121. /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
  122. /package/src/{utils → lib/utils}/colors.ts +0 -0
  123. /package/src/{utils → lib/utils}/time.ts +0 -0
@@ -1,9 +1,11 @@
1
- import { useRef, useState, useEffect, useMemo } from 'react'
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import { useRef, useEffect, useMemo } from 'react'
2
3
  import { css } from '@emotion/react'
3
4
 
4
5
  import { TooltipDisplay } from './tooltip-display'
5
- import { MediaMachineContext } from '../state-machines'
6
- import { logToLinearVolume } from '../utils/volume-utils'
6
+ import { usePlayer } from '../player'
7
+ import { useDragValue } from '../hooks/use-drag-value'
8
+ import { logToLinearVolume } from '../../utils/volume-utils'
7
9
 
8
10
  const style = css`
9
11
  display: flex;
@@ -11,6 +13,10 @@ const style = css`
11
13
 
12
14
  padding: 16px 4px;
13
15
  margin: 0 2px;
16
+ /* a finger needs a 44px band to press, so the padding grows while the track stays 3px */
17
+ @media (pointer: coarse) {
18
+ padding: 22px 4px;
19
+ }
14
20
 
15
21
  cursor: pointer;
16
22
 
@@ -22,6 +28,14 @@ const style = css`
22
28
 
23
29
  width: 80px;
24
30
  height: 3px;
31
+ /* the control bar has under 360px to fit every button once the slider is always open, so the
32
+ track lends some of its length back on a phone and takes it again on anything wider */
33
+ @media (pointer: coarse) {
34
+ width: 64px;
35
+ }
36
+ @media (pointer: coarse) and (min-width: 768px) {
37
+ width: 80px;
38
+ }
25
39
  }
26
40
 
27
41
  .volume-handle {
@@ -31,7 +45,7 @@ const style = css`
31
45
  background: #ffffff;
32
46
  border-radius: 50%;
33
47
  transform: translateX(-50%);
34
- pointer-events: none;
48
+ pointer-events: none;
35
49
 
36
50
  width: 10px;
37
51
  height: 10px;
@@ -43,60 +57,31 @@ type VolumeSliderType = {
43
57
  onChange: (v: number) => void
44
58
  }
45
59
 
46
- const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
47
- const volume = MediaMachineContext.useSelector((state) => state.context.media.volume)
48
- const muted = MediaMachineContext.useSelector((state) => state.context.media.muted)
60
+ export const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
61
+ const volume = usePlayer((state) => state.volume)
62
+ const muted = usePlayer((state) => state.muted)
49
63
 
50
64
  const sliderRef = useRef<HTMLDivElement>(null)
51
- const [isDragging, setIsDragging] = useState(false)
52
-
53
- const getNewVolumeFromEvent = (clientX: number): number => {
54
- if (!sliderRef.current) return value
55
- const rect = sliderRef.current.getBoundingClientRect()
56
- const xPos = clientX - rect.left
57
- const clamped = Math.max(0, Math.min(xPos, rect.width))
58
- return clamped / rect.width
59
- }
65
+ const trackRef = useRef<HTMLDivElement>(null)
60
66
 
61
- const handlePointerDown = (e: React.MouseEvent<HTMLDivElement>) => {
62
- setIsDragging(true)
63
- const newVolume = getNewVolumeFromEvent(e.clientX)
64
- onChange(newVolume)
65
- }
66
-
67
- const handlePointerMove = (e: MouseEvent) => {
68
- if (!isDragging) return
69
- const newVolume = getNewVolumeFromEvent(e.clientX)
70
- onChange(newVolume)
71
- }
72
-
73
- const handlePointerUp = () => {
74
- setIsDragging(false)
75
- }
67
+ // the padded row owns the gesture, but the fraction is measured across the track the handle draws in
68
+ const { handlers } = useDragValue({
69
+ ref: trackRef,
70
+ onChange
71
+ })
76
72
 
77
73
  const handleWheel = (e: WheelEvent) => {
78
74
  e.preventDefault()
79
75
  const step = 0.05
80
76
  let newVol = value
81
77
  if (e.deltaY < 0) {
82
- // scroll up
83
78
  newVol = Math.min(1, newVol + step)
84
79
  } else {
85
- // scroll down
86
80
  newVol = Math.max(0, newVol - step)
87
81
  }
88
82
  onChange(newVol)
89
83
  }
90
84
 
91
- useEffect(() => {
92
- window.addEventListener('mousemove', handlePointerMove)
93
- window.addEventListener('mouseup', handlePointerUp)
94
- return () => {
95
- window.removeEventListener('mousemove', handlePointerMove)
96
- window.removeEventListener('mouseup', handlePointerUp)
97
- }
98
- }, [isDragging])
99
-
100
85
  useEffect(() => {
101
86
  const slider = sliderRef.current
102
87
  if (slider) {
@@ -115,7 +100,7 @@ const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
115
100
 
116
101
  const displayVolumePercent = useMemo(
117
102
  () => Math.round(muted ? 0 : logToLinearVolume(volume) * 100),
118
- [volume]
103
+ [volume, muted]
119
104
  )
120
105
 
121
106
  return (
@@ -125,12 +110,13 @@ const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
125
110
  <div
126
111
  ref={sliderRef}
127
112
  css={style}
128
- onMouseDown={handlePointerDown}
113
+ {...handlers}
129
114
  >
130
115
  <div
116
+ ref={trackRef}
131
117
  style={{
132
- background: `linear-gradient(to right,
133
- ${fillColor} 0% ${fillPercent}%,
118
+ background: `linear-gradient(to right,
119
+ ${fillColor} 0% ${fillPercent}%,
134
120
  ${emptyColor} ${fillPercent}% 100%
135
121
  )`
136
122
  }}
@@ -0,0 +1,73 @@
1
+ import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+
5
+ export type UseDragValueOptions = {
6
+ /** The element the fraction is measured across. */
7
+ ref: RefObject<HTMLElement | null>
8
+ /** Called with a 0..1 fraction on press and on every move, and once more on release. */
9
+ onChange: (fraction: number) => void
10
+ orientation?: 'horizontal' | 'vertical'
11
+ disabled?: boolean
12
+ }
13
+
14
+ /** Press-and-drag over an element, reported as a 0..1 fraction. */
15
+ export const useDragValue = ({ ref, onChange, orientation = 'horizontal', disabled = false }: UseDragValueOptions) => {
16
+ const [dragging, setDragging] = useState(false)
17
+ const onChangeRef = useRef(onChange)
18
+ onChangeRef.current = onChange
19
+ const activePointer = useRef<number | null>(null)
20
+
21
+ const fractionFor = useCallback((clientX: number, clientY: number) => {
22
+ const element = ref.current
23
+ if (!element) return 0
24
+ const { left, right, top, bottom } = element.getBoundingClientRect()
25
+ const fraction = orientation === 'horizontal'
26
+ ? (clientX - left) / (right - left)
27
+ // a vertical track fills upwards, so the top of the box is 1
28
+ : (bottom - clientY) / (bottom - top)
29
+ return Math.min(Math.max(fraction, 0), 1)
30
+ }, [ref, orientation])
31
+
32
+ const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
33
+ if (disabled || event.button > 0) return
34
+ // Do not add preventDefault or stopPropagation here. The first kills the compatibility mouse
35
+ // events the seek preview runs on, the second keeps the press from reaching the document
36
+ // listeners that close the popover and refresh the auto-hide timer.
37
+ activePointer.current = event.pointerId
38
+ event.currentTarget.setPointerCapture?.(event.pointerId)
39
+ setDragging(true)
40
+ onChangeRef.current(fractionFor(event.clientX, event.clientY))
41
+ }, [disabled, fractionFor])
42
+
43
+ const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
44
+ if (activePointer.current !== event.pointerId) return
45
+ onChangeRef.current(fractionFor(event.clientX, event.clientY))
46
+ }, [fractionFor])
47
+
48
+ const endDrag = useCallback((event: ReactPointerEvent<HTMLElement>) => {
49
+ if (activePointer.current !== event.pointerId) return
50
+ activePointer.current = null
51
+ event.currentTarget.releasePointerCapture?.(event.pointerId)
52
+ setDragging(false)
53
+ }, [])
54
+
55
+ useEffect(() => {
56
+ if (!disabled) return
57
+ activePointer.current = null
58
+ setDragging(false)
59
+ }, [disabled])
60
+
61
+ return {
62
+ dragging,
63
+ /** Spread onto the element that owns the gesture. */
64
+ handlers: {
65
+ onPointerDown,
66
+ onPointerMove,
67
+ onPointerUp: endDrag,
68
+ onPointerCancel: endDrag,
69
+ // without this the browser scrolls the page instead of moving the slider
70
+ style: { touchAction: 'none' as const },
71
+ },
72
+ }
73
+ }
@@ -0,0 +1,36 @@
1
+ import type { PictureInPictureController } from '../../engine'
2
+
3
+ import { useCallback, useEffect, useRef } from 'react'
4
+
5
+ import { createPictureInPicture } from '../../engine'
6
+ import { usePlayer } from '../player'
7
+
8
+ /** Picture in picture with the subtitles composited in. */
9
+ export const usePictureInPicture = (
10
+ video: HTMLVideoElement | null,
11
+ canvas: HTMLCanvasElement | null,
12
+ ) => {
13
+ const player = usePlayer()
14
+ const controller = useRef<PictureInPictureController | null>(null)
15
+
16
+ useEffect(() => {
17
+ if (!video || !canvas) return
18
+ const instance = createPictureInPicture({
19
+ video,
20
+ canvas,
21
+ // the store's action handles Safari and exits fullscreen first
22
+ fallback: () => player.togglePictureInPicture(),
23
+ })
24
+ controller.current = instance
25
+ return () => {
26
+ instance.destroy()
27
+ controller.current = null
28
+ }
29
+ }, [video, canvas, player])
30
+
31
+ return useCallback(() => {
32
+ void controller.current?.toggle().catch((error) => {
33
+ console.warn('picture in picture was refused', error)
34
+ })
35
+ }, [])
36
+ }
@@ -0,0 +1,146 @@
1
+ import type { PlaybackController } from '../../engine'
2
+ import type { MediaPlayerLocalOptions } from '../video-player'
3
+
4
+ import { useCallback, useEffect, useRef, useState } from 'react'
5
+
6
+ import { startPlayback } from '../../engine'
7
+ import { usePlayer } from '../player'
8
+ import { toNamedTracks } from '../../utils/track-label'
9
+
10
+ /**
11
+ * Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
12
+ * discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
13
+ */
14
+ export const usePlayback = (
15
+ video: HTMLVideoElement | null,
16
+ canvas: HTMLCanvasElement | null,
17
+ /** null when the media is remote: there are no bytes, so there is no pipeline to run. */
18
+ options: MediaPlayerLocalOptions | null,
19
+ ) => {
20
+ const player = usePlayer()
21
+ // The four asset urls are required on the local arm, so a default only ever applies when options is
22
+ // null, and the effect below returns before touching them in that case.
23
+ const {
24
+ read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
25
+ jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
26
+ } = options ?? ({} as Partial<MediaPlayerLocalOptions>)
27
+
28
+ // The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
29
+ // `selectedAudioStream`, which is whatever is playing right now.
30
+ const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
31
+
32
+ const controllerRef = useRef<PlaybackController | null>(null)
33
+ const resumeTimeRef = useRef(0)
34
+ // The renderer turns the first track on by itself, so the menu has to mirror that or it shows
35
+ // "Disable" ticked over subtitles that are visibly on screen.
36
+ const subtitleChoiceMade = useRef(false)
37
+
38
+ const readRef = useRef(read)
39
+ readRef.current = read
40
+ const onSeekRef = useRef(options?.onSeek)
41
+ onSeekRef.current = options?.onSeek
42
+ const onPlaybackErrorRef = useRef(options?.onPlaybackError)
43
+ onPlaybackErrorRef.current = options?.onPlaybackError
44
+
45
+ // The store's ids are opaque, but everything this hook writes into it came from libav, so every id
46
+ // that comes back is one of its own stream indices.
47
+ const selectSubtitleTrack = useCallback((id: string | number | undefined) => {
48
+ const streamIndex = typeof id === 'number' ? id : undefined
49
+ subtitleChoiceMade.current = true
50
+ player.setSourceState({ selectedSubtitleTrack: streamIndex })
51
+ controllerRef.current?.selectSubtitleStream(streamIndex)
52
+ }, [player])
53
+
54
+ const selectAudioTrack = useCallback((id: string | number) => {
55
+ if (typeof id !== 'number') return
56
+ player.setSourceState({ selectedAudioTrack: id })
57
+ setAudioStreamIndex(id)
58
+ }, [player])
59
+
60
+ // Subscribed rather than read off the store, because it is a no-op until the media element
61
+ // attaches. These deps are otherwise all stable, so a mount-time publish would be the only one and
62
+ // both actions would stay dead defaults.
63
+ const setSourceState = usePlayer((state) => state.setSourceState)
64
+
65
+ useEffect(() => {
66
+ setSourceState({ selectSubtitleTrack, selectAudioTrack })
67
+ }, [setSourceState, selectSubtitleTrack, selectAudioTrack])
68
+
69
+ useEffect(() => {
70
+ if (!video || !canvas || !size || !read) return
71
+ let cancelled = false
72
+ player.setSourceState({ playbackError: null, ready: false })
73
+ const fail = (error: unknown) => {
74
+ if (cancelled) return
75
+ console.error('playback failed', error)
76
+ player.setSourceState({ playbackError: error })
77
+ onPlaybackErrorRef.current?.(error)
78
+ }
79
+ void (async () => {
80
+ try {
81
+ const controller = await startPlayback({
82
+ videoElement: video,
83
+ canvasElement: canvas,
84
+ read: (offset, length) => readRef.current!(offset, length),
85
+ length: size,
86
+ publicPath,
87
+ libavWorkerUrl,
88
+ jassubWorkerUrl,
89
+ jassubWasmUrl,
90
+ jassubLegacyWasmUrl,
91
+ defaultFontUrl,
92
+ bufferSize,
93
+ audioStreamIndex,
94
+ onReady: () => {
95
+ if (cancelled) return
96
+ player.setSourceState({ ready: true })
97
+ if (resumeTimeRef.current > 0) {
98
+ video.currentTime = resumeTimeRef.current
99
+ resumeTimeRef.current = 0
100
+ }
101
+ if (autoplay) video.play().catch(() => {})
102
+ },
103
+ onError: fail,
104
+ onRecovered: () => { if (!cancelled) player.setSourceState({ playbackError: null }) },
105
+ onSeek: (fraction) => onSeekRef.current?.(fraction),
106
+ onSubtitleStreams: (streams) => {
107
+ if (cancelled) return
108
+ player.setSourceState({
109
+ subtitleTracks: toNamedTracks(streams),
110
+ ...subtitleChoiceMade.current ? {} : { selectedSubtitleTrack: streams[0]?.streamIndex },
111
+ })
112
+ },
113
+ onAudioStreams: (streams, selected) => {
114
+ if (cancelled) return
115
+ player.setSourceState({ audioTracks: toNamedTracks(streams), selectedAudioTrack: selected })
116
+ },
117
+ })
118
+ if (cancelled) {
119
+ controller.destroy()
120
+ return
121
+ }
122
+ controllerRef.current = controller
123
+ player.setSourceState({ indexes: controller.indexes })
124
+ // a track chosen before this pipeline existed has to be re-applied to the new renderer
125
+ const chosen = player.selectedSubtitleTrack
126
+ if (typeof chosen === 'number') controller.selectSubtitleStream(chosen)
127
+ } catch (error) {
128
+ fail(error)
129
+ }
130
+ })()
131
+ return () => {
132
+ cancelled = true
133
+ resumeTimeRef.current = video.currentTime
134
+ controllerRef.current?.destroy()
135
+ controllerRef.current = null
136
+ player.setSourceState({ ready: false })
137
+ }
138
+ // `read` is deliberately absent: the effect only ever calls `readRef.current`, so listing it here
139
+ // would tear the whole pipeline down and rebuild it whenever the caller's reader changed
140
+ // identity. A streaming consumer passes a fresh closure on every state update, which is several
141
+ // times a second, and the restart loop reads as "Loading metadata" forever at a flat 0 B/s.
142
+ }, [
143
+ player, video, canvas, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
144
+ jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
145
+ ])
146
+ }
@@ -0,0 +1,78 @@
1
+ import type { ThumbnailGenerator, ThumbnailImage } from '../../engine'
2
+ import type { DownloadedRange } from '../source-feature'
3
+
4
+ import { useEffect, useMemo, useRef, useState } from 'react'
5
+
6
+ import { createThumbnailGenerator } from '../../engine'
7
+
8
+ // backs off because each failed init costs a wasm worker
9
+ const RETRY_DELAY = 5_000
10
+ const MAX_RETRY_DELAY = 60_000
11
+
12
+ export type UseSeekThumbnailsOptions = {
13
+ /** Optional alongside `length`/`read`: with no bytes there is nothing to generate from. */
14
+ publicPath: string | undefined
15
+ workerUrl: string | undefined
16
+ length: number | undefined
17
+ read: ((offset: number, size: number) => Promise<ArrayBuffer>) | undefined
18
+ /** When omitted the whole file is treated as readable, which is the case for a local file. */
19
+ downloadedRanges?: DownloadedRange[]
20
+ }
21
+
22
+ export const useSeekThumbnails = ({
23
+ publicPath, workerUrl, length, read, downloadedRanges,
24
+ }: UseSeekThumbnailsOptions): ThumbnailImage[] => {
25
+ const [thumbnails, setThumbnails] = useState<ThumbnailImage[]>([])
26
+ const generatorRef = useRef<ThumbnailGenerator | null>(null)
27
+ const readRef = useRef(read)
28
+ readRef.current = read
29
+
30
+ const ranges = useMemo(
31
+ () => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
32
+ // a streaming consumer changes the array identity every tick, so compare contents
33
+ [downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}-${endByteOffset}`).join(',')],
34
+ )
35
+ const rangesRef = useRef(ranges)
36
+ rangesRef.current = ranges
37
+
38
+ useEffect(() => {
39
+ if (!length || !read || !publicPath || !workerUrl) return
40
+ let cancelled = false
41
+ let generator: ThumbnailGenerator | null = null
42
+ let retry: ReturnType<typeof setTimeout> | undefined
43
+ let delay = RETRY_DELAY
44
+ const boot = () => {
45
+ createThumbnailGenerator({
46
+ publicPath,
47
+ workerUrl,
48
+ length,
49
+ read: (offset, size) => readRef.current!(offset, size),
50
+ onThumbnails: (next) => { if (!cancelled) setThumbnails(next) },
51
+ }).then((created) => {
52
+ if (cancelled) {
53
+ created.destroy()
54
+ return
55
+ }
56
+ generator = created
57
+ generatorRef.current = created
58
+ created.update(rangesRef.current)
59
+ }, () => {
60
+ if (cancelled) return
61
+ retry = setTimeout(boot, delay)
62
+ delay = Math.min(delay * 2, MAX_RETRY_DELAY)
63
+ })
64
+ }
65
+ boot()
66
+ return () => {
67
+ cancelled = true
68
+ clearTimeout(retry)
69
+ generatorRef.current = null
70
+ generator?.destroy()
71
+ setThumbnails([])
72
+ }
73
+ }, [length, publicPath, workerUrl])
74
+
75
+ useEffect(() => { generatorRef.current?.update(ranges) }, [ranges])
76
+
77
+ return thumbnails
78
+ }
@@ -0,0 +1,124 @@
1
+ import type { ThumbnailImage } from '../engine'
2
+
3
+ /**
4
+ * A media object the player drives but does not own.
5
+ *
6
+ * video.js v10 never asks for an `HTMLVideoElement`. Its `PlayerTarget` is `{ media, container }` and
7
+ * every feature gates on property presence alone, with no `instanceof` anywhere, so a plain object
8
+ * carrying these fields lights the whole chrome up. Upstream relies on the same thing: `VimeoMedia`
9
+ * implements `Partial<Video>` over a cross-origin iframe.
10
+ *
11
+ * Declared here rather than imported from `@videojs/media` on purpose. That package is transitive
12
+ * under `@videojs/core`, its `Media` type moved out of `@videojs/core/dom`'s exports between betas,
13
+ * and its shape changed across them, so importing it would pin every consumer to one beta. The
14
+ * assertion at the bottom of this file keeps this type honest against the real one at build time,
15
+ * where `@videojs/media` does exist.
16
+ *
17
+ * OMITTING a property is how you say "not supported". The feature owning it returns out of its
18
+ * `attach` and stays at its defaults, which is why this is mostly optional.
19
+ */
20
+ export type TimeRangesLike = {
21
+ readonly length: number
22
+ start: (index: number) => number
23
+ end: (index: number) => number
24
+ }
25
+
26
+ export type PlayerMedia = EventTarget & {
27
+ /* MediaPlaybackCapability: the only genuinely required part */
28
+ play: () => Promise<void>
29
+ pause: () => void
30
+ paused: boolean
31
+
32
+ /**
33
+ * Seek and source capability. Both are needed or the store goes quiet: `playbackFeature` refuses
34
+ * to attach without them, and `seek` returns without doing anything. A remote media that loads
35
+ * nothing still has to answer `src`/`currentSrc`/`readyState`/`load`, and `readyState` has to
36
+ * reach HAVE_FUTURE_DATA (3) or `waiting` stays true forever and the spinner never clears.
37
+ */
38
+ currentTime: number
39
+ duration: number
40
+ seeking: boolean
41
+ src?: string
42
+ currentSrc?: string
43
+ readyState: number
44
+ load?: () => void
45
+
46
+ volume?: number
47
+ muted?: boolean
48
+ playbackRate?: number
49
+ ended?: boolean
50
+ /**
51
+ * Structural, not the DOM `TimeRanges`, because a remote source transports these as plain pairs and
52
+ * has nothing to hand back that passes an `instanceof`. Note that video.js decides "no buffer
53
+ * capability" by comparing against its own empty sentinel BY IDENTITY, so a hand-rolled empty
54
+ * `{ length: 0 }` reads as a genuinely empty buffer rather than as an absent capability. Omit the
55
+ * field entirely to mean absent.
56
+ */
57
+ buffered?: TimeRangesLike
58
+ seekable?: TimeRangesLike
59
+ /** Structurally what video.js reads off a failed element, so a remote source can report one too. */
60
+ error?: { readonly code: number, readonly message: string } | null
61
+
62
+ videoWidth?: number
63
+ videoHeight?: number
64
+
65
+ requestPictureInPicture?: () => Promise<unknown>
66
+ requestFullscreen?: () => Promise<unknown>
67
+ }
68
+
69
+ /**
70
+ * Where the seekbar's hover previews come from.
71
+ *
72
+ * With bytes in hand the player generates them itself with a second libav worker. A media it does not
73
+ * own has no bytes, but a commercial source usually publishes its own storyboard, so it can answer
74
+ * for a time directly. Narrowed on `at`, the field that carries the difference, rather than on a tag
75
+ * repeating what the shape already says.
76
+ */
77
+ export type ExternalThumbnails = {
78
+ at: (time: number) => ThumbnailImage | undefined
79
+ /** Shown all at once on the seekbar if the source can enumerate them; previews work without it. */
80
+ all?: ThumbnailImage[]
81
+ }
82
+
83
+ export const isExternalThumbnails = (value: unknown): value is ExternalThumbnails =>
84
+ !!value && typeof value === 'object' && 'at' in value
85
+
86
+ /**
87
+ * One choice among several the player presents but does not fulfil: the source owns the switch.
88
+ *
89
+ * This is what a subtitle or audio track becomes when the media is remote. The player draws the menu
90
+ * and reports the pick; whoever owns the document does the work and renders the result.
91
+ */
92
+ export type DelegatedSelection = {
93
+ options: readonly { id: string, label: string, disabled?: boolean }[]
94
+ selectedId: string | null
95
+ select: (id: string | null) => void | Promise<void>
96
+ /** Label for the entry that turns the feature off, when the source offers one. */
97
+ offLabel?: string
98
+ }
99
+
100
+ export type DelegatedTracks = {
101
+ selection: DelegatedSelection
102
+ }
103
+
104
+ export const isDelegatedTracks = (value: unknown): value is DelegatedTracks =>
105
+ !!value && typeof value === 'object' && 'selection' in value
106
+
107
+ /**
108
+ * Keeps `PlayerMedia` honest.
109
+ *
110
+ * `@videojs/media` is present in this repo but deliberately not in the published type surface, so the
111
+ * check lives here as a compile-time assertion instead of an import consumers inherit. If a beta bump
112
+ * changes the real interface in a way that matters, this stops compiling.
113
+ */
114
+ // Deliberately NOT exported: an exported alias is emitted into the .d.ts, which would put
115
+ // `import('@videojs/media')` back into the published surface and undo the point of declaring this
116
+ // type. Unexported, it is checked here and emitted nowhere.
117
+ type AssertAssignable<T extends U, U> = T
118
+ type PlayerMediaMatchesVideoJs = AssertAssignable<
119
+ PlayerMedia,
120
+ Partial<import('@videojs/media').Video>
121
+ >
122
+ // One use, so the alias is not dead code. `never` carries nothing into the output.
123
+ const _assertPlayerMedia: PlayerMediaMatchesVideoJs | undefined = undefined
124
+ void _assertPlayerMedia
@@ -0,0 +1,20 @@
1
+ import { videoFeatures } from '@videojs/core/dom'
2
+ import { createPlayer } from '@videojs/react'
3
+
4
+ import { sourceFeature } from './source-feature'
5
+
6
+ // The preset plus our own source state, so the chrome reads one store rather than a store and a
7
+ // context. createPlayer's generic overload infers the state from the whole tuple, so the extra
8
+ // feature's fields are as typed as the built-in ones.
9
+ export const Player = createPlayer({
10
+ features: [...videoFeatures, sourceFeature],
11
+ displayName: 'MediaPlayer',
12
+ })
13
+
14
+ /**
15
+ * Typed player state, to be used instead of the `usePlayer` from `@videojs/react`, whose fields come
16
+ * back as `unknown`. With no selector it returns the store without subscribing; with one it subscribes.
17
+ */
18
+ export const usePlayer: typeof Player.usePlayer = Player.usePlayer
19
+
20
+ export type PlayerStore = ReturnType<typeof Player.usePlayer>