@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,20 +1,21 @@
1
- import { ReactNode, RefObject, useCallback, useContext, useEffect, useState } from 'react'
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import { useCallback, useEffect, useState } from 'react'
2
3
  import { css } from '@emotion/react'
3
4
  import { Maximize, Minimize, Pause, Play, RotateCcw } from 'react-feather'
4
5
 
5
- import { linearToLogVolume, logToLinearVolume } from '../utils/volume-utils'
6
- import { MediaMachineContext } from '../state-machines'
6
+ import { linearToLogVolume, logToLinearVolume } from '../../utils/volume-utils'
7
+ import { formatMediaTime } from '../../utils/time'
8
+ import { fonts } from '../../utils/fonts'
9
+ import { usePlayer } from '../player'
7
10
  import { TooltipDisplay } from './tooltip-display'
8
- import { togglePlay } from '../utils/actor-utils'
9
- import { MediaPlayerContext } from '../utils/context'
10
11
  import { ProgressBar } from './progress-bar'
11
- import { formatMediaTime } from '../utils/time'
12
- import { fonts } from '../utils/fonts'
13
- import pictureInPicture from '../assets/picture-in-picture.svg'
12
+ import pictureInPicture from '../../assets/picture-in-picture.svg'
14
13
  import SettingsAction from './settings'
15
- import colors from '../utils/colors'
14
+ import colors from '../../utils/colors'
16
15
  import Sound from './sound'
17
- import useLocalStorage, { booleanType } from '../utils/use-local-storage'
16
+
17
+ const VOLUME_STEP = 0.05
18
+ const SEEK_STEP = 5
18
19
 
19
20
  const style = css`
20
21
  position: absolute;
@@ -28,12 +29,14 @@ const style = css`
28
29
  display: flex;
29
30
  justify-content: space-between;
30
31
 
31
- padding: 6px 12px;
32
+ /* repeated per breakpoint: nested at-rules are hoisted after the rule, so a shorthand inside a
33
+ media query would override a longhand above it and silently drop the safe-area inset */
34
+ padding: 6px calc(12px + env(safe-area-inset-right, 0px)) calc(6px + env(safe-area-inset-bottom, 0px)) calc(12px + env(safe-area-inset-left, 0px));
32
35
  @media (min-width: 768px) {
33
- padding: 8px 16px;
36
+ padding: 8px calc(16px + env(safe-area-inset-right, 0px)) calc(8px + env(safe-area-inset-bottom, 0px)) calc(16px + env(safe-area-inset-left, 0px));
34
37
  }
35
38
  @media (min-width: 2560px) {
36
- padding: 8px 24px;
39
+ padding: 8px calc(24px + env(safe-area-inset-right, 0px)) calc(8px + env(safe-area-inset-bottom, 0px)) calc(24px + env(safe-area-inset-left, 0px));
37
40
  }
38
41
 
39
42
  .left, .right {
@@ -66,7 +69,6 @@ const style = css`
66
69
  }
67
70
  }
68
71
 
69
- // Add basic styling for all the actions
70
72
  .play, .sound, .time, .settings, .picture-in-picture, .full-screen {
71
73
  display: flex;
72
74
  align-items: center;
@@ -85,7 +87,6 @@ const style = css`
85
87
  }
86
88
  }
87
89
 
88
- // Add styling to the actions that have some interactivity
89
90
  .play, .sound, .settings, .picture-in-picture, .full-screen {
90
91
  border-radius: 4px;
91
92
 
@@ -102,6 +103,16 @@ const style = css`
102
103
  :hover {
103
104
  background-color: ${colors.hover};
104
105
  }
106
+
107
+ /* the hit area grows, the icon does not, keyed on the pointer because a narrow desktop
108
+ window still has a mouse */
109
+ @media (pointer: coarse) {
110
+ /* border-box explicitly, since a consumer reset cannot be assumed */
111
+ box-sizing: border-box;
112
+ min-width: 44px;
113
+ min-height: 44px;
114
+ justify-content: center;
115
+ }
105
116
  }
106
117
  }
107
118
 
@@ -130,102 +141,73 @@ const style = css`
130
141
  }
