@voltkit-rtc/client 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +196 -24
  3. package/dist/cjs/chat.d.ts +2 -0
  4. package/dist/cjs/chat.d.ts.map +1 -1
  5. package/dist/cjs/chat.js +23 -4
  6. package/dist/cjs/chat.js.map +1 -1
  7. package/dist/cjs/device.d.ts +85 -0
  8. package/dist/cjs/device.d.ts.map +1 -0
  9. package/dist/cjs/device.js +134 -0
  10. package/dist/cjs/device.js.map +1 -0
  11. package/dist/cjs/index.d.ts +212 -6
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js +822 -52
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/music.d.ts +1 -1
  16. package/dist/cjs/music.d.ts.map +1 -1
  17. package/dist/cjs/quality.d.ts +43 -16
  18. package/dist/cjs/quality.d.ts.map +1 -1
  19. package/dist/cjs/quality.js +81 -31
  20. package/dist/cjs/quality.js.map +1 -1
  21. package/dist/cjs/stats.d.ts +151 -0
  22. package/dist/cjs/stats.d.ts.map +1 -0
  23. package/dist/cjs/stats.js +243 -0
  24. package/dist/cjs/stats.js.map +1 -0
  25. package/dist/esm/chat.d.ts +2 -0
  26. package/dist/esm/chat.d.ts.map +1 -1
  27. package/dist/esm/chat.js +23 -4
  28. package/dist/esm/chat.js.map +1 -1
  29. package/dist/esm/device.d.ts +85 -0
  30. package/dist/esm/device.d.ts.map +1 -0
  31. package/dist/esm/device.js +122 -0
  32. package/dist/esm/device.js.map +1 -0
  33. package/dist/esm/index.d.ts +212 -6
  34. package/dist/esm/index.d.ts.map +1 -1
  35. package/dist/esm/index.js +823 -53
  36. package/dist/esm/index.js.map +1 -1
  37. package/dist/esm/music.d.ts +1 -1
  38. package/dist/esm/music.d.ts.map +1 -1
  39. package/dist/esm/quality.d.ts +43 -16
  40. package/dist/esm/quality.d.ts.map +1 -1
  41. package/dist/esm/quality.js +76 -31
  42. package/dist/esm/quality.js.map +1 -1
  43. package/dist/esm/stats.d.ts +151 -0
  44. package/dist/esm/stats.d.ts.map +1 -0
  45. package/dist/esm/stats.js +232 -0
  46. package/dist/esm/stats.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/chat.ts +21 -4
  49. package/src/device.ts +168 -0
  50. package/src/index.ts +879 -58
  51. package/src/music.ts +1 -1
  52. package/src/quality.ts +96 -32
  53. package/src/stats.ts +328 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * What the camera is really sending, and what a viewer is really receiving,
