@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
package/README.md CHANGED
@@ -1,6 +1,151 @@
1
- ## Media Player
1
+ # @banou/media-player
2
2
 
3
- A player that somehow manages to playback MKV files
3
+ A React video player for files the browser cannot open on its own. It takes a `read(offset, size)` and
4
+ a byte length, remuxes into fragmented MP4 as it plays through [libav-wasm](https://github.com/Banou26/libav-wasm),
5
+ and renders ASS/SSA subtitles with jassub. Nothing is downloaded up front, so it plays a 4 GB MKV over
6
+ HTTP range requests, out of a torrent, off a local disk, or out of anything else that can answer for a
7
+ byte range.
4
8
 
9
+ Playback state runs on [video.js v10](https://github.com/videojs/v10). None of its skin is used: the
10
+ chrome here is its own.
5
11
 
6
- Todo: add support for multi audio tracks
12
+ ## Usage
13
+
14
+ ```tsx
15
+ import MediaPlayer from '@banou/media-player'
16
+
17
+ <MediaPlayer
18
+ read={(offset, size) => Promise<ArrayBuffer>}
19
+ size={fileByteLength}
20
+ publicPath="/"
21
+ libavWorkerUrl="/libav-worker.js"
22
+ jassubWorkerUrl={jassubWorkerUrl}
23
+ jassubWasmUrl="/jassub-worker-modern.wasm"
24
+ defaultFontUrl="/default.woff2"
25
+ title="episode.mkv"
26
+ autoplay
27
+ />
28
+ ```
29
+
30
+ `read` and `size` travel together: pass both or neither. With neither, the player renders its chrome
31
+ over a black frame and waits, which is the empty state.
32
+
33
+ `inputToRemuxerInput` builds the pair from a `Blob`/`File`, a URL (probed for its length over a range
34
+ request), or your own reader:
35
+
36
+ ```ts
37
+ import { inputToRemuxerInput } from '@banou/media-player'
38
+
39
+ const source = await inputToRemuxerInput({ blob: file })
40
+ const source = await inputToRemuxerInput({ url: 'https://example.com/episode.mkv' })
41
+ const source = await inputToRemuxerInput({ length, read })
42
+ ```
43
+
44
+ `usePlayer` reads and drives playback state from anywhere inside a `MediaPlayer`. It is the only hook
45
+ the chrome uses: the built-in video.js state and this player's own source state (tracks, thumbnails,
46
+ indexes, readiness) live on one store, so there is no second context to reach for.
47
+
48
+ ```tsx
49
+ import { usePlayer } from '@banou/media-player'
50
+
51
+ const paused = usePlayer((state) => state.paused) // subscribes to that field
52
+ const player = usePlayer() // no selector: the store, no subscription
53
+ player.play()
54
+ ```
55
+
56
+ `useSeekThumbnails` and `usePictureInPicture` are exported for reuse outside the bundled chrome.
57
+
58
+ `downloadedRanges` paints byte spans you already hold onto the seekbar, mapped through the keyframe
59
+ index rather than by percentage, because a file's download progress is not its playback progress:
60
+ containers carry headers, fonts and attachments that occupy no time at all.
61
+
62
+ ### The assets your app has to serve
63
+
64
+ Nothing is bundled: the workers and the wasm are fetched at runtime from urls you provide, so they have
65
+ to be copied out of `node_modules` and hosted. `src/asset-urls.ts` is a worked example, and the
66
+ `copy-assets` script is what puts them in `public/`.
67
+
68
+ `publicPath` is the directory **libav's two wasm files** are served from, and both have to be there:
69
+
70
+ | file | from | when it is used |
71
+ | --- | --- | --- |
72
+ | `libav.wasm` | `libav-wasm/build/` | browsers without JSPI: Safari, and every browser on iOS |
73
+ | `libav-jspi.wasm` | `libav-wasm/build/` | Chrome and Edge 137+, Firefox 153+ |
74
+
75
+ libav-wasm picks between them at runtime on `typeof WebAssembly.Suspending === 'function'`, so serving
76
+ only one does not fail everywhere: it fails on exactly the browsers that pick the missing file, which
77
+ reads as a browser bug rather than a missing asset. Serve both.
78
+
79
+ The rest are named individually: `libavWorkerUrl` (`libav-wasm/build/worker.js`) and the optional
80
+ `defaultFontUrl`. jassub also has two builds, and the same warning applies:
81
+
82
+ | option | file | when it is used |
83
+ | --- | --- | --- |
84
+ | `jassubWasmUrl` | `jassub/dist/jassub-worker-modern.wasm` | wherever WebAssembly SIMD exists |
85
+ | `jassubLegacyWasmUrl` | `jassub/dist/jassub-worker.wasm` | Safari before 16.4, and anything else without SIMD |
86
+
87
+ `jassubLegacyWasmUrl` is optional in the type and not in practice: jassub falls back to a bare
88
+ `'jassub-worker.wasm'`, which it resolves against the `blob:` url its worker is built from, and that
89
+ throws. Leaving it unset does not fall back to the slower build, it loses subtitles entirely.
90
+
91
+ jassub ships a classic worker script, so wrap it:
92
+
93
+ ```ts
94
+ const jassubWorkerUrl = URL.createObjectURL(
95
+ new Blob([`importScripts("/jassub-worker.js")`], { type: 'application/javascript' }),
96
+ )
97
+ ```
98
+
99
+ The chrome is sized in `rem` against a **62.5% root font size**. Set `html { font-size: 62.5% }` or
100
+ every control renders 1.6x too large.
101
+
102
+ ## What it does
103
+
104
+ Play and pause, seek with a preview thumbnail and a keyframe-accurate scrub, volume on a log curve,
105
+ mute, playback speed, audio track selection, subtitle track selection, picture in picture, fullscreen,
106
+ and keyboard shortcuts. Nothing is persisted: volume, speed and track choices start at their defaults
107
+ every load.
108
+
109
+ ### Picture in picture keeps the subtitles
110
+
111
+ Subtitles are painted by jassub onto a canvas over the video, and picture in picture takes a video
112
+ element and nothing else, so the browser has no way to composite the two: a plain
113
+ `requestPictureInPicture()` puts the bare video in the window and leaves the subtitles on the page.
114
+
115
+ So the player composites them itself. Every presented frame is drawn to an offscreen canvas with the
116
+ subtitle canvas on top, and `captureStream()` turns that into a MediaStream backing a hidden video
117
+ element, which is the one that enters the window. The original element keeps playing and stays the
118
+ only audio source, since a canvas stream carries no audio track.
119
+
120
+ The mirror element is never paused. A paused video stops rendering its MediaStream, so pausing it to
121
+ reflect the real element froze the window: seeking while paused left the old scene on screen. The
122
+ transport state is carried by the Media Session instead, which is what the window reads for its
123
+ play/pause button.
124
+
125
+ If anything in that path is unavailable the player falls back to handing the browser the bare video,
126
+ which plays without subtitles rather than not at all.
127
+
128
+ ## Layout
129
+
130
+ One package. `src/` is the demo app, `src/lib` is the library it publishes, and the app imports it by
131
+ its published name so it stays an honest consumer.
132
+
133
+ - `src/` the app: `main.tsx`, `routes/home.tsx`.
134
+ Built by `vite.config.ts` into `build/`.
135
+ - `src/lib/engine/` the pipeline, with no React in it: MediaSource feeding, remux, jassub, thumbnails.
136
+ Published as `@banou/media-player/engine`.
137
+ - `src/lib/react/` the player component, its chrome, and the hooks.
138
+
139
+ Built by `vite.lib.config.ts` into `dist/`, which is what npm publishes.
140
+
141
+ ## Development
142
+
143
+ ```sh
144
+ npm install
145
+ npm run dev # the demo app on port 4560
146
+ npm run build # the app, into build/
147
+ npm run build-lib # the library, into dist/
148
+ ```
149
+
150
+ The app opens on an empty player: black, with the chrome and nothing else. Drop a file anywhere, click
151
+ to pick one, or paste a URL.
@@ -0,0 +1,10 @@
1
+ export { startPlayback, terminateRemuxer, DEFAULT_BUFFER_SIZE } from './playback';
2
+ export type { PlaybackOptions, PlaybackController, MediaIndex, AudioStream } from './playback';
3
+ export { createSubtitleRenderer, SUBTITLES_OFF } from './subtitles';
4
+ export type { SubtitleRenderer, SubtitleRendererOptions, SubtitleStream } from './subtitles';
5
+ export { createThumbnailGenerator } from './thumbnails';
6
+ export type { ThumbnailGenerator, ThumbnailGeneratorOptions, ThumbnailImage } from './thumbnails';
7
+ export { getTimeRanges, updateSourceBuffer } from './source-buffer';
8
+ export type { TimeRange } from './source-buffer';
9
+ export { createPictureInPicture } from './picture-in-picture';
10
+ export type { PictureInPictureController, PictureInPictureOptions } from './picture-in-picture';
@@ -0,0 +1,2 @@
1
+ import { a as e, c as t, i as n, l as r, n as i, o as a, r as o, s, t as c } from "../engine-Dmc7JSd9.js";
2
+ export { o as DEFAULT_BUFFER_SIZE, a as SUBTITLES_OFF, c as createPictureInPicture, s as createSubtitleRenderer, i as createThumbnailGenerator, t as getTimeRanges, n as startPlayback, e as terminateRemuxer, r as updateSourceBuffer };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Picture in picture with the subtitles burned in.
3
+ *
4
+ * The window takes a video element and nothing else, so subtitles painted on a canvas over the video
5
+ * cannot come along. Each frame is composited with the subtitle canvas onto an offscreen canvas, and
6
+ * `captureStream()` backs a hidden mirror element, which is the one that enters the window. The
7
+ * original element keeps playing and stays the only audio source.
8
+ */
9
+ export type PictureInPictureOptions = {
10
+ video: HTMLVideoElement;
11
+ /** The subtitle canvas. jassub sizes it to the video's content rect, so it maps 1:1. */
12
+ canvas: HTMLCanvasElement;
13
+ maxWidth?: number;
14
+ /** Where the hidden mirror is mounted. It must be in the document. Defaults to the video's parent. */
15
+ container?: HTMLElement;
16
+ /**
17
+ * Toggle used when compositing is unavailable or fails. The React layer passes the player store's
18
+ * own action, which handles Safari and exits fullscreen first; the default here does neither.
19
+ */
20
+ fallback?: () => Promise<void>;
21
+ };
22
+ export type PictureInPictureController = {
23
+ toggle: () => Promise<void>;
24
+ destroy: () => void;
25
+ };
26
+ /** Takes over the document's Media Session play/pause handlers while a session is open. */
27
+ export declare const createPictureInPicture: (options: PictureInPictureOptions) => PictureInPictureController;
@@ -0,0 +1,45 @@
1
+ import type { AudioStream } from 'libav-wasm/build/worker';
2
+ import type { SubtitleStream } from './subtitles';
3
+ export type { AudioStream };
4
+ /** A keyframe index entry: the byte offset a keyframe starts at, and the time it plays at. */
5
+ export type MediaIndex = {
6
+ pos: number;
7
+ timestamp: number;
8
+ };
9
+ export type PlaybackOptions = {
10
+ videoElement: HTMLVideoElement;
11
+ canvasElement: HTMLCanvasElement;
12
+ read: (offset: number, size: number) => Promise<ArrayBuffer>;
13
+ length: number;
14
+ /** serves BOTH `libav.wasm` and `libav-jspi.wasm`; libav-wasm picks one on `WebAssembly.Suspending` */
15
+ publicPath: string;
16
+ libavWorkerUrl: string;
17
+ jassubWorkerUrl: string;
18
+ jassubWasmUrl: string;
19
+ /** jassub's non-SIMD build; without it subtitles fail rather than degrade on Safari before 16.4 */
20
+ jassubLegacyWasmUrl?: string;
21
+ defaultFontUrl?: string;
22
+ bufferSize?: number;
23
+ audioStreamIndex?: number;
24
+ onReady?: () => void;
25
+ onError?: (error: unknown) => void;
26
+ onRecovered?: () => void;
27
+ onSeek?: (fraction: number) => void;
28
+ onSubtitleStreams?: (streams: SubtitleStream[]) => void;
29
+ onAudioStreams?: (streams: AudioStream[], selected: number) => void;
30
+ };
31
+ export type PlaybackController = {
32
+ destroy: () => void;
33
+ selectSubtitleStream: (streamIndex: number | undefined) => void;
34
+ /** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
35
+ indexes: MediaIndex[];
36
+ duration: number;
37
+ videoMimeType: string;
38
+ audioMimeType: string;
39
+ };
40
+ export declare const DEFAULT_BUFFER_SIZE = 2500000;
41
+ export declare const terminateRemuxer: (remuxer: {
42
+ worker: Worker;
43
+ destroy: () => Promise<void>;
44
+ }) => void;
45
+ export declare const startPlayback: (options: PlaybackOptions) => Promise<PlaybackController>;
@@ -0,0 +1,12 @@
1
+ export type TimeRange = {
2
+ index: number;
3
+ start: number;
4
+ end: number;
5
+ };
6
+ export declare const getTimeRanges: (sourceBuffer: SourceBuffer) => TimeRange[];
7
+ export declare const updateSourceBuffer: (sourceBuffer: SourceBuffer, mediaSource: MediaSource) => {
8
+ appendBuffer: (buffer: ArrayBuffer | Uint8Array) => Promise<void>;
9
+ unbufferRange: (start: number, end: number) => Promise<void>;
10
+ updateTimestampOffset: (timestampOffset: number) => Promise<void>;
11
+ endOfStream: () => Promise<void>;
12
+ };
@@ -0,0 +1,36 @@
1
+ import type { Attachment, SubtitleFragment } from 'libav-wasm/build/worker';
2
+ export type SubtitleStream = {
3
+ streamIndex: number;
4
+ title: string;
5
+ language: string;
6
+ };
7
+ /** -1 turns subtitles off. It frees the track and matches no header, so nothing is set. */
8
+ export declare const SUBTITLES_OFF = -1;
9
+ export type SubtitleRendererOptions = {
10
+ video: HTMLVideoElement;
11
+ canvas: HTMLCanvasElement;
12
+ workerUrl: string;
13
+ /** The SIMD build, `jassub-worker-modern.wasm`. Used wherever WebAssembly SIMD is available. */
14
+ wasmUrl: string;
15
+ /**
16
+ * The non-SIMD build, `jassub-worker.wasm`, for Safari before 16.4 and anything else without SIMD.
17
+ *
18
+ * Not optional in practice, only in the type. jassub picks `wasmUrl ?? 'jassub-worker.wasm'` when SIMD
19
+ * is missing, and that bare relative name resolves against the blob: url the worker is built from,
20
+ * which throws. So leaving this unset does not degrade to the slower build, it fails outright.
21
+ */
22
+ legacyWasmUrl?: string;
23
+ /** Fallback face for `liberation sans`. Without it jassub falls back to whatever the wasm build embeds. */
24
+ defaultFontUrl?: string;
25
+ onStreams?: (streams: SubtitleStream[]) => void;
26
+ };
27
+ export type SubtitleRenderer = ReturnType<typeof createSubtitleRenderer>;
28
+ export declare const createSubtitleRenderer: (options: SubtitleRendererOptions) => {
29
+ pushAttachments: (incoming: Attachment[]) => void;
30
+ pushFragments: (fragments: SubtitleFragment[]) => void;
31
+ selectStream: (streamIndex: number | undefined) => void;
32
+ getStreams: () => SubtitleStream[];
33
+ getSelectedStream: () => number | undefined;
34
+ setOnStreams: (cb: (streams: SubtitleStream[]) => void) => void;
35
+ destroy: () => void;
36
+ };
@@ -0,0 +1,22 @@
1
+ export type ThumbnailImage = {
2
+ url: string;
3
+ startTime: number;
4
+ endTime: number;
5
+ };
6
+ export type ThumbnailGeneratorOptions = {
7
+ publicPath: string;
8
+ workerUrl: string;
9
+ length: number;
10
+ /** The same reader playback uses, so generation shares its fetch order rather than competing. */
11
+ read: (offset: number, size: number) => Promise<ArrayBuffer>;
12
+ onThumbnails: (thumbnails: ThumbnailImage[]) => void;
13
+ /** Seconds between thumbnails. Widened automatically on long content to stay under MAX_THUMBNAILS. */
14
+ interval?: number;
15
+ width?: number;
16
+ };
17
+ export type ThumbnailGenerator = {
18
+ /** Report which byte ranges are readable. Called with no argument when the whole file is. */
19
+ update: (ranges?: [number, number][]) => void;
20
+ destroy: () => void;
21
+ };
22
+ export declare const createThumbnailGenerator: (options: ThumbnailGeneratorOptions) => Promise<ThumbnailGenerator>;