@joewinke/jatui 0.2.19 → 0.2.21

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,288 @@
1
+ <script lang="ts">
2
+ /**
3
+ * PreJoinLobby — the device check before joining.
4
+ *
5
+ * Camera/mic self-preview, display-name confirm, per-device pickers, and an
6
+ * optional consent gate (checkbox slot; recording/telehealth contexts). On
7
+ * "Join" it emits a {@link PreJoinResult} the route uses to exchange the join
8
+ * token for a LiveKit JWT and connect.
9
+ *
10
+ * Uses only browser-native `getUserMedia`/`enumerateDevices` — NO
11
+ * livekit-client dependency, so the lobby renders in demos and in apps that
12
+ * haven't installed the peer. Media preview is stopped on unmount and before
13
+ * join (the room re-acquires tracks).
14
+ */
15
+ import type { Snippet } from 'svelte';
16
+ import type { PreJoinResult } from './types.js';
17
+
18
+ interface Props {
19
+ /** Prefilled display name (from the invite); user can edit. */
20
+ displayName?: string;
21
+ /** Whether the name field is editable. */
22
+ nameEditable?: boolean;
23
+ /** Room/meeting title shown as the heading. */
24
+ roomName?: string;
25
+ /** Require the consent checkbox before Join is enabled. */
26
+ consentRequired?: boolean;
27
+ /** Whether Join is in flight (spinner + disabled). */
28
+ joining?: boolean;
29
+ /** Start with the camera enabled. */
30
+ cameraDefault?: boolean;
31
+ /** Start with the mic enabled. */
32
+ micDefault?: boolean;
33
+ /** Consent copy slot (module-config text). Rendered beside the checkbox. */
34
+ consent?: Snippet;
35
+ /** Extra content below the actions (branding, legal, etc.). */
36
+ footer?: Snippet;
37
+ /** Fired when the user confirms and joins. */
38
+ onJoin?: (result: PreJoinResult) => void;
39
+ class?: string;
40
+ }
41
+
42
+ let {
43
+ displayName = $bindable(''),
44
+ nameEditable = true,
45
+ roomName = 'Join meeting',
46
+ consentRequired = false,
47
+ joining = false,
48
+ cameraDefault = true,
49
+ micDefault = true,
50
+ consent,
51
+ footer,
52
+ onJoin,
53
+ class: className = ''
54
+ }: Props = $props();
55
+
56
+ // Seed local toggles from the prop defaults once; thereafter the user drives
57
+ // them via the in-lobby buttons (intentional initial-value capture).
58
+ let cameraEnabled = $state(cameraDefault);
59
+ let micEnabled = $state(micDefault);
60
+ let consentGiven = $state(false);
61
+
62
+ let videoEl = $state<HTMLVideoElement | null>(null);
63
+ let stream = $state<MediaStream | null>(null);
64
+ let permissionError = $state<string | null>(null);
65
+
66
+ let videoDevices = $state<MediaDeviceInfo[]>([]);
67
+ let audioDevices = $state<MediaDeviceInfo[]>([]);
68
+ let videoDeviceId = $state<string>('');
69
+ let audioDeviceId = $state<string>('');
70
+
71
+ const canJoin = $derived(
72
+ displayName.trim().length > 0 && (!consentRequired || consentGiven) && !joining
73
+ );
74
+
75
+ function stopStream() {
76
+ stream?.getTracks().forEach((t) => t.stop());
77
+ stream = null;
78
+ if (videoEl) videoEl.srcObject = null;
79
+ }
80
+
81
+ async function acquire() {
82
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
83
+ permissionError = 'This browser does not support camera/microphone access.';
84
+ return;
85
+ }
86
+ stopStream();
87
+ permissionError = null;
88
+ if (!cameraEnabled && !micEnabled) return;
89
+ 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
101
+ });
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
+ } catch (err) {
112
+ 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.';
116
+ }
117
+ }
118
+
119
+ // (Re)acquire whenever the enabled toggles or selected devices change.
120
+ $effect(() => {
121
+ // Track the reactive deps explicitly.
122
+ void cameraEnabled;
123
+ void micEnabled;
124
+ void videoDeviceId;
125
+ void audioDeviceId;
126
+ void videoEl;
127
+ acquire();
128
+ return () => stopStream();
129
+ });
130
+
131
+ function join() {
132
+ if (!canJoin) return;
133
+ stopStream();
134
+ onJoin?.({
135
+ displayName: displayName.trim(),
136
+ cameraEnabled,
137
+ micEnabled,
138
+ videoDeviceId: videoDeviceId || undefined,
139
+ audioDeviceId: audioDeviceId || undefined,
140
+ consentGiven
141
+ });
142
+ }
143
+ </script>
144
+
145
+ <div
146
+ class="mx-auto flex w-full max-w-4xl flex-col gap-6 bg-base-100 p-6 md:flex-row {className}"
147
+ style="border-radius: var(--jatui-radius-box);"
148
+ >
149
+ <!-- Preview -->
150
+ <div class="flex-1">
151
+ <div
152
+ class="relative flex aspect-video items-center justify-center overflow-hidden bg-base-300"
153
+ style="border-radius: var(--jatui-radius-box);"
154
+ >
155
+ <!-- svelte-ignore a11y_media_has_caption -->
156
+ <video
157
+ bind:this={videoEl}
158
+ class="h-full w-full -scale-x-100 object-cover transition-opacity duration-200"
159
+ class:opacity-0={!cameraEnabled || !stream}
160
+ autoplay
161
+ playsinline
162
+ muted
163
+ ></video>
164
+
165
+ {#if !cameraEnabled}
166
+ <div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-content/50">
167
+ <svg class="h-10 w-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
168
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M3 3l18 18M5 7h6M5 7a2 2 0 00-2 2v6a2 2 0 002 2h8" />
169
+ </svg>
170
+ <span class="text-sm">Camera is off</span>
171
+ </div>
172
+ {/if}
173
+
174
+ <!-- Preview device toggles -->
175
+ <div class="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2">
176
+ <button
177
+ type="button"
178
+ class="btn btn-circle btn-sm"
179
+ class:btn-error={!micEnabled}
180
+ class:btn-neutral={micEnabled}
181
+ aria-pressed={!micEnabled}
182
+ aria-label={micEnabled ? 'Turn microphone off' : 'Turn microphone on'}
183
+ onclick={() => (micEnabled = !micEnabled)}
184
+ >
185
+ {#if micEnabled}
186
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-14 0m7 7v3m0-3a4 4 0 004-4V5a4 4 0 00-8 0v6a4 4 0 004 4z" /></svg>
187
+ {:else}
188
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 5.586A2 2 0 004 7v6a4 4 0 004 4m2.828-1.172A4 4 0 0016 13V9m0-4a2 2 0 00-3.414-1.414M3 3l18 18" /></svg>
189
+ {/if}
190
+ </button>
191
+ <button
192
+ type="button"
193
+ class="btn btn-circle btn-sm"
194
+ class:btn-error={!cameraEnabled}
195
+ class:btn-neutral={cameraEnabled}
196
+ aria-pressed={!cameraEnabled}
197
+ aria-label={cameraEnabled ? 'Turn camera off' : 'Turn camera on'}
198
+ onclick={() => (cameraEnabled = !cameraEnabled)}
199
+ >
200
+ {#if cameraEnabled}
201
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14v-4zM5 7h8a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z" /></svg>
202
+ {:else}
203
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M3 3l18 18M5 7h6M5 7a2 2 0 00-2 2v6a2 2 0 002 2h8" /></svg>
204
+ {/if}
205
+ </button>
206
+ </div>
207
+ </div>
208
+
209
+ {#if permissionError}
210
+ <div class="mt-3 flex items-start gap-2 bg-warning/15 px-3 py-2 text-sm text-warning-content" style="border-radius: var(--jatui-radius-field);" role="alert">
211
+ <svg class="mt-0.5 h-4 w-4 shrink-0 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
212
+ <span>{permissionError}</span>
213
+ </div>
214
+ {/if}
215
+ </div>
216
+
217
+ <!-- Join form -->
218
+ <div class="flex w-full flex-col gap-4 md:w-72">
219
+ <div>
220
+ <h2 class="text-xl font-semibold">{roomName}</h2>
221
+ <p class="mt-1 text-sm text-base-content/60">Check your camera and mic, then join.</p>
222
+ </div>
223
+
224
+ <div>
225
+ <label for="meet-display-name" class="mb-1 block text-sm font-medium">Your name</label>
226
+ <input
227
+ id="meet-display-name"
228
+ type="text"
229
+ class="input input-bordered w-full"
230
+ style="border-radius: var(--jatui-radius-field);"
231
+ bind:value={displayName}
232
+ disabled={!nameEditable}
233
+ placeholder="Enter your name"
234
+ autocomplete="name"
235
+ />
236
+ </div>
237
+
238
+ {#if videoDevices.length > 1}
239
+ <div>
240
+ <label for="meet-camera" class="mb-1 block text-sm font-medium">Camera</label>
241
+ <select id="meet-camera" class="select select-bordered w-full" style="border-radius: var(--jatui-radius-field);" bind:value={videoDeviceId}>
242
+ <option value="">Default camera</option>
243
+ {#each videoDevices as d (d.deviceId)}
244
+ <option value={d.deviceId}>{d.label || 'Camera'}</option>
245
+ {/each}
246
+ </select>
247
+ </div>
248
+ {/if}
249
+
250
+ {#if audioDevices.length > 1}
251
+ <div>
252
+ <label for="meet-mic" class="mb-1 block text-sm font-medium">Microphone</label>
253
+ <select id="meet-mic" class="select select-bordered w-full" style="border-radius: var(--jatui-radius-field);" bind:value={audioDeviceId}>
254
+ <option value="">Default microphone</option>
255
+ {#each audioDevices as d (d.deviceId)}
256
+ <option value={d.deviceId}>{d.label || 'Microphone'}</option>
257
+ {/each}
258
+ </select>
259
+ </div>
260
+ {/if}
261
+
262
+ {#if consentRequired}
263
+ <label class="flex cursor-pointer items-start gap-2 text-sm">
264
+ <input type="checkbox" class="checkbox checkbox-sm mt-0.5" bind:checked={consentGiven} />
265
+ <span class="text-base-content/70">
266
+ {#if consent}{@render consent()}{:else}I consent to joining this session.{/if}
267
+ </span>
268
+ </label>
269
+ {/if}
270
+
271
+ <button
272
+ type="button"
273
+ class="btn btn-primary w-full"
274
+ disabled={!canJoin}
275
+ aria-busy={joining}
276
+ onclick={join}
277
+ >
278
+ {#if joining}
279
+ <span class="loading loading-spinner loading-sm"></span>
280
+ Joining…
281
+ {:else}
282
+ Join now
283
+ {/if}
284
+ </button>
285
+
286
+ {#if footer}{@render footer()}{/if}
287
+ </div>
288
+ </div>
@@ -0,0 +1,41 @@
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
+ import type { Snippet } from 'svelte';
15
+ import type { PreJoinResult } from './types.js';
16
+ interface Props {
17
+ /** Prefilled display name (from the invite); user can edit. */
18
+ displayName?: string;
19
+ /** Whether the name field is editable. */
20
+ nameEditable?: boolean;
21
+ /** Room/meeting title shown as the heading. */
22
+ roomName?: string;
23
+ /** Require the consent checkbox before Join is enabled. */
24
+ consentRequired?: boolean;
25
+ /** Whether Join is in flight (spinner + disabled). */
26
+ joining?: boolean;
27
+ /** Start with the camera enabled. */
28
+ cameraDefault?: boolean;
29
+ /** Start with the mic enabled. */
30
+ micDefault?: boolean;
31
+ /** Consent copy slot (module-config text). Rendered beside the checkbox. */
32
+ consent?: Snippet;
33
+ /** Extra content below the actions (branding, legal, etc.). */
34
+ footer?: Snippet;
35
+ /** Fired when the user confirms and joins. */
36
+ onJoin?: (result: PreJoinResult) => void;
37
+ class?: string;
38
+ }
39
+ declare const PreJoinLobby: import("svelte").Component<Props, {}, "displayName">;
40
+ type PreJoinLobby = ReturnType<typeof PreJoinLobby>;
41
+ export default PreJoinLobby;
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ /**
3
+ * RecordingBadge — always-visible recording indicator.
4
+ *
5
+ * When a meeting is being recorded every participant must see it at all times
6
+ * (consent/compliance requirement, PRD §4.3). Render this fixed in the room
7
+ * chrome; it shows a pulsing red dot + "Recording" label with an `aria-live`
8
+ * announcement, and renders nothing when `recording` is false.
9
+ */
10
+ interface Props {
11
+ /** Whether recording is currently active. */
12
+ recording: boolean;
13
+ /** Optional label override (terminology map: "Recording" | "Grabando" …). */
14
+ label?: string;
15
+ /** Compact dot-only variant for tight chrome. */
16
+ compact?: boolean;
17
+ class?: string;
18
+ }
19
+
20
+ let { recording, label = 'Recording', compact = false, class: className = '' }: Props = $props();
21
+
22
+ const reducedMotion =
23
+ typeof window !== 'undefined' &&
24
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
25
+ </script>
26
+
27
+ {#if recording}
28
+ <div
29
+ class="flex items-center gap-2 bg-error/90 px-2.5 py-1 font-semibold text-error-content {className}"
30
+ style="border-radius: var(--jatui-radius-field);"
31
+ role="status"
32
+ aria-live="polite"
33
+ >
34
+ <span class="relative flex h-2.5 w-2.5">
35
+ {#if !reducedMotion}
36
+ <span
37
+ class="absolute inline-flex h-full w-full animate-ping rounded-full bg-error-content opacity-75"
38
+ ></span>
39
+ {/if}
40
+ <span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-error-content"></span>
41
+ </span>
42
+ {#if !compact}
43
+ <span class="text-xs tracking-wide uppercase">{label}</span>
44
+ {:else}
45
+ <span class="sr-only">{label}</span>
46
+ {/if}
47
+ </div>
48
+ {/if}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * RecordingBadge — always-visible recording indicator.
3
+ *
4
+ * When a meeting is being recorded every participant must see it at all times
5
+ * (consent/compliance requirement, PRD §4.3). Render this fixed in the room
6
+ * chrome; it shows a pulsing red dot + "Recording" label with an `aria-live`
7
+ * announcement, and renders nothing when `recording` is false.
8
+ */
9
+ interface Props {
10
+ /** Whether recording is currently active. */
11
+ recording: boolean;
12
+ /** Optional label override (terminology map: "Recording" | "Grabando" …). */
13
+ label?: string;
14
+ /** Compact dot-only variant for tight chrome. */
15
+ compact?: boolean;
16
+ class?: string;
17
+ }
18
+ declare const RecordingBadge: import("svelte").Component<Props, {}, "">;
19
+ type RecordingBadge = ReturnType<typeof RecordingBadge>;
20
+ export default RecordingBadge;
@@ -0,0 +1,176 @@
1
+ <script lang="ts">
2
+ /**
3
+ * VideoTile — one participant's video surface.
4
+ *
5
+ * Renders a MediaStreamTrack into a muted, autoplaying <video>, or an
6
+ * avatar/initials placeholder when the camera is off. Overlays the name,
7
+ * a mic-muted badge, a screen-share badge, and a speaking ring. Purely
8
+ * presentational: it takes a {@link MeetParticipant} view-model, so demos
9
+ * can drive it with a mocked participant and no LiveKit server.
10
+ */
11
+ import type { MeetParticipant } from './types.js';
12
+
13
+ interface Props {
14
+ /** The participant to render. */
15
+ participant: MeetParticipant;
16
+ /** Render the local participant's own tile mirrored (selfie view). */
17
+ mirror?: boolean;
18
+ /** Compact roster/filmstrip variant — smaller badges. */
19
+ size?: 'sm' | 'md';
20
+ class?: string;
21
+ }
22
+
23
+ let { participant, mirror = false, size = 'md', class: className = '' }: Props = $props();
24
+
25
+ let videoEl = $state<HTMLVideoElement | null>(null);
26
+
27
+ // The track to attach: screen share wins over camera when active.
28
+ const activeTrack = $derived(
29
+ participant.isScreenSharing && participant.screenTrack
30
+ ? participant.screenTrack
31
+ : participant.videoTrack ?? null
32
+ );
33
+
34
+ const showVideo = $derived(
35
+ !!activeTrack && (participant.isScreenSharing || !participant.cameraOff)
36
+ );
37
+
38
+ const initials = $derived(
39
+ participant.name
40
+ .split(/\s+/)
41
+ .filter(Boolean)
42
+ .slice(0, 2)
43
+ .map((w) => w[0]?.toUpperCase() ?? '')
44
+ .join('') || '?'
45
+ );
46
+
47
+ // Attach / detach the MediaStreamTrack whenever it changes.
48
+ $effect(() => {
49
+ const el = videoEl;
50
+ const track = activeTrack;
51
+ if (!el) return;
52
+ if (track) {
53
+ const stream = new MediaStream([track]);
54
+ el.srcObject = stream;
55
+ // Autoplay can reject if the tab isn't yet interacted with; ignore.
56
+ void el.play().catch(() => {});
57
+ return () => {
58
+ el.srcObject = null;
59
+ };
60
+ } else {
61
+ el.srcObject = null;
62
+ }
63
+ });
64
+
65
+ const badgeSize = $derived(size === 'sm' ? 'text-[0.65rem] px-1.5 py-0.5' : 'text-xs px-2 py-1');
66
+ </script>
67
+
68
+ <div
69
+ class="relative flex items-center justify-center overflow-hidden bg-base-300 text-base-content aspect-video {className}"
70
+ class:ring-2={participant.isSpeaking}
71
+ class:ring-success={participant.isSpeaking}
72
+ style="border-radius: var(--jatui-radius-box);"
73
+ aria-label={`${participant.name}${participant.micMuted ? ', muted' : ''}${participant.cameraOff ? ', camera off' : ''}`}
74
+ >
75
+ <!-- svelte-ignore a11y_media_has_caption -->
76
+ <video
77
+ bind:this={videoEl}
78
+ class="h-full w-full object-cover transition-opacity duration-200"
79
+ class:opacity-0={!showVideo}
80
+ class:-scale-x-100={mirror && !participant.isScreenSharing}
81
+ autoplay
82
+ playsinline
83
+ muted={participant.isLocal}
84
+ ></video>
85
+
86
+ {#if !showVideo}
87
+ <!-- Camera-off placeholder: avatar or initials -->
88
+ <div class="absolute inset-0 flex items-center justify-center bg-base-300">
89
+ {#if participant.avatarUrl}
90
+ <img
91
+ src={participant.avatarUrl}
92
+ alt={participant.name}
93
+ class="h-16 w-16 rounded-full object-cover"
94
+ />
95
+ {:else}
96
+ <div
97
+ class="flex h-16 w-16 items-center justify-center rounded-full bg-primary/15 text-xl font-semibold text-primary"
98
+ >
99
+ {initials}
100
+ </div>
101
+ {/if}
102
+ </div>
103
+ {/if}
104
+
105
+ <!-- Bottom-left: name + mic state -->
106
+ <div
107
+ class="absolute bottom-2 left-2 flex items-center gap-1.5 bg-base-100/70 font-medium backdrop-blur-sm {badgeSize}"
108
+ style="border-radius: var(--jatui-radius-field);"
109
+ >
110
+ {#if participant.micMuted}
111
+ <svg
112
+ class="h-3.5 w-3.5 shrink-0 text-error"
113
+ fill="none"
114
+ stroke="currentColor"
115
+ viewBox="0 0 24 24"
116
+ aria-hidden="true"
117
+ >
118
+ <path
119
+ stroke-linecap="round"
120
+ stroke-linejoin="round"
121
+ stroke-width="2"
122
+ d="M5.586 5.586A2 2 0 004 7v6a4 4 0 004 4m2.828-1.172A4 4 0 0016 13V9m0-4a2 2 0 00-3.414-1.414M3 3l18 18"
123
+ />
124
+ </svg>
125
+ {:else}
126
+ <svg
127
+ class="h-3.5 w-3.5 shrink-0 text-base-content/70"
128
+ fill="none"
129
+ stroke="currentColor"
130
+ viewBox="0 0 24 24"
131
+ aria-hidden="true"
132
+ >
133
+ <path
134
+ stroke-linecap="round"
135
+ stroke-linejoin="round"
136
+ stroke-width="2"
137
+ d="M19 11a7 7 0 01-14 0m7 7v3m0-3a4 4 0 004-4V5a4 4 0 00-8 0v6a4 4 0 004 4z"
138
+ />
139
+ </svg>
140
+ {/if}
141
+ <span class="max-w-[10rem] truncate">{participant.name}{participant.isLocal ? ' (you)' : ''}</span>
142
+ </div>
143
+
144
+ <!-- Top-right badges: screen share, connection quality -->
145
+ {#if participant.isScreenSharing || participant.connectionQuality === 'poor'}
146
+ <div class="absolute top-2 right-2 flex items-center gap-1.5">
147
+ {#if participant.isScreenSharing}
148
+ <span
149
+ class="flex items-center gap-1 bg-info/85 text-info-content {badgeSize}"
150
+ style="border-radius: var(--jatui-radius-field);"
151
+ >
152
+ <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
153
+ <path
154
+ stroke-linecap="round"
155
+ stroke-linejoin="round"
156
+ stroke-width="2"
157
+ d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
158
+ />
159
+ </svg>
160
+ Sharing
161
+ </span>
162
+ {/if}
163
+ {#if participant.connectionQuality === 'poor'}
164
+ <span
165
+ class="flex items-center bg-warning/85 text-warning-content {badgeSize}"
166
+ style="border-radius: var(--jatui-radius-field);"
167
+ title="Weak connection"
168
+ >
169
+ <svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
170
+ <path d="M2 22h3v-6H2v6zm5 0h3V10H7v12zm5 0h3V4h-3v18z" opacity="0.4" />
171
+ </svg>
172
+ </span>
173
+ {/if}
174
+ </div>
175
+ {/if}
176
+ </div>
@@ -0,0 +1,22 @@
1
+ /**
2
+ * VideoTile — one participant's video surface.
3
+ *
4
+ * Renders a MediaStreamTrack into a muted, autoplaying <video>, or an
5
+ * avatar/initials placeholder when the camera is off. Overlays the name,
6
+ * a mic-muted badge, a screen-share badge, and a speaking ring. Purely
7
+ * presentational: it takes a {@link MeetParticipant} view-model, so demos
8
+ * can drive it with a mocked participant and no LiveKit server.
9
+ */
10
+ import type { MeetParticipant } from './types.js';
11
+ interface Props {
12
+ /** The participant to render. */
13
+ participant: MeetParticipant;
14
+ /** Render the local participant's own tile mirrored (selfie view). */
15
+ mirror?: boolean;
16
+ /** Compact roster/filmstrip variant — smaller badges. */
17
+ size?: 'sm' | 'md';
18
+ class?: string;
19
+ }
20
+ declare const VideoTile: import("svelte").Component<Props, {}, "">;
21
+ type VideoTile = ReturnType<typeof VideoTile>;
22
+ export default VideoTile;
@@ -0,0 +1,9 @@
1
+ export { default as MeetingRoom } from './MeetingRoom.svelte';
2
+ export { default as VideoTile } from './VideoTile.svelte';
3
+ export { default as ControlBar } from './ControlBar.svelte';
4
+ export { default as PreJoinLobby } from './PreJoinLobby.svelte';
5
+ export { default as ParticipantList } from './ParticipantList.svelte';
6
+ export { default as RecordingBadge } from './RecordingBadge.svelte';
7
+ export { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from './livekit.js';
8
+ export type { LiveKitModule } from './livekit.js';
9
+ export type { MeetRole, MeetConnectionQuality, MeetParticipant, MeetHostActions, MeetLocalControls, PreJoinResult, MeetRoomState, MeetLayout } from './types.js';
@@ -0,0 +1,20 @@
1
+ // Subpath entry: @joewinke/jatui/meet — real-time meeting UI.
2
+ //
3
+ // `livekit-client` is an OPTIONAL peer dependency. It is loaded lazily (see
4
+ // ./livekit.ts) ONLY by the container components that connect to a room, and
5
+ // never imported at module scope — so a consumer that renders only the
6
+ // presentational tiles (or never imports this subpath at all) does not need it
7
+ // installed. This mirrors the d3/cobe/@xyflow optional-peer pattern; the
8
+ // components are intentionally kept out of the main `jatui` barrel so barrel
9
+ // consumers never pull livekit-client.
10
+ //
11
+ // import { MeetingRoom, ControlBar } from '@joewinke/jatui/meet';
12
+ // Components
13
+ export { default as MeetingRoom } from './MeetingRoom.svelte';
14
+ export { default as VideoTile } from './VideoTile.svelte';
15
+ export { default as ControlBar } from './ControlBar.svelte';
16
+ export { default as PreJoinLobby } from './PreJoinLobby.svelte';
17
+ export { default as ParticipantList } from './ParticipantList.svelte';
18
+ export { default as RecordingBadge } from './RecordingBadge.svelte';
19
+ // Optional-peer loader (for the consuming route to connect a Room)
20
+ export { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from './livekit.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * livekit-client optional-peer loader.
3
+ *
4
+ * `livekit-client` is an OPTIONAL peer dependency (peerDependenciesMeta), so a
5
+ * consumer that never touches `@joewinke/jatui/meet` need not install it. To
6
+ * keep that promise we NEVER import runtime values from the package at module
7
+ * scope — a static `import { Room } from 'livekit-client'` would make the whole
8
+ * subpath fail to resolve at import time in a consumer without the peer (the
9
+ * d3 lesson: the missing module resolves to `undefined` / a bundler stub and
10
+ * `Room` is `(void 0)` → "is not a constructor"). Instead every runtime value
11
+ * comes through `loadLiveKit()`, a cached lazy `import()` that throws a clear,
12
+ * actionable error when the peer is absent.
13
+ *
14
+ * Types are safe to import statically with `import type` — they are fully
15
+ * erased at compile time and never emit a runtime `require`/`import`.
16
+ */
17
+ import type { Room as LKRoom, RoomEvent as LKRoomEvent, Track as LKTrack, LocalParticipant as LKLocalParticipant, RemoteParticipant as LKRemoteParticipant, Participant as LKParticipant, ConnectionState as LKConnectionState } from 'livekit-client';
18
+ /** The runtime shape of the `livekit-client` module we depend on. */
19
+ export interface LiveKitModule {
20
+ Room: typeof import('livekit-client').Room;
21
+ RoomEvent: typeof import('livekit-client').RoomEvent;
22
+ Track: typeof import('livekit-client').Track;
23
+ ConnectionState: typeof import('livekit-client').ConnectionState;
24
+ createLocalTracks: typeof import('livekit-client').createLocalTracks;
25
+ }
26
+ /** Thrown when a `jatui/meet` component needs `livekit-client` but it isn't installed. */
27
+ export declare class LiveKitNotInstalledError extends Error {
28
+ constructor(cause?: unknown);
29
+ }
30
+ /**
31
+ * Lazily load `livekit-client`. Cached after the first call so repeated mounts
32
+ * share one module instance. Rejects with {@link LiveKitNotInstalledError} when
33
+ * the peer is missing, so callers can render a graceful fallback instead of a
34
+ * hard page crash.
35
+ */
36
+ export declare function loadLiveKit(): Promise<LiveKitModule>;
37
+ /** True if `livekit-client` can be loaded in this environment. Never throws. */
38
+ export declare function isLiveKitAvailable(): Promise<boolean>;
39
+ export type { LKRoom, LKRoomEvent, LKTrack, LKLocalParticipant, LKRemoteParticipant, LKParticipant, LKConnectionState };