@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.
Files changed (123) hide show
  1. package/README.md +149 -3
  2. package/dist/engine/index.d.ts +10 -0
  3. package/dist/engine/index.js +2 -0
  4. package/dist/engine/picture-in-picture.d.ts +27 -0
  5. package/dist/engine/playback.d.ts +45 -0
  6. package/dist/engine/source-buffer.d.ts +12 -0
  7. package/dist/engine/subtitles.d.ts +36 -0
  8. package/dist/engine/thumbnails.d.ts +22 -0
  9. package/dist/engine-Dmc7JSd9.js +470 -0
  10. package/dist/index.d.ts +14 -0
  11. package/dist/index.js +1610 -0
  12. package/dist/react/components/chrome.d.ts +12 -0
  13. package/dist/react/components/control-bar.d.ts +2 -0
  14. package/dist/react/components/overlay.d.ts +4 -0
  15. package/dist/react/components/playback-slider.d.ts +2 -0
  16. package/dist/react/components/progress-bar.d.ts +2 -0
  17. package/dist/react/components/settings.d.ts +2 -0
  18. package/dist/react/components/sound.d.ts +7 -0
  19. package/{build → dist/react}/components/tooltip-display.d.ts +8 -8
  20. package/dist/react/components/volume-slider.d.ts +6 -0
  21. package/dist/react/hooks/use-drag-value.d.ts +23 -0
  22. package/dist/react/hooks/use-picture-in-picture.d.ts +2 -0
  23. package/dist/react/hooks/use-playback.d.ts +8 -0
  24. package/dist/react/hooks/use-thumbnails.d.ts +12 -0
  25. package/dist/react/media.d.ts +98 -0
  26. package/dist/react/player.d.ts +26 -0
  27. package/dist/react/source-feature.d.ts +106 -0
  28. package/dist/react/video-player.d.ts +83 -0
  29. package/dist/utils/source.d.ts +30 -0
  30. package/dist/utils/track-label.d.ts +31 -0
  31. package/dist/utils/volume-utils.d.ts +6 -0
  32. package/package.json +58 -37
  33. package/src/lib/engine/index.ts +14 -0
  34. package/src/lib/engine/picture-in-picture.ts +250 -0
  35. package/src/lib/engine/playback.ts +352 -0
  36. package/src/lib/engine/source-buffer.ts +61 -0
  37. package/src/lib/engine/subtitles.ts +210 -0
  38. package/src/lib/engine/thumbnails.ts +137 -0
  39. package/src/lib/globals.d.ts +8 -0
  40. package/src/lib/index.tsx +51 -0
  41. package/src/lib/react/components/chrome.tsx +130 -0
  42. package/src/{components → lib/react/components}/control-bar.tsx +74 -96
  43. package/src/lib/react/components/overlay.tsx +125 -0
  44. package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
  45. package/src/lib/react/components/progress-bar.tsx +341 -0
  46. package/src/lib/react/components/settings.tsx +384 -0
  47. package/src/lib/react/components/sound.tsx +133 -0
  48. package/src/{components → lib/react/components}/tooltip-display.tsx +15 -12
  49. package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
  50. package/src/lib/react/hooks/use-drag-value.ts +73 -0
  51. package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
  52. package/src/lib/react/hooks/use-playback.ts +146 -0
  53. package/src/lib/react/hooks/use-thumbnails.ts +78 -0
  54. package/src/lib/react/media.ts +124 -0
  55. package/src/lib/react/player.ts +20 -0
  56. package/src/lib/react/source-feature.ts +108 -0
  57. package/src/lib/react/video-player.tsx +223 -0
  58. package/src/lib/utils/fonts.ts +160 -0
  59. package/src/lib/utils/source.ts +60 -0
  60. package/src/lib/utils/track-label.ts +51 -0
  61. package/src/lib/utils/volume-utils.ts +13 -0
  62. package/build/components/chrome.d.ts +0 -7
  63. package/build/components/control-bar.d.ts +0 -6
  64. package/build/components/overlay.d.ts +0 -4
  65. package/build/components/playback-slider.d.ts +0 -2
  66. package/build/components/progress-bar.d.ts +0 -1
  67. package/build/components/settings.d.ts +0 -2
  68. package/build/components/sound.d.ts +0 -4
  69. package/build/components/volume-slider.d.ts +0 -6
  70. package/build/index.d.ts +0 -26
  71. package/build/index.js +0 -3416
  72. package/build/main.d.ts +0 -1
  73. package/build/state-machines/data-source.d.ts +0 -27
  74. package/build/state-machines/index.d.ts +0 -34038
  75. package/build/state-machines/media-properties.d.ts +0 -45
  76. package/build/state-machines/media-source.d.ts +0 -34
  77. package/build/state-machines/media.d.ts +0 -8558
  78. package/build/state-machines/subtitles.d.ts +0 -56
  79. package/build/state-machines/thumbnails.d.ts +0 -24
  80. package/build/state-machines/utils.d.ts +0 -26
  81. package/build/utils/actor-utils.d.ts +0 -2
  82. package/build/utils/context.d.ts +0 -14
  83. package/build/utils/index.d.ts +0 -4
  84. package/build/utils/languages.d.ts +0 -260
  85. package/build/utils/mp4box.d.ts +0 -61
  86. package/build/utils/use-local-storage.d.ts +0 -3
  87. package/build/utils/use-scrub.d.ts +0 -11
  88. package/build/utils/volume-utils.d.ts +0 -17
  89. package/build/utils/window-height.d.ts +0 -2
  90. package/src/components/chrome.tsx +0 -95
  91. package/src/components/overlay.tsx +0 -91
  92. package/src/components/progress-bar.tsx +0 -251
  93. package/src/components/settings.tsx +0 -322
  94. package/src/components/sound.tsx +0 -101
  95. package/src/index.tsx +0 -195
  96. package/src/main.tsx +0 -169
  97. package/src/state-machines/data-source.ts +0 -84
  98. package/src/state-machines/index.ts +0 -5
  99. package/src/state-machines/media-properties.ts +0 -82
  100. package/src/state-machines/media-source.ts +0 -129
  101. package/src/state-machines/media.ts +0 -255
  102. package/src/state-machines/subtitles.ts +0 -285
  103. package/src/state-machines/thumbnails.ts +0 -123
  104. package/src/state-machines/utils.ts +0 -94
  105. package/src/utils/actor-utils.ts +0 -19
  106. package/src/utils/context.ts +0 -23
  107. package/src/utils/fonts.ts +0 -160
  108. package/src/utils/index.ts +0 -229
  109. package/src/utils/languages.ts +0 -262
  110. package/src/utils/mp4box.ts +0 -74
  111. package/src/utils/use-local-storage.ts +0 -30
  112. package/src/utils/use-scrub.ts +0 -40
  113. package/src/utils/volume-utils.ts +0 -39
  114. package/src/utils/window-height.ts +0 -19
  115. package/src/vite-env.d.ts +0 -1
  116. package/tsconfig.json +0 -28
  117. package/tsconfig.node.json +0 -9
  118. /package/{build → dist}/utils/colors.d.ts +0 -0
  119. /package/{build → dist}/utils/fonts.d.ts +0 -0
  120. /package/{build → dist}/utils/time.d.ts +0 -0
  121. /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
  122. /package/src/{utils → lib/utils}/colors.ts +0 -0
  123. /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