@banou/media-player 0.8.4 → 0.8.6

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.
@@ -0,0 +1,80 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import { css } from '@emotion/react'
3
+
4
+ import { usePlayer } from '../player'
5
+ import { TooltipDisplay } from './tooltip-display'
6
+ import { Captions, CaptionsOff } from './icons'
7
+ import { TrackMenu, popoverStyle, useTrackMenu } from './track-menu'
8
+
9
+ const style = css`
10
+ /* Not a containing block, for the reason given on popoverStyle: the menu anchors to the control bar,
11
+ which is the width of the player box, so it can be clamped to it. */
12
+ position: static;
13
+
14
+ .subtitles {
15
+ /* the icon keeps its size, the pressable box grows around it */
16
+ @media (pointer: coarse) {
17
+ box-sizing: border-box;
18
+ justify-content: center;
19
+
20
+ min-width: 44px;
21
+ min-height: 44px;
22
+ }
23
+ }
24
+
25
+ ${popoverStyle}
26
+ `
27
+
28
+ /**
29
+ * Subtitles, one click from the control bar.
30
+ *
31
+ * It used to be a row inside the settings menu, which put the most-reached control in the player two
32
+ * clicks deep behind a gear, next to playback speed. Audio stays in there: switching it is rare, and
33
+ * on a source that owns its own player it is slow enough to be a considered act.
34
+ */
35
+ export const SubtitlesAction = () => {
36
+ const subtitleTracks = usePlayer((state) => state.subtitleTracks)
37
+ const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
38
+ const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
39
+ const subtitleOffLabel = usePlayer((state) => state.subtitleOffLabel)
40
+
41
+ const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu()
42
+
43
+ // After every hook, never before. One track is enough to offer the menu, because "off" is always a
44
+ // second option; a file carrying none should not offer it at all.
45
+ if (subtitleTracks.length === 0) return null
46
+
47
+ // Read from the store, never from `pending`: mid-switch the selection has not moved, and flipping
48
+ // the glyph early is the same lie a tick on the pending row would be.
49
+ const on = selectedSubtitleTrack !== undefined
50
+
51
+ return (
52
+ <div css={style} ref={containerRef}>
53
+ <TooltipDisplay
54
+ id='subtitles'
55
+ disabled={open}
56
+ text={
57
+ <button className='subtitles' type='button' onClick={toggle}>
58
+ {on ? <Captions className='captions' /> : <CaptionsOff className='captions-off' />}
59
+ </button>
60
+ }
61
+ toolTipText={<span>Subtitles</span>}
62
+ />
63
+ {
64
+ open && (
65
+ <TrackMenu
66
+ title='Subtitles'
67
+ tracks={subtitleTracks}
68
+ selected={selectedSubtitleTrack}
69
+ onSelect={(id) => runSelect(id ?? null, () => selectSubtitleTrack(id))}
70
+ offLabel={subtitleOffLabel ?? 'Disable'}
71
+ pending={pending}
72
+ failed={failed}
73
+ />
74
+ )
75
+ }
76
+ </div>
77
+ )
78
+ }
79
+
80
+ export default SubtitlesAction
@@ -0,0 +1,277 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { TrackChoice } from '../source-feature'
3
+
4
+ import { useEffect, useRef, useState } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { ChevronLeft } from 'react-feather'
7
+
8
+ import { fonts } from '../../utils/fonts'
9
+
10
+ /**
11
+ * The popover surface and the track rows, shared by every menu in the control bar.
12
+ *
13
+ * Carries NO `position` of its own: the menu anchors to the control bar, which is the width of the
14
+ * player box, so each caller keeps `position: static` on its own wrapper. Anchoring to the button
15
+ * instead is what let the menu hang outside the box, where the root's `overflow: hidden` made it
16
+ * unreachable rather than merely ugly.
17
+ */
18
+ export const popoverStyle = css`
19
+ .popover {
20
+ position: absolute;
21
+ /* Anchored to the PLAYER's right edge, never to the gear. The gear is not the last control in the
22
+ bar, so a menu centred on it puts its own edges wherever the button happens to sit: with no
23
+ picture-in-picture button, which is every source whose media the player does not own, an edge
24
+ landed outside the box, and the root's \`overflow: hidden\` cut it off, taking the rows under it
25
+ out of reach. The inset matches the bar's own padding so the menu lines up with the last button. */
26
+ right: calc(12px + env(safe-area-inset-right, 0px));
27
+ bottom: calc(100% + 8px);
28
+
29
+ overflow-y: auto;
30
+ width: 180px;
31
+ /* \`100%\` is the control bar, which is the width of the PLAYER. The old \`100vw\` was the viewport,
32
+ which equals the player only when the player is the whole document, and it is the wrong kind of
33
+ guard besides: a max-width shrinks the box, it never moves it back inside. */
34
+ max-width: calc(100% - 24px);
35
+ /* Sized to its rows, capped at what fits. A fixed height left the settings menu two thirds empty
36
+ once subtitles moved out to its own button, and a long track list scrolls at the cap either way. */
37
+ height: max-content;
38
+ /* The room actually above the bar. A short player had the top of the menu clipped by that same
39
+ \`overflow: hidden\`. With no size container in the ancestry this resolves against the small
40
+ viewport, so it can only ever shrink the box, never grow it. */
41
+ max-height: min(160px, calc(100cqh - 100% - 16px));
42
+ @media (min-width: 768px) {
43
+ right: calc(16px + env(safe-area-inset-right, 0px));
44
+ width: 250px;
45
+ max-width: calc(100% - 32px);
46
+ }
47
+ @media (min-width: 2560px) {
48
+ right: calc(24px + env(safe-area-inset-right, 0px));
49
+ max-width: calc(100% - 48px);
50
+ }
51
+
52
+ display: flex;
53
+ flex-direction: column;
54
+
55
+ border-radius: 8px;
56
+ background-color: rgba(28,28,28,0.95);
57
+ ${fonts.bMedium.regular}
58
+
59
+ z-index: 4;
60
+
61
+ > div {
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+
66
+ color: #fff;
67
+ outline: none;
68
+
69
+ padding: 8px 6px 8px 12px;
70
+
71
+ width: 100%;
72
+ /* rows keep their own height inside the fixed height column, so a long page scrolls rather than
73
+ squashing every row into it */
74
+ flex-shrink: 0;
75
+ @media (pointer: coarse) {
76
+ box-sizing: border-box;
77
+ min-height: 44px;
78
+ }
79
+
80
+ :first-of-type {
81
+ border-radius: 8px 8px 0 0;
82
+ }
83
+ :last-of-type {
84
+ border-radius: 0 0 8px 8px;
85
+ }
86
+ :not(&.no-hover) {
87
+ cursor: pointer;
88
+ }
89
+ :not(&.no-hover):hover {
90
+ background-color: rgba(255,255,255,.1);
91
+ }
92
+
93
+ > div {
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+
98
+ .secondary {
99
+ color: #eee;
100
+ ${fonts.bSmall.regular}
101
+ }
102
+ }
103
+ }
104
+
105
+ .back {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: flex-start;
109
+ border-bottom: 1px solid #4D4D4E;
110
+
111
+ svg {
112
+ transform: translateX(-4px);
113
+ transition: transform 0.2s ease-in-out;
114
+ }
115
+
116
+ :hover {
117
+ svg {
118
+ transform: translateX(-8px);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ .track-list {
125
+ .description {
126
+ word-break: break-word;
127
+ }
128
+
129
+ /* Dimmed and inert, but still listed. A source that names a track it cannot serve right now is
130
+ saying the track exists, and dropping the row would read as the source having nothing. */
131
+ .unavailable {
132
+ opacity: 0.5;
133
+ cursor: default;
134
+ :hover {
135
+ background-color: transparent;
136
+ }
137
+ }
138
+
139
+ .failed {
140
+ border-bottom: 1px solid #4D4D4E;
141
+ color: #f66;
142
+ ${fonts.bSmall.regular}
143
+ }
144
+ }
145
+ `
146
+
147
+ /**
148
+ * One track picker, used by the subtitles button and by the settings menu's audio page.
149
+ *
150
+ * `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
151
+ * is working, every row is inert and the one being switched to says so, because the selection has not
152
+ * moved yet and a tick next to it would be a lie.
153
+ */
154
+ export const TrackMenu = (
155
+ { title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
156
+ title: string
157
+ tracks: TrackChoice[]
158
+ selected: string | number | undefined
159
+ onSelect: (id: string | number | undefined) => void
160
+ /** Absent means the menu was opened straight from the bar, so the header is a label and no more. */
161
+ onBack?: () => void
162
+ /** Absent means the menu offers no way off at all, which is what audio wants. */
163
+ offLabel?: string
164
+ /** The id currently being switched to, where `null` is the off row and undefined means idle. */
165
+ pending?: string | number | null
166
+ failed?: boolean
167
+ }
168
+ ) => {
169
+ const busy = pending !== undefined
170
+ const row = (id: string | number | null, label: string, className?: string) => {
171
+ const isPending = busy && pending === id
172
+ const disabled = busy || tracks.find((track) => track.id === id)?.disabled
173
+ return (
174
+ <div
175
+ key={id ?? '__off__'}
176
+ onClick={() => { if (!disabled) onSelect(id ?? undefined) }}
177
+ className={[className, disabled ? 'unavailable' : null].filter(Boolean).join(' ') || undefined}
178
+ >
179
+ <span>{label}</span>
180
+ <span>{isPending ? '\u2026' : (selected ?? null) === id ? '\u2713' : ''}</span>
181
+ </div>
182
+ )
183
+ }
184
+
185
+ return (
186
+ <div className='popover track-list'>
187
+ {/* The header keeps its name and its border either way. With no page to go back to there is
188
+ nothing for a chevron to point at, and `no-hover` stops the row offering a dead click. */}
189
+ <div className={onBack ? 'back' : 'back no-hover'} onClick={onBack}>
190
+ {onBack ? <ChevronLeft /> : null}
191
+ <span>{title}</span>
192
+ </div>
193
+ {failed ? <div className="no-hover failed">Could not switch. Try again.</div> : null}
194
+ {offLabel ? row(null, offLabel) : null}
195
+ {tracks.map(({ id, label }) => row(id, label, 'description'))}
196
+ </div>
197
+ )
198
+ }
199
+
200
+ /**
201
+ * The open/closed and in-flight state every track menu in the bar needs.
202
+ *
203
+ * One hook rather than two, because `runSelect` is what decides when the menu may close: splitting
204
+ * the popover state from the selection state would wire them in a circle.
205
+ */
206
+ export const useTrackMenu = ({ onReset }: { onReset?: () => void } = {}) => {
207
+ const containerRef = useRef<HTMLDivElement>(null)
208
+ const [open, setOpen] = useState(false)
209
+ // The id being switched to, or undefined when nothing is in flight. Null is the row that turns
210
+ // subtitles off, so this cannot be a plain id: undefined has to keep meaning idle.
211
+ const [pending, setPending] = useState<string | number | null | undefined>(undefined)
212
+ const [failed, setFailed] = useState(false)
213
+
214
+ // Held in a ref because the pointerdown listener and the select promise both outlive the render
215
+ // that handed the callback over.
216
+ const reset = useRef(onReset)
217
+ reset.current = onReset
218
+
219
+ const close = () => {
220
+ setOpen(false)
221
+ reset.current?.()
222
+ }
223
+
224
+ const toggle = () => {
225
+ setOpen((wasOpen) => !wasOpen)
226
+ setFailed(false)
227
+ reset.current?.()
228
+ }
229
+
230
+ useEffect(() => {
231
+ const handleClickOutside = (ev: PointerEvent) => {
232
+ if (open && containerRef.current && !containerRef.current.contains(ev.target as Node)) close()
233
+ }
234
+ // pointerdown, because a touch device synthesizes mousedown too late to close reliably
235
+ document.addEventListener('pointerdown', handleClickOutside)
236
+ return () => document.removeEventListener('pointerdown', handleClickOutside)
237
+ }, [open])
238
+
239
+ /**
240
+ * Runs a track switch and decides when the menu may close.
241
+ *
242
+ * A selector that returns nothing switched synchronously, so the click closes the menu as it always
243
+ * has. One that returns a promise owns a player this one cannot reach, and the menu stays open and
244
+ * inert until it answers: closing first would show a selection that has not happened yet, and
245
+ * dropping the promise would turn a failed switch into an unhandled rejection nobody ever sees.
246
+ */
247
+ const runSelect = (id: string | number | null, select: () => void | Promise<void>) => {
248
+ if (pending !== undefined) return
249
+ setFailed(false)
250
+ let result: void | Promise<void>
251
+ try {
252
+ result = select()
253
+ } catch (err) {
254
+ console.warn('[media-player] track selection failed:', err)
255
+ setFailed(true)
256
+ return
257
+ }
258
+ if (!(result instanceof Promise)) {
259
+ close()
260
+ return
261
+ }
262
+ setPending(id)
263
+ result.then(
264
+ () => {
265
+ setPending(undefined)
266
+ close()
267
+ },
268
+ (err) => {
269
+ console.warn('[media-player] track selection failed:', err)
270
+ setPending(undefined)
271
+ setFailed(true)
272
+ },
273
+ )
274
+ }
275
+
276
+ return { open, toggle, close, containerRef, pending, failed, runSelect }
277
+ }
@@ -1,32 +1,50 @@
1
- import type { PictureInPictureController } from '../../engine'
1
+ import type { PictureInPictureController, PictureInPictureMode } from '../../engine'
2
2
 
