@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,108 @@
1
+ import type { MediaIndex, ThumbnailImage } from '../engine'
2
+
3
+ import { definePlayerFeature } from '@videojs/core/dom'
4
+
5
+ /**
6
+ * One row of a track menu, already named.
7
+ *
8
+ * Labelled by whoever writes it rather than by the menu, because the two writers know different
9
+ * things: the engine has a language tag and a title to disambiguate between, while a source that
10
+ * owns its own player hands over a label it has already decided on. The id is opaque here for the
11
+ * same reason, a libav stream index one way and a site's own track id the other.
12
+ */
13
+ export type TrackChoice = {
14
+ id: string | number
15
+ label: string
16
+ }
17
+
18
+ /**
19
+ * A byte span of the file the consumer has in hand, mapped onto the timeline through the keyframe
20
+ * index, because a file's download percentage is not its playback percentage.
21
+ */
22
+ export type DownloadedRange = {
23
+ startByteOffset: number
24
+ endByteOffset: number
25
+ }
26
+
27
+ /**
28
+ * Everything about the source that the chrome reads, carried on the player store next to the built-in
29
+ * playback state so a component never has to know which of two channels owns a field.
30
+ *
31
+ * The store is the single reader surface; the React layer is the single writer, through
32
+ * `setSourceState`.
33
+ */
34
+ export type SourceState = {
35
+ /** Shown top left over the video. */
36
+ title?: string
37
+ /** Total byte length of the source. */
38
+ size?: number
39
+ downloadedRanges?: DownloadedRange[]
40
+
41
+ /** Keyframe index of the input, which turns a downloaded byte range into a time range. */
42
+ indexes: MediaIndex[]
43
+ thumbnails: ThumbnailImage[]
44
+ /**
45
+ * Answers for one time directly, when the source has a storyboard it can index but not enumerate.
46
+ * Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
47
+ */
48
+ thumbnailAt?: (time: number) => ThumbnailImage | undefined
49
+
50
+ subtitleTracks: TrackChoice[]
51
+ /** undefined means subtitles are off. */
52
+ selectedSubtitleTrack: string | number | undefined
53
+ selectSubtitleTrack: (id: string | number | undefined) => void
54
+
55
+ audioTracks: TrackChoice[]
56
+ selectedAudioTrack: string | number | undefined
57
+ selectAudioTrack: (id: string | number) => void
58
+
59
+ /** Chrome auto-hide. True means the controls, title and cursor are hidden. */
60
+ hideUI: boolean
61
+ setHideUI: (hide: boolean) => void
62
+
63
+ /** Owned here, not by `pip`: that watches the media element, and the window holds a mirror. */
64
+ togglePictureInPicture: () => void
65
+
66
+ /** Set when the pipeline fails. Cleared when it recovers. */
67
+ playbackError: unknown
68
+ /** Whether the engine has produced its first media segment. */
69
+ ready: boolean
70
+
71
+ /**
72
+ * The write seam, wired in `attach`. Only the React layer calls it.
73
+ *
74
+ * It is a no-op until the media element attaches. Every caller writes either from an engine
75
+ * callback or after `startPlayback` resolves, both of which are far later than attach, and the one
76
+ * synchronous write is a reset to the values the defaults below already hold.
77
+ */
78
+ setSourceState: (partial: Partial<SourceState>) => void
79
+ }
80
+
81
+ const initialState: SourceState = {
82
+ indexes: [],
83
+ thumbnails: [],
84
+ subtitleTracks: [],
85
+ selectedSubtitleTrack: undefined,
86
+ selectSubtitleTrack: () => {},
87
+ audioTracks: [],
88
+ selectedAudioTrack: undefined,
89
+ selectAudioTrack: () => {},
90
+ hideUI: false,
91
+ setHideUI: () => {},
92
+ togglePictureInPicture: () => {},
93
+ playbackError: null,
94
+ ready: false,
95
+ setSourceState: () => {},
96
+ }
97
+
98
+ export const sourceFeature = definePlayerFeature({
99
+ name: 'source',
100
+ state: () => ({ ...initialState }),
101
+ attach({ set }) {
102
+ set({
103
+ setSourceState: (partial) => set(partial),
104
+ // needs nothing from the pipeline, so it is answered here rather than pushed in from React
105
+ setHideUI: (hideUI) => set({ hideUI }),
106
+ })
107
+ },
108
+ })
@@ -0,0 +1,223 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { ReactNode } from 'react'
3
+ import type { DownloadedRange } from './source-feature'
4
+ import type { DelegatedTracks, ExternalThumbnails, PlayerMedia } from './media'
5
+
6
+ import { useEffect, useState } from 'react'
7
+ import { css } from '@emotion/react'
8
+ import { useContainerAttach, useMediaAttach } from '@videojs/react'
9
+
10
+ import { Player, usePlayer } from './player'
11
+ import { usePlayback } from './hooks/use-playback'
12
+ import { useSeekThumbnails } from './hooks/use-thumbnails'
13
+ import { usePictureInPicture } from './hooks/use-picture-in-picture'
14
+ import Chrome from './components/chrome'
15
+
16
+ /**
17
+ * The source, read a range at a time: the player never downloads the whole file. Both fields travel
18
+ * together, so passing one without the other is a type error.
19
+ */
20
+ export type MediaPlayerSource =
21
+ | { read: (offset: number, size: number) => Promise<ArrayBuffer>, size: number }
22
+ | { read?: undefined, size?: undefined }
23
+
24
+ /** Shared by both arms: nothing here depends on who owns the media. */
25
+ type CommonOptions = {
26
+ title?: string
27
+ autoplay?: boolean
28
+
29
+ /**
30
+ * Drawn above the control bar and outside the click-to-pause region, for whatever the app has to
31
+ * say over the video. `children` land next to the media instead, below the chrome.
32
+ */
33
+ overlay?: ReactNode
34
+
35
+ onSeek?: (fraction: number) => void
36
+ onPlaybackError?: (error: unknown) => void
37
+ }
38
+
39
+ /**
40
+ * Bytes in: the player owns the `<video>`, and libav feeds it a fragment at a time.
41
+ */
42
+ export type MediaPlayerLocalOptions =
43
+ & CommonOptions
44
+ & MediaPlayerSource
45
+ & {
46
+ /**
47
+ * Where libav's wasm is served from. BOTH `libav.wasm` and `libav-jspi.wasm` have to be there:
48
+ * libav-wasm picks between them on `WebAssembly.Suspending`, so serving one fails only on the
49
+ * browsers that pick the other.
50
+ */
51
+ publicPath: string
52
+ libavWorkerUrl: string
53
+ jassubWorkerUrl: string
54
+ /** jassub's SIMD build, `jassub-worker-modern.wasm`. */
55
+ jassubWasmUrl: string
56
+ /**
57
+ * jassub's non-SIMD build, `jassub-worker.wasm`, for Safari before 16.4.
58
+ * Without it subtitles fail outright on those browsers rather than falling back to the slower build.
59
+ */
60
+ jassubLegacyWasmUrl?: string
61
+ /** Fallback face for `liberation sans`, used when a subtitle track names a font the file does not carry. */
62
+ defaultFontUrl?: string
63
+ bufferSize?: number
64
+
65
+ /** Byte spans available, painted on the seekbar and informing the thumbnail generator. */
66
+ downloadedRanges?: DownloadedRange[]
67
+
68
+ /**
69
+ * Reader for the thumbnail engine, when it should differ from playback's.
70
+ *
71
+ * They are the same by default so generation shares playback's fetch order. A consumer whose
72
+ * reads are not free wants them apart: a torrent hands this a non-prioritising, fail-fast reader
73
+ * so generating previews cannot steal download order from the bytes playback is blocked on.
74
+ */
75
+ thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
76
+ /** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
77
+ thumbnailsEnabled?: boolean
78
+ }
79
+
80
+ /**
81
+ * A media the caller owns and the player only drives.
82
+ *
83
+ * No bytes, so no libav, no MediaSource and no thumbnail generation. For a source whose video lives
84
+ * in a document this one cannot reach into, which is also the only arrangement under which its DRM
85
+ * works: the key session belongs to whoever owns the element.
86
+ */
87
+ export type MediaPlayerRemoteOptions =
88
+ & CommonOptions
89
+ & {
90
+ media: PlayerMedia
91
+ read?: never
92
+ size?: never
93
+
94
+ /** The source's own storyboard, since there are no bytes to generate previews from. */
95
+ thumbnails?: ExternalThumbnails
96
+ /** The source renders these; the player draws the menu and reports the pick. */
97
+ subtitles?: DelegatedTracks
98
+ audioTracks?: DelegatedTracks
99
+ }
100
+
101
+ export type MediaPlayerOptions = MediaPlayerLocalOptions | MediaPlayerRemoteOptions
102
+
103
+ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, children?: ReactNode }) => {
104
+ // Narrowed on the field that carries the difference: a remote arm brings its own media and, by
105
+ // construction, no bytes. Both halves stay null-safe so the engine hooks below can be called
106
+ // unconditionally, which they have to be.
107
+ const remote = 'media' in options ? options : null
108
+ const local = remote ? null : options as MediaPlayerLocalOptions
109
+ // `title` is common to both arms, so it is read off `options`. Everything else here belongs to the
110
+ // local arm and is absent when the media is remote.
111
+ const { title } = options
112
+ const {
113
+ size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled,
114
+ } = local ?? ({} as Partial<MediaPlayerLocalOptions>)
115
+
116
+ const setMedia = useMediaAttach()
117
+ // Mandatory, not an optimisation: without it requestFullscreen falls through to the bare <video>
118
+ // and leaves the whole chrome outside the fullscreen layer.
119
+ const setContainer = useContainerAttach()
120
+
121
+ const [video, setVideo] = useState<HTMLVideoElement | null>(null)
122
+ const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null)
123
+
124
+ // Attaching is not optional in either arm: the store installs `setSourceState` in `attach`, and
125
+ // video.js only runs attach once media is non-null, so skipping it would leave every write below a
126
+ // permanent no-op.
127
+ const media = remote?.media ?? video
128
+ useEffect(() => { setMedia?.(media); return () => setMedia?.(null) }, [media, setMedia])
129
+
130
+ // Each of these no-ops on null inputs, which is what a remote arm supplies: it renders no <video>,
131
+ // so there is nothing for them to attach to and nothing to guard at the call site.
132
+ usePlayback(video, canvas, local)
133
+
134
+ const generatedThumbnails = useSeekThumbnails({
135
+ publicPath,
136
+ workerUrl: libavWorkerUrl,
137
+ length: thumbnailsEnabled === false ? undefined : size,
138
+ read: thumbnailRead ?? read,
139
+ downloadedRanges,
140
+ })
141
+ const thumbnails = remote?.thumbnails?.all ?? generatedThumbnails
142
+ const togglePictureInPicture = usePictureInPicture(video, canvas)
143
+
144
+ // Subscribed rather than read off the store, because it is a no-op until the media element
145
+ // attaches: when attach swaps in the real setter the identity changes and these publish again.
146
+ const setSourceState = usePlayer((state) => state.setSourceState)
147
+
148
+ useEffect(() => {
149
+ setSourceState({ title, size, downloadedRanges })
150
+ }, [setSourceState, title, size, downloadedRanges])
151
+
152
+ const thumbnailAt = remote?.thumbnails?.at
153
+ useEffect(() => {
154
+ setSourceState({ thumbnails, thumbnailAt, togglePictureInPicture })
155
+ }, [setSourceState, thumbnails, thumbnailAt, togglePictureInPicture])
156
+
157
+ // A delegated track list writes the same store fields the engine writes, so the menus never learn
158
+ // which arm they are showing. Only the writer differs: here the pick is forwarded to whoever owns
159
+ // the document, and it renders the result itself.
160
+ const subtitles = remote?.subtitles?.selection
161
+ const audio = remote?.audioTracks?.selection
162
+ useEffect(() => {
163
+ if (!subtitles) return
164
+ setSourceState({
165
+ subtitleTracks: subtitles.options.map(({ id, label }) => ({ id, label })),
166
+ selectedSubtitleTrack: subtitles.selectedId ?? undefined,
167
+ selectSubtitleTrack: (id) => subtitles.select(id == null ? null : String(id)),
168
+ })
169
+ }, [setSourceState, subtitles])
170
+
171
+ useEffect(() => {
172
+ if (!audio) return
173
+ setSourceState({
174
+ audioTracks: audio.options.map(({ id, label }) => ({ id, label })),
175
+ selectedAudioTrack: audio.selectedId ?? undefined,
176
+ selectAudioTrack: (id) => audio.select(String(id)),
177
+ })
178
+ }, [setSourceState, audio])
179
+
180
+ return (
181
+ <Chrome
182
+ ref={setContainer}
183
+ // No element in the remote arm: the media is somebody else's, and whatever renders it is
184
+ // passed in as children. Rendering an idle <video> here would sit over it.
185
+ onVideoRef={remote ? undefined : setVideo}
186
+ onCanvasRef={setCanvas}
187
+ overlay={options.overlay}
188
+ >
189
+ {children}
190
+ </Chrome>
191
+ )
192
+ }
193
+
194
+ // #111 against the black inside is deliberate: this is the letterbox around the player box.
195
+ const rootStyle = css`
196
+ /**
197
+ * The chrome's whole scale, in one place.
198
+ *
199
+ * Everything inside is sized against this rather than against \`rem\`, because \`rem\` is root
200
+ * relative and a library cannot own the host page's root font. The old contract was that the host
201
+ * set \`html { font-size: 62.5% }\`, which silently rendered every control 1.6x too large in any app
202
+ * that did not, and could not be met by an app whose own screens are sized against the default.
203
+ * Override it on the player element to rescale the whole chrome.
204
+ */
205
+ --mp-unit: 10px;
206
+
207
+ display: flex;
208
+ justify-content: center;
209
+ background-color: #111;
210
+ height: 100%;
211
+ width: 100%;
212
+ overflow: hidden;
213
+ `
214
+
215
+ export const MediaPlayer = (options: MediaPlayerOptions & { children?: ReactNode }) => (
216
+ <div css={rootStyle}>
217
+ <Player.Provider>
218
+ <PlayerRoot options={options}>{options.children}</PlayerRoot>
219
+ </Player.Provider>
220
+ </div>
221
+ )
222
+
223
+ export default MediaPlayer
@@ -0,0 +1,160 @@
1
+ export const fonts = {
2
+ headings: {
3
+ large: `
4
+ font-weight: 600;
5
+ font-size: calc(2.8 * var(--mp-unit));
6
+ line-height: calc(3.4 * var(--mp-unit));
7
+ @media (min-width: 960px) {
8
+ font-size: calc(3.4 * var(--mp-unit));
9
+ line-height: calc(4.1 * var(--mp-unit));
10
+ }
11
+ `,
12
+ medium: `
13
+ font-weight: 600;
14
+ font-size: calc(2.4 * var(--mp-unit));
15
+ line-height: calc(2.9 * var(--mp-unit));
16
+ @media (min-width: 960px) {
17
+ font-size: calc(2.8 * var(--mp-unit));
18
+ line-height: calc(3.4 * var(--mp-unit));
19
+ }
20
+ `,
21
+ small: `
22
+ font-weight: 500;
23
+ font-size: calc(1.8 * var(--mp-unit));
24
+ line-height: calc(1.9 * var(--mp-unit));
25
+ @media (min-width: 960px) {
26
+ font-size: calc(1.8 * var(--mp-unit));
27
+ line-height: calc(2.2 * var(--mp-unit));
28
+ }
29
+ @media (min-width: 2560px) {
30
+ font-size: calc(2.2 * var(--mp-unit));
31
+ line-height: calc(2.6 * var(--mp-unit));
32
+ }
33
+ `,
34
+ extraSmall: `
35
+ font-weight: 500;
36
+ font-size: calc(1.4 * var(--mp-unit));
37
+ line-height: calc(1.7 * var(--mp-unit));
38
+ @media (min-width: 960px) {
39
+ font-size: calc(1.6 * var(--mp-unit));
40
+ line-height: calc(1.9 * var(--mp-unit));
41
+ }
42
+ `
43
+ },
44
+ bLarge: {
45
+ bold: `
46
+ font-weight: 600;
47
+ font-size: calc(1.4 * var(--mp-unit));
48
+ line-height: calc(2 * var(--mp-unit));
49
+ @media (min-width: 960px) {
50
+ font-size: calc(1.6 * var(--mp-unit));
51
+ line-height: calc(2.2 * var(--mp-unit));
52
+ }
53
+ `,
54
+ medium: `
55
+ font-weight: 500;
56
+ font-size: calc(1.4 * var(--mp-unit));
57
+ line-height: calc(2 * var(--mp-unit));
58
+ @media (min-width: 960px) {
59
+ font-size: calc(1.6 * var(--mp-unit));
60
+ line-height: calc(2.2 * var(--mp-unit));
61
+ }
62
+ `,
63
+ regular: `
64
+ font-weight: 400;
65
+ font-size: calc(1.4 * var(--mp-unit));
66
+ line-height: calc(2 * var(--mp-unit));
67
+ @media (min-width: 960px) {
68
+ font-size: calc(1.6 * var(--mp-unit));
69
+ line-height: calc(2.2 * var(--mp-unit));
70
+ }
71
+ `,
72
+ },
73
+ bMedium: {
74
+ bold: `
75
+ font-weight: 600;
76
+ font-size: calc(1.2 * var(--mp-unit));
77
+ line-height: calc(1.7 * var(--mp-unit));
78
+ @media (min-width: 960px) {
79
+ font-size: calc(1.4 * var(--mp-unit));
80
+ line-height: calc(2 * var(--mp-unit));
81
+ }
82
+ `,
83
+ medium: `
84
+ font-weight: 500;
85
+ font-size: calc(1.2 * var(--mp-unit));
86
+ line-height: calc(1.7 * var(--mp-unit));
87
+ @media (min-width: 960px) {
88
+ font-size: calc(1.4 * var(--mp-unit));
89
+ line-height: calc(2 * var(--mp-unit));
90
+ }
91
+ `,
92
+ regular: `
93
+ font-weight: 400;
94
+ font-size: calc(1.2 * var(--mp-unit));
95
+ line-height: calc(1.7 * var(--mp-unit));
96
+ @media (min-width: 960px) {
97
+ font-size: calc(1.4 * var(--mp-unit));
98
+ line-height: calc(2 * var(--mp-unit));
99
+ }
100
+ `,
101
+ },
102
+ bSmall: {
103
+ bold: `
104
+ font-weight: 600;
105
+ font-size: calc(1 * var(--mp-unit));
106
+ line-height: calc(1.4 * var(--mp-unit));
107
+ @media (min-width: 960px) {
108
+ font-size: calc(1.2 * var(--mp-unit));
109
+ line-height: calc(1.7 * var(--mp-unit));
110
+ }
111
+ `,
112
+ medium: `
113
+ font-weight: 500;
114
+ font-size: calc(1 * var(--mp-unit));
115
+ line-height: calc(1.4 * var(--mp-unit));
116
+ @media (min-width: 960px) {
117
+ font-size: calc(1.2 * var(--mp-unit));
118
+ line-height: calc(1.7 * var(--mp-unit));
119
+ }
120
+ `,
121
+ regular: `
122
+ font-weight: 400;
123
+ font-size: calc(1 * var(--mp-unit));
124
+ line-height: calc(1.4 * var(--mp-unit));
125
+ @media (min-width: 960px) {
126
+ font-size: calc(1.2 * var(--mp-unit));
127
+ line-height: calc(1.7 * var(--mp-unit));
128
+ }
129
+ `,
130
+ },
131
+ bExtraSmall: {
132
+ bold: `
133
+ font-weight: 600;
134
+ font-size: calc(0.8 * var(--mp-unit));
135
+ line-height: calc(1.1 * var(--mp-unit));
136
+ @media (min-width: 960px) {
137
+ font-size: calc(1 * var(--mp-unit));
138
+ line-height: calc(1.4 * var(--mp-unit));
139
+ }
140
+ `,
141
+ medium: `
142
+ font-weight: 500;
143
+ font-size: calc(0.8 * var(--mp-unit));
144
+ line-height: calc(1.1 * var(--mp-unit));
145
+ @media (min-width: 960px) {
146
+ font-size: calc(1 * var(--mp-unit));
147
+ line-height: calc(1.4 * var(--mp-unit));
148
+ }
149
+ `,
150
+ regular: `
151
+ font-weight: 400;
152
+ font-size: calc(0.8 * var(--mp-unit));
153
+ line-height: calc(1.1 * var(--mp-unit));
154
+ @media (min-width: 960px) {
155
+ font-size: calc(1 * var(--mp-unit));
156
+ line-height: calc(1.4 * var(--mp-unit));
157
+ }
158
+ `,
159
+ },
160
+ }
@@ -0,0 +1,60 @@
1
+ const fromBlob = ({ blob, name }: { blob: Blob, name?: string }) => ({
2
+ length: blob.size,
3
+ name: name ?? (blob instanceof File ? blob.name : undefined),
4
+ read: (offset: number, size: number) => blob.slice(offset, offset + size).arrayBuffer(),
5
+ })
6
+
7
+ const probeLength = async (url: string, credentials?: RequestCredentials) => {
8
+ const response = await fetch(url, { headers: { Range: 'bytes=0-1' }, credentials })
9
+ if (!response.ok) throw new Error(`The source could not be read: HTTP ${response.status}`)
10
+ const fromRange = response.headers.get('Content-Range')?.split('/').at(1)
11
+ const length = fromRange ? Number(fromRange) : Number(response.headers.get('Content-Length'))
12
+ if (!Number.isFinite(length) || length <= 0) throw new Error('The source did not report its length')
13
+ if (!fromRange && response.status !== 206) throw new Error('The source does not support range requests')
14
+ return length
15
+ }
16
+
17
+ const fromUrl = async (
18
+ { url, length: _length, name, credentials }: {
19
+ url: string
20
+ length?: number
21
+ name?: string
22
+ credentials?: RequestCredentials
23
+ }
24
+ ) => {
25
+ const length = _length ?? await probeLength(url, credentials)
26
+ const read = async (offset: number, size: number) => {
27
+ const response = await fetch(url, {
28
+ headers: { Range: `bytes=${offset}-${Math.min(offset + size, length) - 1}` },
29
+ credentials,
30
+ })
31
+ if (!response.ok) throw new Error(`The source could not be read: HTTP ${response.status}`)
32
+ return response.arrayBuffer()
33
+ }
34
+ return { length, name, read }
35
+ }
36
+
37
+ /** A source resolved down to what the remuxer needs: a total length and a byte-range reader. */
38
+ export type RemuxerInput = {
39
+ length: number
40
+ name?: string
41
+ read: (offset: number, size: number) => Promise<ArrayBuffer>
42
+ }
43
+
44
+ export const inputToRemuxerInput = async (
45
+ params:
46
+ | Parameters<typeof fromBlob | typeof fromUrl>[0]
47
+ // the general arm. `length` is required: nothing downstream can seek or report a duration
48
+ // without it, and a caller supplying its own reader already knows the total.
49
+ | {
50
+ length: number
51
+ name?: string
52
+ read: (offset: number, size: number) => Promise<ArrayBuffer>
53
+ }
54
+ // declared rather than inferred, so all three arms are checked against one contract
55
+ ): Promise<RemuxerInput> => {
56
+ if ('blob' in params) return fromBlob(params)
57
+ if ('url' in params) return fromUrl(params)
58
+ if ('read' in params) return { length: params.length, name: params.name, read: params.read }
59
+ throw new Error(`Unknown source type: ${params}`)
60
+ }
@@ -0,0 +1,51 @@
1
+ export type LabelledTrack = {
2
+ streamIndex: number
3
+ title: string
4
+ language: string
5
+ }
6
+
7
+ /** One row of a track menu, already named. Mirrors `TrackChoice` without importing the store. */
8
+ export type NamedTrack = {
9
+ id: string | number
10
+ label: string
11
+ }
12
+
13
+ /**
14
+ * Human name for a track's language tag. `fallback: 'none'` is what makes an unknown tag come back
15
+ * undefined instead of echoing itself, so the caller can fall through to the title.
16
+ */
17
+ export const displayLanguage = (language: string | undefined): string | undefined => {
18
+ if (!language || language === 'und') return undefined
19
+ try {
20
+ return new Intl.DisplayNames([navigator.language || 'en'], { type: 'language', fallback: 'none' }).of(language) || undefined
21
+ } catch {
22
+ return undefined
23
+ }
24
+ }
25
+
26
+ const tidy = (title: string | undefined) => title?.replace(/_/g, ' ').trim() || undefined
27
+
28
+ /**
29
+ * Names a set of tracks for a menu, by language, falling back to the title then the stream index.
30
+ * The title is appended only to break a tie, because on a MultiSub release every title is the same
31
+ * source tag and titling every row gives nine identical entries.
32
+ */
33
+ export const labelTracks = <T extends LabelledTrack>(tracks: T[]): { track: T, label: string }[] => {
34
+ const bases = tracks.map((track) => displayLanguage(track.language) ?? tidy(track.title) ?? `Track ${track.streamIndex}`)
35
+ const counts = bases.reduce<Record<string, number>>((acc, base) => ({ ...acc, [base]: (acc[base] ?? 0) + 1 }), {})
36
+ return tracks.map((track, index) => {
37
+ const base = bases[index]!
38
+ const detail = tidy(track.title)
39
+ const ambiguous = (counts[base] ?? 0) > 1
40
+ return { track, label: ambiguous && detail && detail !== base ? `${base} (${detail})` : base }
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Names the engine's streams once, on the way into the store, so the menu only ever reads rows.
46
+ *
47
+ * The store's ids are opaque so a source that owns its own player can use its own track ids; the id
48
+ * here is the libav stream index, which is what this half of the code has to say.
49
+ */
50
+ export const toNamedTracks = <T extends LabelledTrack>(tracks: T[]): NamedTrack[] =>
51
+ labelTracks(tracks).map(({ track, label }) => ({ id: track.streamIndex, label }))
@@ -0,0 +1,13 @@
1
+ const MIN_VOLUME = 0.0001
2
+ const MAX_VOLUME = 1.0
3
+
4
+ /** Higher values put more of the slider's travel at the quiet end. */
5
+ export const VOLUME_EXPONENT = 2.0
6
+
7
+ /** Slider position (0-1) to gain, on a curve that matches how loudness is perceived. */
8
+ export const linearToLogVolume = (linearValue: number): number =>
9
+ Math.pow(Math.max(MIN_VOLUME, Math.min(MAX_VOLUME, linearValue)), VOLUME_EXPONENT)
10
+
11
+ /** Gain (0-1) back to slider position. */
12
+ export const logToLinearVolume = (logVolume: number): number =>
13
+ Math.pow(Math.max(MIN_VOLUME, Math.min(MAX_VOLUME, logVolume)), MAX_VOLUME / VOLUME_EXPONENT)
@@ -1,7 +0,0 @@
1
- import { ReactNode, type HTMLAttributes } from 'react';
2
- export type ChromeOptions = {
3
- mediaInformation?: ReactNode;
4
- loadingInformation?: ReactNode;
5
- } & HTMLAttributes<HTMLDivElement>;
6
- export declare const Chrome: ({ children, mediaInformation, loadingInformation }: ChromeOptions) => import("react/jsx-runtime").JSX.Element;
7
- export default Chrome;
@@ -1,6 +0,0 @@
1
- import { ReactNode, RefObject } from 'react';
2
- export declare const ControlBar: ({ mediaInformation, containerRef }: {
3
- mediaInformation?: ReactNode;
4
- containerRef: RefObject<HTMLDivElement>;
5
- }) => import("react/jsx-runtime").JSX.Element;
6
- export default ControlBar;
@@ -1,4 +0,0 @@
1
- import { ReactNode } from 'react';
2
- export declare const Overlay: ({ loadingInformation }: {
3
- loadingInformation?: ReactNode;
4
- }) => import("react/jsx-runtime").JSX.Element;
@@ -1,2 +0,0 @@
1
- declare const PlaybackSlider: () => import("react/jsx-runtime").JSX.Element;
2
- export default PlaybackSlider;
@@ -1 +0,0 @@
1
- export declare const ProgressBar: () => import("react/jsx-runtime").JSX.Element;
@@ -1,2 +0,0 @@
1
- declare const SettingsAction: () => import("react/jsx-runtime").JSX.Element;
2
- export default SettingsAction;
@@ -1,4 +0,0 @@
1
- declare const Sound: ({ ref }: {
2
- ref: any;
3
- }) => import("react/jsx-runtime").JSX.Element;
4
- export default Sound;
@@ -1,6 +0,0 @@
1
- type VolumeSliderType = {
2
- value: number;
3
- onChange: (v: number) => void;
4
- };
5
- declare const VolumeSlider: ({ value, onChange }: VolumeSliderType) => import("react/jsx-runtime").JSX.Element;
6
- export default VolumeSlider;