131
142
  `
132
143
 
133
- export const ControlBar = ({ mediaInformation, containerRef }: { mediaInformation?: ReactNode, containerRef: RefObject<HTMLDivElement> }) => {
134
- const mediaActor = MediaMachineContext.useActorRef()
135
- const chromeContext = useContext(MediaPlayerContext)
136
- const isPaused = MediaMachineContext.useSelector((state) => state.context.media.paused)
137
- const volume = MediaMachineContext.useSelector((state) => state.context.media.volume)
138
- const muted = MediaMachineContext.useSelector((state) => state.context.media.muted)
139
- const currentTime = MediaMachineContext.useSelector((state) => state.context.media.currentTime)
140
- const duration = MediaMachineContext.useSelector((state) => state.context.media.duration)
141
- const [volumeElement, setVolumeElement] = useState<HTMLDivElement | undefined>()
142
- const [hideMediaStats, _] = useLocalStorage('hideMediaStats', 'false') as [booleanType, (newValue: booleanType) => void]
143
-
144
- const [isFullscreen, setIsFullscreen] = useState(false)
144
+ export const ControlBar = () => {
145
+ const player = usePlayer()
146
+ const paused = usePlayer((state) => state.paused)
147
+ const currentTime = usePlayer((state) => state.currentTime)
148
+ const duration = usePlayer((state) => state.duration)
149
+ const fullscreen = usePlayer((state) => state.fullscreen)
150
+ const hideUI = usePlayer((state) => state.hideUI)
151
+ const togglePictureInPicture = usePlayer((state) => state.togglePictureInPicture)
152
+ const [volumeElement, setVolumeElement] = useState<HTMLButtonElement | null>(null)
145
153
 
146
- useEffect(() => {
147
- const handleFullscreenChange = () => {
148
- setIsFullscreen(!!document.fullscreenElement)
149
- }
150
- document.addEventListener('fullscreenchange', handleFullscreenChange)
151
- return () => {
152
- document.removeEventListener('fullscreenchange', handleFullscreenChange)
153
- }
154
- }, [])
155
-
156
- const toggleFullScreen = () => {
157
- if (!document.fullscreenElement) {
158
- if (containerRef.current) {
159
- containerRef.current.requestFullscreen()
160
- }
161
- } else {
162
- document.exitFullscreen()
163
- }
164
- }
154
+ // duration is 0 until metadata lands, so a bare equality would show replay before playback starts
155
+ const ended = duration > 0 && currentTime === duration
165
156
 
157
+ // Stepped in linear space and converted back, and the mute flag is restored afterwards because
158
+ // setVolume clears it for any value above zero.
166
159
  const modifyVolume = useCallback(({ direction, stepSize }: { direction: 'up' | 'down', stepSize: number }) => {
167
- if (!mediaActor) return
168
- const linearVolume = logToLinearVolume(volume)
160
+ const linearVolume = logToLinearVolume(player.volume)
169
161
  const step = (direction === 'up' ? stepSize : -stepSize)
170
162
  const newLinearVolume = Math.max(0, Math.min(1, linearVolume + step))
171
- const newLogVolume = linearToLogVolume(newLinearVolume)
172
-
173
- mediaActor.send({
174
- type: 'SET_VOLUME',
175
- muted,
176
- volume: newLogVolume
177
- })
178
- }, [mediaActor.id, muted, volume])
163
+ const wasMuted = player.muted
164
+ player.setVolume(linearToLogVolume(newLinearVolume))
165
+ if (wasMuted && !player.muted) player.toggleMuted()
166
+ }, [player])
179
167
 
180
168
  useEffect(() => {
181
169
  const eventListener = (ev: KeyboardEvent) => {
170
+ // on the window, so typing into a consumer's own input is the one case that opts out
171
+ const target = ev.target as HTMLElement | null
172
+ if (target?.isContentEditable || /^(input|textarea|select)$/i.test(target?.tagName ?? '')) return
173
+
182
174
  let shouldPreventDefault = true
183
- // avoid triggering the browser's default behavior (e.g space for pause it opens the full screen)
184
- if (ev.key === 'f') toggleFullScreen()
185
- else if (ev.key === 'k') togglePlay(mediaActor, isPaused, duration, currentTime)
186
- else if (ev.key === ' ') togglePlay(mediaActor, isPaused, duration, currentTime)
187
- else if (ev.key === 'm') {
188
- mediaActor.send({ type: 'SET_VOLUME', muted: !muted, volume })
189
- }
175
+ // space would otherwise reach the browser's own shortcut
176
+ if (ev.key === 'f') player.toggleFullscreen()
177
+ else if (ev.key === 'k') player.togglePaused()
178
+ else if (ev.key === ' ') player.togglePaused()
179
+ else if (ev.key === 'm') player.toggleMuted()
190
180
  else if (ev.key === 'ArrowUp') {
191
- modifyVolume({ direction: 'up', stepSize: 0.05 })
181
+ modifyVolume({ direction: 'up', stepSize: VOLUME_STEP })
192
182
  }
193
183
  else if (ev.key === 'ArrowDown') {
194
- modifyVolume({ direction: 'down', stepSize: 0.05 })
184
+ modifyVolume({ direction: 'down', stepSize: VOLUME_STEP })
195
185
  }
196
186
  else if (ev.key === 'ArrowRight') {
197
- if (!duration) return
198
- if (currentTime + 5 >= duration) {
199
- mediaActor.send({ type: 'SET_TIME', value: duration })
200
- } else {
201
- mediaActor.send({ type: 'SET_TIME', value: currentTime + 5 })
202
- }
187
+ if (!player.duration) return
188
+ player.seek(Math.min(player.currentTime + SEEK_STEP, player.duration))
203
189
  }
204
190
  else if (ev.key === 'ArrowLeft') {
205
- if (currentTime - 5 < 0) {
206
- mediaActor.send({ type: 'SET_TIME', value: 0 })
207
- } else {
208
- mediaActor.send({ type: 'SET_TIME', value: currentTime - 5 })
209
- }
191
+ player.seek(Math.max(player.currentTime - SEEK_STEP, 0))
210
192
  }
211
193
  else {
212
194
  shouldPreventDefault = false
213
195
  }
214
-
196
+
215
197
  if (shouldPreventDefault) {
216
198
  ev.preventDefault()
217
199
  }
218
200
  }
219
201
  window.addEventListener('keydown', eventListener)
220
202
  return () => window.removeEventListener('keydown', eventListener)
221
- }, [mediaActor, volume, muted, isPaused, currentTime, duration])
203
+ }, [player, modifyVolume])
222
204
 
223
205
  const handleWheel = useCallback((e: WheelEvent) => {
224
206
  e.preventDefault()
225
207
  if (e.deltaY < 0) { // scroll up
226
- modifyVolume({ direction: 'up', stepSize: 0.05 })
208
+ modifyVolume({ direction: 'up', stepSize: VOLUME_STEP })
227
209
  } else { // scroll down
228
- modifyVolume({ direction: 'down', stepSize: 0.05 })
210
+ modifyVolume({ direction: 'down', stepSize: VOLUME_STEP })
229
211
  }
230
212
  }, [modifyVolume])
231
213
 
@@ -233,10 +215,10 @@ export const ControlBar = ({ mediaInformation, containerRef }: { mediaInformatio
233
215
  if (!volumeElement) return
234
216
  volumeElement.addEventListener('wheel', handleWheel, { passive: false })
235
217
  return () => volumeElement.removeEventListener('wheel', handleWheel)
236
- }, [volumeElement, modifyVolume])
218
+ }, [volumeElement, handleWheel])
237
219
 
238
220
  return (
239
- <div css={style} style={{ ...chromeContext.hideUI ? { opacity: '0', pointerEvents: 'none' } : {} }}>
221
+ <div css={style} style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : {} }}>
240
222
  <ProgressBar />
241
223
  <div className='actions'>
242
224
  <div className='left'>
@@ -247,12 +229,12 @@ export const ControlBar = ({ mediaInformation, containerRef }: { mediaInformatio
247
229
  <button
248
230
  className='play'
249
231
  type='button'
250
- onClick={() => togglePlay(mediaActor, isPaused, duration, currentTime)}
232
+ onClick={() => player.togglePaused()}
251
233
  >
252
234
  {
253
- duration === currentTime
235
+ ended
254
236
  ? <RotateCcw />
255
- : isPaused
237
+ : paused
256
238
  ? <Play />
257
239
  : <Pause />
258
240
  }
@@ -261,26 +243,21 @@ export const ControlBar = ({ mediaInformation, containerRef }: { mediaInformatio
261
243
  toolTipText={
262
244
  <span>
263
245
  {
264
- duration === currentTime
246
+ ended
265
247
  ? 'Replay (k)'
266
- : isPaused
248
+ : paused
267
249
  ? 'Play (k)'
268
250
  : 'Pause (k)'
269
251
  }
270
252
  </span>
271
253
  }
272
- />
254
+ />
273
255
  <Sound ref={setVolumeElement}/>
274
256
  <div className='time'>
275
257
  {formatMediaTime(currentTime, duration)}
276
258
  </div>
277
259
  </div>
278
260
  <div className='right'>
279
- {
280
- hideMediaStats !== 'true'
281
- ? mediaInformation
282
- : null
283
- }
284
261
  <SettingsAction />
285
262
  <TooltipDisplay
286
263
  id='picture-in-picture'
@@ -288,6 +265,7 @@ export const ControlBar = ({ mediaInformation, containerRef }: { mediaInformatio
288
265
  <button
289
266
  className='picture-in-picture'
290
267
  type='button'
268
+ onClick={togglePictureInPicture}
291
269
  >
292
270
  <img src={pictureInPicture} />
293
271
  </button>
@@ -305,10 +283,10 @@ export const ControlBar = ({ mediaInformation, containerRef }: { mediaInformatio
305
283
  <button
306
284
  className='full-screen'
307
285
  type='button'
308
- onClick={toggleFullScreen}
286
+ onClick={() => player.toggleFullscreen()}
309
287
  >
310
288
  {
311
- isFullscreen
289
+ fullscreen
312
290
  ? <Minimize />
313
291
  : <Maximize />
314
292
  }
@@ -317,7 +295,7 @@ export const ControlBar = ({ mediaInformation, containerRef }: { mediaInformatio
317
295
  toolTipText={
318
296
  <span>
319
297
  {
320
- isFullscreen
298
+ fullscreen
321
299
  ? 'Exit full screen (f)'
322
300
  : 'Full screen (f)'
323
301
  }
@@ -0,0 +1,125 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import { css, keyframes } from '@emotion/react'
3
+
4
+ import { usePlayer } from '../player'
5
+ import { fonts } from '../../utils/fonts'
6
+
7
+ const style = css`
8
+ top: unset !important;
9
+ left: unset !important;
10
+ width: 100%;
11
+ height: 100%;
12
+ margin: auto;
13
+ pointer-events: none;
14
+ `
15
+
16
+ const titleStyle = css`
17
+ position: absolute;
18
+ top: 0;
19
+ left: 0;
20
+ width: 100%;
21
+ padding: calc(1.2 * var(--mp-unit)) calc(1.6 * var(--mp-unit));
22
+ /* clears a notch once the player is fullscreen; resolves to zero everywhere else */
23
+ padding-top: calc(calc(1.2 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
24
+ ${fonts.headings.small}
25
+ color: white;
26
+ text-shadow: 0 0 4px rgba(0, 0, 0, 1);
27
+ z-index: 2;
28
+ pointer-events: none;
29
+
30
+ /* one line with an ellipsis until there is width to wrap, since a release filename would
31
+ otherwise run to four lines on a phone */
32
+ white-space: nowrap;
33
+ overflow: hidden;
34
+ text-overflow: ellipsis;
35
+
36
+ @media (min-width: 768px) {
37
+ padding: calc(2.4 * var(--mp-unit));
38
+ padding-top: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
39
+ white-space: normal;
40
+ overflow: visible;
41
+ }
42
+
43
+ background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.3) 30%, rgba(0,0,0,0.2) 60%, rgba(0,0,0,0.1) 80%, transparent 100%);
44
+ transition: opacity 0.1s cubic-bezier(.4,0,1,1);
45
+ `
46
+
47
+ const spin = keyframes`
48
+ to { transform: rotate(360deg); }
49
+ `
50
+
51
+ const loadingStyle = css`
52
+ position: absolute;
53
+ inset: 0;
54
+ z-index: 2;
55
+ display: flex;
56
+ justify-content: center;
57
+ align-items: center;
58
+ pointer-events: none;
59
+
60
+ ::after {
61
+ content: '';
62
+ width: calc(4 * var(--mp-unit));
63
+ height: calc(4 * var(--mp-unit));
64
+ border-radius: 50%;
65
+ border: 3px solid rgba(255, 255, 255, 0.25);
66
+ border-top-color: #fff;
67
+ animation: ${spin} 0.8s linear infinite;
68
+ }
69
+ `
70
+
71
+ // a failure would otherwise be a black frame under a spinner that never resolves
72
+ const errorStyle = css`
73
+ position: absolute;
74
+ inset: auto 0 20%;
75
+ z-index: 3;
76
+ padding: 0 calc(2 * var(--mp-unit));
77
+ text-align: center;
78
+ color: #fff;
79
+ ${fonts.bLarge.regular}
80
+ text-shadow: 0 0 4px rgba(0, 0, 0, 1);
81
+ pointer-events: none;
82
+ `
83
+
84
+ const errorMessage = (error: unknown) =>
85
+ error instanceof Error
86
+ ? error.message
87
+ : typeof error === 'string' ? error : 'Playback failed'
88
+
89
+ export const Overlay = ({ onCanvasRef }: { onCanvasRef: (element: HTMLCanvasElement | null) => void }) => {
90
+ const title = usePlayer((state) => state.title)
91
+ const hideUI = usePlayer((state) => state.hideUI)
92
+ const playbackError = usePlayer((state) => state.playbackError)
93
+ const ready = usePlayer((state) => state.ready)
94
+ const size = usePlayer((state) => state.size)
95
+ // video.js's own: readyState below HAVE_FUTURE_DATA while not paused
96
+ const waiting = usePlayer((state) => state.waiting)
97
+
98
+ return (
99
+ <>
100
+ {title
101
+ ? (
102
+ <div
103
+ css={titleStyle}
104
+ style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : {} }}
105
+ >
106
+ {title}
107
+ </div>
108
+ )
109
+ : undefined}
110
+ {/* Two different waits, one spinner. With bytes it is pre-metadata rather than buffering: the
111
+ store reports 0 both before metadata and for a genuinely unknown duration, so `size` is what
112
+ tells whether a source was handed over at all. A media this player does not own has neither
113
+ `size` nor `ready`, and reports the ordinary `waiting` every element does. */}
114
+ {(size ? !ready : waiting) && !playbackError
115
+ ? <div css={loadingStyle} />
116
+ : undefined}
117
+ {playbackError
118
+ ? <div css={errorStyle}>{errorMessage(playbackError)}</div>
119
+ : undefined}
120
+ <canvas ref={onCanvasRef} css={style} />
121
+ </>
122
+ )
123
+ }
124
+
125
+ export default Overlay
@@ -1,7 +1,9 @@
1
- import { useRef, useState, useEffect, useMemo } from 'react'
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import { useRef, useEffect } from 'react'
2
3
  import { css } from '@emotion/react'
3
4
 
4
- import { MediaMachineContext } from '../state-machines'
5
+ import { usePlayer } from '../player'
6
+ import { useDragValue } from '../hooks/use-drag-value'
5
7
 
6
8
  const style = css`
