@banou/media-player 0.7.0 → 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.
@@ -1,6 +1,7 @@
1
1
  /// <reference types="@emotion/react/types/css-prop" />
2
2
  import type { ReactNode } from 'react'
3
3
  import type { DownloadedRange } from './source-feature'
4
+ import type { DelegatedTracks, ExternalThumbnails, PlayerMedia } from './media'
4
5
 
5
6
  import { useEffect, useState } from 'react'
6
7
  import { css } from '@emotion/react'
@@ -20,7 +21,26 @@ export type MediaPlayerSource =
20
21
  | { read: (offset: number, size: number) => Promise<ArrayBuffer>, size: number }
21
22
  | { read?: undefined, size?: undefined }
22
23
 
23
- export type MediaPlayerOptions =
24
+ /** Shared by both arms: nothing here depends on who owns the media. */
25
+ type CommonOptions = {
26
+ title?: string
27
+ autoplay?: boolean
28
+
29
+ /**
30
+ * Drawn above the control bar and outside the click-to-pause region, for whatever the app has to
31
+ * say over the video. `children` land next to the media instead, below the chrome.
32
+ */
33
+ overlay?: ReactNode
34
+
35
+ onSeek?: (fraction: number) => void
36
+ onPlaybackError?: (error: unknown) => void
37
+ }
38
+
39
+ /**
40
+ * Bytes in: the player owns the `<video>`, and libav feeds it a fragment at a time.
41
+ */
42
+ export type MediaPlayerLocalOptions =
43
+ & CommonOptions
24
44
  & MediaPlayerSource
25
45
  & {
26
46
  /**
@@ -41,18 +61,57 @@ export type MediaPlayerOptions =
41
61
  /** Fallback face for `liberation sans`, used when a subtitle track names a font the file does not carry. */
42
62
  defaultFontUrl?: string
43
63
  bufferSize?: number
44
- autoplay?: boolean
45
64
 
46
- title?: string
47
65
  /** Byte spans available, painted on the seekbar and informing the thumbnail generator. */
48
66
  downloadedRanges?: DownloadedRange[]
49
67
 
50
- onSeek?: (fraction: number) => void
51
- onPlaybackError?: (error: unknown) => void
68
+ /**
69
+ * Reader for the thumbnail engine, when it should differ from playback's.
70
+ *
71
+ * They are the same by default so generation shares playback's fetch order. A consumer whose
72
+ * reads are not free wants them apart: a torrent hands this a non-prioritising, fail-fast reader
73
+ * so generating previews cannot steal download order from the bytes playback is blocked on.
74
+ */
75
+ thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
76
+ /** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
77
+ thumbnailsEnabled?: boolean
78
+ }
79
+
80
+ /**
81
+ * A media the caller owns and the player only drives.
82
+ *
83
+ * No bytes, so no libav, no MediaSource and no thumbnail generation. For a source whose video lives
84
+ * in a document this one cannot reach into, which is also the only arrangement under which its DRM
85
+ * works: the key session belongs to whoever owns the element.
86
+ */
87
+ export type MediaPlayerRemoteOptions =
88
+ & CommonOptions
89
+ & {
90
+ media: PlayerMedia
91
+ read?: never
92
+ size?: never
93
+
94
+ /** The source's own storyboard, since there are no bytes to generate previews from. */
95
+ thumbnails?: ExternalThumbnails
96
+ /** The source renders these; the player draws the menu and reports the pick. */
97
+ subtitles?: DelegatedTracks
98
+ audioTracks?: DelegatedTracks
52
99
  }
53
100
 
101
+ export type MediaPlayerOptions = MediaPlayerLocalOptions | MediaPlayerRemoteOptions
102
+
54
103
  const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, children?: ReactNode }) => {
55
- const { title, size, downloadedRanges, publicPath, libavWorkerUrl, read } = options
104
+ // Narrowed on the field that carries the difference: a remote arm brings its own media and, by
105
+ // construction, no bytes. Both halves stay null-safe so the engine hooks below can be called
106
+ // unconditionally, which they have to be.
107
+ const remote = 'media' in options ? options : null
108
+ const local = remote ? null : options as MediaPlayerLocalOptions
109
+ // `title` is common to both arms, so it is read off `options`. Everything else here belongs to the
110
+ // local arm and is absent when the media is remote.
111
+ const { title } = options
112
+ const {
113
+ size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled,
114
+ } = local ?? ({} as Partial<MediaPlayerLocalOptions>)
56
115
 
57
116
  const setMedia = useMediaAttach()
58
117
  // Mandatory, not an optimisation: without it requestFullscreen falls through to the bare <video>
@@ -62,17 +121,24 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
62
121
  const [video, setVideo] = useState<HTMLVideoElement | null>(null)
63
122
  const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null)
