@joewinke/jatui 0.32.0 → 0.33.0

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 CHANGED
@@ -104,7 +104,7 @@ Requires peer `@xyflow/svelte` (install it — the IDE's build breaks on `import
104
104
  | `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `addable`+`onAdd`, `onSelect`, `cell` snippet |
105
105
  | `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
106
106
  | `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
107
- | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `T` toggle) and talk-time legend. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
107
+ | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
108
108
  | `MediaCapture` | One-tap audio/selfie-video/screen(+mic) recording — a host-agnostic PRIMITIVE. Owns permission requests, MIME negotiation, the record/stop state machine, live self-preview, and mixes screen+mic audio into one track via AudioContext. Never uploads anything — hands the finished clip to `onCapture` as a Blob and the host decides what to do with it. `onCapture` may be async; the component shows "processing" until it resolves. | `onCapture: (result: MediaCaptureResult) => void \| Promise<void>`, `modes?` (default all three), `maxDurationMs?` (default 120000), `label?`, `hint?`, `compact?`, `disabled?` |
109
109
  | `MultimodalCaptureField` | "Upload a file, or record yourself going over it" as one input surface — composes `FileDropzone` (capped to one file) with `MediaCapture`. File and recording are independent (either, both, or neither); neither half uploads anything, same "validate and hand back" contract as `FileDropzone`. | `onFileSelected?`, `onFileRemoved?`, `onCapture?`, `onRecordingRemoved?`, `accept?`, `maxFileSize?`, `modes?`, `maxDurationMs?`, `file?`/`fileUrl?`/`fileName?`, `recordingSummary?` |
110
110
 
@@ -251,6 +251,8 @@
251
251
 
252
252
  let {
253
253
  src,
254
+ kind = 'video',
255
+ transport: transportProp = undefined,
254
256
  poster = null,
255
257
  aspect = '16:9',
256
258
  autoplay = false,
@@ -339,6 +341,23 @@
339
341
  class: className = ''
340
342
  }: {
341
343
  src: string;
344
+ /**
345
+ * What the media IS. `'audio'` (a podcast, a call recording, a brief)
346
+ * renders NO stage: there is no picture to frame, so the player is the
347
+ * band (the one timeline) plus its transport row. The media element
348
+ * still plays, just never shows. `aspect`/`poster` are ignored. Default
349
+ * `'video'`.
350
+ */
351
+ kind?: 'video' | 'audio';
352
+ /**
353
+ * Who owns play/pause, time and volume. `'native'` keeps the browser's
354
+ * `<video controls>` bar (which carries its own timeline next to the
355
+ * band). `'band'` drops the native controls and puts a transport row
356
+ * on the band instead: play/pause, `current / total`, speed, mute +
357
+ * volume, and fullscreen for video. One timeline, one set of controls.
358
+ * Default: `'band'` for `kind="audio"`, `'native'` for video.
359
+ */
360
+ transport?: 'native' | 'band';
342
361
  poster?: string | null;
343
362
  /**
344
363
  * Aspect ratio for the video stage — presentation-primitive prop (PRD).
@@ -723,6 +742,53 @@
723
742
  let currentTime = $state(0);
724
743
  let duration = $state(0);
725
744
 
745
+ // ── Band transport (kind="audio" / transport="band") ────────────────────
746
+ // Audio has no picture, so no stage; the band is the only timeline and
747
+ // the transport row under it carries play/pause, time, speed and volume.
748
+ const isAudio = $derived(kind === 'audio');
749
+ const bandTransport = $derived(isAudio || transportProp === 'band');
750
+ let paused = $state(true);
751
+ let volumeLevel = $state(1);
752
+ let mutedNow = $state(false);
753
+ let isFullscreen = $state(false);
754
+ function syncVolume() {
755
+ if (!videoEl) return;
756
+ volumeLevel = videoEl.volume;
757
+ mutedNow = videoEl.muted;
758
+ }
759
+ function togglePlay() {
760
+ const v = videoEl;
761
+ if (!v) return;
762
+ if (v.paused) void v.play().catch(() => {});
763
+ else v.pause();
764
+ }
765
+ function toggleVolumeMute() {
766
+ const v = videoEl;
767
+ if (!v) return;
768
+ v.muted = !v.muted;
769
+ if (!v.muted && v.volume === 0) v.volume = 0.5;
770
+ }
771
+ function onVolumeInput(e: Event) {
772
+ const v = videoEl;
773
+ if (!v) return;
774
+ const next = Number((e.currentTarget as HTMLInputElement).value);
775
+ v.volume = Math.min(1, Math.max(0, next));
776
+ v.muted = next === 0;
777
+ }
778
+ function toggleFullscreen() {
779
+ if (typeof document === 'undefined') return;
780
+ if (document.fullscreenElement) void document.exitFullscreen?.();
781
+ else void stageWrapEl?.requestFullscreen?.();
782
+ }
783
+ /** The speed button: the listening speeds people actually pick, in
784
+ * order, wrapping back to 1×. `-`/`=` still walk the fine staircase. */
785
+ const SPEED_CYCLE = [1, 1.25, 1.5, 1.75, 2];
786
+ function cycleSpeed() {
787
+ baseRate = SPEED_CYCLE.find((s) => s > baseRate + 0.001) ?? 1;
788
+ applyRate();
789
+ flashOsd(formatSpeedLabel(baseRate));
790
+ }
791
+
726
792
  // Canvas box, tracked so a resize (panel toggle, window resize) repaints.
727
793
  let canvasW = $state(0);
728
794
  let canvasH = $state(0);
@@ -1059,6 +1125,9 @@
1059
1125
  const speakerIndex = $derived(buildSpeakerIndex(sortedTurns, sortedSegments));
1060
1126
 
1061
1127
  const hasSpeakers = $derived(speakerIndex.size > 0);
1128
+ /** Two or more voices: only then do talk time, solo and skip mean
1129
+ * anything, and only then does a transcript row need its speaker name. */
1130
+ const multiSpeaker = $derived(speakerIndex.size > 1);
1062
1131
 
1063
1132
  /**
1064
1133
  * Theme-derived speaker identity: colours + the fallback cycle in one read
@@ -2614,6 +2683,9 @@
2614
2683
  const target = e.target as HTMLElement | null;
2615
2684
  const tag = target?.tagName;
2616
2685
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
2686
+ // Space/Enter on a focused button activates THAT button (play, a
2687
+ // speaker chip, a transcript row); toggling playback too would undo it.
2688
+ if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
2617
2689
  // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
2618
2690
  // activation) while focus is inside it; without this guard a rail `k`
2619
2691
  // would also toggle play/pause and a rail `j` also seek ±5s.
@@ -2874,12 +2946,14 @@
2874
2946
  class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
2875
2947
  class:vp-rail-side={railOn}
2876
2948
  class:vp-one-axis={nativeBarHidden}
2949
+ class:vp-band-transport={bandTransport}
2950
+ class:vp-audio={isAudio}
2877
2951
  class:vp-night-capable={nightPresent}
2878
2952
  class:vp-night-surround={surroundMix > 0.001}
2879
2953
  style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
2880
2954
  data-band-axis={currentBandState.axis}
2881
2955
  role="region"
2882
- aria-label="Video player"
2956
+ aria-label={isAudio ? 'Audio player' : 'Video player'}
2883
2957
  tabindex="-1"
2884
2958
  onkeydown={onRootKeyDown}
2885
2959
  onpointerdowncapture={maybeAskIdle}
@@ -2888,7 +2962,12 @@
2888
2962
  onkeydowncapture={maybeAskIdle}
2889
2963
  >
2890
2964
  <div class="vp-main">
2891
- <div class="vp-stage-wrap" bind:this={stageWrapEl}>
2965
+ <div
2966
+ class="vp-stage-wrap"
2967
+ class:vp-stage-audio={isAudio}
2968
+ bind:this={stageWrapEl}
2969
+ onfullscreenchange={() => (isFullscreen = document.fullscreenElement === stageWrapEl)}
2970
+ >
2892
2971
  {#if nightPresent}
2893
2972
  <!-- Bias light: the frame's own colours, blurred, spilled behind the
2894
2973
  picture. Earlier in the DOM than the (positioned) stage, so the
@@ -2900,9 +2979,13 @@
2900
2979
  aria-hidden="true"
2901
2980
  ></div>
2902
2981
  {/if}
2903
- <!-- svelte-ignore a11y_no_static_element_interactions -->
2982
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
2983
+ <!-- With the band transport the native controls are gone, so a click
2984
+ on the picture plays/pauses (Space/K do the same from the keyboard). -->
2904
2985
  <div
2905
2986
  class="vp-stage"
2987
+ class:vp-stage-clickable={bandTransport && !isAudio}
2988
+ onclick={bandTransport && !isAudio ? togglePlay : undefined}
2906
2989
  class:vp-stage-locked={stageAspect != null}
2907
2990
  class:vp-night-capable={nightPresent || guardPresent}
2908
2991
  class:vp-night-on={nightFrame != null}
@@ -2919,6 +3002,8 @@
2919
3002
  bind:this={videoEl}
2920
3003
  bind:currentTime
2921
3004
  bind:duration
3005
+ bind:paused
3006
+ onvolumechange={syncVolume}
2922
3007
  ontimeupdate={handleTimeUpdate}
2923
3008
  onloadedmetadata={onLoadedMetadata}
2924
3009
  onseeked={() => {
@@ -2944,8 +3029,8 @@
2944
3029
  {loop}
2945
3030
  crossorigin={crossorigin ?? undefined}
2946
3031
  muted={effectiveAutoplay || undefined}
2947
- poster={poster ?? undefined}
2948
- controls
3032
+ poster={isAudio ? undefined : (poster ?? undefined)}
3033
+ controls={!bandTransport}
2949
3034
  playsinline
2950
3035
  class="vp-video"
2951
3036
  >
@@ -3070,6 +3155,10 @@
3070
3155
  </div>
3071
3156
 
3072
3157
  <div class="vp-zoom" class:on={zoomLabelOn} aria-hidden="true">{zoomLabel}</div>
3158
+ {#if isAudio}
3159
+ <!-- No stage to float the readout over: it rides the band. -->
3160
+ <div class="vp-osd vp-osd-band" class:on={osdOn} aria-hidden="true">{osd}</div>
3161
+ {/if}
3073
3162
 
3074
3163
  {#if hover}
3075
3164
  <div
@@ -3099,8 +3188,7 @@
3099
3188
  {/if}
3100
3189
  </div>
3101
3190
 
3102
- <div class="vp-times">
3103
- <span>{formatTime(currentTime, useHours)}</span>
3191
+ {#snippet timesMid()}
3104
3192
  <span class="vp-times-mid">
3105
3193
  {#if isZoomedNow()}
3106
3194
  <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
@@ -3145,28 +3233,118 @@
3145
3233
  </button>
3146
3234
  {/if}
3147
3235
  </span>
3148
- <span>{formatTime(effectiveDuration, useHours)}</span>
3149
- </div>
3236
+ {/snippet}
3237
+
3238
+ {#if bandTransport}
3239
+ <!-- The band transport: the band above is the one timeline; this row
3240
+ carries everything the native bar used to (play/pause, time,
3241
+ speed, volume, fullscreen for video). -->
3242
+ <div class="vp-times vp-transport">
3243
+ <span class="vp-transport-start">
3244
+ <button
3245
+ type="button"
3246
+ class="vp-play"
3247
+ aria-label={paused ? 'Play' : 'Pause'}
3248
+ title="{paused ? 'Play' : 'Pause'} · Space"
3249
+ onclick={togglePlay}
3250
+ >
3251
+ {#if paused}
3252
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
3253
+ <path d="M8 5.5v13l10.5-6.5z" fill="currentColor" />
3254
+ </svg>
3255
+ {:else}
3256
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
3257
+ <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
3258
+ </svg>
3259
+ {/if}
3260
+ </button>
3261
+ <span class="vp-clock">
3262
+ <span class="vp-clock-now">{formatTime(currentTime, useHours)}</span>
3263
+ <span class="vp-clock-sep" aria-hidden="true">/</span>
3264
+ <span class="vp-sr">of</span>
3265
+ <span>{formatTime(effectiveDuration, useHours)}</span>
3266
+ </span>
3267
+ </span>
3268
+ {@render timesMid()}
3269
+ <span class="vp-transport-end">
3270
+ <button
3271
+ type="button"
3272
+ class="vp-tbtn vp-speed"
3273
+ aria-label="Playback speed {formatSpeedLabel(baseRate)}"
3274
+ title="Speed · click to change, - and = step"
3275
+ onclick={cycleSpeed}
3276
+ >
3277
+ {formatSpeedLabel(baseRate)}
3278
+ </button>
3279
+ <button
3280
+ type="button"
3281
+ class="vp-tbtn"
3282
+ aria-label={mutedNow || volumeLevel === 0 ? 'Unmute' : 'Mute'}
3283
+ aria-pressed={mutedNow}
3284
+ title="{mutedNow ? 'Unmute' : 'Mute'} · M"
3285
+ onclick={toggleVolumeMute}
3286
+ >
3287
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3288
+ <path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
3289
+ {#if mutedNow || volumeLevel === 0}
3290
+ <path d="M16 9.5l5 5M21 9.5l-5 5" />
3291
+ {:else}
3292
+ <path d="M15.5 9a4 4 0 0 1 0 6" />
3293
+ {#if volumeLevel > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
3294
+ {/if}
3295
+ </svg>
3296
+ </button>
3297
+ <input
3298
+ type="range"
3299
+ class="vp-volume"
3300
+ min="0"
3301
+ max="1"
3302
+ step="0.05"
3303
+ value={mutedNow ? 0 : volumeLevel}
3304
+ aria-label="Volume"
3305
+ aria-valuetext="{Math.round((mutedNow ? 0 : volumeLevel) * 100)}%"
3306
+ oninput={onVolumeInput}
3307
+ />
3308
+ {#if !isAudio}
3309
+ <button
3310
+ type="button"
3311
+ class="vp-tbtn"
3312
+ aria-label={isFullscreen ? 'Exit full screen' : 'Full screen'}
3313
+ title={isFullscreen ? 'Exit full screen' : 'Full screen'}
3314
+ onclick={toggleFullscreen}
3315
+ >
3316
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3317
+ {#if isFullscreen}
3318
+ <path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
3319
+ {:else}
3320
+ <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
3321
+ {/if}
3322
+ </svg>
3323
+ </button>
3324
+ {/if}
3325
+ </span>
3326
+ </div>
3327
+ {:else}
3328
+ <div class="vp-times">
3329
+ <span>{formatTime(currentTime, useHours)}</span>
3330
+ {@render timesMid()}
3331
+ <span>{formatTime(effectiveDuration, useHours)}</span>
3332
+ </div>
3333
+ {/if}
3150
3334
 
3151
- {#if hasSpeakers}
3335
+ {#if multiSpeaker}
3152
3336
  <!-- Talk-time doubles as the speaker legend: hover a chip to raise that
3153
- speaker on the timeline, click to solo, and use its mute toggle to
3154
- skip them. Identification through brightness, never a glow. -->
3155
- <div class="vp-talktime">
3156
- <div class="vp-talktime-bar">
3157
- {#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
3158
- <div
3159
- class="vp-talktime-seg"
3160
- style="width: {(tt.seconds / talkTimeTotal) * 100}%; background: {speakerColor(
3161
- talkTimeKey(tt)
3162
- )}"
3163
- title="{tt.speakerName ?? 'Unknown speaker'} — {formatDuration(tt.seconds)}"
3164
- ></div>
3165
- {/each}
3166
- </div>
3337
+ speaker on the timeline, click to solo, and use its skip toggle to
3338
+ skip them. Identification through brightness, never a glow. Only
3339
+ with 2+ speakers: one voice has nothing to compare, solo or skip,
3340
+ and its talk time reads as a second, different duration. Labelled
3341
+ "Talk time" so a chip's seconds never read as the media's length. -->
3342
+ <div class="vp-talktime" role="group" aria-label="Talk time by speaker">
3343
+ <span class="vp-talktime-label" aria-hidden="true">Talk time</span>
3167
3344
  <div class="vp-legend">
3168
3345
  {#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
3169
3346
  {@const key = talkTimeKey(tt)}
3347
+ {@const name = tt.speakerName ?? 'Unknown speaker'}
3170
3348
  <span class="vp-legend-item">
3171
3349
  <button
3172
3350
  type="button"
@@ -3179,10 +3357,11 @@
3179
3357
  onblur={() => (highlightKey = null)}
3180
3358
  onclick={() => toggleSolo(key)}
3181
3359
  aria-pressed={soloKey === key}
3182
- title="Solo {tt.speakerName ?? 'Unknown speaker'}"
3360
+ aria-label="{name}, talked {formatDuration(tt.seconds)}. Play only {name}"
3361
+ title="Play only {name}"
3183
3362
  >
3184
3363
  <span class="vp-chip-dot" style="background: {speakerColor(key)}"></span>
3185
- {tt.speakerName ?? 'Unknown speaker'}
3364
+ {name}
3186
3365
  <span class="vp-chip-time">{formatDuration(tt.seconds)}</span>
3187
3366
  </button>
3188
3367
  <button
@@ -3191,11 +3370,12 @@
3191
3370
  class:on={mutedKeys.has(key)}
3192
3371
  onclick={() => toggleMute(key)}
3193
3372
  aria-pressed={mutedKeys.has(key)}
3194
- aria-label="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} {tt.speakerName ??
3195
- 'Unknown speaker'}"
3196
- title="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} this speaker"
3373
+ aria-label="Skip {name}"
3374
+ title="{mutedKeys.has(key) ? 'Stop skipping' : 'Skip'} {name}"
3197
3375
  >
3198
- {mutedKeys.has(key) ? '🔇' : '🔈'}
3376
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3377
+ <path d="M5 6l8 6-8 6zM18 6v12" />
3378
+ </svg>
3199
3379
  </button>
3200
3380
  </span>
3201
3381
  {/each}
@@ -3208,7 +3388,7 @@
3208
3388
  mutedKeys = new Set();
3209
3389
  }}
3210
3390
  >
3211
- Clear filter
3391
+ Show all speakers
3212
3392
  </button>
3213
3393
  {/if}
3214
3394
  </div>
@@ -3320,6 +3500,7 @@
3320
3500
  <button
3321
3501
  type="button"
3322
3502
  class="vp-row"
3503
+ class:vp-row-one={!multiSpeaker}
3323
3504
  class:active={activeSegment?.id === seg.id}
3324
3505
  class:vp-dim={highlightKey != null
3325
3506
  ? highlightKey !== speakerKeyOf(seg)
@@ -3328,9 +3509,11 @@
3328
3509
  onclick={() => seekTo(seg.startSec)}
3329
3510
  >
3330
3511
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
3331
- <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3332
- {seg.speakerName ?? 'Unknown speaker'}
3333
- </span>
3512
+ {#if multiSpeaker}
3513
+ <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3514
+ {seg.speakerName ?? 'Unknown speaker'}
3515
+ </span>
3516
+ {/if}
3334
3517
  <span class="vp-row-text">{seg.text}</span>
3335
3518
  </button>
3336
3519
  {/each}
@@ -3340,6 +3523,7 @@
3340
3523
  <button
3341
3524
  type="button"
3342
3525
  class="vp-row"
3526
+ class:vp-row-one={!multiSpeaker}
3343
3527
  class:active={activeSegment?.id === seg.id}
3344
3528
  class:vp-dim={highlightKey != null
3345
3529
  ? highlightKey !== speakerKeyOf(seg)
@@ -3348,9 +3532,11 @@
3348
3532
  onclick={() => seekTo(seg.startSec)}
3349
3533
  >
3350
3534
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
3351
- <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3352
- {seg.speakerName ?? 'Unknown speaker'}
3353
- </span>
3535
+ {#if multiSpeaker}
3536
+ <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
3537
+ {seg.speakerName ?? 'Unknown speaker'}
3538
+ </span>
3539
+ {/if}
3354
3540
  <span class="vp-row-text">{seg.text}</span>
3355
3541
  </button>
3356
3542
  {/each}
@@ -3465,6 +3651,7 @@
3465
3651
  position: relative;
3466
3652
  display: flex;
3467
3653
  flex-direction: column;
3654
+ flex-shrink: 0;
3468
3655
  gap: 2px;
3469
3656
  background: none;
3470
3657
  border: none;
@@ -3479,9 +3666,13 @@
3479
3666
  background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
3480
3667
  }
3481
3668
 
3669
+ /* Active = a tinted row plus a primary title; no side stripe. */
3482
3670
  .vp-chapter-row.active {
3483
- background: color-mix(in oklch, var(--color-primary) 14%, transparent);
3484
- box-shadow: inset 2px 0 0 var(--color-primary);
3671
+ background: color-mix(in oklch, var(--color-primary) 12%, transparent);
3672
+ }
3673
+
3674
+ .vp-chapter-row.active .vp-chapter-title {
3675
+ color: var(--color-primary);
3485
3676
  }
3486
3677
 
3487
3678
  /* listNav's cursor (j/k) — same family as the hover/active surfaces. */
@@ -3494,6 +3685,12 @@
3494
3685
  color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
3495
3686
  }
3496
3687
 
3688
+ /* The playhead's chapter reads as current even once its span is played
3689
+ (playback ran past the last tagged line). */
3690
+ .vp-chapter-row.done.active .vp-chapter-title {
3691
+ color: var(--color-primary);
3692
+ }
3693
+
3497
3694
  .vp-chapter-head {
3498
3695
  display: flex;
3499
3696
  align-items: baseline;
@@ -3608,6 +3805,7 @@
3608
3805
 
3609
3806
  .vp-chapter-row {
3610
3807
  flex: none;
3808
+ justify-content: center;
3611
3809
  max-width: 16rem;
3612
3810
  padding: var(--jatui-space-xs, 2px) var(--jatui-space-sm, 4px);
3613
3811
  }
@@ -3726,10 +3924,133 @@
3726
3924
  }
3727
3925
 
3728
3926
  .vp-root:hover .vp-band,
3729
- .vp-band:focus-within {
3927
+ .vp-band:focus-within,
3928
+ .vp-band-transport .vp-band {
3929
+ /* With the band transport the band is the ONLY timeline: never ambient. */
3730
3930
  opacity: 1;
3731
3931
  }
3732
3932
 
3933
+ /* Audio: no picture, so no stage at all. The element still plays. */
3934
+ .vp-stage-audio {
3935
+ display: none;
3936
+ }
3937
+
3938
+ .vp-stage-clickable {
3939
+ cursor: pointer;
3940
+ }
3941
+
3942
+ /* The OSD's band seat (audio has no stage to float it over). */
3943
+ .vp-osd-band {
3944
+ top: 0;
3945
+ left: 0;
3946
+ max-width: 100%;
3947
+ overflow: hidden;
3948
+ text-overflow: ellipsis;
3949
+ white-space: nowrap;
3950
+ z-index: 4;
3951
+ }
3952
+
3953
+ /* ── Band transport row ─────────────────────────────────────────────── */
3954
+ .vp-transport {
3955
+ font-size: 0.75rem;
3956
+ }
3957
+
3958
+ .vp-transport-start,
3959
+ .vp-transport-end {
3960
+ display: inline-flex;
3961
+ align-items: center;
3962
+ gap: var(--jatui-space-sm, 4px);
3963
+ min-width: 0;
3964
+ }
3965
+
3966
+ .vp-play {
3967
+ display: inline-grid;
3968
+ place-items: center;
3969
+ width: 2rem;
3970
+ height: 2rem;
3971
+ flex: none;
3972
+ border: none;
3973
+ border-radius: 999px;
3974
+ background: var(--color-primary);
3975
+ color: var(--color-primary-content);
3976
+ cursor: pointer;
3977
+ transition: transform var(--motion-fast, 150ms) var(--motion-glide, ease-out);
3978
+ }
3979
+
3980
+ .vp-play:hover {
3981
+ transform: scale(1.06);
3982
+ }
3983
+
3984
+ .vp-play:active {
3985
+ transform: scale(0.96);
3986
+ }
3987
+
3988
+ .vp-clock {
3989
+ display: inline-flex;
3990
+ gap: 0.3em;
3991
+ margin-left: var(--jatui-space-xs, 2px);
3992
+ white-space: nowrap;
3993
+ }
3994
+
3995
+ .vp-clock-now {
3996
+ color: var(--color-base-content);
3997
+ font-weight: 600;
3998
+ }
3999
+
4000
+ .vp-clock-sep {
4001
+ opacity: 0.5;
4002
+ }
4003
+
4004
+ .vp-tbtn {
4005
+ display: inline-grid;
4006
+ place-items: center;
4007
+ min-width: 1.75rem;
4008
+ height: 1.75rem;
4009
+ padding: 0 var(--jatui-space-xs, 2px);
4010
+ border: none;
4011
+ border-radius: var(--jatui-radius-field, 0.25rem);
4012
+ background: none;
4013
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
4014
+ font-size: inherit;
4015
+ font-variant-numeric: tabular-nums;
4016
+ cursor: pointer;
4017
+ }
4018
+
4019
+ .vp-tbtn:hover {
4020
+ color: var(--color-base-content);
4021
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
4022
+ }
4023
+
4024
+ .vp-play:focus-visible,
4025
+ .vp-tbtn:focus-visible,
4026
+ .vp-volume:focus-visible {
4027
+ outline: 2px solid var(--color-primary);
4028
+ outline-offset: 2px;
4029
+ }
4030
+
4031
+ .vp-speed {
4032
+ min-width: 2.5rem;
4033
+ }
4034
+
4035
+ .vp-volume {
4036
+ width: 4.5rem;
4037
+ accent-color: var(--color-primary);
4038
+ cursor: pointer;
4039
+ }
4040
+
4041
+ /* A fingertip sets volume with the device buttons; the slider is noise.
4042
+ The play button grows to a full 44px target (and stays round). */
4043
+ @media (pointer: coarse) {
4044
+ .vp-volume {
4045
+ display: none;
4046
+ }
4047
+
4048
+ .vp-play {
4049
+ width: 2.75rem;
4050
+ height: 2.75rem;
4051
+ }
4052
+ }
4053
+
3733
4054
  /* One time axis: while the band owns it, the native timeline stands down.
3734
4055
  Only Chromium exposes it; elsewhere this matches nothing and the native
3735
4056
  bar simply stays (see video/band/axis.ts). */
@@ -3949,20 +4270,14 @@
3949
4270
  /* Talk-time + legend */
3950
4271
  .vp-talktime {
3951
4272
  display: flex;
3952
- flex-direction: column;
3953
- gap: var(--jatui-space-sm, 4px);
3954
- }
3955
-
3956
- .vp-talktime-bar {
3957
- display: flex;
3958
- height: 0.6rem;
3959
- border-radius: 999px;
3960
- overflow: hidden;
3961
- background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
4273
+ flex-wrap: wrap;
4274
+ align-items: center;
4275
+ gap: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
3962
4276
  }
3963
4277
 
3964
- .vp-talktime-seg {
3965
- height: 100%;
4278
+ .vp-talktime-label {
4279
+ font-size: 0.68rem;
4280
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
3966
4281
  }
3967
4282
 
3968
4283
  .vp-legend {
@@ -3999,7 +4314,10 @@
3999
4314
  }
4000
4315
 
4001
4316
  .vp-chip-clear {
4317
+ background: none;
4002
4318
  color: var(--color-primary);
4319
+ text-decoration: underline;
4320
+ text-underline-offset: 2px;
4003
4321
  }
4004
4322
 
4005
4323
  .vp-chip-dot {
@@ -4015,18 +4333,31 @@
4015
4333
  }
4016
4334
 
4017
4335
  .vp-mute {
4336
+ display: inline-grid;
4337
+ place-items: center;
4338
+ width: 1.5rem;
4339
+ height: 1.5rem;
4018
4340
  background: none;
4019
4341
  border: none;
4020
- padding: 0 var(--jatui-space-xs, 2px);
4021
- font-size: 0.7rem;
4022
- line-height: 1;
4342
+ border-radius: var(--jatui-radius-field, 0.25rem);
4343
+ padding: 0;
4344
+ color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
4023
4345
  cursor: pointer;
4024
- opacity: 0.5;
4025
4346
  }
4026
4347
 
4027
- .vp-mute.on,
4028
4348
  .vp-mute:hover {
4029
- opacity: 1;
4349
+ color: var(--color-base-content);
4350
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
4351
+ }
4352
+
4353
+ .vp-mute.on {
4354
+ color: var(--color-primary);
4355
+ }
4356
+
4357
+ .vp-mute:focus-visible,
4358
+ .vp-chip:focus-visible {
4359
+ outline: 2px solid var(--color-primary);
4360
+ outline-offset: 1px;
4030
4361
  }
4031
4362
 
4032
4363
  /* Add-bookmark row — only rendered when the host wired onAddBookmark. */
@@ -4090,6 +4421,7 @@
4090
4421
  border-radius: var(--jatui-radius-box, 0.5rem);
4091
4422
  padding: var(--jatui-space-sm, 4px);
4092
4423
  min-width: 0;
4424
+ container: vp-transcript / inline-size;
4093
4425
  }
4094
4426
 
4095
4427
  /* Windowing stand-ins (mos-5lzns.95): fixed-height blocks sized by the
@@ -4100,11 +4432,21 @@
4100
4432
  pointer-events: none;
4101
4433
  }
4102
4434
 
4435
+ /* Rows are a three-column grid (time | speaker | text) so every line's
4436
+ text starts on one edge whatever the speaker's name length; one-voice
4437
+ transcripts drop the speaker column. A narrow pane (container query,
4438
+ not viewport: the player often sits in a narrow column) stacks the
4439
+ speaker above the text. */
4103
4440
  .vp-row {
4104
- display: flex;
4441
+ display: grid;
4442
+ grid-template-columns: 2.75rem minmax(0, 6.5rem) minmax(0, 1fr);
4105
4443
  align-items: baseline;
4106
- flex-wrap: wrap;
4107
- gap: var(--jatui-space-sm, 4px);
4444
+ /* Never shrink inside the capped pane: a host's touch min-height
4445
+ replaces min-height:auto, and the flex column would then squash
4446
+ rows to that floor and overlap their text. */
4447
+ flex-shrink: 0;
4448
+ column-gap: var(--jatui-space-base, 8px);
4449
+ row-gap: 0;
4108
4450
  background: none;
4109
4451
  border: none;
4110
4452
  border-radius: var(--jatui-radius-field, 0.25rem);
@@ -4119,8 +4461,11 @@
4119
4461
  }
4120
4462
 
4121
4463
  .vp-row.active {
4122
- background: color-mix(in oklch, var(--color-primary) 14%, transparent);
4123
- box-shadow: inset 2px 0 0 var(--color-primary);
4464
+ background: color-mix(in oklch, var(--color-primary) 12%, transparent);
4465
+ }
4466
+
4467
+ .vp-row.active .vp-row-time {
4468
+ color: var(--color-primary);
4124
4469
  }
4125
4470
 
4126
4471
  .vp-row-time {
@@ -4133,14 +4478,32 @@
4133
4478
  .vp-row-speaker {
4134
4479
  font-size: 0.72rem;
4135
4480
  font-weight: 600;
4136
- flex-shrink: 0;
4481
+ min-width: 0;
4482
+ overflow: hidden;
4483
+ text-overflow: ellipsis;
4484
+ white-space: nowrap;
4137
4485
  }
4138
4486
 
4139
4487
  .vp-row-text {
4140
- font-size: 0.76rem;
4488
+ font-size: 0.8125rem;
4489
+ line-height: 1.45;
4141
4490
  color: color-mix(in oklch, var(--color-base-content) 85%, transparent);
4142
- flex: 1;
4143
- min-width: 10rem;
4491
+ min-width: 0;
4492
+ }
4493
+
4494
+ .vp-row.vp-row-one {
4495
+ grid-template-columns: 2.75rem minmax(0, 1fr);
4496
+ }
4497
+
4498
+ @container vp-transcript (max-width: 26rem) {
4499
+ .vp-row {
4500
+ grid-template-columns: 2.75rem minmax(0, 1fr);
4501
+ }
4502
+
4503
+ .vp-row-speaker,
4504
+ .vp-row-text {
4505
+ grid-column: 2;
4506
+ }
4144
4507
  }
4145
4508
 
4146
4509
  /* Keyboard help overlay — the `?` surface. Keycaps first, description after;
@@ -4226,9 +4589,15 @@
4226
4589
  .vp-add-btn,
4227
4590
  .vp-row,
4228
4591
  .vp-chapter-row,
4229
- .vp-chapter-fill {
4592
+ .vp-chapter-fill,
4593
+ .vp-play {
4230
4594
  transition: none;
4231
4595
  }
4596
+
4597
+ .vp-play:hover,
4598
+ .vp-play:active {
4599
+ transform: none;
4600
+ }
4232
4601
  }
4233
4602
 
4234
4603
  /* ── Night (mos-5lzns.33) ─────────────────────────────────────────────
@@ -73,6 +73,23 @@ import type { ResumePoint } from '../video/advisors/resume-point';
73
73
  import { type SettingValue, type VideoSettingsStore } from '../video/settings';
74
74
  type $$ComponentProps = {
75
75
  src: string;
76
+ /**
77
+ * What the media IS. `'audio'` (a podcast, a call recording, a brief)
78
+ * renders NO stage: there is no picture to frame, so the player is the
79
+ * band (the one timeline) plus its transport row. The media element
80
+ * still plays, just never shows. `aspect`/`poster` are ignored. Default
81
+ * `'video'`.
82
+ */
83
+ kind?: 'video' | 'audio';
84
+ /**
85
+ * Who owns play/pause, time and volume. `'native'` keeps the browser's
86
+ * `<video controls>` bar (which carries its own timeline next to the
87
+ * band). `'band'` drops the native controls and puts a transport row
88
+ * on the band instead: play/pause, `current / total`, speed, mute +
89
+ * volume, and fullscreen for video. One timeline, one set of controls.
90
+ * Default: `'band'` for `kind="audio"`, `'native'` for video.
91
+ */
92
+ transport?: 'native' | 'band';
76
93
  poster?: string | null;
77
94
  /**
78
95
  * Aspect ratio for the video stage — presentation-primitive prop (PRD).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",