@banou/media-player 0.6.2 → 0.7.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 (119) hide show
  1. package/README.md +148 -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 +12 -0
  11. package/dist/index.js +1566 -0
  12. package/dist/react/components/chrome.d.ts +9 -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 +6 -0
  24. package/dist/react/hooks/use-thumbnails.d.ts +11 -0
  25. package/dist/react/player.d.ts +25 -0
  26. package/dist/react/source-feature.d.ts +84 -0
  27. package/dist/react/video-player.d.ts +43 -0
  28. package/dist/utils/source.d.ts +30 -0
  29. package/dist/utils/track-label.d.ts +19 -0
  30. package/dist/utils/volume-utils.d.ts +6 -0
  31. package/package.json +49 -37
  32. package/src/lib/engine/index.ts +14 -0
  33. package/src/lib/engine/picture-in-picture.ts +250 -0
  34. package/src/lib/engine/playback.ts +352 -0
  35. package/src/lib/engine/source-buffer.ts +61 -0
  36. package/src/lib/engine/subtitles.ts +210 -0
  37. package/src/lib/engine/thumbnails.ts +137 -0
  38. package/src/lib/globals.d.ts +8 -0
  39. package/src/lib/index.tsx +36 -0
  40. package/src/lib/react/components/chrome.tsx +126 -0
  41. package/src/{components → lib/react/components}/control-bar.tsx +74 -96
  42. package/src/lib/react/components/overlay.tsx +121 -0
  43. package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
  44. package/src/{components → lib/react/components}/progress-bar.tsx +128 -41
  45. package/src/lib/react/components/settings.tsx +391 -0
  46. package/src/lib/react/components/sound.tsx +133 -0
  47. package/src/{components → lib/react/components}/tooltip-display.tsx +11 -8
  48. package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
  49. package/src/lib/react/hooks/use-drag-value.ts +73 -0
  50. package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
  51. package/src/lib/react/hooks/use-playback.ts +134 -0
  52. package/src/lib/react/hooks/use-thumbnails.ts +77 -0
  53. package/src/lib/react/player.ts +20 -0
  54. package/src/lib/react/source-feature.ts +90 -0
  55. package/src/lib/react/video-player.tsx +119 -0
  56. package/src/lib/utils/source.ts +60 -0
  57. package/src/lib/utils/track-label.ts +36 -0
  58. package/src/lib/utils/volume-utils.ts +13 -0
  59. package/build/components/chrome.d.ts +0 -7
  60. package/build/components/control-bar.d.ts +0 -6
  61. package/build/components/overlay.d.ts +0 -4
  62. package/build/components/playback-slider.d.ts +0 -2
  63. package/build/components/progress-bar.d.ts +0 -1
  64. package/build/components/settings.d.ts +0 -2
  65. package/build/components/sound.d.ts +0 -4
  66. package/build/components/volume-slider.d.ts +0 -6
  67. package/build/index.d.ts +0 -26
  68. package/build/index.js +0 -3416
  69. package/build/main.d.ts +0 -1
  70. package/build/state-machines/data-source.d.ts +0 -27
  71. package/build/state-machines/index.d.ts +0 -34038
  72. package/build/state-machines/media-properties.d.ts +0 -45
  73. package/build/state-machines/media-source.d.ts +0 -34
  74. package/build/state-machines/media.d.ts +0 -8558
  75. package/build/state-machines/subtitles.d.ts +0 -56
  76. package/build/state-machines/thumbnails.d.ts +0 -24
  77. package/build/state-machines/utils.d.ts +0 -26
  78. package/build/utils/actor-utils.d.ts +0 -2
  79. package/build/utils/context.d.ts +0 -14
  80. package/build/utils/index.d.ts +0 -4
  81. package/build/utils/languages.d.ts +0 -260
  82. package/build/utils/mp4box.d.ts +0 -61
  83. package/build/utils/use-local-storage.d.ts +0 -3
  84. package/build/utils/use-scrub.d.ts +0 -11
  85. package/build/utils/volume-utils.d.ts +0 -17
  86. package/build/utils/window-height.d.ts +0 -2
  87. package/src/components/chrome.tsx +0 -95
  88. package/src/components/overlay.tsx +0 -91
  89. package/src/components/settings.tsx +0 -322
  90. package/src/components/sound.tsx +0 -101
  91. package/src/index.tsx +0 -195
  92. package/src/main.tsx +0 -169
  93. package/src/state-machines/data-source.ts +0 -84
  94. package/src/state-machines/index.ts +0 -5
  95. package/src/state-machines/media-properties.ts +0 -82
  96. package/src/state-machines/media-source.ts +0 -129
  97. package/src/state-machines/media.ts +0 -255
  98. package/src/state-machines/subtitles.ts +0 -285
  99. package/src/state-machines/thumbnails.ts +0 -123
  100. package/src/state-machines/utils.ts +0 -94
  101. package/src/utils/actor-utils.ts +0 -19
  102. package/src/utils/context.ts +0 -23
  103. package/src/utils/index.ts +0 -229
  104. package/src/utils/languages.ts +0 -262
  105. package/src/utils/mp4box.ts +0 -74
  106. package/src/utils/use-local-storage.ts +0 -30
  107. package/src/utils/use-scrub.ts +0 -40
  108. package/src/utils/volume-utils.ts +0 -39
  109. package/src/utils/window-height.ts +0 -19
  110. package/src/vite-env.d.ts +0 -1
  111. package/tsconfig.json +0 -28
  112. package/tsconfig.node.json +0 -9
  113. /package/{build → dist}/utils/colors.d.ts +0 -0
  114. /package/{build → dist}/utils/fonts.d.ts +0 -0
  115. /package/{build → dist}/utils/time.d.ts +0 -0
  116. /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
  117. /package/src/{utils → lib/utils}/colors.ts +0 -0
  118. /package/src/{utils → lib/utils}/fonts.ts +0 -0
  119. /package/src/{utils → lib/utils}/time.ts +0 -0
