@greendrake/mse-video 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Greendrake
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # @greendrake/mse-video
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Live camera video in the browser over Media Source Extensions: H.265 in fragmented MP4
4
+ and the camera's G.711 audio, every stream of a page over one WebSocket, recovering on
5
+ its own from stalls and dropped sockets. A framework-free core and a thin Vue component.
6
+ The server end is `greendrake.nz/go/webcast`, whose README has the socket's protocol.
7
+
8
+ ```sh
9
+ bun add @greendrake/mse-video
10
+ ```
11
+
12
+ ## Component
13
+
14
+ `Video`, from `@greendrake/mse-video/vue` (`vue` is an optional peer, needed for this
15
+ entry only), takes props `hubUrl` (the stream hub's WebSocket URL), `streamKey` (which
16
+ stream on it, a key the server knows) and `audio` (whether the camera's sound plays). It
17
+ fills its box, stretched; a browser that cannot play the stream gets a line saying so
18
+ instead of a player. Its root carries the player's state as `data-state`, or
19
+ `unsupported`. A changed `streamKey` switches the stream in place.
20
+
21
+ The video element stays muted: the sound plays through the page's audio context, which
22
+ browsers keep silent until a user gesture. Call `unlockAudio()` from the click that turns
23
+ sound on.
24
+
25
+ ## Core
26
+
27
+ - `streamCodec` is the content type of every stream a webcast server casts, H.265 in
28
+ fMP4. `canPlay()` says whether the browser can play it, through Apple's
29
+ `ManagedMediaSource` where it exists (Safari 17+, iPhones) and `MediaSource` otherwise.
30
+ - `MSEVideo(videoEl, hubURL, key, options?)` plays one stream into a video element;
31
+ construct one only where `canPlay()` holds. `setKey` switches the stream, `setAudio`
32
+ turns the sound on or off, `close` lets go of the stream and the element. Its `state`
33
+ (`closed`, `opening`, `listening`, `playing`, `resetting`, `closing`) is emitted as
34
+ `state` on each change; `error` reports a failure it is recovering from.
35
+ - Every player pointed at one hub URL shares one socket, which goes with the last of
36
+ them. A dropped socket reconnects with exponential backoff, from `reopenDelayMs` (1 s)
37
+ to `maxReopenDelayMs` (30 s) — `MSEVideoOptions.hub`, a `StreamHubOptions`, taken from
38
+ whichever player opens the socket — and subscribes every stream again.
39
+
40
+ ## Recovery
41
+
42
+ `MSEVideoOptions`:
43
+
44
+ - Playing with no packet for `noDataTimeoutMs` (3 s), the player rebuilds its
45
+ MediaSource in place and asks the server to reset the stream, which restarts it from a
46
+ fresh initialisation segment.
47
+ - Nothing within `startTimeoutMs` (9 s) of subscribing or resetting, a subscription not
48
+ confirmed within `connectTimeoutMs` (10 s), a stream the server refuses or stops, or a
49
+ MediaSource that cannot be built: the player drops the subscription and takes out a
50
+ new one, backing off from `reopenDelayMs` (3 s) to `maxReopenDelayMs` (30 s) until a
51
+ packet arrives.
52
+ - Playback is held near the newest frame.
53
+
54
+ ## Audio
55
+
56
+ Audio arrives as frames of its own, G.711 A-law or µ-law, decoded here (no browser media
57
+ pipeline plays G.711) and scheduled against the video element's clock: each frame plays
58
+ when the video reaches it, and nothing plays while the video stands still.
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@greendrake/mse-video",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "MSE live video over WebSocket: framework-free playback core with stall watchdogs and a server reset protocol, plus a thin Vue wrapper",
5
+ "license": "MIT",
6
+ "author": "Eugene Greendrake <eugene@greendrake.nz>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/greendrake/lib.git",
10
+ "directory": "mse-video"
11
+ },
12
+ "homepage": "https://github.com/greendrake/lib/tree/main/mse-video#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/greendrake/lib/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "src/main.ts",
18
+ "types": "src/main.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/main.ts",
22
+ "default": "./src/main.ts"
23
+ },
24
+ "./vue": {
25
+ "types": "./src/vue.ts",
26
+ "default": "./src/vue.ts"
27
+ }
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "prettier": "@greendrake/dev-config/prettier",
36
+ "dependencies": {
37
+ "@greendrake/util": "^0.10.0"
38
+ },
39
+ "peerDependencies": {
40
+ "vue": "^3.5.34"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "vue": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@greendrake/dev-config": "0.8.0",
49
+ "typescript": "6.0.3",
50
+ "vue": "^3.5.34",
51
+ "vue-tsc": "^2.2.0"
52
+ },
53
+ "scripts": {
54
+ "check": "vue-tsc --noEmit"
55
+ }
6
56
  }
