@banou/media-player 0.6.2 → 0.7.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 (119) hide show
  1. package/README.md +148 -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 +12 -0
  11. package/dist/index.js +1566 -0
  12. package/dist/react/components/chrome.d.ts +9 -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 +6 -0
  24. package/dist/react/hooks/use-thumbnails.d.ts +11 -0
  25. package/dist/react/player.d.ts +25 -0
  26. package/dist/react/source-feature.d.ts +84 -0
  27. package/dist/react/video-player.d.ts +43 -0
  28. package/dist/utils/source.d.ts +30 -0
  29. package/dist/utils/track-label.d.ts +19 -0
  30. package/dist/utils/volume-utils.d.ts +6 -0
  31. package/package.json +49 -37
  32. package/src/lib/engine/index.ts +14 -0
  33. package/src/lib/engine/picture-in-picture.ts +250 -0
  34. package/src/lib/engine/playback.ts +352 -0
  35. package/src/lib/engine/source-buffer.ts +61 -0
  36. package/src/lib/engine/subtitles.ts +210 -0
  37. package/src/lib/engine/thumbnails.ts +137 -0
  38. package/src/lib/globals.d.ts +8 -0
  39. package/src/lib/index.tsx +36 -0
  40. package/src/lib/react/components/chrome.tsx +126 -0
  41. package/src/{components → lib/react/components}/control-bar.tsx +74 -96
  42. package/src/lib/react/components/overlay.tsx +121 -0
  43. package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
  44. package/src/{components → lib/react/components}/progress-bar.tsx +128 -41
  45. package/src/lib/react/components/settings.tsx +391 -0
  46. package/src/lib/react/components/sound.tsx +133 -0
  47. package/src/{components → lib/react/components}/tooltip-display.tsx +11 -8
  48. package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
  49. package/src/lib/react/hooks/use-drag-value.ts +73 -0
  50. package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
  51. package/src/lib/react/hooks/use-playback.ts +134 -0
  52. package/src/lib/react/hooks/use-thumbnails.ts +77 -0
  53. package/src/lib/react/player.ts +20 -0
  54. package/src/lib/react/source-feature.ts +90 -0
  55. package/src/lib/react/video-player.tsx +119 -0
  56. package/src/lib/utils/source.ts +60 -0
  57. package/src/lib/utils/track-label.ts +36 -0
  58. package/src/lib/utils/volume-utils.ts +13 -0
  59. package/build/components/chrome.d.ts +0 -7
  60. package/build/components/control-bar.d.ts +0 -6
  61. package/build/components/overlay.d.ts +0 -4
  62. package/build/components/playback-slider.d.ts +0 -2
  63. package/build/components/progress-bar.d.ts +0 -1
  64. package/build/components/settings.d.ts +0 -2
  65. package/build/components/sound.d.ts +0 -4
  66. package/build/components/volume-slider.d.ts +0 -6
  67. package/build/index.d.ts +0 -26
  68. package/build/index.js +0 -3416
  69. package/build/main.d.ts +0 -1
  70. package/build/state-machines/data-source.d.ts +0 -27
  71. package/build/state-machines/index.d.ts +0 -34038
  72. package/build/state-machines/media-properties.d.ts +0 -45
  73. package/build/state-machines/media-source.d.ts +0 -34
  74. package/build/state-machines/media.d.ts +0 -8558
  75. package/build/state-machines/subtitles.d.ts +0 -56
  76. package/build/state-machines/thumbnails.d.ts +0 -24
  77. package/build/state-machines/utils.d.ts +0 -26
  78. package/build/utils/actor-utils.d.ts +0 -2
  79. package/build/utils/context.d.ts +0 -14
  80. package/build/utils/index.d.ts +0 -4
  81. package/build/utils/languages.d.ts +0 -260
  82. package/build/utils/mp4box.d.ts +0 -61
  83. package/build/utils/use-local-storage.d.ts +0 -3
  84. package/build/utils/use-scrub.d.ts +0 -11
  85. package/build/utils/volume-utils.d.ts +0 -17
  86. package/build/utils/window-height.d.ts +0 -2
  87. package/src/components/chrome.tsx +0 -95
  88. package/src/components/overlay.tsx +0 -91
  89. package/src/components/settings.tsx +0 -322
  90. package/src/components/sound.tsx +0 -101
  91. package/src/index.tsx +0 -195
  92. package/src/main.tsx +0 -169
  93. package/src/state-machines/data-source.ts +0 -84
  94. package/src/state-machines/index.ts +0 -5
  95. package/src/state-machines/media-properties.ts +0 -82
  96. package/src/state-machines/media-source.ts +0 -129
  97. package/src/state-machines/media.ts +0 -255
  98. package/src/state-machines/subtitles.ts +0 -285
  99. package/src/state-machines/thumbnails.ts +0 -123
  100. package/src/state-machines/utils.ts +0 -94
  101. package/src/utils/actor-utils.ts +0 -19
  102. package/src/utils/context.ts +0 -23
  103. package/src/utils/index.ts +0 -229
  104. package/src/utils/languages.ts +0 -262
  105. package/src/utils/mp4box.ts +0 -74
  106. package/src/utils/use-local-storage.ts +0 -30
  107. package/src/utils/use-scrub.ts +0 -40
  108. package/src/utils/volume-utils.ts +0 -39
  109. package/src/utils/window-height.ts +0 -19
  110. package/src/vite-env.d.ts +0 -1
  111. package/tsconfig.json +0 -28
  112. package/tsconfig.node.json +0 -9
  113. /package/{build → dist}/utils/colors.d.ts +0 -0
  114. /package/{build → dist}/utils/fonts.d.ts +0 -0
  115. /package/{build → dist}/utils/time.d.ts +0 -0
  116. /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
  117. /package/src/{utils → lib/utils}/colors.ts +0 -0
  118. /package/src/{utils → lib/utils}/fonts.ts +0 -0
  119. /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,36 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+
