replicas-computer 0.2.14 → 0.3.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.
@@ -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 = 1;
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 pointerFrame;
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 send = (message) => {
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
- send({ type: "release" });
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
- if (preferHardware) decoder.hardwareAcceleration = "prefer-hardware";
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, true);
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(error instanceof Error ? error.message : String(error))).finally(() => frame.close());
260
+ void writer.write(frame).catch((error) => showStatus(describe(error))).finally(() => frame.close());
96
261
  } catch (error) {
97
262
  frame.close();
98
- showStatus(error instanceof Error ? error.message : String(error));
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(reason instanceof Error ? reason.message : String(reason)));
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 decoderConfig2 = {
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(decoderConfig2);
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 ?? decoderConfig2);
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 view = new DataView(data);
157
- if (view.getUint8(1) !== STREAM_PROTOCOL_VERSION) return;
158
- const kind = view.getUint8(0);
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(view.getBigUint64(4));
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("negotiating WebRTC\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 nextSocket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/stream`);
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
- send({ type: "theme", theme: document.documentElement.dataset.theme === "light" ? "light" : "dark" });
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 (message.type !== "config" || message.protocolVersion !== STREAM_PROTOCOL_VERSION) return;
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
- pointerFrame = void 0;
550
+ clearTimeout(pointerTimer);
551
+ pointerTimer = void 0;
240
552
  if (!pendingPointer) return;
241
- send({ type: "pointer", ...pendingPointer });
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("pointermove", (event) => {
255
- pendingPointer = normalizedPointer(event);
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
- enableAudio().catch(() => {
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
- enableAudio().catch(() => {
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", () => enableAudio().catch((error) => showStatus(error instanceof Error ? error.message : String(error))));
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
- const measuredFps = renderedFrames / elapsedSeconds;
315
- send({
316
- type: "stats",
317
- videoFps: measuredFps,
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
- }, 1e3);
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();