@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
|
-
|
|
91
|
-
video:
|
|
92
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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 & 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 {
|