@banou/media-player 0.8.5 → 0.8.7

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,6 +10,7 @@ import { usePlayer } from '../player'
10
10
  import { TooltipDisplay } from './tooltip-display'
11
11
  import { ProgressBar } from './progress-bar'
12
12
  import pictureInPicture from '../../assets/picture-in-picture.svg'
13
+ import { SubtitlesInPicture } from './icons'
13
14
  import SettingsAction from './settings'
14
15
  import SubtitlesAction from './subtitles'
15
16
  import colors from '../../utils/colors'
@@ -18,6 +19,25 @@ import Sound from './sound'
18
19
  const VOLUME_STEP = 0.05
19
20
  const SEEK_STEP = 5
20
21
 
22
+ /**
23
+ * Two stacked lines inside a tooltip, with a width to wrap against.
24
+ *
25
+ * Applied to the content rather than to the tooltip, because react-tooltip renders into a portal and
26
+ * an unconstrained tooltip grows to one long line that runs off the side of the player.
27
+ */
28
+ const tooltipLinesStyle = css`
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: calc(0.4 * var(--mp-unit));
32
+ max-width: calc(26 * var(--mp-unit));
33
+ white-space: normal;
34
+
35
+ .hint {
36
+ opacity: 0.72;
37
+ font-size: 0.9em;
38
+ }
39
+ `
40
+
21
41
  const style = css`
22
42
  position: absolute;
23
43
  bottom: 0;
@@ -68,6 +88,22 @@ const style = css`
68
88
  svg {
69
89
  stroke: #fff;
70
90
  }
91
+
92
+ /* Reads as unavailable rather than absent, so the bar does not reflow when tracks land. */
93
+ &:disabled {
94
+ cursor: default;
95
+ opacity: .4;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The burn-in control's own on state.
101
+ *
102
+ * Not \`colors.hover\`: the pointer is on the button at the moment of the click, so an on state
103
+ * drawn in the hover colour is invisible exactly when it is being looked for.
104
+ */
105
+ button[aria-pressed='true'] svg {
106
+ stroke: ${colors.accent};
71
107
  }
72
108
 
73
109
  .play, .sound, .time, .subtitles, .settings, .picture-in-picture, .full-screen {
@@ -150,8 +186,17 @@ export const ControlBar = () => {
150
186
  const fullscreen = usePlayer((state) => state.fullscreen)
151
187
  const hideUI = usePlayer((state) => state.hideUI)
152
188
  const togglePictureInPicture = usePlayer((state) => state.togglePictureInPicture)
189
+ const pictureInPictureMode = usePlayer((state) => state.pictureInPictureMode)
190
+ const burnedInSubtitles = usePlayer((state) => state.burnedInSubtitles)
191
+ const subtitleTracks = usePlayer((state) => state.subtitleTracks)
153
192
  const [volumeElement, setVolumeElement] = useState<HTMLButtonElement | null>(null)
154
193
 
194
+ const burnIn = pictureInPictureMode === 'burn-in'
195
+ // Burning nothing in is a mode with no effect, so the control stays visible and goes dead rather
196
+ // than vanishing: the track list arrives from libav a moment after playback starts, and a button
197
+ // that pops in late shifts every control beside it a second time.
198
+ const hasSubtitles = subtitleTracks.length > 0
199
+
155
200
  // duration is 0 until metadata lands, so a bare equality would show replay before playback starts
156
201
  const ended = duration > 0 && currentTime === duration
157
202
 
@@ -265,18 +310,46 @@ export const ControlBar = () => {
265
310
  ? (
266
311
  <TooltipDisplay
267
312
  id='picture-in-picture'
313
+ // anchored to its end, or the two-line burn-in copy runs off the right of the player
314
+ tooltipPlace='top-end'
268
315
  text={
269
316
  <button
270
317
  className='picture-in-picture'
271
318
  type='button'
272
319
  onClick={togglePictureInPicture}
320
+ disabled={burnIn && !hasSubtitles}
321
+ // Static name with the state on `aria-pressed`, rather than a name that changes
322
+ // on activation: one announces the state once, the other announces it twice and
323
+ // renames the control while the pointer is on it.
324
+ aria-label={burnIn ? 'Put the subtitles in the video' : 'Picture in picture'}
325
+ aria-pressed={burnIn ? burnedInSubtitles : undefined}
273
326
  >
274
- <img src={pictureInPicture} />
327
+ {burnIn
328
+ ? <SubtitlesInPicture />
329
+ : <img src={pictureInPicture} alt='' />}
275
330
  </button>
276
331
  }
277
332
  toolTipText={
278
- <span>
279
- Picture in picture
333
+ // The tooltip is portaled out of this subtree, so the control bar's own rules
334
+ // never reach it: a bare `small` stays inline and runs straight on from the line
335
+ // above it. Both lines carry their layout themselves.
336
+ <span css={tooltipLinesStyle}>
337
+ <span className='lead'>
338
+ {!burnIn
339
+ ? 'Picture in picture'
340
+ : !hasSubtitles
341
+ ? 'This file has no subtitles'
342
+ : burnedInSubtitles
343
+ ? 'Subtitles are in the picture'
344
+ : 'Put the subtitles in the picture'}
345
+ </span>
346
+ {burnIn && hasSubtitles
347
+ ? (
348
+ <span className='hint'>
349
+ Then hover the video and click your browser&apos;s own pop out button
350
+ </span>
351
+ )
352
+ : null}
280
353
  </span>
281
354
  }
282
355
  />
@@ -36,3 +36,18 @@ export const CaptionsOff = (props: SVGProps<SVGSVGElement>) => (
36
36
  <line x1='2' y1='2' x2='22' y2='22' />
37
37
  </svg>
38
38
  )
39
+
40
+ /**
41
+ * Subtitles inside the picture: the picture-in-picture frame with caption bars in the inset.
42
+ *
43
+ * A separate glyph on purpose. The control means something different on a browser that cannot open
44
+ * a window, and the same icon doing two things silently is the thing to avoid. It is not the plain
45
+ * captions glyph either, because the button beside it already is one.
46
+ */
47
+ export const SubtitlesInPicture = (props: SVGProps<SVGSVGElement>) => (
48
+ <svg {...iconProps} {...props}>
49
+ <path d='M21 11V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h5' />
50
+ <rect x='12' y='13' width='10' height='8' rx='1' ry='1' />
51
+ <path d='M14.5 18.5h2m2 0h1' />
52
+ </svg>
53
+ )
@@ -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
@@ -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
  }