@joewinke/jatui 0.6.0 → 0.6.2
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.
|
@@ -33,6 +33,17 @@
|
|
|
33
33
|
* getDisplayMedia exists, and never throws — a permission denial or
|
|
34
34
|
* cancelled screen-share surfaces as inline `errorMessage`, and the
|
|
35
35
|
* component returns to idle so the caller can retry.
|
|
36
|
+
*
|
|
37
|
+
* Two UAT findings shaped the recording UI (mos-ow4q4.37):
|
|
38
|
+
* 1. The elapsed clock is driven by the recorder's own `ondataavailable`
|
|
39
|
+
* timeslice, not only by a setInterval. A `screen` capture is normally
|
|
40
|
+
* started against another window, which leaves THIS page hidden — and
|
|
41
|
+
* a hidden page's timers are throttled to roughly one tick a minute,
|
|
42
|
+
* so the interval alone left the timer visibly frozen mid-recording.
|
|
43
|
+
* 2. A fixed-position banner announces an active screen capture in the
|
|
44
|
+
* page itself. The browser's sharing indicator is chrome, not content:
|
|
45
|
+
* it is not visible from the shared window and it never says how long
|
|
46
|
+
* THIS recording has run.
|
|
36
47
|
*/
|
|
37
48
|
import MediaCaptureIcon from './MediaCaptureIcon.svelte'
|
|
38
49
|
|
|
@@ -51,6 +62,28 @@
|
|
|
51
62
|
/** Smaller footprint (icon-only mode buttons) for embedding inside a
|
|
52
63
|
* larger composite field like MultimodalCaptureField. */
|
|
53
64
|
compact?: boolean
|
|
65
|
+
/** Override the per-mode button label. Hosts that frame recording as
|
|
66
|
+
* "add another response" want their own wording ("Voice" vs "Add voice"),
|
|
67
|
+
* and the screen mode in particular has been called both "Screen" and
|
|
68
|
+
* "Show us" in different products. */
|
|
69
|
+
modeLabels?: Partial<Record<MediaCaptureKind, string>>
|
|
70
|
+
/** Prefix each mode button with a `+`, framing the modes as
|
|
71
|
+
* add-affordances on a field that accepts SEVERAL responses rather than
|
|
72
|
+
* as a one-shot record button. */
|
|
73
|
+
addAffordance?: boolean
|
|
74
|
+
/** Render a fixed, page-level "screen sharing is active" banner while a
|
|
75
|
+
* `screen` capture runs. The browser's own sharing chrome lives outside
|
|
76
|
+
* the page, so without this the page itself gives no sign it is being
|
|
77
|
+
* recorded — the single most-reported confusion in UAT. */
|
|
78
|
+
screenIndicator?: boolean
|
|
79
|
+
/** Observe the capture state machine (phase / kind / elapsed). Lets a
|
|
80
|
+
* host disable competing controls or mirror the recording state without
|
|
81
|
+
* reaching into this component's internals. */
|
|
82
|
+
onStateChange?: (state: {
|
|
83
|
+
phase: 'idle' | 'recording' | 'processing' | 'error'
|
|
84
|
+
kind: MediaCaptureKind | null
|
|
85
|
+
elapsedSeconds: number
|
|
86
|
+
}) => void
|
|
54
87
|
class?: string
|
|
55
88
|
}
|
|
56
89
|
|
|
@@ -62,6 +95,10 @@
|
|
|
62
95
|
hint,
|
|
63
96
|
disabled = false,
|
|
64
97
|
compact = false,
|
|
98
|
+
modeLabels,
|
|
99
|
+
addAffordance = false,
|
|
100
|
+
screenIndicator = true,
|
|
101
|
+
onStateChange,
|
|
65
102
|
class: className = ''
|
|
66
103
|
}: Props = $props()
|
|
67
104
|
|
|
@@ -71,6 +108,10 @@
|
|
|
71
108
|
let errorMessage = $state('')
|
|
72
109
|
let elapsedSec = $state(0)
|
|
73
110
|
|
|
111
|
+
$effect(() => {
|
|
112
|
+
onStateChange?.({ phase, kind: activeKind, elapsedSeconds: elapsedSec })
|
|
113
|
+
})
|
|
114
|
+
|
|
74
115
|
let recorder: MediaRecorder | null = null
|
|
75
116
|
let primaryStream: MediaStream | null = null
|
|
76
117
|
let secondaryStream: MediaStream | null = null
|
|
@@ -94,6 +135,11 @@
|
|
|
94
135
|
const showAudio = $derived(modes.includes('audio') && hasUserMedia)
|
|
95
136
|
const showVideo = $derived(modes.includes('video') && hasUserMedia)
|
|
96
137
|
const showScreen = $derived(modes.includes('screen') && hasDisplayMedia)
|
|
138
|
+
const offeredModes = $derived(
|
|
139
|
+
(['audio', 'video', 'screen'] as MediaCaptureKind[]).filter((k) =>
|
|
140
|
+
k === 'audio' ? showAudio : k === 'video' ? showVideo : showScreen
|
|
141
|
+
)
|
|
142
|
+
)
|
|
97
143
|
|
|
98
144
|
function pickMime(candidates: string[]): string {
|
|
99
145
|
for (const c of candidates) {
|
|
@@ -106,6 +152,14 @@
|
|
|
106
152
|
stream?.getTracks().forEach((t) => t.stop())
|
|
107
153
|
}
|
|
108
154
|
|
|
155
|
+
/** Recompute elapsed from the wall clock rather than incrementing a counter,
|
|
156
|
+
* so a tick that never fired (throttled tab) can never make the display
|
|
157
|
+
* lag the actual recording. */
|
|
158
|
+
function tick() {
|
|
159
|
+
if (!startedAt) return
|
|
160
|
+
elapsedSec = Math.floor((performance.now() - startedAt) / 1000)
|
|
161
|
+
}
|
|
162
|
+
|
|
109
163
|
function cleanup() {
|
|
110
164
|
stopAllTracks(primaryStream)
|
|
111
165
|
stopAllTracks(secondaryStream)
|
|
@@ -127,6 +181,10 @@
|
|
|
127
181
|
clearInterval(tickTimer)
|
|
128
182
|
tickTimer = null
|
|
129
183
|
}
|
|
184
|
+
if (typeof document !== 'undefined') {
|
|
185
|
+
document.removeEventListener('visibilitychange', tick)
|
|
186
|
+
}
|
|
187
|
+
startedAt = 0
|
|
130
188
|
if (videoPreviewEl) videoPreviewEl.srcObject = null
|
|
131
189
|
}
|
|
132
190
|
|
|
@@ -166,30 +224,46 @@
|
|
|
166
224
|
} catch {
|
|
167
225
|
secondaryStream = null
|
|
168
226
|
}
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
227
|
+
const displayAudio = primaryStream.getAudioTracks()
|
|
228
|
+
const micAudio = secondaryStream?.getAudioTracks() ?? []
|
|
229
|
+
const hasLiveAudio = displayAudio.length > 0 || micAudio.length > 0
|
|
230
|
+
|
|
231
|
+
// Only build the mixed audio track when there is a REAL source to
|
|
232
|
+
// feed it. A `MediaStreamAudioDestinationNode` with nothing
|
|
233
|
+
// connected — no display audio (a window share, or a tab with no
|
|
234
|
+
// sound) and no mic (denied, or blocked at the Permissions-Policy
|
|
235
|
+
// layer) — still produces a "live" track, but one that carries no
|
|
236
|
+
// samples at all. Pairing that dead track with the video track in
|
|
237
|
+
// one MediaRecorder stalls the muxer: every `ondataavailable` fires
|
|
238
|
+
// with `size === 0`, the whole clip comes back empty, and the
|
|
239
|
+
// caller's `blob.size === 0` guard silently drops it — a clean
|
|
240
|
+
// "recording…" UI that attaches nothing on Stop, no error shown
|
|
241
|
+
// (mos-oq8b1.27). Recording video-only when there is no audio
|
|
242
|
+
// avoids ever constructing that dead track.
|
|
243
|
+
let audioTracks: MediaStreamTrack[] = []
|
|
244
|
+
if (hasLiveAudio) {
|
|
245
|
+
const AudioCtxCls =
|
|
246
|
+
window.AudioContext ||
|
|
247
|
+
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
|
248
|
+
.webkitAudioContext
|
|
249
|
+
audioCtx = new AudioCtxCls()
|
|
250
|
+
const dest = audioCtx.createMediaStreamDestination()
|
|
251
|
+
for (const tracks of [displayAudio, micAudio]) {
|
|
252
|
+
if (tracks.length) {
|
|
253
|
+
audioCtx.createMediaStreamSource(new MediaStream(tracks)).connect(dest)
|
|
254
|
+
}
|
|
179
255
|
}
|
|
256
|
+
audioTracks = dest.stream.getAudioTracks()
|
|
180
257
|
}
|
|
181
|
-
stream = new MediaStream([
|
|
182
|
-
...primaryStream.getVideoTracks(),
|
|
183
|
-
...dest.stream.getAudioTracks()
|
|
184
|
-
])
|
|
258
|
+
stream = new MediaStream([...primaryStream.getVideoTracks(), ...audioTracks])
|
|
185
259
|
primaryStream.getVideoTracks()[0]?.addEventListener('ended', () => {
|
|
186
260
|
if (phase === 'recording') stop()
|
|
187
261
|
})
|
|
188
|
-
mimeUsed = pickMime(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
262
|
+
mimeUsed = pickMime(
|
|
263
|
+
hasLiveAudio
|
|
264
|
+
? ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm']
|
|
265
|
+
: ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
|
|
266
|
+
)
|
|
193
267
|
}
|
|
194
268
|
recordedStream = stream
|
|
195
269
|
|
|
@@ -202,6 +276,13 @@
|
|
|
202
276
|
const rec = new MediaRecorder(stream, mimeUsed ? { mimeType: mimeUsed } : undefined)
|
|
203
277
|
rec.ondataavailable = (e) => {
|
|
204
278
|
if (e.data.size > 0) chunks.push(e.data)
|
|
279
|
+
// The authoritative tick. `screen` captures are usually started
|
|
280
|
+
// against ANOTHER window or tab, which leaves this page hidden —
|
|
281
|
+
// and a hidden page's setInterval is throttled to roughly once a
|
|
282
|
+
// minute, which is exactly why the UAT screen timer sat frozen at
|
|
283
|
+
// 0:12. MediaRecorder keeps firing this at the timeslice interval
|
|
284
|
+
// regardless of page visibility, so the clock stays live.
|
|
285
|
+
tick()
|
|
205
286
|
}
|
|
206
287
|
rec.onstop = () => void finish(kind)
|
|
207
288
|
rec.start(250)
|
|
@@ -210,9 +291,13 @@
|
|
|
210
291
|
elapsedSec = 0
|
|
211
292
|
activeKind = kind
|
|
212
293
|
phase = 'recording'
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
294
|
+
// Belt: the interval covers the idle gap between timeslices while the
|
|
295
|
+
// page is visible. Braces: a visibility flip resyncs immediately, so
|
|
296
|
+
// returning to a backgrounded tab never shows a stale number.
|
|
297
|
+
tickTimer = setInterval(tick, 500)
|
|
298
|
+
if (typeof document !== 'undefined') {
|
|
299
|
+
document.addEventListener('visibilitychange', tick)
|
|
300
|
+
}
|
|
216
301
|
autoStopTimer = setTimeout(stop, maxDurationMs)
|
|
217
302
|
} catch {
|
|
218
303
|
cleanup()
|
|
@@ -253,12 +338,21 @@
|
|
|
253
338
|
}
|
|
254
339
|
}
|
|
255
340
|
|
|
256
|
-
function modeIcon(kind: MediaCaptureKind):
|
|
341
|
+
function modeIcon(kind: MediaCaptureKind): 'mic' | 'video' | 'monitor' {
|
|
257
342
|
return kind === 'audio' ? 'mic' : kind === 'video' ? 'video' : 'monitor'
|
|
258
343
|
}
|
|
259
344
|
|
|
345
|
+
const DEFAULT_MODE_LABELS: Record<MediaCaptureKind, string> = {
|
|
346
|
+
audio: 'Voice',
|
|
347
|
+
video: 'Video',
|
|
348
|
+
// "Screen" says what it captures. "Show us" said what to do with it and
|
|
349
|
+
// left people guessing (mos-ow4q4.37); hosts that preferred the old
|
|
350
|
+
// wording can pass it back through `modeLabels`.
|
|
351
|
+
screen: 'Screen'
|
|
352
|
+
}
|
|
353
|
+
|
|
260
354
|
function modeLabel(kind: MediaCaptureKind): string {
|
|
261
|
-
return kind
|
|
355
|
+
return modeLabels?.[kind] ?? DEFAULT_MODE_LABELS[kind]
|
|
262
356
|
}
|
|
263
357
|
|
|
264
358
|
function formatElapsed(sec: number): string {
|
|
@@ -304,36 +398,20 @@
|
|
|
304
398
|
</div>
|
|
305
399
|
{:else}
|
|
306
400
|
<div class="flex flex-wrap gap-2">
|
|
307
|
-
{#
|
|
308
|
-
<button
|
|
309
|
-
type="button"
|
|
310
|
-
class="btn btn-outline btn-xs rounded-field gap-1"
|
|
311
|
-
{disabled}
|
|
312
|
-
onclick={() => start('audio')}
|
|
313
|
-
>
|
|
314
|
-
<MediaCaptureIcon name="mic" size={12} /> {modeLabel('audio')}
|
|
315
|
-
</button>
|
|
316
|
-
{/if}
|
|
317
|
-
{#if showVideo}
|
|
318
|
-
<button
|
|
319
|
-
type="button"
|
|
320
|
-
class="btn btn-outline btn-xs rounded-field gap-1"
|
|
321
|
-
{disabled}
|
|
322
|
-
onclick={() => start('video')}
|
|
323
|
-
>
|
|
324
|
-
<MediaCaptureIcon name="video" size={12} /> {modeLabel('video')}
|
|
325
|
-
</button>
|
|
326
|
-
{/if}
|
|
327
|
-
{#if showScreen}
|
|
401
|
+
{#each offeredModes as kind (kind)}
|
|
328
402
|
<button
|
|
329
403
|
type="button"
|
|
330
404
|
class="btn btn-outline btn-xs rounded-field gap-1"
|
|
331
405
|
{disabled}
|
|
332
|
-
onclick={() => start(
|
|
406
|
+
onclick={() => start(kind)}
|
|
333
407
|
>
|
|
334
|
-
|
|
408
|
+
{#if addAffordance}
|
|
409
|
+
<MediaCaptureIcon name="plus" size={12} />
|
|
410
|
+
{/if}
|
|
411
|
+
<MediaCaptureIcon name={modeIcon(kind)} size={12} />
|
|
412
|
+
{modeLabel(kind)}
|
|
335
413
|
</button>
|
|
336
|
-
{/
|
|
414
|
+
{/each}
|
|
337
415
|
</div>
|
|
338
416
|
{#if phase === 'error' && errorMessage}
|
|
339
417
|
<p class="text-warning mt-1.5 text-xs" role="status" aria-live="polite">
|
|
@@ -342,9 +420,48 @@
|
|
|
342
420
|
{/if}
|
|
343
421
|
{/if}
|
|
344
422
|
</div>
|
|
423
|
+
|
|
424
|
+
<!--
|
|
425
|
+
In-page proof that the screen is being captured. The browser's own
|
|
426
|
+
"sharing your screen" bar is browser chrome, not page content — it is
|
|
427
|
+
absent when the user is looking at the shared window, and it never says
|
|
428
|
+
how long this particular recording has been running. Fixed-position so it
|
|
429
|
+
survives scrolling, and it carries its own Stop so the user never has to
|
|
430
|
+
hunt back up the page for the control.
|
|
431
|
+
-->
|
|
432
|
+
{#if screenIndicator && phase === 'recording' && activeKind === 'screen'}
|
|
433
|
+
<div class="media-capture-screen-banner" role="status" aria-live="polite">
|
|
434
|
+
<span class="media-capture-dot"></span>
|
|
435
|
+
<MediaCaptureIcon name="monitor" size={14} />
|
|
436
|
+
<span class="text-xs font-medium">
|
|
437
|
+
Recording your screen — {formatElapsed(elapsedSec)}
|
|
438
|
+
</span>
|
|
439
|
+
<button type="button" class="btn btn-error btn-xs rounded-field gap-1" onclick={stop}>
|
|
440
|
+
<MediaCaptureIcon name="square" size={12} /> Stop
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
{/if}
|
|
345
444
|
{/if}
|
|
346
445
|
|
|
347
446
|
<style>
|
|
447
|
+
.media-capture-screen-banner {
|
|
448
|
+
position: fixed;
|
|
449
|
+
top: 0.75rem;
|
|
450
|
+
left: 50%;
|
|
451
|
+
transform: translateX(-50%);
|
|
452
|
+
z-index: 9999;
|
|
453
|
+
display: flex;
|
|
454
|
+
align-items: center;
|
|
455
|
+
gap: 0.5rem;
|
|
456
|
+
padding: 0.4rem 0.75rem;
|
|
457
|
+
border-radius: var(--radius-box, 0.5rem);
|
|
458
|
+
border: 1px solid color-mix(in oklch, var(--color-error) 45%, transparent);
|
|
459
|
+
background: var(--color-base-100);
|
|
460
|
+
color: var(--color-base-content);
|
|
461
|
+
box-shadow: 0 4px 16px color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
462
|
+
max-width: calc(100vw - 1.5rem);
|
|
463
|
+
}
|
|
464
|
+
|
|
348
465
|
.media-capture-dot {
|
|
349
466
|
display: inline-block;
|
|
350
467
|
width: 0.5rem;
|
|
@@ -365,7 +482,25 @@
|
|
|
365
482
|
}
|
|
366
483
|
}
|
|
367
484
|
@media (prefers-reduced-motion: reduce) {
|
|
368
|
-
.media-capture-
|
|
485
|
+
.media-capture-screen-banner {
|
|
486
|
+
position: fixed;
|
|
487
|
+
top: 0.75rem;
|
|
488
|
+
left: 50%;
|
|
489
|
+
transform: translateX(-50%);
|
|
490
|
+
z-index: 9999;
|
|
491
|
+
display: flex;
|
|
492
|
+
align-items: center;
|
|
493
|
+
gap: 0.5rem;
|
|
494
|
+
padding: 0.4rem 0.75rem;
|
|
495
|
+
border-radius: var(--radius-box, 0.5rem);
|
|
496
|
+
border: 1px solid color-mix(in oklch, var(--color-error) 45%, transparent);
|
|
497
|
+
background: var(--color-base-100);
|
|
498
|
+
color: var(--color-base-content);
|
|
499
|
+
box-shadow: 0 4px 16px color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
500
|
+
max-width: calc(100vw - 1.5rem);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.media-capture-dot {
|
|
369
504
|
animation: none;
|
|
370
505
|
}
|
|
371
506
|
}
|
|
@@ -20,6 +20,28 @@ interface Props {
|
|
|
20
20
|
/** Smaller footprint (icon-only mode buttons) for embedding inside a
|
|
21
21
|
* larger composite field like MultimodalCaptureField. */
|
|
22
22
|
compact?: boolean;
|
|
23
|
+
/** Override the per-mode button label. Hosts that frame recording as
|
|
24
|
+
* "add another response" want their own wording ("Voice" vs "Add voice"),
|
|
25
|
+
* and the screen mode in particular has been called both "Screen" and
|
|
26
|
+
* "Show us" in different products. */
|
|
27
|
+
modeLabels?: Partial<Record<MediaCaptureKind, string>>;
|
|
28
|
+
/** Prefix each mode button with a `+`, framing the modes as
|
|
29
|
+
* add-affordances on a field that accepts SEVERAL responses rather than
|
|
30
|
+
* as a one-shot record button. */
|
|
31
|
+
addAffordance?: boolean;
|
|
32
|
+
/** Render a fixed, page-level "screen sharing is active" banner while a
|
|
33
|
+
* `screen` capture runs. The browser's own sharing chrome lives outside
|
|
34
|
+
* the page, so without this the page itself gives no sign it is being
|
|
35
|
+
* recorded — the single most-reported confusion in UAT. */
|
|
36
|
+
screenIndicator?: boolean;
|
|
37
|
+
/** Observe the capture state machine (phase / kind / elapsed). Lets a
|
|
38
|
+
* host disable competing controls or mirror the recording state without
|
|
39
|
+
* reaching into this component's internals. */
|
|
40
|
+
onStateChange?: (state: {
|
|
41
|
+
phase: 'idle' | 'recording' | 'processing' | 'error';
|
|
42
|
+
kind: MediaCaptureKind | null;
|
|
43
|
+
elapsedSeconds: number;
|
|
44
|
+
}) => void;
|
|
23
45
|
class?: string;
|
|
24
46
|
}
|
|
25
47
|
declare const MediaCapture: import("svelte").Component<Props, {}, "">;
|
|
@@ -10,7 +10,17 @@
|
|
|
10
10
|
size = 14,
|
|
11
11
|
class: className = ''
|
|
12
12
|
}: {
|
|
13
|
-
name:
|
|
13
|
+
name:
|
|
14
|
+
| 'mic'
|
|
15
|
+
| 'video'
|
|
16
|
+
| 'monitor'
|
|
17
|
+
| 'square'
|
|
18
|
+
| 'loader'
|
|
19
|
+
| 'x'
|
|
20
|
+
| 'file'
|
|
21
|
+
| 'trash'
|
|
22
|
+
| 'plus'
|
|
23
|
+
| 'type'
|
|
14
24
|
size?: number
|
|
15
25
|
class?: string
|
|
16
26
|
} = $props()
|
|
@@ -131,4 +141,35 @@
|
|
|
131
141
|
<path d="M3 6h18" />
|
|
132
142
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
133
143
|
</svg>
|
|
144
|
+
{:else if name === 'plus'}
|
|
145
|
+
<svg
|
|
146
|
+
width={size}
|
|
147
|
+
height={size}
|
|
148
|
+
viewBox="0 0 24 24"
|
|
149
|
+
fill="none"
|
|
150
|
+
stroke="currentColor"
|
|
151
|
+
stroke-width="2"
|
|
152
|
+
stroke-linecap="round"
|
|
153
|
+
stroke-linejoin="round"
|
|
154
|
+
class={className}
|
|
155
|
+
>
|
|
156
|
+
<path d="M5 12h14" />
|
|
157
|
+
<path d="M12 5v14" />
|
|
158
|
+
</svg>
|
|
159
|
+
{:else if name === 'type'}
|
|
160
|
+
<svg
|
|
161
|
+
width={size}
|
|
162
|
+
height={size}
|
|
163
|
+
viewBox="0 0 24 24"
|
|
164
|
+
fill="none"
|
|
165
|
+
stroke="currentColor"
|
|
166
|
+
stroke-width="2"
|
|
167
|
+
stroke-linecap="round"
|
|
168
|
+
stroke-linejoin="round"
|
|
169
|
+
class={className}
|
|
170
|
+
>
|
|
171
|
+
<path d="M4 7V4h16v3" />
|
|
172
|
+
<path d="M9 20h6" />
|
|
173
|
+
<path d="M12 4v16" />
|
|
174
|
+
</svg>
|
|
134
175
|
{/if}
|