@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 AudioCtxCls =
170
- window.AudioContext ||
171
- (window as unknown as { webkitAudioContext: typeof AudioContext })
172
- .webkitAudioContext
173
- audioCtx = new AudioCtxCls()
174
- const dest = audioCtx.createMediaStreamDestination()
175
- for (const s of [primaryStream, secondaryStream]) {
176
- const tracks = s?.getAudioTracks() ?? []
177
- if (tracks.length) {
178
- audioCtx.createMediaStreamSource(new MediaStream(tracks)).connect(dest)
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
- 'video/webm;codecs=vp9,opus',
190
- 'video/webm;codecs=vp8,opus',
191
- 'video/webm'
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
- tickTimer = setInterval(() => {
214
- elapsedSec = Math.floor((performance.now() - startedAt) / 1000)
215
- }, 500)
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): string {
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 === 'audio' ? 'Voice' : kind === 'video' ? 'Video' : 'Show us'
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
- {#if showAudio}
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('screen')}
406
+ onclick={() => start(kind)}
333
407
  >
334
- <MediaCaptureIcon name="monitor" size={12} /> {modeLabel('screen')}
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
- {/if}
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-dot {
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: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash'
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}
@@ -1,5 +1,5 @@
1
1
  type $$ComponentProps = {
2
- name: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash';
2
+ name: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash' | 'plus' | 'type';
3
3
  size?: number;
4
4
  class?: string;
5
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",