@banou/media-player 0.7.0 → 0.8.1

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.
@@ -10,10 +10,10 @@ import { fonts } from '../../utils/fonts'
10
10
 
11
11
  const style = css`
12
12
  position: relative;
13
- height: .4rem;
13
+ height: calc(.4 * var(--mp-unit));
14
14
 
15
- --thumbnail-width: 25rem;
16
- --thumbnail-half: 12.5rem;
15
+ --thumbnail-width: calc(25 * var(--mp-unit));
16
+ --thumbnail-half: calc(12.5 * var(--mp-unit));
17
17
 
18
18
  transition: transform .2s ease-in-out;
19
19
 
@@ -34,7 +34,7 @@ const style = css`
34
34
  }
35
35
  .loaded {
36
36
  transform: scaleY(1.5);
37
- top: -.1rem;
37
+ top: calc(-.1 * var(--mp-unit));
38
38
  }
39
39
  .play-container {
40
40
  transform: scaleY(1.5);
@@ -56,10 +56,10 @@ const style = css`
56
56
  ${fonts.bMedium.bold}
57
57
 
58
58
  position: absolute;
59
- top: -2.5rem;
60
- width: 5rem;
59
+ top: calc(-2.5 * var(--mp-unit));
60
+ width: calc(5 * var(--mp-unit));
61
61
 
62
- margin-left: -2.5rem;
62
+ margin-left: calc(-2.5 * var(--mp-unit));
63
63
  pointer-events: none;
64
64
  }
65
65
 
@@ -67,12 +67,12 @@ const style = css`
67
67
  transform-origin: 0 0;
68
68
  position: absolute;
69
69
  bottom: 0;
70
- height: .4rem;
70
+ height: calc(.4 * var(--mp-unit));
71
71
  width: 100%;
72
72
  .loaded-part {
73
73
  transform-origin: 0 0;
74
74
  bottom: 0;
75
- height: .4rem;
75
+ height: calc(.4 * var(--mp-unit));
76
76
  width: 100%;
77
77
  position: absolute;
78
78
  background-color: hsla(0, 100%, 100%, .4);
@@ -83,7 +83,7 @@ const style = css`
83
83
  position: absolute;
84
84
  bottom: 0;
85
85
  width: 100%;
86
- height: .4rem;
86
+ height: calc(.4 * var(--mp-unit));
87
87
  }
88
88
 
89
89
  .play {
@@ -91,14 +91,14 @@ const style = css`
91
91
  background-color: #f03;
92
92
  position: absolute;
93
93
  bottom: 0;
94
- height: .4rem;
94
+ height: calc(.4 * var(--mp-unit));
95
95
  width: 100%;
96
96
  }
97
97
 
98
98
  .padding {
99
99
  position: absolute;
100
100
  bottom: -7.5px;
101
- height: 2rem;
101
+ height: calc(2 * var(--mp-unit));
102
102
  width: 100%;
103
103
  }
104
104
 
@@ -107,7 +107,7 @@ const style = css`
107
107
  justify-content: center;
108
108
 
109
109
  position: absolute;
110
- top: calc(-3.4375rem - var(--thumbnail-width) * 9/16);
110
+ top: calc(calc(-3.4375 * var(--mp-unit)) - var(--thumbnail-width) * 9/16);
111
111
  height: calc(var(--thumbnail-width) * 9/16);
112
112
  width: var(--thumbnail-width);
113
113
 
@@ -115,8 +115,8 @@ const style = css`
115
115
  pointer-events: none;
116
116
 
117
117
  img {
118
- border-radius: .4rem;
119
- box-shadow: 0 0 1rem rgba(0, 0, 0, .5);
118
+ border-radius: calc(.4 * var(--mp-unit));
119
+ box-shadow: 0 0 calc(1 * var(--mp-unit)) rgba(0, 0, 0, .5);
120
120
 
121
121
  width: 100%;
122
122
  height: 100%;
@@ -124,9 +124,9 @@ const style = css`
124
124
  }
125
125
  }
126
126
 
127
- /* a thumb is far wider than a cursor, so the hit strip grows to 44px and the track to .6rem */
127
+ /* a thumb is far wider than a cursor, so the hit strip grows to 44px and the track thickens */
128
128
  @media (pointer: coarse) {
129
- height: .6rem;
129
+ height: calc(.6 * var(--mp-unit));
130
130
 
131
131
  /* press feedback, coarse only: a finger has no hover, so without this the bar gives no sign
132
132
  it is being dragged */
@@ -137,18 +137,18 @@ const style = css`
137
137
  }
138
138
 
139
139
  .loaded {
140
- height: .6rem;
140
+ height: calc(.6 * var(--mp-unit));
141
141
  .loaded-part {
142
- height: .6rem;
142
+ height: calc(.6 * var(--mp-unit));
143
143
  }
144
144
  }
145
145
 
146
146
  .play-container {
147
- height: .6rem;
147
+ height: calc(.6 * var(--mp-unit));
148
148
  }
149
149
 
150
150
  .play {
151
- height: .6rem;
151
+ height: calc(.6 * var(--mp-unit));
152
152
  }
153
153
 
154
154
  /* grows upward into the video: centred, it would hang over the control row and win hit
@@ -162,8 +162,8 @@ const style = css`
162
162
  /* the clamp only holds the preview inside the bar while the bar is the wider of the two, so
163
163
  below that the preview is narrowed instead */
164
164
  @media (max-width: 480px) {
165
- --thumbnail-width: 18rem;
166
- --thumbnail-half: 9rem;
165
+ --thumbnail-width: calc(18 * var(--mp-unit));
166
+ --thumbnail-half: calc(9 * var(--mp-unit));
167
167
  }
168
168
  `
169
169
 
@@ -175,6 +175,7 @@ export const ProgressBar = () => {
175
175
  const downloadedRanges = usePlayer((state) => state.downloadedRanges)
176
176
  const indexes = usePlayer((state) => state.indexes)
177
177
  const thumbnails = usePlayer((state) => state.thumbnails)
178
+ const thumbnailAt = usePlayer((state) => state.thumbnailAt)
178
179
 
179
180
  const progressBarRef = useRef<HTMLDivElement>(null)
180
181
 
@@ -271,14 +272,16 @@ export const ProgressBar = () => {
271
272
 
272
273
  // an empty url is a gap sentinel, so it renders nothing
273
274
  const thumbnail = useMemo(() => {
274
- if (!thumbnails.length || !progressBarHoverTime) return undefined
275
+ if (!progressBarHoverTime) return undefined
276
+ if (thumbnailAt) return thumbnailAt(progressBarHoverTime)
277
+ if (!thumbnails.length) return undefined
275
278
  return (
276
279
  thumbnails
277
280
  .find(({ startTime, endTime }) =>
278
281
  startTime <= progressBarHoverTime && progressBarHoverTime < endTime
279
282
  )
280
283
  )
281
- }, [thumbnails.length, progressBarHoverTime])
284
+ }, [thumbnails, thumbnailAt, progressBarHoverTime])
282
285
 
283
286
  return (
284
287
  <div
@@ -294,7 +297,7 @@ export const ProgressBar = () => {
294
297
  ? (
295
298
  <div
296
299
  className="cursor-time"
297
- style={{ left: `clamp(1.8rem, ${timePercentage(progressBarHoverTime)}%, calc(100% - 1.8rem))` }}
300
+ style={{ left: `clamp(calc(1.8 * var(--mp-unit)), ${timePercentage(progressBarHoverTime)}%, calc(100% - calc(1.8 * var(--mp-unit))))` }}
298
301
  >
299
302
  {cusorTimeString}
300
303
  </div>
@@ -1,12 +1,13 @@
1
1
  /// <reference types="@emotion/react/types/css-prop" />
2
- import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { TrackChoice } from '../source-feature'
3
+
4
+ import { useEffect, useRef, useState } from 'react'
3
5
  import { css } from '@emotion/react'
4
6
  import { ChevronLeft, ChevronRight, Settings } from 'react-feather'
5
7
 
6
8
  import { usePlayer } from '../player'
7
9
  import { TooltipDisplay } from './tooltip-display'
8
10
  import { fonts } from '../../utils/fonts'
9
- import { labelTracks, type LabelledTrack } from '../../utils/track-label'
10
11
  import PlaybackSlider from './playback-slider'
11
12
 
12
13
  const style = css`
@@ -167,18 +168,13 @@ enum PopoverContent {
167
168
  Audio
168
169
  }
169
170
 
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
171
  /** One track picker, shared by the subtitle and audio menus. */
176
172
  const TrackMenu = (
177
173
  { title, tracks, selected, onSelect, onBack, allowDisable }: {
178
174
  title: string
179
- tracks: TrackRow[]
180
- selected: number | undefined
181
- onSelect: (streamIndex: number | undefined) => void
175
+ tracks: TrackChoice[]
176
+ selected: string | number | undefined
177
+ onSelect: (id: string | number | undefined) => void
182
178
  onBack: () => void
183
179
  allowDisable?: boolean
184
180
  }
@@ -199,14 +195,14 @@ const TrackMenu = (
199
195
  : null
200
196
  }
201
197
  {
202
- tracks.map(({ streamIndex, label }) => (
198
+ tracks.map(({ id, label }) => (
203
199
  <div
204
- key={streamIndex}
205
- onClick={() => onSelect(streamIndex)}
200
+ key={id}
201
+ onClick={() => onSelect(id)}
206
202
  className="description"
207
203
  >
208
204
  <span>{label}</span>
209
- <span>{selected === streamIndex ? '✓' : ''}</span>
205
+ <span>{selected === id ? '✓' : ''}</span>
210
206
  </div>
211
207
  ))
212
208
  }
@@ -216,12 +212,12 @@ const TrackMenu = (
216
212
  export const SettingsAction = () => {
217
213
  const player = usePlayer()
218
214
  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)
215
+ const subtitleTracks = usePlayer((state) => state.subtitleTracks)
216
+ const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
217
+ const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
218
+ const audioTracks = usePlayer((state) => state.audioTracks)
219
+ const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
220
+ const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
225
221
 
226
222
  const [isOpenPopover, setIsOpenPopover] = useState(false)
227
223
  const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
@@ -252,17 +248,14 @@ export const SettingsAction = () => {
252
248
  return () => document.removeEventListener('pointerdown', handleClickOutside)
253
249
  }, [isOpenPopover])
254
250
 
255
- const subtitleTracks = useMemo(() => toTrackRows(subtitleStreams), [subtitleStreams])
256
- const audioTracks = useMemo(() => toTrackRows(audioStreams), [audioStreams])
257
-
258
- const chooseSubtitle = (streamIndex: number | undefined) => {
259
- selectSubtitleStream(streamIndex)
251
+ const chooseSubtitle = (id: string | number | undefined) => {
252
+ selectSubtitleTrack(id)
260
253
  togglePopover()
261
254
  }
262
255
 
263
- const chooseAudio = (streamIndex: number | undefined) => {
256
+ const chooseAudio = (id: string | number | undefined) => {
264
257
  // the audio menu never offers "Disable"
265
- if (streamIndex !== undefined) selectAudioStream(streamIndex)
258
+ if (id !== undefined) selectAudioTrack(id)
266
259
  togglePopover()
267
260
  }
268
261
 
@@ -366,7 +359,7 @@ export const SettingsAction = () => {
366
359
  <TrackMenu
367
360
  title='Subtitles'
368
361
  tracks={subtitleTracks}
369
- selected={selectedSubtitleStream}
362
+ selected={selectedSubtitleTrack}
370
363
  onSelect={chooseSubtitle}
371
364
  onBack={changePopoverContent(PopoverContent.Default)}
372
365
  allowDisable
@@ -378,7 +371,7 @@ export const SettingsAction = () => {
378
371
  <TrackMenu
379
372
  title='Audio'
380
373
  tracks={audioTracks}
381
- selected={selectedAudioStream}
374
+ selected={selectedAudioTrack}
382
375
  onSelect={chooseAudio}
383
376
  onBack={changePopoverContent(PopoverContent.Default)}
384
377
  />
@@ -43,7 +43,7 @@ const style = css`
43
43
  }
44
44
 
45
45
  &:hover .volume-slider-container {
46
- width: 9rem;
46
+ width: calc(9 * var(--mp-unit));
47
47
 
48
48
  transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
49
49
  -webkit-transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
@@ -15,7 +15,7 @@ const style = (size: buttonSize) => css`
15
15
  display: flex;
16
16
  justify-content: flex-end;
17
17
 
18
- border-radius: 0.4rem;
18
+ border-radius: calc(0.4 * var(--mp-unit));
19
19
  user-select: none;
20
20
 
21
21
  z-index: 3;
@@ -25,13 +25,13 @@ const style = (size: buttonSize) => css`
25
25
  }
26
26
 
27
27
  ${size === buttonSize.sm && css`
28
- padding: 0.4rem!important;
28
+ padding: calc(0.4 * var(--mp-unit))!important;
29
29
  `}
30
30
  ${size === buttonSize.md && css`
31
- padding: 0.6rem!important;
31
+ padding: calc(0.6 * var(--mp-unit))!important;
32
32
  `}
33
33
  ${size === buttonSize.lg && css`
34
- padding: 1.2rem!important;
34
+ padding: calc(1.2 * var(--mp-unit))!important;
35
35
  `}
36
36
  `
37
37
 
@@ -28,9 +28,13 @@ export const usePictureInPicture = (
28
28
  }
29
29
  }, [video, canvas, player])
30
30
 
31
- return useCallback(() => {
31
+ const toggle = useCallback(() => {
32
32
  void controller.current?.toggle().catch((error) => {
33
33
  console.warn('picture in picture was refused', error)
34
34
  })
35
35
  }, [])
36
+
37
+ // null rather than a dead callback: the chrome hides the control instead of offering one that
38
+ // cannot work, and there is nothing to composite without both an element and a canvas.
39
+ return video && canvas ? toggle : null
36
40
  }
@@ -1,10 +1,11 @@
1
1
  import type { PlaybackController } from '../../engine'
2
- import type { MediaPlayerOptions } from '../video-player'
2
+ import type { MediaPlayerLocalOptions } from '../video-player'
3
3
 
4
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
5
 
6
6
  import { startPlayback } from '../../engine'
7
7
  import { usePlayer } from '../player'
8
+ import { toNamedTracks } from '../../utils/track-label'
8
9
 
9
10
  /**
10
11
  * Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
@@ -13,13 +14,16 @@ import { usePlayer } from '../player'
13
14
  export const usePlayback = (
14
15
  video: HTMLVideoElement | null,
15
16
  canvas: HTMLCanvasElement | null,
16
- options: MediaPlayerOptions,
17
+ /** null when the media is remote: there are no bytes, so there is no pipeline to run. */
18
+ options: MediaPlayerLocalOptions | null,
17
19
  ) => {
18
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.
19
23
  const {
20
- read, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl,
21
- bufferSize, autoplay = false,
22
- } = options
24
+ read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
25
+ jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
26
+ } = options ?? ({} as Partial<MediaPlayerLocalOptions>)
23
27
 
24
28
  // The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
25
29
  // `selectedAudioStream`, which is whatever is playing right now.
@@ -33,20 +37,24 @@ export const usePlayback = (
33
37
 
34
38
  const readRef = useRef(read)
35
39
  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
+ const onSeekRef = useRef(options?.onSeek)
41
+ onSeekRef.current = options?.onSeek
42
+ const onPlaybackErrorRef = useRef(options?.onPlaybackError)
43
+ onPlaybackErrorRef.current = options?.onPlaybackError
40
44
 
41
- const selectSubtitleStream = useCallback((streamIndex: number | undefined) => {
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
42
49
  subtitleChoiceMade.current = true
43
- player.setSourceState({ selectedSubtitleStream: streamIndex })
50
+ player.setSourceState({ selectedSubtitleTrack: streamIndex })
44
51
  controllerRef.current?.selectSubtitleStream(streamIndex)
45
52
  }, [player])
46
53
 
47
- const selectAudioStream = useCallback((streamIndex: number) => {
48
- player.setSourceState({ selectedAudioStream: streamIndex })
49
- setAudioStreamIndex(streamIndex)
54
+ const selectAudioTrack = useCallback((id: string | number) => {
55
+ if (typeof id !== 'number') return
56
+ player.setSourceState({ selectedAudioTrack: id })
57
+ setAudioStreamIndex(id)
50
58
  }, [player])
51
59
 
52
60
  // Subscribed rather than read off the store, because it is a no-op until the media element
@@ -55,8 +63,8 @@ export const usePlayback = (
55
63
  const setSourceState = usePlayer((state) => state.setSourceState)
56
64
 
57
65
  useEffect(() => {
58
- setSourceState({ selectSubtitleStream, selectAudioStream })
59
- }, [setSourceState, selectSubtitleStream, selectAudioStream])
66
+ setSourceState({ selectSubtitleTrack, selectAudioTrack })
67
+ }, [setSourceState, selectSubtitleTrack, selectAudioTrack])
60
68
 
61
69
  useEffect(() => {
62
70
  if (!video || !canvas || !size || !read) return
@@ -98,13 +106,13 @@ export const usePlayback = (
98
106
  onSubtitleStreams: (streams) => {
99
107
  if (cancelled) return
100
108
  player.setSourceState({
101
- subtitleStreams: streams,
102
- ...subtitleChoiceMade.current ? {} : { selectedSubtitleStream: streams[0]?.streamIndex },
109
+ subtitleTracks: toNamedTracks(streams),
110
+ ...subtitleChoiceMade.current ? {} : { selectedSubtitleTrack: streams[0]?.streamIndex },
103
111
  })
104
112
  },
105
113
  onAudioStreams: (streams, selected) => {
106
114
  if (cancelled) return
107
- player.setSourceState({ audioStreams: streams, selectedAudioStream: selected })
115
+ player.setSourceState({ audioTracks: toNamedTracks(streams), selectedAudioTrack: selected })
108
116
  },
109
117
  })
110
118
  if (cancelled) {
@@ -114,8 +122,8 @@ export const usePlayback = (
114
122
  controllerRef.current = controller
115
123
  player.setSourceState({ indexes: controller.indexes })
116
124
  // 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)
125
+ const chosen = player.selectedSubtitleTrack
126
+ if (typeof chosen === 'number') controller.selectSubtitleStream(chosen)
119
127
  } catch (error) {
120
128
  fail(error)
121
129
  }
@@ -127,8 +135,12 @@ export const usePlayback = (
127
135
  controllerRef.current = null
128
136
  player.setSourceState({ ready: false })
129
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.
130
142
  }, [
131
- player, video, canvas, size, read, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
143
+ player, video, canvas, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
132
144
  jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
133
145
  ])
134
146
  }
@@ -10,8 +10,9 @@ const RETRY_DELAY = 5_000
10
10
  const MAX_RETRY_DELAY = 60_000
11
11
 
12
12
  export type UseSeekThumbnailsOptions = {
13
- publicPath: string
14
- workerUrl: string
13
+ /** Optional alongside `length`/`read`: with no bytes there is nothing to generate from. */
14
+ publicPath: string | undefined
15
+ workerUrl: string | undefined
15
16
  length: number | undefined
16
17
  read: ((offset: number, size: number) => Promise<ArrayBuffer>) | undefined
17
18
  /** When omitted the whole file is treated as readable, which is the case for a local file. */
@@ -35,7 +36,7 @@ export const useSeekThumbnails = ({
35
36
  rangesRef.current = ranges
36
37
 
37
38
  useEffect(() => {
38
- if (!length || !read) return
39
+ if (!length || !read || !publicPath || !workerUrl) return
39
40
  let cancelled = false
40
41
  let generator: ThumbnailGenerator | null = null
41
42
  let retry: ReturnType<typeof setTimeout> | undefined
@@ -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