replicas-computer 0.2.15 → 0.3.1
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/README.md +36 -4
- package/assets/desktop/geist-mono-400.woff2 +0 -0
- package/assets/desktop/geist-mono-600.woff2 +0 -0
- package/assets/desktop/geist-mono-700.woff2 +0 -0
- package/assets/desktop/general-sans-300.woff2 +0 -0
- package/assets/desktop/start-services.sh +4 -0
- package/assets/desktop/stream-index.html +4 -4
- package/assets/desktop/stream-viewer.css +5 -0
- package/assets/desktop/viewer-theme.css +4 -4
- package/dist/index.js +1707 -549
- package/dist/stream-viewer.js +519 -67
- package/dist/viewer.js +0 -9
- package/package.json +6 -2
- package/patches/libdatachannel-dtls-bio.patch +20 -0
- package/patches/libdatachannel-nack-duplicates.patch +13 -0
- package/scripts/build-webrtc.sh +31 -0
package/dist/stream-viewer.js
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
|
+
// ../shared/src/type-guards.ts
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// src/input-validation.ts
|
|
7
|
+
var finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
8
|
+
var iceServer = (value) => {
|
|
9
|
+
if (!isRecord(value)) return false;
|
|
10
|
+
return typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
|
|
11
|
+
};
|
|
12
|
+
function validServerMessage(value) {
|
|
13
|
+
if (!isRecord(value) || typeof value.type !== "string") return false;
|
|
14
|
+
switch (value.type) {
|
|
15
|
+
case "ping":
|
|
16
|
+
case "pong":
|
|
17
|
+
return finite(value.sentAt);
|
|
18
|
+
case "webrtc-failed":
|
|
19
|
+
return true;
|
|
20
|
+
case "offer":
|
|
21
|
+
return typeof value.sdp === "string" && value.sdp.length < 65536;
|
|
22
|
+
case "candidate":
|
|
23
|
+
return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
|
|
24
|
+
case "config":
|
|
25
|
+
return finite(value.protocolVersion) && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
|
|
26
|
+
case "telemetry":
|
|
27
|
+
return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer);
|
|
28
|
+
default:
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ../shared/src/desktop-stream.ts
|
|
34
|
+
var DEFAULT_STREAM_REQUEST = {
|
|
35
|
+
transport: "webrtc",
|
|
36
|
+
profile: "balanced",
|
|
37
|
+
adaptive: true,
|
|
38
|
+
iceTransportPolicy: "all"
|
|
39
|
+
};
|
|
40
|
+
function parseStreamRequest(query, defaults = DEFAULT_STREAM_REQUEST) {
|
|
41
|
+
const transport2 = query.get("transport");
|
|
42
|
+
const profile = query.get("profile");
|
|
43
|
+
const adapt = query.get("adapt");
|
|
44
|
+
const ice = query.get("ice");
|
|
45
|
+
return {
|
|
46
|
+
transport: transport2 === "webrtc" || transport2 === "websocket" ? transport2 : defaults.transport,
|
|
47
|
+
profile: profile === "text" || profile === "balanced" ? profile : defaults.profile,
|
|
48
|
+
adaptive: adapt === "0" ? false : adapt === "1" ? true : defaults.adaptive,
|
|
49
|
+
iceTransportPolicy: ice === "relay" || ice === "all" ? ice : defaults.iceTransportPolicy
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
var H264_PROFILES = {
|
|
53
|
+
high444: { payloadType: 99, profileLevelId: "f4001f", codec: "avc1.F4002A", label: "High 4:4:4" },
|
|
54
|
+
high: { payloadType: 96, profileLevelId: "64001f", codec: "avc1.64002A", label: "High" },
|
|
55
|
+
main: { payloadType: 97, profileLevelId: "4d001f", codec: "avc1.4D002A", label: "Main" },
|
|
56
|
+
baseline: { payloadType: 98, profileLevelId: "42e01f", codec: "avc1.42E02A", label: "Baseline" }
|
|
57
|
+
};
|
|
58
|
+
function h264ProfileLabel(sdpFmtpLine) {
|
|
59
|
+
const id = sdpFmtpLine.match(/profile-level-id=([0-9a-f]{6})/i)?.[1].toLowerCase();
|
|
60
|
+
const match = Object.values(H264_PROFILES).find((profile) => id?.startsWith(profile.profileLevelId.slice(0, 2)));
|
|
61
|
+
return match?.label ?? id ?? "";
|
|
62
|
+
}
|
|
63
|
+
var DESKTOP_TRANSPORT_MESSAGE = "replicas-desktop-transport";
|
|
64
|
+
|
|
1
65
|
// src/media-protocol.ts
|
|
2
|
-
var STREAM_PROTOCOL_VERSION =
|
|
66
|
+
var STREAM_PROTOCOL_VERSION = 3;
|
|
3
67
|
var MEDIA_PACKET_HEADER_BYTES = 12;
|
|
4
68
|
|
|
5
69
|
// src/stream-viewer.ts
|
|
@@ -10,12 +74,24 @@ var audioPrompt = document.querySelector("#audio-prompt");
|
|
|
10
74
|
var diagnostics = document.querySelector("#diagnostics");
|
|
11
75
|
var TrackGenerator = globalThis.MediaStreamTrackGenerator;
|
|
12
76
|
if (!screen || !status || !statusText || !audioPrompt || !diagnostics) throw new Error("Desktop stream markup is incomplete");
|
|
13
|
-
if (!TrackGenerator) throw new Error("This browser cannot render the desktop stream");
|
|
14
77
|
var params = new URLSearchParams(location.search);
|
|
78
|
+
var request = parseStreamRequest(params);
|
|
15
79
|
var showDiagnostics = params.get("diagnostics") === "1";
|
|
80
|
+
var view = params.get("view") === "native" ? "native" : params.get("view") === "pixel" ? "pixel" : "fit";
|
|
81
|
+
document.documentElement.dataset.view = view;
|
|
16
82
|
diagnostics.classList.toggle("visible", showDiagnostics);
|
|
83
|
+
var webrtcFrameTimeoutMs = 6e3;
|
|
84
|
+
var webrtcSkipsAfterFailure = 3;
|
|
85
|
+
var pointerIntervalMs = 4;
|
|
17
86
|
var socket;
|
|
18
87
|
var config;
|
|
88
|
+
var transport;
|
|
89
|
+
var transportReason = "";
|
|
90
|
+
var peer;
|
|
91
|
+
var inputChannel;
|
|
92
|
+
var pointerChannel;
|
|
93
|
+
var webrtcDeadline;
|
|
94
|
+
var webrtcSkips = 0;
|
|
19
95
|
var videoTrack;
|
|
20
96
|
var videoWriter;
|
|
21
97
|
var videoDecoder;
|
|
@@ -27,34 +103,123 @@ var nextAudioTime = 0;
|
|
|
27
103
|
var waitingForKeyframe = true;
|
|
28
104
|
var reconnectAttempt = 0;
|
|
29
105
|
var reconnectTimer;
|
|
30
|
-
var
|
|
106
|
+
var pointerTimer;
|
|
107
|
+
var pointerSettleTimer;
|
|
31
108
|
var pendingPointer;
|
|
109
|
+
var lastPointerSentAt = 0;
|
|
110
|
+
var positionSequence = 0;
|
|
111
|
+
var actionId = 0;
|
|
112
|
+
var statsSequence = 0;
|
|
113
|
+
var reportedStatsSequence = 0;
|
|
32
114
|
var renderedFrames = 0;
|
|
115
|
+
var presentedFrames = 0;
|
|
116
|
+
var lastPresentedCount = 0;
|
|
117
|
+
var lastFrameAt = 0;
|
|
118
|
+
var lastKeyframeRequestAt = 0;
|
|
33
119
|
var decodedAudioPackets = 0;
|
|
34
120
|
var renderedWindowStartedAt = performance.now();
|
|
35
121
|
var rtt = 0;
|
|
122
|
+
var rttSentAt = 0;
|
|
123
|
+
var socketRtt = 0;
|
|
124
|
+
var lastSocketPingAt = 0;
|
|
125
|
+
var inbound;
|
|
126
|
+
var inboundAt = 0;
|
|
127
|
+
var inboundHistory = [];
|
|
128
|
+
var telemetry;
|
|
129
|
+
var route = { path: "", codec: "", decoded: "", kbps: 0, displayedFps: 0 };
|
|
130
|
+
var state = {};
|
|
131
|
+
globalThis.__replicasDesktop = state;
|
|
36
132
|
var showStatus = (message) => {
|
|
37
133
|
statusText.textContent = message;
|
|
38
134
|
status.classList.add("visible");
|
|
39
135
|
};
|
|
136
|
+
var describe = (error) => error instanceof Error ? error.message : String(error);
|
|
40
137
|
var fitScreen = () => {
|
|
41
138
|
if (!config) return;
|
|
42
|
-
const scale = Math.min(innerWidth / config.width, innerHeight / config.height);
|
|
139
|
+
const scale = view === "fit" ? Math.min(innerWidth / config.width, innerHeight / config.height) : view === "native" ? 1 : 1 / devicePixelRatio;
|
|
43
140
|
screen.style.width = `${Math.floor(config.width * scale)}px`;
|
|
44
141
|
screen.style.height = `${Math.floor(config.height * scale)}px`;
|
|
45
142
|
};
|
|
46
|
-
var
|
|
143
|
+
var sendSocket = (message) => {
|
|
47
144
|
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
|
48
145
|
};
|
|
146
|
+
var sendInput = (message, unreliable = false) => {
|
|
147
|
+
if (message.type === "pointer" || message.type === "button" || message.type === "wheel" || message.type === "release") {
|
|
148
|
+
message = { ...message, sequence: message.sequence ?? ++positionSequence };
|
|
149
|
+
if (message.type !== "pointer") clearTimeout(pointerSettleTimer);
|
|
150
|
+
if (message.type === "release") {
|
|
151
|
+
clearTimeout(pointerTimer);
|
|
152
|
+
pointerTimer = void 0;
|
|
153
|
+
pendingPointer = void 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (config?.inputRace && transport && (message.type === "button" || message.type === "wheel" || message.type === "key" || message.type === "release")) {
|
|
157
|
+
const ordered = socket?.readyState === WebSocket.OPEN ? socket : void 0;
|
|
158
|
+
const reliable = transport === "webrtc" && inputChannel?.readyState === "open" ? inputChannel : void 0;
|
|
159
|
+
const nextAction = actionId + 1;
|
|
160
|
+
const payload = JSON.stringify({ ...message, actionId: nextAction });
|
|
161
|
+
const deliver = (target) => {
|
|
162
|
+
if (!target) return false;
|
|
163
|
+
try {
|
|
164
|
+
target.send(payload);
|
|
165
|
+
return true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const viaSocket = deliver(ordered);
|
|
171
|
+
if (!viaSocket && !deliver(reliable)) return;
|
|
172
|
+
actionId = nextAction;
|
|
173
|
+
if (viaSocket && transport === "webrtc" && pointerChannel?.readyState === "open" && pointerChannel.bufferedAmount < 16384) {
|
|
174
|
+
deliver(pointerChannel);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (transport !== "webrtc") {
|
|
179
|
+
if (transport === "websocket") sendSocket(message);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (unreliable && pointerChannel?.readyState === "open") {
|
|
183
|
+
pointerChannel.send(JSON.stringify(message));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!unreliable && ["pointer", "button", "wheel", "key", "release"].includes(message.type) && socket?.readyState === WebSocket.OPEN) {
|
|
187
|
+
sendSocket(message);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (inputChannel?.readyState === "open") {
|
|
191
|
+
inputChannel.send(JSON.stringify(message));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!unreliable) sendSocket(message);
|
|
195
|
+
};
|
|
196
|
+
var announceTransport = () => {
|
|
197
|
+
if (window.parent === window) return;
|
|
198
|
+
window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec }, "*");
|
|
199
|
+
};
|
|
200
|
+
var closePeer = (preserveDeadline = false) => {
|
|
201
|
+
if (!preserveDeadline) {
|
|
202
|
+
clearTimeout(webrtcDeadline);
|
|
203
|
+
webrtcDeadline = void 0;
|
|
204
|
+
}
|
|
205
|
+
inputChannel = void 0;
|
|
206
|
+
pointerChannel = void 0;
|
|
207
|
+
peer?.close();
|
|
208
|
+
peer = void 0;
|
|
209
|
+
inbound = void 0;
|
|
210
|
+
};
|
|
49
211
|
var disconnect = () => {
|
|
50
212
|
clearTimeout(reconnectTimer);
|
|
51
213
|
reconnectTimer = void 0;
|
|
52
|
-
|
|
214
|
+
sendInput({ type: "release" });
|
|
215
|
+
closePeer();
|
|
216
|
+
transport = void 0;
|
|
53
217
|
const previousSocket = socket;
|
|
54
218
|
socket = void 0;
|
|
55
219
|
previousSocket?.close();
|
|
56
220
|
};
|
|
57
221
|
var resetVideoTrack = () => {
|
|
222
|
+
if (!TrackGenerator) return;
|
|
58
223
|
void videoWriter?.close().catch(() => {
|
|
59
224
|
});
|
|
60
225
|
videoTrack?.stop();
|
|
@@ -70,16 +235,16 @@ var decoderConfig = (next, preferHardware = false) => {
|
|
|
70
235
|
optimizeForLatency: true,
|
|
71
236
|
avc: { format: "annexb" }
|
|
72
237
|
};
|
|
73
|
-
|
|
238
|
+
decoder.hardwareAcceleration = preferHardware ? "prefer-hardware" : "prefer-software";
|
|
74
239
|
return decoder;
|
|
75
240
|
};
|
|
76
|
-
var configureVideo = async (next) => {
|
|
241
|
+
var configureVideo = async (next, preferHardware = true) => {
|
|
77
242
|
const configuration = ++videoConfiguration;
|
|
78
243
|
const previousDecoder = videoDecoder;
|
|
79
244
|
videoDecoder = void 0;
|
|
80
245
|
activeVideoConfig = void 0;
|
|
81
246
|
if (previousDecoder && previousDecoder.state !== "closed") previousDecoder.close();
|
|
82
|
-
const preferred = decoderConfig(next,
|
|
247
|
+
const preferred = decoderConfig(next, preferHardware);
|
|
83
248
|
const support = await VideoDecoder.isConfigSupported(preferred);
|
|
84
249
|
if (configuration !== videoConfiguration) return;
|
|
85
250
|
const selectedConfig = support.supported && support.config ? support.config : decoderConfig(next);
|
|
@@ -92,10 +257,10 @@ var configureVideo = async (next) => {
|
|
|
92
257
|
return;
|
|
93
258
|
}
|
|
94
259
|
try {
|
|
95
|
-
void writer.write(frame).catch((error) => showStatus(
|
|
260
|
+
void writer.write(frame).catch((error) => showStatus(describe(error))).finally(() => frame.close());
|
|
96
261
|
} catch (error) {
|
|
97
262
|
frame.close();
|
|
98
|
-
showStatus(
|
|
263
|
+
showStatus(describe(error));
|
|
99
264
|
}
|
|
100
265
|
},
|
|
101
266
|
error: (error) => {
|
|
@@ -103,7 +268,7 @@ var configureVideo = async (next) => {
|
|
|
103
268
|
videoDecoder = void 0;
|
|
104
269
|
waitingForKeyframe = true;
|
|
105
270
|
showStatus(`recovering video: ${error.message}`);
|
|
106
|
-
if (config) void configureVideo(config).catch((reason) => showStatus(
|
|
271
|
+
if (config) void configureVideo(config, false).catch((reason) => showStatus(describe(reason)));
|
|
107
272
|
}
|
|
108
273
|
});
|
|
109
274
|
videoDecoder = decoder;
|
|
@@ -115,12 +280,12 @@ var enableAudio = async () => {
|
|
|
115
280
|
audioContext ??= new AudioContext({ latencyHint: "interactive", sampleRate: config.audioSampleRate });
|
|
116
281
|
await audioContext.resume();
|
|
117
282
|
if (!audioDecoder || audioDecoder.state === "closed") {
|
|
118
|
-
const
|
|
283
|
+
const audioConfig = {
|
|
119
284
|
codec: config.audioCodec,
|
|
120
285
|
sampleRate: config.audioSampleRate,
|
|
121
286
|
numberOfChannels: config.audioChannels
|
|
122
287
|
};
|
|
123
|
-
const support = await AudioDecoder.isConfigSupported(
|
|
288
|
+
const support = await AudioDecoder.isConfigSupported(audioConfig);
|
|
124
289
|
if (!support.supported) throw new Error(`Browser cannot decode ${config.audioCodec}`);
|
|
125
290
|
audioDecoder = new AudioDecoder({
|
|
126
291
|
output: (data) => {
|
|
@@ -141,24 +306,24 @@ var enableAudio = async () => {
|
|
|
141
306
|
nextAudioTime += data.numberOfFrames / data.sampleRate;
|
|
142
307
|
data.close();
|
|
143
308
|
decodedAudioPackets += 1;
|
|
144
|
-
window.dispatchEvent(new Event("replicasdesktopaudio"));
|
|
145
309
|
},
|
|
146
310
|
error: () => {
|
|
147
311
|
audioPrompt.hidden = false;
|
|
148
312
|
}
|
|
149
313
|
});
|
|
150
|
-
audioDecoder.configure(support.config ??
|
|
314
|
+
audioDecoder.configure(support.config ?? audioConfig);
|
|
151
315
|
}
|
|
152
316
|
audioPrompt.hidden = true;
|
|
153
317
|
};
|
|
154
318
|
var handleBinary = (data) => {
|
|
155
319
|
if (!config || data.byteLength <= MEDIA_PACKET_HEADER_BYTES) return;
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
const kind =
|
|
320
|
+
const view2 = new DataView(data);
|
|
321
|
+
if (view2.getUint8(1) !== STREAM_PROTOCOL_VERSION) return;
|
|
322
|
+
const kind = view2.getUint8(0);
|
|
159
323
|
if (kind !== 1 /* VideoKey */ && kind !== 2 /* VideoDelta */ && kind !== 3 /* Audio */) return;
|
|
160
|
-
const timestamp = Number(
|
|
324
|
+
const timestamp = Number(view2.getBigUint64(4));
|
|
161
325
|
const payload = new Uint8Array(data, MEDIA_PACKET_HEADER_BYTES);
|
|
326
|
+
route.kbps += payload.byteLength * 8 / 1e3;
|
|
162
327
|
if (kind === 3 /* Audio */) {
|
|
163
328
|
if (!audioDecoder || audioDecoder.state !== "configured" || audioDecoder.decodeQueueSize > 12) return;
|
|
164
329
|
audioDecoder.decode(new EncodedAudioChunk({ type: "key", timestamp, data: payload }));
|
|
@@ -181,47 +346,193 @@ var handleBinary = (data) => {
|
|
|
181
346
|
waitingForKeyframe = true;
|
|
182
347
|
}
|
|
183
348
|
};
|
|
349
|
+
var unlockAudio = () => {
|
|
350
|
+
if (transport !== "webrtc") {
|
|
351
|
+
enableAudio().catch(() => {
|
|
352
|
+
audioPrompt.hidden = false;
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
screen.muted = false;
|
|
357
|
+
screen.play().then(() => {
|
|
358
|
+
audioPrompt.hidden = true;
|
|
359
|
+
}).catch(() => {
|
|
360
|
+
screen.muted = true;
|
|
361
|
+
audioPrompt.hidden = false;
|
|
362
|
+
void screen.play().catch(() => {
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
};
|
|
366
|
+
var useFallback = (reason, failed = false) => {
|
|
367
|
+
if (transport === "websocket" || !config) return;
|
|
368
|
+
closePeer();
|
|
369
|
+
if (failed) webrtcSkips = webrtcSkipsAfterFailure;
|
|
370
|
+
transport = "websocket";
|
|
371
|
+
transportReason = reason;
|
|
372
|
+
route = { ...route, path: "proxy", codec: config.videoCodec };
|
|
373
|
+
announceTransport();
|
|
374
|
+
if (!TrackGenerator) {
|
|
375
|
+
showStatus("This browser cannot render the desktop stream");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
sendSocket({ type: "fallback", reason });
|
|
379
|
+
resetVideoTrack();
|
|
380
|
+
void configureVideo(config).catch((error) => showStatus(describe(error)));
|
|
381
|
+
void screen.play().catch(() => showStatus("Click the stream to begin playback"));
|
|
382
|
+
unlockAudio();
|
|
383
|
+
};
|
|
384
|
+
var negotiate = async (sdp) => {
|
|
385
|
+
if (!config?.iceServers || transport === "websocket") return;
|
|
386
|
+
closePeer(true);
|
|
387
|
+
const pc = new RTCPeerConnection({ iceServers: config.iceServers, bundlePolicy: "max-bundle", iceTransportPolicy: config.request.iceTransportPolicy });
|
|
388
|
+
peer = pc;
|
|
389
|
+
pc.ontrack = (event) => {
|
|
390
|
+
if (peer !== pc) return;
|
|
391
|
+
try {
|
|
392
|
+
event.receiver.jitterBufferTarget = 0;
|
|
393
|
+
} catch {
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
event.receiver.playoutDelayHint = 0;
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
if (screen.srcObject !== event.streams[0]) screen.srcObject = event.streams[0] ?? new MediaStream([event.track]);
|
|
400
|
+
void screen.play().catch(() => {
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
pc.ondatachannel = (event) => {
|
|
404
|
+
if (peer !== pc) return;
|
|
405
|
+
if (event.channel.label === "pointer") pointerChannel = event.channel;
|
|
406
|
+
else inputChannel = event.channel;
|
|
407
|
+
event.channel.onmessage = (message) => {
|
|
408
|
+
if (typeof message.data === "string") handleServerText(message.data, false);
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
pc.onicecandidate = (event) => {
|
|
412
|
+
if (peer !== pc) return;
|
|
413
|
+
if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
|
|
414
|
+
};
|
|
415
|
+
pc.onconnectionstatechange = () => {
|
|
416
|
+
if (peer !== pc) return;
|
|
417
|
+
if (pc.connectionState === "connected") {
|
|
418
|
+
transport = "webrtc";
|
|
419
|
+
transportReason = "";
|
|
420
|
+
webrtcSkips = 0;
|
|
421
|
+
lastFrameAt = performance.now();
|
|
422
|
+
announceTransport();
|
|
423
|
+
unlockAudio();
|
|
424
|
+
} else if (pc.connectionState === "failed") {
|
|
425
|
+
if (transport === "webrtc") socket?.close();
|
|
426
|
+
else useFallback("WebRTC connection failed", true);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
await pc.setRemoteDescription({ type: "offer", sdp });
|
|
431
|
+
if (peer !== pc) return;
|
|
432
|
+
const answer = await pc.createAnswer();
|
|
433
|
+
if (peer !== pc) return;
|
|
434
|
+
await pc.setLocalDescription(answer);
|
|
435
|
+
if (peer !== pc || !answer.sdp) return;
|
|
436
|
+
const video = answer.sdp.split(/^m=/m).find((section) => section.startsWith("video")) ?? "";
|
|
437
|
+
if (video.startsWith("video 0 ") || !/^a=rtpmap:\d+ H264\/90000/m.test(video)) {
|
|
438
|
+
useFallback("browser cannot receive H.264 over WebRTC", true);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
sendSocket({ type: "answer", sdp: answer.sdp });
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (peer === pc) throw error;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
var handleServerMessage = (message, viaSocket) => {
|
|
447
|
+
if (message.type === "pong") {
|
|
448
|
+
const elapsed = performance.now() - message.sentAt;
|
|
449
|
+
if ((!viaSocket || transport !== "webrtc") && message.sentAt > rttSentAt) {
|
|
450
|
+
rtt = elapsed;
|
|
451
|
+
rttSentAt = message.sentAt;
|
|
452
|
+
}
|
|
453
|
+
if (viaSocket) socketRtt = elapsed;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (message.type === "ping") {
|
|
457
|
+
sendSocket({ type: "pong", sentAt: message.sentAt });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (message.type === "telemetry") {
|
|
461
|
+
telemetry = message;
|
|
462
|
+
state.telemetry = message;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (message.type === "webrtc-failed") {
|
|
466
|
+
useFallback("Server WebRTC connection failed", true);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (message.type === "offer") {
|
|
470
|
+
void negotiate(message.sdp).catch((error) => useFallback(`negotiation failed: ${describe(error)}`, true));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (message.type === "candidate") {
|
|
474
|
+
void peer?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (message.type !== "config") return;
|
|
479
|
+
if (message.protocolVersion !== STREAM_PROTOCOL_VERSION) {
|
|
480
|
+
showStatus("Desktop viewer is out of date; reload the page");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
config = message;
|
|
484
|
+
request = message.request;
|
|
485
|
+
fitScreen();
|
|
486
|
+
if (request.transport !== "webrtc") useFallback("WebSocket transport selected");
|
|
487
|
+
else if (!message.iceServers) useFallback("server has no WebRTC");
|
|
488
|
+
else if (typeof RTCPeerConnection !== "function") useFallback("browser has no WebRTC");
|
|
489
|
+
else if (webrtcSkips > 0) {
|
|
490
|
+
webrtcSkips -= 1;
|
|
491
|
+
useFallback("recent WebRTC failure");
|
|
492
|
+
} else if (!transport) {
|
|
493
|
+
showStatus("Establishing connection\u2026");
|
|
494
|
+
webrtcDeadline ??= setTimeout(() => useFallback("WebRTC did not display video in time", true), webrtcFrameTimeoutMs);
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
var handleServerText = (data, viaSocket) => {
|
|
498
|
+
let parsed;
|
|
499
|
+
try {
|
|
500
|
+
parsed = JSON.parse(data);
|
|
501
|
+
} catch {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (validServerMessage(parsed)) handleServerMessage(parsed, viaSocket);
|
|
505
|
+
};
|
|
184
506
|
var connect = () => {
|
|
185
507
|
if (document.visibilityState !== "visible") return;
|
|
186
508
|
clearTimeout(reconnectTimer);
|
|
509
|
+
closePeer();
|
|
510
|
+
transport = void 0;
|
|
187
511
|
socket?.close();
|
|
188
512
|
showStatus(reconnectAttempt ? "reconnecting\u2026" : "connecting\u2026");
|
|
189
|
-
const
|
|
513
|
+
const query = new URLSearchParams([...params].filter(([key]) => ["transport", "profile", "adapt", "ice"].includes(key)));
|
|
514
|
+
const nextSocket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/stream?${query}`);
|
|
190
515
|
nextSocket.binaryType = "arraybuffer";
|
|
191
516
|
socket = nextSocket;
|
|
192
517
|
nextSocket.addEventListener("open", () => {
|
|
193
518
|
if (socket !== nextSocket) return;
|
|
519
|
+
actionId = 0;
|
|
194
520
|
reconnectAttempt = 0;
|
|
195
|
-
|
|
521
|
+
sendSocket({ type: "theme", theme: document.documentElement.dataset.theme === "light" ? "light" : "dark" });
|
|
196
522
|
});
|
|
197
523
|
nextSocket.addEventListener("message", (event) => {
|
|
198
524
|
if (socket !== nextSocket) return;
|
|
199
525
|
if (event.data instanceof ArrayBuffer) {
|
|
200
|
-
handleBinary(event.data);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (typeof event.data !== "string") return;
|
|
204
|
-
const message = JSON.parse(event.data);
|
|
205
|
-
if (message.type === "pong") {
|
|
206
|
-
rtt = performance.now() - message.sentAt;
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
if (message.type === "ping") {
|
|
210
|
-
send({ type: "pong", sentAt: message.sentAt });
|
|
526
|
+
if (transport === "websocket") handleBinary(event.data);
|
|
211
527
|
return;
|
|
212
528
|
}
|
|
213
|
-
if (
|
|
214
|
-
config = message;
|
|
215
|
-
fitScreen();
|
|
216
|
-
resetVideoTrack();
|
|
217
|
-
void configureVideo(message).catch((error) => showStatus(error instanceof Error ? error.message : String(error)));
|
|
218
|
-
void screen.play().catch(() => showStatus("Click the stream to begin playback"));
|
|
219
|
-
enableAudio().catch(() => {
|
|
220
|
-
audioPrompt.hidden = false;
|
|
221
|
-
});
|
|
529
|
+
if (typeof event.data === "string") handleServerText(event.data, true);
|
|
222
530
|
});
|
|
223
531
|
nextSocket.addEventListener("close", () => {
|
|
224
532
|
if (socket !== nextSocket) return;
|
|
533
|
+
closePeer();
|
|
534
|
+
transport = void 0;
|
|
535
|
+
announceTransport();
|
|
225
536
|
reconnectAttempt += 1;
|
|
226
537
|
const delay = Math.min(8e3, 250 * 2 ** Math.min(reconnectAttempt, 5));
|
|
227
538
|
reconnectTimer = setTimeout(connect, delay * (0.85 + Math.random() * 0.3));
|
|
@@ -236,32 +547,49 @@ var normalizedPointer = (event) => {
|
|
|
236
547
|
};
|
|
237
548
|
};
|
|
238
549
|
var flushPointer = () => {
|
|
239
|
-
|
|
550
|
+
clearTimeout(pointerTimer);
|
|
551
|
+
pointerTimer = void 0;
|
|
240
552
|
if (!pendingPointer) return;
|
|
241
|
-
|
|
553
|
+
const position = pendingPointer;
|
|
554
|
+
const message = { type: "pointer", ...position, sequence: ++positionSequence };
|
|
555
|
+
sendInput(message, true);
|
|
242
556
|
pendingPointer = void 0;
|
|
557
|
+
lastPointerSentAt = performance.now();
|
|
558
|
+
clearTimeout(pointerSettleTimer);
|
|
559
|
+
pointerSettleTimer = setTimeout(() => sendInput(message), 60);
|
|
243
560
|
};
|
|
244
561
|
var frameCallback = 0;
|
|
245
562
|
var observeFrame = () => {
|
|
246
|
-
frameCallback = screen.requestVideoFrameCallback(() => {
|
|
563
|
+
frameCallback = screen.requestVideoFrameCallback((_now, metadata) => {
|
|
564
|
+
const stream = screen.srcObject;
|
|
565
|
+
if (webrtcDeadline && transport === "webrtc" && stream instanceof MediaStream && peer?.getReceivers().some(({ track }) => stream.getVideoTracks().includes(track))) {
|
|
566
|
+
clearTimeout(webrtcDeadline);
|
|
567
|
+
webrtcDeadline = void 0;
|
|
568
|
+
}
|
|
247
569
|
renderedFrames += 1;
|
|
570
|
+
presentedFrames += lastPresentedCount ? Math.max(0, metadata.presentedFrames - lastPresentedCount) : 1;
|
|
571
|
+
lastPresentedCount = metadata.presentedFrames;
|
|
572
|
+
lastFrameAt = performance.now();
|
|
248
573
|
status.classList.remove("visible");
|
|
249
|
-
window.dispatchEvent(new Event("replicasdesktopframe"));
|
|
250
574
|
observeFrame();
|
|
251
575
|
});
|
|
252
576
|
};
|
|
253
577
|
observeFrame();
|
|
254
|
-
screen.addEventListener("
|
|
255
|
-
|
|
256
|
-
pointerFrame ??= requestAnimationFrame(flushPointer);
|
|
578
|
+
screen.addEventListener("play", () => {
|
|
579
|
+
lastFrameAt = performance.now();
|
|
257
580
|
});
|
|
581
|
+
var trackPointer = (event) => {
|
|
582
|
+
pendingPointer = normalizedPointer(event);
|
|
583
|
+
const wait = pointerIntervalMs - (performance.now() - lastPointerSentAt);
|
|
584
|
+
if (wait <= 0) flushPointer();
|
|
585
|
+
else pointerTimer ??= setTimeout(flushPointer, wait);
|
|
586
|
+
};
|
|
587
|
+
screen.addEventListener("pointermove", trackPointer);
|
|
258
588
|
screen.addEventListener("pointerdown", (event) => {
|
|
259
589
|
event.preventDefault();
|
|
260
590
|
screen.focus({ preventScroll: true });
|
|
261
591
|
screen.setPointerCapture(event.pointerId);
|
|
262
|
-
|
|
263
|
-
audioPrompt.hidden = false;
|
|
264
|
-
});
|
|
592
|
+
if (screen.muted || audioContext?.state !== "running") unlockAudio();
|
|
265
593
|
send({ type: "button", ...normalizedPointer(event), button: event.button, down: true });
|
|
266
594
|
});
|
|
267
595
|
screen.addEventListener("pointerup", (event) => {
|
|
@@ -277,9 +605,7 @@ screen.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
|
277
605
|
window.addEventListener("keydown", (event) => {
|
|
278
606
|
if (!screen.matches(":focus")) return;
|
|
279
607
|
event.preventDefault();
|
|
280
|
-
|
|
281
|
-
audioPrompt.hidden = false;
|
|
282
|
-
});
|
|
608
|
+
if (screen.muted || audioContext?.state !== "running") unlockAudio();
|
|
283
609
|
send({ type: "key", code: event.code, down: true, repeat: event.repeat });
|
|
284
610
|
}, { capture: true });
|
|
285
611
|
window.addEventListener("keyup", (event) => {
|
|
@@ -302,28 +628,154 @@ window.addEventListener("pagehide", () => {
|
|
|
302
628
|
screen.cancelVideoFrameCallback(frameCallback);
|
|
303
629
|
videoDecoder?.close();
|
|
304
630
|
audioDecoder?.close();
|
|
305
|
-
audioContext?.close();
|
|
631
|
+
void audioContext?.close();
|
|
306
632
|
void videoWriter?.close();
|
|
307
633
|
videoTrack?.stop();
|
|
308
634
|
});
|
|
309
|
-
audioPrompt.addEventListener("click",
|
|
635
|
+
audioPrompt.addEventListener("click", unlockAudio);
|
|
636
|
+
function send(message) {
|
|
637
|
+
flushPointer();
|
|
638
|
+
sendInput(message);
|
|
639
|
+
}
|
|
640
|
+
var updateDiagnostics = (stats) => {
|
|
641
|
+
const bounds = screen.getBoundingClientRect();
|
|
642
|
+
const dims = {
|
|
643
|
+
source: config ? `${config.width}\xD7${config.height}` : "",
|
|
644
|
+
decoded: route.decoded || (screen.videoWidth ? `${screen.videoWidth}\xD7${screen.videoHeight}` : ""),
|
|
645
|
+
css: `${Math.round(bounds.width)}\xD7${Math.round(bounds.height)}`,
|
|
646
|
+
physical: `${Math.round(bounds.width * devicePixelRatio)}\xD7${Math.round(bounds.height * devicePixelRatio)} @${devicePixelRatio}x`
|
|
647
|
+
};
|
|
648
|
+
Object.assign(state, { transport, transportReason, socketRttMs: socketRtt, view, route, dims, ...stats });
|
|
649
|
+
if (!showDiagnostics) return;
|
|
650
|
+
const network = stats.lossPercent === void 0 ? `${stats.decodeQueue} queued` : `${stats.lossPercent.toFixed(1)}% loss \xB7 ${(stats.playbackDroppedPercent ?? 0).toFixed(1)}% playback drops \xB7 ${stats.nacks ?? 0} nack \xB7 ${stats.freezes ?? 0} freezes \xB7 ${(stats.jitterBufferMs ?? 0).toFixed(0)} ms buffer`;
|
|
651
|
+
const encoder = telemetry?.encoder;
|
|
652
|
+
const server = telemetry?.viewer;
|
|
653
|
+
diagnostics.textContent = [
|
|
654
|
+
`${transport ?? "connecting"}${transportReason ? ` (${transportReason})` : ""} \xB7 ${route.path || "\u2014"} \xB7 ${route.codec || config?.videoCodec || ""} \xB7 ${view} view`,
|
|
655
|
+
`source ${dims.source} \xB7 decoded ${dims.decoded || "\u2014"} \xB7 css ${dims.css} \xB7 device ${dims.physical}`,
|
|
656
|
+
`${route.displayedFps.toFixed(1)} displayed/s \xB7 ${stats.videoFps.toFixed(1)} decoded/s \xB7 ${route.kbps.toFixed(0)} kbps \xB7 ${stats.rttMs.toFixed(0)} ms rtt \xB7 ${socketRtt.toFixed(0)} ms via proxy \xB7 ${network}`,
|
|
657
|
+
encoder ? `encoder ${encoder.profile} crf ${encoder.crf} \xB7 ${encoder.codecProfile} ${encoder.pixelFormat} ${encoder.color} \xB7 rung ${encoder.rung}/${encoder.rungs - 1} cap ${encoder.kbpsCap} kbps${encoder.adaptive ? "" : " pinned"} \xB7 ${encoder.fps} fps ${encoder.kbps} kbps \xB7 ${encoder.preset}` : "",
|
|
658
|
+
telemetry && server ? `server cpu: encoder ${telemetry.cpu.encoderPercent}% \xB7 stream ${telemetry.cpu.serverPercent}% \xB7 load ${telemetry.cpu.load} on ${telemetry.cpu.cores} cores \xB7 budget ${server.budgetKbps} kbps sent ${server.sentKbps} \xB7 queue ${server.queueMs} ms ${server.queuedPackets} pkt \xB7 stale ${server.staleEvents} dropped ${server.droppedFrames} \xB7 repairs ${server.repairsSent}/${server.repairsSkipped} skipped \xB7 copies ${server.duplicatesSent}${server.protecting ? " on" : ""} \xB7 remb ${server.rembKbps ?? "\u2014"}` : ""
|
|
659
|
+
].filter(Boolean).join("\n");
|
|
660
|
+
};
|
|
661
|
+
var inboundOf = (kind) => (stat) => stat.type === "inbound-rtp" && "kind" in stat && stat.kind === kind;
|
|
662
|
+
var reportWebRtcStats = async (pc, stats) => {
|
|
663
|
+
const entries = [...(await pc.getStats()).values()];
|
|
664
|
+
if (peer !== pc || stats.sequence !== void 0 && stats.sequence <= reportedStatsSequence) return;
|
|
665
|
+
reportedStatsSequence = stats.sequence ?? reportedStatsSequence;
|
|
666
|
+
const video = entries.find(inboundOf("video"));
|
|
667
|
+
const audio = entries.find(inboundOf("audio"));
|
|
668
|
+
const transportStats = entries.find((stat) => stat.type === "transport");
|
|
669
|
+
const pair = entries.find((stat) => stat.type === "candidate-pair" && stat.id === transportStats?.selectedCandidatePairId);
|
|
670
|
+
const candidate = (id) => entries.find((stat) => stat.id === id);
|
|
671
|
+
const codec = entries.find((stat) => stat.type === "codec" && stat.id === video?.codecId);
|
|
672
|
+
const local = candidate(pair?.localCandidateId);
|
|
673
|
+
const remote = candidate(pair?.remoteCandidateId);
|
|
674
|
+
const previousPath = route.path;
|
|
675
|
+
route.path = local && remote ? `${remote.candidateType}\u2192${local.candidateType} ${(local.relayProtocol ?? local.protocol ?? "").toLowerCase()}`.trim() : route.path;
|
|
676
|
+
route.codec = codec ? `${codec.mimeType.replace("video/", "")} ${h264ProfileLabel(codec.sdpFmtpLine ?? "")}`.trim() : route.codec;
|
|
677
|
+
if (video?.frameWidth) route.decoded = `${video.frameWidth}\xD7${video.frameHeight}`;
|
|
678
|
+
if (route.path !== previousPath) announceTransport();
|
|
679
|
+
const now = performance.now();
|
|
680
|
+
const playback = screen.getVideoPlaybackQuality?.();
|
|
681
|
+
const counters = {
|
|
682
|
+
framesDecoded: video?.framesDecoded ?? 0,
|
|
683
|
+
totalVideoFrames: playback?.totalVideoFrames ?? 0,
|
|
684
|
+
droppedVideoFrames: playback?.droppedVideoFrames ?? 0,
|
|
685
|
+
packetsLost: video?.packetsLost ?? 0,
|
|
686
|
+
packetsReceived: video?.packetsReceived ?? 0,
|
|
687
|
+
bytesReceived: video?.bytesReceived ?? 0,
|
|
688
|
+
nackCount: video?.nackCount ?? 0,
|
|
689
|
+
freezeCount: video?.freezeCount ?? 0,
|
|
690
|
+
jitterBufferDelay: video?.jitterBufferDelay ?? 0,
|
|
691
|
+
jitterBufferEmittedCount: video?.jitterBufferEmittedCount ?? 0,
|
|
692
|
+
audioPackets: audio?.packetsReceived ?? 0
|
|
693
|
+
};
|
|
694
|
+
if (!inbound) inboundHistory = [];
|
|
695
|
+
while (inboundHistory.length > 1 && inboundHistory[1].at <= now - 1e3) inboundHistory.shift();
|
|
696
|
+
const oldest = inboundHistory[0];
|
|
697
|
+
if (oldest) {
|
|
698
|
+
inbound = oldest.counters;
|
|
699
|
+
inboundAt = oldest.at;
|
|
700
|
+
}
|
|
701
|
+
if (inbound) {
|
|
702
|
+
const seconds = Math.max(1e-3, (now - inboundAt) / 1e3);
|
|
703
|
+
const lost = Math.max(0, counters.packetsLost - inbound.packetsLost);
|
|
704
|
+
const received = counters.packetsReceived - inbound.packetsReceived;
|
|
705
|
+
stats.videoPacketsPerSecond = Math.max(0, received) / seconds;
|
|
706
|
+
const emitted = counters.jitterBufferEmittedCount - inbound.jitterBufferEmittedCount;
|
|
707
|
+
stats.videoFps = (counters.framesDecoded - inbound.framesDecoded) / seconds;
|
|
708
|
+
const total = counters.totalVideoFrames - inbound.totalVideoFrames;
|
|
709
|
+
const dropped = Math.max(0, counters.droppedVideoFrames - inbound.droppedVideoFrames);
|
|
710
|
+
if (playback) stats.playbackDroppedPercent = total > 0 ? dropped * 100 / total : 0;
|
|
711
|
+
stats.audioPacketsPerSecond = (counters.audioPackets - inbound.audioPackets) / seconds;
|
|
712
|
+
stats.lossPercent = lost + received > 0 ? lost * 100 / (lost + received) : 0;
|
|
713
|
+
stats.nacks = counters.nackCount - inbound.nackCount;
|
|
714
|
+
stats.freezes = counters.freezeCount - inbound.freezeCount;
|
|
715
|
+
stats.jitterBufferMs = emitted > 0 ? (counters.jitterBufferDelay - inbound.jitterBufferDelay) * 1e3 / emitted : 0;
|
|
716
|
+
route.kbps = (counters.bytesReceived - inbound.bytesReceived) * 8 / 1e3 / seconds;
|
|
717
|
+
}
|
|
718
|
+
inbound = counters;
|
|
719
|
+
inboundAt = now;
|
|
720
|
+
inboundHistory.push({ at: now, counters });
|
|
721
|
+
if (pair?.availableIncomingBitrate) stats.availableKbps = pair.availableIncomingBitrate / 1e3;
|
|
722
|
+
sendInput({ type: "stats", ...stats }, true);
|
|
723
|
+
updateDiagnostics(stats);
|
|
724
|
+
state.framesDropped = video?.framesDropped;
|
|
725
|
+
};
|
|
726
|
+
var watchdogPlaybackFrames = 0;
|
|
727
|
+
var watchdogPlaybackAt = 0;
|
|
728
|
+
setInterval(() => {
|
|
729
|
+
if (transport !== "webrtc") return;
|
|
730
|
+
if (screen.paused && screen.srcObject instanceof MediaStream && screen.srcObject.getVideoTracks().some((track) => track.readyState === "live")) return;
|
|
731
|
+
const now = performance.now();
|
|
732
|
+
if (now - lastFrameAt >= webrtcFrameTimeoutMs) {
|
|
733
|
+
useFallback("WebRTC video stopped arriving", true);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const quality = screen.getVideoPlaybackQuality();
|
|
737
|
+
const playbackFrames = quality.totalVideoFrames - quality.droppedVideoFrames;
|
|
738
|
+
if (playbackFrames !== watchdogPlaybackFrames) {
|
|
739
|
+
watchdogPlaybackFrames = playbackFrames;
|
|
740
|
+
watchdogPlaybackAt = now;
|
|
741
|
+
}
|
|
742
|
+
if (inputChannel?.readyState !== "open") return;
|
|
743
|
+
if (now - watchdogPlaybackAt < 300 || now - lastFrameAt < 300 || now - lastKeyframeRequestAt < 1e3) return;
|
|
744
|
+
lastKeyframeRequestAt = now;
|
|
745
|
+
sendInput({ type: "keyframe" });
|
|
746
|
+
}, 100);
|
|
747
|
+
setInterval(() => {
|
|
748
|
+
sendInput({ type: "ping", sentAt: performance.now() }, true);
|
|
749
|
+
}, 250);
|
|
750
|
+
var displayedWindow = { at: performance.now(), frames: 0 };
|
|
310
751
|
setInterval(() => {
|
|
311
|
-
if (socket?.readyState === WebSocket.OPEN) send({ type: "ping", sentAt: performance.now() });
|
|
312
752
|
const now = performance.now();
|
|
753
|
+
if (transport === "webrtc" && now - lastSocketPingAt >= 1e3) {
|
|
754
|
+
lastSocketPingAt = now;
|
|
755
|
+
sendSocket({ type: "ping", sentAt: now });
|
|
756
|
+
}
|
|
757
|
+
if (now - displayedWindow.at >= 1e3) {
|
|
758
|
+
route.displayedFps = (presentedFrames - displayedWindow.frames) * 1e3 / (now - displayedWindow.at);
|
|
759
|
+
displayedWindow = { at: now, frames: presentedFrames };
|
|
760
|
+
}
|
|
313
761
|
const elapsedSeconds = (now - renderedWindowStartedAt) / 1e3;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
videoFps:
|
|
762
|
+
if (transport !== "webrtc" && elapsedSeconds < 1) return;
|
|
763
|
+
const stats = {
|
|
764
|
+
sequence: ++statsSequence,
|
|
765
|
+
videoFps: renderedFrames / elapsedSeconds,
|
|
318
766
|
audioPacketsPerSecond: decodedAudioPackets / elapsedSeconds,
|
|
319
767
|
decodeQueue: videoDecoder?.decodeQueueSize ?? 0,
|
|
320
768
|
rttMs: rtt
|
|
321
|
-
}
|
|
322
|
-
if (showDiagnostics) {
|
|
323
|
-
diagnostics.value = `${measuredFps.toFixed(1)} fps \xB7 ${rtt.toFixed(0)} ms RTT \xB7 ${videoDecoder?.decodeQueueSize ?? 0} queued`;
|
|
324
|
-
}
|
|
769
|
+
};
|
|
325
770
|
renderedFrames = 0;
|
|
326
771
|
decodedAudioPackets = 0;
|
|
327
772
|
renderedWindowStartedAt = now;
|
|
328
|
-
|
|
773
|
+
if (transport === "webrtc" && peer) void reportWebRtcStats(peer, stats).catch(() => {
|
|
774
|
+
});
|
|
775
|
+
else if (transport === "websocket") {
|
|
776
|
+
sendInput({ type: "stats", ...stats });
|
|
777
|
+
updateDiagnostics(stats);
|
|
778
|
+
route.kbps = 0;
|
|
779
|
+
}
|
|
780
|
+
}, 250);
|
|
329
781
|
connect();
|