@@ -0,0 +1,133 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { Ref } from 'react'
3
+
4
+ import { useMemo } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { Volume1, Volume2, VolumeX } from 'react-feather'
7
+
8
+ import { usePlayer } from '../player'
9
+ import { TooltipDisplay } from './tooltip-display'
10
+ import { fonts } from '../../utils/fonts'
11
+ import VolumeSlider from './volume-slider'
12
+ import { linearToLogVolume, logToLinearVolume } from '../../utils/volume-utils'
13
+
14
+ const style = css`
15
+ position: relative;
16
+ display: flex;
17
+ align-items: center;
18
+
19
+ .volume-slider-container {
20
+ display: flex;
21
+ align-items: center;
22
+
23
+ width: 0;
24
+ margin-right: 0;
25
+ overflow: hidden;
26
+ pointer-events: none;
27
+
28
+ transition: margin .2s cubic-bezier(0.4,0,1,1), width .2s cubic-bezier(0.4,0,1,1);
29
+ -webkit-transition: margin .2s cubic-bezier(0.4,0,1,1), width .2s cubic-bezier(0.4,0,1,1);
30
+
31
+ .volume-slider-background {
32
+ display: flex;
33
+ flex-direction: row;
34
+ align-items: center;
35
+
36
+ border-radius: 4px;
37
+
38
+ .volume-value {
39
+ ${fonts.bMedium.regular};
40
+ color: #ffffff;
41
+ }
42
+ }
43
+ }
44
+
45
+ &:hover .volume-slider-container {
46
+ width: 9rem;
47
+
48
+ transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
49
+ -webkit-transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
50
+
51
+ pointer-events: auto;
52
+ }
53
+
54
+ /* a touch screen has no hover, so the slider stays open there and the button only mutes */
55
+ @media (pointer: coarse) {
56
+ /* auto, not a fixed rem: the container keeps overflow: hidden, so a narrower fixed width
57
+ silently clips the track and makes the clipped part unhittable */
58
+ .volume-slider-container,
59
+ &:hover .volume-slider-container {
60
+ width: auto;
61
+
62
+ pointer-events: auto;
63
+ }
64
+
65
+ /* a 44px press target around an 18px icon, floored here because the control bar sets the
66
+ padding at a higher specificity */
67
+ .sound {
68
+ /* border-box explicitly, since a consumer reset cannot be assumed */
69
+ box-sizing: border-box;
70
+ justify-content: center;
71
+
72
+ min-width: 44px;
73
+ min-height: 44px;
74
+ }
75
+ }
76
+
77
+ `
78
+
79
+ export type SoundProps = {
80
+ /** Lands on the button, which is what the control bar attaches its wheel listener to. */
81
+ ref?: Ref<HTMLButtonElement> | ((element: HTMLButtonElement | null) => void)
82
+ }
83
+
84
+ export const Sound = ({ ref }: SoundProps) => {
85
+ const player = usePlayer()
86
+ const volume = usePlayer((state) => state.volume)
87
+ const muted = usePlayer((state) => state.muted)
88
+
89
+ // the store holds gain, the slider holds linear position, so every read and write converts
90
+ const linearVolume = useMemo(() => logToLinearVolume(volume), [volume])
91
+
92
+ // linearToLogVolume floors its input, so the slider never produces a zero and the store's own
93
+ // unmute-above-zero is enough
94
+ const setVolume = (newLinearVolume: number) => {
95
+ player.setVolume(linearToLogVolume(newLinearVolume))
96
+ }
97
+
98
+ return (
99
+ <div css={style}>
100
+ <TooltipDisplay
101
+ id='sound'
102
+ text={
103
+ <button
104
+ className='sound'
105
+ type='button'
106
+ onClick={() => player.toggleMuted()}
107
+ ref={ref}
108
+ >
109
+ {muted || volume === 0
110
+ ? <VolumeX size={18} color='#fff' />
111
+ : volume <= 0.5
112
+ ? <Volume1 size={18} color='#fff' />
113
+ : <Volume2 size={18} color='#fff' />
114
+ }
115
+ </button>
116
+ }
117
+ toolTipText={
118
+ <span>{muted ? 'Unmute (m)' : 'Mute (m)'}</span>
119
+ }
120
+ />
121
+ <div className='volume-slider-container'>
122
+ <div className='volume-slider-background'>
123
+ <VolumeSlider
124
+ value={muted ? 0 : linearVolume}
125
+ onChange={setVolume}
126
+ />
127
+ </div>
128
+ </div>
129
+ </div>
130
+ )
131
+ }
132
+
133
+ export default Sound
@@ -1,8 +1,15 @@
1
- import { ReactNode } from 'react'
1
+ import type { ReactNode } from 'react'
2
+
2
3
  import { css } from '@emotion/react'
