@displayxr/inline3d 1.27.0 → 1.29.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.
@@ -0,0 +1,217 @@
1
+ // call/element.js — `<dxr-call>`: the one-line path as markup (RFC 0003 §1, C1).
2
+ //
3
+ // <script type="module" src="https://cdn.jsdelivr.net/npm/@displayxr/inline3d@1/dist/call.js"></script>
4
+ // <dxr-call accent="violet"></dxr-call>
5
+ //
6
+ // REGISTRATION. Importing `@displayxr/inline3d/call` (or `/call/full`, or the CDN bundle) defines
7
+ // the element — the entry calls `defineCallElement()` at the bottom of inline3d-call.js, which is
8
+ // why that file is listed in package.json `sideEffects`. This file itself defines nothing on
9
+ // import; it is the class, importable by path for tests.
10
+ //
11
+ // The element is SUGAR over `mountCall(this, attrsToOpts(this))`: it mounts on connect, `leave()`s
12
+ // on disconnect, re-dispatches every call event as a DOM `CustomEvent` (`dxr-call:peer`,
13
+ // `dxr-call:joined`, …, `detail` = the call's payload, bubbling and composed) and exposes the handle
14
+ // as `el.call`. It is the element's LIGHT DOM that hosts the tiles — no shadow root: woven canvases
15
+ // live in the document's own tree, and the SDK's chrome stylesheet is document-level.
16
+ //
17
+ // Attributes are the string-typed subset of `CallOptions` (attrsToOpts below). Anything else — a
18
+ // `MediaStream` camera, a custom `SignalingAdapter`, a `lift` function, a `wall` — is a JS
19
+ // property set BEFORE the element connects: `el.options = { … }`. Options win over attributes.
20
+ // Attributes are read once, at connect: this element is not reactive to later attribute changes
21
+ // (change them, then re-append it — which is a new call; see below).
22
+ //
23
+ // DISCONNECT = LEAVE. Removing the element ends the call, deliberately: a woven canvas must never
24
+ // be remounted mid-call (woven-canvas rule 2), so a DOM move IS a teardown. SPA routers: mount
25
+ // `<dxr-call>` in a layout that survives navigation. Re-appending a disconnected element starts a
26
+ // fresh call (a new mount; the old handle is gone).
27
+ //
28
+ // NO GLOBALS (Decision 11): nothing here touches `globalThis` beyond `customElements.define`,
29
+ // which `defineCallElement()` does exactly once and only when a registry exists. The class is
30
+ // usable — and unit-testable — without a DOM: the base class falls back to a plain class when
31
+ // `HTMLElement` is absent, and the mount seam (`DxrCallElement.mount`) is overridable.
32
+ //
33
+ // The import below is CIRCULAR (inline3d-call.js imports this file to register the element), so
34
+ // nothing from it may be read while this module evaluates: `mount` defaults lazily, at connect.
35
+
36
+ import { mountCall, dxrSignaling } from '../inline3d-call.js';
37
+
38
+ /** Every call event is re-dispatched as `dxr-call:<type>`; `dxr-call:ready` carries the handle. */
39
+ export const CALL_EVENT_PREFIX = 'dxr-call:';
40
+ const CALL_EVENTS = ['joined', 'left', 'peer', 'peerleft', 'state', 'format', 'speaker', 'quality', 'error', 'warning', 'session'];
41
+ const TAG = '[inline3d/call] <dxr-call>';
42
+
43
+ /** A boolean attribute: present is true, unless its value spells "false"/"0"/"off"/"no". */
44
+ const isOn = (v) => v !== null && v !== undefined && !/^(false|0|off|no)$/i.test(String(v).trim());
45
+ const num = (v) => {
46
+ if (v === null || v === undefined || String(v).trim() === '') return undefined;
47
+ const m = /^\s*([0-9.]+)\s*\/\s*([0-9.]+)\s*$/.exec(String(v)); // "16/9"
48
+ const n = m ? +m[1] / +m[2] : +v;
49
+ return Number.isFinite(n) ? n : undefined;
50
+ };
51
+ const str = (v) => (v === null || v === undefined || String(v).trim() === '' ? undefined : String(v).trim());
52
+
53
+ /**
54
+ * Attribute → option mapping, pure. `source` is an element (or anything with `getAttribute`) or a
55
+ * `(name) => value | null` function. Unset attributes contribute nothing (the call's own defaults
56
+ * apply), so the result can be spread under `el.options`.
57
+ *
58
+ * | attribute | option |
59
+ * |----------------------|------------------------------------------|
60
+ * | `room` | `room` (an id or an invite link) |
61
+ * | `signaling` | `signaling: dxrSignaling(url)` |
62
+ * | `key` | `key` |
63
+ * | `camera` | `camera` (`auto`/`stereo`/`mono`/deviceId)|
64
+ * | `layout` | `layout` (`grid`/`speaker`) |
65
+ * | `accent` | `accent` |
66
+ * | `max-peers` | `maxPeers` |
67
+ * | `no-ui` | `ui: false` |
68
+ * | `auto-join` | `autoJoin: true` |
69
+ * | `mono3d="off"` | `mono3D: 'off'` |
70
+ * | `no-audio` | `audio: false` |
71
+ * | `no-self-view` | `selfView: false` |
72
+ * | `no-auto-converge` | `autoConverge: false` |
73
+ * | `tile-aspect` | `tileAspect` (a number, or `w/h`) |
74
+ * | `invite-base` | `inviteBase` |
75
+ * | `browser-url` | `browserUrl` |
76
+ * | `debug` | `debug: true` |
77
+ *
78
+ * @param {{ getAttribute(name: string): string | null } | ((name: string) => string | null)} source
79
+ * @returns {object} a partial `CallOptions`
80
+ */
81
+ export function attrsToOpts(source) {
82
+ const get = typeof source === 'function' ? source : (n) => (source && typeof source.getAttribute === 'function' ? source.getAttribute(n) : null);
83
+ const o = {};
84
+ const put = (k, v) => {
85
+ if (v !== undefined) o[k] = v;
86
+ };
87
+ put('room', str(get('room')));
88
+ const signaling = str(get('signaling'));
89
+ if (signaling) o.signaling = dxrSignaling(signaling, str(get('key')) ? { key: str(get('key')) } : {});
90
+ put('key', str(get('key')));
91
+ put('camera', str(get('camera')));
92
+ put('layout', str(get('layout')));
93
+ put('accent', str(get('accent')));
94
+ put('maxPeers', num(get('max-peers')));
95
+ if (isOn(get('no-ui'))) o.ui = false;
96
+ if (isOn(get('auto-join'))) o.autoJoin = true;
97
+ const m3 = str(get('mono3d'));
98
+ if (m3 && /^(off|false|0|no)$/i.test(m3)) o.mono3D = 'off';
99
+ if (isOn(get('no-audio'))) o.audio = false;
100
+ if (isOn(get('no-self-view'))) o.selfView = false;
101
+ if (isOn(get('no-auto-converge'))) o.autoConverge = false;
102
+ put('tileAspect', num(get('tile-aspect')));
103
+ put('inviteBase', str(get('invite-base')));
104
+ put('browserUrl', str(get('browser-url')));
105
+ if (isOn(get('debug'))) o.debug = true;
106
+ return o;
107
+ }
108
+
109
+ // Usable without a DOM (unit tests, SSR imports): a plain base class where HTMLElement is absent.
110
+ const Base = typeof HTMLElement === 'function' ? HTMLElement : class {};
111
+
112
+ export class DxrCallElement extends Base {
113
+ /**
114
+ * The mount seam: null = `mountCall` of `./call`. `./call/full` points it at its lift-wired
115
+ * `mountCall`, so importing that entry makes every `<dxr-call>` lift 2D participants; tests
116
+ * inject a fake. Resolved at connect, never at import (the circular import above).
117
+ */
118
+ static mount = null;
119
+
120
+ constructor() {
121
+ super();
122
+ /** Options that are not strings — set BEFORE connect; win over attributes. */
123
+ this.options = null;
124
+ /** The `CallHandle` once mounted; null before, and again after disconnect. */
125
+ this.call = null;
126
+ /** Resolves with the handle when the mount lands (null if the element left first); rejects if it failed. */
127
+ this.ready = null;
128
+ this._gen = 0;
129
+ this._off = [];
130
+ this._pending = null;
131
+ }
132
+
133
+ connectedCallback() {
134
+ if (this.call || this._pending) return; // already mounted, or mounting
135
+ const gen = ++this._gen;
136
+ const opts = { ...attrsToOpts(this), ...(this.options && typeof this.options === 'object' ? this.options : {}) };
137
+ const mount = this.constructor.mount || DxrCallElement.mount || mountCall;
138
+ // Synchronous call (a throw becomes a rejection): the container's chrome exists on return.
139
+ this._pending = new Promise((res) => res(mount(this, opts)))
140
+ .then(
141
+ (handle) => {
142
+ this._pending = null;
143
+ if (gen !== this._gen) {
144
+ // Disconnected while the camera/lobby was coming up: the mount landed on a dead element.
145
+ handle.leave();
146
+ return null;
147
+ }
148
+ this.call = handle;
149
+ this._off = CALL_EVENTS.map((t) => handle.on(t, (detail) => this._dispatch(t, detail)));
150
+ // The call module only console.warns an error when NOBODY listens; the element always
151
+ // does, so it says it here — a drop-in widget's page usually has no JS listening at all.
152
+ this._off.push(handle.on('error', (e) => console.warn(`${TAG} ${e.code}: ${e.message}`)));
153
+ this._dispatch('ready', { call: handle });
154
+ // `auto-join` (and `no-ui`) join DURING the mount, before anything could subscribe —
155
+ // and a room that already had people reports them during the join too. Replay what a
156
+ // listener would have seen, from the handle's state: 'joined', then one 'peer' per
157
+ // participant already there. (Errors raised while mounting — 'camera-busy' — were
158
+ // console.warned by the call itself, since nothing was listening yet.)
159
+ if (handle.state === 'in-call') {
160
+ this._dispatch('joined', { room: handle.room, id: handle.id });
161
+ for (const p of handle.peers) this._dispatch('peer', { id: p.id });
162
+ }
163
+ return handle;
164
+ },
165
+ (err) => {
166
+ this._pending = null;
167
+ this._dispatch('error', { code: 'mount-failed', message: String((err && err.message) || err), error: err || null });
168
+ console.warn(`${TAG} mount failed: ${String((err && err.message) || err)}`);
169
+ throw err;
170
+ }
171
+ );
172
+ this.ready = this._pending;
173
+ // A rejected `ready` nobody awaits must not be an unhandled rejection in a plain page.
174
+ this.ready.catch(() => {});
175
+ }
176
+
177
+ disconnectedCallback() {
178
+ this._gen++; // a mount still in flight lands on a dead element (see above)
179
+ const call = this.call;
180
+ this.call = null;
181
+ // leave() FIRST, so the call's 'left' still reaches listeners on the element as
182
+ // `dxr-call:left` (it no longer bubbles anywhere: the element is out of the tree); THEN unhook.
183
+ if (call) call.leave();
184
+ for (const off of this._off) {
185
+ try {
186
+ off();
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+ }
191
+ this._off = [];
192
+ }
193
+
194
+ _dispatch(type, detail) {
195
+ if (typeof CustomEvent !== 'function' || typeof this.dispatchEvent !== 'function') return;
196
+ this.dispatchEvent(new CustomEvent(CALL_EVENT_PREFIX + type, { detail, bubbles: true, composed: true }));
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Register `<dxr-call>` (or another tag name) once. Returns true when this call registered it,
202
+ * false when the name was already taken (by this class: fine; by another: a warning) or when there
203
+ * is no `customElements` registry (Node, a worker).
204
+ *
205
+ * @param {string} [name='dxr-call']
206
+ * @param {CustomElementRegistry} [registry=globalThis.customElements]
207
+ */
208
+ export function defineCallElement(name = 'dxr-call', registry = globalThis.customElements) {
209
+ if (!registry || typeof registry.define !== 'function') return false;
210
+ const existing = registry.get(name);
211
+ if (existing) {
212
+ if (existing !== DxrCallElement) console.warn(`${TAG} <${name}> is already defined by another class; leaving it`);
213
+ return false;
214
+ }
215
+ registry.define(name, DxrCallElement);
216
+ return true;
217
+ }
@@ -0,0 +1,323 @@
1
+ // call/lift.js — mono→3D for mono peers, through the SDK's lift() provider chain (RFC 0002 §5).
2
+ //
3
+ // This module never contains a depth model or vendor code. It decides WHETHER a mono peer is
4
+ // lifted, hands its <video> to `lift()` (`@displayxr/inline3d/lift`), and steers the lifted
5
+ // streams: priority from the active speaker and tile visibility, convergence from the call's one
6
+ // depth control, a cap on concurrent lifts, and a frame-time watch for the web provider.
7
+ //
8
+ // `lift` is NEVER a hard dependency. `mono3D: 'auto'` imports it lazily at runtime; a missing
9
+ // module (not installed, not built into this copy of the SDK), a failed import, or a device that
10
+ // can do neither native nor WebGPU lifting resolves to FLAT with one log line. A page can inject
11
+ // its own `lift` function (`mono3D: lift`) — that is how tests, bundled apps and pre-merge demos
12
+ // work.
13
+ //
14
+ // Pure except for `resolveLift`'s default importer — see test/call.test.mjs.
15
+
16
+ /** How a mono peer's lifted stream is scheduled. Mirrors lift()'s `LiftPriority`. */
17
+ export const LIFT_PRIORITY = Object.freeze({ speaker: 'high', other: 'normal', hidden: 'paused' });
18
+ const PRIORITIES = ['high', 'normal', 'low', 'paused'];
19
+
20
+ /** Convergence dead band: a depth slider this close to 0 hands lift 'auto'. */
21
+ const DEPTH_AUTO_BAND = 0.02;
22
+
23
+ /** Web-provider frame-time watch: warn when frames average slower than this for this long. */
24
+ const DEGRADED_FRAME_MS = 1000 / 20;
25
+ const DEGRADED_HOLD_MS = 3000;
26
+ const RECOVERED_FRAME_MS = 1000 / 28;
27
+
28
+ /**
29
+ * `mono3D` option → `'auto' | 'off' | Function`. `false`/`'off'` → 'off'; a function is an
30
+ * injected `lift`; anything else → 'auto'.
31
+ */
32
+ export function normalizeMono3D(v) {
33
+ if (typeof v === 'function') return v;
34
+ if (v === false || v === 'off') return 'off';
35
+ return 'auto';
36
+ }
37
+
38
+ /**
39
+ * Where `mono3D: 'auto'` looks for the lift module: the `./lift` subpath of THIS copy of the SDK,
40
+ * resolved relative to this file (js/call/ → js/lift/index.js), exactly as the package maps
41
+ * `@displayxr/inline3d/lift`. The specifier is computed, so a bundler neither fails on a copy of
42
+ * the SDK without lift nor pulls lift into every call bundle; a bundled app that wants lifting
43
+ * passes `mono3D: lift` (imported itself) instead.
44
+ */
45
+ export function defaultLiftSpecifier(base = import.meta.url) {
46
+ if (liftSpecifier) return typeof liftSpecifier === 'function' ? liftSpecifier() : liftSpecifier;
47
+ return new URL('../lift/index.js', base).href;
48
+ }
49
+
50
+ // The pre-bundled CDN build (dist/call.js) has no js/call/ directory to resolve against: it points
51
+ // this at its own sibling chunk (`dist/lift.js`) instead. A URL string, or a function returning
52
+ // one (evaluated at import time, so `import.meta.url` of the caller can be used). Null = default.
53
+ let liftSpecifier = null;
54
+ export function setLiftSpecifier(spec) {
55
+ liftSpecifier = spec || null;
56
+ }
57
+
58
+ function defaultImporter() {
59
+ return import(/* @vite-ignore */ /* webpackIgnore: true */ defaultLiftSpecifier());
60
+ }
61
+
62
+ /**
63
+ * Resolve the `mono3D` option to a usable lift function, or to null (flat) with a reason.
64
+ *
65
+ * 'off' → { lift: null, reason: 'off' }
66
+ * function → { lift: fn, capabilities: fn.liftCapabilities || null, source: 'injected' }
67
+ * 'auto' → import the lift module; then, when it has `liftCapabilities()`, require a native
68
+ * provider OR a WebGPU adapter for the web fallback (without it, the module has no
69
+ * live video path). Without `liftCapabilities`, a missing `navigator.gpu` is the
70
+ * same verdict. Any failure → { lift: null, reason }, never a throw.
71
+ *
72
+ * @param {'auto'|'off'|Function} mono3D
73
+ * @param {{ importer?: () => Promise<any>, nav?: any, log?: (tag: string, o: object) => void, capsOpts?: object }} [o]
74
+ * @returns {Promise<{ lift: Function|null, capabilities: Function|null, caps: object|null, source: string, reason: string|null }>}
75
+ */
76
+ export async function resolveLift(mono3D, { importer = defaultImporter, nav = globalThis.navigator, log = null, capsOpts = { webFallback: true } } = {}) {
77
+ const say = (tag, x) => log && log(tag, x);
78
+ const flat = (reason, extra = {}) => {
79
+ say('lift-unavailable', { reason, ...extra });
80
+ return { lift: null, capabilities: null, caps: null, source: 'none', reason };
81
+ };
82
+ const m = normalizeMono3D(mono3D);
83
+ if (m === 'off') return { lift: null, capabilities: null, caps: null, source: 'none', reason: 'off' };
84
+ if (typeof m === 'function') {
85
+ const capabilities = typeof m.liftCapabilities === 'function' ? m.liftCapabilities : null;
86
+ return { lift: m, capabilities, caps: null, source: 'injected', reason: null };
87
+ }
88
+ let mod;
89
+ try {
90
+ mod = await importer();
91
+ } catch (err) {
92
+ return flat('import-failed', { message: String((err && err.message) || err).slice(0, 200) });
93
+ }
94
+ const lift = mod && typeof mod.lift === 'function' ? mod.lift : null;
95
+ if (!lift) return flat('no-lift-export');
96
+ const capabilities = typeof mod.liftCapabilities === 'function' ? mod.liftCapabilities : null;
97
+ let caps = null;
98
+ if (capabilities) {
99
+ try {
100
+ caps = await capabilities(capsOpts);
101
+ } catch {
102
+ caps = null;
103
+ }
104
+ const web = !!(caps && caps.webFallback && caps.webFallback.webgpu);
105
+ if (caps && !caps.native && !web) return flat('no-provider', { native: false, webgpu: false });
106
+ } else if (!(nav && nav.gpu)) return flat('no-webgpu');
107
+ say('lift-ready', { native: !!(caps && caps.native), provider: (caps && caps.provider) || null });
108
+ return { lift, capabilities, caps, source: 'module', reason: null };
109
+ }
110
+
111
+ /**
112
+ * The call's ONE depth control ([-1, 1], + = push the picture back) as lift's convergence.
113
+ * lift's `convergence` is the normalised disparity (0 = far, 1 = near) placed on the glass, so a
114
+ * larger value puts more of the picture behind it. 0 (± a small band) = 'auto' (lift centres its
115
+ * own subject); otherwise 0.5 + 0.5·depth, clamped to [0, 1].
116
+ */
117
+ export function liftConvergenceFor(depth) {
118
+ const d = Number.isFinite(+depth) && depth !== null ? Math.max(-1, Math.min(1, +depth)) : 0;
119
+ if (Math.abs(d) < DEPTH_AUTO_BAND) return 'auto';
120
+ return Math.round((0.5 + 0.5 * d) * 1000) / 1000;
121
+ }
122
+
123
+ /** A lifted tile's priority: hidden/offscreen → paused, the active speaker → high, else normal. */
124
+ export function liftPriorityFor({ id, speakerId, visible = true }) {
125
+ if (!visible) return LIFT_PRIORITY.hidden;
126
+ return id !== null && id !== undefined && id === speakerId ? LIFT_PRIORITY.speaker : LIFT_PRIORITY.other;
127
+ }
128
+
129
+ /**
130
+ * THE HOOK FOR THE NATIVE PROVIDER'S PER-STREAM PRIORITY (`xrSetLiftStreamPriorityDXR`: HIGH =
131
+ * every round, NORMAL = round-robin, LOW = every Nth, PAUSED = keep the last frame).
132
+ *
133
+ * Today it forwards to the lift handle's `setPriority()` when it has one: in native mode that is
134
+ * the element's `dxr-lift-priority` attribute, which the browser maps onto the runtime's stream
135
+ * priority; on the web path lift runs one stream per element and it is a no-op. A handle without
136
+ * `setPriority` (an injected lift, an older lift build) is a no-op here — the call still works,
137
+ * every stream is just scheduled equally. Nothing else in the call module talks priority, so this
138
+ * one function is what changes if the native provider grows a different surface.
139
+ *
140
+ * @returns {boolean} true when the handle accepted it
141
+ */
142
+ export function setLiftPriority(handle, level) {
143
+ if (!handle || !PRIORITIES.includes(level)) return false;
144
+ if (typeof handle.setPriority !== 'function') return false;
145
+ try {
146
+ return handle.setPriority(level) !== false;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Frame-time watch for lifted tiles on the WEB provider (the native one runs in the display
154
+ * service, off the page's frame budget). Feed it the page's frame intervals; it reports
155
+ * `'degraded'` once when the smoothed interval stays above ~20 fps for 3 s, and `'recovered'`
156
+ * once it is back above ~28 fps. Pure.
157
+ */
158
+ export function createFrameWatch({ degradedMs = DEGRADED_FRAME_MS, recoveredMs = RECOVERED_FRAME_MS, holdMs = DEGRADED_HOLD_MS, alpha = 0.1 } = {}) {
159
+ let ema = null;
160
+ let slowFor = 0;
161
+ let degraded = false;
162
+ return {
163
+ /** @param {number} dtMs one frame interval @returns {'degraded'|'recovered'|null} */
164
+ feed(dtMs) {
165
+ if (!(dtMs > 0) || dtMs > 1000) return null; // a tab switch, not a frame
166
+ ema = ema === null ? dtMs : ema + alpha * (dtMs - ema);
167
+ if (!degraded) {
168
+ slowFor = ema > degradedMs ? slowFor + dtMs : 0;
169
+ if (slowFor >= holdMs) {
170
+ degraded = true;
171
+ return 'degraded';
172
+ }
173
+ } else if (ema < recoveredMs) {
174
+ degraded = false;
175
+ slowFor = 0;
176
+ return 'recovered';
177
+ }
178
+ return null;
179
+ },
180
+ reset() {
181
+ ema = null;
182
+ slowFor = 0;
183
+ degraded = false;
184
+ },
185
+ get frameMs() {
186
+ return ema;
187
+ },
188
+ get degraded() {
189
+ return degraded;
190
+ },
191
+ };
192
+ }
193
+
194
+ /**
195
+ * The call's lifted streams: at most `max` at once, each one lift() handle per peer, steered as a
196
+ * group (speaker → priority, depth → convergence). Pure apart from the `lift` it is given.
197
+ *
198
+ * @param {{ lift: Function, max?: number, log?: (tag: string, o: object) => void, options?: object }} o
199
+ */
200
+ export function createLiftPool({ lift, max = 4, log = null, options = null } = {}) {
201
+ // Page-supplied lift options (models, ort, quality, providers, …). The call owns the keys that
202
+ // make it a call tile — mode, wall, ui, convergence, priority — so those always win.
203
+ const extra = options && typeof options === 'object' ? options : {};
204
+ const entries = new Map(); // id → { gen, handle|null, visible, priority, pending }
205
+ let speakerId = null;
206
+ let depth = 0;
207
+ let gen = 0;
208
+ const say = (tag, x) => log && log(tag, x);
209
+
210
+ const apply = (id, e) => {
211
+ if (!e.handle) return;
212
+ const p = liftPriorityFor({ id, speakerId, visible: e.visible });
213
+ if (p === e.priority) return;
214
+ e.priority = p;
215
+ const ok = setLiftPriority(e.handle, p);
216
+ say('lift-priority', { peer: id, priority: p, applied: ok });
217
+ };
218
+
219
+ const pool = {
220
+ get size() {
221
+ return entries.size;
222
+ },
223
+ get max() {
224
+ return max;
225
+ },
226
+ set max(n) {
227
+ max = Math.max(0, n | 0);
228
+ },
229
+ has: (id) => entries.has(id),
230
+ handle: (id) => (entries.get(id) && entries.get(id).handle) || null,
231
+ priority: (id) => (entries.get(id) && entries.get(id).priority) || null,
232
+ /** Is there a slot for `id` (it already holds one, or fewer than `max` are taken)? */
233
+ canAcquire: (id) => entries.has(id) || entries.size < max,
234
+ /**
235
+ * Lift `element` for peer `id`. Resolves with the handle, or null when there is no slot, when
236
+ * `release(id)` ran meanwhile (the late handle is removed), or when lift() rejects (`onError`
237
+ * gets it). One pending/live stream per id: a second acquire returns the first.
238
+ */
239
+ acquire(id, element, { wall, visible = true, onError } = {}) {
240
+ const cur = entries.get(id);
241
+ if (cur) return cur.pending || Promise.resolve(cur.handle);
242
+ if (entries.size >= max) return Promise.resolve(null);
243
+ const my = ++gen;
244
+ const priority = liftPriorityFor({ id, speakerId, visible });
245
+ const e = { gen: my, handle: null, visible, priority, pending: null };
246
+ entries.set(id, e);
247
+ const opts = { quality: 'auto', ...extra, mode: 'live', wall, ui: 'none', convergence: liftConvergenceFor(depth), priority };
248
+ say('lift-acquire', { peer: id, priority, convergence: opts.convergence });
249
+ e.pending = Promise.resolve()
250
+ .then(() => lift(element, opts))
251
+ .then(
252
+ (h) => {
253
+ if (entries.get(id) !== e || e.gen !== my) {
254
+ try {
255
+ h && h.remove && h.remove();
256
+ } catch {
257
+ /* ignore */
258
+ }
259
+ return null;
260
+ }
261
+ e.pending = null;
262
+ e.handle = h || null;
263
+ // Converge on the CURRENT state (speaker/depth/visibility may have moved meanwhile).
264
+ e.priority = null;
265
+ apply(id, e);
266
+ if (h && typeof h.setConvergence === 'function') h.setConvergence(liftConvergenceFor(depth));
267
+ return e.handle;
268
+ },
269
+ (err) => {
270
+ if (entries.get(id) === e) entries.delete(id);
271
+ say('lift-failed', { peer: id, message: String((err && err.message) || err).slice(0, 200) });
272
+ if (onError) onError(err);
273
+ return null;
274
+ }
275
+ );
276
+ return e.pending;
277
+ },
278
+ /** Remove peer `id`'s lift (a pending one is removed when it lands). */
279
+ release(id) {
280
+ const e = entries.get(id);
281
+ if (!e) return;
282
+ entries.delete(id);
283
+ if (e.handle) {
284
+ try {
285
+ e.handle.remove();
286
+ } catch {
287
+ /* the session may be gone */
288
+ }
289
+ }
290
+ say('lift-release', { peer: id });
291
+ },
292
+ releaseAll() {
293
+ for (const id of [...entries.keys()]) pool.release(id);
294
+ },
295
+ setSpeaker(id) {
296
+ speakerId = id === undefined ? null : id;
297
+ for (const [pid, e] of entries) apply(pid, e);
298
+ },
299
+ setVisible(id, visible) {
300
+ const e = entries.get(id);
301
+ if (!e || e.visible === !!visible) return;
302
+ e.visible = !!visible;
303
+ apply(id, e);
304
+ },
305
+ /** The call's depth control → every lifted stream's convergence. */
306
+ setDepth(v) {
307
+ depth = v;
308
+ const c = liftConvergenceFor(v);
309
+ for (const e of entries.values()) if (e.handle && typeof e.handle.setConvergence === 'function') e.handle.setConvergence(c);
310
+ return c;
311
+ },
312
+ /** True when any live lifted stream runs on the page (the web provider), not in the service. */
313
+ get anyWeb() {
314
+ for (const e of entries.values()) if (e.handle && !e.handle.native) return true;
315
+ return false;
316
+ },
317
+ /** Snapshot for info()/tests. */
318
+ list() {
319
+ return [...entries].map(([id, e]) => ({ id, priority: e.priority, pending: !!e.pending, native: !!(e.handle && e.handle.native) }));
320
+ },
321
+ };
322
+ return pool;
323
+ }