64
123
 
65
- useEffect(() => { setMedia?.(video); return () => setMedia?.(null) }, [video, setMedia])
124
+ // Attaching is not optional in either arm: the store installs `setSourceState` in `attach`, and
125
+ // video.js only runs attach once media is non-null, so skipping it would leave every write below a
126
+ // permanent no-op.
127
+ const media = remote?.media ?? video
128
+ useEffect(() => { setMedia?.(media); return () => setMedia?.(null) }, [media, setMedia])
66
129
 
67
- usePlayback(video, canvas, options)
130
+ // Each of these no-ops on null inputs, which is what a remote arm supplies: it renders no <video>,
131
+ // so there is nothing for them to attach to and nothing to guard at the call site.
132
+ usePlayback(video, canvas, local)
68
133
 
69
- const thumbnails = useSeekThumbnails({
134
+ const generatedThumbnails = useSeekThumbnails({
70
135
  publicPath,
71
136
  workerUrl: libavWorkerUrl,
72
- length: size,
73
- read,
137
+ length: thumbnailsEnabled === false ? undefined : size,
138
+ read: thumbnailRead ?? read,
74
139
  downloadedRanges,
75
140
  })
141
+ const thumbnails = remote?.thumbnails?.all ?? generatedThumbnails
76
142
  const togglePictureInPicture = usePictureInPicture(video, canvas)
77
143
 
78
144
  // Subscribed rather than read off the store, because it is a no-op until the media element
@@ -83,15 +149,42 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
83
149
  setSourceState({ title, size, downloadedRanges })
84
150
  }, [setSourceState, title, size, downloadedRanges])
85
151
 
152
+ const thumbnailAt = remote?.thumbnails?.at
86
153
  useEffect(() => {
87
- setSourceState({ thumbnails, togglePictureInPicture })
88
- }, [setSourceState, thumbnails, togglePictureInPicture])
154
+ setSourceState({ thumbnails, thumbnailAt, togglePictureInPicture })
155
+ }, [setSourceState, thumbnails, thumbnailAt, togglePictureInPicture])
156
+
157
+ // A delegated track list writes the same store fields the engine writes, so the menus never learn
158
+ // which arm they are showing. Only the writer differs: here the pick is forwarded to whoever owns
159
+ // the document, and it renders the result itself.
160
+ const subtitles = remote?.subtitles?.selection
161
+ const audio = remote?.audioTracks?.selection
162
+ useEffect(() => {
163
+ if (!subtitles) return
164
+ setSourceState({
165
+ subtitleTracks: subtitles.options.map(({ id, label }) => ({ id, label })),
166
+ selectedSubtitleTrack: subtitles.selectedId ?? undefined,
167
+ selectSubtitleTrack: (id) => subtitles.select(id == null ? null : String(id)),
168
+ })
169
+ }, [setSourceState, subtitles])
170
+
171
+ useEffect(() => {
172
+ if (!audio) return
173
+ setSourceState({
174
+ audioTracks: audio.options.map(({ id, label }) => ({ id, label })),
175
+ selectedAudioTrack: audio.selectedId ?? undefined,
176
+ selectAudioTrack: (id) => audio.select(String(id)),
177
+ })
178
+ }, [setSourceState, audio])
89
179
 
