@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 CHANGED
@@ -74,6 +74,43 @@ Then import:
74
74
  |-----------|---------|-----------|
75
75
  | `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?` |
76
76
 
77
+ ### Meetings (subpath: `jatui/meet`)
78
+
79
+ Real-time video-meeting UI. `livekit-client` is an **OPTIONAL peer dep**, lazily
80
+ imported (`loadLiveKit()`) only by container components and never at module scope —
81
+ so consumers who never render meeting UI don't install it (the d3/cobe/@xyflow
82
+ pattern). Import via the subpath, NOT the main barrel:
83
+
84
+ ```svelte
85
+ <script>
86
+ import { MeetingRoom, ControlBar, PreJoinLobby } from 'jatui/meet'
87
+ import type { MeetParticipant } from 'jatui/meet'
88
+ </script>
89
+ ```
90
+
91
+ The presentational components (`VideoTile`, `ControlBar`, `ParticipantList`,
92
+ `RecordingBadge`) are driven by plain `MeetParticipant`/`MeetRoomState` view-models
93
+ (no LiveKit types), so a route adapts real LiveKit tracks into them and demos mock
94
+ them with no server.
95
+
96
+ | Component | Purpose | Key Props |
97
+ |-----------|---------|-----------|
98
+ | `PreJoinLobby` | Device check before joining — camera/mic self-preview (browser-native, no LiveKit), display-name confirm, device pickers, optional consent gate (`consent` snippet slot). Emits `PreJoinResult` on join. | `displayName` (bindable), `roomName`, `consentRequired`, `joining`, `cameraDefault`, `micDefault`, `onJoin` |
99
+ | `MeetingRoom` | In-room layout shell — responsive grid, auto focus/filmstrip layout on screen-share. Snippet slots: `controls`, `panel`, `chrome`. | `participants`, `layout`, `focusedId`, `showPanel`, `controls`/`panel`/`chrome` (snippets) |
100
+ | `VideoTile` | One participant's video surface — attaches a `MediaStreamTrack`, camera-off avatar/initials, mic/share/connection badges, speaking ring. | `participant: MeetParticipant`, `mirror`, `size` |
101
+ | `ControlBar` | In-room action bar — mic/cam/screenshare/leave + host menu (lock, record, end-for-all). Real buttons, `aria-pressed`. | `local: MeetParticipant`, `isHost`, `room: MeetRoomState`, `controls: MeetLocalControls`, `hostActions: MeetHostActions` |
102
+ | `ParticipantList` | Roster sidebar — mic/camera state, per-participant host actions (mute, remove) on hover/focus. | `participants`, `isHost`, `hostActions`, `title` |
103
+ | `RecordingBadge` | Always-visible pulsing recording indicator with `aria-live`; renders nothing when off (consent-safe room chrome). | `recording`, `label`, `compact` |
104
+
105
+ **Types & loader:**
106
+ ```typescript
107
+ import type {
108
+ MeetParticipant, MeetRole, MeetRoomState, MeetLayout,
109
+ MeetHostActions, MeetLocalControls, PreJoinResult
110
+ } from 'jatui/meet'
111
+ import { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from 'jatui/meet'
112
+ ```
113
+
77
114
  ### Layout
78
115
 
79
116
  | Component | Purpose | Key Props |
