@joewinke/jatui 0.2.20 → 0.2.22

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.
@@ -375,13 +375,13 @@
375
375
  transition: background 0.15s, border-color 0.15s;
376
376
  min-height: 2rem;
377
377
  cursor: pointer;
378
- background: oklch(0.16 0.01 250);
379
- border: 1px solid oklch(0.25 0.02 250);
380
- color: oklch(0.85 0.02 250);
378
+ background: var(--color-base-200, oklch(0.16 0.01 250));
379
+ border: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.85 0.02 250)) 30%, transparent);
380
+ color: var(--color-base-content, oklch(0.85 0.02 250));
381
381
  }
382
382
  .sd-trigger:hover:not(:disabled) {
383
- background: oklch(0.18 0.01 250);
384
- border-color: oklch(0.30 0.02 250);
383
+ background: color-mix(in oklch, var(--color-base-content, oklch(0.85 0.02 250)) 8%, var(--color-base-200, oklch(0.16 0.01 250)));
384
+ border-color: color-mix(in oklch, var(--color-base-content, oklch(0.85 0.02 250)) 45%, transparent);
385
385
  }
386
386
  .sd-trigger:disabled, .sd-disabled {
387
387
  opacity: 0.5;
@@ -394,9 +394,6 @@
394
394
  min-height: 2rem;
395
395
  padding: 0 0.75rem;
396
396
  border-radius: var(--rounded-btn, 0.5rem);
397
- background: var(--color-base-200, oklch(0.16 0.01 250));
398
- border: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.85 0.02 250)) 30%, transparent);
399
- color: var(--color-base-content, oklch(0.85 0.02 250));
400
397
  }
401
398
  .sd-sm .sd-chevron {
402
399
  width: 0.625rem;
@@ -440,15 +437,15 @@
440
437
  text-transform: uppercase;
441
438
  letter-spacing: 0.025em;
442
439
  background: transparent;
443
- border: 1px solid color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 35%, transparent);
444
- color: color-mix(in oklch, var(--sd-color, oklch(0.7 0 0)) 85%, oklch(0.80 0 0));
440
+ border: 1px solid color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 35%, transparent);
441
+ color: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.7 0 0))) 85%, var(--color-base-content, oklch(0.80 0 0)));
445
442
  transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, color 0.15s;
446
443
  }
447
444
  .sd-chip:hover:not(:disabled) {
448
- background: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 15%, transparent);
449
- border-color: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 50%, transparent);
450
- color: var(--sd-color, oklch(0.80 0 0));
451
- box-shadow: 0 0 6px color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 15%, transparent);
445
+ background: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 15%, transparent);
446
+ border-color: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 50%, transparent);
447
+ color: var(--sd-color, var(--color-base-content, oklch(0.80 0 0)));
448
+ box-shadow: 0 0 6px color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 15%, transparent);
452
449
  }
453
450
 
454
451
  .sd-trigger-label {
@@ -488,12 +485,12 @@
488
485
  background: none;
489
486
  border: none;
490
487
  cursor: pointer;
491
- color: oklch(0.50 0.02 250);
488
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.50 0.02 250)) 55%, transparent);
492
489
  transition: color 0.1s, background 0.1s;
493
490
  }
494
491
  .sd-clear:hover {
495
- color: oklch(0.75 0.15 30);
496
- background: oklch(0.75 0.15 30 / 0.12);
492
+ color: var(--color-error, oklch(0.75 0.15 30));
493
+ background: color-mix(in oklch, var(--color-error, oklch(0.75 0.15 30)) 12%, transparent);
497
494
  }
498
495
 
499
496
  .sd-chevron {
@@ -501,7 +498,7 @@
501
498
  height: 0.75rem;
502
499
  flex-shrink: 0;
503
500
  transition: transform 0.15s;
504
- color: oklch(0.50 0.02 250);
501
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.50 0.02 250)) 55%, transparent);
505
502
  }
506
503
  .sd-chevron-open {
507
504
  transform: rotate(180deg);
@@ -513,7 +510,7 @@
513
510
  }
514
511
  .sd-embedded .sd-search {
515
512
  background: transparent;
516
- border-bottom: 1px solid oklch(0.22 0.02 250);
513
+ border-bottom: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.22 0.02 250)) 25%, transparent);
517
514
  }
