@joewinke/jatui 0.2.24 → 0.2.26

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.
@@ -12,6 +12,7 @@
12
12
  * haven't installed the peer. Media preview is stopped on unmount and before
13
13
  * join (the room re-acquires tracks).
14
14
  */
15
+ import { untrack } from 'svelte';
15
16
  import type { Snippet } from 'svelte';
16
17
  import type { PreJoinResult } from './types.js';
17
18
 
@@ -78,42 +79,93 @@
78
79
  if (videoEl) videoEl.srcObject = null;
79
80
  }
80
81
 
82
+ // Serialize acquisitions: toggles/device switches re-run acquire() while a
83
+ // previous getUserMedia may still be pending, and a stale resolution used to
84
+ // stop the live stream or attach a dead one (black preview). Latest-wins:
85
+ // stale acquisitions stop their own tracks and change nothing.
86
+ let acquireSeq = 0;
87
+
88
+ function videoConstraint(): MediaStreamConstraints['video'] {
89
+ if (!cameraEnabled) return false;
90
+ return videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true;
91
+ }
92
+ function audioConstraint(): MediaStreamConstraints['audio'] {
93
+ if (!micEnabled) return false;
94
+ return audioDeviceId ? { deviceId: { exact: audioDeviceId } } : true;
95
+ }
96
+
81
97
  async function acquire() {
82
98
  if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
83
99
  permissionError = 'This browser does not support camera/microphone access.';
84
100
  return;
85
101
  }
102
+ const seq = ++acquireSeq;
86
103
  stopStream();
87
104
  permissionError = null;
88
105
  if (!cameraEnabled && !micEnabled) return;
