@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.
- package/CLAUDE.md +37 -0
- package/dist/components/TranscriptPlayer.svelte +575 -0
- package/dist/components/TranscriptPlayer.svelte.d.ts +48 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/meet/ControlBar.svelte +176 -0
- package/dist/meet/ControlBar.svelte.d.ts +31 -0
- package/dist/meet/MeetingRoom.svelte +123 -0
- package/dist/meet/MeetingRoom.svelte.d.ts +34 -0
- package/dist/meet/ParticipantList.svelte +133 -0
- package/dist/meet/ParticipantList.svelte.d.ts +22 -0
- package/dist/meet/PreJoinLobby.svelte +288 -0
- package/dist/meet/PreJoinLobby.svelte.d.ts +41 -0
- package/dist/meet/RecordingBadge.svelte +48 -0
- package/dist/meet/RecordingBadge.svelte.d.ts +20 -0
- package/dist/meet/VideoTile.svelte +176 -0
- package/dist/meet/VideoTile.svelte.d.ts +22 -0
- package/dist/meet/index.d.ts +9 -0
- package/dist/meet/index.js +20 -0
- package/dist/meet/livekit.d.ts +39 -0
- package/dist/meet/livekit.js +56 -0
- package/dist/meet/types.d.ts +103 -0
- package/dist/meet/types.js +11 -0
- package/package.json +20 -10
|
@@ -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 };
|