518
515
  .sd-embedded .sd-list {
519
516
  max-height: none;
@@ -530,8 +527,8 @@
530
527
  border-radius: 0.5rem;
531
528
  overflow: hidden;
532
529
  box-shadow: 0 4px 24px oklch(0 0 0 / 0.4);
533
- background: oklch(0.16 0.01 250);
534
- border: 1px solid oklch(0.25 0.02 250);
530
+ background: var(--color-base-200, oklch(0.16 0.01 250));
531
+ border: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.85 0.02 250)) 30%, transparent);
535
532
  }
536
533
 
537
534
  .sd-panel-dropup {
@@ -544,21 +541,21 @@
544
541
 
545
542
  /* Header section */
546
543
  .sd-header {
547
- border-bottom: 1px solid oklch(0.25 0.02 250);
544
+ border-bottom: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.25 0.02 250)) 25%, transparent);
548
545
  }
549
546
 
550
547
  /* Search section */
551
548
  .sd-search {
552
549
  padding: 0.375rem 0.5rem;
553
- border-bottom: 1px solid oklch(0.25 0.02 250);
554
- background: oklch(0.13 0.01 250);
550
+ border-bottom: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.25 0.02 250)) 25%, transparent);
551
+ background: var(--color-base-300, oklch(0.13 0.01 250));
555
552
  }
556
553
  .sd-search-inner {
557
554
  display: flex;
558
555
  align-items: center;
559
556
  gap: 0.375rem;
560
- background: oklch(0.19 0.01 250);
561
- border: 1px solid oklch(0.28 0.02 250);
557
+ background: color-mix(in oklch, var(--color-base-content, oklch(0.19 0.01 250)) 10%, var(--color-base-200, oklch(0.19 0.01 250)));
558
+ border: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.28 0.02 250)) 28%, transparent);
562
559
  border-radius: 0.25rem;
563
560
  padding: 0.2rem 0.4rem;
564
561
  }
@@ -566,22 +563,22 @@
566
563
  width: 0.75rem;
567
564
  height: 0.75rem;
568
565
  flex-shrink: 0;
569
- color: oklch(0.60 0.04 250);
566
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.60 0.04 250)) 60%, transparent);
570
567
  }
571
568
  .sd-search-input {
572
569
  width: 100%;
573
570
  background: transparent;
574
571
  font-size: 0.6875rem;
575
572
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
576
- color: oklch(0.85 0.02 250);
573
+ color: var(--color-base-content, oklch(0.85 0.02 250));
577
574
  border: none;
578
575
  outline: none;
579
576
  }
580
577
  .sd-search-input::placeholder {
581
- color: oklch(0.52 0.03 250);
578
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.52 0.03 250)) 40%, transparent);
582
579
  }
583
580
  .sd-search-clear {
584
- color: oklch(0.50 0.02 250);
581
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.50 0.02 250)) 55%, transparent);
585
582
  background: none;
586
583
  border: none;
587
584
  cursor: pointer;
@@ -589,7 +586,7 @@
589
586
  display: flex;
590
587
  }
591
588
  .sd-search-clear:hover {
592
- color: oklch(0.75 0.15 30);
589
+ color: var(--color-error, oklch(0.75 0.15 30));
593
590
  }
594
591
 
595
592
  /* Options list */
@@ -610,7 +607,7 @@
610
607
  font-weight: 600;
611
608
  text-transform: uppercase;
612
609
  letter-spacing: 0.05em;
613
- color: oklch(0.50 0.10 250);
610
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.50 0.10 250)) 55%, transparent);
614
611
  }
615
612
 
616
613
  .sd-option {
@@ -627,22 +624,22 @@
627
624
  background: transparent;
628
625
  border: none;
629
626
  border-left: 2px solid transparent;
630
- color: oklch(0.80 0.02 250);
627
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.80 0.02 250)) 90%, transparent);
631
628
  }
632
629
  .sd-option:hover {
633
- background: oklch(0.19 0.01 250);
630
+ background: color-mix(in oklch, var(--color-base-content, oklch(0.19 0.01 250)) 8%, var(--color-base-200, oklch(0.19 0.01 250)));
634
631
  }