90
180
  return (
91
181
  <Chrome
92
182
  ref={setContainer}
93
- onVideoRef={setVideo}
183
+ // No element in the remote arm: the media is somebody else's, and whatever renders it is
184
+ // passed in as children. Rendering an idle <video> here would sit over it.
185
+ onVideoRef={remote ? undefined : setVideo}
94
186
  onCanvasRef={setCanvas}
187
+ overlay={options.overlay}
95
188
  >
96
189
  {children}
97
190
  </Chrome>
@@ -100,6 +193,17 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
100
193
 
101
194
  // #111 against the black inside is deliberate: this is the letterbox around the player box.
102
195
  const rootStyle = css`
196
+ /**
197
+ * The chrome's whole scale, in one place.
198
+ *
199
+ * Everything inside is sized against this rather than against \`rem\`, because \`rem\` is root
200
+ * relative and a library cannot own the host page's root font. The old contract was that the host
201
+ * set \`html { font-size: 62.5% }\`, which silently rendered every control 1.6x too large in any app
202
+ * that did not, and could not be met by an app whose own screens are sized against the default.
203
+ * Override it on the player element to rescale the whole chrome.
204
+ */
205
+ --mp-unit: 10px;
206
+
103
207
  display: flex;
104
208
  justify-content: center;
105
209
  background-color: #111;
@@ -2,158 +2,158 @@ export const fonts = {
2
2
  headings: {
3
3
  large: `
4
4
  font-weight: 600;
5
- font-size: 2.8rem;
6
- line-height: 3.4rem;
5
+ font-size: calc(2.8 * var(--mp-unit));
6
+ line-height: calc(3.4 * var(--mp-unit));
7
7
  @media (min-width: 960px) {
8
- font-size: 3.4rem;
9
- line-height: 4.1rem;
8
+ font-size: calc(3.4 * var(--mp-unit));
9
+ line-height: calc(4.1 * var(--mp-unit));
10
10
  }
11
11
  `,
12
12
  medium: `
13
13
  font-weight: 600;
14
- font-size: 2.4rem;
15
- line-height: 2.9rem;
14
+ font-size: calc(2.4 * var(--mp-unit));
15
+ line-height: calc(2.9 * var(--mp-unit));
16
16
  @media (min-width: 960px) {
17
- font-size: 2.8rem;
18
- line-height: 3.4rem;
17
+ font-size: calc(2.8 * var(--mp-unit));
18
+ line-height: calc(3.4 * var(--mp-unit));
19
19
  }
20
20
  `,
21
21
  small: `
22
22
  font-weight: 500;
23
- font-size: 1.8rem;
24
- line-height: 1.9rem;
23
+ font-size: calc(1.8 * var(--mp-unit));
24
+ line-height: calc(1.9 * var(--mp-unit));
25
25
  @media (min-width: 960px) {
26
- font-size: 1.8rem;
27
- line-height: 2.2rem;
26
+ font-size: calc(1.8 * var(--mp-unit));
27
+ line-height: calc(2.2 * var(--mp-unit));
28
28
  }
29
29
  @media (min-width: 2560px) {
30
- font-size: 2.2rem;
31
- line-height: 2.6rem;
30
+ font-size: calc(2.2 * var(--mp-unit));
31
+ line-height: calc(2.6 * var(--mp-unit));
32
32
  }
33
33
  `,
34
34
  extraSmall: `
35
35
  font-weight: 500;
36
- font-size: 1.4rem;
37
- line-height: 1.7rem;
36
+ font-size: calc(1.4 * var(--mp-unit));
37
+ line-height: calc(1.7 * var(--mp-unit));
38
38
  @media (min-width: 960px) {
39
- font-size: 1.6rem;
40
- line-height: 1.9rem;
39
+ font-size: calc(1.6 * var(--mp-unit));
40
+ line-height: calc(1.9 * var(--mp-unit));
41
41
  }
42
42
  `
43
43
  },
44
44
  bLarge: {
45
45
  bold: `
46
46
  font-weight: 600;
47
- font-size: 1.4rem;
48
- line-height: 2rem;
47
+ font-size: calc(1.4 * var(--mp-unit));
48
+ line-height: calc(2 * var(--mp-unit));
49
49
  @media (min-width: 960px) {
50
- font-size: 1.6rem;
51
- line-height: 2.2rem;
50
+ font-size: calc(1.6 * var(--mp-unit));
51
+ line-height: calc(2.2 * var(--mp-unit));
52
52
  }
53
53
  `,
54
54
  medium: `
55
55
  font-weight: 500;
56
- font-size: 1.4rem;
57
- line-height: 2rem;
56
+ font-size: calc(1.4 * var(--mp-unit));
57
+ line-height: calc(2 * var(--mp-unit));
58
58
  @media (min-width: 960px) {
59
- font-size: 1.6rem;
60
- line-height: 2.2rem;
59
+ font-size: calc(1.6 * var(--mp-unit));
60
+ line-height: calc(2.2 * var(--mp-unit));
61
61
  }
62
62
  `,
63
63
  regular: `
64
64
  font-weight: 400;
65
- font-size: 1.4rem;
66
- line-height: 2rem;
65
+ font-size: calc(1.4 * var(--mp-unit));
66
+ line-height: calc(2 * var(--mp-unit));
67
67
  @media (min-width: 960px) {
68
- font-size: 1.6rem;
69
- line-height: 2.2rem;
68
+ font-size: calc(1.6 * var(--mp-unit));
69
+ line-height: calc(2.2 * var(--mp-unit));
70
70
  }
71
71
  `,
72
72
  },
73
73
  bMedium: {
74
74
  bold: `
75
75
  font-weight: 600;
76
- font-size: 1.2rem;
77
- line-height: 1.7rem;
76
+ font-size: calc(1.2 * var(--mp-unit));
77
+ line-height: calc(1.7 * var(--mp-unit));
78
78
  @media (min-width: 960px) {
79
- font-size: 1.4rem;
80
- line-height: 2rem;
79
+ font-size: calc(1.4 * var(--mp-unit));
80
+ line-height: calc(2 * var(--mp-unit));
81
81
  }
82
82
  `,
83
83
  medium: `
84
84
  font-weight: 500;
85
- font-size: 1.2rem;
86
- line-height: 1.7rem;
85
+ font-size: calc(1.2 * var(--mp-unit));
86
+ line-height: calc(1.7 * var(--mp-unit));
87
87
  @media (min-width: 960px) {
88
- font-size: 1.4rem;
89
- line-height: 2rem;
88
+ font-size: calc(1.4 * var(--mp-unit));
89
+ line-height: calc(2 * var(--mp-unit));
90
90
  }
91
91
  `,
92
92
  regular: `
93
93
  font-weight: 400;
94
- font-size: 1.2rem;
95
- line-height: 1.7rem;
94
+ font-size: calc(1.2 * var(--mp-unit));
95
+ line-height: calc(1.7 * var(--mp-unit));
96
96
  @media (min-width: 960px) {
97
- font-size: 1.4rem;
98
- line-height: 2rem;
97
+ font-size: calc(1.4 * var(--mp-unit));
98
+ line-height: calc(2 * var(--mp-unit));
99
99
  }
100
100
  `,
101
101
  },
102
102
  bSmall: {
103
103
  bold: `
104
104
  font-weight: 600;
105
- font-size: 1rem;
106
- line-height: 1.4rem;
105
+ font-size: calc(1 * var(--mp-unit));
106
+ line-height: calc(1.4 * var(--mp-unit));
107
107
  @media (min-width: 960px) {
108
- font-size: 1.2rem;
109
- line-height: 1.7rem;
108
+ font-size: calc(1.2 * var(--mp-unit));
109
+ line-height: calc(1.7 * var(--mp-unit));
110
110
  }
111
111
  `,
112
112
  medium: `
113
113
  font-weight: 500;
114
- font-size: 1rem;
115
- line-height: 1.4rem;
114
+ font-size: calc(1 * var(--mp-unit));
115
+ line-height: calc(1.4 * var(--mp-unit));
116
116
  @media (min-width: 960px) {
117
- font-size: 1.2rem;
118
- line-height: 1.7rem;
117
+ font-size: calc(1.2 * var(--mp-unit));
118
+ line-height: calc(1.7 * var(--mp-unit));
119
119
  }
120
120
  `,
121
121
  regular: `
122
122
  font-weight: 400;
123
- font-size: 1rem;
124
- line-height: 1.4rem;
123
+ font-size: calc(1 * var(--mp-unit));
124
+ line-height: calc(1.4 * var(--mp-unit));
125
125
  @media (min-width: 960px) {
126
- font-size: 1.2rem;
127
- line-height: 1.7rem;
126
+ font-size: calc(1.2 * var(--mp-unit));
127
+ line-height: calc(1.7 * var(--mp-unit));
128
128
  }
129
129
  `,
130
130
  },
131
131
  bExtraSmall: {
132
132
  bold: `
133
133
  font-weight: 600;
134
- font-size: 0.8rem;
135
- line-height: 1.1rem;
134
+ font-size: calc(0.8 * var(--mp-unit));
135
+ line-height: calc(1.1 * var(--mp-unit));
136
136
  @media (min-width: 960px) {
137
- font-size: 1rem;
138
- line-height: 1.4rem;
137
+ font-size: calc(1 * var(--mp-unit));
138
+ line-height: calc(1.4 * var(--mp-unit));
139
139
  }
140
140
  `,
141
141
  medium: `
142
142
  font-weight: 500;
143
- font-size: 0.8rem;
144
- line-height: 1.1rem;
143
+ font-size: calc(0.8 * var(--mp-unit));
144
+ line-height: calc(1.1 * var(--mp-unit));
145
145
  @media (min-width: 960px) {
146
- font-size: 1rem;
147
- line-height: 1.4rem;
146
+ font-size: calc(1 * var(--mp-unit));
147
+ line-height: calc(1.4 * var(--mp-unit));
148
148
  }
149
149
  `,
150
150
  regular: `
151
151
  font-weight: 400;
152
- font-size: 0.8rem;
153
- line-height: 1.1rem;
152
+ font-size: calc(0.8 * var(--mp-unit));
153
+ line-height: calc(1.1 * var(--mp-unit));
154
154
  @media (min-width: 960px) {
155
- font-size: 1rem;
156
- line-height: 1.4rem;
155
+ font-size: calc(1 * var(--mp-unit));
156
+ line-height: calc(1.4 * var(--mp-unit));
157
157
  }
158
158
  `,
159
159
  },
@@ -4,6 +4,12 @@ export type LabelledTrack = {
4
4
  language: string
5
5
  }
6
6
 
7
+ /** One row of a track menu, already named. Mirrors `TrackChoice` without importing the store. */
8
+ export type NamedTrack = {
9
+ id: string | number
10
+ label: string
11
+ }
12
+
7
13
  /**
8
14
  * Human name for a track's language tag. `fallback: 'none'` is what makes an unknown tag come back
9
15
  * undefined instead of echoing itself, so the caller can fall through to the title.
@@ -34,3 +40,12 @@ export const labelTracks = <T extends LabelledTrack>(tracks: T[]): { track: T, l
34
40
  return { track, label: ambiguous && detail && detail !== base ? `${base} (${detail})` : base }
35
41
  })
36
42
  }
43
+
44
+ /**
45
+ * Names the engine's streams once, on the way into the store, so the menu only ever reads rows.
46
+ *
47
+ * The store's ids are opaque so a source that owns its own player can use its own track ids; the id
48
+ * here is the libav stream index, which is what this half of the code has to say.
49
+ */
50
+ export const toNamedTracks = <T extends LabelledTrack>(tracks: T[]): NamedTrack[] =>
51
+ labelTracks(tracks).map(({ track, label }) => ({ id: track.streamIndex, label }))