@banou/media-player 0.8.16 → 0.8.18

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,3 +10,12 @@ export declare const CaptionsOff: (props: SVGProps<SVGSVGElement>) => import("@e
10
10
  * captions glyph either, because the button beside it already is one.
11
11
  */
12
12
  export declare const SubtitlesInPicture: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
13
+ /**
14
+ * The same frame with the captions still on the MAIN picture: burn-in is available but off.
15
+ *
16
+ * The pair exists because that control is the one button in the bar whose glyph did not move with its
17
+ * state, so the state was carried by an accent colour and by nothing else. A pair, not a slash: a
18
+ * slashed resting state reads as unavailable, and the subtitles button beside it already draws
19
+ * Feather's slash for its own off state.
20
+ */
21
+ export declare const SubtitlesOutsidePicture: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
@@ -2,8 +2,6 @@ declare const colors: {
2
2
  primary: string;
3
3
  secondary: string;
4
4
  hover: string;
5
- /** A control that is ON. Distinct from `hover`, which the pointer is already painting. */
6
- accent: string;
7
5
  borderPrimary: string;
8
6
  backgroundTooltip: string;
9
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.16",
3
+ "version": "0.8.18",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -2,7 +2,7 @@ import type { ASS_Event } from 'jassub'
2
2
  import type { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
3
3
 
4
4
  import JASSUB from 'jassub'
5
- import { parse, stringify } from 'ass-compiler'
5
+ import { parse } from 'ass-compiler'
6
6
 
7
7
  export type SubtitleStream = { streamIndex: number, title: string, language: string }
8
8
 
@@ -35,11 +35,30 @@ export type SubtitleRendererOptions = {
35
35
  * which throws. So leaving this unset does not degrade to the slower build, it fails outright.
36
36
  */
37
37
  legacyWasmUrl?: string
38
- /** Fallback face for `liberation sans`. Without it jassub falls back to whatever the wasm build embeds. */
38
+ /**
39
+ * Fallback face for `liberation sans`. Without it jassub falls back to whatever the wasm build embeds.
40
+ *
41
+ * May be relative: it is resolved against the document before the worker sees it, for the reason
42
+ * given on `workerFetched`.
43
+ */
39
44
  defaultFontUrl?: string
40
45
  onStreams?: (streams: SubtitleStream[]) => void
41
46
  }
42
47
 
48
+ /**
49
+ * A url the WORKER will fetch, resolved against the document first.
50
+ *
51
+ * jassub's worker is built from a `blob:` url, so a relative url handed to it resolves against that
52
+ * blob inside the worker and the fetch never lands. For the wasm that fails loudly. For the fallback
53
+ * font it fails in SILENCE: libass is left with no face to draw with, so the track renders nothing at
54
+ * all and the picture simply has no subtitles on it, with no error anywhere to say why.
55
+ *
56
+ * `workerUrl` is deliberately not passed through here. `new Worker()` is called on the main thread,
57
+ * where a relative url already resolves against the document.
58
+ */
59
+ const workerFetched = (url: string | undefined) =>
60
+ url === undefined ? undefined : new URL(url, document.baseURI).toString()
61
+
43
62
  const convertTimestamp = (ms: number) => new Date(ms).toISOString().slice(11, 22)
44
63
 
45
64
  /**
@@ -73,12 +92,6 @@ const styleIndex = (header: SubtitleHeaderPart, name: string) => {
73
92
  return header.styles.get(key) ?? header.styles.get('Default') ?? 0
74
93
  }
75
94
 
76
- // cleared so jassub scales the script to the canvas, not to the authored resolution
77
- const renderable = (content: string) => {
78
- const parsed = parse(content)
79
- return stringify({ ...parsed, info: { ...parsed.info, ScaledBorderAndShadow: 'no', LayoutResX: '', LayoutResY: '' } })
80
- }
81
-
82
95
  /**
83
96
  * `\r?\n`, not `\r\n`: an ASS header muxed straight out of a matroska file uses CRLF, but one libav
84
97
  * CONVERTED from another format (an srt track, most commonly) is LF only, and requiring CRLF rejected it.
@@ -126,7 +139,10 @@ const toDialoguePart = (header: SubtitleHeaderPart, fragment: SubtitleFragment &
126
139
  export type SubtitleRenderer = ReturnType<typeof createSubtitleRenderer>
127
140
 
128
141
  export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
129
- const { video, canvas, workerUrl, wasmUrl, legacyWasmUrl, defaultFontUrl } = options
142
+ const { video, canvas, workerUrl } = options
143
+ const wasmUrl = workerFetched(options.wasmUrl)!
144
+ const legacyWasmUrl = workerFetched(options.legacyWasmUrl)
145
+ const defaultFontUrl = workerFetched(options.defaultFontUrl)
130
146
  let jassub: JASSUB | undefined
131
147
  let attachments: [string, Uint8Array][] = []
132
148
  const headers = new Map<number, SubtitleHeaderPart>()
@@ -146,7 +162,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
146
162
  onDemandRender: false,
147
163
  video,
148
164
  canvas,
149
- subContent: renderable(header.content),
165
+ subContent: header.content,
150
166
  workerUrl,
151
167
  modernWasmUrl: wasmUrl,
152
168
  ...legacyWasmUrl ? { wasmUrl: legacyWasmUrl } : {},
@@ -196,7 +212,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
196
212
  jassub.freeTrack()
197
213
  const header = headers.get(next)
198
214
  if (!header) return
199
- jassub.setTrack(renderable(header.content))
215
+ jassub.setTrack(header.content)
200
216
  for (const part of dialogues.get(next)?.values() ?? []) createEvent(jassub, part.assEvent)
201
217
  jassub.setCurrentTime(video.paused, video.currentTime, video.playbackRate)
202
218
  }
@@ -59,7 +59,7 @@ const style = css`
59
59
  flex: none;
60
60
  font-size: calc(2.6 * var(--mp-unit));
61
61
  line-height: 1;
62
- color: #6EA8FE;
62
+ color: #fff;
63
63
  }
64
64
  `
65
65
 
@@ -10,7 +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
+ import { SubtitlesInPicture, SubtitlesOutsidePicture } from './icons'
14
14
  import ErrorsAction from './errors'
15
15
  import SettingsAction from './settings'
16
16
  import SubtitlesAction from './subtitles'
@@ -21,17 +21,15 @@ const VOLUME_STEP = 0.05
21
21
  const SEEK_STEP = 5
22
22
 
23
23
  /**
24
- * Two stacked lines inside a tooltip, with a width to wrap against.
24
+ * Two stacked lines inside a tooltip.
25
25
  *
26
- * Applied to the content rather than to the tooltip, because react-tooltip renders into a portal and
27
- * an unconstrained tooltip grows to one long line that runs off the side of the player.
26
+ * The width to wrap against is no longer here: TooltipDisplay bounds every chip it draws, at the
27
+ * same 26 units this had imposed by hand, so a call site only says what its content is.
28
28
  */
29
29
  const tooltipLinesStyle = css`
30
30
  display: flex;
31
31
  flex-direction: column;
32
32
  gap: calc(0.4 * var(--mp-unit));
33
- max-width: calc(26 * var(--mp-unit));
34
- white-space: normal;
35
33
 
36
34
  .hint {
37
35
  opacity: 0.72;
@@ -97,16 +95,6 @@ const style = css`
97
95
  }
98
96
  }
99
97
 
100
- /**
101
- * The burn-in control's own on state.
102
- *
103
- * Not \`colors.hover\`: the pointer is on the button at the moment of the click, so an on state
104
- * drawn in the hover colour is invisible exactly when it is being looked for.
105
- */
106
- button[aria-pressed='true'] svg {
107
- stroke: ${colors.accent};
108
- }
109
-
110
98
  .play, .sound, .time, .errors, .subtitles, .settings, .picture-in-picture, .full-screen {
111
99
  display: flex;
112
100
  align-items: center;
@@ -333,15 +321,20 @@ export const ControlBar = () => {
333
321
  aria-label={burnIn ? 'Put the subtitles in the video' : 'Picture in picture'}
334
322
  aria-pressed={burnIn ? burnedInSubtitles : undefined}
335
323
  >
324
+ {/* The glyph carries the on state, which is what every other toggle in this bar
325
+ does. It used to be carried by an accent stroke instead, and that was the only
326
+ blue in the chrome. */}
336
327
  {burnIn
337
- ? <SubtitlesInPicture />
328
+ ? burnedInSubtitles
329
+ ? <SubtitlesInPicture />
330
+ : <SubtitlesOutsidePicture />
338
331
  : <img src={pictureInPicture} alt='' />}
339
332
  </button>
340
333
  }
341
334
  toolTipText={
342
- // The tooltip is portaled out of this subtree, so the control bar's own rules
343
- // never reach it: a bare `small` stays inline and runs straight on from the line
344
- // above it. Both lines carry their layout themselves.
335
+ // The chip is drawn in its own subtree, where the control bar's rules never
336
+ // reach it: a bare `small` stays inline and runs straight on from the line above
337
+ // it. Both lines carry their layout themselves.
345
338
  <span css={tooltipLinesStyle}>
346
339
  <span className='lead'>
347
340
  {!burnIn
@@ -51,3 +51,19 @@ export const SubtitlesInPicture = (props: SVGProps<SVGSVGElement>) => (
51
51
  <path d='M14.5 18.5h2m2 0h1' />
52
52
  </svg>
53
53
  )
54
+
55
+ /**
56
+ * The same frame with the captions still on the MAIN picture: burn-in is available but off.
57
+ *
58
+ * The pair exists because that control is the one button in the bar whose glyph did not move with its
59
+ * state, so the state was carried by an accent colour and by nothing else. A pair, not a slash: a
60
+ * slashed resting state reads as unavailable, and the subtitles button beside it already draws
61
+ * Feather's slash for its own off state.
62
+ */
63
+ export const SubtitlesOutsidePicture = (props: SVGProps<SVGSVGElement>) => (
64
+ <svg {...iconProps} {...props}>
65
+ <path d='M21 11V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h5' />
66
+ <rect x='12' y='13' width='10' height='8' rx='1' ry='1' />
67
+ <path d='M6 9h3m2 0h2' />
68
+ </svg>
69
+ )
@@ -1,5 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
+ import type { TooltipRefProps } from 'react-tooltip'
2
3
 
4
+ import { useEffect, useRef } from 'react'
3
5
  import { css } from '@emotion/react'
4
6
  import { PlacesType, Tooltip } from 'react-tooltip'
5
7
 
@@ -13,9 +15,31 @@ export enum buttonSize {
13
15
 
14
16
  const style = (size: buttonSize) => css`
15
17
  display: flex;
16
- justify-content: flex-end;
18
+ justify-content: flex-start;
17
19
 
18
- border-radius: calc(0.4 * var(--mp-unit));
20
+ /*
21
+ * Bounded here rather than at each call site.
22
+ *
23
+ * react-tooltip's own chip rule carries width: max-content, so with nothing opposing it a chip
24
+ * grows to the widest UNWRAPPED line of its content: a two sentence tooltip measured 1178px
25
+ * through this component. Both declarations are load bearing, since a bound with no wrapping only
26
+ * moves the overflow inside a narrow box. 26 units is the width control-bar.tsx was already
27
+ * imposing on its own two line tooltip by hand, so nothing in the bar changes shape.
28
+ */
29
+ max-width: min(calc(26 * var(--mp-unit)), calc(100vw - calc(2.4 * var(--mp-unit))));
30
+ white-space: normal;
31
+ overflow-wrap: anywhere;
32
+ text-align: left;
33
+
34
+ /*
35
+ * The radius carries !important for the same reason the paddings below do.
36
+ *
37
+ * react-tooltip injects its stylesheet from a passive effect while emotion inserts through
38
+ * useInsertionEffect, which runs earlier, so react-tooltip's sheet lands in the head LAST at
39
+ * exactly this specificity and wins every tie. Its own radius is 3px, which is what was drawn
40
+ * here until this was stated.
41
+ */
42
+ border-radius: calc(0.4 * var(--mp-unit))!important;
19
43
  user-select: none;
20
44
 
21
45
  z-index: 3;
@@ -35,6 +59,25 @@ const style = (size: buttonSize) => css`
35
59
  `}
36
60
  `
37
61
 
62
+ /**
63
+ * Where the pointer is, shared by every anchor rather than tracked once per tooltip.
64
+ *
65
+ * The chrome mounts eight of these, and each only needs to answer one question at one moment, so a
66
+ * single refcounted listener answers it for all of them. Installed on the first mount rather than at
67
+ * module scope, so importing this library still does nothing to the document.
68
+ */
69
+ const pointer = { x: -1, y: -1, anchors: 0 }
70
+ const trackPointer = (event: PointerEvent) => { pointer.x = event.clientX; pointer.y = event.clientY }
71
+
72
+ const watchPointer = () => {
73
+ if (pointer.anchors++ === 0) {
74
+ window.addEventListener('pointermove', trackPointer, { capture: true, passive: true })
75
+ }
76
+ return () => {
77
+ if (--pointer.anchors === 0) window.removeEventListener('pointermove', trackPointer, { capture: true })
78
+ }
79
+ }
80
+
38
81
  interface TooltipDisplayProps {
39
82
  id: string
40
83
  toolTipText: ReactNode
@@ -57,30 +100,77 @@ export const TooltipDisplay = ({
57
100
  tooltipPlace = 'top',
58
101
  disabled = false,
59
102
  size = buttonSize.md
60
- }: TooltipDisplayProps) => (
61
- <>
62
- <div
63
- data-tooltip-id={id}
64
- data-open={true}
65
- data-tooltip-offset={offset}
66
- data-tooltip-delay-show={delayShow}
67
- data-tooltip-delay-hide={closeDelay}
68
- data-tooltip-place={tooltipPlace}
69
- >
70
- {text}
71
- </div>
72
- {
73
- !disabled && (
74
- <Tooltip
75
- css={style(size)}
76
- id={id}
77
- noArrow={true}
78
- >
79
- {toolTipText}
80
- </Tooltip>
81
- )
103
+ }: TooltipDisplayProps) => {
104
+ const anchor = useRef<HTMLDivElement>(null)
105
+ const tooltip = useRef<TooltipRefProps>(null)
106
+
107
+ /**
108
+ * Close on a fullscreen transition, unless the pointer really is still on the anchor.
109
+ *
110
+ * Going fullscreen relays the whole chrome out from under a pointer that never moved, and the
111
+ * browser recomputes the hover chain for that SILENTLY: the :hover flag flips a frame later, so
112
+ * the grey pill corrects itself in about 14ms, but no boundary event is dispatched at all.
113
+ * react-tooltip closes on mouseout and on nothing else, so it never learns the pointer left and
114
+ * the chip stays painted for the rest of the session. It survives arbitrary pointer movement,
115
+ * because the browser has already updated its own element-under-pointer, and it survives the
116
+ * chrome's auto-hide, coming back the moment the controls wake.
117
+ *
118
+ * Scoped to the fullscreen transition on purpose: the same layout move made by ordinary CSS does
119
+ * dispatch mouseout and closes the chip by itself, so no other reflow needs this and widening it
120
+ * would only add ways to close a tooltip that should be open.
121
+ *
122
+ * Tested against the anchor's rect rather than closed outright, because a player that already
123
+ * fills the window moves nothing, and the tooltip under the pointer is then legitimately open.
124
+ * That is the same test chrome.tsx's onMouseOut makes when relatedTarget comes back null. The rect
125
+ * is already the post-transition one when fullscreenchange fires, where matches(':hover') is not,
126
+ * which is why the pointer is tracked rather than asked for.
127
+ */
128
+ useEffect(() => {
129
+ const untrack = watchPointer()
130
+ const closeIfPointerLeft = () => {
131
+ const element = anchor.current
132
+ if (!element) return
133
+ const { left, right, top, bottom } = element.getBoundingClientRect()
134
+ if (pointer.x >= left && pointer.x < right && pointer.y >= top && pointer.y < bottom) return
135
+ tooltip.current?.close()
136
+ }
137
+ // webkit's own name as well, the way @videojs/core's fullscreen feature listens for both. Typed
138
+ // as string so the prefixed name checks against the DOM lib's event map.
139
+ const changeEvents: string[] = ['fullscreenchange', 'webkitfullscreenchange']
140
+ for (const type of changeEvents) document.addEventListener(type, closeIfPointerLeft)
141
+ return () => {
142
+ untrack()
143
+ for (const type of changeEvents) document.removeEventListener(type, closeIfPointerLeft)
82
144
  }
83
- </>
84
- )
145
+ }, [])
146
+
147
+ return (
148
+ <>
149
+ <div
150
+ ref={anchor}
151
+ data-tooltip-id={id}
152
+ data-open={true}
153
+ data-tooltip-offset={offset}
154
+ data-tooltip-delay-show={delayShow}
155
+ data-tooltip-delay-hide={closeDelay}
156
+ data-tooltip-place={tooltipPlace}
157
+ >
158
+ {text}
159
+ </div>
160
+ {
161
+ !disabled && (
162
+ <Tooltip
163
+ ref={tooltip}
164
+ css={style(size)}
165
+ id={id}
166
+ noArrow={true}
167
+ >
168
+ {toolTipText}
169
+ </Tooltip>
170
+ )
171
+ }
172
+ </>
173
+ )
174
+ }
85
175
 
86
176
  export default TooltipDisplay
@@ -2,8 +2,6 @@ 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',
7
5
  borderPrimary: '#384F70',
8
6
  backgroundTooltip: '#222222',
9
7
  }