3
- import { useCallback, useEffect, useRef } from 'react'
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
4
 
5
- import { createPictureInPicture } from '../../engine'
6
- import { usePlayer } from '../player'
5
+ import { createPictureInPicture, pictureInPictureMode } from '../../engine'
7
6
 
8
- /** Picture in picture with the subtitles composited in. */
7
+ export type PictureInPicture = {
8
+ /** null when nothing here can work, and the chrome then offers no control at all. */
9
+ toggle: (() => void) | null
10
+ mode: PictureInPictureMode | null
11
+ /** Burn-in only. True while the composite is the picture on screen. */
12
+ burnedIn: boolean
13
+ }
14
+
15
+ /**
16
+ * Picture in picture with the subtitles composited in.
17
+ *
18
+ * Two shapes, because two kinds of browser. Where the W3C API exists this opens a real window off a
19
+ * hidden mirror. Where it does not, and the engine is Gecko, the same composite becomes the picture
20
+ * in the page so that the BROWSER'S own picture in picture control carries the subtitles with it,
21
+ * which it otherwise cannot: it takes a video element, and the subtitles live on a canvas above one.
22
+ */
9
23
  export const usePictureInPicture = (
10
24
  video: HTMLVideoElement | null,
11
25
  canvas: HTMLCanvasElement | null,
12
- ) => {
13
- const player = usePlayer()
26
+ ): PictureInPicture => {
14
27
  const controller = useRef<PictureInPictureController | null>(null)
28
+ const [burnedIn, setBurnedIn] = useState(false)
29
+ // Detected once. It cannot change for the life of the document, and recomputing it per render
30
+ // would churn the identity of everything downstream.
31
+ const [mode] = useState<PictureInPictureMode | null>(() => pictureInPictureMode())
15
32
 
16
33
  useEffect(() => {
17
- if (!video || !canvas) return
34
+ if (!video || !canvas || !mode) return
18
35
  const instance = createPictureInPicture({
19
36
  video,
20
37
  canvas,
21
- // the store's action handles Safari and exits fullscreen first
22
- fallback: () => player.togglePictureInPicture(),
38
+ mode,
39
+ onBurnedInChange: setBurnedIn,
23
40
  })
24
41
  controller.current = instance
25
42
  return () => {
26
43
  instance.destroy()
27
44
  controller.current = null
45
+ setBurnedIn(false)
28
46
  }
29
- }, [video, canvas, player])
47
+ }, [video, canvas, mode])
30
48
 
