@camera.ui/browser 0.1.14 → 0.1.15

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/dist/index.d.ts CHANGED
@@ -209,6 +209,8 @@ export declare interface CameraStream {
209
209
  readonly status: Ref<StreamStatus>;
210
210
  readonly isPlaying: Ref<boolean>;
211
211
  readonly stalled: Ref<boolean>;
212
+ readonly connectionPath: Ref<StreamConnectionPath | undefined>;
213
+ readonly webrtcUnreachable: Ref<boolean>;
212
214
  readonly activeMode: Ref<ActiveStreamingMode>;
213
215
  readonly activeResolution: Ref<StreamingRole>;
214
216
  readonly degradedFrom: Ref<StreamingRole | null>;
@@ -1129,6 +1131,8 @@ export declare interface ReactiveStream {
1129
1131
  readonly hasBackchannel: Ref<boolean>;
1130
1132
  readonly isPlaying: Ref<boolean>;
1131
1133
  readonly stalled?: Ref<boolean>;
1134
+ readonly connectionPath?: Readonly<Ref<StreamConnectionPath | undefined>>;
1135
+ readonly webrtcUnreachable?: Readonly<Ref<boolean>>;
1132
1136
  readonly error: Ref<Error | undefined>;
1133
1137
  readonly probeInfo: Ref<ProbeStream | undefined>;
1134
1138
  readonly muted: Ref<boolean>;
@@ -1345,8 +1349,9 @@ export declare const STREAM_CONFIG: {
1345
1349
  readonly RECONNECT_DELAY: 1000;
1346
1350
  readonly CONNECT_TIMEOUT: 10000;
1347
1351
  readonly WS_CONNECT_TIMEOUT: 5000;
1352
+ readonly DISCONNECT_GRACE: 5000;
1348
1353
  readonly ICE_SERVERS: readonly [{
1349
- readonly urls: "stun:stun.l.google.com:19302";
1354
+ readonly urls: "stun:stun.cloudflare.com:3478";
1350
1355
  }];
1351
1356
  };
1352
1357
  readonly MSE: {
@@ -1361,6 +1366,8 @@ export declare const STREAM_CONFIG: {
1361
1366
  };
1362
1367
  };
1363
1368
 
1369
+ export declare type StreamConnectionPath = 'direct' | 'relay' | 'tunnel';
1370
+
1364
1371
  declare type StreamDegradeReason = 'codec';
1365
1372
 
1366
1373
  export declare interface StreamOptions {
package/dist/index.js CHANGED
@@ -484,7 +484,7 @@ function makeAbortError() {
484
484
  return err;
485
485
  }
486
486
  // src/composables/bulkLane.ts
487
- var PROBE_TIMEOUT_MS = 2e3;
487
+ var PROBE_TIMEOUT_MS$1 = 2e3;
488
488
  var RECHECK_AFTER_MS = 6e4;
489
489
  var deadMarks = /* @__PURE__ */ new WeakMap();
490
490
  var runningProbes = /* @__PURE__ */ new WeakMap();
@@ -521,7 +521,7 @@ function probeBulkClient(client) {
521
521
  }
522
522
  async function answersPing(client) {
523
523
  try {
524
- await client.flush(PROBE_TIMEOUT_MS);
524
+ await client.flush(PROBE_TIMEOUT_MS$1);
525
525
  return true;
526
526
  } catch {
527
527
  return false;
@@ -3668,7 +3668,8 @@ var STREAM_CONFIG = {
3668
3668
  RECONNECT_DELAY: 1e3,
3669
3669
  CONNECT_TIMEOUT: 1e4,
3670
3670
  WS_CONNECT_TIMEOUT: 5e3,
3671
- ICE_SERVERS: [{ urls: "stun:stun.l.google.com:19302" }]
3671
+ DISCONNECT_GRACE: 5e3,
3672
+ ICE_SERVERS: [{ urls: "stun:stun.cloudflare.com:3478" }]
3672
3673
  },
3673
3674
  MSE: {
3674
3675
  BUFFER_SIZE: 2097152,
@@ -3999,6 +4000,9 @@ function checkWebRTCCompatibility(probe) {
3999
4000
  function hasManagedMediaSource() {
4000
4001
  return "ManagedMediaSource" in window;
4001
4002
  }
4003
+ function hasMediaSource() {
4004
+ return "MediaSource" in window || hasManagedMediaSource();
4005
+ }
4002
4006
  function getMediaSourceConstructor() {
4003
4007
  if (hasManagedMediaSource()) return window.ManagedMediaSource;
4004
4008
  if ("MediaSource" in window) return window.MediaSource;
@@ -4281,6 +4285,101 @@ async function playVideo(videoElement) {
4281
4285
  }
4282
4286
  }
4283
4287
  }
4288
+ // src/streaming/relayFirst.ts
4289
+ var PROBE_STUN_SERVERS = [{ urls: "stun:stun.cloudflare.com:3478" }, { urls: "stun:stun.l.google.com:19302" }];
4290
+ var PROBE_SETTLE_MS = 1500;
4291
+ var PROBE_TIMEOUT_MS = 4e3;
4292
+ var PROBE_REUSE_MS = 6e4;
4293
+ var RELAY_WAIT_MS = 3e3;
4294
+ var RELAY_HEAD_START_MS = 500;
4295
+ var IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
4296
+ var lastProbe;
4297
+ function relayFirstAddress(iceServers) {
4298
+ if (!usesWebKitNetworking() || !iceServers?.some((server) => urlsOf(server).some(isIPv6OnlyStun))) return Promise.resolve(void 0);
4299
+ if (!lastProbe || Date.now() - lastProbe.startedAt > PROBE_REUSE_MS) lastProbe = {
4300
+ startedAt: Date.now(),
4301
+ address: probeSingleIPv4Target()
4302
+ };
4303
+ return lastProbe.address;
4304
+ }
4305
+ function relayFirstIceServers(iceServers) {
4306
+ return (iceServers ?? []).map((server) => ({
4307
+ ...server,
4308
+ urls: urlsOf(server).filter((url) => !/^stuns?:/i.test(url) || isIPv6OnlyStun(url))
4309
+ })).filter((server) => server.urls.length > 0);
4310
+ }
4311
+ function relayPermitCandidate(address) {
4312
+ return `candidate:1 1 udp 1 ${address} 9 typ srflx raddr 0.0.0.0 rport 0`;
4313
+ }
4314
+ function orderRelayFirst(add) {
4315
+ const held = [];
4316
+ let relaySeen = false;
4317
+ let open = false;
4318
+ function release() {
4319
+ if (open) return;
4320
+ open = true;
4321
+ clearTimeout(wait);
4322
+ for (const candidate of held.splice(0)) add(candidate).catch(() => {});
4323
+ }
4324
+ const wait = setTimeout(release, RELAY_WAIT_MS);
4325
+ return async (candidate) => {
4326
+ if (open || !isUdpIPv4(candidate)) return add(candidate);
4327
+ if (relaySeen || !/ typ relay\b/.test(candidate)) {
4328
+ held.push(candidate);
4329
+ return;
4330
+ }
4331
+ relaySeen = true;
4332
+ setTimeout(release, RELAY_HEAD_START_MS);
4333
+ return add(candidate);
4334
+ };
4335
+ }
4336
+ function probeSingleIPv4Target() {
4337
+ return new Promise((resolve) => {
4338
+ let pc;
4339
+ try {
4340
+ pc = new RTCPeerConnection({ iceServers: PROBE_STUN_SERVERS });
4341
+ } catch {
4342
+ resolve(void 0);
4343
+ return;
4344
+ }
4345
+ let address;
4346
+ let settle;
4347
+ const timeout = setTimeout(() => finish(void 0), PROBE_TIMEOUT_MS);
4348
+ function finish(result) {
4349
+ clearTimeout(timeout);
4350
+ clearTimeout(settle);
4351
+ pc.onicecandidate = null;
4352
+ pc.close();
4353
+ resolve(result);
4354
+ }
4355
+ pc.onicecandidate = (event) => {
4356
+ if (!event.candidate) {
4357
+ finish(void 0);
4358
+ return;
4359
+ }
4360
+ const found = event.candidate.address ?? event.candidate.candidate.split(" ")[4];
4361
+ if (address || event.candidate.type !== "srflx" || !found || !IPV4.test(found)) return;
4362
+ address = found;
4363
+ settle = setTimeout(() => finish(address), PROBE_SETTLE_MS);
4364
+ };
4365
+ pc.createDataChannel("probe");
4366
+ pc.createOffer().then((offer) => pc.setLocalDescription(offer)).catch(() => finish(void 0));
4367
+ });
4368
+ }
4369
+ function usesWebKitNetworking() {
4370
+ if (typeof navigator === "undefined") return false;
4371
+ return /AppleWebKit/i.test(navigator.userAgent) && !/Chrome|Chromium|Android|Edg/i.test(navigator.userAgent);
4372
+ }
4373
+ function urlsOf(server) {
4374
+ return Array.isArray(server.urls) ? server.urls : [server.urls];
4375
+ }
4376
+ function isIPv6OnlyStun(url) {
4377
+ return /^stuns?:\[/i.test(url);
4378
+ }
4379
+ function isUdpIPv4(candidate) {
4380
+ const fields = candidate.split(" ");
4381
+ return fields[2]?.toLowerCase() === "udp" && IPV4.test(fields[4] ?? "");
4382
+ }
4284
4383
  // src/streaming/webrtc.ts
4285
4384
  function newPeerConnection(iceServers = [...STREAM_CONFIG.WEBRTC.ICE_SERVERS]) {
4286
4385
  try {
@@ -4320,12 +4419,40 @@ async function handleIceCandidate(pc, signal, candidate, filterUdp) {
4320
4419
  sdpMid: "0"
4321
4420
  });
4322
4421
  }
4422
+ function createRelayFirst(state, signal, filterUdp, probe) {
4423
+ const add = (candidate) => handleIceCandidate(state.pc, signal, candidate, filterUdp);
4424
+ let address;
4425
+ let handle = add;
4426
+ return {
4427
+ async settle() {
4428
+ address = probe && !filterUdp ? await probe : void 0;
4429
+ if (address) handle = orderRelayFirst(add);
4430
+ },
4431
+ iceServers: (servers) => address ? relayFirstIceServers(servers) : servers,
4432
+ permit(send) {
4433
+ if (address && !signal.aborted) send(relayPermitCandidate(address));
4434
+ },
4435
+ handleCandidate: (candidate) => handle(candidate)
4436
+ };
4437
+ }
4323
4438
  async function setMicTrack(micTransceiver, signal, track) {
4324
4439
  if (signal.aborted || !micTransceiver) return;
4325
4440
  try {
4326
4441
  await micTransceiver.sender.replaceTrack(track);
4327
4442
  } catch {}
4328
4443
  }
4444
+ async function readPeerPath(pc) {
4445
+ const reports = /* @__PURE__ */ new Map();
4446
+ let pairId;
4447
+ (await pc.getStats()).forEach((report, id) => {
4448
+ reports.set(id, report);
4449
+ if (report.type === "transport" && report.selectedCandidatePairId) pairId = report.selectedCandidatePairId;
4450
+ else if (report.type === "candidate-pair" && report.selected) pairId ??= id;
4451
+ });
4452
+ const pair = pairId ? reports.get(pairId) : void 0;
4453
+ if (!pair) return void 0;
4454
+ return [reports.get(pair.localCandidateId)?.candidateType, reports.get(pair.remoteCandidateId)?.candidateType].includes("relay") ? "relay" : "direct";
4455
+ }
4329
4456
  function closePeerConnection(state) {
4330
4457
  if (state.micTransceiver) {
4331
4458
  state.micTransceiver.sender.replaceTrack(null).catch(() => {});
@@ -4339,21 +4466,38 @@ function closePeerConnection(state) {
4339
4466
  state.isConnected = false;
4340
4467
  }
4341
4468
  function createWebRTCHandler(options) {
4342
- const { mode, iceServers, onConnected, onDisconnected, onFailed, onCandidate, signal } = options;
4469
+ const { mode, iceServers, relayFirst, onConnected, onReconnected, onDisconnected, onFailed, onCandidate, onPath, signal } = options;
4343
4470
  const state = {
4344
4471
  pc: null,
4345
4472
  micTransceiver: null,
4346
4473
  isConnected: false
4347
4474
  };
4348
4475
  const filterUdp = mode === "webrtc/tcp";
4476
+ const relay = createRelayFirst(state, signal, filterUdp, relayFirst);
4477
+ async function handleAnswer(sdp) {
4478
+ await handleSdpAnswer(state.pc, signal, sdp);
4479
+ relay.permit(onCandidate);
4480
+ }
4349
4481
  function createPeerConnection() {
4350
- const peerConnection = createBasePeerConnection(signal, onCandidate, filterUdp, iceServers);
4482
+ const peerConnection = createBasePeerConnection(signal, onCandidate, filterUdp, relay.iceServers(iceServers));
4483
+ let stream;
4484
+ function reportPath() {
4485
+ readPeerPath(peerConnection).then((path) => {
4486
+ if (path && !signal.aborted && state.pc === peerConnection) onPath(path);
4487
+ }, () => {});
4488
+ }
4351
4489
  peerConnection.onconnectionstatechange = () => {
4352
4490
  if (signal.aborted) return;
4353
4491
  const connectionState = peerConnection.connectionState;
4354
4492
  if (connectionState === "connected") {
4355
4493
  state.isConnected = true;
4356
- const stream = new MediaStream(peerConnection.getTransceivers().filter((tr) => tr.currentDirection === "recvonly").map((tr) => tr.receiver.track));
4494
+ reportPath();
4495
+ if (stream) {
4496
+ onReconnected();
4497
+ return;
4498
+ }
4499
+ stream = new MediaStream(peerConnection.getTransceivers().filter((tr) => tr.currentDirection === "recvonly").map((tr) => tr.receiver.track));
4500
+ peerConnection.getReceivers()[0]?.transport?.iceTransport?.addEventListener("selectedcandidatepairchange", reportPath);
4357
4501
  onConnected(stream);
4358
4502
  } else if (connectionState === "failed") {
4359
4503
  state.isConnected = false;
@@ -4363,11 +4507,16 @@ function createWebRTCHandler(options) {
4363
4507
  onDisconnected();
4364
4508
  }
4365
4509
  };
4510
+ peerConnection.oniceconnectionstatechange = () => {
4511
+ if (!signal.aborted && state.isConnected) reportPath();
4512
+ };
4366
4513
  peerConnection.addTransceiver("video", { direction: "recvonly" });
4367
4514
  peerConnection.addTransceiver("audio", { direction: "recvonly" });
4368
4515
  return peerConnection;
4369
4516
  }
4370
4517
  async function createOffer() {
4518
+ if (signal.aborted) return void 0;
4519
+ await relay.settle();
4371
4520
  if (signal.aborted) return void 0;
4372
4521
  state.pc = createPeerConnection();
4373
4522
  const offer = await state.pc.createOffer();
@@ -4386,13 +4535,13 @@ function createWebRTCHandler(options) {
4386
4535
  return state.isConnected;
4387
4536
  },
4388
4537
  createOffer,
4389
- handleAnswer: (sdp) => handleSdpAnswer(state.pc, signal, sdp),
4390
- handleCandidate: (candidate) => handleIceCandidate(state.pc, signal, candidate, filterUdp),
4538
+ handleAnswer,
4539
+ handleCandidate: relay.handleCandidate,
4391
4540
  close
4392
4541
  };
4393
4542
  }
4394
4543
  function createBackchannelHandler(options) {
4395
- const { filterUdp, iceServers, onConnected, onFailed, onCandidate, signal } = options;
4544
+ const { filterUdp, iceServers, relayFirst, onConnected, onFailed, onCandidate, signal } = options;
4396
4545
  const state = {
4397
4546
  pc: null,
4398
4547
  micTransceiver: null,
@@ -4400,6 +4549,8 @@ function createBackchannelHandler(options) {
4400
4549
  };
4401
4550
  let answered = false;
4402
4551
  const heldCandidates = [];
4552
+ let disconnectGrace;
4553
+ const relay = createRelayFirst(state, signal, filterUdp, relayFirst);
4403
4554
  function sendCandidate(candidate) {
4404
4555
  if (answered) onCandidate(candidate);
4405
4556
  else heldCandidates.push(candidate);
@@ -4408,21 +4559,26 @@ function createBackchannelHandler(options) {
4408
4559
  if (signal.aborted || !state.pc) return;
4409
4560
  answered = true;
4410
4561
  for (const candidate of heldCandidates.splice(0)) onCandidate(candidate);
4562
+ relay.permit(onCandidate);
4411
4563
  await state.pc.setRemoteDescription({
4412
4564
  type: "answer",
4413
4565
  sdp
4414
4566
  });
4415
4567
  }
4416
4568
  function createPeerConnection() {
4417
- const peerConnection = createBasePeerConnection(signal, sendCandidate, filterUdp, iceServers);
4569
+ const peerConnection = createBasePeerConnection(signal, sendCandidate, filterUdp, relay.iceServers(iceServers));
4418
4570
  peerConnection.onconnectionstatechange = () => {
4419
4571
  if (signal.aborted) return;
4420
4572
  const connectionState = peerConnection.connectionState;
4421
4573
  if (connectionState === "connected") {
4574
+ clearTimeout(disconnectGrace);
4422
4575
  state.isConnected = true;
4423
4576
  onConnected();
4424
- } else if (connectionState === "disconnected") state.isConnected = false;
4425
- else if (connectionState === "failed") {
4577
+ } else if (connectionState === "disconnected") {
4578
+ state.isConnected = false;
4579
+ disconnectGrace = setTimeout(onFailed, STREAM_CONFIG.WEBRTC.DISCONNECT_GRACE);
4580
+ } else if (connectionState === "failed") {
4581
+ clearTimeout(disconnectGrace);
4426
4582
  state.isConnected = false;
4427
4583
  onFailed();
4428
4584
  }
@@ -4431,6 +4587,8 @@ function createBackchannelHandler(options) {
4431
4587
  return peerConnection;
4432
4588
  }
4433
4589
  async function createOffer() {
4590
+ if (signal.aborted) return void 0;
4591
+ await relay.settle();
4434
4592
  if (signal.aborted) return void 0;
4435
4593
  state.pc = createPeerConnection();
4436
4594
  const offer = await state.pc.createOffer();
@@ -4438,6 +4596,7 @@ function createBackchannelHandler(options) {
4438
4596
  return offer.sdp;
4439
4597
  }
4440
4598
  function close() {
4599
+ clearTimeout(disconnectGrace);
4441
4600
  closePeerConnection(state);
4442
4601
  }
4443
4602
  signal.addEventListener("abort", close, { once: true });
@@ -4447,7 +4606,7 @@ function createBackchannelHandler(options) {
4447
4606
  },
4448
4607
  createOffer,
4449
4608
  handleAnswer,
4450
- handleCandidate: (candidate) => handleIceCandidate(state.pc, signal, candidate, filterUdp),
4609
+ handleCandidate: relay.handleCandidate,
4451
4610
  setMicrophoneTrack: (track) => setMicTrack(state.micTransceiver, signal, track),
4452
4611
  close
4453
4612
  };
@@ -4472,6 +4631,11 @@ var STALL_AFTER = 3e3;
4472
4631
  var STALL_RECONNECT_AFTER = 15e3;
4473
4632
  var HAVE_CURRENT_DATA$1 = 2;
4474
4633
  var PROBE_WAIT_MS = 2e3;
4634
+ var WEBRTC_RETRY_DELAYS = [
4635
+ 3e4,
4636
+ 12e4,
4637
+ 6e5
4638
+ ];
4475
4639
  function isPageHidden() {
4476
4640
  return typeof document !== "undefined" && document.visibilityState === "hidden";
4477
4641
  }
@@ -4495,6 +4659,8 @@ var StreamConnection = class {
4495
4659
  hasBackchannel;
4496
4660
  isPlaying;
4497
4661
  stalled;
4662
+ connectionPath;
4663
+ webrtcUnreachable;
4498
4664
  error;
4499
4665
  probeInfo;
4500
4666
  muted;
@@ -4512,6 +4678,7 @@ var StreamConnection = class {
4512
4678
  iceServers;
4513
4679
  scope = effectScope(true);
4514
4680
  stopsWhenHidden = suspendsWhenHidden();
4681
+ webrtcPath = ref();
4515
4682
  connectionGeneration = 0;
4516
4683
  abortController = new AbortController();
4517
4684
  probedSourceId;
@@ -4548,6 +4715,9 @@ var StreamConnection = class {
4548
4715
  lastMediaTime = 0;
4549
4716
  lastFrameCount;
4550
4717
  frameCounterLive = false;
4718
+ webrtcRetries = 0;
4719
+ webrtcRetryDue = false;
4720
+ webrtcPlayingSince;
4551
4721
  offTabVisible;
4552
4722
  offTabPaused;
4553
4723
  onVideoPauseBound;
@@ -4559,6 +4729,10 @@ var StreamConnection = class {
4559
4729
  stopConnectTimeout;
4560
4730
  startMseConnectTimeout;
4561
4731
  stopMseConnectTimeout;
4732
+ startDisconnectGrace;
4733
+ stopDisconnectGrace;
4734
+ startWebRTCRetry;
4735
+ stopWebRTCRetry;
4562
4736
  constructor(options) {
4563
4737
  this.options = options;
4564
4738
  const ctx = useCameraUi();
@@ -4578,6 +4752,7 @@ var StreamConnection = class {
4578
4752
  this.hasBackchannel = ref(false);
4579
4753
  this.isPlaying = ref(false);
4580
4754
  this.stalled = ref(false);
4755
+ this.webrtcUnreachable = ref(false);
4581
4756
  this.error = ref();
4582
4757
  this.probeInfo = shallowRef();
4583
4758
  this.muted = ref(true);
@@ -4590,6 +4765,10 @@ var StreamConnection = class {
4590
4765
  this.videoElement = computed(() => toValue(options.videoElement));
4591
4766
  this.containerElement = computed(() => toValue(options.containerElement));
4592
4767
  this.isReady = computed(() => !!this.camera.value && !!this.videoElement.value && !!this.target.value);
4768
+ this.connectionPath = computed(() => {
4769
+ if (this.status.value !== "connected") return void 0;
4770
+ return this.activeMode.value === "mse" || this.activeMode.value === "mjpeg" ? "tunnel" : this.webrtcPath.value;
4771
+ });
4593
4772
  this.scope.run(() => {
4594
4773
  const { onTabPaused, onTabVisible } = useTabVisibility();
4595
4774
  const hiddenStopDelay = this.stopsWhenHidden ? 0 : DEFAULT_TAB_PAUSE_MS;
@@ -4605,10 +4784,8 @@ var StreamConnection = class {
4605
4784
  }, STREAM_CONFIG.WEBRTC.WS_CONNECT_TIMEOUT, { immediate: false });
4606
4785
  const connectTimeout = useTimeoutFn(() => {
4607
4786
  if (this.abortController.signal.aborted) return;
4608
- if (this.webrtcHandler && !this.webrtcHandler.isConnected) {
4609
- if (this.requestedMode.value === "auto") this.fallBackToMSE();
4610
- else this.retryAfterNoMedia();
4611
- } else if (this.webrtcHandler && this.awaitingWebRTCFrame || this.mjpegHandler && !this.isPlaying.value || this.status.value === "connected" && !this.isPlaying.value) {
4787
+ if (this.webrtcHandler && !this.webrtcHandler.isConnected) this.handleWebRTCFailed(true);
4788
+ else if (this.webrtcHandler && this.awaitingWebRTCFrame || this.mjpegHandler && !this.isPlaying.value || this.status.value === "connected" && !this.isPlaying.value) {
4612
4789
  if (isPageHidden()) {
4613
4790
  this.startConnectTimeout();
4614
4791
  return;
@@ -4620,12 +4797,18 @@ var StreamConnection = class {
4620
4797
  if (this.abortController.signal.aborted || this.status.value === "connected" || this.status.value === "closed") return;
4621
4798
  if (this.mseHandler && !this.mseHandler.isReady) this.retryAfterNoMedia();
4622
4799
  }, STREAM_CONFIG.WEBRTC.CONNECT_TIMEOUT, { immediate: false });
4800
+ const disconnectGrace = useTimeoutFn(() => this.handleWebRTCLost(), STREAM_CONFIG.WEBRTC.DISCONNECT_GRACE, { immediate: false });
4801
+ const webrtcRetry = useTimeoutFn(() => this.retryWebRTC(), () => WEBRTC_RETRY_DELAYS[Math.min(this.webrtcRetries, WEBRTC_RETRY_DELAYS.length - 1)], { immediate: false });
4623
4802
  this.startWsConnectTimeout = wsConnectTimeout.start;
4624
4803
  this.stopWsConnectTimeout = wsConnectTimeout.stop;
4625
4804
  this.startConnectTimeout = connectTimeout.start;
4626
4805
  this.stopConnectTimeout = connectTimeout.stop;
4627
4806
  this.startMseConnectTimeout = mseConnectTimeout.start;
4628
4807
  this.stopMseConnectTimeout = mseConnectTimeout.stop;
4808
+ this.startDisconnectGrace = disconnectGrace.start;
4809
+ this.stopDisconnectGrace = disconnectGrace.stop;
4810
+ this.startWebRTCRetry = webrtcRetry.start;
4811
+ this.stopWebRTCRetry = webrtcRetry.stop;
4629
4812
  this.setupWatchers();
4630
4813
  this.offTabPaused = onTabPaused(() => {
4631
4814
  log.debug(`onTabPaused fired — status=${this.status.value}, isReady=${this.isReady.value}, target=${!!this.target.value}`);
@@ -4658,6 +4841,11 @@ var StreamConnection = class {
4658
4841
  this.restart();
4659
4842
  return;
4660
4843
  }
4844
+ if (this.webrtcRetryDue) {
4845
+ log.debug("onTabVisible: trying WebRTC again");
4846
+ this.retryWebRTC();
4847
+ return;
4848
+ }
4661
4849
  log.debug("onTabVisible — not paused by visibility, no-op");
4662
4850
  });
4663
4851
  if (autoStart) whenever(this.isReady, () => {
@@ -4710,6 +4898,8 @@ var StreamConnection = class {
4710
4898
  this.status.value = "connecting";
4711
4899
  this.error.value = void 0;
4712
4900
  this.isPlaying.value = false;
4901
+ this.webrtcUnreachable.value = false;
4902
+ if (this.requestedMode.value !== "mse") relayFirstAddress(this.iceServers?.value);
4713
4903
  try {
4714
4904
  const init = this.initializeSource();
4715
4905
  if (init === "unsupported") {
@@ -4993,6 +5183,10 @@ var StreamConnection = class {
4993
5183
  if (active) this.startHealthCheck();
4994
5184
  }, { immediate: true });
4995
5185
  this.stopWatchers.push(stopHealthWatch);
5186
+ const stopPathWatch = watch(this.connectionPath, (path) => {
5187
+ if (path) log.debug(`connection path: ${path}`);
5188
+ });
5189
+ this.stopWatchers.push(stopPathWatch);
4996
5190
  const stopCameraWatch = watch([
4997
5191
  this.camera,
4998
5192
  this.videoElement,
@@ -5129,8 +5323,7 @@ var StreamConnection = class {
5129
5323
  handleNoFramesWhileConnected() {
5130
5324
  if (this.sourceLikelyH265() && !this.webrtcTookOver()) reportH265DecodeFailure(this.activeMode.value === "mse" ? "mse" : "webrtc");
5131
5325
  if (this.requestedMode.value === "auto" && this.activeMode.value !== "mse" && !this.mjpegHandler && !this.mseRejected) {
5132
- this.webrtcHandler?.close();
5133
- this.webrtcHandler = void 0;
5326
+ this.closeWebRTC();
5134
5327
  this.awaitingWebRTCFrame = false;
5135
5328
  this.activeMode.value = "mse";
5136
5329
  this.isPlaying.value = false;
@@ -5333,7 +5526,7 @@ var StreamConnection = class {
5333
5526
  if (peer && peer === this.webrtcHandler && !peer.isConnected) {
5334
5527
  this.lastError = err;
5335
5528
  this.stopConnectTimeout();
5336
- if (this.requestedMode.value === "auto") this.fallBackToMSE();
5529
+ if (this.requestedMode.value === "auto") this.fallBackToMSE(false);
5337
5530
  else this.retryLater(err);
5338
5531
  return;
5339
5532
  }
@@ -5370,14 +5563,19 @@ var StreamConnection = class {
5370
5563
  const handler = createWebRTCHandler({
5371
5564
  mode,
5372
5565
  iceServers: this.iceServers?.value,
5566
+ relayFirst: relayFirstAddress(this.iceServers?.value),
5373
5567
  signal: this.abortController.signal,
5374
5568
  onConnected: (stream) => this.handleWebRTCConnected(stream),
5569
+ onReconnected: () => this.handleWebRTCReconnected(),
5375
5570
  onDisconnected: () => this.handleWebRTCDisconnected(),
5376
- onFailed: () => this.handleWebRTCFailed(),
5571
+ onFailed: () => this.handleWebRTCFailed(true),
5377
5572
  onCandidate: (candidate) => this.sendWsMessage({
5378
5573
  type: "webrtc/candidate",
5379
5574
  value: candidate
5380
- })
5575
+ }),
5576
+ onPath: (path) => {
5577
+ this.webrtcPath.value = path;
5578
+ }
5381
5579
  });
5382
5580
  this.webrtcHandler = handler;
5383
5581
  const offer = await handler.createOffer();
@@ -5385,12 +5583,14 @@ var StreamConnection = class {
5385
5583
  }
5386
5584
  handleWebRTCConnected(stream) {
5387
5585
  if (this.abortController.signal.aborted) return;
5586
+ this.stopDisconnectGrace();
5388
5587
  const video = this.videoElement.value;
5389
5588
  if (!video) return;
5390
5589
  if (this.hasVideo.value && stream.getVideoTracks().length === 0) {
5391
- this.handleWebRTCFailed();
5590
+ this.handleWebRTCFailed(false);
5392
5591
  return;
5393
5592
  }
5593
+ this.webrtcUnreachable.value = false;
5394
5594
  this.noteDeliveredAudio(stream.getAudioTracks().length > 0);
5395
5595
  this.activeMode.value = this.requestedMode.value === "auto" ? "webrtc" : this.requestedMode.value;
5396
5596
  this.lastMediaStream = stream;
@@ -5407,26 +5607,35 @@ var StreamConnection = class {
5407
5607
  if (this.requestedMode.value === "auto" && this.mseHandler) this.dropMSE();
5408
5608
  }
5409
5609
  handleWebRTCDisconnected() {
5410
- if (this.status.value !== "closed" && !this.abortController.signal.aborted) {
5411
- this.status.value = "reconnecting";
5412
- if (this.requestedMode.value === "auto" && this.mseHandler?.isReady) {
5413
- this.webrtcHandler?.close();
5414
- this.webrtcHandler = void 0;
5415
- this.activeMode.value = "mse";
5416
- this.status.value = "connected";
5417
- this.resumeBackchannel();
5418
- } else this.retryLater();
5610
+ if (this.status.value === "closed" || this.abortController.signal.aborted) return;
5611
+ log.debug("WebRTC disconnected, waiting for it to come back");
5612
+ if (this.activeMode.value !== "mse") this.status.value = "reconnecting";
5613
+ this.startDisconnectGrace();
5614
+ }
5615
+ handleWebRTCReconnected() {
5616
+ if (this.abortController.signal.aborted) return;
5617
+ log.debug("WebRTC connected again");
5618
+ this.stopDisconnectGrace();
5619
+ this.status.value = "connected";
5620
+ }
5621
+ handleWebRTCLost() {
5622
+ log.debug("WebRTC did not come back");
5623
+ if (this.requestedMode.value !== "auto" || this.mseRejected || !hasMediaSource()) {
5624
+ this.retryLater();
5625
+ return;
5419
5626
  }
5627
+ if (this.activeMode.value !== "mse") this.isPlaying.value = false;
5628
+ this.fallBackToMSE(true);
5420
5629
  }
5421
- handleWebRTCFailed() {
5630
+ handleWebRTCFailed(retryWebRTC) {
5422
5631
  if (this.abortController.signal.aborted) return;
5423
5632
  this.stopConnectTimeout();
5424
- if (this.requestedMode.value === "auto") this.fallBackToMSE();
5633
+ if (this.requestedMode.value === "auto") this.fallBackToMSE(retryWebRTC);
5425
5634
  else this.retryLater(this.lastError ?? /* @__PURE__ */ new Error("WebRTC connection failed"));
5426
5635
  }
5427
- fallBackToMSE() {
5428
- this.webrtcHandler?.close();
5429
- this.webrtcHandler = void 0;
5636
+ fallBackToMSE(retryWebRTC) {
5637
+ this.closeWebRTC();
5638
+ this.webrtcUnreachable.value = retryWebRTC;
5430
5639
  this.awaitingWebRTCFrame = false;
5431
5640
  if (this.mseRejected) {
5432
5641
  this.retryLater(this.lastError);
@@ -5436,6 +5645,33 @@ var StreamConnection = class {
5436
5645
  if (this.mseHandler?.isReady) this.handleMSEReady();
5437
5646
  else this.startMSE();
5438
5647
  this.resumeBackchannel();
5648
+ if (retryWebRTC) this.scheduleWebRTCRetry();
5649
+ }
5650
+ scheduleWebRTCRetry() {
5651
+ if (this.webrtcPlayingSince !== void 0 && performance.now() - this.webrtcPlayingSince >= RETRY_RESET_AFTER) this.webrtcRetries = 0;
5652
+ this.webrtcPlayingSince = void 0;
5653
+ this.startWebRTCRetry();
5654
+ }
5655
+ retryWebRTC() {
5656
+ if (isPageHidden()) {
5657
+ this.webrtcRetryDue = true;
5658
+ return;
5659
+ }
5660
+ this.webrtcRetryDue = false;
5661
+ this.webrtcRetries++;
5662
+ const signalingBusy = !!this.offerPeer || !!this.backchannelHandler;
5663
+ if (this.requestedMode.value !== "auto" || this.activeMode.value !== "mse" || this.status.value !== "connected" || this.webrtcHandler || signalingBusy) {
5664
+ this.startWebRTCRetry();
5665
+ return;
5666
+ }
5667
+ log.debug("trying WebRTC again while MSE plays");
5668
+ this.startWebRTC("webrtc");
5669
+ }
5670
+ closeWebRTC() {
5671
+ this.stopDisconnectGrace();
5672
+ this.webrtcHandler?.close();
5673
+ this.webrtcHandler = void 0;
5674
+ this.webrtcPath.value = void 0;
5439
5675
  }
5440
5676
  startMSE() {
5441
5677
  if (this.mseHandler) return;
@@ -5599,6 +5835,7 @@ var StreamConnection = class {
5599
5835
  if (!this.webrtcHandler || this.webrtcHandler.isConnected) {
5600
5836
  this.stopConnectTimeout();
5601
5837
  this.awaitingWebRTCFrame = false;
5838
+ if (this.webrtcHandler) this.webrtcPlayingSince = performance.now();
5602
5839
  this.resumeBackchannel();
5603
5840
  }
5604
5841
  this.error.value = void 0;
@@ -5680,12 +5917,15 @@ var StreamConnection = class {
5680
5917
  this.stopConnectTimeout();
5681
5918
  this.stopWsConnectTimeout();
5682
5919
  this.stopMseConnectTimeout();
5920
+ this.stopWebRTCRetry();
5921
+ this.webrtcRetryDue = false;
5922
+ this.webrtcRetries = 0;
5923
+ this.webrtcPlayingSince = void 0;
5683
5924
  this.stopHealthCheck();
5684
5925
  this.cancelFirstFrameWatch?.();
5685
5926
  this.awaitingWebRTCFrame = false;
5686
5927
  this.lastMediaStream = null;
5687
- this.webrtcHandler?.close();
5688
- this.webrtcHandler = void 0;
5928
+ this.closeWebRTC();
5689
5929
  this.mseHandler?.close();
5690
5930
  this.mseHandler = void 0;
5691
5931
  this.mjpegHandler?.close();
@@ -5709,6 +5949,7 @@ var StreamConnection = class {
5709
5949
  const handler = createBackchannelHandler({
5710
5950
  filterUdp: this.requestedMode.value === "webrtc/tcp",
5711
5951
  iceServers: this.iceServers?.value,
5952
+ relayFirst: relayFirstAddress(this.iceServers?.value),
5712
5953
  signal: this.abortController.signal,
5713
5954
  onCandidate: (candidate) => this.sendWsMessage({
5714
5955
  type: "webrtc/candidate",
@@ -6114,6 +6355,8 @@ function useCameraStream(options) {
6114
6355
  const status = computed(() => currentStream.value?.status.value ?? "idle");
6115
6356
  const isPlaying = computed(() => currentStream.value?.isPlaying.value ?? false);
6116
6357
  const stalled = computed(() => currentStream.value?.stalled?.value ?? false);
6358
+ const connectionPath = computed(() => currentStream.value?.connectionPath?.value);
6359
+ const webrtcUnreachable = computed(() => currentStream.value?.webrtcUnreachable?.value ?? false);
6117
6360
  const activeMode = computed(() => currentStream.value?.activeMode.value ?? "webrtc");
6118
6361
  const activeResolution = computed(() => currentStream.value?.activeResolution.value ?? "low-resolution");
6119
6362
  const degradedFrom = computed(() => currentStream.value?.degradedFrom.value ?? null);
@@ -6665,6 +6908,8 @@ function useCameraStream(options) {
6665
6908
  status,
6666
6909
  isPlaying,
6667
6910
  stalled,
6911
+ connectionPath,
6912
+ webrtcUnreachable,
6668
6913
  activeMode,
6669
6914
  activeResolution,
6670
6915
  degradedFrom,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camera.ui/browser",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "camera.ui browser client",
5
5
  "author": "seydx (https://github.com/cameraui/clients)",
6
6
  "type": "module",
@@ -54,7 +54,7 @@
54
54
  "@vueuse/core": "^15.0.0",
55
55
  "@webgpu/types": "^0.1.74",
56
56
  "eslint": "9.39.2",
57
- "eslint-plugin-vue": "^10.12.0",
57
+ "eslint-plugin-vue": "^10.12.1",
58
58
  "globals": "^17.14.0",
59
59
  "prettier": "^3.9.10",
60
60
  "rimraf": "^6.1.4",