A-Mahla HF Staff commited on
Commit
81940c4
·
1 Parent(s): da67b51

Add explicit Join gate, your-turn state, and at-capacity modal

Browse files

After waiting in line the conversation no longer auto-starts: reaching the front
holds a slot and shows a "your turn" orb with a "Join now" countdown button, so a
slot is never spent on someone who stepped away, and the click is a fresh gesture
that re-arms the AudioContext on iOS. Leaving or letting the countdown lapse
refunds the reserved budget.

When the whole waiting line is full, a warm "we're at capacity" modal (reusing the
limit-modal shell) explains the wait instead of a terse error. Queue captions and
retry messages reworded to be kinder.

Files changed (5) hide show
  1. index.html +8 -3
  2. main.js +88 -12
  3. style.css +42 -3
  4. ui/account.js +11 -0
  5. ws/s2s-ws-client.js +82 -9
index.html CHANGED
@@ -132,9 +132,14 @@
132
 
133
  <p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
134
 
135
- <button id="leave-queue-btn" class="leave-queue-btn" type="button" hidden>
136
- Leave queue
137
- </button>
 
 
 
 
 
138
  </main>
139
 
140
  <footer class="footer">
 
132
 
133
  <p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
134
 
135
+ <div id="queue-actions" class="queue-actions" hidden>
136
+ <button id="join-queue-btn" class="join-queue-btn" type="button" hidden>
137
+ Join now
138
+ </button>
139
+ <button id="leave-queue-btn" class="leave-queue-btn" type="button" hidden>
140
+ Leave queue
141
+ </button>
142
+ </div>
143
  </main>
144
 
145
  <footer class="footer">
main.js CHANGED
@@ -13,7 +13,7 @@
13
  * client owns its own AudioContext (no `attachOutputTrack`), so we hand
14
  * it the MediaStream directly.
15
  *
