@camstack/ui-library 1.2.100 → 1.2.102

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.
@@ -18,6 +18,57 @@ export declare function stripDownlinkForColdStart<T extends {
18
18
  }>(hints: T): Omit<T, 'downlinkMbps'>;
19
19
  /** After a probe, only mid/high justify a make-before-break climb. */
20
20
  export declare function shouldUpgradeAfterProbe(mbps: number): boolean;
21
+ /**
22
+ * The origin `/adaptive-probe` must be fetched from — the HUB, not the page.
23
+ *
24
+ * The page origin was the original assumption ("same-origin as the player",
25
+ * `adaptive-probe.routes.ts`) and it is only true for the web viewer and the
26
+ * iframe embed, both served by the hub. The NATIVE viewer loads this same
27
+ * player from an asset bundled with the app (`useNativeEmbedSource` resolves
28
+ * `assets/embed/camstack-embed.html` through `Image.resolveAssetSource`), so
29
+ * inside the WKWebView `window.location.origin` is `file://` — and
30
+ * `file:///adaptive-probe` is not a request any hub can answer. The probe
31
+ * therefore returned null on EVERY native connect, `downlinkMbps` was never
32
+ * sent, `pickAdaptiveTier` scored every session `default-low`, and
33
+ * `shouldUpgradeAfterProbe` was never even reached — so the make-before-break
34
+ * climb that the cold-start default depends on could not run. That is the
35
+ * whole of "the automatic quality always drops to low" on the app.
36
+ *
37
+ * The hub the player is already talking to is the `serverUrl` prop, so it
38
+ * wins; the page origin remains as the fallback for a host that passes a
39
+ * relative/empty `serverUrl`. Null means no probe is possible and the caller
40
+ * must skip it rather than fetch a URL that cannot exist.
41
+ */
42
+ export declare function resolveProbeOrigin(serverUrl: string | undefined, pageOrigin: string | undefined): string | null;
43
+ /** Which offer composed the hints. A climb and a cold start read identically
44
+ * in the hub's log; only the client knows which one it just sent. */
45
+ export type AdaptiveOfferPhase = 'cold-start' | 'probe-climb' | 'renegotiate';
46
+ /**
47
+ * What the CLIENT sent for one adaptive decision. Deliberately not the hub's
48
+ * verdict — the hub already logs its own pick, and a client line that repeated
49
+ * it would prove nothing about the client. This is the other half of that
50
+ * join: the inputs the hub scored, plus whether the downlink probe had an
51
+ * origin it could even reach.
52
+ */
53
+ export interface AdaptiveHintsReport {
54
+ readonly phase: AdaptiveOfferPhase;
55
+ /** Null when no http(s) origin resolved — the probe did not run at all. */
56
+ readonly probeOrigin: string | null;
57
+ readonly viewportWidth?: number;
58
+ readonly viewportHeight?: number;
59
+ readonly devicePixelRatio?: number;
60
+ readonly downlinkMbps?: number;
61
+ readonly prefersTier?: string;
62
+ }
63
+ /**
64
+ * Identity of a decision, for "log once per CHANGE, not per tick".
65
+ *
66
+ * `downlinkMbps` is a fresh measurement on every probe and would otherwise
67
+ * make every offer look like a new decision. What the ladder actually consumes
68
+ * is the RUNG it maps to (`suggestedAdaptiveTier`), so two measurements that
69
+ * pick the same rung are the same decision and must collapse to one line.
70
+ */
71
+ export declare function adaptiveHintsKey(report: AdaptiveHintsReport): string;
21
72
  export declare function measureDownlinkMbps(origin: string, deps: {
22
73
  readonly fetchImpl: (url: string, init: {
23
74
  signal: AbortSignal;
@@ -1,6 +1,7 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { CamProfile } from '@camstack/types';
3
3
  import { ReconnectAction } from './reconnect-schedule';
4
+ import { AdaptiveHintsReport } from './adaptive-downlink';
4
5
  type WebkitPresentationMode = 'inline' | 'picture-in-picture' | 'fullscreen';
5
6
  declare global {
6
7
  interface HTMLVideoElement {
@@ -227,6 +228,21 @@ export interface CameraStreamPlayerProps {
227
228
  * Use for network simulation (benchmark) or manual tier forcing.
228
229
  */
229
230
  hintsOverride?: Partial<ClientStreamHints>;
231
+ /**
232
+ * Observer of the ADAPTIVE decision this client just made — fired once per
233
+ * decision CHANGE (see `adaptiveHintsKey`), never per tick.
234
+ *
235
+ * Deliberately the client's half only: the viewport it measured, the pixel
236
+ * ratio, the downlink it managed to measure (and the origin it probed, null
237
+ * when none was reachable), and the phase of the offer that carried them.
238
+ * The hub logs its own pick from the same inputs; duplicating that here
239
+ * would be a second opinion on someone else's decision. Hosts join the two
240
+ * halves — the RN viewer forwards this over the embed log bridge so a
241
+ * "quality is stuck on low" report carries what the app actually SENT.
242
+ *
243
+ * Inert unless wired.
244
+ */
245
+ onAdaptiveHints?: (report: AdaptiveHintsReport) => void;
230
246
  /**
231
247
  * Host-driven fast reconnect. Bump this number (monotonic) to force a
232
248
  * full reconnect WITHOUT remounting the player — used by the embed when
@@ -258,5 +274,5 @@ export interface CameraStreamPlayerProps {
258
274
  */
259
275
  onVideoElement?: (el: HTMLVideoElement | null) => void;
260
276
  }
261
- export declare function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay, muted: initialMuted, showControls, showStats, onPlaybackStats, onClientNetworkSample, onConnectTiming, className, onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, reoffer, posterUrl, hintsOverride, reconnectSignal, onControlChannel, onVideoElement, }: CameraStreamPlayerProps): import("react").JSX.Element;
277
+ export declare function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay, muted: initialMuted, showControls, showStats, onPlaybackStats, onClientNetworkSample, onConnectTiming, className, onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, reoffer, posterUrl, hintsOverride, onAdaptiveHints, reconnectSignal, onControlChannel, onVideoElement, }: CameraStreamPlayerProps): import("react").JSX.Element;
262
278
  export {};
@@ -12,6 +12,7 @@ export { AudioWaveform } from './audio-waveform';
12
12
  export type { BatteryBadgeProps, BatteryStatusLike } from './battery-badge';
13
13
  export { BatteryBadge } from './battery-badge';
14
14
  export * from './breadcrumb';
15
+ export type { AdaptiveHintsReport, AdaptiveOfferPhase, } from './adaptive-downlink';
15
16
  export type { CameraStreamPlayerProps, ClientOfferResult, ClientStreamHints, PlayerConnectionState, PlayerWebrtcTarget, SessionSignalingState, SignalingResult, } from './camera-stream-player';
16
17
  export { CameraStreamPlayer } from './camera-stream-player';
17
18
  export { AutotrackSection } from './cap-settings/AutotrackSection';
package/dist/index.cjs CHANGED
@@ -24373,6 +24373,62 @@ function stripDownlinkForColdStart(hints) {
24373
24373
  function shouldUpgradeAfterProbe(mbps) {
24374
24374
  return suggestedAdaptiveTier(mbps) !== "low";
24375
24375
  }
24376
+ /** An http(s) origin, or null for anything that cannot serve the probe. */
24377
+ function httpOrigin(value) {
24378
+ if (value === void 0 || value === "") return null;
24379
+ let parsed;
24380
+ try {
24381
+ parsed = new URL(value);
24382
+ } catch {
24383
+ return null;
24384
+ }
24385
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
24386
+ return parsed.origin;
24387
+ }
24388
+ /**
24389
+ * The origin `/adaptive-probe` must be fetched from — the HUB, not the page.
24390
+ *
24391
+ * The page origin was the original assumption ("same-origin as the player",
24392
+ * `adaptive-probe.routes.ts`) and it is only true for the web viewer and the
24393
+ * iframe embed, both served by the hub. The NATIVE viewer loads this same
24394
+ * player from an asset bundled with the app (`useNativeEmbedSource` resolves
24395
+ * `assets/embed/camstack-embed.html` through `Image.resolveAssetSource`), so
24396
+ * inside the WKWebView `window.location.origin` is `file://` — and
24397
+ * `file:///adaptive-probe` is not a request any hub can answer. The probe
24398
+ * therefore returned null on EVERY native connect, `downlinkMbps` was never
24399
+ * sent, `pickAdaptiveTier` scored every session `default-low`, and
24400
+ * `shouldUpgradeAfterProbe` was never even reached — so the make-before-break
24401
+ * climb that the cold-start default depends on could not run. That is the
24402
+ * whole of "the automatic quality always drops to low" on the app.
24403
+ *
24404
+ * The hub the player is already talking to is the `serverUrl` prop, so it
24405
+ * wins; the page origin remains as the fallback for a host that passes a
24406
+ * relative/empty `serverUrl`. Null means no probe is possible and the caller
24407
+ * must skip it rather than fetch a URL that cannot exist.
24408
+ */
24409
+ function resolveProbeOrigin(serverUrl, pageOrigin) {
24410
+ return httpOrigin(serverUrl) ?? httpOrigin(pageOrigin);
24411
+ }
24412
+ /**
24413
+ * Identity of a decision, for "log once per CHANGE, not per tick".
24414
+ *
24415
+ * `downlinkMbps` is a fresh measurement on every probe and would otherwise
24416
+ * make every offer look like a new decision. What the ladder actually consumes
24417
+ * is the RUNG it maps to (`suggestedAdaptiveTier`), so two measurements that
24418
+ * pick the same rung are the same decision and must collapse to one line.
24419
+ */
24420
+ function adaptiveHintsKey(report) {
24421
+ const rung = report.downlinkMbps === void 0 ? "none" : suggestedAdaptiveTier(report.downlinkMbps);
24422
+ return [
24423
+ report.phase,
24424
+ report.viewportWidth ?? "-",
24425
+ report.viewportHeight ?? "-",
24426
+ report.devicePixelRatio ?? "-",
24427
+ rung,
24428
+ report.prefersTier ?? "-",
24429
+ report.probeOrigin ?? "unreachable"
24430
+ ].join("|");
24431
+ }
24376
24432
  async function measureDownlinkMbps(origin, deps) {
24377
24433
  const base = origin.replace(/\/+$/, "");
24378
24434
  const ctrl = new AbortController();
@@ -24455,9 +24511,19 @@ function computeClientHints(container) {
24455
24511
  }
24456
24512
  var downlinkCache = null;
24457
24513
  var DOWNLINK_TTL_MS = 3e4;
24458
- async function measuredDownlinkMbps() {
24459
- if (typeof window === "undefined") return void 0;
24460
- const origin = window.location.origin;
24514
+ /**
24515
+ * Measure the downlink against the HUB, not the page. `serverUrl` is the hub
24516
+ * this player is already signalling to; the page origin is only a fallback,
24517
+ * and inside the native WKWebView it is `file://` — see `resolveProbeOrigin`
24518
+ * for the full account of what that cost.
24519
+ */
24520
+ /** The origin the probe WILL use, for the client's own decision log. */
24521
+ function probeOriginFor(serverUrl) {
24522
+ return resolveProbeOrigin(serverUrl, typeof window !== "undefined" ? window.location.origin : void 0);
24523
+ }
24524
+ async function measuredDownlinkMbps(serverUrl) {
24525
+ const origin = probeOriginFor(serverUrl);
24526
+ if (origin === null) return void 0;
24461
24527
  if (downlinkCache && downlinkCache.origin === origin && Date.now() - downlinkCache.at < DOWNLINK_TTL_MS) return downlinkCache.mbps;
24462
24528
  const mbps = await measureDownlinkMbps(origin, {
24463
24529
  fetchImpl: (url, init) => fetch(url, init),
@@ -24480,7 +24546,7 @@ function mergePlayerHints(autoHints, override, downlinkMbps, coldStart) {
24480
24546
  return coldStart ? stripDownlinkForColdStart(merged) : merged;
24481
24547
  }
24482
24548
  var FIRST_FRAME_TIMEOUT_MS = 8e3;
24483
- function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, muted: initialMuted = true, showControls = true, showStats = false, onPlaybackStats, onClientNetworkSample, onConnectTiming, className = "", onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, reoffer, posterUrl, hintsOverride, reconnectSignal, onControlChannel, onVideoElement }) {
24549
+ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, muted: initialMuted = true, showControls = true, showStats = false, onPlaybackStats, onClientNetworkSample, onConnectTiming, className = "", onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, reoffer, posterUrl, hintsOverride, onAdaptiveHints, reconnectSignal, onControlChannel, onVideoElement }) {
24484
24550
  const videoRef = (0, react$1.useRef)(null);
24485
24551
  const containerRef = (0, react$1.useRef)(null);
24486
24552
  const pcRef = (0, react$1.useRef)(null);
@@ -24504,6 +24570,30 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24504
24570
  onVideoElementRef.current?.(videoRef.current);
24505
24571
  return () => onVideoElementRef.current?.(null);
24506
24572
  }, []);
24573
+ /** Same pattern for the adaptive-decision observer — a ref so wiring it does
24574
+ * not re-create the mount-stable connect callbacks. */
24575
+ const onAdaptiveHintsRef = (0, react$1.useRef)(onAdaptiveHints);
24576
+ onAdaptiveHintsRef.current = onAdaptiveHints;
24577
+ /** Last reported decision key — the "once per CHANGE, not per tick" latch. */
24578
+ const lastAdaptiveKeyRef = (0, react$1.useRef)(null);
24579
+ /**
24580
+ * Report the hints THIS offer carried, when they differ from the last
24581
+ * reported decision. Mount-stable (`[]` deps by contract) so the connect
24582
+ * callbacks can call it without gaining a dependency.
24583
+ */
24584
+ const reportAdaptiveHints = (0, react$1.useCallback)((phase, hints, probeOrigin) => {
24585
+ const cb = onAdaptiveHintsRef.current;
24586
+ if (!cb) return;
24587
+ const report = {
24588
+ phase,
24589
+ probeOrigin,
24590
+ ...hints
24591
+ };
24592
+ const key = adaptiveHintsKey(report);
24593
+ if (key === lastAdaptiveKeyRef.current) return;
24594
+ lastAdaptiveKeyRef.current = key;
24595
+ cb(report);
24596
+ }, []);
24507
24597
  /** The live session being polled for `pendingRenegotiation` (client-offer). */
24508
24598
  const activeSessionIdRef = (0, react$1.useRef)(null);
24509
24599
  /** Timer for the session-state (renegotiation) poll loop. */
@@ -24636,8 +24726,10 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24636
24726
  const abort = new AbortController();
24637
24727
  connectAbortRef.current = abort;
24638
24728
  try {
24639
- const probePromise = !withDownlink ? measuredDownlinkMbps() : void 0;
24640
- const { sessionId, sdpOffer, iceServers } = await createSession(mergePlayerHints(computeClientHints(containerRef.current), hintsOverride, withDownlink ? await measuredDownlinkMbps() : void 0, !withDownlink));
24729
+ const probePromise = !withDownlink ? measuredDownlinkMbps(serverUrl) : void 0;
24730
+ const mergedHints = mergePlayerHints(computeClientHints(containerRef.current), hintsOverride, withDownlink ? await measuredDownlinkMbps(serverUrl) : void 0, !withDownlink);
24731
+ reportAdaptiveHints(withDownlink ? "probe-climb" : "cold-start", mergedHints, probeOriginFor(serverUrl));
24732
+ const { sessionId, sdpOffer, iceServers } = await createSession(mergedHints);
24641
24733
  sessionIdsRef.current.add(sessionId);
24642
24734
  if (probePromise !== void 0) probePromise.then((mbps) => {
24643
24735
  if (mbps === void 0 || !shouldUpgradeAfterProbe(mbps)) return;
@@ -24752,7 +24844,8 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24752
24844
  closeSession,
24753
24845
  updateState,
24754
24846
  onError,
24755
- hintsOverride
24847
+ hintsOverride,
24848
+ reportAdaptiveHints
24756
24849
  ]);
24757
24850
  const connectWhep = (0, react$1.useCallback)(async () => {
24758
24851
  if (!mountedRef.current) return;
@@ -24828,7 +24921,7 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24828
24921
  const ms = () => Math.round(performance.now() - t0);
24829
24922
  const timing = {};
24830
24923
  try {
24831
- const probePromise = !withDownlink ? measuredDownlinkMbps() : void 0;
24924
+ const probePromise = !withDownlink ? measuredDownlinkMbps(serverUrl) : void 0;
24832
24925
  const iceServers = await getIceServers?.();
24833
24926
  timing.iceServersMs = ms();
24834
24927
  if (!mountedRef.current || abort.signal.aborted) return;
@@ -24936,8 +25029,9 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24936
25029
  await pc.setLocalDescription(offer);
24937
25030
  timing.localSetMs = ms();
24938
25031
  if (!mountedRef.current || abort.signal.aborted) return;
24939
- const downlinkMbps = withDownlink ? await measuredDownlinkMbps() : void 0;
25032
+ const downlinkMbps = withDownlink ? await measuredDownlinkMbps(serverUrl) : void 0;
24940
25033
  const clientOfferHints = mergePlayerHints(computeClientHints(containerRef.current), hintsOverride, downlinkMbps, !withDownlink);
25034
+ reportAdaptiveHints(withDownlink ? "probe-climb" : "cold-start", clientOfferHints, probeOriginFor(serverUrl));
24941
25035
  const signalingStart = ms();
24942
25036
  const { sessionId: sid, sdpAnswer } = await handleOffer(pc.localDescription?.sdp ?? offer.sdp ?? "", void 0, clientOfferHints);
24943
25037
  timing.signalingRttMs = ms() - signalingStart;
@@ -24998,6 +25092,7 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24998
25092
  scheduleReconnect();
24999
25093
  }
25000
25094
  }, [
25095
+ serverUrl,
25001
25096
  handleOffer,
25002
25097
  getIceServers,
25003
25098
  addIceCandidate,
@@ -25008,7 +25103,8 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
25008
25103
  updateState,
25009
25104
  onError,
25010
25105
  startSessionStatePoll,
25011
- hintsOverride
25106
+ hintsOverride,
25107
+ reportAdaptiveHints
25012
25108
  ]);
25013
25109
  renegotiateRef.current = (0, react$1.useCallback)(async (sessionId, target) => {
25014
25110
  const reofferFn = reoffer;
@@ -25115,12 +25211,13 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
25115
25211
  pc.close();
25116
25212
  return;
25117
25213
  }
25118
- const downlinkMbps = await measuredDownlinkMbps();
25214
+ const downlinkMbps = await measuredDownlinkMbps(serverUrl);
25119
25215
  const reofferHints = {
25120
25216
  ...computeClientHints(containerRef.current),
25121
25217
  ...downlinkMbps !== void 0 ? { downlinkMbps } : {},
25122
25218
  ...hintsOverride
25123
25219
  };
25220
+ reportAdaptiveHints("renegotiate", reofferHints, probeOriginFor(serverUrl));
25124
25221
  const { sessionId: sid, sdpAnswer } = await reofferFn(sessionId, pc.localDescription?.sdp ?? offer.sdp ?? "", target, reofferHints);
25125
25222
  if (!mountedRef.current) {
25126
25223
  pc.close();
@@ -25167,13 +25264,15 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
25167
25264
  renegotiatingRef.current = false;
25168
25265
  }
25169
25266
  }, [
25267
+ serverUrl,
25170
25268
  reoffer,
25171
25269
  getIceServers,
25172
25270
  addIceCandidate,
25173
25271
  getIceCandidates,
25174
25272
  updateState,
25175
25273
  startSessionStatePoll,
25176
- hintsOverride
25274
+ hintsOverride,
25275
+ reportAdaptiveHints
25177
25276
  ]);
25178
25277
  const connect = useClientOffer ? connectClientOffer : useServerOffer ? connectServerOffer : connectWhep;
25179
25278
  const connectRef = (0, react$1.useRef)(() => {});
package/dist/index.js CHANGED
@@ -24349,6 +24349,62 @@ function stripDownlinkForColdStart(hints) {
24349
24349
  function shouldUpgradeAfterProbe(mbps) {
24350
24350
  return suggestedAdaptiveTier(mbps) !== "low";
24351
24351
  }
24352
+ /** An http(s) origin, or null for anything that cannot serve the probe. */
24353
+ function httpOrigin(value) {
24354
+ if (value === void 0 || value === "") return null;
24355
+ let parsed;
24356
+ try {
24357
+ parsed = new URL(value);
24358
+ } catch {
24359
+ return null;
24360
+ }
24361
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
24362
+ return parsed.origin;
24363
+ }
24364
+ /**
24365
+ * The origin `/adaptive-probe` must be fetched from — the HUB, not the page.
24366
+ *
24367
+ * The page origin was the original assumption ("same-origin as the player",
24368
+ * `adaptive-probe.routes.ts`) and it is only true for the web viewer and the
24369
+ * iframe embed, both served by the hub. The NATIVE viewer loads this same
24370
+ * player from an asset bundled with the app (`useNativeEmbedSource` resolves
24371
+ * `assets/embed/camstack-embed.html` through `Image.resolveAssetSource`), so
24372
+ * inside the WKWebView `window.location.origin` is `file://` — and
24373
+ * `file:///adaptive-probe` is not a request any hub can answer. The probe
24374
+ * therefore returned null on EVERY native connect, `downlinkMbps` was never
24375
+ * sent, `pickAdaptiveTier` scored every session `default-low`, and
24376
+ * `shouldUpgradeAfterProbe` was never even reached — so the make-before-break
24377
+ * climb that the cold-start default depends on could not run. That is the
24378
+ * whole of "the automatic quality always drops to low" on the app.
24379
+ *
24380
+ * The hub the player is already talking to is the `serverUrl` prop, so it
24381
+ * wins; the page origin remains as the fallback for a host that passes a
24382
+ * relative/empty `serverUrl`. Null means no probe is possible and the caller
24383
+ * must skip it rather than fetch a URL that cannot exist.
24384
+ */
24385
+ function resolveProbeOrigin(serverUrl, pageOrigin) {
24386
+ return httpOrigin(serverUrl) ?? httpOrigin(pageOrigin);
24387
+ }
24388
+ /**
24389
+ * Identity of a decision, for "log once per CHANGE, not per tick".
24390
+ *
24391
+ * `downlinkMbps` is a fresh measurement on every probe and would otherwise
24392
+ * make every offer look like a new decision. What the ladder actually consumes
24393
+ * is the RUNG it maps to (`suggestedAdaptiveTier`), so two measurements that
24394
+ * pick the same rung are the same decision and must collapse to one line.
24395
+ */
24396
+ function adaptiveHintsKey(report) {
24397
+ const rung = report.downlinkMbps === void 0 ? "none" : suggestedAdaptiveTier(report.downlinkMbps);
24398
+ return [
24399
+ report.phase,
24400
+ report.viewportWidth ?? "-",
24401
+ report.viewportHeight ?? "-",
24402
+ report.devicePixelRatio ?? "-",
24403
+ rung,
24404
+ report.prefersTier ?? "-",
24405
+ report.probeOrigin ?? "unreachable"
24406
+ ].join("|");
24407
+ }
24352
24408
  async function measureDownlinkMbps(origin, deps) {
24353
24409
  const base = origin.replace(/\/+$/, "");
24354
24410
  const ctrl = new AbortController();
@@ -24431,9 +24487,19 @@ function computeClientHints(container) {
24431
24487
  }
24432
24488
  var downlinkCache = null;
24433
24489
  var DOWNLINK_TTL_MS = 3e4;
24434
- async function measuredDownlinkMbps() {
24435
- if (typeof window === "undefined") return void 0;
24436
- const origin = window.location.origin;
24490
+ /**
24491
+ * Measure the downlink against the HUB, not the page. `serverUrl` is the hub
24492
+ * this player is already signalling to; the page origin is only a fallback,
24493
+ * and inside the native WKWebView it is `file://` — see `resolveProbeOrigin`
24494
+ * for the full account of what that cost.
24495
+ */
24496
+ /** The origin the probe WILL use, for the client's own decision log. */
24497
+ function probeOriginFor(serverUrl) {
24498
+ return resolveProbeOrigin(serverUrl, typeof window !== "undefined" ? window.location.origin : void 0);
24499
+ }
24500
+ async function measuredDownlinkMbps(serverUrl) {
24501
+ const origin = probeOriginFor(serverUrl);
24502
+ if (origin === null) return void 0;
24437
24503
  if (downlinkCache && downlinkCache.origin === origin && Date.now() - downlinkCache.at < DOWNLINK_TTL_MS) return downlinkCache.mbps;
24438
24504
  const mbps = await measureDownlinkMbps(origin, {
24439
24505
  fetchImpl: (url, init) => fetch(url, init),
@@ -24456,7 +24522,7 @@ function mergePlayerHints(autoHints, override, downlinkMbps, coldStart) {
24456
24522
  return coldStart ? stripDownlinkForColdStart(merged) : merged;
24457
24523
  }
24458
24524
  var FIRST_FRAME_TIMEOUT_MS = 8e3;
24459
- function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, muted: initialMuted = true, showControls = true, showStats = false, onPlaybackStats, onClientNetworkSample, onConnectTiming, className = "", onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, reoffer, posterUrl, hintsOverride, reconnectSignal, onControlChannel, onVideoElement }) {
24525
+ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, muted: initialMuted = true, showControls = true, showStats = false, onPlaybackStats, onClientNetworkSample, onConnectTiming, className = "", onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, reoffer, posterUrl, hintsOverride, onAdaptiveHints, reconnectSignal, onControlChannel, onVideoElement }) {
24460
24526
  const videoRef = useRef(null);
24461
24527
  const containerRef = useRef(null);
24462
24528
  const pcRef = useRef(null);
@@ -24480,6 +24546,30 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24480
24546
  onVideoElementRef.current?.(videoRef.current);
24481
24547
  return () => onVideoElementRef.current?.(null);
24482
24548
  }, []);
24549
+ /** Same pattern for the adaptive-decision observer — a ref so wiring it does
24550
+ * not re-create the mount-stable connect callbacks. */
24551
+ const onAdaptiveHintsRef = useRef(onAdaptiveHints);
24552
+ onAdaptiveHintsRef.current = onAdaptiveHints;
24553
+ /** Last reported decision key — the "once per CHANGE, not per tick" latch. */
24554
+ const lastAdaptiveKeyRef = useRef(null);
24555
+ /**
24556
+ * Report the hints THIS offer carried, when they differ from the last
24557
+ * reported decision. Mount-stable (`[]` deps by contract) so the connect
24558
+ * callbacks can call it without gaining a dependency.
24559
+ */
24560
+ const reportAdaptiveHints = useCallback((phase, hints, probeOrigin) => {
24561
+ const cb = onAdaptiveHintsRef.current;
24562
+ if (!cb) return;
24563
+ const report = {
24564
+ phase,
24565
+ probeOrigin,
24566
+ ...hints
24567
+ };
24568
+ const key = adaptiveHintsKey(report);
24569
+ if (key === lastAdaptiveKeyRef.current) return;
24570
+ lastAdaptiveKeyRef.current = key;
24571
+ cb(report);
24572
+ }, []);
24483
24573
  /** The live session being polled for `pendingRenegotiation` (client-offer). */
24484
24574
  const activeSessionIdRef = useRef(null);
24485
24575
  /** Timer for the session-state (renegotiation) poll loop. */
@@ -24612,8 +24702,10 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24612
24702
  const abort = new AbortController();
24613
24703
  connectAbortRef.current = abort;
24614
24704
  try {
24615
- const probePromise = !withDownlink ? measuredDownlinkMbps() : void 0;
24616
- const { sessionId, sdpOffer, iceServers } = await createSession(mergePlayerHints(computeClientHints(containerRef.current), hintsOverride, withDownlink ? await measuredDownlinkMbps() : void 0, !withDownlink));
24705
+ const probePromise = !withDownlink ? measuredDownlinkMbps(serverUrl) : void 0;
24706
+ const mergedHints = mergePlayerHints(computeClientHints(containerRef.current), hintsOverride, withDownlink ? await measuredDownlinkMbps(serverUrl) : void 0, !withDownlink);
24707
+ reportAdaptiveHints(withDownlink ? "probe-climb" : "cold-start", mergedHints, probeOriginFor(serverUrl));
24708
+ const { sessionId, sdpOffer, iceServers } = await createSession(mergedHints);
24617
24709
  sessionIdsRef.current.add(sessionId);
24618
24710
  if (probePromise !== void 0) probePromise.then((mbps) => {
24619
24711
  if (mbps === void 0 || !shouldUpgradeAfterProbe(mbps)) return;
@@ -24728,7 +24820,8 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24728
24820
  closeSession,
24729
24821
  updateState,
24730
24822
  onError,
24731
- hintsOverride
24823
+ hintsOverride,
24824
+ reportAdaptiveHints
24732
24825
  ]);
24733
24826
  const connectWhep = useCallback(async () => {
24734
24827
  if (!mountedRef.current) return;
@@ -24804,7 +24897,7 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24804
24897
  const ms = () => Math.round(performance.now() - t0);
24805
24898
  const timing = {};
24806
24899
  try {
24807
- const probePromise = !withDownlink ? measuredDownlinkMbps() : void 0;
24900
+ const probePromise = !withDownlink ? measuredDownlinkMbps(serverUrl) : void 0;
24808
24901
  const iceServers = await getIceServers?.();
24809
24902
  timing.iceServersMs = ms();
24810
24903
  if (!mountedRef.current || abort.signal.aborted) return;
@@ -24912,8 +25005,9 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24912
25005
  await pc.setLocalDescription(offer);
24913
25006
  timing.localSetMs = ms();
24914
25007
  if (!mountedRef.current || abort.signal.aborted) return;
24915
- const downlinkMbps = withDownlink ? await measuredDownlinkMbps() : void 0;
25008
+ const downlinkMbps = withDownlink ? await measuredDownlinkMbps(serverUrl) : void 0;
24916
25009
  const clientOfferHints = mergePlayerHints(computeClientHints(containerRef.current), hintsOverride, downlinkMbps, !withDownlink);
25010
+ reportAdaptiveHints(withDownlink ? "probe-climb" : "cold-start", clientOfferHints, probeOriginFor(serverUrl));
24917
25011
  const signalingStart = ms();
24918
25012
  const { sessionId: sid, sdpAnswer } = await handleOffer(pc.localDescription?.sdp ?? offer.sdp ?? "", void 0, clientOfferHints);
24919
25013
  timing.signalingRttMs = ms() - signalingStart;
@@ -24974,6 +25068,7 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24974
25068
  scheduleReconnect();
24975
25069
  }
24976
25070
  }, [
25071
+ serverUrl,
24977
25072
  handleOffer,
24978
25073
  getIceServers,
24979
25074
  addIceCandidate,
@@ -24984,7 +25079,8 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
24984
25079
  updateState,
24985
25080
  onError,
24986
25081
  startSessionStatePoll,
24987
- hintsOverride
25082
+ hintsOverride,
25083
+ reportAdaptiveHints
24988
25084
  ]);
24989
25085
  renegotiateRef.current = useCallback(async (sessionId, target) => {
24990
25086
  const reofferFn = reoffer;
@@ -25091,12 +25187,13 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
25091
25187
  pc.close();
25092
25188
  return;
25093
25189
  }
25094
- const downlinkMbps = await measuredDownlinkMbps();
25190
+ const downlinkMbps = await measuredDownlinkMbps(serverUrl);
25095
25191
  const reofferHints = {
25096
25192
  ...computeClientHints(containerRef.current),
25097
25193
  ...downlinkMbps !== void 0 ? { downlinkMbps } : {},
25098
25194
  ...hintsOverride
25099
25195
  };
25196
+ reportAdaptiveHints("renegotiate", reofferHints, probeOriginFor(serverUrl));
25100
25197
  const { sessionId: sid, sdpAnswer } = await reofferFn(sessionId, pc.localDescription?.sdp ?? offer.sdp ?? "", target, reofferHints);
25101
25198
  if (!mountedRef.current) {
25102
25199
  pc.close();
@@ -25143,13 +25240,15 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
25143
25240
  renegotiatingRef.current = false;
25144
25241
  }
25145
25242
  }, [
25243
+ serverUrl,
25146
25244
  reoffer,
25147
25245
  getIceServers,
25148
25246
  addIceCandidate,
25149
25247
  getIceCandidates,
25150
25248
  updateState,
25151
25249
  startSessionStatePoll,
25152
- hintsOverride
25250
+ hintsOverride,
25251
+ reportAdaptiveHints
25153
25252
  ]);
25154
25253
  const connect = useClientOffer ? connectClientOffer : useServerOffer ? connectServerOffer : connectWhep;
25155
25254
  const connectRef = useRef(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/ui-library",
3
- "version": "1.2.100",
3
+ "version": "1.2.102",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",