@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
@@ -1,15 +1,20 @@
1
- import { DOMAttributes, useContext, useEffect, useMemo, useRef, useState } from 'react'
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { DOMAttributes } from 'react'
3
+
4
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
5
  import { css } from '@emotion/react'
3
6
 
4
- import { MediaMachineContext } from '../state-machines'
5
- import { MediaPlayerContext } from '../utils/context'
6
- import useScrub from '../utils/use-scrub'
7
- import { fonts } from '../utils/fonts'
7
+ import { usePlayer } from '../player'
8
+ import { useDragValue } from '../hooks/use-drag-value'
9
+ import { fonts } from '../../utils/fonts'
8
10
 
9
11
  const style = css`
10
12
  position: relative;
11
13
  height: .4rem;
12
14
 
15
+ --thumbnail-width: 25rem;
16
+ --thumbnail-half: 12.5rem;
17
+
13
18
  transition: transform .2s ease-in-out;
14
19
 
15
20
  margin: 0 12px;
@@ -36,6 +41,7 @@ const style = css`
36
41
  }
37
42
  }
38
43
 
44
+
39
45
  .background-bar {
40
46
  position: absolute;
41
47
  inset: 0;
@@ -88,7 +94,7 @@ const style = css`
88
94
  height: .4rem;
89
95
  width: 100%;
90
96
  }
91
-
97
+
92
98
  .padding {
93
99
  position: absolute;
94
100
  bottom: -7.5px;
@@ -101,11 +107,11 @@ const style = css`
101
107
  justify-content: center;
102
108
 
103
109
  position: absolute;
104
- top: -17.5rem;
105
- height: calc(25rem * 9/16);
106
- width: 25rem;
110
+ top: calc(-3.4375rem - var(--thumbnail-width) * 9/16);
111
+ height: calc(var(--thumbnail-width) * 9/16);
112
+ width: var(--thumbnail-width);
107
113
 
108
- margin-left: -12.5rem;
114
+ margin-left: calc(var(--thumbnail-half) * -1);
109
115
  pointer-events: none;
110
116
 
111
117
  img {
@@ -117,26 +123,97 @@ const style = css`
117
123
  object-fit: cover;
118
124
  }
119
125
  }
126
+
127
+ /* a thumb is far wider than a cursor, so the hit strip grows to 44px and the track to .6rem */
128
+ @media (pointer: coarse) {
129
+ height: .6rem;
130
+
131
+ /* press feedback, coarse only: a finger has no hover, so without this the bar gives no sign
132
+ it is being dragged */
133
+ &.dragging {
134
+ .background-bar, .loaded, .play-container {
135
+ transform: scaleY(1.5);
136
+ }
137
+ }
138
+
139
+ .loaded {
140
+ height: .6rem;
141
+ .loaded-part {
142
+ height: .6rem;
143
+ }
144
+ }
145
+
146
+ .play-container {
147
+ height: .6rem;
148
+ }
149
+
150
+ .play {
151
+ height: .6rem;
152
+ }
153
+
154
+ /* grows upward into the video: centred, it would hang over the control row and win hit
155
+ testing against taps meant for play, mute and fullscreen */
156
+ .padding {
157
+ height: 44px;
158
+ bottom: 0;
159
+ }
160
+ }
161
+
162
+ /* the clamp only holds the preview inside the bar while the bar is the wider of the two, so
163
+ below that the preview is narrowed instead */
164
+ @media (max-width: 480px) {
165
+ --thumbnail-width: 18rem;
166
+ --thumbnail-half: 9rem;
167
+ }
120
168
  `
121
169
 
122
170
  export const ProgressBar = () => {
123
- const mediaActor = MediaMachineContext.useActorRef()
124
- const currentTime = MediaMachineContext.useSelector((state) => state.context.media.currentTime)
125
- const duration = MediaMachineContext.useSelector((state) => state.context.media.duration)
126
- const indexes = MediaMachineContext.useSelector((state) => state.context.indexes)
127
- const thumbnails = MediaMachineContext.useSelector((state) => state.context.thumbnails)
128
-
129
- const mediaPlayerContext = useContext(MediaPlayerContext)
171
+ const player = usePlayer()
172
+ const currentTime = usePlayer((state) => state.currentTime)
173
+ const duration = usePlayer((state) => state.duration)
174
+ const size = usePlayer((state) => state.size)
175
+ const downloadedRanges = usePlayer((state) => state.downloadedRanges)
176
+ const indexes = usePlayer((state) => state.indexes)
177
+ const thumbnails = usePlayer((state) => state.thumbnails)
130
178
 
131
179
  const progressBarRef = useRef<HTMLDivElement>(null)
132
- const { scrub: seekScrub, value: seekScrubValue, setValue: setSeekValue } = useScrub({ ref: progressBarRef })
133
-
180
+
181
+ const [seekFraction, setSeekFraction] = useState<number | undefined>(undefined)
134
182
  const [progressBarHoverTime, setProgressBarOverTime] = useState<number | undefined>(undefined)
135
183
 
184
+ // onChange reports a bare fraction, so the device that opened the gesture is recorded on press
185
+ const dragPointerType = useRef<string | undefined>(undefined)
186
+
187
+ const onSeekDrag = (fraction: number) => {
188
+ setSeekFraction(fraction)
189
+ if (dragPointerType.current === 'mouse') return
190
+ setProgressBarOverTime(fraction * duration)
191
+ }
192
+
193
+ const { dragging, handlers } = useDragValue({ ref: progressBarRef, onChange: onSeekDrag })
194
+
195
+ const onDragStart: DOMAttributes<HTMLDivElement>['onPointerDown'] = (ev) => {
196
+ dragPointerType.current = ev.pointerType
197
+ handlers.onPointerDown(ev)
198
+ }
199
+
200
+ const onDragEnd: DOMAttributes<HTMLDivElement>['onPointerUp'] = (ev) => {
201
+ handlers.onPointerUp(ev)
202
+ // a lifted finger leaves nothing over the bar, so the preview it opened closes with it
203
+ if (ev.pointerType === 'mouse') return
204
+ setProgressBarOverTime(undefined)
205
+ }
206
+
207
+ // offsetX is relative to whichever child is under the pointer, and a captured pointer has none
208
+ const timeAtClientX = (clientX: number) => {
209
+ if (!progressBarRef.current) return undefined
210
+ const { left, right } = progressBarRef.current.getBoundingClientRect()
211
+ const fraction = Math.min(Math.max((clientX - left) / (right - left), 0), 1)
212
+ return fraction * duration
213
+ }
214
+
136
215
  const onProgressBarOver: DOMAttributes<HTMLDivElement>['onMouseMove'] = (ev) => {
137
- const percentage = ev.nativeEvent.offsetX / ev.currentTarget.offsetWidth
138
- const time = percentage * (duration ?? 0)
139
- setProgressBarOverTime(time)
216
+ setProgressBarOverTime(timeAtClientX(ev.clientX))
140
217
  }
141
218
 
142
219
  const hideProgressBarTime = () => {
@@ -144,12 +221,14 @@ export const ProgressBar = () => {
144
221
  setProgressBarOverTime(undefined)
145
222
  }
146
223
 
147
- // file download % does not equal video time %, as the video contains sometimes, big headers including fonts, which might be ~50mb
224
+ // duration is 0 until metadata lands, which is never a divisor
225
+ const timePercentage = (time: number) => duration ? (time / duration) * 100 : 0
226
+
227
+ // download percentage is not time percentage: headers and fonts can run to tens of megabytes
148
228
  const loadedParts = useMemo(() =>
149
- mediaPlayerContext
150
- .downloadedRanges
229
+ downloadedRanges
151
230
  ?.map((range, i) => {
152
- if (!mediaPlayerContext.size || !duration) return null
231
+ if (!size || !duration) return null
153
232
  const matchingIndexes = indexes.filter(index => range.startByteOffset <= index.pos && index.pos <= range.endByteOffset)
154
233
  const firstIndex = matchingIndexes.at(0)
155
234
  const lastIndex = matchingIndexes.at(-1)
@@ -163,7 +242,7 @@ export const ProgressBar = () => {
163
242
  )
164
243
  })
165
244
  ?? [],
166
- [duration, indexes.length, mediaPlayerContext.downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}/${endByteOffset}`).join(',')]
245
+ [duration, indexes.length, downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}/${endByteOffset}`).join(',')]
167
246
  )
168
247
 
169
248
  const cusorTimeString = useMemo(() => {
@@ -179,23 +258,24 @@ export const ProgressBar = () => {
179
258
  }, [progressBarHoverTime])
180
259
 
181
260
  useEffect(() => {
182
- if (!mediaActor || seekScrubValue === undefined || !duration) return
183
- const timestamp = seekScrubValue * duration
184
- mediaActor.send({ type: 'SET_TIME', value: timestamp })
185
- }, [mediaActor, seekScrubValue, duration])
261
+ if (seekFraction === undefined || !duration) return
262
+ const timestamp = seekFraction * duration
263
+ player.seek(timestamp)
264
+ }, [player, seekFraction, duration])
186
265
 
187
266
  const scaleX = useMemo(() => {
188
- return typeof duration !== 'number' || typeof currentTime !== 'number'
267
+ return !duration || typeof currentTime !== 'number'
189
268
  ? 0
190
- : 1 / ((duration ?? 0) / (currentTime ?? 0))
269
+ : currentTime / duration
191
270
  }, [duration, currentTime])
192
271
 
272
+ // an empty url is a gap sentinel, so it renders nothing
193
273
  const thumbnail = useMemo(() => {
194
274
  if (!thumbnails.length || !progressBarHoverTime) return undefined
195
275
  return (
196
276
  thumbnails
197
- .find(({ timestamp, duration }) =>
198
- timestamp <= progressBarHoverTime && progressBarHoverTime <= timestamp + duration
277
+ .find(({ startTime, endTime }) =>
278
+ startTime <= progressBarHoverTime && progressBarHoverTime < endTime
199
279
  )
200
280
  )
201
281
  }, [thumbnails.length, progressBarHoverTime])
@@ -204,7 +284,7 @@ export const ProgressBar = () => {
204
284
  <div
205
285
  css={style}
206
286
  ref={progressBarRef}
207
- className="progress-bar"
287
+ className={dragging ? 'progress-bar dragging' : 'progress-bar'}
208
288
  onMouseMove={onProgressBarOver}
209
289
  onMouseOut={hideProgressBarTime}
210
290
  >
@@ -214,7 +294,7 @@ export const ProgressBar = () => {
214
294
  ? (
215
295
  <div
216
296
  className="cursor-time"
217
- style={{ left: `clamp(1.8rem, ${1 / ((duration ?? 0) / (progressBarHoverTime ?? 1)) * 100}%, calc(100% - 1.8rem))` }}
297
+ style={{ left: `clamp(1.8rem, ${timePercentage(progressBarHoverTime)}%, calc(100% - 1.8rem))` }}
218
298
  >
219
299
  {cusorTimeString}
220
300
  </div>
@@ -226,7 +306,6 @@ export const ProgressBar = () => {
226
306
  <div className="loaded">
227
307
  {loadedParts}
228
308
  </div>
229
- {/* <div className="load" style={{ transform: `scaleX(${1 / ((duration ?? 0) / (loadedTime?.[1] ?? 0))})` }}></div> */}
230
309
  {/* bar to show when hovering to potentially seek */}
231
310
  <div className="hover"></div>
232
311
  {/* bar displaying the current playback progress */}
@@ -235,13 +314,19 @@ export const ProgressBar = () => {
235
314
  </div>
236
315
  <div className="chapters"></div>
237
316
  <div className="scrubber"></div>
238
- <div className="padding" onMouseDown={seekScrub}></div>
317
+ <div
318
+ className="padding"
319
+ {...handlers}
320
+ onPointerDown={onDragStart}
321
+ onPointerUp={onDragEnd}
322
+ onPointerCancel={onDragEnd}
323
+ ></div>
239
324
  <div
240
325
  className="thumbnail"
241
- style={{ left: `clamp(12.5rem, ${1 / ((duration ?? 0) / (progressBarHoverTime ?? 1)) * 100}%, calc(100% - 12.5rem))` }}
326
+ style={{ left: `clamp(var(--thumbnail-half), ${timePercentage(progressBarHoverTime ?? 1)}%, calc(100% - var(--thumbnail-half)))` }}
242
327
  >
243
328
  {
244
- thumbnail
329
+ thumbnail?.url
245
330
  ? <img src={thumbnail.url}/>
246
331
  : undefined
247
332
  }
@@ -249,3 +334,5 @@ export const ProgressBar = () => {
249
334
  </div>
250
335
  )
251
336
  }
337
+
338
+ export default ProgressBar
@@ -0,0 +1,391 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
+ import { css } from '@emotion/react'
4
+ import { ChevronLeft, ChevronRight, Settings } from 'react-feather'
5
+
6
+ import { usePlayer } from '../player'
7
+ import { TooltipDisplay } from './tooltip-display'
8
+ import { fonts } from '../../utils/fonts'
9
+ import { labelTracks, type LabelledTrack } from '../../utils/track-label'
10
+ import PlaybackSlider from './playback-slider'
11
+
12
+ const style = css`
13
+ position: relative;
14
+
15
+ .settings {
16
+ /* the icon keeps its size, the pressable box grows around it */
17
+ @media (pointer: coarse) {
18
+ box-sizing: border-box;
19
+ justify-content: center;
20
+
21
+ min-width: 44px;
22
+ min-height: 44px;
23
+ }
24
+ }
25
+
26
+ .popover {
27
+ position: absolute;
28
+ /* the gear sits a couple of buttons in from the right edge, so a centred popover hangs off that
29
+ edge on a phone. It anchors to the button instead until the viewport has room to centre it. */
30
+ right: 0;
31
+ transform: none;
32
+
33
+ overflow-y: auto;
34
+ width: 180px;
35
+ max-width: calc(100vw - 24px);
36
+ height: 160px;
37
+ top: -180px;
38
+ @media (min-width: 768px) {
39
+ right: 50%;
40
+ transform: translateX(50%);
41
+ width: 250px;
42
+ height: 160px;
43
+ }
44
+
45
+ display: flex;
46
+ flex-direction: column;
47
+
48
+ border-radius: 8px;
49
+ background-color: rgba(28,28,28,0.95);
50
+ ${fonts.bMedium.regular}
51
+
52
+ z-index: 4;
53
+
54
+ > div {
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+
59
+ color: #fff;
60
+ outline: none;
61
+
62
+ padding: 8px 6px 8px 12px;
63
+
64
+ width: 100%;
65
+ /* rows keep their own height inside the fixed height column, so a long page scrolls rather than
66
+ squashing every row into it */
67
+ flex-shrink: 0;
68
+ @media (pointer: coarse) {
69
+ box-sizing: border-box;
70
+ min-height: 44px;
71
+ }
72
+
73
+ :first-of-type {
74
+ border-radius: 8px 8px 0 0;
75
+ }
76
+ :last-of-type {
77
+ border-radius: 0 0 8px 8px;
78
+ }
79
+ :not(&.no-hover) {
80
+ cursor: pointer;
81
+ }
82
+ :not(&.no-hover):hover {
83
+ background-color: rgba(255,255,255,.1);
84
+ }
85
+
86
+ > div {
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+
91
+ .secondary {
92
+ color: #eee;
93
+ ${fonts.bSmall.regular}
94
+ }
95
+ }
96
+ }
97
+
98
+ .back {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: flex-start;
102
+ border-bottom: 1px solid #4D4D4E;
103
+
104
+ svg {
105
+ transform: translateX(-4px);
106
+ transition: transform 0.2s ease-in-out;
107
+ }
108
+
109
+ :hover {
110
+ svg {
111
+ transform: translateX(-8px);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ .playback-rate {
118
+ .slider {
119
+ width: 100%;
120
+ }
121
+ .options {
122
+ display: grid;
123
+ grid-template-columns: 1fr 1fr 1fr 1fr;
124
+ align-items: center;
125
+
126
+ padding: 8px 12px;
127
+ /* four presets across a 180px popover leave targets 34px wide, so a finger gets two rows of two
128
+ until the popover is wide enough for the row of four */
129
+ @media (pointer: coarse) {
130
+ grid-template-columns: 1fr 1fr;
131
+ }
132
+ @media (pointer: coarse) and (min-width: 768px) {
133
+ grid-template-columns: 1fr 1fr 1fr 1fr;
134
+ }
135
+
136
+ > div {
137
+ display: flex;
138
+ align-items: center;
139
+ border-radius: 4px;
140
+
141
+ padding: 8px 6px;
142
+ height: 100%;
143
+ @media (pointer: coarse) {
144
+ box-sizing: border-box;
145
+ min-height: 44px;
146
+ }
147
+
148
+ cursor: pointer;
149
+ :hover {
150
+ background-color: rgba(255,255,255,.1);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ .track-list {
157
+ .description {
158
+ word-break: break-word;
159
+ }
160
+ }
161
+ `
162
+
163
+ enum PopoverContent {
164
+ Default,
165
+ PlaybackRate,
166
+ Subtitles,
167
+ Audio
168
+ }
169
+
170
+ type TrackRow = { streamIndex: number, label: string }
171
+
172
+ const toTrackRows = <T extends LabelledTrack>(streams: T[]): TrackRow[] =>
173
+ labelTracks(streams).map(({ track, label }) => ({ streamIndex: track.streamIndex, label }))
174
+
175
+ /** One track picker, shared by the subtitle and audio menus. */
176
+ const TrackMenu = (
177
+ { title, tracks, selected, onSelect, onBack, allowDisable }: {
178
+ title: string
179
+ tracks: TrackRow[]
180
+ selected: number | undefined
181
+ onSelect: (streamIndex: number | undefined) => void
182
+ onBack: () => void
183
+ allowDisable?: boolean
184
+ }
185
+ ) => (
186
+ <div className='popover track-list'>
187
+ <div className="back" onClick={onBack}>
188
+ <ChevronLeft />
189
+ <span>{title}</span>
190
+ </div>
191
+ {
192
+ allowDisable
193
+ ? (
194
+ <div onClick={() => onSelect(undefined)}>
195
+ <span>Disable</span>
196
+ <span>{selected === undefined ? '✓' : ''}</span>
197
+ </div>
198
+ )
199
+ : null
200
+ }
201
+ {
202
+ tracks.map(({ streamIndex, label }) => (
203
+ <div
204
+ key={streamIndex}
205
+ onClick={() => onSelect(streamIndex)}
206
+ className="description"
207
+ >
208
+ <span>{label}</span>
209
+ <span>{selected === streamIndex ? '✓' : ''}</span>
210
+ </div>
211
+ ))
212
+ }
213
+ </div>
214
+ )
215
+
216
+ export const SettingsAction = () => {
217
+ const player = usePlayer()
218
+ const playbackRate = usePlayer((state) => state.playbackRate)
219
+ const subtitleStreams = usePlayer((state) => state.subtitleStreams)
220
+ const selectedSubtitleStream = usePlayer((state) => state.selectedSubtitleStream)
221
+ const selectSubtitleStream = usePlayer((state) => state.selectSubtitleStream)
222
+ const audioStreams = usePlayer((state) => state.audioStreams)
223
+ const selectedAudioStream = usePlayer((state) => state.selectedAudioStream)
224
+ const selectAudioStream = usePlayer((state) => state.selectAudioStream)
225
+
226
+ const [isOpenPopover, setIsOpenPopover] = useState(false)
227
+ const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
228
+ const settingsContainerRef = useRef<HTMLDivElement>(null)
229
+
230
+ const togglePopover = () => {
231
+ setIsOpenPopover(!isOpenPopover)
232
+ setPopoverContent(PopoverContent.Default)
233
+ }
234
+
235
+ const setPlaybackRate = (rate: number) => {
236
+ player.setPlaybackRate(rate)
237
+ setPopoverContent(PopoverContent.Default)
238
+ }
239
+
240
+ useEffect(() => {
241
+ const handleClickOutside = (ev: PointerEvent) => {
242
+ if (
243
+ isOpenPopover &&
244
+ settingsContainerRef.current &&
245
+ !settingsContainerRef.current.contains(ev.target as Node)
246
+ ) {
247
+ togglePopover()
248
+ }
249
+ }
250
+ // pointerdown, because a touch device synthesizes mousedown too late to close reliably
251
+ document.addEventListener('pointerdown', handleClickOutside)
252
+ return () => document.removeEventListener('pointerdown', handleClickOutside)
253
+ }, [isOpenPopover])
254
+
255
+ const subtitleTracks = useMemo(() => toTrackRows(subtitleStreams), [subtitleStreams])
256
+ const audioTracks = useMemo(() => toTrackRows(audioStreams), [audioStreams])
257
+
258
+ const chooseSubtitle = (streamIndex: number | undefined) => {
259
+ selectSubtitleStream(streamIndex)
260
+ togglePopover()
261
+ }
262
+
263
+ const chooseAudio = (streamIndex: number | undefined) => {
264
+ // the audio menu never offers "Disable"
265
+ if (streamIndex !== undefined) selectAudioStream(streamIndex)
266
+ togglePopover()
267
+ }
268
+
269
+ const changePopoverContent = (newPopoverContent: PopoverContent) => () => {
270
+ setPopoverContent(newPopoverContent)
271
+ }
272
+
273
+ return (
274
+ <div css={style} ref={settingsContainerRef}>
275
+ <TooltipDisplay
276
+ id='settings'
277
+ disabled={isOpenPopover}
278
+ text={
279
+ <button
280
+ className='settings'
281
+ type='button'
282
+ onClick={togglePopover}
283
+ >
284
+ <Settings />
285
+ </button>
286
+ }
287
+ toolTipText={
288
+ <span>
289
+ Settings
290
+ </span>
291
+ }
292
+ />
293
+ {
294
+ isOpenPopover && popoverContent === PopoverContent.Default && (
295
+ <div className='popover menu'>
296
+ {/*
297
+ A row is offered only where there is more than one thing behind it to choose between.
298
+ Audio needs two tracks for that; subtitles need one, because "Disable" is always a
299
+ second option, and a file with no subtitles at all should not offer the menu.
300
+ */}
301
+ {audioTracks.length > 1
302
+ ? (
303
+ <div onClick={changePopoverContent(PopoverContent.Audio)}>
304
+ <div>Audio</div>
305
+ <div>
306
+ <ChevronRight />
307
+ </div>
308
+ </div>
309
+ )
310
+ : null}
311
+ {subtitleTracks.length > 0
312
+ ? (
313
+ <div onClick={changePopoverContent(PopoverContent.Subtitles)}>
314
+ <div>Subtitles</div>
315
+ <div>
316
+ <ChevronRight />
317
+ </div>
318
+ </div>
319
+ )
320
+ : null}
321
+ <div onClick={changePopoverContent(PopoverContent.PlaybackRate)}>
322
+ <div>Playback speed</div>
323
+ <div>
324
+ <span className='secondary'>
325
+ {
326
+ playbackRate === 1
327
+ ? '(Default)'
328
+ : playbackRate.toFixed(2)
329
+ }
330
+ </span>
331
+ <ChevronRight />
332
+ </div>
333
+ </div>
334
+ </div>
335
+ )
336
+ }
337
+ {
338
+ isOpenPopover && popoverContent === PopoverContent.PlaybackRate && (
339
+ <div className='popover playback-rate'>
340
+ <div className="back" onClick={changePopoverContent(PopoverContent.Default)}>
341
+ <ChevronLeft />
342
+ <span>Playback speed</span>
343
+ </div>
344
+ <div className="slider no-hover">
345
+ <PlaybackSlider />
346
+ </div>
347
+ <div className="options no-hover">
348
+ <div onClick={() => setPlaybackRate(0.5)}>
349
+ 0.5x
350
+ </div>
351
+ <div onClick={() => setPlaybackRate(1)}>
352
+ 1x
353
+ </div>
354
+ <div onClick={() => setPlaybackRate(1.5)}>
355
+ 1.5x
356
+ </div>
357
+ <div onClick={() => setPlaybackRate(2)}>
358
+ 2x
359
+ </div>
360
+ </div>
361
+ </div>
362
+ )
363
+ }
364
+ {
365
+ isOpenPopover && popoverContent === PopoverContent.Subtitles && (
366
+ <TrackMenu
367
+ title='Subtitles'
368
+ tracks={subtitleTracks}
369
+ selected={selectedSubtitleStream}
370
+ onSelect={chooseSubtitle}
371
+ onBack={changePopoverContent(PopoverContent.Default)}
372
+ allowDisable
373
+ />
374
+ )
375
+ }
376
+ {
377
+ isOpenPopover && popoverContent === PopoverContent.Audio && (
378
+ <TrackMenu
379
+ title='Audio'
380
+ tracks={audioTracks}
381
+ selected={selectedAudioStream}
382
+ onSelect={chooseAudio}
383
+ onBack={changePopoverContent(PopoverContent.Default)}
384
+ />
385
+ )
386
+ }
387
+ </div>
388
+ )
389
+ }
390
+
391
+ export default SettingsAction