16
- * @typedef {"idle" | "connecting" | "queued" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
17
  */
18
 
19
  import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
@@ -151,7 +151,8 @@ function saveTools() {
151
  const STATE_VIEWS = {
152
  idle: { caption: "Tap to start", disabled: false },
153
  connecting: { caption: "Connecting", disabled: true },
154
- queued: { caption: "Waiting…", disabled: true },
 
155
  listening: { caption: "", disabled: false },
156
  "user-speaking": { caption: "", disabled: false },
157
  processing: { caption: "", disabled: false },
@@ -164,6 +165,7 @@ const STATE_CLASS = {
164
  idle: "state-idle",
165
  connecting: "state-connecting",
166
  queued: "state-queued",
 
167
  listening: "state-listening",
168
  "user-speaking": "state-user-speaking",
169
  processing: "state-processing",
@@ -184,6 +186,10 @@ const orbWrap = $(".orb-wrap");
184
  const micBtn = $("#mic-btn");
185
  /** @type {HTMLButtonElement} */
186
  const stopBtn = $("#stop-btn");
 
 
 
 
187
  /** @type {HTMLButtonElement} */
188
  const leaveQueueBtn = $("#leave-queue-btn");
189
 
@@ -343,10 +349,16 @@ function setState(next) {
343
  micBtn.tabIndex = live ? 0 : -1;
344
  stopBtn.tabIndex = live ? 0 : -1;
345
 
346
- // The "Leave queue" button shows only while waiting in line.
347
- const queued = next === "queued";
348
- leaveQueueBtn.hidden = !queued;
349
- leaveQueueBtn.tabIndex = queued ? 0 : -1;
 
 
 
 
 
 
350
 
351
  updateRestartAvailability();
352
  }
@@ -355,7 +367,8 @@ function updateRestartAvailability() {
355
  // Restart works from any settled state — it tears down a live call (if any)
356
  // and reconnects with the current settings. Only block while mid-connect or
357
  // while waiting in the queue (restarting from there would just re-queue).
358
- restartBtn.disabled = currentState === "connecting" || currentState === "queued";
 
359
  restartHint.hidden = false;
360
  restartHint.textContent = LIVE_STATES.has(currentState)
361
  ? "Reconnects now with the settings above."
@@ -989,13 +1002,21 @@ async function handleStartError(err) {
989
  // The user left the queue (close() aborted the wait): teardown already reset
990
  // the UI to idle, so there's nothing to report.
991
  if (err && err.code === "aborted") return;
992
- // The queue was full or our ticket timed out recoverable, not a fault. Land
993
- // on the retry state with a plain-language reason instead of an error dump.
994
- if (err && (err.code === "queue-full" || err.code === "queue-expired")) {
 
 
 
 
 
 
995
  await teardown();
996
  setState("error");
997
  setCaption(
998
- err.code === "queue-full" ? "All lines busy — tap to retry" : "Queue timed out — tap to retry",
 
 
999
  "error",
1000
  );
1001
  return;
@@ -1025,6 +1046,13 @@ leaveQueueBtn.addEventListener("click", async () => {
1025
  await teardown();
1026
  });
1027
 
 
 
 
 
 
 
 
1028
  const MIC_CONSTRAINTS = {
1029
  audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
1030
  };
@@ -1053,7 +1081,38 @@ async function acquireMicStream() {
1053
  /** @param {number} position Update the queued caption ("You're #N in line"). */
1054
  function onQueuePosition(position) {
1055
  const n = Number(position) || 0;
1056
- setCaption(n > 0 ? `You're #${n} in line` : "Waiting for a free slot…", "muted");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  }
1058
 
1059
  /**
@@ -1109,6 +1168,19 @@ async function doStart(audioContext = null) {
1109
  onQueuePosition(position);
1110
  });
1111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1112
  c.addEventListener("status", (e) => {
1113
  const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
1114
  onClientStatus(detail.status);
@@ -1260,6 +1332,9 @@ function onClientStatus(status) {
1260
  case "queued":
1261
  setState("queued");
1262
  break;
 
 
 
1263
  case "connected":
1264
  setState("listening");
1265
  break;
@@ -1283,6 +1358,7 @@ function onClientStatus(status) {
1283
 
1284
  async function teardown() {
1285
  stopHeartbeat();
 
1286
  endTrackedSession();
1287
  endQueueTicket();
1288
  chat.reset({ dismiss: true });
 
13
  * client owns its own AudioContext (no `attachOutputTrack`), so we hand
14
  * it the MediaStream directly.
15
  *
16
+ * @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
17
  */
18
 
19
  import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
 
151
  const STATE_VIEWS = {
152
  idle: { caption: "Tap to start", disabled: false },
153
  connecting: { caption: "Connecting", disabled: true },
154
+ queued: { caption: "Finding you a spot…", disabled: true },
155
+ "your-turn": { caption: "You're up! 🎉", disabled: true },
156
  listening: { caption: "", disabled: false },
157
  "user-speaking": { caption: "", disabled: false },
158
  processing: { caption: "", disabled: false },
 
165
  idle: "state-idle",
166
  connecting: "state-connecting",
167
  queued: "state-queued",
168
+ "your-turn": "state-your-turn",
169
  listening: "state-listening",
170
  "user-speaking": "state-user-speaking",
171
  processing: "state-processing",
 
186
  const micBtn = $("#mic-btn");
187
  /** @type {HTMLButtonElement} */
188
  const stopBtn = $("#stop-btn");
189
+ /** @type {HTMLElement} */
190
+ const queueActions = $("#queue-actions");
191
+ /** @type {HTMLButtonElement} */
192
+ const joinQueueBtn = $("#join-queue-btn");
193
  /** @type {HTMLButtonElement} */
194
  const leaveQueueBtn = $("#leave-queue-btn");
195
 
 
349
  micBtn.tabIndex = live ? 0 : -1;
350
  stopBtn.tabIndex = live ? 0 : -1;
351
 
352
+ // Queue affordances: "Leave queue" whenever we're in line; "Join now" only once
353
+ // it's our turn (a slot is held for us). Both live under #queue-actions.
354
+ const yourTurn = next === "your-turn";
355
+ const inLine = next === "queued" || yourTurn;
356
+ queueActions.hidden = !inLine;
357
+ joinQueueBtn.hidden = !yourTurn;
358
+ joinQueueBtn.tabIndex = yourTurn ? 0 : -1;
359
+ leaveQueueBtn.hidden = !inLine;
360
+ leaveQueueBtn.tabIndex = inLine ? 0 : -1;
361
+ if (!yourTurn) stopJoinCountdown();
362
 
363
  updateRestartAvailability();
364
  }
 
367
  // Restart works from any settled state — it tears down a live call (if any)
368
  // and reconnects with the current settings. Only block while mid-connect or
369
  // while waiting in the queue (restarting from there would just re-queue).
370
+ restartBtn.disabled =
371
+ currentState === "connecting" || currentState === "queued" || currentState === "your-turn";
372
  restartHint.hidden = false;
373
  restartHint.textContent = LIVE_STATES.has(currentState)
374
  ? "Reconnects now with the settings above."
 
1002
  // The user left the queue (close() aborted the wait): teardown already reset
1003
  // the UI to idle, so there's nothing to report.
1004
  if (err && err.code === "aborted") return;
1005
+ // The whole waiting line is full: a warm, reassuring modal rather than an error.
1006
+ if (err && err.code === "queue-full") {
1007
+ await teardown();
1008
+ account.showBusy();
1009
+ return;
1010
+ }
1011
+ // Our place lapsed (ticket reaped, or the join window ran out). Recoverable, not
1012
+ // a fault: land on the retry state with a kind, plain-language reason.
1013
+ if (err && (err.code === "queue-expired" || err.code === "join-expired")) {
1014
  await teardown();
1015
  setState("error");
1016
  setCaption(
1017
+ err.code === "join-expired"
1018
+ ? "Your spot expired. Tap to rejoin."
1019
+ : "That took a while. Tap to rejoin.",
1020
  "error",
1021
  );
1022
  return;
 
1046
  await teardown();
1047
  });
1048
 
1049
+ // "Join now": accept the held slot. The click is a user gesture, so the client
1050
+ // re-resumes the AudioContext here (iOS) before dialing.
1051
+ joinQueueBtn.addEventListener("click", () => {
1052
+ stopJoinCountdown();
1053
+ if (client) client.join();
1054
+ });
1055
+
1056
  const MIC_CONSTRAINTS = {
1057
  audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
1058
  };
 
1081
  /** @param {number} position Update the queued caption ("You're #N in line"). */
1082
  function onQueuePosition(position) {
1083
  const n = Number(position) || 0;
1084
+ setCaption(n > 0 ? `You're #${n} in line` : "Finding you a spot…", "muted");
1085
+ }
1086
+
1087
+ // ── "Your turn" join countdown ──────────────────────────────────────────────
1088
+ // While a slot is held for us, show how long is left to accept it. The client's
1089
+ // join gate expires just before the load balancer reclaims the slot.
1090
+ let joinCountdownTimer = 0;
1091
+
1092
+ /** @param {number} sec */
1093
+ function startJoinCountdown(sec) {
1094
+ stopJoinCountdown();
1095
+ let left = Math.max(0, Math.floor(sec));
1096
+ const paint = () => {
1097
+ joinQueueBtn.textContent = left > 0 ? `Join now (${left}s)` : "Join now";
1098
+ };
1099
+ paint();
1100
+ joinCountdownTimer = window.setInterval(() => {
1101
+ left -= 1;
1102
+ if (left <= 0) {
1103
+ stopJoinCountdown();
1104
+ joinQueueBtn.textContent = "Join now";
1105
+ return;
1106
+ }
1107
+ paint();
1108
+ }, 1000);
1109
+ }
1110
+
1111
+ function stopJoinCountdown() {
1112
+ if (joinCountdownTimer) {
1113
+ clearInterval(joinCountdownTimer);
1114
+ joinCountdownTimer = 0;
1115
+ }
1116
  }
1117
 
1118
  /**
 
1168
  onQueuePosition(position);
1169
  });
1170
 
1171
+ c.addEventListener("ready-to-join", (e) => {
1172
+ const { info, expiresSec } = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo; expiresSec: number }>} */ (e).detail;
1173
+ // A slot is held for us. We're out of the queue now, so drop the ticket ref.
1174
+ // Track the granted session id already so that leaving (or letting the timer
1175
+ // lapse) refunds the budget the server reserved at claim, even before we dial.
1176
+ queuedTicketId = "";
1177
+ if (info?.sessionId) {
1178
+ trackedSessionId = info.sessionId;
1179
+ trackedTier = info.tier || "anon";
1180
+ }
1181
+ startJoinCountdown(expiresSec);
1182
+ });
1183
+
1184
  c.addEventListener("status", (e) => {
1185
  const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
1186
  onClientStatus(detail.status);
 
1332
  case "queued":
1333
  setState("queued");
1334
  break;
1335
+ case "your-turn":
1336
+ setState("your-turn");
1337
+ break;
1338
  case "connected":
1339
  setState("listening");
1340
  break;
 
1358
 
1359
  async function teardown() {
1360
  stopHeartbeat();
1361
+ stopJoinCountdown();
1362
  endTrackedSession();
1363
  endQueueTicket();
1364
  chat.reset({ dismiss: true });
style.css CHANGED
@@ -847,10 +847,45 @@ a:hover {
847
  letter-spacing: 0.08em;
848
  }
849
 
850
- /* "Leave queue": a quiet outlined pill under the caption, shown only while
851
- * waiting. Understated to match the caption it's an escape hatch, not a CTA. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  .leave-queue-btn {
853
- margin-top: 14px;
854
  padding: 7px 16px;
855
  font-family: var(--font-mono);
856
  font-size: 11px;
@@ -1125,6 +1160,10 @@ body.booting *::after {
1125
  .circle.state-queued { --glow: #94a3b8; }
1126
  .circle.state-queued .ind-spinner { animation-duration: 2.4s; }
1127
  .circle.state-queued .circle-ring { animation: breathe 2.6s ease-in-out infinite; }
 
 
 
 
1128
  .circle.state-listening,
1129
  .circle.state-user-speaking { --glow: var(--listening); }
1130
  .circle.state-processing { --glow: var(--processing); }
 
847
  letter-spacing: 0.08em;
848
  }
849
 
850
+ /* Queue actions sit under the caption: "Join now" (primary, only when it's your
851
+ * turn) stacked above the quiet "Leave queue" escape hatch. */
852
+ .queue-actions {
853
+ margin-top: 16px;
854
+ display: flex;
855
+ flex-direction: column;
856
+ align-items: center;
857
+ gap: 10px;
858
+ }
859
+ .queue-actions[hidden] { display: none; }
860
+
861
+ /* "Join now": the one call to action in the queue flow, so it reads as inviting
862
+ * (filled accent) while everything around it stays quiet. */
863
+ .join-queue-btn {
864
+ padding: 10px 26px;
865
+ font-family: var(--font-mono);
866
+ font-size: 12px;
867
+ font-weight: 600;
868
+ letter-spacing: 0.1em;
869
+ text-transform: uppercase;
870
+ color: #0b0b10;
871
+ background: var(--accent);
872
+ border: none;
873
+ border-radius: 999px;
874
+ cursor: pointer;
875
+ font-variant-numeric: tabular-nums;
876
+ transition: transform 0.12s ease, filter 0.2s ease;
877
+ }
878
+ .join-queue-btn:hover { filter: brightness(1.08); }
879
+ .join-queue-btn:active { transform: scale(0.97); }
880
+ .join-queue-btn:focus-visible {
881
+ outline: 2px solid var(--accent);
882
+ outline-offset: 3px;
883
+ }
884
+ .join-queue-btn[hidden] { display: none; }
885
+
886
+ /* "Leave queue": a quiet outlined pill, understated so it reads as an escape
887
+ * hatch, not a CTA. */
888
  .leave-queue-btn {
 
889
  padding: 7px 16px;
890
  font-family: var(--font-mono);
891
  font-size: 11px;
 
1160
  .circle.state-queued { --glow: #94a3b8; }
1161
  .circle.state-queued .ind-spinner { animation-duration: 2.4s; }
1162
  .circle.state-queued .circle-ring { animation: breathe 2.6s ease-in-out infinite; }
1163
+ /* Your turn: a slot is held for you — the orb warms to the accent and breathes a
1164
+ * little quicker, an invitation to join. */
1165
+ .circle.state-your-turn { --glow: var(--accent); }
1166
+ .circle.state-your-turn .circle-ring { animation: breathe 1.6s ease-in-out infinite; }
1167
  .circle.state-listening,
1168
  .circle.state-user-speaking { --glow: var(--listening); }
1169
  .circle.state-processing { --glow: var(--processing); }
ui/account.js CHANGED
@@ -175,4 +175,15 @@ export class Account {
175
  }
176
  if (!this._modal.open) this._modal.showModal();
177
  }
 
 
 
 
 
 
 
 
 
 
 
178
  }
 
175
  }
176
  if (!this._modal.open) this._modal.showModal();
177
  }
178
+
179
+ /** Show a warm "we're at capacity" message when even the waiting line is full.
180
+ * Reuses the limit modal shell; no call-to-action, just reassurance. */
181
+ showBusy() {
182
+ this._modalTitle.textContent = "We're at capacity right now";
183
+ this._modalMsg.textContent =
184
+ "Lots of people are trying the demo, so every spot and the whole waiting line are full. Please give it a minute and try again. Thanks so much for your patience!";
185
+ this._modalNote.textContent = "A spot usually opens up within a few minutes.";
186
+ this._modalCta.hidden = true;
187
+ if (!this._modal.open) this._modal.showModal();
188
+ }
189
  }
ws/s2s-ws-client.js CHANGED
@@ -23,8 +23,9 @@
23
  * sees high-level lifecycle events (`status`, `transcript`, `error`,
24
  * `session`), the same shape as the WebRTC client.
25
  *
26
- * @typedef {"idle" | "creating-session" | "queued" | "connecting" | "connected" |
27
- * "user-speaking" | "processing" | "ai-speaking" | "closed" | "error"
 
28
  * } WsStatus
29
  *
30
  * @typedef {Object} WsSessionInfo
@@ -135,6 +136,15 @@ export class S2sWsRealtimeClient extends EventTarget {
135
  this._queueWake = null;
136
  /** @type {ReturnType<typeof setTimeout> | 0} */
137
  this._queueTimer = 0;
 
 
 
 
 
 
 
 
 
138
  /** @type {NoiseGate} Mic noise gate; off by default. */
139
  this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 };
140
  /** @type {WebSocket | null} */
@@ -237,10 +247,17 @@ export class S2sWsRealtimeClient extends EventTarget {
237
  throw new Error("No session endpoint or direct URL configured");
238
  }
239
  this._setStatus("creating-session");
240
- const session = await this._createSessionOrQueue();
241
  if (this._closed) throw _codedError("connect aborted", "aborted");
242
- this.dispatchEvent(new CustomEvent("session", { detail: { info: session } }));
243
- connectUrl = session.connectUrl;
 
 
 
 
 
 
 
244
  this._setStatus("connecting");
245
  }
246
 
@@ -259,16 +276,62 @@ export class S2sWsRealtimeClient extends EventTarget {
259
 
260
  /**
261
  * POST the session handshake; if the pool is busy, wait in the queue (polling
262
- * position) until a slot is claimed. Resolves to a grant either way.
263
- * @returns {Promise<WsSessionInfo>}
 
264
  */
265
  async _createSessionOrQueue() {
266
  const first = await this._postSession();
267
  if (first.state === "queued") {
268
  this._setStatus("queued");
269
- return await this._pollQueue(first);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  }
271
- return first.grant;
 
 
 
 
 
 
 
 
272
  }
273
 
274
  /**
@@ -978,6 +1041,16 @@ export class S2sWsRealtimeClient extends EventTarget {
978
  this._queueWake = null;
979
  wake();
980
  }
 
 
 
 
 
 
 
 
 
 
981
  this._visualiser?.stop();
982
  this._visualiser = null;
983
  try {
 
23
  * sees high-level lifecycle events (`status`, `transcript`, `error`,
24
  * `session`), the same shape as the WebRTC client.
25
  *
26
+ * @typedef {"idle" | "creating-session" | "queued" | "your-turn" | "connecting" |
27
+ * "connected" | "user-speaking" | "processing" | "ai-speaking" |
28
+ * "closed" | "error"
29
  * } WsStatus
30
  *
31
  * @typedef {Object} WsSessionInfo
 
136
  this._queueWake = null;
137
  /** @type {ReturnType<typeof setTimeout> | 0} */
138
  this._queueTimer = 0;
139
+ // Join gate: after waiting in line the caller must explicitly `join()` before
140
+ // we dial, so a slot isn't spent on someone who walked away. Resolved by
141
+ // join(), rejected on timeout (the LB reclaims the slot) or close().
142
+ /** @type {(() => void) | null} */
143
+ this._joinResolve = null;
144
+ /** @type {((err: Error) => void) | null} */
145
+ this._joinReject = null;
146
+ /** @type {ReturnType<typeof setTimeout> | 0} */
147
+ this._joinTimer = 0;
148
  /** @type {NoiseGate} Mic noise gate; off by default. */
149
  this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 };
150
  /** @type {WebSocket | null} */
 
247
  throw new Error("No session endpoint or direct URL configured");
248
  }
249
  this._setStatus("creating-session");
250
+ const { grant, waited } = await this._createSessionOrQueue();
251
  if (this._closed) throw _codedError("connect aborted", "aborted");
252
+ // If we waited in line, don't dial until the user explicitly joins — this
253
+ // keeps a freed slot from being spent on someone who stepped away, and the
254
+ // click is a fresh gesture (re-arms the AudioContext on iOS).
255
+ if (waited) {
256
+ await this._awaitJoin(grant);
257
+ if (this._closed) throw _codedError("connect aborted", "aborted");
258
+ }
259
+ this.dispatchEvent(new CustomEvent("session", { detail: { info: grant } }));
260
+ connectUrl = grant.connectUrl;
261
  this._setStatus("connecting");
262
  }
263
 
 
276
 
277
  /**
278
  * POST the session handshake; if the pool is busy, wait in the queue (polling
279
+ * position) until a slot is claimed. Resolves to a grant plus whether we had to
280
+ * wait (which decides if an explicit join is required before dialing).
281
+ * @returns {Promise<{ grant: WsSessionInfo, waited: boolean }>}
282
  */
283
  async _createSessionOrQueue() {
284
  const first = await this._postSession();
285
  if (first.state === "queued") {
286
  this._setStatus("queued");
287
+ const grant = await this._pollQueue(first);
288
+ return { grant, waited: true };
289
+ }
290
+ return { grant: first.grant, waited: false };
291
+ }
292
+
293
+ /**
294
+ * Hold at the front of the line until the user clicks join (resolves the gate)
295
+ * or the grant lapses. Announces "your-turn" + a deadline the UI counts down.
296
+ * @param {WsSessionInfo} grant
297
+ * @returns {Promise<void>}
298
+ */
299
+ _awaitJoin(grant) {
300
+ // The LB reclaims an unclaimed slot at its pending timeout; expire the gate a
301
+ // touch earlier so we never dial a session the LB just reaped.
302
+ const windowS = Math.max(3, (grant.pendingTimeoutS || 60) - 3);
303
+ this._setStatus("your-turn");
304
+ this.dispatchEvent(
305
+ new CustomEvent("ready-to-join", { detail: { info: grant, expiresSec: windowS } }),
306
+ );
307
+ return new Promise((resolve, reject) => {
308
+ this._joinResolve = resolve;
309
+ this._joinReject = reject;
310
+ this._joinTimer = setTimeout(() => {
311
+ this._joinResolve = null;
312
+ this._joinReject = null;
313
+ reject(_codedError("Your spot expired", "join-expired"));
314
+ }, windowS * 1000);
315
+ });
316
+ }
317
+
318
+ /** Accept the held slot and let connect() proceed to dial. Called from the
319
+ * "Join now" click, so it's a user gesture: re-resume the AudioContext, which
320
+ * iOS may have suspended while we waited. */
321
+ join() {
322
+ if (this._joinTimer) {
323
+ clearTimeout(this._joinTimer);
324
+ this._joinTimer = 0;
325
  }
326
+ try {
327
+ void this.options.audioContext?.resume();
328
+ } catch {
329
+ // best-effort; _setupAudio resumes again
330
+ }
331
+ const resolve = this._joinResolve;
332
+ this._joinResolve = null;
333
+ this._joinReject = null;
334
+ resolve?.();
335
  }
336
 
337
  /**
 
1041
  this._queueWake = null;
1042
  wake();
1043
  }
1044
+ if (this._joinTimer) {
1045
+ clearTimeout(this._joinTimer);
1046
+ this._joinTimer = 0;
1047
+ }
1048
+ if (this._joinReject) {
1049
+ const reject = this._joinReject;
1050
+ this._joinResolve = null;
1051
+ this._joinReject = null;
1052
+ reject(_codedError("join aborted", "aborted"));
1053
+ }
1054
  this._visualiser?.stop();
1055
  this._visualiser = null;
1056
  try {