31
49
  const toggle = useCallback(() => {
32
50
  void controller.current?.toggle().catch((error) => {
@@ -36,5 +54,5 @@ export const usePictureInPicture = (
36
54
 
37
55
  // null rather than a dead callback: the chrome hides the control instead of offering one that
38
56
  // cannot work, and there is nothing to composite without both an element and a canvas.
39
- return video && canvas ? toggle : null
57
+ return { toggle: video && canvas && mode ? toggle : null, mode, burnedIn }
40
58
  }
@@ -1,4 +1,4 @@
1
- import type { MediaIndex, ThumbnailImage } from '../engine'
1
+ import type { MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine'
2
2
 
3
3
  import { definePlayerFeature } from '@videojs/core/dom'
4
4
 
@@ -89,6 +89,15 @@ export type SourceState = {
89
89
  */
90
90
  togglePictureInPicture: (() => void) | null
91
91
 
92
+ /**
93
+ * Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
94
+ * subtitles into the picture so the BROWSER'S own control carries them; the viewer presses that
95
+ * one afterwards, so the button has to say something different. null means no control.
96
+ */
97
+ pictureInPictureMode: PictureInPictureMode | null
98
+ /** Burn-in only. True while the composite is the picture on screen. */
99
+ burnedInSubtitles: boolean
100
+
92
101
  /** Set when the pipeline fails. Cleared when it recovers. */
93
102
  playbackError: unknown
94
103
  /** Whether the engine has produced its first media segment. */
@@ -116,6 +125,8 @@ const initialState: SourceState = {
116
125
  hideUI: false,
117
126
  setHideUI: () => {},
118
127
  togglePictureInPicture: null,
128
+ pictureInPictureMode: null,
129
+ burnedInSubtitles: false,
119
130
  playbackError: null,
120
131
  ready: false,
121
132
  setSourceState: () => {},
@@ -187,7 +187,11 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
187
187
  downloadedRanges,
188
188
  })
189
189
  const thumbnails = remote?.thumbnails?.all ?? generatedThumbnails
190
- const togglePictureInPicture = usePictureInPicture(video, canvas)
190
+ const {
191
+ toggle: togglePictureInPicture,
192
+ mode: pictureInPictureMode,
193
+ burnedIn: burnedInSubtitles,
194
+ } = usePictureInPicture(video, canvas)
191
195
 
192
196
  // Subscribed rather than read off the store, because it is a no-op until the media element
193
197
  // attaches: when attach swaps in the real setter the identity changes and these publish again.
@@ -199,8 +203,16 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
199
203
 
200
204
  const thumbnailAt = remote?.thumbnails?.at
201
205
  useEffect(() => {
202
- setSourceState({ thumbnails, thumbnailAt, togglePictureInPicture })
203
- }, [setSourceState, thumbnails, thumbnailAt, togglePictureInPicture])
206
+ setSourceState({
207
+ thumbnails,
208
+ thumbnailAt,
209
+ togglePictureInPicture,
210
+ pictureInPictureMode,
211
+ burnedInSubtitles,
212
+ })
213
+ }, [
214
+ setSourceState, thumbnails, thumbnailAt, togglePictureInPicture, pictureInPictureMode, burnedInSubtitles,
215
+ ])
204
216
 
205
217
  // A delegated track list writes the same store fields the engine writes, so the menus never learn
206
218
  // which arm they are showing. Only the writer differs: here the pick is forwarded to whoever owns
@@ -257,6 +269,45 @@ const rootStyle = css`
257
269
  */
258
270
  --mp-unit: 10px;
259
271
 
272
+ /**
273
+ * The chrome's own typeface, for exactly the reason the unit above exists.
274
+ *
275
+ * \`font-family\` inherits, and this library declared it nowhere, so every label drew in whatever the
276
+ * embedding document happened to set, and in the UA's serif where it set nothing. Not hypothetical:
277
+ * stub mounts the player in a second document, \`embed.html\`, which sets no font and loads no face,
278
+ * and the entire settings menu came out in Times New Roman.
279
+ *
280
+ * Named faces only, no bundled webfont. The chrome is a handful of short labels, so a font request
281
+ * on the critical path of a document whose job is to paint video buys a round trip and a flash of
282
+ * unstyled text for nothing.
283
+ *
284
+ * Read through \`var()\` so a host with a brand face sets \`--mp-font-family\` on any ancestor and it
285
+ * inherits in. A bare \`font-family\` on the host can no longer reach the chrome: this declaration is
286
+ * on the player element itself and beats anything inherited.
287
+ */
288
+ font-family: var(
289
+ --mp-font-family,
290
+ system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif,
291
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji'
292
+ );
293
+
294
+ /**
295
+ * The chrome's default ink, same argument again.
296
+ *
297
+ * Most text here names its own colour, but the two time readouts do not: the elapsed/duration pair
298
+ * and the seekbar's hover time carry a font token and a black text-shadow and nothing else.
299
+ * Inherited from a host that declares none that resolves to \`canvastext\`, which is black text under
300
+ * a black halo on a dark gradient.
301
+ */
302
+ color: #fff;
303
+
304
+ /**
305
+ * The settings menu scrolls, and a UA that has not been told the surface is dark paints that
306
+ * scrollbar in its light theme: a white track down the side of a #1c1c1c menu. Declared here rather
307
+ * than asked of the host, so it reaches the menu and nothing outside the player.
308
+ */
309
+ color-scheme: dark;
310
+
260
311
  /**
261
312
  * The other half of owning the chrome's own scale.
262
313
  *
@@ -2,6 +2,8 @@ const colors = {
2
2
  primary: '#EAEBEE',
3
3
  secondary: '#D0D0D9',
4
4
  hover: 'rgba(255, 255, 255, 0.13)',
5
+ /** A control that is ON. Distinct from `hover`, which the pointer is already painting. */
6
+ accent: '#6EA8FE',
5
7
  borderPrimary: '#384F70',
6
8
  backgroundTooltip: '#222222',
7
9
  }