3
+ * read from the browser's WebRTC statistics.
4
+ *
5
+ * The parsers are pure: they take the stats objects a `getStats()` report
6
+ * holds and a baseline of byte counts from the previous call, so they can be
7
+ * tested with plain objects. Bitrates are the bytes sent or received since
8
+ * the previous call for the same key, over the time between the two reports'
9
+ * timestamps: 0 on the first call.
10
+ */
11
+ import type { VideoQuality } from './quality.js';
12
+ /** One simulcast layer of the camera video. */
13
+ export interface VideoLayerStats {
14
+ /** The layer's RTP stream id, as the browser reports it (`r0` is the smallest). */
15
+ rid?: string;
16
+ width: number;
17
+ height: number;
18
+ fps: number;
19
+ /** Bits per second since the previous call; 0 on the first. */
20
+ bitrate: number;
21
+ /** Switched on, and frames or bytes are going out. */
22
+ active: boolean;
23
+ /** Why the browser is sending less than asked: `cpu`, `bandwidth`, `other` or `none`. */
24
+ qualityLimitation?: string;
25
+ }
26
+ /** What the camera is sending. The top-level fields describe the biggest active layer. */
27
+ export interface VideoSendStats {
28
+ /** `VP8` or `H264`, when the browser says. */
29
+ codec?: string;
30
+ /** The quality being published, after the project's limit. */
31
+ quality: VideoQuality;
32
+ width: number;
33
+ height: number;
34
+ fps: number;
35
+ bitrate: number;
36
+ qualityLimitation?: string;
37
+ /** The browser's estimate of the upload it can use, in bits per second. */
38
+ availableOutgoingBitrate?: number;
39
+ /** Every layer, smallest first. */
40
+ layers: VideoLayerStats[];
41
+ }
42
+ /** What this client is receiving of someone's video. */
43
+ export interface VideoReceiveStats {
44
+ producerId: string;
45
+ codec?: string;
46
+ width: number;
47
+ height: number;
48
+ fps: number;
49
+ /** Bits per second since the previous call for this video; 0 on the first. */
50
+ bitrate: number;
51
+ /** Packets lost since this video started. */
52
+ packetsLost: number;
53
+ /** Seconds, as the browser reports it. */
54
+ jitter?: number;
55
+ /** How many times the picture froze, where the browser counts it. */
56
+ freezeCount?: number;
57
+ }
58
+ /** A stats object as a report holds it. Only `type` is relied on. */
59
+ export type StatsEntry = Record<string, unknown>;
60
+ /** Byte counts and their report time from the previous call, per key. The caller keeps it. */
61
+ export type StatsBaseline = Map<string, {
62
+ bytes: number;
63
+ timestamp: number;
64
+ }>;
65
+ /**
66
+ * Bits per second from `bytes` at `timestamp` (ms), against what `key` had
67
+ * last time, and remembers this reading for the next call. 0 on the first
68
+ * reading, and whenever the counter went backwards (a new stream).
69
+ */
70
+ export declare function bitrateSince(baseline: StatsBaseline, key: string, bytes: number, timestamp: number): number;
71
+ /** Forget every key `keep` turns down: the streams they counted are gone. */
72
+ export declare function pruneBaseline(baseline: StatsBaseline, keep: (key: string) => boolean): void;
73
+ /**
74
+ * The camera's send statistics from a sender's report. `keyPrefix` names the
75
+ * producer, so a new producer's counters never meet an old one's.
76
+ */
77
+ export declare function parseSendStats(report: Iterable<StatsEntry>, options: {
78
+ quality: VideoQuality;
79
+ baseline: StatsBaseline;
80
+ keyPrefix: string;
81
+ }): VideoSendStats;
82
+ /** One consumed video's receive statistics from a receiver's report; null when it has none. */
83
+ export declare function parseReceiveStats(report: Iterable<StatsEntry>, options: {
84
+ producerId: string;
85
+ baseline: StatsBaseline;
86
+ key: string;
87
+ }): VideoReceiveStats | null;
88
+ /** What is holding the camera video back: the device, the upload, something else, or nothing. */
89
+ export type VideoLimitReason = 'cpu' | 'bandwidth' | 'other' | 'none';
90
+ export interface VideoLimitedEvent {
91
+ reason: VideoLimitReason;
92
+ /** The biggest layer going out when this was decided. */
93
+ width: number;
94
+ height: number;
95
+ fps: number;
96
+ bitrate: number;
97
+ }
98
+ /**
99
+ * The reason across the layers going out (all of them when none is): the
100
+ * device first, since it costs the most picture, then the upload, then
101
+ * anything else.
102
+ */
103
+ export declare function limitReasonOf(layers: readonly VideoLayerStats[]): VideoLimitReason;
104
+ /** Samples a new reason must hold for before it is reported: about 4 s at one every 2 s. */
105
+ export declare const LIMIT_SAMPLES_TO_REPORT = 2;
106
+ export interface LimitTracker {
107
+ /** The last reason reported; null before the first. */
108
+ reported: VideoLimitReason | null;
109
+ /** A different reason seen lately, and for how many samples in a row. */
110
+ pending: VideoLimitReason | null;
111
+ streak: number;
112
+ }
113
+ export declare const initialLimitTracker: () => LimitTracker;
114
+ /**
115
+ * Feed one sample's reason. Returns the reason to report, if any: one that
116
+ * differs from the last reported and held for LIMIT_SAMPLES_TO_REPORT samples
117
+ * in a row, so a brief spike stays quiet. Before anything is reported the
118
+ * state counts as 'none', so 'none' is never the first event.
119
+ */
120
+ export declare function trackLimit(tracker: LimitTracker, reason: VideoLimitReason): {
121
+ tracker: LimitTracker;
122
+ report: VideoLimitReason | null;
123
+ };
124
+ /** How often the camera is sampled while someone listens for `videoLimited`. */
125
+ export declare const LIMIT_SAMPLE_MS = 2000;
126
+ /**
127
+ * Samples the camera's send statistics on a timer while it is wanted and
128
+ * reports changes in what holds the video back. The stats it reads keep their
129
+ * own baseline, so its sampling never disturbs the bitrates an app reads with
130
+ * `getVideoStats()`.
131
+ */
132
+ export declare class VideoLimitWatch {
133
+ private readonly sample;
134
+ private readonly report;
135
+ private readonly intervalMs;
136
+ private timer;
137
+ private tracker;
138
+ private sampling;
139
+ private readonly baseline;
140
+ constructor(sample: (baseline: StatsBaseline) => Promise<VideoSendStats | null>, report: (event: VideoLimitedEvent) => void, intervalMs?: number);
141
+ get running(): boolean;
142
+ /** Start or stop sampling. Safe to call as often as anything changes. */
143
+ set(wanted: boolean): void;
144
+ /**
145
+ * Stop, and start over as if nothing had been reported. Returns the event
146
+ * that clears a limitation still being reported, for when publishing stops.
147
+ */
148
+ reset(): VideoLimitedEvent | null;
149
+ private tick;
150
+ }
151
+ //# sourceMappingURL=stats.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/stats.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,232 @@
1
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
2
+ const optNum = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
3
+ const optStr = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
4
+ const oneDecimal = (value) => Math.round(value * 10) / 10;
5
+ /**
6
+ * Bits per second from `bytes` at `timestamp` (ms), against what `key` had
7
+ * last time, and remembers this reading for the next call. 0 on the first
8
+ * reading, and whenever the counter went backwards (a new stream).
9
+ */
10
+ export function bitrateSince(baseline, key, bytes, timestamp) {
11
+ const previous = baseline.get(key);
12
+ baseline.set(key, { bytes, timestamp });
13
+ if (!previous)
14
+ return 0;
15
+ const seconds = (timestamp - previous.timestamp) / 1000;
16
+ const delta = bytes - previous.bytes;
17
+ if (seconds <= 0 || delta < 0)
18
+ return 0;
19
+ return Math.round((delta * 8) / seconds);
20
+ }
21
+ /** Forget every key `keep` turns down: the streams they counted are gone. */
22
+ export function pruneBaseline(baseline, keep) {
23
+ for (const key of [...baseline.keys()]) {
24
+ if (!keep(key))
25
+ baseline.delete(key);
26
+ }
27
+ }
28
+ const isVideo = (entry) => entry.kind === 'video' || entry.mediaType === 'video';
29
+ /** `VP8` from the codec entry an RTP stream points at. */
30
+ function codecOf(byId, entry) {
31
+ const codec = entry && typeof entry.codecId === 'string' ? byId.get(entry.codecId) : undefined;
32
+ const mime = optStr(codec?.mimeType);
33
+ return mime ? mime.replace(/^video\//i, '') : undefined;
34
+ }
35
+ /** The upload estimate on the candidate pair in use. */
36
+ function availableOutgoing(entries, byId) {
37
+ for (const transport of entries.filter((e) => e.type === 'transport')) {
38
+ const pair = typeof transport.selectedCandidatePairId === 'string' ? byId.get(transport.selectedCandidatePairId) : undefined;
39
+ const bitrate = optNum(pair?.availableOutgoingBitrate);
40
+ if (bitrate !== undefined)
41
+ return bitrate;
42
+ }
43
+ const pairs = entries.filter((e) => e.type === 'candidate-pair' && optNum(e.availableOutgoingBitrate) !== undefined);
44
+ const chosen = pairs.find((p) => p.nominated === true && p.state === 'succeeded') ?? pairs.find((p) => p.nominated === true) ?? pairs[0];
45
+ return optNum(chosen?.availableOutgoingBitrate);
46
+ }
47
+ const area = (layer) => layer.width * layer.height;
48
+ /** r0, r1, r2 in order; layers without a rid by size. */
49
+ function layerOrder(a, b) {
50
+ if (a.rid !== undefined && b.rid !== undefined && a.rid !== b.rid) {
51
+ return a.rid.localeCompare(b.rid, 'en', { numeric: true });
52
+ }
53
+ return area(a) - area(b);
54
+ }
55
+ /**
56
+ * The camera's send statistics from a sender's report. `keyPrefix` names the
57
+ * producer, so a new producer's counters never meet an old one's.
58
+ */
59
+ export function parseSendStats(report, options) {
60
+ const entries = [...report].filter((e) => typeof e === 'object' && e !== null);
61
+ const byId = new Map();
62
+ for (const entry of entries) {
63
+ if (typeof entry.id === 'string')
64
+ byId.set(entry.id, entry);
65
+ }
66
+ const outbound = entries.filter((e) => e.type === 'outbound-rtp' && isVideo(e));
67
+ const layers = outbound.map((entry) => {
68
+ const rid = optStr(entry.rid);
69
+ const key = `${options.keyPrefix}${rid ?? String(entry.ssrc ?? entry.id ?? '')}`;
70
+ const bitrate = bitrateSince(options.baseline, key, num(entry.bytesSent), num(entry.timestamp));
71
+ const fps = oneDecimal(num(entry.framesPerSecond));
72
+ const reason = optStr(entry.qualityLimitationReason);
73
+ return {
74
+ ...(rid !== undefined ? { rid } : {}),
75
+ width: num(entry.frameWidth),
76
+ height: num(entry.frameHeight),
77
+ fps,
78
+ bitrate,
79
+ active: entry.active !== false && (fps > 0 || bitrate > 0),
80
+ ...(reason !== undefined ? { qualityLimitation: reason } : {}),
81
+ entry,
82
+ };
83
+ });
84
+ layers.sort(layerOrder);
85
+ // The biggest layer going out is what the best-connected viewer gets. With
86
+ // nothing going out (paused, or the very first frames), the biggest there is.
87
+ const pool = layers.some((l) => l.active) ? layers.filter((l) => l.active) : layers;
88
+ const top = pool.reduce((best, l) => (!best || area(l) > area(best) ? l : best), undefined);
89
+ const codec = codecOf(byId, top?.entry) ?? outbound.map((e) => codecOf(byId, e)).find(Boolean);
90
+ const available = availableOutgoing(entries, byId);
91
+ return {
92
+ ...(codec !== undefined ? { codec } : {}),
93
+ quality: options.quality,
94
+ width: top?.width ?? 0,
95
+ height: top?.height ?? 0,
96
+ fps: top?.fps ?? 0,
97
+ bitrate: top?.bitrate ?? 0,
98
+ ...(top?.qualityLimitation !== undefined ? { qualityLimitation: top.qualityLimitation } : {}),
99
+ ...(available !== undefined ? { availableOutgoingBitrate: available } : {}),
100
+ layers: layers.map(({ entry: _entry, ...layer }) => layer),
101
+ };
102
+ }
103
+ /** One consumed video's receive statistics from a receiver's report; null when it has none. */
104
+ export function parseReceiveStats(report, options) {
105
+ const entries = [...report].filter((e) => typeof e === 'object' && e !== null);
106
+ const byId = new Map();
107
+ for (const entry of entries) {
108
+ if (typeof entry.id === 'string')
109
+ byId.set(entry.id, entry);
110
+ }
111
+ const inbound = entries
112
+ .filter((e) => e.type === 'inbound-rtp' && isVideo(e))
113
+ .sort((a, b) => num(b.bytesReceived) - num(a.bytesReceived))[0];
114
+ if (!inbound)
115
+ return null;
116
+ const codec = codecOf(byId, inbound);
117
+ const jitter = optNum(inbound.jitter);
118
+ const freezeCount = optNum(inbound.freezeCount);
119
+ return {
120
+ producerId: options.producerId,
121
+ ...(codec !== undefined ? { codec } : {}),
122
+ width: num(inbound.frameWidth),
123
+ height: num(inbound.frameHeight),
124
+ fps: oneDecimal(num(inbound.framesPerSecond)),
125
+ bitrate: bitrateSince(options.baseline, options.key, num(inbound.bytesReceived), num(inbound.timestamp)),
126
+ packetsLost: num(inbound.packetsLost),
127
+ ...(jitter !== undefined ? { jitter } : {}),
128
+ ...(freezeCount !== undefined ? { freezeCount } : {}),
129
+ };
130
+ }
131
+ /**
132
+ * The reason across the layers going out (all of them when none is): the
133
+ * device first, since it costs the most picture, then the upload, then
134
+ * anything else.
135
+ */
136
+ export function limitReasonOf(layers) {
137
+ const pool = layers.some((l) => l.active) ? layers.filter((l) => l.active) : layers;
138
+ const reasons = new Set(pool.map((l) => l.qualityLimitation));
139
+ if (reasons.has('cpu'))
140
+ return 'cpu';
141
+ if (reasons.has('bandwidth'))
142
+ return 'bandwidth';
143
+ if (reasons.has('other'))
144
+ return 'other';
145
+ return 'none';
146
+ }
147
+ /** Samples a new reason must hold for before it is reported: about 4 s at one every 2 s. */
148
+ export const LIMIT_SAMPLES_TO_REPORT = 2;
149
+ export const initialLimitTracker = () => ({ reported: null, pending: null, streak: 0 });
150
+ /**
151
+ * Feed one sample's reason. Returns the reason to report, if any: one that
152
+ * differs from the last reported and held for LIMIT_SAMPLES_TO_REPORT samples
153
+ * in a row, so a brief spike stays quiet. Before anything is reported the
154
+ * state counts as 'none', so 'none' is never the first event.
155
+ */
156
+ export function trackLimit(tracker, reason) {
157
+ const current = tracker.reported ?? 'none';
158
+ if (reason === current)
159
+ return { tracker: { reported: tracker.reported, pending: null, streak: 0 }, report: null };
160
+ const streak = reason === tracker.pending ? tracker.streak + 1 : 1;
161
+ if (streak >= LIMIT_SAMPLES_TO_REPORT)
162
+ return { tracker: { reported: reason, pending: null, streak: 0 }, report: reason };
163
+ return { tracker: { reported: tracker.reported, pending: reason, streak }, report: null };
164
+ }
165
+ /** How often the camera is sampled while someone listens for `videoLimited`. */
166
+ export const LIMIT_SAMPLE_MS = 2000;
167
+ /**
168
+ * Samples the camera's send statistics on a timer while it is wanted and
169
+ * reports changes in what holds the video back. The stats it reads keep their
170
+ * own baseline, so its sampling never disturbs the bitrates an app reads with
171
+ * `getVideoStats()`.
172
+ */
173
+ export class VideoLimitWatch {
174
+ sample;
175
+ report;
176
+ intervalMs;
177
+ timer = null;
178
+ tracker = initialLimitTracker();
179
+ sampling = false;
180
+ baseline = new Map();
181
+ constructor(sample, report, intervalMs = LIMIT_SAMPLE_MS) {
182
+ this.sample = sample;
183
+ this.report = report;
184
+ this.intervalMs = intervalMs;
185
+ }
186
+ get running() {
187
+ return this.timer !== null;
188
+ }
189
+ /** Start or stop sampling. Safe to call as often as anything changes. */
190
+ set(wanted) {
191
+ if (wanted && !this.timer) {
192
+ this.timer = setInterval(() => void this.tick(), this.intervalMs);
193
+ }
194
+ else if (!wanted && this.timer) {
195
+ clearInterval(this.timer);
196
+ this.timer = null;
197
+ }
198
+ }
199
+ /**
200
+ * Stop, and start over as if nothing had been reported. Returns the event
201
+ * that clears a limitation still being reported, for when publishing stops.
202
+ */
203
+ reset() {
204
+ this.set(false);
205
+ const was = this.tracker.reported;
206
+ this.tracker = initialLimitTracker();
207
+ this.baseline.clear();
208
+ return was && was !== 'none' ? { reason: 'none', width: 0, height: 0, fps: 0, bitrate: 0 } : null;
209
+ }
210
+ async tick() {
211
+ // A slow getStats must not pile samples up behind it.
212
+ if (this.sampling)
213
+ return;
214
+ this.sampling = true;
215
+ try {
216
+ const stats = await this.sample(this.baseline);
217
+ if (!stats || !this.timer)
218
+ return;
219
+ const { tracker, report } = trackLimit(this.tracker, limitReasonOf(stats.layers));
220
+ this.tracker = tracker;
221
+ if (report)
222
+ this.report({ reason: report, width: stats.width, height: stats.height, fps: stats.fps, bitrate: stats.bitrate });
223
+ }
224
+ catch {
225
+ // Statistics are best effort; the next sample tries again.
226
+ }
227
+ finally {
228
+ this.sampling = false;
229
+ }
230
+ }
231
+ }
232
+ //# sourceMappingURL=stats.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stats.js","sourceRoot":"","sources":["../../src/stats.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltkit-rtc/client",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Browser SDK for VoltKit RTC: realtime audio/video rooms, chat and live broadcast publishing.",
5
5
  "keywords": [
6
6
  "voltkit",
package/src/chat.ts CHANGED
@@ -122,6 +122,8 @@ export class ChatClient {
122
122
  private closedByUs = false;
123
123
  private reconnecting = false;
124
124
  private reconnectAttempt = 0;
125
+ /** Set by the first successful join. Only a session that has joined is reconnected. */
126
+ private joinedOnce = false;
125
127
 
126
128
  constructor(private readonly wsUrl: string, token: string) {
127
129
  this.token = token;
@@ -153,8 +155,22 @@ export class ChatClient {
153
155
  }
154
156
 
155
157
  async connect(): Promise<ChatJoinResult> {
156
- await this.openSocket();
157
- const result = (await this.rpc('join', { token: this.token })) as ChatJoinResult;
158
+ let result: ChatJoinResult;
159
+ try {
160
+ await this.openSocket();
161
+ result = (await this.rpc('join', { token: this.token })) as ChatJoinResult;
162
+ } catch (err) {
163
+ // Never joined: the app has the error. Left open, the socket would be
164
+ // closed by the server's join deadline, and that would start
165
+ // reconnecting a session that never began.
166
+ if (!this.joinedOnce) this.detachSocket();
167
+ throw err;
168
+ }
169
+ // Joined: the next drop gets every reconnect attempt again. Not when the
170
+ // socket opens, or a join refused with a code worth retrying never uses
171
+ // them up and is retried about twice a second, forever.
172
+ this.reconnectAttempt = 0;
173
+ this.joinedOnce = true;
158
174
  this.joinResult = result;
159
175
  this.emit('joined', result);
160
176
  return result;
@@ -170,7 +186,6 @@ export class ChatClient {
170
186
 
171
187
  const onError = () => reject(new Error('websocket failed to open'));
172
188
  ws.addEventListener('open', () => {
173
- this.reconnectAttempt = 0;
174
189
  this.emit('connected');
175
190
  resolve();
176
191
  }, { once: true });
@@ -191,7 +206,9 @@ export class ChatClient {
191
206
  this.emit('closed', 'ended');
192
207
  return;
193
208
  }
194
- if (!this.closedByUs) void this.scheduleReconnect();
209
+ // Before a first join the app is still waiting on connect(), which
210
+ // reports the failure; there is no session to restore.
211
+ if (!this.closedByUs && this.joinedOnce) void this.scheduleReconnect();
195
212
  });
196
213
  });
197
214
  }
package/src/device.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * What kind of device is publishing, and which video codec it should send.
3
+ *
4
+ * Phones and tablets get different defaults from computers: each simulcast
5
+ * layer is a separate encode, and a phone encoding VP8 does it in software.
6
+ * Under that load Chrome shrinks the picture, and with three layers asked for
7
+ * it then sends only two and drops the TOP one: viewers got 180×320 from a
8
+ * 720p phone. Two layers in hardware H.264 keep the full picture.
9
+ *
10
+ * Everything here reads the browser through small injectable parameters, so
11
+ * tests can stand in for any device.
12
+ */
13
+ import type { types as ms } from 'mediasoup-client';
14
+
15
+ type RtpCodecCapability = ms.RtpCodecCapability;
16
+
17
+ /** What the SDK reads about the browser to choose its defaults. */
18
+ export interface DeviceEnv {
19
+ userAgent: string;
20
+ /** `navigator.userAgentData.mobile`, where the browser has it (Chromium). */
21
+ uaMobile?: boolean;
22
+ maxTouchPoints: number;
23
+ }
24
+
25
+ /** The browser this runs in; an empty desktop-like one outside a browser. */
26
+ export function currentEnv(): DeviceEnv {
27
+ if (typeof navigator === 'undefined') return { userAgent: '', maxTouchPoints: 0 };
28
+ const nav = navigator as Navigator & { userAgentData?: { mobile?: unknown } };
29
+ const mobile = nav.userAgentData?.mobile;
30
+ return {
31
+ userAgent: typeof nav.userAgent === 'string' ? nav.userAgent : '',
32
+ ...(typeof mobile === 'boolean' ? { uaMobile: mobile } : {}),
33
+ maxTouchPoints: typeof nav.maxTouchPoints === 'number' ? nav.maxTouchPoints : 0,
34
+ };
35
+ }
36
+
37
+ // iPadOS asks for desktop sites, so an iPad says "Macintosh". No Mac has a
38
+ // touch screen, so one that reports touch points is an iPad.
39
+ const isIPadOS = (env: DeviceEnv): boolean => /Macintosh/.test(env.userAgent) && env.maxTouchPoints > 1;
40
+
41
+ /**
42
+ * A phone or a tablet. `userAgentData.mobile` is false on Android tablets, so
43
+ * the user agent is checked as well.
44
+ */
45
+ export function isPhoneOrTablet(env: DeviceEnv = currentEnv()): boolean {
46
+ if (env.uaMobile === true) return true;
47
+ if (/Android|iPhone|iPad|iPod|Mobile/.test(env.userAgent)) return true;
48
+ return isIPadOS(env);
49
+ }
50
+
51
+ /** An iPhone, iPad or iPod: every one of them encodes H.264 in hardware. */
52
+ export function isAppleMobile(env: DeviceEnv = currentEnv()): boolean {
53
+ return /iPhone|iPad|iPod/.test(env.userAgent) || isIPadOS(env);
54
+ }
55
+
56
+ /** Two layers on phones and tablets, three elsewhere. */
57
+ export function defaultLayerCount(env: DeviceEnv = currentEnv()): 2 | 3 {
58
+ return isPhoneOrTablet(env) ? 2 : 3;
59
+ }
60
+
61
+ /** `navigator.mediaCapabilities.encodingInfo`, or a stand-in for tests. */
62
+ export type EncodingInfoProbe = (configuration: MediaEncodingConfiguration) => Promise<MediaCapabilitiesEncodingInfo>;
63
+
64
+ /** The browser's own probe, when it has one. */
65
+ export function currentProbe(): EncodingInfoProbe | undefined {
66
+ if (typeof navigator === 'undefined') return undefined;
67
+ const capabilities = (navigator as Navigator & { mediaCapabilities?: MediaCapabilities }).mediaCapabilities;
68
+ if (!capabilities || typeof capabilities.encodingInfo !== 'function') return undefined;
69
+ return (configuration) => capabilities.encodingInfo(configuration);
70
+ }
71
+
72
+ /** The H.264 the media server takes: constrained baseline, packetization mode 1. */
73
+ export const H264_CONTENT_TYPE = 'video/H264;profile-level-id=42e01f;packetization-mode=1';
74
+
75
+ const PROBE_TIMEOUT_MS = 1500;
76
+
77
+ /**
78
+ * Whether this browser encodes H.264 at this size and bitrate in hardware:
79
+ * true or false when it says so, null when it cannot say (no
80
+ * `mediaCapabilities`, no `webrtc` support in it, an error, or no answer in
81
+ * time). Publishing never waits on it for more than a moment.
82
+ */
83
+ export async function h264HardwareEncode(
84
+ video: { width: number; height: number; bitrate: number },
85
+ probe: EncodingInfoProbe | undefined = currentProbe(),
86
+ timeoutMs = PROBE_TIMEOUT_MS,
87
+ ): Promise<boolean | null> {
88
+ if (!probe) return null;
89
+ let timer: ReturnType<typeof setTimeout> | undefined;
90
+ try {
91
+ const answer = probe({
92
+ type: 'webrtc',
93
+ video: {
94
+ contentType: H264_CONTENT_TYPE,
95
+ width: Math.round(video.width),
96
+ height: Math.round(video.height),
97
+ bitrate: video.bitrate,
98
+ framerate: 30,
99
+ },
100
+ });
101
+ const late = new Promise<null>((resolve) => {
102
+ timer = setTimeout(() => resolve(null), timeoutMs);
103
+ });
104
+ const info = await Promise.race([answer, late]);
105
+ if (!info) return null;
106
+ return Boolean(info.supported && info.powerEfficient);
107
+ } catch {
108
+ return null;
109
+ } finally {
110
+ if (timer !== undefined) clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ /** What `PublishOptions.codec` may say. */
115
+ export type CodecPreference = 'auto' | 'vp8' | 'h264';
116
+
117
+ /** 'vp8' or 'h264' in any case; anything else, including nothing, is 'auto'. */
118
+ export function normalizeCodec(codec: unknown): CodecPreference {
119
+ const name = typeof codec === 'string' ? codec.toLowerCase() : '';
120
+ return name === 'vp8' || name === 'h264' ? name : 'auto';
121
+ }
122
+
123
+ export interface CodecChoiceInput {
124
+ requested: CodecPreference;
125
+ phoneOrTablet: boolean;
126
+ appleMobile: boolean;
127
+ /** This browser can send H.264 to this server (it is in the device's send capabilities). */
128
+ h264Available: boolean;
129
+ /** What the hardware probe said; null when it could not say. Only asked for 'auto' on phones and tablets. */
130
+ h264Hardware: boolean | null;
131
+ }
132
+
133
+ export interface CodecChoice {
134
+ codec: 'vp8' | 'h264';
135
+ /** Set when the codec asked for cannot be used: report it, and publish anyway. */
136
+ warning?: string;
137
+ }
138
+
139
+ /**
140
+ * The codec to publish with.
141
+ *
142
+ * 'auto' is H.264 on a phone or tablet that encodes it in hardware, and VP8
143
+ * everywhere else: computers encode VP8 well, and a few desktop browsers
144
+ * cannot decode H.264. When the probe cannot say, an Apple device is taken to
145
+ * have hardware H.264 (they all do) and anything else is not.
146
+ */
147
+ export function chooseCodec(input: CodecChoiceInput): CodecChoice {
148
+ if (input.requested === 'h264') {
149
+ return input.h264Available
150
+ ? { codec: 'h264' }
151
+ : { codec: 'vp8', warning: 'H.264 was asked for, but this browser cannot send it to this server: publishing VP8 instead' };
152
+ }
153
+ if (input.requested === 'vp8') return { codec: 'vp8' };
154
+ if (!input.phoneOrTablet || !input.h264Available) return { codec: 'vp8' };
155
+ return { codec: (input.h264Hardware ?? input.appleMobile) ? 'h264' : 'vp8' };
156
+ }
157
+
158
+ /**
159
+ * The device's own capability entry for a codec, to hand to `produce()`.
160
+ * Only ever pass `produce()` an entry from the device's send capabilities:
161
+ * mediasoup-client adds the transceiver before it checks the codec, so one it
162
+ * cannot match leaves a transceiver behind that breaks every later produce.
163
+ */
164
+ export function findCodec(codecs: readonly RtpCodecCapability[] | undefined, codec: 'vp8' | 'h264'): RtpCodecCapability | undefined {
165
+ if (!codecs) return undefined;
166
+ if (codec === 'vp8') return codecs.find((c) => c.mimeType.toLowerCase() === 'video/vp8');
167
+ return codecs.find((c) => c.mimeType.toLowerCase() === 'video/h264' && Number(c.parameters?.['packetization-mode'] ?? 0) === 1);
168
+ }