3
4
  import { PlacesType, Tooltip } from 'react-tooltip'
4
5
 
5
- import { fonts } from '../utils/fonts'
6
+ import { fonts } from '../../utils/fonts'
7
+
8
+ export enum buttonSize {
9
+ sm = 'sm',
10
+ md = 'md',
11
+ lg = 'lg'
12
+ }
6
13
 
7
14
  const style = (size: buttonSize) => css`
8
15
  display: flex;
@@ -40,12 +47,6 @@ interface TooltipDisplayProps {
40
47
  disabled?: boolean
41
48
  }
42
49
 
43
- export enum buttonSize {
44
- sm = 'sm',
45
- md = 'md',
46
- lg = 'lg'
47
- }
48
-
49
50
  export const TooltipDisplay = ({
50
51
  id,
51
52
  toolTipText,
@@ -81,3 +82,5 @@ export const TooltipDisplay = ({
81
82
  }
82
83
  </>
83
84
  )
85
+
86
+ export default TooltipDisplay
@@ -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,134 @@
1
+ import type { PlaybackController } from '../../engine'
2
+ import type { MediaPlayerOptions } from '../video-player'
3
+
4
+ import { useCallback, useEffect, useRef, useState } from 'react'
5
+
6
+ import { startPlayback } from '../../engine'
7
+ import { usePlayer } from '../player'
8
+
9
+ /**
10
+ * Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
11
+ * discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
12
+ */
13
+ export const usePlayback = (
14
+ video: HTMLVideoElement | null,
15
+ canvas: HTMLCanvasElement | null,
16
+ options: MediaPlayerOptions,
17
+ ) => {
18
+ const player = usePlayer()
19
+ const {
20
+ read, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl,
21
+ bufferSize, autoplay = false,
22
+ } = options
23
+
24
+ // The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
25
+ // `selectedAudioStream`, which is whatever is playing right now.
26
+ const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
27
+
28
+ const controllerRef = useRef<PlaybackController | null>(null)
29
+ const resumeTimeRef = useRef(0)
30
+ // The renderer turns the first track on by itself, so the menu has to mirror that or it shows
31
+ // "Disable" ticked over subtitles that are visibly on screen.
32
+ const subtitleChoiceMade = useRef(false)
33
+
34
+ const readRef = useRef(read)
35
+ readRef.current = read
36
+ const onSeekRef = useRef(options.onSeek)
37
+ onSeekRef.current = options.onSeek
38
+ const onPlaybackErrorRef = useRef(options.onPlaybackError)
39
+ onPlaybackErrorRef.current = options.onPlaybackError
40
+
41
+ const selectSubtitleStream = useCallback((streamIndex: number | undefined) => {
42
+ subtitleChoiceMade.current = true
43
+ player.setSourceState({ selectedSubtitleStream: streamIndex })
44
+ controllerRef.current?.selectSubtitleStream(streamIndex)
45
+ }, [player])
46
+
47
+ const selectAudioStream = useCallback((streamIndex: number) => {
48
+ player.setSourceState({ selectedAudioStream: streamIndex })
49
+ setAudioStreamIndex(streamIndex)
50
+ }, [player])
51
+
52
+ // Subscribed rather than read off the store, because it is a no-op until the media element
53
+ // attaches. These deps are otherwise all stable, so a mount-time publish would be the only one and
54
+ // both actions would stay dead defaults.
55
+ const setSourceState = usePlayer((state) => state.setSourceState)
56
+
57
+ useEffect(() => {
58
+ setSourceState({ selectSubtitleStream, selectAudioStream })
59
+ }, [setSourceState, selectSubtitleStream, selectAudioStream])
60
+
61
+ useEffect(() => {
62
+ if (!video || !canvas || !size || !read) return
63
+ let cancelled = false
64
+ player.setSourceState({ playbackError: null, ready: false })
65
+ const fail = (error: unknown) => {
66
+ if (cancelled) return
67
+ console.error('playback failed', error)
68
+ player.setSourceState({ playbackError: error })
69
+ onPlaybackErrorRef.current?.(error)
70
+ }
71
+ void (async () => {
72
+ try {
73
+ const controller = await startPlayback({
74
+ videoElement: video,
75
+ canvasElement: canvas,
76
+ read: (offset, length) => readRef.current!(offset, length),
77
+ length: size,
78
+ publicPath,
79
+ libavWorkerUrl,
80
+ jassubWorkerUrl,
81
+ jassubWasmUrl,
82
+ jassubLegacyWasmUrl,
83
+ defaultFontUrl,
84
+ bufferSize,
85
+ audioStreamIndex,
86
+ onReady: () => {
87
+ if (cancelled) return
88
+ player.setSourceState({ ready: true })
89
+ if (resumeTimeRef.current > 0) {
90
+ video.currentTime = resumeTimeRef.current
91
+ resumeTimeRef.current = 0
92
+ }
93
+ if (autoplay) video.play().catch(() => {})
94
+ },
95
+ onError: fail,
96
+ onRecovered: () => { if (!cancelled) player.setSourceState({ playbackError: null }) },
97
+ onSeek: (fraction) => onSeekRef.current?.(fraction),
98
+ onSubtitleStreams: (streams) => {
99
+ if (cancelled) return
100
+ player.setSourceState({
101
+ subtitleStreams: streams,
102
+ ...subtitleChoiceMade.current ? {} : { selectedSubtitleStream: streams[0]?.streamIndex },
103
+ })
104
+ },
105
+ onAudioStreams: (streams, selected) => {
106
+ if (cancelled) return
107
+ player.setSourceState({ audioStreams: streams, selectedAudioStream: selected })
108
+ },
109
+ })
110
+ if (cancelled) {
111
+ controller.destroy()
112
+ return
113
+ }
114
+ controllerRef.current = controller
115
+ player.setSourceState({ indexes: controller.indexes })
116
+ // a track chosen before this pipeline existed has to be re-applied to the new renderer
117
+ const chosen = player.selectedSubtitleStream
118
+ if (chosen !== undefined) controller.selectSubtitleStream(chosen)
119
+ } catch (error) {
120
+ fail(error)
121
+ }
122
+ })()
123
+ return () => {
124
+ cancelled = true
125
+ resumeTimeRef.current = video.currentTime
126
+ controllerRef.current?.destroy()
127
+ controllerRef.current = null
128
+ player.setSourceState({ ready: false })
129
+ }
130
+ }, [
131
+ player, video, canvas, size, read, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
132
+ jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
133
+ ])
134
+ }
@@ -0,0 +1,77 @@
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
+ publicPath: string
14
+ workerUrl: string
15
+ length: number | undefined
16
+ read: ((offset: number, size: number) => Promise<ArrayBuffer>) | undefined
17
+ /** When omitted the whole file is treated as readable, which is the case for a local file. */
18
+ downloadedRanges?: DownloadedRange[]
19
+ }
20
+
21
+ export const useSeekThumbnails = ({
22
+ publicPath, workerUrl, length, read, downloadedRanges,
23
+ }: UseSeekThumbnailsOptions): ThumbnailImage[] => {
24
+ const [thumbnails, setThumbnails] = useState<ThumbnailImage[]>([])
25
+ const generatorRef = useRef<ThumbnailGenerator | null>(null)
26
+ const readRef = useRef(read)
27
+ readRef.current = read
28
+
29
+ const ranges = useMemo(
30
+ () => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
31
+ // a streaming consumer changes the array identity every tick, so compare contents
32
+ [downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}-${endByteOffset}`).join(',')],
33
+ )
34
+ const rangesRef = useRef(ranges)
35
+ rangesRef.current = ranges
36
+
37
+ useEffect(() => {
38
+ if (!length || !read) return
39
+ let cancelled = false
40
+ let generator: ThumbnailGenerator | null = null
41
+ let retry: ReturnType<typeof setTimeout> | undefined
42
+ let delay = RETRY_DELAY
43
+ const boot = () => {
44
+ createThumbnailGenerator({
45
+ publicPath,
46
+ workerUrl,
47
+ length,
48
+ read: (offset, size) => readRef.current!(offset, size),
49
+ onThumbnails: (next) => { if (!cancelled) setThumbnails(next) },
50
+ }).then((created) => {
51
+ if (cancelled) {
52
+ created.destroy()
53
+ return
54
+ }
55
+ generator = created
56
+ generatorRef.current = created
57
+ created.update(rangesRef.current)
58
+ }, () => {
59
+ if (cancelled) return
60
+ retry = setTimeout(boot, delay)
61
+ delay = Math.min(delay * 2, MAX_RETRY_DELAY)
62
+ })
63
+ }
64
+ boot()
65
+ return () => {
66
+ cancelled = true
67
+ clearTimeout(retry)
68
+ generatorRef.current = null
69
+ generator?.destroy()
70
+ setThumbnails([])
71
+ }
72
+ }, [length, publicPath, workerUrl])
73
+
74
+ useEffect(() => { generatorRef.current?.update(ranges) }, [ranges])
75
+
76
+ return thumbnails
77
+ }
@@ -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>