@@ -0,0 +1,575 @@
1
+ <script lang="ts">
2
+ /**
3
+ * TranscriptPlayer — synced video + transcript viewer for post-meeting
4
+ * recordings (LiveKit meeting recordings with diarized/speaker-aligned
5
+ * transcripts).
6
+ *
7
+ * Video playback drives a scrollable transcript panel (click a line to
8
+ * seek, active line auto-highlights + auto-scrolls), a chapter/bookmark
9
+ * marker strip over the scrub range, an optional solo-speaker playback
10
+ * mode, and a per-speaker talk-time bar.
11
+ *
12
+ * Built for the mardukos meeting-recordings admin viewer (mos-omq6g.23).
13
+ * Presentational only — no fetching, no LiveKit dependency. The consuming
14
+ * app supplies a (possibly presigned/expiring) video URL plus pre-computed
15
+ * transcript/chapter/bookmark/talk-time data.
16
+ */
17
+
18
+ export interface TranscriptSegment {
19
+ id: string;
20
+ startSec: number;
21
+ endSec: number;
22
+ speakerIdentity: string | null; // null = couldn't be resolved to a real participant
23
+ speakerName: string | null; // display name, null if unresolved
24
+ text: string;
25
+ }
26
+
27
+ export interface TranscriptBookmark {
28
+ playbackSec: number; // seconds into the recording
29
+ participantName: string | null;
30
+ label: string | null;
31
+ }
32
+
33
+ export interface TranscriptChapter {
34
+ startSec: number;
35
+ title: string;
36
+ }
37
+
38
+ export interface TranscriptSpeakerTalkTime {
39
+ speakerIdentity: string | null;
40
+ speakerName: string | null;
41
+ seconds: number;
42
+ }
43
+
44
+ /** Small distinct oklch palette, cycled per speaker (mirrors ChapterTimeline's SIGNAL_COLORS approach). */
45
+ const TALK_TIME_PALETTE = [
46
+ 'oklch(0.70 0.16 250)', // blue
47
+ 'oklch(0.68 0.18 145)', // green
48
+ 'oklch(0.78 0.16 85)', // amber
49
+ 'oklch(0.68 0.19 25)', // red
50
+ 'oklch(0.68 0.16 300)', // violet
51
+ 'oklch(0.72 0.14 200)', // cyan
52
+ ];
53
+
54
+ const ALL_KEY = '__all__';
55
+ const UNKNOWN_KEY = '__unknown__';
56
+
57
+ let {
58
+ src,
59
+ segments,
60
+ chapters = [],
61
+ bookmarks = [],
62
+ talkTime = [],
63
+ onAddBookmark = (_playbackSec: number, _label: string) => {},
64
+ }: {
65
+ src: string;
66
+ segments: TranscriptSegment[];
67
+ chapters?: TranscriptChapter[];
68
+ bookmarks?: TranscriptBookmark[];
69
+ talkTime?: TranscriptSpeakerTalkTime[];
70
+ onAddBookmark?: (playbackSec: number, label: string) => void;
71
+ } = $props();
72
+
73
+ let videoEl: HTMLVideoElement | undefined = $state();
74
+ let currentTime = $state(0);
75
+ let duration = $state(0);
76
+ let paused = $state(true);
77
+
78
+ let soloSpeakerKey = $state(ALL_KEY);
79
+ let bookmarkLabel = $state('');
80
+
81
+ // Row refs for auto-scroll-into-view. Plain (non-reactive) map — refs are
82
+ // populated on mount, read from inside the $effect below on active-row change.
83
+ let rowRefs: Record<string, HTMLButtonElement> = {};
84
+
85
+ function speakerKey(seg: TranscriptSegment): string {
86
+ return seg.speakerIdentity ?? UNKNOWN_KEY;
87
+ }
88
+
89
+ const effectiveDuration = $derived(Number.isFinite(duration) && duration > 0 ? duration : 0);
90
+ const useHours = $derived(effectiveDuration >= 3600 || currentTime >= 3600);
91
+ const playedPct = $derived(Math.min(100, Math.max(0, (currentTime / (effectiveDuration || 1)) * 100)));
92
+
93
+ const activeSegment = $derived(
94
+ segments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
95
+ );
96
+
97
+ // Chapters are assumed sorted ascending by startSec (same convention as segments).
98
+ const activeChapterIndex = $derived.by(() => {
99
+ let idx = -1;
100
+ for (let i = 0; i < chapters.length; i++) {
101
+ if (chapters[i].startSec <= currentTime) idx = i;
102
+ else break;
103
+ }
104
+ return idx;
105
+ });
106
+
107
+ const speakerOptions = $derived.by(() => {
108
+ const seen = new Map<string, string>();
109
+ for (const seg of segments) {
110
+ const key = speakerKey(seg);
111
+ if (!seen.has(key)) seen.set(key, seg.speakerName ?? 'Unknown speaker');
112
+ }
113
+ return Array.from(seen.entries()).map(([key, label]) => ({ key, label }));
114
+ });
115
+
116
+ const effectiveTalkTime = $derived.by(() => {
117
+ if (talkTime.length > 0) return talkTime;
118
+ const totals = new Map<string, TranscriptSpeakerTalkTime>();
119
+ for (const seg of segments) {
120
+ const key = speakerKey(seg);
121
+ const dur = Math.max(0, seg.endSec - seg.startSec);
122
+ const existing = totals.get(key);
123
+ if (existing) existing.seconds += dur;
124
+ else totals.set(key, { speakerIdentity: seg.speakerIdentity, speakerName: seg.speakerName, seconds: dur });
125
+ }
126
+ return Array.from(totals.values()).sort((a, b) => b.seconds - a.seconds);
127
+ });
128
+
129
+ const talkTimeTotal = $derived(effectiveTalkTime.reduce((s, t) => s + t.seconds, 0) || 1);
130
+
131
+ function paletteColor(i: number): string {
132
+ return TALK_TIME_PALETTE[i % TALK_TIME_PALETTE.length];
133
+ }
134
+
135
+ function formatTime(totalSeconds: number, withHours: boolean): string {
136
+ const t = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
137
+ const s = Math.floor(t % 60);
138
+ const pad = (n: number) => String(n).padStart(2, '0');
139
+ if (withHours) {
140
+ const h = Math.floor(t / 3600);
141
+ const m = Math.floor((t % 3600) / 60);
142
+ return `${h}:${pad(m)}:${pad(s)}`;
143
+ }
144
+ const m = Math.floor(t / 60);
145
+ return `${m}:${pad(s)}`;
146
+ }
147
+
148
+ function formatDuration(totalSeconds: number): string {
149
+ const s = Math.round(Math.max(0, totalSeconds));
150
+ const h = Math.floor(s / 3600);
151
+ const m = Math.floor((s % 3600) / 60);
152
+ const sec = s % 60;
153
+ if (h > 0) return `${h}h ${m}m`;
154
+ if (m > 0) return `${m}m ${sec}s`;
155
+ return `${sec}s`;
156
+ }
157
+
158
+ function seekTo(sec: number) {
159
+ currentTime = sec; // two-way bind:currentTime pushes this to the <video> element
160
+ }
161
+
162
+ /**
163
+ * Solo-speaker playback: on every timeupdate, if we're not inside a segment
164
+ * belonging to the selected speaker, jump forward to that speaker's next
165
+ * segment (segments are sorted ascending by startSec, so this is a simple
166
+ * forward scan). If there's no later segment for them, we pause playback
167
+ * rather than let it run on into someone else's talk time unchecked.
168
+ */
169
+ function handleSoloTimeUpdate() {
170
+ if (soloSpeakerKey === ALL_KEY || !videoEl) return;
171
+ const t = videoEl.currentTime;
172
+ const inSelected = segments.some((s) => speakerKey(s) === soloSpeakerKey && t >= s.startSec && t < s.endSec);
173
+ if (inSelected) return;
174
+ const next = segments.find((s) => speakerKey(s) === soloSpeakerKey && s.startSec > t);
175
+ if (next) {
176
+ videoEl.currentTime = next.startSec;
177
+ } else {
178
+ videoEl.pause();
179
+ }
180
+ }
181
+
182
+ // Re-run the solo scan immediately when the selection changes (don't wait
183
+ // for the next native timeupdate tick to react to the new speaker choice).
184
+ $effect(() => {
185
+ soloSpeakerKey;
186
+ if (videoEl) handleSoloTimeUpdate();
187
+ });
188
+
189
+ // Auto-scroll the active transcript row into view as playback advances.
190
+ $effect(() => {
191
+ const seg = activeSegment;
192
+ if (!seg) return;
193
+ rowRefs[seg.id]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
194
+ });
195
+
196
+ function submitBookmark() {
197
+ onAddBookmark(currentTime, bookmarkLabel.trim());
198
+ bookmarkLabel = '';
199
+ }
200
+ </script>
201
+
202
+ <div class="tp-root">
203
+ <video
204
+ bind:this={videoEl}
205
+ bind:currentTime
206
+ bind:duration
207
+ bind:paused
208
+ ontimeupdate={handleSoloTimeUpdate}
209
+ {src}
210
+ controls
211
+ class="tp-video"
212
+ >
213
+ <!-- Captions track present for a11y_media_has_caption compliance; consumer
214
+ apps that have caption files can pass a src via a wrapping component. -->
215
+ <track kind="captions" />
216
+ </video>
217
+
218
+ <div class="tp-timeline">
219
+ <div class="tp-rail">
220
+ <div class="tp-rail-fill" style="width: {playedPct}%"></div>
221
+ </div>
222
+ {#each chapters as chapter, i (chapter.startSec + ':' + i)}
223
+ <button
224
+ type="button"
225
+ class="tp-marker tp-chapter-marker"
226
+ class:active={i === activeChapterIndex}
227
+ style="left: {(chapter.startSec / (effectiveDuration || 1)) * 100}%"
228
+ title={chapter.title}
229
+ onclick={() => seekTo(chapter.startSec)}
230
+ >
231
+ <span class="tp-chapter-flag"></span>
232
+ </button>
233
+ {/each}
234
+ {#each bookmarks as bookmark, i (bookmark.playbackSec + ':' + i)}
235
+ <button
236
+ type="button"
237
+ class="tp-marker tp-bookmark-marker"
238
+ style="left: {(bookmark.playbackSec / (effectiveDuration || 1)) * 100}%"
239
+ title={bookmark.label || (bookmark.participantName ? `${bookmark.participantName}'s bookmark` : 'Bookmark')}
240
+ onclick={() => seekTo(bookmark.playbackSec)}
241
+ >
242
+ <span class="tp-bookmark-dot"></span>
243
+ </button>
244
+ {/each}
245
+ </div>
246
+
247
+ <div class="tp-toolbar">
248
+ <div class="tp-solo">
249
+ <label for="tp-solo-select">Solo speaker</label>
250
+ <select id="tp-solo-select" bind:value={soloSpeakerKey}>
251
+ <option value={ALL_KEY}>All speakers</option>
252
+ {#each speakerOptions as opt (opt.key)}
253
+ <option value={opt.key}>{opt.label}</option>
254
+ {/each}
255
+ </select>
256
+ </div>
257
+
258
+ <div class="tp-bookmark-add">
259
+ <label for="tp-bookmark-label-input">Bookmark label</label>
260
+ <input
261
+ id="tp-bookmark-label-input"
262
+ type="text"
263
+ placeholder="Bookmark label (optional)"
264
+ bind:value={bookmarkLabel}
265
+ />
266
+ <button type="button" class="tp-add-bookmark-btn" onclick={submitBookmark}>
267
+ + Bookmark at {formatTime(currentTime, useHours)}
268
+ </button>
269
+ </div>
270
+ </div>
271
+
272
+ {#if effectiveTalkTime.length > 0}
273
+ <div class="tp-talktime">
274
+ <div class="tp-talktime-bar">
275
+ {#each effectiveTalkTime as tt, i (tt.speakerIdentity ?? 'unknown-' + i)}
276
+ <div
277
+ class="tp-talktime-seg"
278
+ style="width: {(tt.seconds / talkTimeTotal) * 100}%; background: {paletteColor(i)}"
279
+ title="{tt.speakerName ?? 'Unknown speaker'} — {formatDuration(tt.seconds)}"
280
+ ></div>
281
+ {/each}
282
+ </div>
283
+ <div class="tp-talktime-legend">
284
+ {#each effectiveTalkTime as tt, i (tt.speakerIdentity ?? 'unknown-' + i)}
285
+ <span class="tp-legend-item">
286
+ <span class="tp-legend-dot" style="background: {paletteColor(i)}"></span>
287
+ {tt.speakerName ?? 'Unknown speaker'} — {formatDuration(tt.seconds)}
288
+ </span>
289
+ {/each}
290
+ </div>
291
+ </div>
292
+ {/if}
293
+
294
+ <div class="tp-transcript">
295
+ {#if segments.length === 0}
296
+ <div class="tp-empty">No transcript available for this recording.</div>
297
+ {:else}
298
+ {#each segments as seg (seg.id)}
299
+ <button
300
+ type="button"
301
+ class="tp-row"
302
+ class:active={activeSegment?.id === seg.id}
303
+ bind:this={rowRefs[seg.id]}
304
+ onclick={() => seekTo(seg.startSec)}
305
+ >
306
+ <span class="tp-row-time">[{formatTime(seg.startSec, useHours)}]</span>
307
+ <span class="tp-row-speaker">{seg.speakerName ?? 'Unknown speaker'}:</span>
308
+ <span class="tp-row-text">{seg.text}</span>
309
+ </button>
310
+ {/each}
311
+ {/if}
312
+ </div>
313
+ </div>
314
+
315
+ <style>
316
+ .tp-root {
317
+ display: flex;
318
+ flex-direction: column;
319
+ gap: 0.6rem;
320
+ background: oklch(0.14 0.01 250);
321
+ border: 1px solid oklch(0.22 0.02 250);
322
+ border-radius: 0.6rem;
323
+ padding: 0.75rem;
324
+ }
325
+
326
+ .tp-video {
327
+ width: 100%;
328
+ border-radius: 0.4rem;
329
+ background: oklch(0.08 0 0);
330
+ display: block;
331
+ }
332
+
333
+ /* Timeline — chapter + bookmark marker strip */
334
+ .tp-timeline {
335
+ position: relative;
336
+ height: 1.75rem;
337
+ margin: 0 0.25rem;
338
+ }
339
+
340
+ .tp-rail {
341
+ position: absolute;
342
+ top: 50%;
343
+ left: 0;
344
+ right: 0;
345
+ height: 0.35rem;
346
+ transform: translateY(-50%);
347
+ background: oklch(0.20 0.02 250);
348
+ border: 1px solid oklch(0.26 0.02 250);
349
+ border-radius: 999px;
350
+ overflow: hidden;
351
+ }
352
+
353
+ .tp-rail-fill {
354
+ height: 100%;
355
+ background: oklch(0.60 0.16 250);
356
+ }
357
+
358
+ .tp-marker {
359
+ position: absolute;
360
+ top: 0;
361
+ transform: translateX(-50%);
362
+ background: none;
363
+ border: none;
364
+ padding: 0.1rem;
365
+ cursor: pointer;
366
+ display: flex;
367
+ align-items: center;
368
+ justify-content: center;
369
+ }
370
+
371
+ .tp-chapter-flag {
372
+ display: block;
373
+ width: 0.55rem;
374
+ height: 0.9rem;
375
+ background: oklch(0.72 0.16 200);
376
+ border-radius: 0.15rem 0.15rem 0.15rem 0;
377
+ box-shadow: 0 0 0 1px oklch(0.20 0.02 250);
378
+ }
379
+
380
+ .tp-chapter-marker:hover .tp-chapter-flag {
381
+ background: oklch(0.80 0.16 200);
382
+ }
383
+
384
+ .tp-chapter-marker.active .tp-chapter-flag {
385
+ background: oklch(0.85 0.18 200);
386
+ box-shadow: 0 0 8px oklch(0.72 0.16 200 / 0.6);
387
+ }
388
+
389
+ .tp-bookmark-dot {
390
+ display: block;
391
+ width: 0.6rem;
392
+ height: 0.6rem;
393
+ margin-top: 0.55rem;
394
+ background: oklch(0.78 0.16 85);
395
+ transform: rotate(45deg);
396
+ box-shadow: 0 0 0 1px oklch(0.20 0.02 250);
397
+ }
398
+
399
+ .tp-bookmark-marker:hover .tp-bookmark-dot {
400
+ background: oklch(0.85 0.18 85);
401
+ }
402
+
403
+ /* Toolbar — solo-speaker select + add-bookmark control */
404
+ .tp-toolbar {
405
+ display: flex;
406
+ flex-wrap: wrap;
407
+ align-items: center;
408
+ gap: 0.75rem;
409
+ font-size: 0.75rem;
410
+ color: oklch(0.75 0.04 250);
411
+ }
412
+
413
+ .tp-solo,
414
+ .tp-bookmark-add {
415
+ display: flex;
416
+ align-items: center;
417
+ gap: 0.4rem;
418
+ }
419
+
420
+ .tp-solo label {
421
+ color: oklch(0.60 0.03 250);
422
+ }
423
+
424
+ .tp-bookmark-add label {
425
+ /* visually hidden, still associated for a11y */
426
+ position: absolute;
427
+ width: 1px;
428
+ height: 1px;
429
+ padding: 0;
430
+ margin: -1px;
431
+ overflow: hidden;
432
+ clip: rect(0, 0, 0, 0);
433
+ white-space: nowrap;
434
+ border: 0;
435
+ }
436
+
437
+ .tp-solo select,
438
+ .tp-bookmark-add input {
439
+ background: oklch(0.18 0.01 250);
440
+ border: 1px solid oklch(0.26 0.02 250);
441
+ border-radius: 0.35rem;
442
+ color: oklch(0.85 0.02 250);
443
+ padding: 0.3rem 0.5rem;
444
+ font-size: 0.75rem;
445
+ }
446
+
447
+ .tp-bookmark-add input {
448
+ min-width: 12rem;
449
+ }
450
+
451
+ .tp-add-bookmark-btn {
452
+ background: oklch(0.22 0.03 250);
453
+ border: 1px solid oklch(0.30 0.03 250);
454
+ border-radius: 0.35rem;
455
+ color: oklch(0.85 0.04 250);
456
+ padding: 0.3rem 0.6rem;
457
+ font-size: 0.75rem;
458
+ cursor: pointer;
459
+ white-space: nowrap;
460
+ transition: background 0.12s ease, border-color 0.12s ease;
461
+ }
462
+
463
+ .tp-add-bookmark-btn:hover {
464
+ background: color-mix(in oklch, oklch(0.78 0.16 85) 18%, oklch(0.22 0.03 250));
465
+ border-color: oklch(0.78 0.16 85 / 0.5);
466
+ }
467
+
468
+ /* Talk-time bar */
469
+ .tp-talktime {
470
+ display: flex;
471
+ flex-direction: column;
472
+ gap: 0.35rem;
473
+ }
474
+
475
+ .tp-talktime-bar {
476
+ display: flex;
477
+ height: 0.6rem;
478
+ border-radius: 999px;
479
+ overflow: hidden;
480
+ background: oklch(0.20 0.02 250);
481
+ }
482
+
483
+ .tp-talktime-seg {
484
+ height: 100%;
485
+ }
486
+
487
+ .tp-talktime-legend {
488
+ display: flex;
489
+ flex-wrap: wrap;
490
+ gap: 0.6rem;
491
+ font-size: 0.68rem;
492
+ color: oklch(0.68 0.03 250);
493
+ }
494
+
495
+ .tp-legend-item {
496
+ display: inline-flex;
497
+ align-items: center;
498
+ gap: 0.3rem;
499
+ }
500
+
501
+ .tp-legend-dot {
502
+ width: 0.5rem;
503
+ height: 0.5rem;
504
+ border-radius: 999px;
505
+ flex-shrink: 0;
506
+ }
507
+
508
+ /* Transcript panel */
509
+ .tp-transcript {
510
+ display: flex;
511
+ flex-direction: column;
512
+ gap: 0.15rem;
513
+ max-height: 18rem;
514
+ overflow-y: auto;
515
+ background: oklch(0.12 0.01 250);
516
+ border: 1px solid oklch(0.22 0.02 250);
517
+ border-radius: 0.4rem;
518
+ padding: 0.35rem;
519
+ }
520
+
521
+ .tp-empty {
522
+ font-size: 0.75rem;
523
+ color: oklch(0.55 0.02 250);
524
+ padding: 0.75rem;
525
+ text-align: center;
526
+ }
527
+
528
+ .tp-row {
529
+ display: flex;
530
+ align-items: baseline;
531
+ flex-wrap: wrap;
532
+ gap: 0.35rem;
533
+ background: none;
534
+ border: none;
535
+ border-radius: 0.3rem;
536
+ padding: 0.3rem 0.45rem;
537
+ text-align: left;
538
+ cursor: pointer;
539
+ transition: background 0.12s ease;
540
+ }
541
+
542
+ .tp-row:hover {
543
+ background: oklch(0.18 0.02 250);
544
+ }
545
+
546
+ .tp-row.active {
547
+ background: color-mix(in oklch, oklch(0.70 0.16 250) 16%, oklch(0.16 0.01 250));
548
+ box-shadow: inset 2px 0 0 oklch(0.70 0.16 250);
549
+ }
550
+
551
+ .tp-row-time {
552
+ font-family: monospace;
553
+ font-size: 0.68rem;
554
+ color: oklch(0.50 0.02 250);
555
+ flex-shrink: 0;
556
+ }
557
+
558
+ .tp-row-speaker {
559
+ font-size: 0.75rem;
560
+ font-weight: 600;
561
+ color: oklch(0.78 0.06 250);
562
+ flex-shrink: 0;
563
+ }
564
+
565
+ .tp-row.active .tp-row-speaker {
566
+ color: oklch(0.88 0.08 250);
567
+ }
568
+
569
+ .tp-row-text {
570
+ font-size: 0.78rem;
571
+ color: oklch(0.80 0.02 250);
572
+ flex: 1;
573
+ min-width: 10rem;
574
+ }
575
+ </style>
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TranscriptPlayer — synced video + transcript viewer for post-meeting
3
+ * recordings (LiveKit meeting recordings with diarized/speaker-aligned
4
+ * transcripts).
5
+ *
6
+ * Video playback drives a scrollable transcript panel (click a line to
7
+ * seek, active line auto-highlights + auto-scrolls), a chapter/bookmark
8
+ * marker strip over the scrub range, an optional solo-speaker playback
9
+ * mode, and a per-speaker talk-time bar.
10
+ *
11
+ * Built for the mardukos meeting-recordings admin viewer (mos-omq6g.23).
12
+ * Presentational only — no fetching, no LiveKit dependency. The consuming
13
+ * app supplies a (possibly presigned/expiring) video URL plus pre-computed
14
+ * transcript/chapter/bookmark/talk-time data.
15
+ */
16
+ export interface TranscriptSegment {
17
+ id: string;
18
+ startSec: number;
19
+ endSec: number;
20
+ speakerIdentity: string | null;
21
+ speakerName: string | null;
22
+ text: string;
23
+ }
24
+ export interface TranscriptBookmark {
25
+ playbackSec: number;
26
+ participantName: string | null;
27
+ label: string | null;
28
+ }
29
+ export interface TranscriptChapter {
30
+ startSec: number;
31
+ title: string;
32
+ }
33
+ export interface TranscriptSpeakerTalkTime {
34
+ speakerIdentity: string | null;
35
+ speakerName: string | null;
36
+ seconds: number;
37
+ }
38
+ type $$ComponentProps = {
39
+ src: string;
40
+ segments: TranscriptSegment[];
41
+ chapters?: TranscriptChapter[];
42
+ bookmarks?: TranscriptBookmark[];
43
+ talkTime?: TranscriptSpeakerTalkTime[];
44
+ onAddBookmark?: (playbackSec: number, label: string) => void;
45
+ };
46
+ declare const TranscriptPlayer: import("svelte").Component<$$ComponentProps, {}, "">;
47
+ type TranscriptPlayer = ReturnType<typeof TranscriptPlayer>;
48
+ export default TranscriptPlayer;
package/dist/index.d.ts CHANGED
@@ -9,6 +9,8 @@ export { default as LinkedColumns } from './components/linked-columns/LinkedColu
9
9
  export type { ColumnLink } from './components/linked-columns/LinkedColumns.svelte';
10
10
  export { default as ChapterTimeline } from './components/replay/ChapterTimeline.svelte';
11
11
  export type { ChapterBlock } from './components/replay/ChapterTimeline.svelte';
12
+ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
13
+ export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
12
14
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
13
15
  export type { ItemKind, TranscriptItem, FileTouchedEntry, SignalNode, LinkedSession, WorkBlockClient, WorkTranscriptItemClient, } from './components/session-nav/transcriptModel';
14
16
  export { default as UserAvatar } from './components/UserAvatar.svelte';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { default as Collapse } from './components/Collapse.svelte';
6
6
  // Components — Session primitives (from JAT IDE, jat-jj79f.16 G1)
7
7
  export { default as LinkedColumns } from './components/linked-columns/LinkedColumns.svelte';
8
8
  export { default as ChapterTimeline } from './components/replay/ChapterTimeline.svelte';
9
+ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
9
10
  // Data models — Session transcript (from JAT IDE, jat-jj79f.16 G1)
10
11
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
11
12
  // Components — Universal