livekit-client 2.9.0 → 2.9.2
Sign up to get free protection for your applications and to get access to all the features.
- package/dist/livekit-client.esm.mjs +393 -75
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/connectionHelper/ConnectionCheck.d.ts +2 -0
- package/dist/src/connectionHelper/ConnectionCheck.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/Checker.d.ts +5 -2
- package/dist/src/connectionHelper/checks/Checker.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/cloudRegion.d.ts +17 -0
- package/dist/src/connectionHelper/checks/cloudRegion.d.ts.map +1 -0
- package/dist/src/connectionHelper/checks/connectionProtocol.d.ts +19 -0
- package/dist/src/connectionHelper/checks/connectionProtocol.d.ts.map +1 -0
- package/dist/src/connectionHelper/checks/publishAudio.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/publishVideo.d.ts +1 -0
- package/dist/src/connectionHelper/checks/publishVideo.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/StreamReader.d.ts +4 -4
- package/dist/src/room/StreamReader.d.ts.map +1 -1
- package/dist/src/room/StreamWriter.d.ts +3 -3
- package/dist/src/room/StreamWriter.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/create.d.ts.map +1 -1
- package/dist/src/room/types.d.ts +0 -5
- package/dist/src/room/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +1 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/src/connectionHelper/ConnectionCheck.d.ts +2 -0
- package/dist/ts4.2/src/connectionHelper/checks/Checker.d.ts +5 -2
- package/dist/ts4.2/src/connectionHelper/checks/cloudRegion.d.ts +18 -0
- package/dist/ts4.2/src/connectionHelper/checks/connectionProtocol.d.ts +20 -0
- package/dist/ts4.2/src/connectionHelper/checks/publishVideo.d.ts +1 -0
- package/dist/ts4.2/src/index.d.ts +2 -2
- package/dist/ts4.2/src/room/StreamReader.d.ts +4 -4
- package/dist/ts4.2/src/room/StreamWriter.d.ts +3 -12
- package/dist/ts4.2/src/room/types.d.ts +0 -5
- package/dist/ts4.2/src/room/utils.d.ts +1 -0
- package/package.json +17 -17
- package/src/connectionHelper/ConnectionCheck.ts +15 -0
- package/src/connectionHelper/checks/Checker.ts +41 -8
- package/src/connectionHelper/checks/cloudRegion.ts +94 -0
- package/src/connectionHelper/checks/connectionProtocol.ts +149 -0
- package/src/connectionHelper/checks/publishAudio.ts +8 -0
- package/src/connectionHelper/checks/publishVideo.ts +52 -0
- package/src/index.ts +1 -1
- package/src/room/StreamReader.ts +9 -16
- package/src/room/StreamWriter.ts +4 -4
- package/src/room/participant/LocalParticipant.ts +9 -26
- package/src/room/participant/publishUtils.ts +4 -0
- package/src/room/track/LocalTrack.ts +5 -2
- package/src/room/track/create.ts +9 -5
- package/src/room/types.ts +0 -6
- package/src/room/utils.ts +16 -0
@@ -2,6 +2,8 @@ import { EventEmitter } from 'events';
|
|
2
2
|
import type TypedEmitter from 'typed-emitter';
|
3
3
|
import type { CheckInfo, CheckerOptions, InstantiableCheck } from './checks/Checker';
|
4
4
|
import { CheckStatus, Checker } from './checks/Checker';
|
5
|
+
import { CloudRegionCheck } from './checks/cloudRegion';
|
6
|
+
import { ConnectionProtocolCheck, type ProtocolStats } from './checks/connectionProtocol';
|
5
7
|
import { PublishAudioCheck } from './checks/publishAudio';
|
6
8
|
import { PublishVideoCheck } from './checks/publishVideo';
|
7
9
|
import { ReconnectCheck } from './checks/reconnect';
|
@@ -86,6 +88,19 @@ export class ConnectionCheck extends (EventEmitter as new () => TypedEmitter<Con
|
|
86
88
|
async checkPublishVideo() {
|
87
89
|
return this.createAndRunCheck(PublishVideoCheck);
|
88
90
|
}
|
91
|
+
|
92
|
+
async checkConnectionProtocol() {
|
93
|
+
const info = await this.createAndRunCheck(ConnectionProtocolCheck);
|
94
|
+
if (info.data && 'protocol' in info.data) {
|
95
|
+
const stats = info.data as ProtocolStats;
|
96
|
+
this.options.protocol = stats.protocol;
|
97
|
+
}
|
98
|
+
return info;
|
99
|
+
}
|
100
|
+
|
101
|
+
async checkCloudRegion() {
|
102
|
+
return this.createAndRunCheck(CloudRegionCheck);
|
103
|
+
}
|
89
104
|
}
|
90
105
|
|
91
106
|
type ConnectionCheckCallbacks = {
|
@@ -3,6 +3,9 @@ import type TypedEmitter from 'typed-emitter';
|
|
3
3
|
import type { RoomConnectOptions, RoomOptions } from '../../options';
|
4
4
|
import type RTCEngine from '../../room/RTCEngine';
|
5
5
|
import Room, { ConnectionState } from '../../room/Room';
|
6
|
+
import { RoomEvent } from '../../room/events';
|
7
|
+
import type { SimulationScenario } from '../../room/types';
|
8
|
+
import { sleep } from '../../room/utils';
|
6
9
|
|
7
10
|
type LogMessage = {
|
8
11
|
level: 'info' | 'warning' | 'error';
|
@@ -22,12 +25,14 @@ export type CheckInfo = {
|
|
22
25
|
logs: Array<LogMessage>;
|
23
26
|
status: CheckStatus;
|
24
27
|
description: string;
|
28
|
+
data?: any;
|
25
29
|
};
|
26
30
|
|
27
31
|
export interface CheckerOptions {
|
28
32
|
errorsAsWarnings?: boolean;
|
29
33
|
roomOptions?: RoomOptions;
|
30
34
|
connectOptions?: RoomConnectOptions;
|
35
|
+
protocol?: 'udp' | 'tcp';
|
31
36
|
}
|
32
37
|
|
33
38
|
export abstract class Checker extends (EventEmitter as new () => TypedEmitter<CheckerCallbacks>) {
|
@@ -43,10 +48,10 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
43
48
|
|
44
49
|
logs: Array<LogMessage> = [];
|
45
50
|
|
46
|
-
errorsAsWarnings: boolean = false;
|
47
|
-
|
48
51
|
name: string;
|
49
52
|
|
53
|
+
options: CheckerOptions = {};
|
54
|
+
|
50
55
|
constructor(url: string, token: string, options: CheckerOptions = {}) {
|
51
56
|
super();
|
52
57
|
this.url = url;
|
@@ -54,9 +59,7 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
54
59
|
this.name = this.constructor.name;
|
55
60
|
this.room = new Room(options.roomOptions);
|
56
61
|
this.connectOptions = options.connectOptions;
|
57
|
-
|
58
|
-
this.errorsAsWarnings = options.errorsAsWarnings;
|
59
|
-
}
|
62
|
+
this.options = options;
|
60
63
|
}
|
61
64
|
|
62
65
|
abstract get description(): string;
|
@@ -73,7 +76,7 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
73
76
|
await this.perform();
|
74
77
|
} catch (err) {
|
75
78
|
if (err instanceof Error) {
|
76
|
-
if (this.errorsAsWarnings) {
|
79
|
+
if (this.options.errorsAsWarnings) {
|
77
80
|
this.appendWarning(err.message);
|
78
81
|
} else {
|
79
82
|
this.appendError(err.message);
|
@@ -101,11 +104,14 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
101
104
|
return !this.logs.some((l) => l.level === 'error');
|
102
105
|
}
|
103
106
|
|
104
|
-
protected async connect(): Promise<Room> {
|
107
|
+
protected async connect(url?: string): Promise<Room> {
|
105
108
|
if (this.room.state === ConnectionState.Connected) {
|
106
109
|
return this.room;
|
107
110
|
}
|
108
|
-
|
111
|
+
if (!url) {
|
112
|
+
url = this.url;
|
113
|
+
}
|
114
|
+
await this.room.connect(url, this.token, this.connectOptions);
|
109
115
|
return this.room;
|
110
116
|
}
|
111
117
|
|
@@ -121,6 +127,33 @@ export abstract class Checker extends (EventEmitter as new () => TypedEmitter<Ch
|
|
121
127
|
this.setStatus(CheckStatus.SKIPPED);
|
122
128
|
}
|
123
129
|
|
130
|
+
protected async switchProtocol(protocol: 'udp' | 'tcp' | 'tls') {
|
131
|
+
let hasReconnecting = false;
|
132
|
+
let hasReconnected = false;
|
133
|
+
this.room.on(RoomEvent.Reconnecting, () => {
|
134
|
+
hasReconnecting = true;
|
135
|
+
});
|
136
|
+
this.room.once(RoomEvent.Reconnected, () => {
|
137
|
+
hasReconnected = true;
|
138
|
+
});
|
139
|
+
this.room.simulateScenario(`force-${protocol}` as SimulationScenario);
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
141
|
+
if (!hasReconnecting) {
|
142
|
+
// no need to wait for reconnection
|
143
|
+
return;
|
144
|
+
}
|
145
|
+
|
146
|
+
// wait for 10 seconds for reconnection
|
147
|
+
const timeout = Date.now() + 10000;
|
148
|
+
while (Date.now() < timeout) {
|
149
|
+
if (hasReconnected) {
|
150
|
+
return;
|
151
|
+
}
|
152
|
+
await sleep(100);
|
153
|
+
}
|
154
|
+
throw new Error(`Could not reconnect using ${protocol} protocol after 10 seconds`);
|
155
|
+
}
|
156
|
+
|
124
157
|
protected appendMessage(message: string) {
|
125
158
|
this.logs.push({ level: 'info', message });
|
126
159
|
this.emit('update', this.getInfo());
|
@@ -0,0 +1,94 @@
|
|
1
|
+
import { RegionUrlProvider } from '../../room/RegionUrlProvider';
|
2
|
+
import { type CheckInfo, Checker } from './Checker';
|
3
|
+
|
4
|
+
export interface RegionStats {
|
5
|
+
region: string;
|
6
|
+
rtt: number;
|
7
|
+
duration: number;
|
8
|
+
}
|
9
|
+
|
10
|
+
/**
|
11
|
+
* Checks for connections quality to closests Cloud regions and determining the best quality
|
12
|
+
*/
|
13
|
+
export class CloudRegionCheck extends Checker {
|
14
|
+
private bestStats?: RegionStats;
|
15
|
+
|
16
|
+
get description(): string {
|
17
|
+
return 'Cloud regions';
|
18
|
+
}
|
19
|
+
|
20
|
+
async perform(): Promise<void> {
|
21
|
+
const regionProvider = new RegionUrlProvider(this.url, this.token);
|
22
|
+
if (!regionProvider.isCloud()) {
|
23
|
+
this.skip();
|
24
|
+
return;
|
25
|
+
}
|
26
|
+
|
27
|
+
const regionStats: RegionStats[] = [];
|
28
|
+
const seenUrls: Set<string> = new Set();
|
29
|
+
for (let i = 0; i < 3; i++) {
|
30
|
+
const regionUrl = await regionProvider.getNextBestRegionUrl();
|
31
|
+
if (!regionUrl) {
|
32
|
+
break;
|
33
|
+
}
|
34
|
+
if (seenUrls.has(regionUrl)) {
|
35
|
+
continue;
|
36
|
+
}
|
37
|
+
seenUrls.add(regionUrl);
|
38
|
+
const stats = await this.checkCloudRegion(regionUrl);
|
39
|
+
this.appendMessage(`${stats.region} RTT: ${stats.rtt}ms, duration: ${stats.duration}ms`);
|
40
|
+
regionStats.push(stats);
|
41
|
+
}
|
42
|
+
|
43
|
+
regionStats.sort((a, b) => {
|
44
|
+
return (a.duration - b.duration) * 0.5 + (a.rtt - b.rtt) * 0.5;
|
45
|
+
});
|
46
|
+
const bestRegion = regionStats[0];
|
47
|
+
this.bestStats = bestRegion;
|
48
|
+
this.appendMessage(`best Cloud region: ${bestRegion.region}`);
|
49
|
+
}
|
50
|
+
|
51
|
+
getInfo(): CheckInfo {
|
52
|
+
const info = super.getInfo();
|
53
|
+
info.data = this.bestStats;
|
54
|
+
return info;
|
55
|
+
}
|
56
|
+
|
57
|
+
private async checkCloudRegion(url: string): Promise<RegionStats> {
|
58
|
+
await this.connect(url);
|
59
|
+
if (this.options.protocol === 'tcp') {
|
60
|
+
await this.switchProtocol('tcp');
|
61
|
+
}
|
62
|
+
const region = this.room.serverInfo?.region;
|
63
|
+
if (!region) {
|
64
|
+
throw new Error('Region not found');
|
65
|
+
}
|
66
|
+
|
67
|
+
const writer = await this.room.localParticipant.streamText({ topic: 'test' });
|
68
|
+
const chunkSize = 1000; // each chunk is about 1000 bytes
|
69
|
+
const totalSize = 1_000_000; // approximately 1MB of data
|
70
|
+
const numChunks = totalSize / chunkSize; // will yield 1000 chunks
|
71
|
+
const chunkData = 'A'.repeat(chunkSize); // create a string of 1000 'A' characters
|
72
|
+
|
73
|
+
const startTime = Date.now();
|
74
|
+
for (let i = 0; i < numChunks; i++) {
|
75
|
+
await writer.write(chunkData);
|
76
|
+
}
|
77
|
+
await writer.close();
|
78
|
+
const endTime = Date.now();
|
79
|
+
const stats = await this.room.engine.pcManager?.publisher.getStats();
|
80
|
+
const regionStats: RegionStats = {
|
81
|
+
region: region,
|
82
|
+
rtt: 10000,
|
83
|
+
duration: endTime - startTime,
|
84
|
+
};
|
85
|
+
stats?.forEach((stat) => {
|
86
|
+
if (stat.type === 'candidate-pair' && stat.nominated) {
|
87
|
+
regionStats.rtt = stat.currentRoundTripTime * 1000;
|
88
|
+
}
|
89
|
+
});
|
90
|
+
|
91
|
+
await this.disconnect();
|
92
|
+
return regionStats;
|
93
|
+
}
|
94
|
+
}
|
@@ -0,0 +1,149 @@
|
|
1
|
+
import { type CheckInfo, Checker } from './Checker';
|
2
|
+
|
3
|
+
export interface ProtocolStats {
|
4
|
+
protocol: 'udp' | 'tcp';
|
5
|
+
packetsLost: number;
|
6
|
+
packetsSent: number;
|
7
|
+
qualityLimitationDurations: Record<string, number>;
|
8
|
+
// total metrics measure sum of all measurements, along with a count
|
9
|
+
rttTotal: number;
|
10
|
+
jitterTotal: number;
|
11
|
+
bitrateTotal: number;
|
12
|
+
count: number;
|
13
|
+
}
|
14
|
+
|
15
|
+
const TEST_DURATION = 10000;
|
16
|
+
|
17
|
+
export class ConnectionProtocolCheck extends Checker {
|
18
|
+
private bestStats?: ProtocolStats;
|
19
|
+
|
20
|
+
get description(): string {
|
21
|
+
return 'Connection via UDP vs TCP';
|
22
|
+
}
|
23
|
+
|
24
|
+
async perform(): Promise<void> {
|
25
|
+
const udpStats = await this.checkConnectionProtocol('udp');
|
26
|
+
const tcpStats = await this.checkConnectionProtocol('tcp');
|
27
|
+
this.bestStats = udpStats;
|
28
|
+
// udp should is the better protocol typically. however, we'd prefer TCP when either of these conditions are true:
|
29
|
+
// 1. the bandwidth limitation is worse on UDP by 500ms
|
30
|
+
// 2. the packet loss is higher on UDP by 1%
|
31
|
+
if (
|
32
|
+
udpStats.qualityLimitationDurations.bandwidth -
|
33
|
+
tcpStats.qualityLimitationDurations.bandwidth >
|
34
|
+
0.5 ||
|
35
|
+
(udpStats.packetsLost - tcpStats.packetsLost) / udpStats.packetsSent > 0.01
|
36
|
+
) {
|
37
|
+
this.appendMessage('best connection quality via tcp');
|
38
|
+
this.bestStats = tcpStats;
|
39
|
+
} else {
|
40
|
+
this.appendMessage('best connection quality via udp');
|
41
|
+
}
|
42
|
+
|
43
|
+
const stats = this.bestStats;
|
44
|
+
this.appendMessage(
|
45
|
+
`upstream bitrate: ${(stats.bitrateTotal / stats.count / 1000 / 1000).toFixed(2)} mbps`,
|
46
|
+
);
|
47
|
+
this.appendMessage(`RTT: ${((stats.rttTotal / stats.count) * 1000).toFixed(2)} ms`);
|
48
|
+
this.appendMessage(`jitter: ${((stats.jitterTotal / stats.count) * 1000).toFixed(2)} ms`);
|
49
|
+
|
50
|
+
if (stats.packetsLost > 0) {
|
51
|
+
this.appendWarning(
|
52
|
+
`packets lost: ${((stats.packetsLost / stats.packetsSent) * 100).toFixed(2)}%`,
|
53
|
+
);
|
54
|
+
}
|
55
|
+
if (stats.qualityLimitationDurations.bandwidth > 1) {
|
56
|
+
this.appendWarning(
|
57
|
+
`bandwidth limited ${((stats.qualityLimitationDurations.bandwidth / (TEST_DURATION / 1000)) * 100).toFixed(2)}%`,
|
58
|
+
);
|
59
|
+
}
|
60
|
+
if (stats.qualityLimitationDurations.cpu > 0) {
|
61
|
+
this.appendWarning(
|
62
|
+
`cpu limited ${((stats.qualityLimitationDurations.cpu / (TEST_DURATION / 1000)) * 100).toFixed(2)}%`,
|
63
|
+
);
|
64
|
+
}
|
65
|
+
}
|
66
|
+
|
67
|
+
getInfo(): CheckInfo {
|
68
|
+
const info = super.getInfo();
|
69
|
+
info.data = this.bestStats;
|
70
|
+
return info;
|
71
|
+
}
|
72
|
+
|
73
|
+
private async checkConnectionProtocol(protocol: 'tcp' | 'udp'): Promise<ProtocolStats> {
|
74
|
+
await this.connect();
|
75
|
+
if (protocol === 'tcp') {
|
76
|
+
await this.switchProtocol('tcp');
|
77
|
+
} else {
|
78
|
+
await this.switchProtocol('udp');
|
79
|
+
}
|
80
|
+
|
81
|
+
// create a canvas with animated content
|
82
|
+
const canvas = document.createElement('canvas');
|
83
|
+
canvas.width = 1280;
|
84
|
+
canvas.height = 720;
|
85
|
+
const ctx = canvas.getContext('2d');
|
86
|
+
if (!ctx) {
|
87
|
+
throw new Error('Could not get canvas context');
|
88
|
+
}
|
89
|
+
|
90
|
+
let hue = 0;
|
91
|
+
const animate = () => {
|
92
|
+
hue = (hue + 1) % 360;
|
93
|
+
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
|
94
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
95
|
+
requestAnimationFrame(animate);
|
96
|
+
};
|
97
|
+
animate();
|
98
|
+
|
99
|
+
// create video track from canvas
|
100
|
+
const stream = canvas.captureStream(30); // 30fps
|
101
|
+
const videoTrack = stream.getVideoTracks()[0];
|
102
|
+
|
103
|
+
// publish to room
|
104
|
+
const pub = await this.room.localParticipant.publishTrack(videoTrack, {
|
105
|
+
simulcast: false,
|
106
|
+
degradationPreference: 'maintain-resolution',
|
107
|
+
videoEncoding: {
|
108
|
+
maxBitrate: 2000000,
|
109
|
+
},
|
110
|
+
});
|
111
|
+
const track = pub!.track!;
|
112
|
+
|
113
|
+
const protocolStats: ProtocolStats = {
|
114
|
+
protocol,
|
115
|
+
packetsLost: 0,
|
116
|
+
packetsSent: 0,
|
117
|
+
qualityLimitationDurations: {},
|
118
|
+
rttTotal: 0,
|
119
|
+
jitterTotal: 0,
|
120
|
+
bitrateTotal: 0,
|
121
|
+
count: 0,
|
122
|
+
};
|
123
|
+
// gather stats once a second
|
124
|
+
const interval = setInterval(async () => {
|
125
|
+
const stats = await track.getRTCStatsReport();
|
126
|
+
stats?.forEach((stat) => {
|
127
|
+
if (stat.type === 'outbound-rtp') {
|
128
|
+
protocolStats.packetsSent = stat.packetsSent;
|
129
|
+
protocolStats.qualityLimitationDurations = stat.qualityLimitationDurations;
|
130
|
+
protocolStats.bitrateTotal += stat.targetBitrate;
|
131
|
+
protocolStats.count++;
|
132
|
+
} else if (stat.type === 'remote-inbound-rtp') {
|
133
|
+
protocolStats.packetsLost = stat.packetsLost;
|
134
|
+
protocolStats.rttTotal += stat.roundTripTime;
|
135
|
+
protocolStats.jitterTotal += stat.jitter;
|
136
|
+
}
|
137
|
+
});
|
138
|
+
}, 1000);
|
139
|
+
|
140
|
+
// wait a bit to gather stats
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, TEST_DURATION));
|
142
|
+
clearInterval(interval);
|
143
|
+
|
144
|
+
videoTrack.stop();
|
145
|
+
canvas.remove();
|
146
|
+
await this.disconnect();
|
147
|
+
return protocolStats;
|
148
|
+
}
|
149
|
+
}
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import { createLocalAudioTrack } from '../../room/track/create';
|
2
|
+
import { detectSilence } from '../../room/track/utils';
|
2
3
|
import { Checker } from './Checker';
|
3
4
|
|
4
5
|
export class PublishAudioCheck extends Checker {
|
@@ -10,6 +11,13 @@ export class PublishAudioCheck extends Checker {
|
|
10
11
|
const room = await this.connect();
|
11
12
|
|
12
13
|
const track = await createLocalAudioTrack();
|
14
|
+
|
15
|
+
const trackIsSilent = await detectSilence(track, 1000);
|
16
|
+
if (trackIsSilent) {
|
17
|
+
throw new Error('unable to detect audio from microphone');
|
18
|
+
}
|
19
|
+
this.appendMessage('detected audio from microphone');
|
20
|
+
|
13
21
|
room.localParticipant.publishTrack(track);
|
14
22
|
// wait for a few seconds to publish
|
15
23
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
@@ -10,6 +10,10 @@ export class PublishVideoCheck extends Checker {
|
|
10
10
|
const room = await this.connect();
|
11
11
|
|
12
12
|
const track = await createLocalVideoTrack();
|
13
|
+
|
14
|
+
// check if we have video from camera
|
15
|
+
await this.checkForVideo(track.mediaStreamTrack);
|
16
|
+
|
13
17
|
room.localParticipant.publishTrack(track);
|
14
18
|
// wait for a few seconds to publish
|
15
19
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
@@ -33,4 +37,52 @@ export class PublishVideoCheck extends Checker {
|
|
33
37
|
}
|
34
38
|
this.appendMessage(`published ${numPackets} video packets`);
|
35
39
|
}
|
40
|
+
|
41
|
+
async checkForVideo(track: MediaStreamTrack) {
|
42
|
+
const stream = new MediaStream();
|
43
|
+
stream.addTrack(track.clone());
|
44
|
+
|
45
|
+
// Create video element to check frames
|
46
|
+
const video = document.createElement('video');
|
47
|
+
video.srcObject = stream;
|
48
|
+
video.muted = true;
|
49
|
+
|
50
|
+
await new Promise<void>((resolve) => {
|
51
|
+
video.onplay = () => {
|
52
|
+
setTimeout(() => {
|
53
|
+
const canvas = document.createElement('canvas');
|
54
|
+
const settings = track.getSettings();
|
55
|
+
const width = settings.width ?? video.videoWidth ?? 1280;
|
56
|
+
const height = settings.height ?? video.videoHeight ?? 720;
|
57
|
+
canvas.width = width;
|
58
|
+
canvas.height = height;
|
59
|
+
const ctx = canvas.getContext('2d')!;
|
60
|
+
|
61
|
+
// Draw video frame to canvas
|
62
|
+
ctx.drawImage(video, 0, 0);
|
63
|
+
|
64
|
+
// Get image data and check if all pixels are black
|
65
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
66
|
+
const data = imageData.data;
|
67
|
+
let isAllBlack = true;
|
68
|
+
for (let i = 0; i < data.length; i += 4) {
|
69
|
+
if (data[i] !== 0 || data[i + 1] !== 0 || data[i + 2] !== 0) {
|
70
|
+
isAllBlack = false;
|
71
|
+
break;
|
72
|
+
}
|
73
|
+
}
|
74
|
+
|
75
|
+
if (isAllBlack) {
|
76
|
+
this.appendError('camera appears to be producing only black frames');
|
77
|
+
} else {
|
78
|
+
this.appendMessage('received video frames');
|
79
|
+
}
|
80
|
+
resolve();
|
81
|
+
}, 1000);
|
82
|
+
};
|
83
|
+
video.play();
|
84
|
+
});
|
85
|
+
|
86
|
+
video.remove();
|
87
|
+
}
|
36
88
|
}
|
package/src/index.ts
CHANGED
@@ -95,6 +95,7 @@ export {
|
|
95
95
|
Room,
|
96
96
|
SubscriptionError,
|
97
97
|
TrackPublication,
|
98
|
+
TrackType,
|
98
99
|
compareVersions,
|
99
100
|
createAudioAnalyser,
|
100
101
|
getBrowser,
|
@@ -127,5 +128,4 @@ export type {
|
|
127
128
|
VideoSenderStats,
|
128
129
|
ReconnectContext,
|
129
130
|
ReconnectPolicy,
|
130
|
-
TrackType,
|
131
131
|
};
|
package/src/room/StreamReader.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import type { DataStream_Chunk } from '@livekit/protocol';
|
2
|
-
import type { BaseStreamInfo, ByteStreamInfo,
|
2
|
+
import type { BaseStreamInfo, ByteStreamInfo, TextStreamInfo } from './types';
|
3
3
|
import { bigIntToNumber } from './utils';
|
4
4
|
|
5
5
|
abstract class BaseStreamReader<T extends BaseStreamInfo> {
|
@@ -59,7 +59,7 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
|
|
59
59
|
}
|
60
60
|
},
|
61
61
|
|
62
|
-
return(): IteratorResult<Uint8Array
|
62
|
+
async return(): Promise<IteratorResult<Uint8Array>> {
|
63
63
|
reader.releaseLock();
|
64
64
|
return { done: true, value: undefined };
|
65
65
|
},
|
@@ -124,7 +124,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
124
124
|
const decoder = new TextDecoder();
|
125
125
|
|
126
126
|
return {
|
127
|
-
next: async (): Promise<IteratorResult<
|
127
|
+
next: async (): Promise<IteratorResult<string>> => {
|
128
128
|
try {
|
129
129
|
const { done, value } = await reader.read();
|
130
130
|
if (done) {
|
@@ -134,14 +134,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
134
134
|
|
135
135
|
return {
|
136
136
|
done: false,
|
137
|
-
value:
|
138
|
-
index: bigIntToNumber(value.chunkIndex),
|
139
|
-
current: decoder.decode(value.content),
|
140
|
-
collected: Array.from(this.receivedChunks.values())
|
141
|
-
.sort((a, b) => bigIntToNumber(a.chunkIndex) - bigIntToNumber(b.chunkIndex))
|
142
|
-
.map((chunk) => decoder.decode(chunk.content))
|
143
|
-
.join(''),
|
144
|
-
},
|
137
|
+
value: decoder.decode(value.content),
|
145
138
|
};
|
146
139
|
}
|
147
140
|
} catch (error) {
|
@@ -150,7 +143,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
150
143
|
}
|
151
144
|
},
|
152
145
|
|
153
|
-
return(): IteratorResult<
|
146
|
+
async return(): Promise<IteratorResult<string>> {
|
154
147
|
reader.releaseLock();
|
155
148
|
return { done: true, value: undefined };
|
156
149
|
},
|
@@ -158,11 +151,11 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
158
151
|
}
|
159
152
|
|
160
153
|
async readAll(): Promise<string> {
|
161
|
-
let
|
162
|
-
for await (const
|
163
|
-
|
154
|
+
let finalString: string = '';
|
155
|
+
for await (const chunk of this) {
|
156
|
+
finalString += chunk;
|
164
157
|
}
|
165
|
-
return
|
158
|
+
return finalString;
|
166
159
|
}
|
167
160
|
}
|
168
161
|
|
package/src/room/StreamWriter.ts
CHANGED
@@ -1,15 +1,15 @@
|
|
1
1
|
import type { BaseStreamInfo, ByteStreamInfo, TextStreamInfo } from './types';
|
2
2
|
|
3
3
|
class BaseStreamWriter<T, InfoType extends BaseStreamInfo> {
|
4
|
-
protected writableStream: WritableStream<
|
4
|
+
protected writableStream: WritableStream<T>;
|
5
5
|
|
6
|
-
protected defaultWriter: WritableStreamDefaultWriter<
|
6
|
+
protected defaultWriter: WritableStreamDefaultWriter<T>;
|
7
7
|
|
8
8
|
protected onClose?: () => void;
|
9
9
|
|
10
10
|
readonly info: InfoType;
|
11
11
|
|
12
|
-
constructor(writableStream: WritableStream<
|
12
|
+
constructor(writableStream: WritableStream<T>, info: InfoType, onClose?: () => void) {
|
13
13
|
this.writableStream = writableStream;
|
14
14
|
this.defaultWriter = writableStream.getWriter();
|
15
15
|
this.onClose = onClose;
|
@@ -17,7 +17,7 @@ class BaseStreamWriter<T, InfoType extends BaseStreamInfo> {
|
|
17
17
|
}
|
18
18
|
|
19
19
|
write(chunk: T): Promise<void> {
|
20
|
-
return this.defaultWriter.write(
|
20
|
+
return this.defaultWriter.write(chunk);
|
21
21
|
}
|
22
22
|
|
23
23
|
async close() {
|
@@ -90,6 +90,7 @@ import {
|
|
90
90
|
isWeb,
|
91
91
|
numberToBigInt,
|
92
92
|
sleep,
|
93
|
+
splitUtf8,
|
93
94
|
supportsAV1,
|
94
95
|
supportsVP9,
|
95
96
|
} from '../utils';
|
@@ -1536,19 +1537,9 @@ export default class LocalParticipant extends Participant {
|
|
1536
1537
|
attachedStreamIds: fileIds,
|
1537
1538
|
});
|
1538
1539
|
|
1539
|
-
|
1540
|
-
|
1541
|
-
|
1542
|
-
for (let i = 0; i < totalTextChunks; i++) {
|
1543
|
-
const chunkData = text.slice(
|
1544
|
-
i * textChunkSize,
|
1545
|
-
Math.min((i + 1) * textChunkSize, totalTextLength),
|
1546
|
-
);
|
1547
|
-
await this.engine.waitForBufferStatusLow(DataPacket_Kind.RELIABLE);
|
1548
|
-
await writer.write(chunkData);
|
1549
|
-
|
1550
|
-
handleProgress(Math.ceil((i + 1) / totalTextChunks), 0);
|
1551
|
-
}
|
1540
|
+
await writer.write(text);
|
1541
|
+
// set text part of progress to 1
|
1542
|
+
handleProgress(1, 0);
|
1552
1543
|
|
1553
1544
|
await writer.close();
|
1554
1545
|
|
@@ -1614,20 +1605,13 @@ export default class LocalParticipant extends Participant {
|
|
1614
1605
|
let chunkId = 0;
|
1615
1606
|
const localP = this;
|
1616
1607
|
|
1617
|
-
const writableStream = new WritableStream<
|
1608
|
+
const writableStream = new WritableStream<string>({
|
1618
1609
|
// Implement the sink
|
1619
|
-
write(
|
1620
|
-
const
|
1621
|
-
|
1622
|
-
if (textInBytes.byteLength > STREAM_CHUNK_SIZE) {
|
1623
|
-
this.abort?.();
|
1624
|
-
throw new Error('chunk size too large');
|
1625
|
-
}
|
1626
|
-
|
1627
|
-
return new Promise(async (resolve) => {
|
1610
|
+
async write(text) {
|
1611
|
+
for (const textChunk in splitUtf8(text, STREAM_CHUNK_SIZE)) {
|
1628
1612
|
await localP.engine.waitForBufferStatusLow(DataPacket_Kind.RELIABLE);
|
1629
1613
|
const chunk = new DataStream_Chunk({
|
1630
|
-
content:
|
1614
|
+
content: new TextEncoder().encode(textChunk),
|
1631
1615
|
streamId,
|
1632
1616
|
chunkIndex: numberToBigInt(chunkId),
|
1633
1617
|
});
|
@@ -1641,8 +1625,7 @@ export default class LocalParticipant extends Participant {
|
|
1641
1625
|
await localP.engine.sendDataPacket(chunkPacket, DataPacket_Kind.RELIABLE);
|
1642
1626
|
|
1643
1627
|
chunkId += 1;
|
1644
|
-
|
1645
|
-
});
|
1628
|
+
}
|
1646
1629
|
},
|
1647
1630
|
async close() {
|
1648
1631
|
const trailer = new DataStream_Trailer({
|
@@ -256,6 +256,10 @@ export function computeTrackBackupEncodings(
|
|
256
256
|
const width = settings.width ?? track.dimensions?.width;
|
257
257
|
const height = settings.height ?? track.dimensions?.height;
|
258
258
|
|
259
|
+
// disable simulcast for screenshare backup codec since L1Tx is used by primary codec
|
260
|
+
if (track.source === Track.Source.ScreenShare && opts.simulcast) {
|
261
|
+
opts.simulcast = false;
|
262
|
+
}
|
259
263
|
const encodings = computeVideoEncodings(
|
260
264
|
track.source === Track.Source.ScreenShare,
|
261
265
|
width,
|
@@ -232,6 +232,7 @@ export default abstract class LocalTrack<
|
|
232
232
|
) {
|
233
233
|
return true;
|
234
234
|
}
|
235
|
+
|
235
236
|
this._constraints.deviceId = deviceId;
|
236
237
|
|
237
238
|
// when track is muted, underlying media stream track is stopped and
|
@@ -313,6 +314,7 @@ export default abstract class LocalTrack<
|
|
313
314
|
if (!constraints) {
|
314
315
|
constraints = this._constraints;
|
315
316
|
}
|
317
|
+
const { deviceId, ...otherConstraints } = this._constraints;
|
316
318
|
this.log.debug('restarting track with constraints', { ...this.logContext, constraints });
|
317
319
|
|
318
320
|
const streamConstraints: MediaStreamConstraints = {
|
@@ -321,9 +323,9 @@ export default abstract class LocalTrack<
|
|
321
323
|
};
|
322
324
|
|
323
325
|
if (this.kind === Track.Kind.Video) {
|
324
|
-
streamConstraints.video =
|
326
|
+
streamConstraints.video = deviceId ? { deviceId } : true;
|
325
327
|
} else {
|
326
|
-
streamConstraints.audio =
|
328
|
+
streamConstraints.audio = deviceId ? { deviceId } : true;
|
327
329
|
}
|
328
330
|
|
329
331
|
// these steps are duplicated from setMediaStreamTrack because we must stop
|
@@ -340,6 +342,7 @@ export default abstract class LocalTrack<
|
|
340
342
|
// create new track and attach
|
341
343
|
const mediaStream = await navigator.mediaDevices.getUserMedia(streamConstraints);
|
342
344
|
const newTrack = mediaStream.getTracks()[0];
|
345
|
+
await newTrack.applyConstraints(otherConstraints);
|
343
346
|
newTrack.addEventListener('ended', this.handleEnded);
|
344
347
|
this.log.debug('re-acquired MediaStreamTrack', this.logContext);
|
345
348
|
|