@joewinke/jatui 0.2.28 → 0.2.30
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 +10 -0
- package/dist/components/MediaWorkbench.svelte +12 -1
- package/dist/components/MediaWorkbench.svelte.d.ts +9 -0
- package/dist/components/VideoPlayer.svelte +1592 -0
- package/dist/components/VideoPlayer.svelte.d.ts +72 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1592 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* VideoPlayer — one video surface for everything, from a bare mp4 to a
|
|
4
|
+
* fully-analysed recording.
|
|
5
|
+
*
|
|
6
|
+
* Marries two prior implementations rather than inventing a third:
|
|
7
|
+
*
|
|
8
|
+
* 1. extension-library's framework-agnostic `mountPlayer` (player.js) —
|
|
9
|
+
* the timeline band: waveform envelope as the scrubber, chapters as
|
|
10
|
+
* structure inside it, diarized speaker colouring, bookmark diamonds,
|
|
11
|
+
* scroll-to-zoom, hover card with "what's being said here".
|
|
12
|
+
* 2. jatui's TranscriptPlayer — the transcript pane: click a line to seek,
|
|
13
|
+
* active line auto-scroll, solo-speaker playback, talk-time bars.
|
|
14
|
+
*
|
|
15
|
+
* EVERY intelligence prop is optional. A bare `<VideoPlayer {src} />` still
|
|
16
|
+
* gets the band (a real scrubber with hover-time, click-to-seek, keyboard
|
|
17
|
+
* seek), wheel volume and the keyboard map; chapters, speakers, waveform,
|
|
18
|
+
* transcript, bookmarks and talk-time each light up their own pane as the
|
|
19
|
+
* data exists. Nothing shifts layout when data arrives late (diarization is
|
|
20
|
+
* slow) — the band's slots are reserved from the first frame.
|
|
21
|
+
*
|
|
22
|
+
* ── Design notes carried over from player.js ────────────────────────────
|
|
23
|
+
* · ONE time axis. Every position — waveform bars, chapter flags, bookmarks,
|
|
24
|
+
* the speaker lane, click/hover-to-seek — maps through secToFrac/fracToSec,
|
|
25
|
+
* so scroll-to-zoom is one piece of state everything else respects for free.
|
|
26
|
+
* · ONE speaker surface. With a waveform, the envelope's own bars wear the
|
|
27
|
+
* turn colours; the slim lane under it paints only as the no-waveform
|
|
28
|
+
* fallback. Never two stacked time-parallel strips.
|
|
29
|
+
* · Chapter flags sit on the TOP edge, bookmark diamonds on the BOTTOM edge —
|
|
30
|
+
* same idea (a marker on the timeline), different origin (the model's vs.
|
|
31
|
+
* yours), so the two families are never confused at a glance.
|
|
32
|
+
* · The wheel means different things in different regions, and each region is
|
|
33
|
+
* its own hover target so nothing is ambiguous: over the video it's volume
|
|
34
|
+
* (shift = seek); over the band it's zoom.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately NOT ported: player.js's trim region (in/out handles). That's
|
|
37
|
+
* clip-editing, not playback, and belongs in an editor component.
|
|
38
|
+
*
|
|
39
|
+
* Theming is token-only — DaisyUI semantic vars for chrome, resolved at
|
|
40
|
+
* runtime via `getDaisyUIColor` for the canvas, repainted on theme change.
|
|
41
|
+
* Speaker hues follow TranscriptPlayer's oklch palette (data-driven identity
|
|
42
|
+
* colour, cycled per speaker — not brand colour).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { onMount, untrack } from 'svelte';
|
|
46
|
+
import { getDaisyUIColor, watchThemeChanges, clearColorCache } from '../utils/daisyuiColors';
|
|
47
|
+
|
|
48
|
+
/** A model-authored marker: the video's structure. */
|
|
49
|
+
export interface VideoChapter {
|
|
50
|
+
startSec: number;
|
|
51
|
+
title: string;
|
|
52
|
+
/** Optional captured still, shown in the band's hover card. */
|
|
53
|
+
thumbUrl?: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A diarized turn (or an ad read, `ad: true`). Structurally accepts meeting data. */
|
|
57
|
+
export interface VideoSpeakerTurn {
|
|
58
|
+
startSec: number;
|
|
59
|
+
endSec: number;
|
|
60
|
+
speakerIdentity?: string | null;
|
|
61
|
+
speakerName?: string | null;
|
|
62
|
+
/** Explicit oklch hue (0–360). Omit to take the next palette colour. */
|
|
63
|
+
hue?: number | null;
|
|
64
|
+
/** Ad read — shares the lane but reads as not-a-person. */
|
|
65
|
+
ad?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Transcript line. Structurally compatible with TranscriptPlayer's `TranscriptSegment`. */
|
|
69
|
+
export interface VideoTranscriptSegment {
|
|
70
|
+
id: string;
|
|
71
|
+
startSec: number;
|
|
72
|
+
endSec: number;
|
|
73
|
+
speakerIdentity?: string | null;
|
|
74
|
+
speakerName?: string | null;
|
|
75
|
+
text: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A user-authored marker. Structurally compatible with `TranscriptBookmark`. */
|
|
79
|
+
export interface VideoBookmark {
|
|
80
|
+
playbackSec: number;
|
|
81
|
+
label?: string | null;
|
|
82
|
+
note?: string | null;
|
|
83
|
+
participantName?: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Precomputed audio envelope. `peaks` are normalised against their own max. */
|
|
87
|
+
export interface VideoWaveform {
|
|
88
|
+
peaks: number[];
|
|
89
|
+
/** Seconds the peaks span. Defaults to the video's duration. */
|
|
90
|
+
durationSec?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Per-speaker totals. Structurally compatible with `TranscriptSpeakerTalkTime`. */
|
|
94
|
+
export interface VideoSpeakerTalkTime {
|
|
95
|
+
speakerIdentity?: string | null;
|
|
96
|
+
speakerName?: string | null;
|
|
97
|
+
seconds: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Speaker identity palette — the same small distinct oklch set TranscriptPlayer
|
|
102
|
+
* cycles, so a speaker reads as the same colour in both components.
|
|
103
|
+
*/
|
|
104
|
+
const SPEAKER_PALETTE = [
|
|
105
|
+
'oklch(0.70 0.16 250)', // blue
|
|
106
|
+
'oklch(0.68 0.18 145)', // green
|
|
107
|
+
'oklch(0.78 0.16 85)', // amber
|
|
108
|
+
'oklch(0.68 0.19 25)', // red
|
|
109
|
+
'oklch(0.68 0.16 300)', // violet
|
|
110
|
+
'oklch(0.72 0.14 200)' // cyan
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const ALL_KEY = '__all__';
|
|
114
|
+
const UNKNOWN_KEY = '__unknown__';
|
|
115
|
+
|
|
116
|
+
let {
|
|
117
|
+
src,
|
|
118
|
+
poster = null,
|
|
119
|
+
captionsSrc = null,
|
|
120
|
+
captionsLabel = 'Captions',
|
|
121
|
+
captionsLang = 'en',
|
|
122
|
+
chapters = [],
|
|
123
|
+
speakers = [],
|
|
124
|
+
transcript = [],
|
|
125
|
+
bookmarks = [],
|
|
126
|
+
waveform = null,
|
|
127
|
+
talkTime = [],
|
|
128
|
+
startAt = null,
|
|
129
|
+
transcriptPosition = 'below',
|
|
130
|
+
transcriptMaxHeight = '18rem',
|
|
131
|
+
onTimeUpdate = undefined,
|
|
132
|
+
onAddBookmark = undefined,
|
|
133
|
+
onBookmarkContext = undefined,
|
|
134
|
+
class: className = ''
|
|
135
|
+
}: {
|
|
136
|
+
src: string;
|
|
137
|
+
poster?: string | null;
|
|
138
|
+
captionsSrc?: string | null;
|
|
139
|
+
captionsLabel?: string;
|
|
140
|
+
captionsLang?: string;
|
|
141
|
+
chapters?: VideoChapter[];
|
|
142
|
+
speakers?: VideoSpeakerTurn[];
|
|
143
|
+
transcript?: VideoTranscriptSegment[];
|
|
144
|
+
bookmarks?: VideoBookmark[];
|
|
145
|
+
waveform?: VideoWaveform | null;
|
|
146
|
+
talkTime?: VideoSpeakerTalkTime[];
|
|
147
|
+
/** Seek here once metadata lands (a queued seek never fires on a later source). */
|
|
148
|
+
startAt?: number | null;
|
|
149
|
+
transcriptPosition?: 'below' | 'side';
|
|
150
|
+
transcriptMaxHeight?: string;
|
|
151
|
+
onTimeUpdate?: (video: HTMLVideoElement) => void;
|
|
152
|
+
/** Present ⇒ the band grows an "add bookmark at current time" control. */
|
|
153
|
+
onAddBookmark?: (playbackSec: number, label: string) => void;
|
|
154
|
+
/** Right-click on the band. The host owns the panel; this only hit-tests. */
|
|
155
|
+
onBookmarkContext?: (
|
|
156
|
+
sec: number,
|
|
157
|
+
existing: VideoBookmark | null,
|
|
158
|
+
clientX: number,
|
|
159
|
+
clientY: number
|
|
160
|
+
) => void;
|
|
161
|
+
class?: string;
|
|
162
|
+
} = $props();
|
|
163
|
+
|
|
164
|
+
// ── Element refs + media state ──────────────────────────────────────────
|
|
165
|
+
let videoEl: HTMLVideoElement | undefined = $state();
|
|
166
|
+
let canvasEl: HTMLCanvasElement | undefined = $state();
|
|
167
|
+
|
|
168
|
+
let currentTime = $state(0);
|
|
169
|
+
let duration = $state(0);
|
|
170
|
+
|
|
171
|
+
// Canvas box, tracked so a resize (panel toggle, window resize) repaints.
|
|
172
|
+
let canvasW = $state(0);
|
|
173
|
+
let canvasH = $state(0);
|
|
174
|
+
// Bumped by the theme observer so the draw effect re-runs on a theme swap.
|
|
175
|
+
let themeTick = $state(0);
|
|
176
|
+
|
|
177
|
+
// ── Band interaction state ──────────────────────────────────────────────
|
|
178
|
+
let viewStart = $state(0);
|
|
179
|
+
let viewEnd = $state(0); // 0 until a duration is known
|
|
180
|
+
let hoverChapter = $state(-1);
|
|
181
|
+
let hover: {
|
|
182
|
+
x: number;
|
|
183
|
+
title: string;
|
|
184
|
+
text: string;
|
|
185
|
+
thumb: string | null;
|
|
186
|
+
isBookmark: boolean;
|
|
187
|
+
} | null = $state(null);
|
|
188
|
+
let zoomLabel = $state('');
|
|
189
|
+
let zoomLabelOn = $state(false);
|
|
190
|
+
let osd = $state(''); // transient wheel readout (volume / seek)
|
|
191
|
+
let osdOn = $state(false);
|
|
192
|
+
|
|
193
|
+
// ── Speaker filter state ────────────────────────────────────────────────
|
|
194
|
+
let soloKey = $state(ALL_KEY);
|
|
195
|
+
let mutedKeys = $state(new Set<string>());
|
|
196
|
+
let highlightKey: string | null = $state(null);
|
|
197
|
+
let bookmarkLabel = $state('');
|
|
198
|
+
|
|
199
|
+
let zoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|
200
|
+
let osdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
201
|
+
|
|
202
|
+
// Row refs for transcript auto-scroll. Plain object — refs are written on
|
|
203
|
+
// mount and only read from inside an effect, never used as a reactive source.
|
|
204
|
+
let rowRefs: Record<string, HTMLButtonElement> = {};
|
|
205
|
+
|
|
206
|
+
// ── Derived data ────────────────────────────────────────────────────────
|
|
207
|
+
const effectiveDuration = $derived(
|
|
208
|
+
Number.isFinite(duration) && duration > 0 ? duration : (waveform?.durationSec ?? 0)
|
|
209
|
+
);
|
|
210
|
+
const useHours = $derived(effectiveDuration >= 3600);
|
|
211
|
+
|
|
212
|
+
const sortedChapters = $derived(
|
|
213
|
+
[...chapters]
|
|
214
|
+
.filter((c) => c && Number.isFinite(c.startSec) && c.startSec >= 0)
|
|
215
|
+
.sort((a, b) => a.startSec - b.startSec)
|
|
216
|
+
);
|
|
217
|
+
const sortedTurns = $derived(
|
|
218
|
+
[...speakers]
|
|
219
|
+
.filter(
|
|
220
|
+
(t) =>
|
|
221
|
+
t &&
|
|
222
|
+
Number.isFinite(t.startSec) &&
|
|
223
|
+
Number.isFinite(t.endSec) &&
|
|
224
|
+
t.endSec > t.startSec
|
|
225
|
+
)
|
|
226
|
+
.sort((a, b) => a.startSec - b.startSec)
|
|
227
|
+
);
|
|
228
|
+
const sortedSegments = $derived(
|
|
229
|
+
[...transcript]
|
|
230
|
+
.filter((s) => s && Number.isFinite(s.startSec))
|
|
231
|
+
.sort((a, b) => a.startSec - b.startSec)
|
|
232
|
+
);
|
|
233
|
+
const sortedBookmarks = $derived(
|
|
234
|
+
[...bookmarks]
|
|
235
|
+
.filter((b) => b && Number.isFinite(b.playbackSec) && b.playbackSec >= 0)
|
|
236
|
+
.sort((a, b) => a.playbackSec - b.playbackSec)
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
/** Peaks normalised to 0–1 against their own max, so 0–1, 0–99 and 0–255 inputs all work. */
|
|
240
|
+
const peaks = $derived.by(() => {
|
|
241
|
+
const raw = waveform?.peaks;
|
|
242
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
243
|
+
let max = 0;
|
|
244
|
+
for (const p of raw) if (Number.isFinite(p) && p > max) max = p;
|
|
245
|
+
if (max <= 0) return null;
|
|
246
|
+
return raw.map((p) => (Number.isFinite(p) ? Math.max(0, p) / max : 0));
|
|
247
|
+
});
|
|
248
|
+
const hasWave = $derived(!!peaks);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Ordered speaker registry, built from turns first then transcript, so the
|
|
252
|
+
* palette index is stable across both panes and the legend.
|
|
253
|
+
*/
|
|
254
|
+
const speakerIndex = $derived.by(() => {
|
|
255
|
+
const map = new Map<string, { key: string; name: string; hue: number | null }>();
|
|
256
|
+
const add = (
|
|
257
|
+
identity: string | null | undefined,
|
|
258
|
+
name: string | null | undefined,
|
|
259
|
+
hue: number | null | undefined
|
|
260
|
+
) => {
|
|
261
|
+
const key = identity ?? name ?? UNKNOWN_KEY;
|
|
262
|
+
const existing = map.get(key);
|
|
263
|
+
if (existing) {
|
|
264
|
+
if (existing.hue == null && hue != null) existing.hue = hue;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
map.set(key, { key, name: name ?? 'Unknown speaker', hue: hue ?? null });
|
|
268
|
+
};
|
|
269
|
+
for (const t of sortedTurns) {
|
|
270
|
+
if (t.ad) continue;
|
|
271
|
+
add(t.speakerIdentity, t.speakerName, t.hue ?? null);
|
|
272
|
+
}
|
|
273
|
+
for (const s of sortedSegments) add(s.speakerIdentity, s.speakerName, null);
|
|
274
|
+
return map;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const hasSpeakers = $derived(speakerIndex.size > 0);
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* key → identity colour, resolved ONCE per data change. draw() asks for a
|
|
281
|
+
* colour per rendered column and reruns on every playhead tick, so this must
|
|
282
|
+
* never be an indexOf scan at call time.
|
|
283
|
+
*/
|
|
284
|
+
const speakerColors = $derived.by(() => {
|
|
285
|
+
const map = new Map<string, string>();
|
|
286
|
+
let i = 0;
|
|
287
|
+
for (const [key, entry] of speakerIndex) {
|
|
288
|
+
map.set(
|
|
289
|
+
key,
|
|
290
|
+
entry.hue != null
|
|
291
|
+
? `oklch(0.72 0.14 ${entry.hue})`
|
|
292
|
+
: SPEAKER_PALETTE[i % SPEAKER_PALETTE.length]
|
|
293
|
+
);
|
|
294
|
+
i++;
|
|
295
|
+
}
|
|
296
|
+
return map;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/** With an envelope the bars carry the hues; the lane is the no-waveform fallback only. */
|
|
300
|
+
const laneCarriesSpeakers = $derived(!hasWave && sortedTurns.length > 0);
|
|
301
|
+
|
|
302
|
+
const effectiveTalkTime = $derived.by(() => {
|
|
303
|
+
if (talkTime.length > 0) {
|
|
304
|
+
return [...talkTime].sort((a, b) => b.seconds - a.seconds);
|
|
305
|
+
}
|
|
306
|
+
const totals = new Map<string, VideoSpeakerTalkTime & { key: string }>();
|
|
307
|
+
const bump = (
|
|
308
|
+
identity: string | null | undefined,
|
|
309
|
+
name: string | null | undefined,
|
|
310
|
+
secs: number
|
|
311
|
+
) => {
|
|
312
|
+
const key = identity ?? name ?? UNKNOWN_KEY;
|
|
313
|
+
const existing = totals.get(key);
|
|
314
|
+
if (existing) existing.seconds += secs;
|
|
315
|
+
else totals.set(key, { key, speakerIdentity: identity ?? null, speakerName: name ?? null, seconds: secs });
|
|
316
|
+
};
|
|
317
|
+
const source = sortedTurns.length > 0 ? sortedTurns : sortedSegments;
|
|
318
|
+
for (const s of source) {
|
|
319
|
+
if ('ad' in s && s.ad) continue;
|
|
320
|
+
bump(s.speakerIdentity, s.speakerName, Math.max(0, s.endSec - s.startSec));
|
|
321
|
+
}
|
|
322
|
+
return [...totals.values()].sort((a, b) => b.seconds - a.seconds);
|
|
323
|
+
});
|
|
324
|
+
const talkTimeTotal = $derived(effectiveTalkTime.reduce((n, t) => n + t.seconds, 0) || 1);
|
|
325
|
+
|
|
326
|
+
const activeSegment = $derived(
|
|
327
|
+
sortedSegments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
331
|
+
function speakerKeyOf(s: {
|
|
332
|
+
speakerIdentity?: string | null;
|
|
333
|
+
speakerName?: string | null;
|
|
334
|
+
}): string {
|
|
335
|
+
return s.speakerIdentity ?? s.speakerName ?? UNKNOWN_KEY;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Explicit hue wins (host-supplied identity colour); otherwise the cycled palette. */
|
|
339
|
+
function speakerColor(key: string): string {
|
|
340
|
+
return speakerColors.get(key) ?? SPEAKER_PALETTE[0];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function formatTime(totalSeconds: number, withHours: boolean): string {
|
|
344
|
+
const t = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
|
|
345
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
346
|
+
const s = Math.floor(t % 60);
|
|
347
|
+
if (withHours) {
|
|
348
|
+
return `${Math.floor(t / 3600)}:${pad(Math.floor((t % 3600) / 60))}:${pad(s)}`;
|
|
349
|
+
}
|
|
350
|
+
return `${Math.floor(t / 60)}:${pad(s)}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatDuration(totalSeconds: number): string {
|
|
354
|
+
const s = Math.round(Math.max(0, totalSeconds));
|
|
355
|
+
const h = Math.floor(s / 3600);
|
|
356
|
+
const m = Math.floor((s % 3600) / 60);
|
|
357
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
358
|
+
if (m > 0) return `${m}m ${s % 60}s`;
|
|
359
|
+
return `${s}s`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function truncate(s: string, n: number): string {
|
|
363
|
+
return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Zoom window: one mapping every position computation goes through ────
|
|
367
|
+
/**
|
|
368
|
+
* The visible window in seconds. Falls back to the whole video until the
|
|
369
|
+
* duration effect has seeded viewEnd, so a first paint that races metadata
|
|
370
|
+
* still maps positions correctly instead of collapsing to zero.
|
|
371
|
+
*/
|
|
372
|
+
function spanSec(): number {
|
|
373
|
+
return viewEnd > viewStart ? viewEnd - viewStart : effectiveDuration;
|
|
374
|
+
}
|
|
375
|
+
function isZoomed(): boolean {
|
|
376
|
+
return viewEnd > 0 && spanSec() < effectiveDuration - 0.01;
|
|
377
|
+
}
|
|
378
|
+
function resetZoom() {
|
|
379
|
+
viewStart = 0;
|
|
380
|
+
viewEnd = effectiveDuration;
|
|
381
|
+
}
|
|
382
|
+
function secToFrac(sec: number): number {
|
|
383
|
+
const span = spanSec();
|
|
384
|
+
return span > 0 ? (sec - viewStart) / span : 0;
|
|
385
|
+
}
|
|
386
|
+
function fracToSec(frac: number): number {
|
|
387
|
+
return viewStart + frac * spanSec();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function flashZoomLabel() {
|
|
391
|
+
const d = effectiveDuration;
|
|
392
|
+
const span = spanSec();
|
|
393
|
+
const ratio = span > 0 ? d / span : 1;
|
|
394
|
+
zoomLabel = (ratio < 1.05 ? '1' : ratio.toFixed(1).replace(/\.0$/, '')) + '×';
|
|
395
|
+
zoomLabelOn = true;
|
|
396
|
+
if (zoomTimer) clearTimeout(zoomTimer);
|
|
397
|
+
zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function flashOsd(text: string) {
|
|
401
|
+
osd = text;
|
|
402
|
+
osdOn = true;
|
|
403
|
+
if (osdTimer) clearTimeout(osdTimer);
|
|
404
|
+
osdTimer = setTimeout(() => (osdOn = false), 900);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Seeking ─────────────────────────────────────────────────────────────
|
|
408
|
+
function seekTo(sec: number) {
|
|
409
|
+
const d = effectiveDuration;
|
|
410
|
+
const t = Math.max(0, d > 0 ? Math.min(sec, d) : sec);
|
|
411
|
+
currentTime = t; // bind:currentTime pushes this to the element
|
|
412
|
+
if (videoEl) videoEl.currentTime = t;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Click-to-seek biases toward the START of the spoken line under the click —
|
|
417
|
+
* nobody wants to land mid-sentence. Only when the transcript is dense right
|
|
418
|
+
* there, though: on a sparse transcript the containing line could have begun
|
|
419
|
+
* minutes earlier, and biasing to it would fling the seek somewhere the user
|
|
420
|
+
* never clicked.
|
|
421
|
+
*/
|
|
422
|
+
const DENSITY_WINDOW_SEC = 60;
|
|
423
|
+
function biasToSegmentStart(sec: number): number {
|
|
424
|
+
if (sortedSegments.length === 0) return sec;
|
|
425
|
+
let nearby = 0;
|
|
426
|
+
for (const s of sortedSegments) {
|
|
427
|
+
if (Math.abs(s.startSec - sec) <= DENSITY_WINDOW_SEC / 2) nearby++;
|
|
428
|
+
}
|
|
429
|
+
if (nearby <= 1) return sec;
|
|
430
|
+
return segmentAt(sec)?.startSec ?? sec;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Last line starting at or before `sec` — segments are ascending. */
|
|
434
|
+
function segmentAt(sec: number): VideoTranscriptSegment | null {
|
|
435
|
+
let best: VideoTranscriptSegment | null = null;
|
|
436
|
+
for (const s of sortedSegments) {
|
|
437
|
+
if (s.startSec <= sec) best = s;
|
|
438
|
+
else break;
|
|
439
|
+
}
|
|
440
|
+
return best;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function chapterAt(sec: number): number {
|
|
444
|
+
let idx = -1;
|
|
445
|
+
for (let i = 0; i < sortedChapters.length; i++) {
|
|
446
|
+
if (sortedChapters[i].startSec <= sec) idx = i;
|
|
447
|
+
else break;
|
|
448
|
+
}
|
|
449
|
+
return idx;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const BOOKMARK_HIT_PX = 6;
|
|
453
|
+
function nearestBookmark(xCss: number, width: number): VideoBookmark | null {
|
|
454
|
+
if (sortedBookmarks.length === 0 || !width) return null;
|
|
455
|
+
let best: VideoBookmark | null = null;
|
|
456
|
+
let bestD = BOOKMARK_HIT_PX;
|
|
457
|
+
for (const b of sortedBookmarks) {
|
|
458
|
+
const d = Math.abs(secToFrac(b.playbackSec) * width - xCss);
|
|
459
|
+
if (d < bestD) {
|
|
460
|
+
bestD = d;
|
|
461
|
+
best = b;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return best;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── Canvas ──────────────────────────────────────────────────────────────
|
|
468
|
+
const RAIL_H = 10; // no-waveform fallback: a flat rail instead of an envelope
|
|
469
|
+
|
|
470
|
+
/** Does the active solo/mute filter recede this turn's paint? */
|
|
471
|
+
function filterDims(key: string | null, ad: boolean): boolean {
|
|
472
|
+
if (soloKey !== ALL_KEY) return !(key != null && !ad && key === soloKey);
|
|
473
|
+
return !!(key != null && !ad && mutedKeys.has(key));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function draw() {
|
|
477
|
+
const canvas = canvasEl;
|
|
478
|
+
if (!canvas) return;
|
|
479
|
+
const W = canvas.clientWidth;
|
|
480
|
+
const H = canvas.clientHeight;
|
|
481
|
+
if (!W || !H) return;
|
|
482
|
+
const dpr = window.devicePixelRatio || 1;
|
|
483
|
+
if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
|
|
484
|
+
canvas.width = Math.round(W * dpr);
|
|
485
|
+
canvas.height = Math.round(H * dpr);
|
|
486
|
+
}
|
|
487
|
+
const ctx = canvas.getContext('2d');
|
|
488
|
+
if (!ctx) return;
|
|
489
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
490
|
+
ctx.clearRect(0, 0, W, H);
|
|
491
|
+
|
|
492
|
+
const dur = effectiveDuration;
|
|
493
|
+
const accent = getDaisyUIColor('primary');
|
|
494
|
+
const neutral = getDaisyUIColor('base-content');
|
|
495
|
+
const marker = getDaisyUIColor('warning');
|
|
496
|
+
|
|
497
|
+
if (dur <= 0) {
|
|
498
|
+
// No duration yet — paint the empty rail so the band owns its space
|
|
499
|
+
// from the first frame and metadata arriving is never a layout shove.
|
|
500
|
+
ctx.globalAlpha = 0.2;
|
|
501
|
+
ctx.fillStyle = neutral;
|
|
502
|
+
ctx.fillRect(0, (H - RAIL_H) / 2, W, RAIL_H);
|
|
503
|
+
ctx.globalAlpha = 1;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const p = peaks;
|
|
507
|
+
const withWave = !!p;
|
|
508
|
+
const n = p?.length ?? 0;
|
|
509
|
+
// viewEnd is seeded by the duration effect; until then the window is the
|
|
510
|
+
// whole video, so a first paint that races metadata still lands correctly.
|
|
511
|
+
const span = viewEnd - viewStart || dur;
|
|
512
|
+
// Chapter boundaries in px — a column straddling one is skipped (the seam).
|
|
513
|
+
const bounds = sortedChapters.slice(1).map((c) => secToFrac(c.startSec) * W);
|
|
514
|
+
const GAP = 4;
|
|
515
|
+
const nowChapter = chapterAt(currentTime);
|
|
516
|
+
|
|
517
|
+
const visiblePeaks = withWave ? Math.max(1, Math.round((span / dur) * n)) : 0;
|
|
518
|
+
const step = withWave ? Math.max(1.5, W / visiblePeaks) : 2;
|
|
519
|
+
const cols = Math.max(1, Math.ceil(W / step));
|
|
520
|
+
const bw = Math.max(1, step - 1);
|
|
521
|
+
|
|
522
|
+
// Speaker sweep: columns advance monotonically in time and turns are
|
|
523
|
+
// sorted by start, so ONE pointer walks the turn list across the whole
|
|
524
|
+
// draw — never a per-column find(). Turns can overlap (an ad read inside
|
|
525
|
+
// a speaker's turn), so the sweep keeps a stack of open turns and the
|
|
526
|
+
// latest start wins, matching the lane's paint order.
|
|
527
|
+
let ti = 0;
|
|
528
|
+
const open: VideoSpeakerTurn[] = [];
|
|
529
|
+
|
|
530
|
+
for (let ci = 0; ci < cols; ci++) {
|
|
531
|
+
const x = ci * step;
|
|
532
|
+
if (bounds.some((b) => Math.abs(x + bw / 2 - b) < GAP)) continue;
|
|
533
|
+
const sec = fracToSec((x + step / 2) / W);
|
|
534
|
+
while (ti < sortedTurns.length && sortedTurns[ti].startSec <= sec) open.push(sortedTurns[ti++]);
|
|
535
|
+
while (open.length && open[open.length - 1].endSec <= sec) open.pop();
|
|
536
|
+
const turn = laneCarriesSpeakers ? null : (open[open.length - 1] ?? null);
|
|
537
|
+
const turnKey = turn && !turn.ad ? speakerKeyOf(turn) : null;
|
|
538
|
+
|
|
539
|
+
const ch = sortedChapters.length ? chapterAt(sec) : -1;
|
|
540
|
+
const played = sec <= currentTime;
|
|
541
|
+
const hovered = ch >= 0 && ch === hoverChapter;
|
|
542
|
+
|
|
543
|
+
// Unplayed floor is 0.38 rather than player.js's 0.3: that value was
|
|
544
|
+
// tuned against the theater's near-black backdrop, and on a light
|
|
545
|
+
// theme's base-200 the envelope washed out at it.
|
|
546
|
+
let alpha: number;
|
|
547
|
+
if (played) alpha = hovered ? 1 : 0.85;
|
|
548
|
+
else if (hovered) alpha = 0.6;
|
|
549
|
+
else if (ch >= 0 && ch === nowChapter) alpha = 0.52;
|
|
550
|
+
else alpha = 0.38;
|
|
551
|
+
|
|
552
|
+
// Legend hover extends onto the envelope: the named speaker at full
|
|
553
|
+
// strength, everything else at 40% of its normal alpha — the
|
|
554
|
+
// played/unplayed ladder survives inside both halves.
|
|
555
|
+
if (highlightKey != null) {
|
|
556
|
+
if (turnKey === highlightKey) alpha = played ? 1 : 0.8;
|
|
557
|
+
else alpha *= 0.4;
|
|
558
|
+
} else if (filterDims(turnKey, !!turn?.ad)) {
|
|
559
|
+
alpha *= 0.4;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
ctx.globalAlpha = alpha;
|
|
563
|
+
if (turn?.ad) ctx.fillStyle = marker;
|
|
564
|
+
else if (turnKey) ctx.fillStyle = speakerColor(turnKey);
|
|
565
|
+
else ctx.fillStyle = played ? accent : neutral;
|
|
566
|
+
|
|
567
|
+
const h = withWave ? Math.max(2, (p as number[])[peakIdx(sec, dur, n)] * (H - 4)) : RAIL_H;
|
|
568
|
+
ctx.fillRect(x, withWave ? H - h : (H - RAIL_H) / 2, bw, h);
|
|
569
|
+
}
|
|
570
|
+
ctx.globalAlpha = 1;
|
|
571
|
+
|
|
572
|
+
// Boundary notches: a hairline in each seam plus a small accent flag on
|
|
573
|
+
// top — the flag reads as a UI marker even when the envelope is dense.
|
|
574
|
+
// Flags within a few px of each other collapse to one so a long video at
|
|
575
|
+
// full zoom-out never smears them into a solid bar.
|
|
576
|
+
ctx.globalAlpha = 0.4;
|
|
577
|
+
ctx.fillStyle = neutral;
|
|
578
|
+
for (const b of bounds) ctx.fillRect(b - 1, 2, 2, H - 4);
|
|
579
|
+
ctx.globalAlpha = 0.95;
|
|
580
|
+
ctx.fillStyle = accent;
|
|
581
|
+
const FLAG_MERGE_PX = 6;
|
|
582
|
+
let lastFlagX = -Infinity;
|
|
583
|
+
for (const b of bounds) {
|
|
584
|
+
if (b - lastFlagX < FLAG_MERGE_PX) continue;
|
|
585
|
+
lastFlagX = b;
|
|
586
|
+
ctx.beginPath();
|
|
587
|
+
ctx.moveTo(b - 3, 0);
|
|
588
|
+
ctx.lineTo(b + 3, 0);
|
|
589
|
+
ctx.lineTo(b, 4);
|
|
590
|
+
ctx.closePath();
|
|
591
|
+
ctx.fill();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Bookmarks: diamonds pointing up from the BOTTOM edge — the mirror image
|
|
595
|
+
// of the chapter flags on top, so yours and the model's never blur together.
|
|
596
|
+
ctx.fillStyle = marker;
|
|
597
|
+
for (const b of sortedBookmarks) {
|
|
598
|
+
const x = secToFrac(b.playbackSec) * W;
|
|
599
|
+
if (x < -4 || x > W + 4) continue;
|
|
600
|
+
ctx.beginPath();
|
|
601
|
+
ctx.moveTo(x, H);
|
|
602
|
+
ctx.lineTo(x - 3, H - 4);
|
|
603
|
+
ctx.lineTo(x, H - 8);
|
|
604
|
+
ctx.lineTo(x + 3, H - 4);
|
|
605
|
+
ctx.closePath();
|
|
606
|
+
ctx.fill();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Playhead
|
|
610
|
+
ctx.globalAlpha = 0.9;
|
|
611
|
+
ctx.fillStyle = neutral;
|
|
612
|
+
ctx.fillRect(Math.max(0, Math.min(1, secToFrac(currentTime))) * W - 0.5, 0, 1.5, H);
|
|
613
|
+
ctx.globalAlpha = 1;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function peakIdx(sec: number, dur: number, n: number): number {
|
|
617
|
+
return Math.max(0, Math.min(n - 1, Math.floor((sec / dur) * n)));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Repaint on any input the canvas reads. Deps are listed explicitly and the
|
|
621
|
+
// body is untracked — draw() touches a lot of state, and letting it register
|
|
622
|
+
// its own reads is exactly the effect-dep leak that has bitten this library
|
|
623
|
+
// before (PreJoinLobby's acquire loop).
|
|
624
|
+
$effect(() => {
|
|
625
|
+
// canvasEl is a dep, not just a read inside draw(): bind:this lands after
|
|
626
|
+
// the first effect run, so without it the very first paint is skipped and
|
|
627
|
+
// nothing ever re-triggers one.
|
|
628
|
+
void canvasEl;
|
|
629
|
+
void currentTime;
|
|
630
|
+
void viewStart;
|
|
631
|
+
void viewEnd;
|
|
632
|
+
void hoverChapter;
|
|
633
|
+
void highlightKey;
|
|
634
|
+
void soloKey;
|
|
635
|
+
void mutedKeys;
|
|
636
|
+
void sortedChapters;
|
|
637
|
+
void sortedTurns;
|
|
638
|
+
void sortedBookmarks;
|
|
639
|
+
void speakerColors;
|
|
640
|
+
void laneCarriesSpeakers;
|
|
641
|
+
void peaks;
|
|
642
|
+
void effectiveDuration;
|
|
643
|
+
void canvasW;
|
|
644
|
+
void canvasH;
|
|
645
|
+
void themeTick;
|
|
646
|
+
untrack(() => draw());
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// A new duration means the old zoom window may no longer make sense.
|
|
650
|
+
$effect(() => {
|
|
651
|
+
void effectiveDuration;
|
|
652
|
+
untrack(() => resetZoom());
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// The band gets resized by things this component can't see (a panel toggling,
|
|
656
|
+
// the window, the side-transcript breakpoint), so the canvas tracks its own
|
|
657
|
+
// box. Set up from an $effect keyed on canvasEl rather than onMount: bind:this
|
|
658
|
+
// isn't guaranteed to have landed by the time onMount runs, and a missed
|
|
659
|
+
// observer means a canvas that never paints at all.
|
|
660
|
+
$effect(() => {
|
|
661
|
+
const el = canvasEl;
|
|
662
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
663
|
+
const ro = new ResizeObserver((entries) => {
|
|
664
|
+
const r = entries[0]?.contentRect;
|
|
665
|
+
if (!r) return;
|
|
666
|
+
canvasW = Math.round(r.width);
|
|
667
|
+
canvasH = Math.round(r.height);
|
|
668
|
+
});
|
|
669
|
+
ro.observe(el);
|
|
670
|
+
return () => ro.disconnect();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
onMount(() => {
|
|
674
|
+
const stopTheme = watchThemeChanges(() => {
|
|
675
|
+
clearColorCache();
|
|
676
|
+
themeTick++;
|
|
677
|
+
});
|
|
678
|
+
return () => {
|
|
679
|
+
stopTheme();
|
|
680
|
+
if (zoomTimer) clearTimeout(zoomTimer);
|
|
681
|
+
if (osdTimer) clearTimeout(osdTimer);
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// ── Band pointer handlers ───────────────────────────────────────────────
|
|
686
|
+
function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
|
|
687
|
+
const canvas = canvasEl;
|
|
688
|
+
if (!canvas) return null;
|
|
689
|
+
const r = canvas.getBoundingClientRect();
|
|
690
|
+
if (!r.width || spanSec() <= 0) return null;
|
|
691
|
+
const x = e.clientX - r.left;
|
|
692
|
+
return { sec: fracToSec(x / r.width), x, width: r.width };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function onBandClick(e: MouseEvent) {
|
|
696
|
+
const hit = canvasSec(e);
|
|
697
|
+
if (!hit) return;
|
|
698
|
+
seekTo(biasToSegmentStart(hit.sec));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function onBandDblClick() {
|
|
702
|
+
if (!isZoomed()) return;
|
|
703
|
+
resetZoom();
|
|
704
|
+
flashZoomLabel();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function onBandContextMenu(e: MouseEvent) {
|
|
708
|
+
if (!onBookmarkContext) return;
|
|
709
|
+
const hit = canvasSec(e);
|
|
710
|
+
if (!hit) return;
|
|
711
|
+
e.preventDefault();
|
|
712
|
+
const near = nearestBookmark(hit.x, hit.width);
|
|
713
|
+
onBookmarkContext(near ? near.playbackSec : hit.sec, near, e.clientX, e.clientY);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Keep the (centre-anchored) hover card inside the band's own width. */
|
|
717
|
+
const CARD_HALF_PX = 90;
|
|
718
|
+
function clampCardX(x: number, width: number): number {
|
|
719
|
+
return Math.max(Math.min(CARD_HALF_PX, width / 2), Math.min(x, width - CARD_HALF_PX));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function onBandMove(e: MouseEvent) {
|
|
723
|
+
const hit = canvasSec(e);
|
|
724
|
+
if (!hit) return;
|
|
725
|
+
const cardX = clampCardX(hit.x, hit.width);
|
|
726
|
+
const bm = nearestBookmark(hit.x, hit.width);
|
|
727
|
+
if (bm) {
|
|
728
|
+
// A bookmark under the pointer takes over the card entirely — it's
|
|
729
|
+
// yours, and it outranks the model's chapter guess at this exact spot.
|
|
730
|
+
hover = {
|
|
731
|
+
x: cardX,
|
|
732
|
+
title: bm.label || formatTime(bm.playbackSec, useHours),
|
|
733
|
+
text: bm.note ? truncate(bm.note, 90) : formatTime(bm.playbackSec, useHours),
|
|
734
|
+
thumb: null,
|
|
735
|
+
isBookmark: true
|
|
736
|
+
};
|
|
737
|
+
if (hoverChapter !== -1) hoverChapter = -1;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const ch = chapterAt(hit.sec);
|
|
741
|
+
const title = ch >= 0 ? sortedChapters[ch]?.title : '';
|
|
742
|
+
const at = segmentAt(hit.sec);
|
|
743
|
+
hover = {
|
|
744
|
+
x: cardX,
|
|
745
|
+
title: title ? `${title} · ${formatTime(hit.sec, useHours)}` : formatTime(hit.sec, useHours),
|
|
746
|
+
text: at?.text ? truncate(at.text, 90) : '',
|
|
747
|
+
thumb: ch >= 0 ? (sortedChapters[ch]?.thumbUrl ?? null) : null,
|
|
748
|
+
isBookmark: false
|
|
749
|
+
};
|
|
750
|
+
if (ch !== hoverChapter) hoverChapter = ch;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function onBandLeave() {
|
|
754
|
+
hover = null;
|
|
755
|
+
if (hoverChapter !== -1) hoverChapter = -1;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Scroll over the band to zoom, centred on the cursor — the DAW/mpv
|
|
760
|
+
* convention: the point under the pointer stays fixed while the window
|
|
761
|
+
* resizes around it, so scrolling repeatedly walks you inward on exactly the
|
|
762
|
+
* spot you're pointing at. Plain scroll, no modifier: the band is its own
|
|
763
|
+
* hover region (distinct from the video, which owns volume), so nothing else
|
|
764
|
+
* here claims the wheel.
|
|
765
|
+
*/
|
|
766
|
+
const ZOOM_FACTOR = 1.3;
|
|
767
|
+
const MIN_WINDOW_SEC = 3;
|
|
768
|
+
function onBandWheel(e: WheelEvent) {
|
|
769
|
+
const d = effectiveDuration;
|
|
770
|
+
const canvas = canvasEl;
|
|
771
|
+
if (!d || !canvas) return;
|
|
772
|
+
const r = canvas.getBoundingClientRect();
|
|
773
|
+
const w = r.width;
|
|
774
|
+
if (!w) return;
|
|
775
|
+
e.preventDefault();
|
|
776
|
+
if (viewEnd <= 0) resetZoom();
|
|
777
|
+
const cursorSec = fracToSec(Math.max(0, Math.min(1, (e.clientX - r.left) / w)));
|
|
778
|
+
const span = spanSec();
|
|
779
|
+
let newSpan = e.deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
|
|
780
|
+
newSpan = Math.min(d, Math.max(MIN_WINDOW_SEC, newSpan));
|
|
781
|
+
const frac = span > 0 ? (cursorSec - viewStart) / span : 0.5;
|
|
782
|
+
viewStart = Math.max(0, Math.min(cursorSec - frac * newSpan, d - newSpan));
|
|
783
|
+
viewEnd = viewStart + newSpan;
|
|
784
|
+
flashZoomLabel();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Keyboard seek on the band — it advertises `role="slider"`, so it must honour arrows. */
|
|
788
|
+
function onBandKeyDown(e: KeyboardEvent) {
|
|
789
|
+
const d = effectiveDuration;
|
|
790
|
+
if (!d) return;
|
|
791
|
+
const step = e.shiftKey ? 10 : 5;
|
|
792
|
+
if (e.key === 'ArrowLeft') seekTo(currentTime - step);
|
|
793
|
+
else if (e.key === 'ArrowRight') seekTo(currentTime + step);
|
|
794
|
+
else if (e.key === 'Home') seekTo(0);
|
|
795
|
+
else if (e.key === 'End') seekTo(d);
|
|
796
|
+
else if (e.key === 'Escape' && isZoomed()) resetZoom();
|
|
797
|
+
else return;
|
|
798
|
+
e.preventDefault();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ── Video region: wheel = volume (shift = seek), plus a keyboard map ─────
|
|
802
|
+
function onStageWheel(e: WheelEvent) {
|
|
803
|
+
if (!videoEl) return;
|
|
804
|
+
e.preventDefault();
|
|
805
|
+
if (e.shiftKey) {
|
|
806
|
+
const delta = e.deltaY < 0 ? 5 : -5;
|
|
807
|
+
seekTo(currentTime + delta);
|
|
808
|
+
flashOsd(`${delta > 0 ? '+' : ''}${delta}s · ${formatTime(currentTime + delta, useHours)}`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const next = Math.min(1, Math.max(0, videoEl.volume - Math.sign(e.deltaY) * 0.05));
|
|
812
|
+
videoEl.volume = next;
|
|
813
|
+
if (next > 0) videoEl.muted = false;
|
|
814
|
+
flashOsd(`${Math.round(next * 100)}%`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function onRootKeyDown(e: KeyboardEvent) {
|
|
818
|
+
const target = e.target as HTMLElement | null;
|
|
819
|
+
const tag = target?.tagName;
|
|
820
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
|
|
821
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
822
|
+
const v = videoEl;
|
|
823
|
+
if (!v) return;
|
|
824
|
+
switch (e.key) {
|
|
825
|
+
case ' ':
|
|
826
|
+
case 'k':
|
|
827
|
+
v.paused ? v.play() : v.pause();
|
|
828
|
+
break;
|
|
829
|
+
case 'ArrowLeft':
|
|
830
|
+
case 'j':
|
|
831
|
+
seekTo(currentTime - (e.shiftKey ? 10 : 5));
|
|
832
|
+
break;
|
|
833
|
+
case 'ArrowRight':
|
|
834
|
+
case 'l':
|
|
835
|
+
seekTo(currentTime + (e.shiftKey ? 10 : 5));
|
|
836
|
+
break;
|
|
837
|
+
case 'ArrowUp':
|
|
838
|
+
v.volume = Math.min(1, v.volume + 0.05);
|
|
839
|
+
flashOsd(`${Math.round(v.volume * 100)}%`);
|
|
840
|
+
break;
|
|
841
|
+
case 'ArrowDown':
|
|
842
|
+
v.volume = Math.max(0, v.volume - 0.05);
|
|
843
|
+
flashOsd(`${Math.round(v.volume * 100)}%`);
|
|
844
|
+
break;
|
|
845
|
+
case 'm':
|
|
846
|
+
v.muted = !v.muted;
|
|
847
|
+
flashOsd(v.muted ? 'Muted' : `${Math.round(v.volume * 100)}%`);
|
|
848
|
+
break;
|
|
849
|
+
case 'Home':
|
|
850
|
+
seekTo(0);
|
|
851
|
+
break;
|
|
852
|
+
case 'End':
|
|
853
|
+
seekTo(effectiveDuration);
|
|
854
|
+
break;
|
|
855
|
+
default:
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
e.preventDefault();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── Playback: startAt + solo-speaker skipping ───────────────────────────
|
|
862
|
+
let appliedStartAt = false;
|
|
863
|
+
function onLoadedMetadata() {
|
|
864
|
+
if (!appliedStartAt && startAt != null && Number.isFinite(startAt)) {
|
|
865
|
+
appliedStartAt = true;
|
|
866
|
+
seekTo(startAt);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Solo playback: if we're not inside a turn belonging to the selected
|
|
872
|
+
* speaker, jump forward to their next one. With no later turn we pause
|
|
873
|
+
* rather than let playback run on into someone else's talk time unchecked.
|
|
874
|
+
*/
|
|
875
|
+
function applySoloSkip() {
|
|
876
|
+
if (soloKey === ALL_KEY || !videoEl) return;
|
|
877
|
+
const t = videoEl.currentTime;
|
|
878
|
+
const source: { startSec: number; endSec: number; speakerIdentity?: string | null; speakerName?: string | null; ad?: boolean }[] =
|
|
879
|
+
sortedTurns.length > 0 ? sortedTurns : sortedSegments;
|
|
880
|
+
const mine = source.filter((s) => !('ad' in s && s.ad) && speakerKeyOf(s) === soloKey);
|
|
881
|
+
if (mine.length === 0) return;
|
|
882
|
+
if (mine.some((s) => t >= s.startSec && t < s.endSec)) return;
|
|
883
|
+
const next = mine.find((s) => s.startSec > t);
|
|
884
|
+
if (next) videoEl.currentTime = next.startSec;
|
|
885
|
+
else videoEl.pause();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function handleTimeUpdate() {
|
|
889
|
+
applySoloSkip();
|
|
890
|
+
// Zoomed in and playback walked past the edge of the window — follow it,
|
|
891
|
+
// or you'd watch a frozen slice of waveform while the video plays on.
|
|
892
|
+
if (isZoomed() && (currentTime < viewStart || currentTime > viewEnd)) {
|
|
893
|
+
const span = spanSec();
|
|
894
|
+
const d = effectiveDuration;
|
|
895
|
+
viewStart = Math.max(0, Math.min(currentTime - span / 2, Math.max(0, d - span)));
|
|
896
|
+
viewEnd = viewStart + span;
|
|
897
|
+
}
|
|
898
|
+
if (videoEl) onTimeUpdate?.(videoEl);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Re-run the solo scan the moment the selection changes rather than waiting
|
|
902
|
+
// for the next native timeupdate tick.
|
|
903
|
+
$effect(() => {
|
|
904
|
+
void soloKey;
|
|
905
|
+
untrack(() => applySoloSkip());
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// Auto-scroll the active transcript row into view as playback advances.
|
|
909
|
+
$effect(() => {
|
|
910
|
+
const seg = activeSegment;
|
|
911
|
+
if (!seg) return;
|
|
912
|
+
untrack(() => rowRefs[seg.id]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// ── Legend interactions ─────────────────────────────────────────────────
|
|
916
|
+
function toggleSolo(key: string) {
|
|
917
|
+
soloKey = soloKey === key ? ALL_KEY : key;
|
|
918
|
+
}
|
|
919
|
+
function toggleMute(key: string) {
|
|
920
|
+
const next = new Set(mutedKeys);
|
|
921
|
+
if (next.has(key)) next.delete(key);
|
|
922
|
+
else next.add(key);
|
|
923
|
+
mutedKeys = next;
|
|
924
|
+
}
|
|
925
|
+
function talkTimeKey(t: VideoSpeakerTalkTime): string {
|
|
926
|
+
return t.speakerIdentity ?? t.speakerName ?? UNKNOWN_KEY;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function submitBookmark() {
|
|
930
|
+
onAddBookmark?.(currentTime, bookmarkLabel.trim());
|
|
931
|
+
bookmarkLabel = '';
|
|
932
|
+
}
|
|
933
|
+
</script>
|
|
934
|
+
|
|
935
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
936
|
+
<div
|
|
937
|
+
class="vp-root {className}"
|
|
938
|
+
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0}
|
|
939
|
+
role="region"
|
|
940
|
+
aria-label="Video player"
|
|
941
|
+
tabindex="-1"
|
|
942
|
+
onkeydown={onRootKeyDown}
|
|
943
|
+
>
|
|
944
|
+
<div class="vp-main">
|
|
945
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
946
|
+
<div class="vp-stage" onwheel={onStageWheel}>
|
|
947
|
+
<video
|
|
948
|
+
bind:this={videoEl}
|
|
949
|
+
bind:currentTime
|
|
950
|
+
bind:duration
|
|
951
|
+
ontimeupdate={handleTimeUpdate}
|
|
952
|
+
onloadedmetadata={onLoadedMetadata}
|
|
953
|
+
{src}
|
|
954
|
+
poster={poster ?? undefined}
|
|
955
|
+
controls
|
|
956
|
+
class="vp-video"
|
|
957
|
+
>
|
|
958
|
+
<!-- A captions track is always present for a11y; without a src it is
|
|
959
|
+
an empty, inert track rather than a missing affordance. -->
|
|
960
|
+
<track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc} />
|
|
961
|
+
</video>
|
|
962
|
+
<div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
963
|
+
</div>
|
|
964
|
+
|
|
965
|
+
<!-- The band: one time axis for the envelope, chapters, speakers and
|
|
966
|
+
bookmarks. Its height is reserved from the first frame, so data
|
|
967
|
+
arriving late fades into already-owned space. -->
|
|
968
|
+
<div
|
|
969
|
+
class="vp-band"
|
|
970
|
+
onwheel={onBandWheel}
|
|
971
|
+
onmousemove={onBandMove}
|
|
972
|
+
onmouseleave={onBandLeave}
|
|
973
|
+
role="presentation"
|
|
974
|
+
>
|
|
975
|
+
<canvas
|
|
976
|
+
bind:this={canvasEl}
|
|
977
|
+
class="vp-wave"
|
|
978
|
+
class:vp-wave-tall={hasWave}
|
|
979
|
+
role="slider"
|
|
980
|
+
tabindex="0"
|
|
981
|
+
aria-label="Seek"
|
|
982
|
+
aria-valuemin={0}
|
|
983
|
+
aria-valuemax={Math.round(effectiveDuration)}
|
|
984
|
+
aria-valuenow={Math.round(currentTime)}
|
|
985
|
+
aria-valuetext={formatTime(currentTime, useHours)}
|
|
986
|
+
onclick={onBandClick}
|
|
987
|
+
ondblclick={onBandDblClick}
|
|
988
|
+
oncontextmenu={onBandContextMenu}
|
|
989
|
+
onkeydown={onBandKeyDown}
|
|
990
|
+
></canvas>
|
|
991
|
+
|
|
992
|
+
<!-- Speaker lane. Painted ONLY as the no-waveform fallback: with an
|
|
993
|
+
envelope its own bars wear the turn colours, and two stacked
|
|
994
|
+
time-parallel strips would just restate the same fact twice. The
|
|
995
|
+
empty div still renders so the slot is reserved from the first
|
|
996
|
+
frame — diarization is slow, and turns arriving minutes into a
|
|
997
|
+
session must fade into owned space, never shove the layout.
|
|
998
|
+
Purely informational: the band already owns seek/scrub/zoom, so
|
|
999
|
+
the lane takes no pointer events and grows no affordances. -->
|
|
1000
|
+
<div class="vp-lane" aria-hidden="true">
|
|
1001
|
+
{#if laneCarriesSpeakers}
|
|
1002
|
+
{#each sortedTurns as turn, i (turn.startSec + ':' + i)}
|
|
1003
|
+
{@const key = speakerKeyOf(turn)}
|
|
1004
|
+
{@const l = secToFrac(turn.startSec) * 100}
|
|
1005
|
+
{@const r = secToFrac(turn.endSec) * 100}
|
|
1006
|
+
{#if turn.endSec > viewStart && turn.startSec < (viewEnd || effectiveDuration)}
|
|
1007
|
+
<span
|
|
1008
|
+
class="vp-lane-span"
|
|
1009
|
+
class:vp-lane-ad={turn.ad}
|
|
1010
|
+
class:vp-dim={highlightKey != null
|
|
1011
|
+
? highlightKey !== key
|
|
1012
|
+
: filterDims(turn.ad ? null : key, !!turn.ad)}
|
|
1013
|
+
style="left: {Math.max(0, Math.min(100, l))}%; width: max({Math.max(
|
|
1014
|
+
0,
|
|
1015
|
+
Math.min(100, r) - Math.max(0, l)
|
|
1016
|
+
)}%, 0.35%); {turn.ad ? '' : `background: ${speakerColor(key)};`}"
|
|
1017
|
+
></span>
|
|
1018
|
+
{/if}
|
|
1019
|
+
{/each}
|
|
1020
|
+
{/if}
|
|
1021
|
+
</div>
|
|
1022
|
+
|
|
1023
|
+
<div class="vp-zoom" class:on={zoomLabelOn} aria-hidden="true">{zoomLabel}</div>
|
|
1024
|
+
|
|
1025
|
+
{#if hover}
|
|
1026
|
+
<div
|
|
1027
|
+
class="vp-card"
|
|
1028
|
+
class:has-thumb={!!hover.thumb}
|
|
1029
|
+
style="left: {hover.x}px"
|
|
1030
|
+
aria-hidden="true"
|
|
1031
|
+
>
|
|
1032
|
+
{#if hover.thumb}
|
|
1033
|
+
<img class="vp-card-thumb" src={hover.thumb} alt="" />
|
|
1034
|
+
{/if}
|
|
1035
|
+
<div class="vp-card-title">
|
|
1036
|
+
{#if hover.isBookmark}
|
|
1037
|
+
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true" class="vp-bm-icon">
|
|
1038
|
+
<path d="M6 2a2 2 0 0 0-2 2v18l8-5 8 5V4a2 2 0 0 0-2-2H6z" fill="currentColor" />
|
|
1039
|
+
</svg>
|
|
1040
|
+
{/if}
|
|
1041
|
+
{hover.title}
|
|
1042
|
+
</div>
|
|
1043
|
+
{#if hover.text}<div class="vp-card-text">{hover.text}</div>{/if}
|
|
1044
|
+
</div>
|
|
1045
|
+
{/if}
|
|
1046
|
+
</div>
|
|
1047
|
+
|
|
1048
|
+
<div class="vp-times">
|
|
1049
|
+
<span>{formatTime(currentTime, useHours)}</span>
|
|
1050
|
+
{#if isZoomed()}
|
|
1051
|
+
<button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
|
|
1052
|
+
Reset zoom
|
|
1053
|
+
</button>
|
|
1054
|
+
{/if}
|
|
1055
|
+
<span>{formatTime(effectiveDuration, useHours)}</span>
|
|
1056
|
+
</div>
|
|
1057
|
+
|
|
1058
|
+
{#if hasSpeakers}
|
|
1059
|
+
<!-- Talk-time doubles as the speaker legend: hover a chip to raise that
|
|
1060
|
+
speaker on the timeline, click to solo, and use its mute toggle to
|
|
1061
|
+
skip them. Identification through brightness, never a glow. -->
|
|
1062
|
+
<div class="vp-talktime">
|
|
1063
|
+
<div class="vp-talktime-bar">
|
|
1064
|
+
{#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
|
|
1065
|
+
<div
|
|
1066
|
+
class="vp-talktime-seg"
|
|
1067
|
+
style="width: {(tt.seconds / talkTimeTotal) * 100}%; background: {speakerColor(
|
|
1068
|
+
talkTimeKey(tt)
|
|
1069
|
+
)}"
|
|
1070
|
+
title="{tt.speakerName ?? 'Unknown speaker'} — {formatDuration(tt.seconds)}"
|
|
1071
|
+
></div>
|
|
1072
|
+
{/each}
|
|
1073
|
+
</div>
|
|
1074
|
+
<div class="vp-legend">
|
|
1075
|
+
{#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
|
|
1076
|
+
{@const key = talkTimeKey(tt)}
|
|
1077
|
+
<span class="vp-legend-item">
|
|
1078
|
+
<button
|
|
1079
|
+
type="button"
|
|
1080
|
+
class="vp-chip"
|
|
1081
|
+
class:solo={soloKey === key}
|
|
1082
|
+
class:vp-dim={mutedKeys.has(key) || (soloKey !== ALL_KEY && soloKey !== key)}
|
|
1083
|
+
onmouseenter={() => (highlightKey = key)}
|
|
1084
|
+
onmouseleave={() => (highlightKey = null)}
|
|
1085
|
+
onfocus={() => (highlightKey = key)}
|
|
1086
|
+
onblur={() => (highlightKey = null)}
|
|
1087
|
+
onclick={() => toggleSolo(key)}
|
|
1088
|
+
aria-pressed={soloKey === key}
|
|
1089
|
+
title="Solo {tt.speakerName ?? 'Unknown speaker'}"
|
|
1090
|
+
>
|
|
1091
|
+
<span class="vp-chip-dot" style="background: {speakerColor(key)}"></span>
|
|
1092
|
+
{tt.speakerName ?? 'Unknown speaker'}
|
|
1093
|
+
<span class="vp-chip-time">{formatDuration(tt.seconds)}</span>
|
|
1094
|
+
</button>
|
|
1095
|
+
<button
|
|
1096
|
+
type="button"
|
|
1097
|
+
class="vp-mute"
|
|
1098
|
+
class:on={mutedKeys.has(key)}
|
|
1099
|
+
onclick={() => toggleMute(key)}
|
|
1100
|
+
aria-pressed={mutedKeys.has(key)}
|
|
1101
|
+
aria-label="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} {tt.speakerName ??
|
|
1102
|
+
'Unknown speaker'}"
|
|
1103
|
+
title="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} this speaker"
|
|
1104
|
+
>
|
|
1105
|
+
{mutedKeys.has(key) ? '🔇' : '🔈'}
|
|
1106
|
+
</button>
|
|
1107
|
+
</span>
|
|
1108
|
+
{/each}
|
|
1109
|
+
{#if soloKey !== ALL_KEY || mutedKeys.size > 0}
|
|
1110
|
+
<button
|
|
1111
|
+
type="button"
|
|
1112
|
+
class="vp-chip vp-chip-clear"
|
|
1113
|
+
onclick={() => {
|
|
1114
|
+
soloKey = ALL_KEY;
|
|
1115
|
+
mutedKeys = new Set();
|
|
1116
|
+
}}
|
|
1117
|
+
>
|
|
1118
|
+
Clear filter
|
|
1119
|
+
</button>
|
|
1120
|
+
{/if}
|
|
1121
|
+
</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
{/if}
|
|
1124
|
+
|
|
1125
|
+
{#if onAddBookmark}
|
|
1126
|
+
<div class="vp-bookmark-add">
|
|
1127
|
+
<label for="vp-bookmark-label">Bookmark label</label>
|
|
1128
|
+
<input
|
|
1129
|
+
id="vp-bookmark-label"
|
|
1130
|
+
type="text"
|
|
1131
|
+
placeholder="Bookmark label (optional)"
|
|
1132
|
+
bind:value={bookmarkLabel}
|
|
1133
|
+
/>
|
|
1134
|
+
<button type="button" class="vp-add-btn" onclick={submitBookmark}>
|
|
1135
|
+
+ Bookmark at {formatTime(currentTime, useHours)}
|
|
1136
|
+
</button>
|
|
1137
|
+
</div>
|
|
1138
|
+
{/if}
|
|
1139
|
+
</div>
|
|
1140
|
+
|
|
1141
|
+
{#if sortedSegments.length > 0}
|
|
1142
|
+
<div class="vp-transcript" style="max-height: {transcriptMaxHeight}">
|
|
1143
|
+
{#each sortedSegments as seg (seg.id)}
|
|
1144
|
+
<button
|
|
1145
|
+
type="button"
|
|
1146
|
+
class="vp-row"
|
|
1147
|
+
class:active={activeSegment?.id === seg.id}
|
|
1148
|
+
class:vp-dim={highlightKey != null
|
|
1149
|
+
? highlightKey !== speakerKeyOf(seg)
|
|
1150
|
+
: filterDims(speakerKeyOf(seg), false)}
|
|
1151
|
+
bind:this={rowRefs[seg.id]}
|
|
1152
|
+
onclick={() => seekTo(seg.startSec)}
|
|
1153
|
+
>
|
|
1154
|
+
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
1155
|
+
<span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
|
|
1156
|
+
{seg.speakerName ?? 'Unknown speaker'}
|
|
1157
|
+
</span>
|
|
1158
|
+
<span class="vp-row-text">{seg.text}</span>
|
|
1159
|
+
</button>
|
|
1160
|
+
{/each}
|
|
1161
|
+
</div>
|
|
1162
|
+
{/if}
|
|
1163
|
+
</div>
|
|
1164
|
+
|
|
1165
|
+
<style>
|
|
1166
|
+
/* Chrome is token-only: DaisyUI semantic vars for colour, --jatui-* for
|
|
1167
|
+
radius/spacing, --motion-* for easing and duration. The canvas resolves
|
|
1168
|
+
the same semantic colours at runtime via getDaisyUIColor. */
|
|
1169
|
+
.vp-root {
|
|
1170
|
+
display: flex;
|
|
1171
|
+
flex-direction: column;
|
|
1172
|
+
gap: var(--jatui-space-md, 6px);
|
|
1173
|
+
background: var(--color-base-200);
|
|
1174
|
+
border: var(--jatui-border-w, 1px) solid var(--color-base-content, currentColor);
|
|
1175
|
+
border-color: color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
1176
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
1177
|
+
padding: var(--jatui-space-lg, 12px);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
.vp-root:focus {
|
|
1181
|
+
outline: none;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
.vp-main {
|
|
1185
|
+
display: flex;
|
|
1186
|
+
flex-direction: column;
|
|
1187
|
+
gap: var(--jatui-space-md, 6px);
|
|
1188
|
+
min-width: 0;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/* Side layout: transcript beside the video once there's room for both. */
|
|
1192
|
+
@media (min-width: 64rem) {
|
|
1193
|
+
.vp-root.vp-side {
|
|
1194
|
+
display: grid;
|
|
1195
|
+
grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr);
|
|
1196
|
+
gap: var(--jatui-space-lg, 12px);
|
|
1197
|
+
align-items: start;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
.vp-stage {
|
|
1202
|
+
position: relative;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
.vp-video {
|
|
1206
|
+
display: block;
|
|
1207
|
+
width: 100%;
|
|
1208
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1209
|
+
background: var(--color-neutral);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/* Transient wheel readout (volume / seek) — one calm flash, then gone. */
|
|
1213
|
+
.vp-osd {
|
|
1214
|
+
position: absolute;
|
|
1215
|
+
top: var(--jatui-space-base, 8px);
|
|
1216
|
+
left: var(--jatui-space-base, 8px);
|
|
1217
|
+
padding: var(--jatui-space-xs, 2px) var(--jatui-space-base, 8px);
|
|
1218
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
1219
|
+
background: color-mix(in oklch, var(--color-base-100) 88%, transparent);
|
|
1220
|
+
color: var(--color-base-content);
|
|
1221
|
+
font-size: 0.7rem;
|
|
1222
|
+
font-weight: 600;
|
|
1223
|
+
font-variant-numeric: tabular-nums;
|
|
1224
|
+
pointer-events: none;
|
|
1225
|
+
opacity: 0;
|
|
1226
|
+
transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
.vp-osd.on {
|
|
1230
|
+
opacity: 1;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/* The band owns its height from the first frame so a waveform or speaker
|
|
1234
|
+
turns arriving minutes in fade into space that is already theirs. */
|
|
1235
|
+
.vp-band {
|
|
1236
|
+
position: relative;
|
|
1237
|
+
padding-top: var(--jatui-space-base, 8px);
|
|
1238
|
+
opacity: 0.55; /* ambient until the pointer arrives */
|
|
1239
|
+
transition: opacity var(--motion-move, 300ms) var(--motion-glide, ease-out);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
.vp-root:hover .vp-band,
|
|
1243
|
+
.vp-band:focus-within {
|
|
1244
|
+
opacity: 1;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
.vp-wave {
|
|
1248
|
+
display: block;
|
|
1249
|
+
width: 100%;
|
|
1250
|
+
height: 22px;
|
|
1251
|
+
cursor: pointer;
|
|
1252
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
.vp-wave:focus-visible {
|
|
1256
|
+
outline: 2px solid var(--color-primary);
|
|
1257
|
+
outline-offset: 2px;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
.vp-wave-tall {
|
|
1261
|
+
height: 44px;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/* Speaker lane — the no-waveform fallback. Its slot is reserved whether or
|
|
1265
|
+
not turns exist, so arrival is a fade, never a layout shove. */
|
|
1266
|
+
.vp-lane {
|
|
1267
|
+
position: relative;
|
|
1268
|
+
height: 6px;
|
|
1269
|
+
margin-top: var(--jatui-space-xs, 2px);
|
|
1270
|
+
border-radius: 3px;
|
|
1271
|
+
overflow: hidden;
|
|
1272
|
+
pointer-events: none;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
.vp-lane-span {
|
|
1276
|
+
position: absolute;
|
|
1277
|
+
top: 0;
|
|
1278
|
+
bottom: 0;
|
|
1279
|
+
opacity: 0.85;
|
|
1280
|
+
transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/* Ad reads share the lane but read as not-a-person: the warning token,
|
|
1284
|
+
quieter than any voice — never one of the speaker identity colours. */
|
|
1285
|
+
.vp-lane-ad {
|
|
1286
|
+
background: var(--color-warning);
|
|
1287
|
+
opacity: 0.5;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
.vp-dim {
|
|
1291
|
+
opacity: 0.4;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
.vp-lane-span.vp-dim {
|
|
1295
|
+
opacity: 0.3;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
.vp-zoom {
|
|
1299
|
+
position: absolute;
|
|
1300
|
+
top: var(--jatui-space-base, 8px);
|
|
1301
|
+
left: var(--jatui-space-sm, 4px);
|
|
1302
|
+
padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
|
|
1303
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
1304
|
+
background: color-mix(in oklch, var(--color-base-100) 85%, transparent);
|
|
1305
|
+
color: var(--color-base-content);
|
|
1306
|
+
font-size: 0.68rem;
|
|
1307
|
+
font-weight: 600;
|
|
1308
|
+
font-variant-numeric: tabular-nums;
|
|
1309
|
+
pointer-events: none;
|
|
1310
|
+
opacity: 0;
|
|
1311
|
+
transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.vp-zoom.on {
|
|
1315
|
+
opacity: 1;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/* Two-line hover card: what's here on top, what's being said beneath — a
|
|
1319
|
+
timestamp alone doesn't tell you whether a moment is worth a click. */
|
|
1320
|
+
.vp-card {
|
|
1321
|
+
position: absolute;
|
|
1322
|
+
bottom: calc(100% - var(--jatui-space-base, 8px));
|
|
1323
|
+
transform: translateX(-50%);
|
|
1324
|
+
max-width: 22rem;
|
|
1325
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
|
|
1326
|
+
background: var(--color-base-100);
|
|
1327
|
+
border: var(--jatui-border-w, 1px) solid
|
|
1328
|
+
color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
1329
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
1330
|
+
color: var(--color-base-content);
|
|
1331
|
+
font-size: 0.7rem;
|
|
1332
|
+
line-height: 1.4;
|
|
1333
|
+
pointer-events: none;
|
|
1334
|
+
z-index: 5;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
.vp-card.has-thumb {
|
|
1338
|
+
width: 16rem;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.vp-card-thumb {
|
|
1342
|
+
display: block;
|
|
1343
|
+
width: 100%;
|
|
1344
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1345
|
+
margin-bottom: var(--jatui-space-sm, 4px);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
.vp-card-title {
|
|
1349
|
+
display: flex;
|
|
1350
|
+
align-items: center;
|
|
1351
|
+
gap: var(--jatui-space-sm, 4px);
|
|
1352
|
+
font-weight: 600;
|
|
1353
|
+
white-space: nowrap;
|
|
1354
|
+
overflow: hidden;
|
|
1355
|
+
text-overflow: ellipsis;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
.vp-bm-icon {
|
|
1359
|
+
color: var(--color-warning);
|
|
1360
|
+
flex-shrink: 0;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.vp-card-text {
|
|
1364
|
+
margin-top: var(--jatui-space-xs, 2px);
|
|
1365
|
+
color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
|
|
1366
|
+
white-space: nowrap;
|
|
1367
|
+
overflow: hidden;
|
|
1368
|
+
text-overflow: ellipsis;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
.vp-times {
|
|
1372
|
+
display: flex;
|
|
1373
|
+
align-items: center;
|
|
1374
|
+
justify-content: space-between;
|
|
1375
|
+
gap: var(--jatui-space-base, 8px);
|
|
1376
|
+
font-size: 0.68rem;
|
|
1377
|
+
font-variant-numeric: tabular-nums;
|
|
1378
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
.vp-reset-zoom {
|
|
1382
|
+
background: none;
|
|
1383
|
+
border: none;
|
|
1384
|
+
padding: 0;
|
|
1385
|
+
font-size: inherit;
|
|
1386
|
+
color: var(--color-primary);
|
|
1387
|
+
cursor: pointer;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/* Talk-time + legend */
|
|
1391
|
+
.vp-talktime {
|
|
1392
|
+
display: flex;
|
|
1393
|
+
flex-direction: column;
|
|
1394
|
+
gap: var(--jatui-space-sm, 4px);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
.vp-talktime-bar {
|
|
1398
|
+
display: flex;
|
|
1399
|
+
height: 0.6rem;
|
|
1400
|
+
border-radius: 999px;
|
|
1401
|
+
overflow: hidden;
|
|
1402
|
+
background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
.vp-talktime-seg {
|
|
1406
|
+
height: 100%;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
.vp-legend {
|
|
1410
|
+
display: flex;
|
|
1411
|
+
flex-wrap: wrap;
|
|
1412
|
+
gap: var(--jatui-space-md, 6px);
|
|
1413
|
+
font-size: 0.68rem;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
.vp-legend-item {
|
|
1417
|
+
display: inline-flex;
|
|
1418
|
+
align-items: center;
|
|
1419
|
+
gap: var(--jatui-space-xs, 2px);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
.vp-chip {
|
|
1423
|
+
display: inline-flex;
|
|
1424
|
+
align-items: center;
|
|
1425
|
+
gap: var(--jatui-space-sm, 4px);
|
|
1426
|
+
padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
|
|
1427
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
1428
|
+
border: var(--jatui-border-w, 1px) solid transparent;
|
|
1429
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
1430
|
+
color: var(--color-base-content);
|
|
1431
|
+
font-size: inherit;
|
|
1432
|
+
cursor: pointer;
|
|
1433
|
+
transition:
|
|
1434
|
+
opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out),
|
|
1435
|
+
border-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
.vp-chip.solo {
|
|
1439
|
+
border-color: var(--color-primary);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
.vp-chip-clear {
|
|
1443
|
+
color: var(--color-primary);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
.vp-chip-dot {
|
|
1447
|
+
width: 0.5rem;
|
|
1448
|
+
height: 0.5rem;
|
|
1449
|
+
border-radius: 999px;
|
|
1450
|
+
flex-shrink: 0;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
.vp-chip-time {
|
|
1454
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
1455
|
+
font-variant-numeric: tabular-nums;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
.vp-mute {
|
|
1459
|
+
background: none;
|
|
1460
|
+
border: none;
|
|
1461
|
+
padding: 0 var(--jatui-space-xs, 2px);
|
|
1462
|
+
font-size: 0.7rem;
|
|
1463
|
+
line-height: 1;
|
|
1464
|
+
cursor: pointer;
|
|
1465
|
+
opacity: 0.5;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
.vp-mute.on,
|
|
1469
|
+
.vp-mute:hover {
|
|
1470
|
+
opacity: 1;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/* Add-bookmark row — only rendered when the host wired onAddBookmark. */
|
|
1474
|
+
.vp-bookmark-add {
|
|
1475
|
+
display: flex;
|
|
1476
|
+
align-items: center;
|
|
1477
|
+
gap: var(--jatui-space-sm, 4px);
|
|
1478
|
+
font-size: 0.72rem;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
.vp-bookmark-add label {
|
|
1482
|
+
position: absolute;
|
|
1483
|
+
width: 1px;
|
|
1484
|
+
height: 1px;
|
|
1485
|
+
padding: 0;
|
|
1486
|
+
margin: -1px;
|
|
1487
|
+
overflow: hidden;
|
|
1488
|
+
clip-path: inset(50%);
|
|
1489
|
+
white-space: nowrap;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
.vp-bookmark-add input {
|
|
1493
|
+
flex: 1;
|
|
1494
|
+
min-width: 8rem;
|
|
1495
|
+
max-width: 18rem;
|
|
1496
|
+
background: var(--color-base-100);
|
|
1497
|
+
border: var(--jatui-border-w, 1px) solid
|
|
1498
|
+
color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
1499
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1500
|
+
color: var(--color-base-content);
|
|
1501
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
|
|
1502
|
+
font-size: inherit;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
.vp-add-btn {
|
|
1506
|
+
background: color-mix(in oklch, var(--color-warning) 16%, transparent);
|
|
1507
|
+
border: var(--jatui-border-w, 1px) solid
|
|
1508
|
+
color-mix(in oklch, var(--color-warning) 45%, transparent);
|
|
1509
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1510
|
+
color: var(--color-base-content);
|
|
1511
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-lg, 12px);
|
|
1512
|
+
font-size: inherit;
|
|
1513
|
+
white-space: nowrap;
|
|
1514
|
+
cursor: pointer;
|
|
1515
|
+
transition: background var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
.vp-add-btn:hover {
|
|
1519
|
+
background: color-mix(in oklch, var(--color-warning) 28%, transparent);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/* Transcript pane */
|
|
1523
|
+
.vp-transcript {
|
|
1524
|
+
display: flex;
|
|
1525
|
+
flex-direction: column;
|
|
1526
|
+
gap: 1px;
|
|
1527
|
+
overflow-y: auto;
|
|
1528
|
+
background: var(--color-base-100);
|
|
1529
|
+
border: var(--jatui-border-w, 1px) solid
|
|
1530
|
+
color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
1531
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
1532
|
+
padding: var(--jatui-space-sm, 4px);
|
|
1533
|
+
min-width: 0;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
.vp-row {
|
|
1537
|
+
display: flex;
|
|
1538
|
+
align-items: baseline;
|
|
1539
|
+
flex-wrap: wrap;
|
|
1540
|
+
gap: var(--jatui-space-sm, 4px);
|
|
1541
|
+
background: none;
|
|
1542
|
+
border: none;
|
|
1543
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1544
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-md, 6px);
|
|
1545
|
+
text-align: left;
|
|
1546
|
+
cursor: pointer;
|
|
1547
|
+
transition: background var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
.vp-row:hover {
|
|
1551
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
.vp-row.active {
|
|
1555
|
+
background: color-mix(in oklch, var(--color-primary) 14%, transparent);
|
|
1556
|
+
box-shadow: inset 2px 0 0 var(--color-primary);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
.vp-row-time {
|
|
1560
|
+
font-family: ui-monospace, monospace;
|
|
1561
|
+
font-size: 0.66rem;
|
|
1562
|
+
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
|
|
1563
|
+
flex-shrink: 0;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
.vp-row-speaker {
|
|
1567
|
+
font-size: 0.72rem;
|
|
1568
|
+
font-weight: 600;
|
|
1569
|
+
flex-shrink: 0;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
.vp-row-text {
|
|
1573
|
+
font-size: 0.76rem;
|
|
1574
|
+
color: color-mix(in oklch, var(--color-base-content) 85%, transparent);
|
|
1575
|
+
flex: 1;
|
|
1576
|
+
min-width: 10rem;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/* Motion is decoration here — every state also has a static read (colour,
|
|
1580
|
+
position, label), so collapsing it changes nothing functional. */
|
|
1581
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1582
|
+
.vp-band,
|
|
1583
|
+
.vp-osd,
|
|
1584
|
+
.vp-zoom,
|
|
1585
|
+
.vp-lane-span,
|
|
1586
|
+
.vp-chip,
|
|
1587
|
+
.vp-add-btn,
|
|
1588
|
+
.vp-row {
|
|
1589
|
+
transition: none;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
</style>
|