@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 +37 -0
- package/dist/components/TranscriptPlayer.svelte +575 -0
- package/dist/components/TranscriptPlayer.svelte.d.ts +48 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/meet/ControlBar.svelte +176 -0
- package/dist/meet/ControlBar.svelte.d.ts +31 -0
- package/dist/meet/MeetingRoom.svelte +123 -0
- package/dist/meet/MeetingRoom.svelte.d.ts +34 -0
- package/dist/meet/ParticipantList.svelte +133 -0
- package/dist/meet/ParticipantList.svelte.d.ts +22 -0
- package/dist/meet/PreJoinLobby.svelte +288 -0
- package/dist/meet/PreJoinLobby.svelte.d.ts +41 -0
- package/dist/meet/RecordingBadge.svelte +48 -0
- package/dist/meet/RecordingBadge.svelte.d.ts +20 -0
- package/dist/meet/VideoTile.svelte +176 -0
- package/dist/meet/VideoTile.svelte.d.ts +22 -0
- package/dist/meet/index.d.ts +9 -0
- package/dist/meet/index.js +20 -0
- package/dist/meet/livekit.d.ts +39 -0
- package/dist/meet/livekit.js +56 -0
- package/dist/meet/types.d.ts +103 -0
- package/dist/meet/types.js +11 -0
- package/package.json +20 -10
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
|