@greendrake/mse-video 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +57 -2
- package/package.json +55 -5
- package/src/AudioPlayer.ts +131 -0
- package/src/Backoff.ts +24 -0
- package/src/MSEVideo.ts +365 -0
- package/src/StreamHub.ts +316 -0
- package/src/Video.vue +63 -0
- package/src/codec.ts +28 -0
- package/src/g711.ts +38 -0
- package/src/liveEdge.ts +32 -0
- package/src/main.ts +4 -0
- package/src/vue.ts +2 -0
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
|
-
#
|
|
1
|
+
# @greendrake/mse-video
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@greendrake/mse-video",
|
|
3
|
+
"version": "0.1.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
|
+
"scripts": {
|
|
36
|
+
"check": "vue-tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"prettier": "@greendrake/dev-config/prettier",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@greendrake/util": "^0.9.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"vue": "^3.5.34"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"vue": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@greendrake/dev-config": "0.7.0",
|
|
52
|
+
"typescript": "6.0.3",
|
|
53
|
+
"vue": "^3.5.34",
|
|
54
|
+
"vue-tsc": "^2.2.0"
|
|
55
|
+
}
|
|
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
|
+
}
|
package/src/MSEVideo.ts
ADDED
|
@@ -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
|
+
}
|
package/src/StreamHub.ts
ADDED
|
@@ -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
|
+
}
|
package/src/liveEdge.ts
ADDED
|
@@ -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
package/src/vue.ts
ADDED