@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.
- package/dist/engine/index.js +1 -1
- package/dist/engine/subtitles.d.ts +6 -1
- package/dist/{engine-DPnChmYc.js → engine-B8CKGIng.js} +180 -191
- package/dist/index.js +209 -157
- package/dist/react/components/icons.d.ts +9 -0
- package/dist/utils/colors.d.ts +0 -2
- package/package.json +1 -1
- package/src/lib/engine/subtitles.ts +27 -11
- package/src/lib/react/components/burn-in-hint.tsx +1 -1
- package/src/lib/react/components/control-bar.tsx +13 -20
- package/src/lib/react/components/icons.tsx +16 -0
- package/src/lib/react/components/tooltip-display.tsx +116 -26
- package/src/lib/utils/colors.ts +0 -2
|
@@ -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;
|
package/dist/utils/colors.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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(
|
|
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
|
}
|
|
@@ -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
|
|
24
|
+
* Two stacked lines inside a tooltip.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
?
|
|
328
|
+
? burnedInSubtitles
|
|
329
|
+
? <SubtitlesInPicture />
|
|
330
|
+
: <SubtitlesOutsidePicture />
|
|
338
331
|
: <img src={pictureInPicture} alt='' />}
|
|
339
332
|
</button>
|
|
340
333
|
}
|
|
341
334
|
toolTipText={
|
|
342
|
-
// The
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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-
|
|
18
|
+
justify-content: flex-start;
|
|
17
19
|
|
|
18
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
package/src/lib/utils/colors.ts
CHANGED
|
@@ -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
|
}
|