7
9
  display: flex;
@@ -16,6 +18,10 @@ width: 100%;
16
18
 
17
19
  padding: 8px 4px;
18
20
  margin: 0 2px;
21
+ /* a finger needs a 44px band to press, so the padding grows while the track stays 3px */
22
+ @media (pointer: coarse) {
23
+ padding: 20px 8px;
24
+ }
19
25
 
20
26
  cursor: pointer;
21
27
 
@@ -26,6 +32,7 @@ width: 100%;
26
32
  border-radius: 4px;
27
33
 
28
34
  width: 120px;
35
+ max-width: 100%;
29
36
  height: 3px;
30
37
  @media (min-width: 768px) {
31
38
  height: 4px;
@@ -39,7 +46,7 @@ width: 100%;
39
46
  background: #ffffff;
40
47
  border-radius: 50%;
41
48
  transform: translateX(-50%);
42
- pointer-events: none;
49
+ pointer-events: none;
43
50
 
44
51
  width: 10px;
45
52
  height: 10px;
@@ -53,77 +60,47 @@ width: 100%;
53
60
  }
54
61
  `
55
62
 
56
- const PlaybackSlider = () => {
57
- const playbackRate = MediaMachineContext.useSelector((state) => state.context.media.playbackRate)
58
- const mediaActor = MediaMachineContext.useActorRef()
63
+ export const PlaybackSlider = () => {
64
+ const player = usePlayer()
65
+ const playbackRate = usePlayer((state) => state.playbackRate)
59
66
  const sliderRef = useRef<HTMLDivElement>(null)
60
- const [isDragging, setIsDragging] = useState(false)
67
+ const trackRef = useRef<HTMLDivElement>(null)
61
68
 
62
69
  const MIN_RATE = 0.25
63
70
  const MAX_RATE = 3.0
64
71
  const RANGE = MAX_RATE - MIN_RATE
65
-
72
+
66
73
  const roundToStep = (value: number, step: number = 0.05): number => {
67
74
  return Math.round(value / step) * step
68
75
  }
69
-
76
+
70
77
  const roundedRate = roundToStep(playbackRate || 1)
71
-
72
- // Calculate position as percentage
78
+
73
79
  const getHandlePosition = (rate: number): number => {
74
80
  return ((rate - MIN_RATE) / RANGE) * 100
75
81
  }
76
82
 
77
- const getPlaybackRateFromEvent = (clientX: number): number => {
78
- if (!sliderRef.current) return roundedRate;
79
-
80
- const rect = sliderRef.current.getBoundingClientRect()
81
- const xPos = clientX - rect.left
82
- const percent = Math.max(0, Math.min(xPos, rect.width)) / rect.width
83
-
84
- const rawRate = MIN_RATE + (percent * RANGE)
85
-
86
- return roundToStep(rawRate)
87
- }
88
-
89
- const handlePointerDown = (e: React.MouseEvent<HTMLDivElement>) => {
90
- setIsDragging(true)
91
- const newRate = getPlaybackRateFromEvent(e.clientX)
92
- mediaActor.send({ type: 'SET_PLAYBACK_RATE', playbackRate: newRate })
93
- }
94
-
95
- const handlePointerMove = (e: MouseEvent) => {
96
- if (!isDragging) return;
97
- const newRate = getPlaybackRateFromEvent(e.clientX)
98
- mediaActor.send({ type: 'SET_PLAYBACK_RATE', playbackRate: newRate })
99
- }
83
+ // the inverse of getHandlePosition, measured across the track rather than the padded row
84
+ const getPlaybackRateFromFraction = (fraction: number): number => roundToStep(MIN_RATE + (fraction * RANGE))
100
85
 
101
- const handlePointerUp = () => {
102
- setIsDragging(false)
103
- }
86
+ const { handlers } = useDragValue({
87
+ ref: trackRef,
88
+ onChange: (fraction) => player.setPlaybackRate(getPlaybackRateFromFraction(fraction))
89
+ })
104
90
 
105
91
  const handleWheel = (e: WheelEvent) => {
106
92
  e.preventDefault()
107
93
  const step = 0.05
108
94
  let newRate = roundedRate
109
-
95
+
110
96
  if (e.deltaY < 0) {
111
97
  newRate = Math.min(MAX_RATE, newRate + step)
112
98
  } else {
113
99
  newRate = Math.max(MIN_RATE, newRate - step)
114
100
  }
115
-
116
- mediaActor.send({ type: 'SET_PLAYBACK_RATE', playbackRate: newRate })
117
- }
118
101
 
119
- useEffect(() => {
120
- window.addEventListener('mousemove', handlePointerMove)
121
- window.addEventListener('mouseup', handlePointerUp)
122
- return () => {
123
- window.removeEventListener('mousemove', handlePointerMove)
124
- window.removeEventListener('mouseup', handlePointerUp)
125
- }
126
- }, [isDragging])
102
+ player.setPlaybackRate(newRate)
103
+ }
127
104
 
128
105
  useEffect(() => {
129
106
  const slider = sliderRef.current
@@ -149,12 +126,13 @@ const PlaybackSlider = () => {
149
126
  <div
150
127
  className="playback-slider"
151
128
  ref={sliderRef}
152
- onMouseDown={handlePointerDown}
129
+ {...handlers}
153
130
  >
154
131
  <div
132
+ ref={trackRef}
155
133
  style={{
156
- background: `linear-gradient(to right,
157
- ${fillColor} 0% ${handlePosition}%,
134
+ background: `linear-gradient(to right,
135
+ ${fillColor} 0% ${handlePosition}%,
158
136
  ${emptyColor} ${handlePosition}% 100%
159
137
  )`
160
138
  }}