635
632
  .sd-option-selected {
636
- background: oklch(0.20 0.02 250);
637
- border-left-color: oklch(0.65 0.15 250);
633
+ background: color-mix(in oklch, var(--color-base-content, oklch(0.20 0.02 250)) 12%, var(--color-base-200, oklch(0.20 0.02 250)));
634
+ border-left-color: var(--color-primary, oklch(0.65 0.15 250));
638
635
  }
639
636
  .sd-option-highlighted {
640
- background: oklch(0.22 0.02 250);
641
- outline: 1px solid oklch(0.45 0.10 250 / 0.5);
637
+ background: color-mix(in oklch, var(--color-base-content, oklch(0.22 0.02 250)) 16%, var(--color-base-200, oklch(0.22 0.02 250)));
638
+ outline: 1px solid color-mix(in oklch, var(--color-primary, oklch(0.45 0.10 250)) 50%, transparent);
642
639
  outline-offset: -1px;
643
640
  }
644
641
  .sd-option-highlighted.sd-option-selected {
645
- background: oklch(0.24 0.03 250);
642
+ background: color-mix(in oklch, var(--color-base-content, oklch(0.24 0.03 250)) 20%, var(--color-base-200, oklch(0.24 0.03 250)));
646
643
  }
647
644
 
648
645
  /* Chip variant — options render as inline chip pills */
@@ -651,7 +648,7 @@
651
648
  padding: 0.25rem 0.625rem;
652
649
  }
653
650
  .sd-chip-wrap .sd-option-selected {
654
- background: oklch(0.19 0.01 250);
651
+ background: color-mix(in oklch, var(--color-base-content, oklch(0.19 0.01 250)) 8%, var(--color-base-200, oklch(0.19 0.01 250)));
655
652
  border-left: none;
656
653
  }
657
654
  .sd-option-chip {
@@ -663,20 +660,20 @@
663
660
  font-weight: 600;
664
661
  text-transform: uppercase;
665
662
  letter-spacing: 0.025em;
666
- border: 1px solid color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 35%, transparent);
667
- color: color-mix(in oklch, var(--sd-color, oklch(0.7 0 0)) 85%, oklch(0.80 0 0));
663
+ border: 1px solid color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 35%, transparent);
664
+ color: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.7 0 0))) 85%, var(--color-base-content, oklch(0.80 0 0)));
668
665
  background: transparent;
669
666
  transition: background 0.1s, border-color 0.1s, color 0.1s;
670
667
  }
671
668
  .sd-chip-wrap .sd-option:hover .sd-option-chip {
672
- background: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 12%, transparent);
673
- border-color: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 50%, transparent);
674
- color: var(--sd-color, oklch(0.80 0 0));
669
+ background: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 12%, transparent);
670
+ border-color: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 50%, transparent);
671
+ color: var(--sd-color, var(--color-base-content, oklch(0.80 0 0)));
675
672
  }
676
673
  .sd-chip-wrap .sd-option-selected .sd-option-chip {
677
- background: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 15%, transparent);
678
- border-color: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 50%, transparent);
679
- color: var(--sd-color, oklch(0.80 0 0));
674
+ background: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 15%, transparent);
675
+ border-color: color-mix(in oklch, var(--sd-color, var(--color-base-content, oklch(0.5 0 0))) 50%, transparent);
676
+ color: var(--sd-color, var(--color-base-content, oklch(0.80 0 0)));
680
677
  }
681
678
 
682
679
  .sd-option-icon {
@@ -691,11 +688,11 @@
691
688
  height: 0.75rem;
692
689
  flex-shrink: 0;
693
690
  margin-left: auto;
694
- color: oklch(0.70 0.15 145);
691
+ color: var(--color-success, oklch(0.70 0.15 145));
695
692
  }
696
693
 
697
694
  .sd-footer {
698
- border-top: 1px solid oklch(0.22 0.02 250);
695
+ border-top: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.22 0.02 250)) 25%, transparent);
699
696
  }
700
697
 
701
698
  .sd-empty {
@@ -703,6 +700,6 @@
703
700
  text-align: center;
704
701
  font-size: 0.625rem;
705
702
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
706
- color: oklch(0.45 0.02 250);
703
+ color: color-mix(in oklch, var(--color-base-content, oklch(0.45 0.02 250)) 45%, transparent);
707
704
  }
708
705
  </style>
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",