3
+ export { MediaPlayer, default } from './react/video-player'
4
+ export type { MediaPlayerOptions, MediaPlayerSource } from './react/video-player'
5
+
6
+ export { Player, usePlayer } from './react/player'
7
+ export type { PlayerStore } from './react/player'
8
+
9
+ // Source state lives on the player store next to the built-in playback state, so `usePlayer` is the
10
+ // only hook the chrome needs.
11
+ export { sourceFeature } from './react/source-feature'
12
+ export type { DownloadedRange, SourceState } from './react/source-feature'
13
+
14
+ export { useSeekThumbnails } from './react/hooks/use-thumbnails'
15
+ export { usePictureInPicture } from './react/hooks/use-picture-in-picture'
16
+
17
+ export { inputToRemuxerInput } from './utils/source'
18
+ export type { RemuxerInput } from './utils/source'
19
+
20
+ // The engine is also published on its own subpath for consumers that want the pipeline with no React.
21
+ export type {
22
+ AudioStream,
23
+ MediaIndex,
24
+ PictureInPictureController,
25
+ PlaybackController,
26
+ PlaybackOptions,
27
+ SubtitleStream,
28
+ ThumbnailImage,
29
+ } from './engine'
30
+ export {
31
+ startPlayback,
32
+ createThumbnailGenerator,
33
+ createSubtitleRenderer,
34
+ createPictureInPicture,
35
+ SUBTITLES_OFF,
36
+ } from './engine'
@@ -0,0 +1,126 @@
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
+ onVideoRef: (element: HTMLVideoElement | null) => void
50
+ onCanvasRef: (element: HTMLCanvasElement | null) => void
51
+ children?: ReactNode
52
+ }
53
+
54
+ export const Chrome = ({ ref, onVideoRef, onCanvasRef, children }: ChromeProps) => {
55
+ const player = usePlayer()
56
+ const hideUI = usePlayer((state) => state.hideUI)
57
+ const setHideUI = usePlayer((state) => state.setHideUI)
58
+ const autoHide = useRef<ReturnType<typeof setTimeout>>(undefined)
59
+ // a tap and a click mean different things, so the last pointer kind is remembered
60
+ const lastPointerType = useRef<string>('mouse')
61
+
62
+ useEffect(() => () => clearTimeout(autoHide.current), [])
63
+
64
+ const reveal = () => {
65
+ setHideUI(false)
66
+ clearTimeout(autoHide.current)
67
+ autoHide.current = setTimeout(() => setHideUI(true), AUTO_HIDE_DELAY)
68
+ }
69
+
70
+ // Hides after the delay whether or not playback is running. A finger produces no move, so this is
71
+ // the mouse path only and touch reveals on tap instead.
72
+ const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
73
+ lastPointerType.current = event.pointerType
74
+ if (event.pointerType !== 'mouse') return
75
+ reveal()
76
+ }
77
+
78
+ const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
79
+ lastPointerType.current = event.pointerType
80
+ }
81
+
82
+ // play/pause with a mouse, show or hide the controls with a finger, which has no other way back
83
+ const onVideoClick = () => {
84
+ if (lastPointerType.current === 'mouse') {
85
+ player.togglePaused()
86
+ return
87
+ }
88
+ if (hideUI) reveal()
89
+ else {
90
+ clearTimeout(autoHide.current)
91
+ setHideUI(true)
92
+ }
93
+ }
94
+
95
+ // relatedTarget alone is not enough: the browser fires mouseout with a null one while the pointer
96
+ // still sits over the video, so a null is confirmed against the pointer's own coordinates.
97
+ const onMouseOut: React.DOMAttributes<HTMLDivElement>['onMouseOut'] = (ev) => {
98
+ const related = ev.relatedTarget as Node | null
99
+ if (related && ev.currentTarget.contains(related)) return
100
+ const { left, right, top, bottom } = ev.currentTarget.getBoundingClientRect()
101
+ const inside = ev.clientX >= left && ev.clientX < right && ev.clientY >= top && ev.clientY < bottom
102
+ if (inside) return
103
+ clearTimeout(autoHide.current)
104
+ setHideUI(true)
105
+ }
106
+
107
+ return (
108
+ <div
109
+ css={style}
110
+ ref={ref}
111
+ onPointerMove={onPointerMove}
112
+ onPointerDown={onPointerDown}
113
+ onMouseOut={onMouseOut}
114
+ className={hideUI ? 'hide' : ''}
115
+ >
116
+ <Overlay onCanvasRef={onCanvasRef} />
117
+ <ControlBar />
118
+ <div className="video" onClick={onVideoClick}>
119
+ <video ref={onVideoRef} playsInline />
120
+ {children}
121
+ </div>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ export default Chrome