@@ -0,0 +1,131 @@
1
+ import { decodeG711, type G711Codec } from './g711'
2
+
3
+ // One frame of a camera's audio, as the stream hub delivers it.
4
+ export interface AudioFrame {
5
+ // When the frame is due on the timeline of the video it belongs to.
6
+ ptsMs: number
7
+ sampleRate: number
8
+ codec: G711Codec
9
+ samples: Uint8Array
10
+ }
11
+
12
+ // What the player needs of the video element showing the stream: its clock, and the
13
+ // events that say whether that clock is running.
14
+ export type AudioClock = EventTarget & Pick<HTMLVideoElement, 'currentTime' | 'paused' | 'seeking' | 'readyState'>
15
+
16
+ // Where the player's samples go: a clock, and the scheduling of samples on it.
17
+ export interface AudioOutput {
18
+ readonly currentTime: number
19
+ // Plays the samples at the given time on the output's clock, calling ended once
20
+ // they have played or been stopped. Returns what stops them.
21
+ schedule(samples: Float32Array<ArrayBuffer>, sampleRate: number, at: number, ended: () => void): () => void
22
+ }
23
+
24
+ // How far the next frame may be due from where the last one ends and still follow it
25
+ // without a gap. Beyond it the frame is put where the video says, at the cost of a gap
26
+ // or of what was queued.
27
+ const TOLERANCE_S = 0.06
28
+
29
+ // The video stops showing what is due on its clock on these.
30
+ const HALTS = ['waiting', 'pause', 'seeking', 'emptied'] as const
31
+
32
+ // HTMLMediaElement.HAVE_FUTURE_DATA: the video has what it needs to keep playing.
33
+ const HAVE_FUTURE_DATA = 3
34
+
35
+ let context: AudioContext | undefined
36
+
37
+ // The page's one audio context.
38
+ const audioContext = (): AudioContext => {
39
+ context ??= new AudioContext()
40
+ return context
41
+ }
42
+
43
+ // Browsers keep an audio context silent until a user gesture lets it play, and some
44
+ // only during the gesture itself: call this from the handler of the click that turns
45
+ // sound on.
46
+ export const unlockAudio = (): void => {
47
+ void audioContext().resume()
48
+ }
49
+
50
+ const contextOutput = (context: AudioContext): AudioOutput => ({
51
+ get currentTime() {
52
+ return context.currentTime
53
+ },
54
+ schedule(samples, sampleRate, at, ended) {
55
+ const buffer = context.createBuffer(1, samples.length, sampleRate)
56
+ buffer.copyToChannel(samples, 0)
57
+ const source = context.createBufferSource()
58
+ source.buffer = buffer
59
+ source.connect(context.destination)
60
+ source.addEventListener('ended', ended)
61
+ source.start(at)
62
+ return () => source.stop()
63
+ }
64
+ })
65
+
66
+ // Plays a stream's audio in step with the video element showing it. The video's clock
67
+ // is the master: each frame is scheduled for when the video will reach it, frames
68
+ // follow each other back to back while they stay within reach of that, and nothing
69
+ // plays while the video stands still.
70
+ export class AudioPlayer {
71
+ readonly #video: AudioClock
72
+ readonly #output: AudioOutput
73
+ readonly #abort = new AbortController()
74
+ #running: boolean
75
+ // Output time at which the last scheduled frame ends.
76
+ #queuedUntil = 0
77
+ // What stops each scheduled frame that has yet to end.
78
+ readonly #queued = new Set<() => void>()
79
+
80
+ constructor(video: AudioClock, output: AudioOutput = contextOutput(audioContext())) {
81
+ this.#video = video
82
+ this.#output = output
83
+ this.#running = !video.paused && !video.seeking && video.readyState >= HAVE_FUTURE_DATA
84
+ const signal = this.#abort.signal
85
+ video.addEventListener('playing', () => (this.#running = true), { signal })
86
+ for (const event of HALTS) {
87
+ video.addEventListener(
88
+ event,
89
+ () => {
90
+ this.#running = false
91
+ this.flush()
92
+ },
93
+ { signal }
94
+ )
95
+ }
96
+ }
97
+
98
+ push(frame: AudioFrame): void {
99
+ if (!this.#running) {
100
+ return
101
+ }
102
+ const now = this.#output.currentTime
103
+ const due = now + frame.ptsMs / 1000 - this.#video.currentTime
104
+ if (due < now) {
105
+ return
106
+ }
107
+ let at = this.#queuedUntil
108
+ if (Math.abs(due - at) > TOLERANCE_S) {
109
+ if (due < at) {
110
+ // The video has moved on from what is queued.
111
+ this.flush()
112
+ }
113
+ at = due
114
+ }
115
+ const stop = this.#output.schedule(decodeG711(frame.codec, frame.samples), frame.sampleRate, at, () => this.#queued.delete(stop))
116
+ this.#queued.add(stop)
117
+ this.#queuedUntil = at + frame.samples.length / frame.sampleRate
118
+ }
119
+
120
+ // Silences whatever is queued.
121
+ flush(): void {
122
+ this.#queued.forEach(stop => stop())
123
+ this.#queued.clear()
124
+ this.#queuedUntil = 0
125
+ }
126
+
127
+ close(): void {
128
+ this.#abort.abort()
129
+ this.flush()
130
+ }
131
+ }
package/src/Backoff.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Exponential backoff for retry loops. The first delay after a reset is zero, so a
2
+ // one-off failure recovers immediately; each further failure without a reset doubles
3
+ // the wait up to a cap.
4
+ export class Backoff {
5
+ readonly #baseMs: number
6
+ readonly #maxMs: number
7
+ #delayMs = 0
8
+
9
+ constructor(baseMs: number, maxMs: number) {
10
+ this.#baseMs = baseMs
11
+ this.#maxMs = maxMs
12
+ }
13
+
14
+ // How long to wait before the attempt being made now, advancing the sequence.
15
+ next(): number {
16
+ const delayMs = this.#delayMs
17
+ this.#delayMs = delayMs === 0 ? this.#baseMs : Math.min(delayMs * 2, this.#maxMs)
18
+ return delayMs
19
+ }
20
+
21
+ reset(): void {
22
+ this.#delayMs = 0
23
+ }
24
+ }
@@ -0,0 +1,365 @@
1
+ import { Emitter } from '@greendrake/util'
2
+ import { AudioPlayer } from './AudioPlayer'
3
+ import { acquireStreamHub, type StreamHub, type StreamHubOptions, type StreamSubscription } from './StreamHub'
4
+ import { Backoff } from './Backoff'
5
+ import { canPlay, mediaSourceOf, streamCodec } from './codec'
6
+ import { followLiveEdge } from './liveEdge'
7
+
8
+ export type MSEVideoState = 'closed' | 'opening' | 'listening' | 'playing' | 'resetting' | 'closing'
9
+
10
+ export interface MSEVideoEvents {
11
+ // Fired on every state transition.
12
+ state: [state: MSEVideoState]
13
+ // Fired when a recoverable failure triggers a reset or reopen; the player
14
+ // recovers by itself, so this is diagnostic, not fatal.
15
+ error: [reason: string]
16
+ [key: string]: unknown[]
17
+ }
18
+
19
+ export interface MSEVideoOptions {
20
+ // The shared socket's, taken from whichever player opens it: every player on
21
+ // the page pointed at one hub URL shares that one socket.
22
+ hub?: StreamHubOptions
23
+ // Base delay before rebuilding the pipeline, doubling on each reopen that brings
24
+ // no data and capping at maxReopenDelayMs. The first reopen after a packet has
25
+ // arrived is immediate.
26
+ reopenDelayMs?: number
27
+ maxReopenDelayMs?: number
28
+ // While playing: max silence between appended packets before an in-place reset.
29
+ noDataTimeoutMs?: number
30
+ // After subscribing or resetting: max wait for the first packet before a full reopen.
31
+ startTimeoutMs?: number
32
+ // Max wait for the hub to confirm the subscription, which covers the shared socket
33
+ // connecting or reconnecting underneath it.
34
+ connectTimeoutMs?: number
35
+ }
36
+
37
+ const DEFAULT_REOPEN_DELAY_MS = 3000
38
+ const DEFAULT_MAX_REOPEN_DELAY_MS = 30000
39
+ const DEFAULT_NO_DATA_TIMEOUT_MS = 3000
40
+ const DEFAULT_START_TIMEOUT_MS = 9000
41
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10000
42
+
43
+ const concatArrayBuffers = (...bufs: ArrayBuffer[]): ArrayBuffer => {
44
+ const result = new Uint8Array(bufs.reduce((totalSize, buf) => totalSize + buf.byteLength, 0))
45
+ bufs.reduce((offset, buf) => {
46
+ result.set(new Uint8Array(buf), offset)
47
+ return offset + buf.byteLength
48
+ }, 0)
49
+ return result.buffer
50
+ }
51
+
52
+ // MSE live playback engine, fed by one stream of a StreamHub's shared socket.
53
+ //
54
+ // The server streams an fMP4 initialization segment followed by media segments;
55
+ // each payload the hub routes here is appended to a single SourceBuffer.
56
+ //
57
+ // On Safari the source is a ManagedMediaSource, which leaves buffer eviction to
58
+ // the user agent. Its startstreaming/endstreaming throttling hints are not
59
+ // honoured: this is a live feed with no seekable past, so there is nothing to
60
+ // prefetch and nothing to defer.
61
+ //
62
+ // Reset protocol (application-level): when playback stalls (no packet appended
63
+ // within noDataTimeoutMs while playing), the player keeps the subscription but
64
+ // tears down and rebuilds the MediaSource + SourceBuffer, then asks the server to
65
+ // reset. The server acknowledges with a zero-length payload and restarts the
66
+ // stream from a fresh initialization segment; on receiving the ack while
67
+ // 'resetting' the player returns to 'listening' and the next payload resumes
68
+ // playback. If nothing arrives within startTimeoutMs, the player falls back to a
69
+ // full reopen: drop the subscription and take out a new one.
70
+ //
71
+ // Every other failure is a full reopen too: a subscription the hub does not
72
+ // confirm within connectTimeoutMs, a stream the server refuses or stops serving,
73
+ // a MediaSource or SourceBuffer that cannot be built. Consecutive reopens back off
74
+ // exponentially, and the first packet to arrive clears the backoff.
75
+ //
76
+ // A socket that drops is the hub's business, not this class's: it reconnects and
77
+ // re-subscribes, and the 'subscribed' event that follows rebuilds the pipeline here
78
+ // around the fresh initialization segment the server starts with.
79
+ //
80
+ // Playback is kept close to the newest frame by followLiveEdge.
81
+ //
82
+ // Audio, when on, arrives as frames of its own and plays through an AudioPlayer slaved
83
+ // to the video element's clock.
84
+ export class MSEVideo extends Emitter<MSEVideoEvents> {
85
+ readonly #videoEl: HTMLVideoElement
86
+ // Safari is the only engine that attaches a media source object directly,
87
+ // and the only one with ManagedMediaSource, so one flag selects both. Read
88
+ // as a player is made rather than as the module loads, which may be where
89
+ // there is no window.
90
+ readonly #managed = Boolean(window.ManagedMediaSource)
91
+ readonly #backoff: Backoff
92
+ readonly #noDataTimeoutMs: number
93
+ readonly #startTimeoutMs: number
94
+ readonly #connectTimeoutMs: number
95
+ readonly #hub: StreamHub
96
+ // Removes the listeners this player puts on the video element.
97
+ readonly #elementAbort = new AbortController()
98
+ #key: string
99
+ #subscription?: StreamSubscription
100
+ #state: MSEVideoState = 'closed'
101
+ #mse?: MediaSource
102
+ #sourceBuffer?: SourceBuffer
103
+ #bufferAbort?: AbortController
104
+ #pendingBuffer?: ArrayBuffer
105
+ #watchdog?: ReturnType<typeof setTimeout>
106
+ #reopenTimer?: ReturnType<typeof setTimeout>
107
+ // Exists while the audio is on.
108
+ #audio?: AudioPlayer
109
+
110
+ // Construct one only in a browser canPlay says can play the stream.
111
+ constructor(videoEl: HTMLVideoElement, hubURL: string, key: string, options: MSEVideoOptions = {}) {
112
+ super()
113
+ // Before anything that has to be handed back, the hub reference in particular.
114
+ if (!canPlay()) {
115
+ throw new Error(`Unsupported codec: ${streamCodec}`)
116
+ }
117
+ this.#videoEl = videoEl
118
+ this.#hub = acquireStreamHub(hubURL, options.hub)
119
+ this.#key = key
120
+ this.#backoff = new Backoff(
121
+ options.reopenDelayMs ?? DEFAULT_REOPEN_DELAY_MS,
122
+ options.maxReopenDelayMs ?? DEFAULT_MAX_REOPEN_DELAY_MS
123
+ )
124
+ this.#noDataTimeoutMs = options.noDataTimeoutMs ?? DEFAULT_NO_DATA_TIMEOUT_MS
125
+ this.#startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS
126
+ this.#connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
127
+ // A MediaSource cannot be handed to an AirPlay receiver, so WebKit keeps
128
+ // a ManagedMediaSource inert — sourceopen never fires — unless the element
129
+ // either opts out of remote playback or offers an HLS <source> alternative.
130
+ // Must be set before any src assignment.
131
+ videoEl.disableRemotePlayback = true
132
+ const signal = this.#elementAbort.signal
133
+ // play() is interrupted by the load() a reset/close performs; that
134
+ // rejection is part of normal teardown, not a failure to surface.
135
+ videoEl.addEventListener('loadeddata', () => void videoEl.play().catch(() => undefined), { signal })
136
+ videoEl.addEventListener('timeupdate', () => followLiveEdge(videoEl), { signal })
137
+ videoEl.addEventListener('progress', () => followLiveEdge(videoEl), { signal })
138
+ this.#open()
139
+ }
140
+
141
+ get state(): MSEVideoState {
142
+ return this.#state
143
+ }
144
+
145
+ setKey(key: string): void {
146
+ this.#key = key
147
+ // A different stream is a fresh start, not a retry of the failing one.
148
+ this.#backoff.reset()
149
+ this.#restart(0)
150
+ }
151
+
152
+ // Turns the stream's audio on or off. Call unlockAudio from the user gesture that
153
+ // turns it on, or the browser may keep it silent.
154
+ setAudio(on: boolean): void {
155
+ if (on === Boolean(this.#audio)) {
156
+ return
157
+ }
158
+ if (on) {
159
+ this.#audio = new AudioPlayer(this.#videoEl)
160
+ } else {
161
+ this.#audio!.close()
162
+ this.#audio = undefined
163
+ }
164
+ this.#subscription?.setAudio(on)
165
+ }
166
+
167
+ close(): void {
168
+ this.#elementAbort.abort()
169
+ this.#audio?.close()
170
+ this.#audio = undefined
171
+ this.#teardown()
172
+ this.#hub.release()
173
+ }
174
+
175
+ #teardown(): void {
176
+ if (this.#reopenTimer) {
177
+ clearTimeout(this.#reopenTimer)
178
+ this.#reopenTimer = undefined
179
+ }
180
+ this.#clearWatchdog()
181
+ this.#setState('closing')
182
+ this.#closeVideo()
183
+ this.#subscription?.close()
184
+ this.#subscription = undefined
185
+ this.#setState('closed')
186
+ }
187
+
188
+ #setState(state: MSEVideoState): void {
189
+ if (this.#state === state) {
190
+ return
191
+ }
192
+ this.#state = state
193
+ if (state === 'opening') {
194
+ this.#armWatchdog(this.#connectTimeoutMs)
195
+ } else if (state === 'listening' || state === 'resetting') {
196
+ this.#armWatchdog(this.#startTimeoutMs)
197
+ }
198
+ this.emit('state', state)
199
+ }
200
+
201
+ // The MediaSource is built only once the subscription is live, because that is when
202
+ // the server starts a fresh initialization segment — and it does so again on every
203
+ // re-subscribe the hub performs after a socket reconnect.
204
+ #open(): void {
205
+ this.#setState('opening')
206
+ const subscription = this.#hub.subscribe(this.#key)
207
+ this.#subscription = subscription
208
+ subscription.setAudio(Boolean(this.#audio))
209
+ subscription.on('subscribed', () => this.#onSubscribed())
210
+ subscription.on('packet', payload => this.#readPacket(payload))
211
+ subscription.on('audio', frame => {
212
+ // While resetting, what arrives belongs to the timeline being discarded.
213
+ if (this.#state !== 'resetting') {
214
+ this.#audio?.push(frame)
215
+ }
216
+ })
217
+ subscription.on('failed', reason => this.#reopen(reason))
218
+ }
219
+
220
+ #onSubscribed(): void {
221
+ this.#closeVideo()
222
+ this.#setState('opening')
223
+ this.#armWatchdog(this.#startTimeoutMs)
224
+ this.#setUp()
225
+ }
226
+
227
+ #setUp(): void {
228
+ this.#mse = new (mediaSourceOf(window)!)()
229
+ if (this.#managed) {
230
+ this.#videoEl.srcObject = this.#mse
231
+ } else {
232
+ this.#videoEl.src = URL.createObjectURL(this.#mse)
233
+ this.#videoEl.srcObject = null
234
+ }
235
+ this.#mse.addEventListener('sourceopen', () => this.#onSourceOpen(), { once: true })
236
+ }
237
+
238
+ #onSourceOpen(): void {
239
+ if (!this.#managed) {
240
+ URL.revokeObjectURL(this.#videoEl.src)
241
+ }
242
+ try {
243
+ this.#sourceBuffer = this.#mse!.addSourceBuffer(streamCodec)
244
+ } catch {
245
+ this.#reopen('Could not create SourceBuffer')
246
+ return
247
+ }
248
+ this.#sourceBuffer.mode = 'segments'
249
+ this.#bufferAbort = new AbortController()
250
+ this.#sourceBuffer.addEventListener('updateend', () => this.#eatPacket(), { signal: this.#bufferAbort.signal })
251
+ if (this.#state === 'opening') {
252
+ this.#setState('listening')
253
+ // Whatever arrived while the pipeline was being built, initialization
254
+ // segment included.
255
+ this.#eatPacket()
256
+ } else if (this.#state === 'resetting') {
257
+ this.#subscription!.reset()
258
+ } else {
259
+ throw new Error(`Invalid state to set up: ${this.#state}`)
260
+ }
261
+ }
262
+
263
+ #readPacket(packet: ArrayBuffer): void {
264
+ // The server is alive and talking, so the next failure starts a fresh backoff.
265
+ this.#backoff.reset()
266
+ if (this.#state === 'resetting') {
267
+ if (packet.byteLength === 0) {
268
+ // The server's reset acknowledgement.
269
+ this.#setState('listening')
270
+ }
271
+ // Anything ahead of it belongs to the stream being discarded.
272
+ return
273
+ }
274
+ // The subscription goes live a turn of the event loop before the SourceBuffer
275
+ // it feeds exists, so packets accumulate whether or not there is one yet.
276
+ this.#pendingBuffer = this.#pendingBuffer ? concatArrayBuffers(this.#pendingBuffer, packet) : packet
277
+ this.#eatPacket()
278
+ }
279
+
280
+ // Append the accumulated bytes to the SourceBuffer once there is one and it is not
281
+ // busy; until then packets pile up in #pendingBuffer, and the 'updateend' listener
282
+ // re-enters here to flush them.
283
+ #eatPacket(): void {
284
+ if (!this.#sourceBuffer || !this.#pendingBuffer) {
285
+ return
286
+ }
287
+ // Data with a pipeline to take it: playing, whether it has just arrived or has
288
+ // been waiting for the SourceBuffer to be built or to finish updating.
289
+ this.#setState('playing')
290
+ if (this.#sourceBuffer.updating) {
291
+ return
292
+ }
293
+ this.#armWatchdog(this.#noDataTimeoutMs)
294
+ const packet = this.#pendingBuffer
295
+ this.#pendingBuffer = undefined
296
+ try {
297
+ this.#sourceBuffer.appendBuffer(packet)
298
+ } catch {
299
+ this.#reset('Append buffer error')
300
+ }
301
+ }
302
+
303
+ // In-place reset: rebuild the MediaSource pipeline but keep the WebSocket,
304
+ // asking the server (via #onSourceOpen) to restart the stream.
305
+ #reset(reason: string): void {
306
+ if (this.#state !== 'playing') {
307
+ return
308
+ }
309
+ this.#clearWatchdog()
310
+ this.emit('error', reason)
311
+ this.#setState('resetting')
312
+ this.#closeVideo()
313
+ this.#setUp()
314
+ }
315
+
316
+ // Full recovery: drop the subscription and take out a new one. Repeated reopens back
317
+ // off, so a camera that is down does not have every player watching it churning a
318
+ // subscription — and a Stream node, and a camera dial — on every timeout.
319
+ #reopen(reason: string): void {
320
+ this.emit('error', reason)
321
+ this.#restart(this.#backoff.next())
322
+ }
323
+
324
+ #restart(delayMs: number): void {
325
+ this.#teardown()
326
+ if (delayMs > 0) {
327
+ this.#reopenTimer = setTimeout(() => this.#open(), delayMs)
328
+ } else {
329
+ this.#open()
330
+ }
331
+ }
332
+
333
+ #closeVideo(): void {
334
+ this.#bufferAbort?.abort()
335
+ this.#bufferAbort = undefined
336
+ this.#pendingBuffer = undefined
337
+ this.#sourceBuffer = undefined
338
+ this.#videoEl.pause()
339
+ if (this.#managed) {
340
+ this.#videoEl.srcObject = null
341
+ } else {
342
+ this.#videoEl.removeAttribute('src')
343
+ }
344
+ this.#videoEl.load()
345
+ this.#mse = undefined
346
+ }
347
+
348
+ #armWatchdog(ms: number): void {
349
+ this.#clearWatchdog()
350
+ this.#watchdog = setTimeout(() => {
351
+ if (this.#state === 'playing') {
352
+ this.#reset('No video timeout')
353
+ } else {
354
+ this.#reopen(`No video timeout on ${this.#state}`)
355
+ }
356
+ }, ms)
357
+ }
358
+
359
+ #clearWatchdog(): void {
360
+ if (this.#watchdog) {
361
+ clearTimeout(this.#watchdog)
362
+ this.#watchdog = undefined
363
+ }
364
+ }
365
+ }
@@ -0,0 +1,316 @@
1
+ import { Emitter } from '@greendrake/util'
2
+ import type { AudioFrame } from './AudioPlayer'
3
+ import { Backoff } from './Backoff'
4
+ import type { G711Codec } from './g711'
5
+
6
+ export interface StreamSubscriptionEvents {
7
+ // The subscription is live on the server. Fired on the first subscribe and again
8
+ // every time the hub re-establishes it on a new socket, where the server restarts
9
+ // the stream from a fresh initialization segment: the consumer must rebuild its
10
+ // decoding pipeline each time.
11
+ subscribed: []
12
+ // One fMP4 payload. A zero-length payload is the server's reset acknowledgement.
13
+ packet: [payload: ArrayBuffer]
14
+ // One frame of the camera's audio, while the subscription has it on.
15
+ audio: [frame: AudioFrame]
16
+ // The server will not serve this stream, or has stopped serving it. The consumer
17
+ // decides whether and when to subscribe again.
18
+ failed: [reason: string]
19
+ [key: string]: unknown[]
20
+ }
21
+
22
+ export interface StreamHubOptions {
23
+ // Base delay before reconnecting a dropped socket, doubling up to maxReopenDelayMs.
24
+ reopenDelayMs?: number
25
+ maxReopenDelayMs?: number
26
+ }
27
+
28
+ const DEFAULT_REOPEN_DELAY_MS = 1000
29
+ const DEFAULT_MAX_REOPEN_DELAY_MS = 30000
30
+
31
+ interface ServerMessage {
32
+ op: 'subbed' | 'error' | 'gone'
33
+ id?: number
34
+ ch?: number
35
+ key?: string
36
+ }
37
+
38
+ // What a binary frame's payload is: the byte after the channel byte.
39
+ const KIND_VIDEO = 0
40
+ const KIND_AUDIO = 1
41
+
42
+ // An audio payload's codec byte.
43
+ const AUDIO_CODECS: G711Codec[] = ['alaw', 'ulaw']
44
+
45
+ // An audio payload is [u32 ms][u16 sample rate][u8 codec][samples], big-endian.
46
+ const AUDIO_HEADER = 7
47
+
48
+ const audioFrame = (payload: DataView): AudioFrame => ({
49
+ ptsMs: payload.getUint32(0),
50
+ sampleRate: payload.getUint16(4),
51
+ codec: AUDIO_CODECS[payload.getUint8(6)],
52
+ samples: new Uint8Array(payload.buffer, payload.byteOffset + AUDIO_HEADER, payload.byteLength - AUDIO_HEADER)
53
+ })
54
+
55
+ export class StreamSubscription extends Emitter<StreamSubscriptionEvents> {
56
+ readonly key: string
57
+ readonly id: number
58
+ readonly #hub: StreamHub
59
+ // Assigned by the server, and cleared whenever the socket carrying it goes away.
60
+ channel?: number
61
+ // Whether the server is to send the audio: off until asked for, on every socket.
62
+ #audio = false
63
+
64
+ constructor(hub: StreamHub, key: string, id: number) {
65
+ super()
66
+ this.#hub = hub
67
+ this.key = key
68
+ this.id = id
69
+ }
70
+
71
+ get audio(): boolean {
72
+ return this.#audio
73
+ }
74
+
75
+ setAudio(on: boolean): void {
76
+ this.#audio = on
77
+ this.#hub.sendAudio(this)
78
+ }
79
+
80
+ // Ask the server to restart this stream from a fresh initialization segment. It
81
+ // answers with a zero-length packet.
82
+ reset(): void {
83
+ this.#hub.resetSubscription(this)
84
+ }
85
+
86
+ close(): void {
87
+ this.#hub.unsubscribe(this)
88
+ }
89
+ }
90
+
91
+ // One WebSocket per host, shared by every player on the page and demultiplexed by the
92
+ // channel byte the server puts in front of each payload.
93
+ //
94
+ // Browsers hold at most one connection per host in CONNECTING (RFC 6455 4.1), so a grid
95
+ // of N tiles on N sockets pays N serialized handshakes plus the browser's own growing
96
+ // inter-handshake delay. One socket removes that entirely.
97
+ export class StreamHub {
98
+ readonly #url: string
99
+ readonly #onIdle: () => void
100
+ readonly #backoff: Backoff
101
+ #ws?: WebSocket
102
+ #abort?: AbortController
103
+ #reopenTimer?: ReturnType<typeof setTimeout>
104
+ #refs = 0
105
+ #nextId = 1
106
+ #subscriptions = new Set<StreamSubscription>()
107
+ #byChannel = new Map<number, StreamSubscription>()
108
+ // Subscribe requests awaiting an answer, by the id they were sent with.
109
+ #awaiting = new Map<number, StreamSubscription>()
110
+
111
+ constructor(url: string, onIdle: () => void, options: StreamHubOptions = {}) {
112
+ this.#url = url
113
+ this.#onIdle = onIdle
114
+ this.#backoff = new Backoff(options.reopenDelayMs ?? DEFAULT_REOPEN_DELAY_MS, options.maxReopenDelayMs ?? DEFAULT_MAX_REOPEN_DELAY_MS)
115
+ }
116
+
117
+ retain(): void {
118
+ this.#refs++
119
+ }
120
+
121
+ release(): void {
122
+ this.#refs--
123
+ if (this.#refs <= 0) {
124
+ this.#close()
125
+ this.#onIdle()
126
+ }
127
+ }
128
+
129
+ subscribe(key: string): StreamSubscription {
130
+ const subscription = new StreamSubscription(this, key, this.#nextId++)
131
+ this.#subscriptions.add(subscription)
132
+ if (this.#ws?.readyState === WebSocket.OPEN) {
133
+ this.#request(subscription)
134
+ } else {
135
+ this.#connect()
136
+ }
137
+ return subscription
138
+ }
139
+
140
+ unsubscribe(subscription: StreamSubscription): void {
141
+ this.#subscriptions.delete(subscription)
142
+ this.#awaiting.delete(subscription.id)
143
+ if (subscription.channel !== undefined) {
144
+ this.#byChannel.delete(subscription.channel)
145
+ this.#send({ op: 'unsub', ch: subscription.channel })
146
+ subscription.channel = undefined
147
+ }
148
+ }
149
+
150
+ resetSubscription(subscription: StreamSubscription): void {
151
+ this.#sendOn(subscription, { op: 'reset' })
152
+ }
153
+
154
+ // Tells the server whether to send the subscription's audio. One without a channel
155
+ // yet gets it with its next one.
156
+ sendAudio(subscription: StreamSubscription): void {
157
+ this.#sendOn(subscription, { op: 'audio', on: subscription.audio })
158
+ }
159
+
160
+ // Sends an op about a subscription's channel, if it has one.
161
+ #sendOn(subscription: StreamSubscription, op: object): void {
162
+ if (subscription.channel !== undefined) {
163
+ this.#send({ ...op, ch: subscription.channel })
164
+ }
165
+ }
166
+
167
+ #request(subscription: StreamSubscription): void {
168
+ this.#awaiting.set(subscription.id, subscription)
169
+ this.#send({
170
+ op: 'sub',
171
+ id: subscription.id,
172
+ key: subscription.key
173
+ })
174
+ }
175
+
176
+ #send(message: object): void {
177
+ if (this.#ws?.readyState === WebSocket.OPEN) {
178
+ this.#ws.send(JSON.stringify(message))
179
+ }
180
+ }
181
+
182
+ #connect(): void {
183
+ if (this.#ws || this.#reopenTimer) {
184
+ return
185
+ }
186
+ try {
187
+ this.#ws = new WebSocket(this.#url)
188
+ } catch {
189
+ this.#onDown()
190
+ return
191
+ }
192
+ this.#ws.binaryType = 'arraybuffer'
193
+ this.#abort = new AbortController()
194
+ const signal = this.#abort.signal
195
+ this.#ws.addEventListener('open', () => this.#onOpen(), { signal })
196
+ this.#ws.addEventListener('message', event => this.#onMessage(event.data), { signal })
197
+ this.#ws.addEventListener('error', () => this.#onDown(), { signal })
198
+ this.#ws.addEventListener('close', () => this.#onDown(), { signal })
199
+ }
200
+
201
+ #onOpen(): void {
202
+ this.#subscriptions.forEach(subscription => this.#request(subscription))
203
+ }
204
+
205
+ #onMessage(data: ArrayBuffer | string): void {
206
+ // The socket is not merely open but carrying traffic, so the next drop starts a
207
+ // fresh backoff. Clearing it on the handshake alone would leave no backoff at all
208
+ // against a server that accepts connections and then drops them.
209
+ this.#backoff.reset()
210
+ if (typeof data === 'string') {
211
+ this.#onServerMessage(JSON.parse(data) as ServerMessage)
212
+ return
213
+ }
214
+ const frame = new DataView(data)
215
+ const subscription = this.#byChannel.get(frame.getUint8(0))
216
+ if (!subscription) {
217
+ return
218
+ }
219
+ if (data.byteLength === 1) {
220
+ // A bare channel byte acknowledges a reset.
221
+ subscription.emit('packet', new ArrayBuffer(0))
222
+ return
223
+ }
224
+ const kind = frame.getUint8(1)
225
+ if (kind === KIND_VIDEO) {
226
+ subscription.emit('packet', data.slice(2))
227
+ } else if (kind === KIND_AUDIO) {
228
+ subscription.emit('audio', audioFrame(new DataView(data, 2)))
229
+ }
230
+ }
231
+
232
+ #onServerMessage(message: ServerMessage): void {
233
+ if (message.op === 'gone') {
234
+ const subscription = this.#byChannel.get(message.ch!)
235
+ if (subscription) {
236
+ this.#byChannel.delete(message.ch!)
237
+ this.#subscriptions.delete(subscription)
238
+ subscription.channel = undefined
239
+ subscription.emit('failed', 'Stream gone')
240
+ }
241
+ return
242
+ }
243
+ const subscription = this.#awaiting.get(message.id!)
244
+ this.#awaiting.delete(message.id!)
245
+ if (!subscription) {
246
+ // A subscription cancelled while its request was in flight. The channel the
247
+ // server just allocated for it would otherwise stay open forever.
248
+ if (message.op === 'subbed') {
249
+ this.#send({ op: 'unsub', ch: message.ch })
250
+ }
251
+ return
252
+ }
253
+ if (message.op === 'subbed') {
254
+ subscription.channel = message.ch
255
+ this.#byChannel.set(message.ch!, subscription)
256
+ // A new channel starts with its audio off.
257
+ if (subscription.audio) {
258
+ this.sendAudio(subscription)
259
+ }
260
+ subscription.emit('subscribed')
261
+ } else {
262
+ this.#subscriptions.delete(subscription)
263
+ subscription.emit('failed', `Server refused ${subscription.key}`)
264
+ }
265
+ }
266
+
267
+ #onDown(): void {
268
+ this.#dropSocket()
269
+ this.#byChannel.clear()
270
+ this.#awaiting.clear()
271
+ this.#subscriptions.forEach(subscription => {
272
+ subscription.channel = undefined
273
+ })
274
+ if (this.#refs <= 0 || this.#subscriptions.size === 0) {
275
+ return
276
+ }
277
+ // Every live subscription is re-sent once the socket is back, and each one is
278
+ // told it has restarted so it can rebuild around the fresh init segment.
279
+ this.#reopenTimer = setTimeout(() => {
280
+ this.#reopenTimer = undefined
281
+ this.#connect()
282
+ }, this.#backoff.next())
283
+ }
284
+
285
+ #dropSocket(): void {
286
+ this.#abort?.abort()
287
+ this.#abort = undefined
288
+ this.#ws?.close()
289
+ this.#ws = undefined
290
+ }
291
+
292
+ #close(): void {
293
+ if (this.#reopenTimer) {
294
+ clearTimeout(this.#reopenTimer)
295
+ this.#reopenTimer = undefined
296
+ }
297
+ this.#dropSocket()
298
+ this.#subscriptions.clear()
299
+ this.#byChannel.clear()
300
+ this.#awaiting.clear()
301
+ }
302
+ }
303
+
304
+ const hubs = new Map<string, StreamHub>()
305
+
306
+ // Hubs are shared by URL and reference counted: every player on a page talks to the same
307
+ // host over the same socket, and the socket goes away with the last of them.
308
+ export const acquireStreamHub = (url: string, options?: StreamHubOptions): StreamHub => {
309
+ let hub = hubs.get(url)
310
+ if (!hub) {
311
+ hub = new StreamHub(url, () => hubs.delete(url), options)
312
+ hubs.set(url, hub)
313
+ }
314
+ hub.retain()
315
+ return hub
316
+ }
package/src/Video.vue ADDED
@@ -0,0 +1,63 @@
1
+ <template>
2
+ <div class="Video" :data-state="state">
3
+ <video v-if="supported" ref="videoEl" muted playsinline></video>
4
+ <p v-else class="unsupported">This browser cannot play the camera's H.265 video.</p>
5
+ </div>
6
+ </template>
7
+ <script setup lang="ts">
8
+ import { onMounted, onUnmounted, ref, watch } from 'vue'
9
+ import { canPlay } from './codec'
10
+ import { MSEVideo, type MSEVideoState } from './MSEVideo'
11
+
12
+ const props = defineProps<{
13
+ // WebSocket URL of the stream hub. Every player pointed at the same one shares a
14
+ // single socket.
15
+ hubUrl: string
16
+ // Which stream on that hub: a key its server knows.
17
+ streamKey: string
18
+ // Whether to play the camera's audio. The element itself stays muted: the audio
19
+ // plays through the page's audio context, which unlockAudio has to be called for,
20
+ // from the click that turns this on.
21
+ audio: boolean
22
+ }>()
23
+
24
+ // A browser that cannot play the stream gets no player at all, and says so.
25
+ const supported = canPlay()
26
+ const state = ref<MSEVideoState | 'unsupported'>(supported ? 'closed' : 'unsupported')
27
+ const videoEl = ref<HTMLVideoElement | null>(null)
28
+ let player: MSEVideo | undefined
29
+
30
+ onMounted(() => {
31
+ if (!supported) {
32
+ return
33
+ }
34
+ player = new MSEVideo(videoEl.value!, props.hubUrl, props.streamKey)
35
+ player.on('state', s => (state.value = s))
36
+ state.value = player.state
37
+ player.setAudio(props.audio)
38
+ })
39
+ onUnmounted(() => player?.close())
40
+ watch(
41
+ () => props.streamKey,
42
+ key => player?.setKey(key)
43
+ )
44
+ watch(
45
+ () => props.audio,
46
+ on => player?.setAudio(on)
47
+ )
48
+ </script>
49
+ <style scoped>
50
+ .Video,
51
+ video {
52
+ width: 100%;
53
+ height: 100%;
54
+ }
55
+ video {
56
+ object-fit: fill;
57
+ }
58
+ .unsupported {
59
+ margin: 0;
60
+ padding: 1em;
61
+ text-align: center;
62
+ }
63
+ </style>
package/src/codec.ts ADDED
@@ -0,0 +1,28 @@
1
+ declare global {
2
+ interface Window {
3
+ // Apple's Managed Media Source (Safari 17+), the only Media Source flavour
4
+ // iPhones expose: plain MediaSource is undefined there. TypeScript's DOM
5
+ // library has no definition yet; it is a MediaSource subclass, and the
6
+ // constructor type covers all of it used here — its extra streaming API is
7
+ // not. https://w3c.github.io/media-source/#managedmediasource
8
+ ManagedMediaSource?: typeof MediaSource
9
+ }
10
+ }
11
+
12
+ // The content type of every stream a webcast server casts: H.265 in fMP4.
13
+ export const streamCodec = 'video/mp4; codecs="hvc1.1.6.L153.B0"'
14
+
15
+ // A browser's Media Source constructors, as its globals have them.
16
+ export interface MediaSources {
17
+ ManagedMediaSource?: typeof MediaSource
18
+ MediaSource?: typeof MediaSource
19
+ }
20
+
21
+ // The Media Source a browser has: Apple's Managed one where it exists, the standard one
22
+ // otherwise, and none at all on an iPhone before Safari 17.
23
+ export const mediaSourceOf = (browser: MediaSources): typeof MediaSource | undefined =>
24
+ browser.ManagedMediaSource ?? browser.MediaSource
25
+
26
+ // Whether a browser can play the stream at all.
27
+ export const canPlay = (browser: MediaSources = window): boolean =>
28
+ mediaSourceOf(browser)?.isTypeSupported(streamCodec) ?? false
package/src/g711.ts ADDED
@@ -0,0 +1,38 @@
1
+ // G.711 decoding (ITU-T G.711), which no browser media pipeline offers: a table from
2
+ // each 8-bit code to its sample.
3
+
4
+ export type G711Codec = 'alaw' | 'ulaw'
5
+
6
+ const alawSample = (code: number): number => {
7
+ const a = code ^ 0x55
8
+ const segment = (a & 0x70) >> 4
9
+ let magnitude = (a & 0x0f) << 4
10
+ magnitude += segment === 0 ? 8 : 0x108
11
+ if (segment > 1) {
12
+ magnitude <<= segment - 1
13
+ }
14
+ return a & 0x80 ? magnitude : -magnitude
15
+ }
16
+
17
+ const ulawSample = (code: number): number => {
18
+ const u = ~code & 0xff
19
+ const magnitude = (((u & 0x0f) << 3) + 0x84) << ((u & 0x70) >> 4)
20
+ return u & 0x80 ? 0x84 - magnitude : magnitude - 0x84
21
+ }
22
+
23
+ const table = (sample: (code: number) => number): Float32Array => Float32Array.from({ length: 256 }, (_, code) => sample(code) / 32768)
24
+
25
+ const tables: Record<G711Codec, Float32Array> = {
26
+ alaw: table(alawSample),
27
+ ulaw: table(ulawSample)
28
+ }
29
+
30
+ // One sample per byte, scaled to [-1, 1).
31
+ export const decodeG711 = (codec: G711Codec, codes: Uint8Array): Float32Array<ArrayBuffer> => {
32
+ const decode = tables[codec]
33
+ const samples = new Float32Array(codes.length)
34
+ for (let i = 0; i < codes.length; i++) {
35
+ samples[i] = decode[codes[i]]
36
+ }
37
+ return samples
38
+ }
@@ -0,0 +1,32 @@
1
+ // Keeping a live stream's playback close to the newest frame it has. The server times
2
+ // frames on the camera's own clock, so playback keeps pace with the camera, and the lag
3
+ // a viewer starts with or picks up from a stall would otherwise stay.
4
+
5
+ // In seconds of video buffered ahead of what is on screen: past CATCH_UP_LEAD playback
6
+ // speeds up to CATCH_UP_RATE until the lead is back under CAUGHT_UP_LEAD; past
7
+ // JUMP_LEAD it jumps to JUMP_TO_LEAD behind the newest frame.
8
+ const CATCH_UP_LEAD = 0.5
9
+ const CAUGHT_UP_LEAD = 0.2
10
+ const CATCH_UP_RATE = 1.1
11
+ const JUMP_LEAD = 2.5
12
+ const JUMP_TO_LEAD = 0.3
13
+
14
+ export type LiveVideo = Pick<HTMLVideoElement, 'buffered' | 'currentTime' | 'playbackRate'>
15
+
16
+ // Steers playback towards the live edge; meant to run on every timeupdate and
17
+ // progress event.
18
+ export const followLiveEdge = (video: LiveVideo): void => {
19
+ const buffered = video.buffered
20
+ if (buffered.length === 0) {
21
+ return
22
+ }
23
+ const end = buffered.end(buffered.length - 1)
24
+ const lead = end - video.currentTime
25
+ if (lead > JUMP_LEAD) {
26
+ video.currentTime = end - JUMP_TO_LEAD
27
+ } else if (lead > CATCH_UP_LEAD) {
28
+ video.playbackRate = CATCH_UP_RATE
29
+ } else if (lead < CAUGHT_UP_LEAD) {
30
+ video.playbackRate = 1
31
+ }
32
+ }
package/src/main.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { unlockAudio } from './AudioPlayer'
2
+ export { canPlay, streamCodec } from './codec'
3
+ export { MSEVideo, type MSEVideoEvents, type MSEVideoOptions, type MSEVideoState } from './MSEVideo'
4
+ export type { StreamHubOptions } from './StreamHub'
package/src/vue.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Its own entry, so a page without Vue takes the core alone.
2
+ export { default as Video } from './Video.vue'