@banou/media-player 0.6.2 → 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.
- package/README.md +149 -3
- package/dist/engine/index.d.ts +10 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/picture-in-picture.d.ts +27 -0
- package/dist/engine/playback.d.ts +45 -0
- package/dist/engine/source-buffer.d.ts +12 -0
- package/dist/engine/subtitles.d.ts +36 -0
- package/dist/engine/thumbnails.d.ts +22 -0
- package/dist/engine-Dmc7JSd9.js +470 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +1610 -0
- package/dist/react/components/chrome.d.ts +12 -0
- package/dist/react/components/control-bar.d.ts +2 -0
- package/dist/react/components/overlay.d.ts +4 -0
- package/dist/react/components/playback-slider.d.ts +2 -0
- package/dist/react/components/progress-bar.d.ts +2 -0
- package/dist/react/components/settings.d.ts +2 -0
- package/dist/react/components/sound.d.ts +7 -0
- package/{build → dist/react}/components/tooltip-display.d.ts +8 -8
- package/dist/react/components/volume-slider.d.ts +6 -0
- package/dist/react/hooks/use-drag-value.d.ts +23 -0
- package/dist/react/hooks/use-picture-in-picture.d.ts +2 -0
- package/dist/react/hooks/use-playback.d.ts +8 -0
- package/dist/react/hooks/use-thumbnails.d.ts +12 -0
- package/dist/react/media.d.ts +98 -0
- package/dist/react/player.d.ts +26 -0
- package/dist/react/source-feature.d.ts +106 -0
- package/dist/react/video-player.d.ts +83 -0
- package/dist/utils/source.d.ts +30 -0
- package/dist/utils/track-label.d.ts +31 -0
- package/dist/utils/volume-utils.d.ts +6 -0
- package/package.json +58 -37
- package/src/lib/engine/index.ts +14 -0
- package/src/lib/engine/picture-in-picture.ts +250 -0
- package/src/lib/engine/playback.ts +352 -0
- package/src/lib/engine/source-buffer.ts +61 -0
- package/src/lib/engine/subtitles.ts +210 -0
- package/src/lib/engine/thumbnails.ts +137 -0
- package/src/lib/globals.d.ts +8 -0
- package/src/lib/index.tsx +51 -0
- package/src/lib/react/components/chrome.tsx +130 -0
- package/src/{components → lib/react/components}/control-bar.tsx +74 -96
- package/src/lib/react/components/overlay.tsx +125 -0
- package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
- package/src/lib/react/components/progress-bar.tsx +341 -0
- package/src/lib/react/components/settings.tsx +384 -0
- package/src/lib/react/components/sound.tsx +133 -0
- package/src/{components → lib/react/components}/tooltip-display.tsx +15 -12
- package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
- package/src/lib/react/hooks/use-drag-value.ts +73 -0
- package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
- package/src/lib/react/hooks/use-playback.ts +146 -0
- package/src/lib/react/hooks/use-thumbnails.ts +78 -0
- package/src/lib/react/media.ts +124 -0
- package/src/lib/react/player.ts +20 -0
- package/src/lib/react/source-feature.ts +108 -0
- package/src/lib/react/video-player.tsx +223 -0
- package/src/lib/utils/fonts.ts +160 -0
- package/src/lib/utils/source.ts +60 -0
- package/src/lib/utils/track-label.ts +51 -0
- package/src/lib/utils/volume-utils.ts +13 -0
- package/build/components/chrome.d.ts +0 -7
- package/build/components/control-bar.d.ts +0 -6
- package/build/components/overlay.d.ts +0 -4
- package/build/components/playback-slider.d.ts +0 -2
- package/build/components/progress-bar.d.ts +0 -1
- package/build/components/settings.d.ts +0 -2
- package/build/components/sound.d.ts +0 -4
- package/build/components/volume-slider.d.ts +0 -6
- package/build/index.d.ts +0 -26
- package/build/index.js +0 -3416
- package/build/main.d.ts +0 -1
- package/build/state-machines/data-source.d.ts +0 -27
- package/build/state-machines/index.d.ts +0 -34038
- package/build/state-machines/media-properties.d.ts +0 -45
- package/build/state-machines/media-source.d.ts +0 -34
- package/build/state-machines/media.d.ts +0 -8558
- package/build/state-machines/subtitles.d.ts +0 -56
- package/build/state-machines/thumbnails.d.ts +0 -24
- package/build/state-machines/utils.d.ts +0 -26
- package/build/utils/actor-utils.d.ts +0 -2
- package/build/utils/context.d.ts +0 -14
- package/build/utils/index.d.ts +0 -4
- package/build/utils/languages.d.ts +0 -260
- package/build/utils/mp4box.d.ts +0 -61
- package/build/utils/use-local-storage.d.ts +0 -3
- package/build/utils/use-scrub.d.ts +0 -11
- package/build/utils/volume-utils.d.ts +0 -17
- package/build/utils/window-height.d.ts +0 -2
- package/src/components/chrome.tsx +0 -95
- package/src/components/overlay.tsx +0 -91
- package/src/components/progress-bar.tsx +0 -251
- package/src/components/settings.tsx +0 -322
- package/src/components/sound.tsx +0 -101
- package/src/index.tsx +0 -195
- package/src/main.tsx +0 -169
- package/src/state-machines/data-source.ts +0 -84
- package/src/state-machines/index.ts +0 -5
- package/src/state-machines/media-properties.ts +0 -82
- package/src/state-machines/media-source.ts +0 -129
- package/src/state-machines/media.ts +0 -255
- package/src/state-machines/subtitles.ts +0 -285
- package/src/state-machines/thumbnails.ts +0 -123
- package/src/state-machines/utils.ts +0 -94
- package/src/utils/actor-utils.ts +0 -19
- package/src/utils/context.ts +0 -23
- package/src/utils/fonts.ts +0 -160
- package/src/utils/index.ts +0 -229
- package/src/utils/languages.ts +0 -262
- package/src/utils/mp4box.ts +0 -74
- package/src/utils/use-local-storage.ts +0 -30
- package/src/utils/use-scrub.ts +0 -40
- package/src/utils/volume-utils.ts +0 -39
- package/src/utils/window-height.ts +0 -19
- package/src/vite-env.d.ts +0 -1
- package/tsconfig.json +0 -28
- package/tsconfig.node.json +0 -9
- /package/{build → dist}/utils/colors.d.ts +0 -0
- /package/{build → dist}/utils/fonts.d.ts +0 -0
- /package/{build → dist}/utils/time.d.ts +0 -0
- /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
- /package/src/{utils → lib/utils}/colors.ts +0 -0
- /package/src/{utils → lib/utils}/time.ts +0 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { ParsedASS, ParsedASSStyles } from 'ass-compiler'
|
|
2
|
+
import type { ASS_Event } from 'jassub'
|
|
3
|
+
import type { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
|
|
4
|
+
|
|
5
|
+
import JASSUB from 'jassub'
|
|
6
|
+
import { parse, stringify } from 'ass-compiler'
|
|
7
|
+
|
|
8
|
+
export type SubtitleStream = { streamIndex: number, title: string, language: string }
|
|
9
|
+
|
|
10
|
+
/** -1 turns subtitles off. It frees the track and matches no header, so nothing is set. */
|
|
11
|
+
export const SUBTITLES_OFF = -1
|
|
12
|
+
|
|
13
|
+
type SubtitleHeaderPart = { type: 'header', streamIndex: number, content: string, eventsContent: string, parsed: ParsedASS }
|
|
14
|
+
type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent: ASS_Event }
|
|
15
|
+
|
|
16
|
+
export type SubtitleRendererOptions = {
|
|
17
|
+
video: HTMLVideoElement
|
|
18
|
+
canvas: HTMLCanvasElement
|
|
19
|
+
workerUrl: string
|
|
20
|
+
/** The SIMD build, `jassub-worker-modern.wasm`. Used wherever WebAssembly SIMD is available. */
|
|
21
|
+
wasmUrl: string
|
|
22
|
+
/**
|
|
23
|
+
* The non-SIMD build, `jassub-worker.wasm`, for Safari before 16.4 and anything else without SIMD.
|
|
24
|
+
*
|
|
25
|
+
* Not optional in practice, only in the type. jassub picks `wasmUrl ?? 'jassub-worker.wasm'` when SIMD
|
|
26
|
+
* is missing, and that bare relative name resolves against the blob: url the worker is built from,
|
|
27
|
+
* which throws. So leaving this unset does not degrade to the slower build, it fails outright.
|
|
28
|
+
*/
|
|
29
|
+
legacyWasmUrl?: string
|
|
30
|
+
/** Fallback face for `liberation sans`. Without it jassub falls back to whatever the wasm build embeds. */
|
|
31
|
+
defaultFontUrl?: string
|
|
32
|
+
onStreams?: (streams: SubtitleStream[]) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const convertTimestamp = (ms: number) => new Date(ms).toISOString().slice(11, 22)
|
|
36
|
+
|
|
37
|
+
const appendParsedStyle = (jassub: JASSUB, style: ParsedASSStyles['style'][number]) =>
|
|
38
|
+
jassub.createStyle({
|
|
39
|
+
...style,
|
|
40
|
+
treat_fontname_as_pattern: 0,
|
|
41
|
+
Blur: 0,
|
|
42
|
+
Justify: 0,
|
|
43
|
+
FontName: style.Fontname,
|
|
44
|
+
FontSize: Number(style.Fontsize),
|
|
45
|
+
PrimaryColour: Number(style.PrimaryColour),
|
|
46
|
+
SecondaryColour: Number(style.SecondaryColour),
|
|
47
|
+
OutlineColour: Number(style.OutlineColour),
|
|
48
|
+
BackColour: Number(style.BackColour),
|
|
49
|
+
Bold: Number(style.Bold),
|
|
50
|
+
Italic: Number(style.Italic),
|
|
51
|
+
Underline: Number(style.Underline),
|
|
52
|
+
StrikeOut: Number(style.StrikeOut),
|
|
53
|
+
ScaleX: Number(style.ScaleX),
|
|
54
|
+
ScaleY: Number(style.ScaleY),
|
|
55
|
+
Spacing: Number(style.Spacing),
|
|
56
|
+
Angle: Number(style.Angle),
|
|
57
|
+
BorderStyle: Number(style.BorderStyle),
|
|
58
|
+
Outline: Number(style.Outline),
|
|
59
|
+
Shadow: Number(style.Shadow),
|
|
60
|
+
Alignment: Number(style.Alignment),
|
|
61
|
+
MarginL: Number(style.MarginL),
|
|
62
|
+
MarginR: Number(style.MarginR),
|
|
63
|
+
MarginV: Number(style.MarginV),
|
|
64
|
+
Encoding: Number(style.Encoding),
|
|
65
|
+
} as Parameters<JASSUB['createStyle']>[0])
|
|
66
|
+
|
|
67
|
+
// cleared so jassub scales the script to the canvas, not to the authored resolution
|
|
68
|
+
const renderable = (content: string) => {
|
|
69
|
+
const parsed = parse(content)
|
|
70
|
+
return stringify({ ...parsed, info: { ...parsed.info, ScaledBorderAndShadow: 'no', LayoutResX: '', LayoutResY: '' } })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `\r?\n`, not `\r\n`: an ASS header muxed straight out of a matroska file uses CRLF, but one libav
|
|
75
|
+
* CONVERTED from another format (an srt track, most commonly) is LF only, and requiring CRLF rejected it.
|
|
76
|
+
*
|
|
77
|
+
* Returns null rather than throwing. A header this cannot read is a reason to have no subtitles, and it
|
|
78
|
+
* used to be a reason to have no VIDEO: the throw crossed pushFragments into startPlayback's try, so one
|
|
79
|
+
* unreadable track failed the whole file with "playback failed".
|
|
80
|
+
*/
|
|
81
|
+
const toHeaderPart = (fragment: SubtitleFragment & { type: 'header' }): SubtitleHeaderPart | null => {
|
|
82
|
+
const eventsContent = fragment.content.match(/\r?\n\[Events\]\r?\nFormat: (.*)/)?.[0]
|
|
83
|
+
if (!eventsContent) {
|
|
84
|
+
console.warn(`subtitle stream ${fragment.streamIndex} has no Events format, ignoring the track`)
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
return { type: 'header', streamIndex: fragment.streamIndex, content: fragment.content, eventsContent, parsed: parse(fragment.content) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const toDialoguePart = (header: SubtitleHeaderPart, fragment: SubtitleFragment & { type: 'dialogue' }): SubtitleDialoguePart => {
|
|
91
|
+
const [dialogueIndexString, layer] = fragment.content.split(',')
|
|
92
|
+
const dialogueIndex = Number(dialogueIndexString)
|
|
93
|
+
const start = convertTimestamp(fragment.start)
|
|
94
|
+
const end = convertTimestamp(fragment.end)
|
|
95
|
+
const rest = fragment.content.replace(`${dialogueIndex},${layer},`, '')
|
|
96
|
+
const dialogueContent = `Dialogue: ${layer},${start},${end},${rest}`
|
|
97
|
+
const event = parse(`${header.eventsContent}\r\n${dialogueContent}`).events.dialogue[0]
|
|
98
|
+
if (!event) throw new Error('dialogue event is undefined')
|
|
99
|
+
return {
|
|
100
|
+
type: 'dialogue',
|
|
101
|
+
streamIndex: fragment.streamIndex,
|
|
102
|
+
index: dialogueIndex,
|
|
103
|
+
assEvent: {
|
|
104
|
+
...event,
|
|
105
|
+
Effect: event.Effect ?? '',
|
|
106
|
+
Text: event.Text.raw,
|
|
107
|
+
Duration: (event.End - event.Start) * 1000,
|
|
108
|
+
Start: event.Start * 1000,
|
|
109
|
+
End: event.End * 1000,
|
|
110
|
+
ReadOrder: dialogueIndex,
|
|
111
|
+
_index: dialogueIndex,
|
|
112
|
+
} as ASS_Event,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type SubtitleRenderer = ReturnType<typeof createSubtitleRenderer>
|
|
117
|
+
|
|
118
|
+
export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
|
|
119
|
+
const { video, canvas, workerUrl, wasmUrl, legacyWasmUrl, defaultFontUrl } = options
|
|
120
|
+
let jassub: JASSUB | undefined
|
|
121
|
+
let attachments: [string, Uint8Array][] = []
|
|
122
|
+
const headers = new Map<number, SubtitleHeaderPart>()
|
|
123
|
+
const streams: SubtitleStream[] = []
|
|
124
|
+
const dialogues = new Map<number, Map<number, SubtitleDialoguePart>>()
|
|
125
|
+
let selected: number | undefined
|
|
126
|
+
let onStreams = options.onStreams
|
|
127
|
+
|
|
128
|
+
const tick = setInterval(() => {
|
|
129
|
+
jassub?.setCurrentTime(video.paused, video.currentTime, video.playbackRate)
|
|
130
|
+
}, 100)
|
|
131
|
+
|
|
132
|
+
const onRateChange = () => jassub?.setRate(video.playbackRate)
|
|
133
|
+
|
|
134
|
+
const bootJassub = (header: SubtitleHeaderPart) => {
|
|
135
|
+
jassub = new JASSUB({
|
|
136
|
+
onDemandRender: false,
|
|
137
|
+
video,
|
|
138
|
+
canvas,
|
|
139
|
+
subContent: renderable(header.content),
|
|
140
|
+
workerUrl,
|
|
141
|
+
modernWasmUrl: wasmUrl,
|
|
142
|
+
...legacyWasmUrl ? { wasmUrl: legacyWasmUrl } : {},
|
|
143
|
+
fonts: attachments.map(([, data]) => data),
|
|
144
|
+
availableFonts: {
|
|
145
|
+
...Object.fromEntries(attachments),
|
|
146
|
+
...(defaultFontUrl ? { 'liberation sans': defaultFontUrl } : {}),
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
// jassub 1.8.x binds setRate as the ratechange listener, so the Event becomes the rate
|
|
150
|
+
video.removeEventListener('ratechange', (jassub as unknown as { _boundSetRate: EventListener })._boundSetRate)
|
|
151
|
+
video.addEventListener('ratechange', onRateChange)
|
|
152
|
+
for (const style of header.parsed.styles.style) appendParsedStyle(jassub, style)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pushAttachments = (incoming: Attachment[]) => {
|
|
156
|
+
attachments = [...attachments, ...incoming.map((a) => [a.filename, new Uint8Array(a.data)] as [string, Uint8Array])]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const pushFragments = (fragments: SubtitleFragment[]) => {
|
|
160
|
+
for (const fragment of fragments) {
|
|
161
|
+
if (fragment.type === 'header') {
|
|
162
|
+
if (headers.has(fragment.streamIndex)) continue
|
|
163
|
+
const header = toHeaderPart(fragment)
|
|
164
|
+
if (!header) continue
|
|
165
|
+
headers.set(fragment.streamIndex, header)
|
|
166
|
+
streams.push({ streamIndex: fragment.streamIndex, title: fragment.title, language: fragment.language })
|
|
167
|
+
onStreams?.([...streams])
|
|
168
|
+
if (selected === undefined) selected = fragment.streamIndex
|
|
169
|
+
if (!jassub) bootJassub(header)
|
|
170
|
+
} else {
|
|
171
|
+
const header = headers.get(fragment.streamIndex)
|
|
172
|
+
if (!header) continue
|
|
173
|
+
let byIndex = dialogues.get(fragment.streamIndex)
|
|
174
|
+
if (!byIndex) { byIndex = new Map(); dialogues.set(fragment.streamIndex, byIndex) }
|
|
175
|
+
const part = toDialoguePart(header, fragment)
|
|
176
|
+
if (byIndex.has(part.index)) continue
|
|
177
|
+
byIndex.set(part.index, part)
|
|
178
|
+
if (selected === fragment.streamIndex) jassub?.createEvent(part.assEvent)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const selectStream = (streamIndex: number | undefined) => {
|
|
184
|
+
const next = streamIndex ?? SUBTITLES_OFF
|
|
185
|
+
if (next === selected || !jassub) return
|
|
186
|
+
selected = next
|
|
187
|
+
jassub.freeTrack()
|
|
188
|
+
const header = headers.get(next)
|
|
189
|
+
if (!header) return
|
|
190
|
+
jassub.setTrack(renderable(header.content))
|
|
191
|
+
for (const style of header.parsed.styles.style) appendParsedStyle(jassub, style)
|
|
192
|
+
for (const part of dialogues.get(next)?.values() ?? []) jassub.createEvent(part.assEvent)
|
|
193
|
+
jassub.setCurrentTime(video.paused, video.currentTime, video.playbackRate)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
pushAttachments,
|
|
198
|
+
pushFragments,
|
|
199
|
+
selectStream,
|
|
200
|
+
getStreams: () => [...streams],
|
|
201
|
+
getSelectedStream: () => selected,
|
|
202
|
+
setOnStreams: (cb: (streams: SubtitleStream[]) => void) => { onStreams = cb },
|
|
203
|
+
destroy: () => {
|
|
204
|
+
clearInterval(tick)
|
|
205
|
+
video.removeEventListener('ratechange', onRateChange)
|
|
206
|
+
jassub?.destroy()
|
|
207
|
+
jassub = undefined
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { makeThumbnailer } from 'libav-wasm'
|
|
2
|
+
|
|
3
|
+
import { terminateRemuxer } from './playback'
|
|
4
|
+
|
|
5
|
+
export type ThumbnailImage = { url: string, startTime: number, endTime: number }
|
|
6
|
+
|
|
7
|
+
export type ThumbnailGeneratorOptions = {
|
|
8
|
+
publicPath: string
|
|
9
|
+
workerUrl: string
|
|
10
|
+
length: number
|
|
11
|
+
/** The same reader playback uses, so generation shares its fetch order rather than competing. */
|
|
12
|
+
read: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
13
|
+
onThumbnails: (thumbnails: ThumbnailImage[]) => void
|
|
14
|
+
/** Seconds between thumbnails. Widened automatically on long content to stay under MAX_THUMBNAILS. */
|
|
15
|
+
interval?: number
|
|
16
|
+
width?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const INTERVAL = 5
|
|
20
|
+
// the interval widens on long content rather than paying thousands of decodes and blobs
|
|
21
|
+
const MAX_THUMBNAILS = 500
|
|
22
|
+
const WIDTH = 320
|
|
23
|
+
// avio reads up to bufferSize past the slot span, so require that margin
|
|
24
|
+
const READAHEAD = 1_000_000
|
|
25
|
+
const MAX_ATTEMPTS = 3
|
|
26
|
+
// a keyframe decode can hang without ever settling
|
|
27
|
+
const KEYFRAME_TIMEOUT = 10_000
|
|
28
|
+
|
|
29
|
+
export type ThumbnailGenerator = {
|
|
30
|
+
/** Report which byte ranges are readable. Called with no argument when the whole file is. */
|
|
31
|
+
update: (ranges?: [number, number][]) => void
|
|
32
|
+
destroy: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const createThumbnailGenerator = async (options: ThumbnailGeneratorOptions): Promise<ThumbnailGenerator> => {
|
|
36
|
+
const { publicPath, workerUrl, length, read, onThumbnails, width = WIDTH } = options
|
|
37
|
+
// a thumbnailer, not a remuxer: readKeyframe seeks backward, which an output muxer cannot follow
|
|
38
|
+
const remuxer = await makeThumbnailer({
|
|
39
|
+
publicPath,
|
|
40
|
+
workerUrl,
|
|
41
|
+
workerOptions: { type: 'module' },
|
|
42
|
+
length,
|
|
43
|
+
read,
|
|
44
|
+
})
|
|
45
|
+
// init and the index walk both throw on a file that is not readable yet, so the worker must go too
|
|
46
|
+
try {
|
|
47
|
+
const metadata = await remuxer.init()
|
|
48
|
+
const duration = metadata.duration
|
|
49
|
+
const interval = Math.max(options.interval ?? INTERVAL, duration / MAX_THUMBNAILS)
|
|
50
|
+
|
|
51
|
+
type Slot = { timestamp: number, endTime: number, startByte: number, endByte: number, done: boolean, attempts: number }
|
|
52
|
+
const slots: Slot[] = []
|
|
53
|
+
for (const [i, index] of metadata.indexes.entries()) {
|
|
54
|
+
const last = slots.at(-1)
|
|
55
|
+
if (last && index.timestamp - last.timestamp < interval) continue
|
|
56
|
+
slots.push({
|
|
57
|
+
timestamp: index.timestamp,
|
|
58
|
+
endTime: duration,
|
|
59
|
+
startByte: index.pos,
|
|
60
|
+
endByte: Math.min((metadata.indexes[i + 1]?.pos ?? length) + READAHEAD, length),
|
|
61
|
+
done: false,
|
|
62
|
+
attempts: 0,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
for (const [i, slot] of slots.entries()) slot.endTime = slots[i + 1]?.timestamp ?? duration
|
|
66
|
+
// reading the last keyframe runs the demuxer into EOF, which crashes the libav build
|
|
67
|
+
if (slots.length > 1 && (slots.at(-1)!.timestamp > duration - interval * 2)) slots.pop()
|
|
68
|
+
|
|
69
|
+
let thumbnails: ThumbnailImage[] = []
|
|
70
|
+
let destroyed = false
|
|
71
|
+
let queue = Promise.resolve()
|
|
72
|
+
|
|
73
|
+
// the slider assumes a gapless storyboard, so gaps get sentinels the UI hides
|
|
74
|
+
const emit = () => {
|
|
75
|
+
const display: ThumbnailImage[] = []
|
|
76
|
+
for (const [i, t] of thumbnails.entries()) {
|
|
77
|
+
if (t.startTime - (display.at(-1)?.endTime ?? 0) > 0.01) {
|
|
78
|
+
display.push({ url: '', startTime: display.at(-1)?.endTime ?? 0, endTime: t.startTime })
|
|
79
|
+
}
|
|
80
|
+
display.push(t)
|
|
81
|
+
const next = thumbnails[i + 1]
|
|
82
|
+
if (next && next.startTime - t.endTime > 0.01) {
|
|
83
|
+
display.push({ url: '', startTime: t.endTime, endTime: next.startTime })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const tailEnd = display.at(-1)?.endTime ?? 0
|
|
87
|
+
if (duration - tailEnd > 0.01) display.push({ url: '', startTime: tailEnd, endTime: duration })
|
|
88
|
+
onThumbnails(display)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const generate = (slot: Slot) => {
|
|
92
|
+
slot.done = true
|
|
93
|
+
queue = queue
|
|
94
|
+
.then(async () => {
|
|
95
|
+
if (destroyed) return
|
|
96
|
+
const png = await Promise.race([
|
|
97
|
+
remuxer.readKeyframe(slot.timestamp),
|
|
98
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), KEYFRAME_TIMEOUT)),
|
|
99
|
+
])
|
|
100
|
+
const bitmap = await createImageBitmap(new Blob([png], { type: 'image/png' }))
|
|
101
|
+
const canvas = new OffscreenCanvas(width, Math.max(1, Math.round(bitmap.height * (width / bitmap.width))))
|
|
102
|
+
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
103
|
+
bitmap.close()
|
|
104
|
+
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.7 })
|
|
105
|
+
if (destroyed) return
|
|
106
|
+
thumbnails = [...thumbnails, { url: URL.createObjectURL(blob), startTime: slot.timestamp, endTime: slot.endTime }]
|
|
107
|
+
.sort((a, b) => a.startTime - b.startTime)
|
|
108
|
+
emit()
|
|
109
|
+
})
|
|
110
|
+
.catch(() => {
|
|
111
|
+
slot.attempts += 1
|
|
112
|
+
slot.done = slot.attempts >= MAX_ATTEMPTS
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
emit()
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
update: (ranges) => {
|
|
120
|
+
if (destroyed) return
|
|
121
|
+
for (const slot of slots) {
|
|
122
|
+
if (slot.done) continue
|
|
123
|
+
if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to)) generate(slot)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
destroy: () => {
|
|
127
|
+
destroyed = true
|
|
128
|
+
for (const t of thumbnails) URL.revokeObjectURL(t.url)
|
|
129
|
+
thumbnails = []
|
|
130
|
+
terminateRemuxer(remuxer)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
terminateRemuxer(remuxer)
|
|
135
|
+
throw error
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library ships an inline SVG asset, so it declares the module shape itself rather than relying
|
|
3
|
+
* on `vite/client`, which belongs to the app and is not in the library's type surface.
|
|
4
|
+
*/
|
|
5
|
+
declare module '*.svg' {
|
|
6
|
+
const src: string
|
|
7
|
+
export default src
|
|
8
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
|
|
3
|
+
export { MediaPlayer, default } from './react/video-player'
|
|
4
|
+
export type {
|
|
5
|
+
MediaPlayerOptions,
|
|
6
|
+
MediaPlayerLocalOptions,
|
|
7
|
+
MediaPlayerRemoteOptions,
|
|
8
|
+
MediaPlayerSource,
|
|
9
|
+
} from './react/video-player'
|
|
10
|
+
|
|
11
|
+
// A media the player drives but does not own, for a source whose video lives somewhere unreachable.
|
|
12
|
+
export { isDelegatedTracks, isExternalThumbnails } from './react/media'
|
|
13
|
+
export type {
|
|
14
|
+
DelegatedSelection,
|
|
15
|
+
DelegatedTracks,
|
|
16
|
+
ExternalThumbnails,
|
|
17
|
+
PlayerMedia,
|
|
18
|
+
TimeRangesLike,
|
|
19
|
+
} from './react/media'
|
|
20
|
+
|
|
21
|
+
export { Player, usePlayer } from './react/player'
|
|
22
|
+
export type { PlayerStore } from './react/player'
|
|
23
|
+
|
|
24
|
+
// Source state lives on the player store next to the built-in playback state, so `usePlayer` is the
|
|
25
|
+
// only hook the chrome needs.
|
|
26
|
+
export { sourceFeature } from './react/source-feature'
|
|
27
|
+
export type { DownloadedRange, SourceState } from './react/source-feature'
|
|
28
|
+
|
|
29
|
+
export { useSeekThumbnails } from './react/hooks/use-thumbnails'
|
|
30
|
+
export { usePictureInPicture } from './react/hooks/use-picture-in-picture'
|
|
31
|
+
|
|
32
|
+
export { inputToRemuxerInput } from './utils/source'
|
|
33
|
+
export type { RemuxerInput } from './utils/source'
|
|
34
|
+
|
|
35
|
+
// The engine is also published on its own subpath for consumers that want the pipeline with no React.
|
|
36
|
+
export type {
|
|
37
|
+
AudioStream,
|
|
38
|
+
MediaIndex,
|
|
39
|
+
PictureInPictureController,
|
|
40
|
+
PlaybackController,
|
|
41
|
+
PlaybackOptions,
|
|
42
|
+
SubtitleStream,
|
|
43
|
+
ThumbnailImage,
|
|
44
|
+
} from './engine'
|
|
45
|
+
export {
|
|
46
|
+
startPlayback,
|
|
47
|
+
createThumbnailGenerator,
|
|
48
|
+
createSubtitleRenderer,
|
|
49
|
+
createPictureInPicture,
|
|
50
|
+
SUBTITLES_OFF,
|
|
51
|
+
} from './engine'
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import type { ReactNode, Ref } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useEffect, useRef } from 'react'
|
|
5
|
+
import { css } from '@emotion/react'
|
|
6
|
+
|
|
7
|
+
import { usePlayer } from '../player'
|
|
8
|
+
import { Overlay } from './overlay'
|
|
9
|
+
import ControlBar from './control-bar'
|
|
10
|
+
|
|
11
|
+
const AUTO_HIDE_DELAY = 3_000
|
|
12
|
+
|
|
13
|
+
const style = css`
|
|
14
|
+
position: relative;
|
|
15
|
+
background-color: black;
|
|
16
|
+
display: flex;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
align-items: center;
|
|
19
|
+
height: 100%;
|
|
20
|
+
width: 100%;
|
|
21
|
+
|
|
22
|
+
& > div:not(:last-of-type) {
|
|
23
|
+
position: absolute;
|
|
24
|
+
z-index: 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
canvas {
|
|
28
|
+
height: 100%;
|
|
29
|
+
width: 100%;
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
position: absolute;
|
|
32
|
+
z-index: 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.video, video {
|
|
36
|
+
height: 100%;
|
|
37
|
+
width: 100%;
|
|
38
|
+
background-color: black;
|
|
39
|
+
object-fit: contain;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&.hide {
|
|
43
|
+
cursor: none;
|
|
44
|
+
}
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
export type ChromeProps = {
|
|
48
|
+
ref?: Ref<HTMLDivElement> | ((element: HTMLDivElement | null) => void)
|
|
49
|
+
/** Absent means render no video element: the media belongs to someone else and arrives as children. */
|
|
50
|
+
onVideoRef?: (element: HTMLVideoElement | null) => void
|
|
51
|
+
onCanvasRef: (element: HTMLCanvasElement | null) => void
|
|
52
|
+
/** Above the control bar and outside the click-to-pause region, unlike `children`. */
|
|
53
|
+
overlay?: ReactNode
|
|
54
|
+
children?: ReactNode
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, children }: ChromeProps) => {
|
|
58
|
+
const player = usePlayer()
|
|
59
|
+
const hideUI = usePlayer((state) => state.hideUI)
|
|
60
|
+
const setHideUI = usePlayer((state) => state.setHideUI)
|
|
61
|
+
const autoHide = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
62
|
+
// a tap and a click mean different things, so the last pointer kind is remembered
|
|
63
|
+
const lastPointerType = useRef<string>('mouse')
|
|
64
|
+
|
|
65
|
+
useEffect(() => () => clearTimeout(autoHide.current), [])
|
|
66
|
+
|
|
67
|
+
const reveal = () => {
|
|
68
|
+
setHideUI(false)
|
|
69
|
+
clearTimeout(autoHide.current)
|
|
70
|
+
autoHide.current = setTimeout(() => setHideUI(true), AUTO_HIDE_DELAY)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Hides after the delay whether or not playback is running. A finger produces no move, so this is
|
|
74
|
+
// the mouse path only and touch reveals on tap instead.
|
|
75
|
+
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
76
|
+
lastPointerType.current = event.pointerType
|
|
77
|
+
if (event.pointerType !== 'mouse') return
|
|
78
|
+
reveal()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
82
|
+
lastPointerType.current = event.pointerType
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// play/pause with a mouse, show or hide the controls with a finger, which has no other way back
|
|
86
|
+
const onVideoClick = () => {
|
|
87
|
+
if (lastPointerType.current === 'mouse') {
|
|
88
|
+
player.togglePaused()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (hideUI) reveal()
|
|
92
|
+
else {
|
|
93
|
+
clearTimeout(autoHide.current)
|
|
94
|
+
setHideUI(true)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// relatedTarget alone is not enough: the browser fires mouseout with a null one while the pointer
|
|
99
|
+
// still sits over the video, so a null is confirmed against the pointer's own coordinates.
|
|
100
|
+
const onMouseOut: React.DOMAttributes<HTMLDivElement>['onMouseOut'] = (ev) => {
|
|
101
|
+
const related = ev.relatedTarget as Node | null
|
|
102
|
+
if (related && ev.currentTarget.contains(related)) return
|
|
103
|
+
const { left, right, top, bottom } = ev.currentTarget.getBoundingClientRect()
|
|
104
|
+
const inside = ev.clientX >= left && ev.clientX < right && ev.clientY >= top && ev.clientY < bottom
|
|
105
|
+
if (inside) return
|
|
106
|
+
clearTimeout(autoHide.current)
|
|
107
|
+
setHideUI(true)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
css={style}
|
|
113
|
+
ref={ref}
|
|
114
|
+
onPointerMove={onPointerMove}
|
|
115
|
+
onPointerDown={onPointerDown}
|
|
116
|
+
onMouseOut={onMouseOut}
|
|
117
|
+
className={hideUI ? 'hide' : ''}
|
|
118
|
+
>
|
|
119
|
+
<Overlay onCanvasRef={onCanvasRef} />
|
|
120
|
+
{overlay}
|
|
121
|
+
<ControlBar />
|
|
122
|
+
<div className="video" onClick={onVideoClick}>
|
|
123
|
+
{onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default Chrome
|