106
+
107
+ // One combined call first (single permission prompt). If it fails with
108
+ // both kinds requested, fall back to acquiring each kind separately so
109
+ // one bad device (camera held by another app, unplugged mic) doesn't
110
+ // take the other down with it — that failure mode left the lobby with a
111
+ // dead preview tile even though the mic was fine.
112
+ let next: MediaStream | null = null;
113
+ let failedKinds: string[] = [];
114
+ let blocked = false;
89
115
  try {
90
- const next = await navigator.mediaDevices.getUserMedia({
91
- video: cameraEnabled
92
- ? videoDeviceId
93
- ? { deviceId: { exact: videoDeviceId } }
94
- : true
95
- : false,
96
- audio: micEnabled
97
- ? audioDeviceId
98
- ? { deviceId: { exact: audioDeviceId } }
99
- : true
100
- : false
116
+ next = await navigator.mediaDevices.getUserMedia({
117
+ video: videoConstraint(),
118
+ audio: audioConstraint()
101
119
  });
102
- stream = next;
103
- if (videoEl && cameraEnabled) {
104
- videoEl.srcObject = next;
105
- void videoEl.play().catch(() => {});
106
- }
107
- // Enumerate labelled devices now that permission is granted.
108
- const devices = await navigator.mediaDevices.enumerateDevices();
109
- videoDevices = devices.filter((d) => d.kind === 'videoinput');
110
- audioDevices = devices.filter((d) => d.kind === 'audioinput');
111
120
  } catch (err) {
121
+ if (err instanceof DOMException && err.name === 'NotAllowedError') blocked = true;
122
+ else if (cameraEnabled && micEnabled) {
123
+ for (const kind of ['audio', 'video'] as const) {
124
+ try {
125
+ const partial = await navigator.mediaDevices.getUserMedia(
126
+ kind === 'video'
127
+ ? { video: videoConstraint(), audio: false }
128
+ : { video: false, audio: audioConstraint() }
129
+ );
130
+ if (next) partial.getTracks().forEach((t) => next!.addTrack(t));
131
+ else next = partial;
132
+ } catch {
133
+ failedKinds.push(kind === 'video' ? 'camera' : 'microphone');
134
+ }
135
+ }
136
+ } else {
137
+ failedKinds.push(cameraEnabled ? 'camera' : 'microphone');
138
+ }
139
+ }
140
+
141
+ if (seq !== acquireSeq) {
142
+ // A newer acquisition superseded this one while we awaited.
143
+ next?.getTracks().forEach((t) => t.stop());
144
+ return;
145
+ }
146
+
147
+ if (blocked) {
112
148
  permissionError =
113
- err instanceof DOMException && err.name === 'NotAllowedError'
114
- ? 'Camera/microphone access was blocked. Enable it in your browser to preview.'
115
- : 'Could not access your camera or microphone.';
149
+ 'Camera/microphone access was blocked. Enable it in your browser to preview.';
150
+ return;
116
151
  }
152
+ if (failedKinds.length) {
153
+ permissionError = `Could not access your ${failedKinds.join(' or ')}${
154
+ failedKinds.includes('camera') ? ' — it may be in use by another app' : ''
155
+ }.`;
156
+ }
157
+ if (!next) return;
158
+
159
+ stream = next;
160
+ if (videoEl && cameraEnabled && next.getVideoTracks().length) {
161
+ videoEl.srcObject = next;
162
+ void videoEl.play().catch(() => {});
163
+ }
164
+ // Enumerate labelled devices now that permission is granted.
165
+ const devices = await navigator.mediaDevices.enumerateDevices();
166
+ if (seq !== acquireSeq) return;
167
+ videoDevices = devices.filter((d) => d.kind === 'videoinput');
168
+ audioDevices = devices.filter((d) => d.kind === 'audioinput');
117
169
  }
118
170
 
119
171
  // (Re)acquire whenever the enabled toggles or selected devices change.
@@ -124,7 +176,12 @@
124
176
  void videoDeviceId;
125
177
  void audioDeviceId;
126
178
  void videoEl;
127
- acquire();
179
+ // untrack is load-bearing: acquire() synchronously calls stopStream(),
180
+ // which READS `stream` — without untrack the effect silently depends on
181
+ // `stream`, so the `stream = next` assignment re-runs the effect, whose
182
+ // cleanup stops the just-acquired stream and re-acquires, forever. The
183
+ // observable symptom was a preview that never survived acquisition.
184
+ untrack(() => acquire());
128
185
  return () => stopStream();
129
186
  });
130
187
 
@@ -169,6 +226,14 @@
169
226
  </svg>
170
227
  <span class="text-sm">Camera is off</span>
171
228
  </div>
229
+ {:else if !stream && !permissionError}
230
+ <!-- Camera requested but no stream yet: the browser permission prompt is
231
+ (or is about to be) up. Without this state the tile is a blank black
232
+ box while the user decides — observed on iOS Safari. -->
233
+ <div class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-6 text-center text-base-content/60">
234
+ <span class="loading loading-ring loading-lg" aria-hidden="true"></span>
235
+ <span class="text-sm">Allow camera &amp; microphone access to see your preview</span>
236
+ </div>
172
237
  {/if}
173
238
 
174
239
  <!-- Preview device toggles -->
@@ -1,16 +1,3 @@
1
- /**
2
- * PreJoinLobby — the device check before joining.
3
- *
4
- * Camera/mic self-preview, display-name confirm, per-device pickers, and an
5
- * optional consent gate (checkbox slot; recording/telehealth contexts). On
6
- * "Join" it emits a {@link PreJoinResult} the route uses to exchange the join
7
- * token for a LiveKit JWT and connect.
8
- *
9
- * Uses only browser-native `getUserMedia`/`enumerateDevices` — NO
10
- * livekit-client dependency, so the lobby renders in demos and in apps that
11
- * haven't installed the peer. Media preview is stopped on unmount and before
12
- * join (the room re-acquires tracks).
13
- */
14
1
  import type { Snippet } from 'svelte';
15
2
  import type { PreJoinResult } from './types.js';
16
3
  interface Props {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",