@joewinke/jatui 0.8.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/dist/components/ContextMenu.svelte +13 -1
- package/dist/components/ContextMenu.svelte.d.ts +6 -0
- package/dist/components/VideoPlayer.svelte +453 -496
- package/dist/components/VideoPlayer.svelte.d.ts +4 -46
- package/dist/video/README.md +53 -0
- package/dist/video/audio.d.ts +29 -0
- package/dist/video/audio.js +29 -0
- package/dist/video/band.d.ts +116 -0
- package/dist/video/band.js +311 -0
- package/dist/video/deepLink.d.ts +40 -0
- package/dist/video/deepLink.js +111 -0
- package/dist/video/keys.d.ts +43 -0
- package/dist/video/keys.js +62 -0
- package/dist/video/model.d.ts +57 -0
- package/dist/video/model.js +109 -0
- package/dist/video/night.d.ts +26 -0
- package/dist/video/night.js +26 -0
- package/dist/video/presence.d.ts +25 -0
- package/dist/video/presence.js +25 -0
- package/dist/video/rate.d.ts +69 -0
- package/dist/video/rate.js +88 -0
- package/dist/video/settings.d.ts +50 -0
- package/dist/video/settings.js +62 -0
- package/dist/video/time.d.ts +19 -0
- package/dist/video/time.js +55 -0
- package/dist/video/types.d.ts +59 -0
- package/dist/video/types.js +14 -0
- package/package.json +1 -1
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
// Public type surface — unchanged (the interfaces moved to video/types.ts in
|
|
3
|
+
// the mos-5lzns.28 internal split; both import sites keep working).
|
|
4
|
+
export type {
|
|
5
|
+
VideoChapter,
|
|
6
|
+
VideoSpeakerTurn,
|
|
7
|
+
VideoTranscriptSegment,
|
|
8
|
+
VideoBookmark,
|
|
9
|
+
VideoWaveform,
|
|
10
|
+
VideoSpeakerTalkTime
|
|
11
|
+
} from '../video/types';
|
|
12
|
+
</script>
|
|
13
|
+
|
|
1
14
|
<script lang="ts">
|
|
2
15
|
/**
|
|
3
16
|
* VideoPlayer — one video surface for everything, from a bare mp4 to a
|
|
@@ -21,8 +34,9 @@
|
|
|
21
34
|
*
|
|
22
35
|
* ── Design notes carried over from player.js ────────────────────────────
|
|
23
36
|
* · ONE time axis. Every position — waveform bars, chapter flags, bookmarks,
|
|
24
|
-
* the speaker lane, click/hover-to-seek — maps through
|
|
25
|
-
* so scroll-to-zoom is one piece of
|
|
37
|
+
* the speaker lane, click/hover-to-seek — maps through the band window's
|
|
38
|
+
* secToFrac/fracToSec (video/band.ts), so scroll-to-zoom is one piece of
|
|
39
|
+
* state everything else respects for free.
|
|
26
40
|
* · ONE speaker surface. With a waveform, the envelope's own bars wear the
|
|
27
41
|
* turn colours; the slim lane under it paints only as the no-waveform
|
|
28
42
|
* fallback. Never two stacked time-parallel strips.
|
|
@@ -31,7 +45,15 @@
|
|
|
31
45
|
* yours), so the two families are never confused at a glance.
|
|
32
46
|
* · The wheel means different things in different regions, and each region is
|
|
33
47
|
* its own hover target so nothing is ambiguous: over the video it's volume
|
|
34
|
-
* (shift = seek); over the band it's zoom.
|
|
48
|
+
* (shift = seek, alt = speed); over the band it's zoom.
|
|
49
|
+
* · The keyboard map (video/keys.ts) is the single source the keydown
|
|
50
|
+
* handler and the `?` overlay both read — a key that exists is documented,
|
|
51
|
+
* a documented key exists.
|
|
52
|
+
*
|
|
53
|
+
* Internals live in `src/lib/video/` (band / keys / rate / deepLink /
|
|
54
|
+
* settings + types/time/model; audio, night and presence are contracts for
|
|
55
|
+
* mos-5lzns.31/.33/.34 — see that directory's README for the ownership map
|
|
56
|
+
* and the advisor doctrine).
|
|
35
57
|
*
|
|
36
58
|
* Deliberately NOT ported: player.js's trim region (in/out handles). That's
|
|
37
59
|
* clip-editing, not playback, and belongs in an editor component.
|
|
@@ -44,74 +66,49 @@
|
|
|
44
66
|
|
|
45
67
|
import { onMount, untrack } from 'svelte';
|
|
46
68
|
import { getDaisyUIColor, watchThemeChanges, clearColorCache } from '../utils/daisyuiColors';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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__';
|
|
69
|
+
import type {
|
|
70
|
+
VideoBookmark,
|
|
71
|
+
VideoChapter,
|
|
72
|
+
VideoSpeakerTalkTime,
|
|
73
|
+
VideoSpeakerTurn,
|
|
74
|
+
VideoTranscriptSegment,
|
|
75
|
+
VideoWaveform
|
|
76
|
+
} from '../video/types';
|
|
77
|
+
import { aspectRatioCss, formatDuration, formatTime, truncate } from '../video/time';
|
|
78
|
+
import {
|
|
79
|
+
ALL_KEY,
|
|
80
|
+
buildSpeakerColors,
|
|
81
|
+
buildSpeakerIndex,
|
|
82
|
+
buildTalkTime,
|
|
83
|
+
filterDims,
|
|
84
|
+
sortBookmarks,
|
|
85
|
+
sortChapters,
|
|
86
|
+
sortSegments,
|
|
87
|
+
sortTurns,
|
|
88
|
+
speakerKeyOf,
|
|
89
|
+
SPEAKER_PALETTE,
|
|
90
|
+
UNKNOWN_KEY
|
|
91
|
+
} from '../video/model';
|
|
92
|
+
import {
|
|
93
|
+
clampCardX,
|
|
94
|
+
followPlayhead,
|
|
95
|
+
fracToSec,
|
|
96
|
+
isZoomed,
|
|
97
|
+
nearestBookmark,
|
|
98
|
+
paintBand,
|
|
99
|
+
prepareCanvas,
|
|
100
|
+
resetWindow,
|
|
101
|
+
secToFrac,
|
|
102
|
+
biasToSegmentStart,
|
|
103
|
+
segmentAt,
|
|
104
|
+
chapterAt,
|
|
105
|
+
toBandTurns,
|
|
106
|
+
zoomAt,
|
|
107
|
+
type BandWindow
|
|
108
|
+
} from '../video/band';
|
|
109
|
+
import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
|
|
110
|
+
import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
|
|
111
|
+
import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
|
|
115
112
|
|
|
116
113
|
let {
|
|
117
114
|
src,
|
|
@@ -132,6 +129,27 @@
|
|
|
132
129
|
startAt = null,
|
|
133
130
|
transcriptPosition = 'below',
|
|
134
131
|
transcriptMaxHeight = '18rem',
|
|
132
|
+
/**
|
|
133
|
+
* CORS mode for the <video> element — passed straight through as the
|
|
134
|
+
* `crossorigin` attribute. `'anonymous'` asks the server for a
|
|
135
|
+
* CORS-readable load, which is what frame-reading features (night
|
|
136
|
+
* dimming's canvas sampling, mos-5lzns.33) require. Omitted (default)
|
|
137
|
+
* keeps today's behaviour exactly. extension-library parity: it
|
|
138
|
+
* re-requests with `crossOrigin = "anonymous"` and remembers success —
|
|
139
|
+
* here the host opts in explicitly, because a *component* can't own a
|
|
140
|
+
* localStorage memory the host page also depends on.
|
|
141
|
+
*/
|
|
142
|
+
crossorigin = null,
|
|
143
|
+
/**
|
|
144
|
+
* Deep-link the timestamp: when true, a `?t=` param on the current URL
|
|
145
|
+
* seeds the start position (an explicit `startAt` prop still wins) and
|
|
146
|
+
* playback keeps the param in step via history.replaceState — throttled,
|
|
147
|
+
* whole-second precision, every other query param preserved. Opt-in
|
|
148
|
+
* because a component library must not rewrite the host page's URL
|
|
149
|
+
* uninvited. The param persists after unmount: it IS the deep link —
|
|
150
|
+
* refresh/copy/share picks up where playback left off.
|
|
151
|
+
*/
|
|
152
|
+
syncUrlTime = false,
|
|
135
153
|
onTimeUpdate = undefined,
|
|
136
154
|
onAddBookmark = undefined,
|
|
137
155
|
onBookmarkContext = undefined,
|
|
@@ -185,6 +203,8 @@
|
|
|
185
203
|
startAt?: number | null;
|
|
186
204
|
transcriptPosition?: 'below' | 'side';
|
|
187
205
|
transcriptMaxHeight?: string;
|
|
206
|
+
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
207
|
+
syncUrlTime?: boolean;
|
|
188
208
|
onTimeUpdate?: (video: HTMLVideoElement) => void;
|
|
189
209
|
/** Present ⇒ the band grows an "add bookmark at current time" control. */
|
|
190
210
|
onAddBookmark?: (playbackSec: number, label: string) => void;
|
|
@@ -198,28 +218,6 @@
|
|
|
198
218
|
class?: string;
|
|
199
219
|
} = $props();
|
|
200
220
|
|
|
201
|
-
/**
|
|
202
|
-
* `aspect` → a CSS `aspect-ratio` value. Accepts `"W:H"`, a bare ratio number,
|
|
203
|
-
* or null/"auto" (⇒ no lock; the video's intrinsic ratio drives the box). Shared
|
|
204
|
-
* with <Flythrough>/<LessonEmbed> conceptually, kept inline so the primitive has
|
|
205
|
-
* no cross-file coupling for one small pure function.
|
|
206
|
-
*/
|
|
207
|
-
const aspectRatioCss = $derived.by(() => {
|
|
208
|
-
const a = aspect;
|
|
209
|
-
if (a == null) return null;
|
|
210
|
-
const trimmed = String(a).trim();
|
|
211
|
-
if (trimmed === '' || trimmed === 'auto') return null;
|
|
212
|
-
const colon = trimmed.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
|
|
213
|
-
if (colon) {
|
|
214
|
-
const w = parseFloat(colon[1]);
|
|
215
|
-
const h = parseFloat(colon[2]);
|
|
216
|
-
if (w > 0 && h > 0) return `${w} / ${h}`;
|
|
217
|
-
}
|
|
218
|
-
const num = Number(trimmed);
|
|
219
|
-
if (Number.isFinite(num) && num > 0) return String(num);
|
|
220
|
-
return null;
|
|
221
|
-
});
|
|
222
|
-
|
|
223
221
|
// prefers-reduced-motion gate for autoplay: an autoplaying clip IS motion, so
|
|
224
222
|
// under the reduce preference the video holds on its poster instead. Tracked
|
|
225
223
|
// live (media query listener) so a mid-session preference flip is honoured, and
|
|
@@ -255,7 +253,7 @@
|
|
|
255
253
|
} | null = $state(null);
|
|
256
254
|
let zoomLabel = $state('');
|
|
257
255
|
let zoomLabelOn = $state(false);
|
|
258
|
-
let osd = $state(''); // transient wheel readout (volume / seek)
|
|
256
|
+
let osd = $state(''); // transient wheel/keyboard readout (volume / seek / speed)
|
|
259
257
|
let osdOn = $state(false);
|
|
260
258
|
|
|
261
259
|
// ── Speaker filter state ────────────────────────────────────────────────
|
|
@@ -264,6 +262,17 @@
|
|
|
264
262
|
let highlightKey: string | null = $state(null);
|
|
265
263
|
let bookmarkLabel = $state('');
|
|
266
264
|
|
|
265
|
+
// ── Speed state (rate.ts owns the staircase; see the composer contract) ──
|
|
266
|
+
// `base` is the only rate a human chose, and therefore the only one the UI
|
|
267
|
+
// reports. Advisors (mos-5lzns.32) multiply into the applied rate without
|
|
268
|
+
// ever touching this — the label must keep saying 1× while a gap is taken
|
|
269
|
+
// at 2.2×, or the readout becomes a strobe.
|
|
270
|
+
let baseRate = $state(1);
|
|
271
|
+
|
|
272
|
+
// ── Transcript + help overlays ──────────────────────────────────────────
|
|
273
|
+
let transcriptVisible = $state(true);
|
|
274
|
+
let keysOpen = $state(false);
|
|
275
|
+
|
|
267
276
|
let zoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|
268
277
|
let osdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
269
278
|
|
|
@@ -277,32 +286,10 @@
|
|
|
277
286
|
);
|
|
278
287
|
const useHours = $derived(effectiveDuration >= 3600);
|
|
279
288
|
|
|
280
|
-
const sortedChapters = $derived(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
);
|
|
285
|
-
const sortedTurns = $derived(
|
|
286
|
-
[...speakers]
|
|
287
|
-
.filter(
|
|
288
|
-
(t) =>
|
|
289
|
-
t &&
|
|
290
|
-
Number.isFinite(t.startSec) &&
|
|
291
|
-
Number.isFinite(t.endSec) &&
|
|
292
|
-
t.endSec > t.startSec
|
|
293
|
-
)
|
|
294
|
-
.sort((a, b) => a.startSec - b.startSec)
|
|
295
|
-
);
|
|
296
|
-
const sortedSegments = $derived(
|
|
297
|
-
[...transcript]
|
|
298
|
-
.filter((s) => s && Number.isFinite(s.startSec))
|
|
299
|
-
.sort((a, b) => a.startSec - b.startSec)
|
|
300
|
-
);
|
|
301
|
-
const sortedBookmarks = $derived(
|
|
302
|
-
[...bookmarks]
|
|
303
|
-
.filter((b) => b && Number.isFinite(b.playbackSec) && b.playbackSec >= 0)
|
|
304
|
-
.sort((a, b) => a.playbackSec - b.playbackSec)
|
|
305
|
-
);
|
|
289
|
+
const sortedChapters = $derived(sortChapters(chapters));
|
|
290
|
+
const sortedTurns = $derived(sortTurns(speakers));
|
|
291
|
+
const sortedSegments = $derived(sortSegments(transcript));
|
|
292
|
+
const sortedBookmarks = $derived(sortBookmarks(bookmarks));
|
|
306
293
|
|
|
307
294
|
/** Peaks normalised to 0–1 against their own max, so 0–1, 0–99 and 0–255 inputs all work. */
|
|
308
295
|
const peaks = $derived.by(() => {
|
|
@@ -319,142 +306,83 @@
|
|
|
319
306
|
* Ordered speaker registry, built from turns first then transcript, so the
|
|
320
307
|
* palette index is stable across both panes and the legend.
|
|
321
308
|
*/
|
|
322
|
-
const speakerIndex = $derived
|
|
323
|
-
const map = new Map<string, { key: string; name: string; hue: number | null }>();
|
|
324
|
-
const add = (
|
|
325
|
-
identity: string | null | undefined,
|
|
326
|
-
name: string | null | undefined,
|
|
327
|
-
hue: number | null | undefined
|
|
328
|
-
) => {
|
|
329
|
-
const key = identity ?? name ?? UNKNOWN_KEY;
|
|
330
|
-
const existing = map.get(key);
|
|
331
|
-
if (existing) {
|
|
332
|
-
if (existing.hue == null && hue != null) existing.hue = hue;
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
map.set(key, { key, name: name ?? 'Unknown speaker', hue: hue ?? null });
|
|
336
|
-
};
|
|
337
|
-
for (const t of sortedTurns) {
|
|
338
|
-
if (t.ad) continue;
|
|
339
|
-
add(t.speakerIdentity, t.speakerName, t.hue ?? null);
|
|
340
|
-
}
|
|
341
|
-
for (const s of sortedSegments) add(s.speakerIdentity, s.speakerName, null);
|
|
342
|
-
return map;
|
|
343
|
-
});
|
|
309
|
+
const speakerIndex = $derived(buildSpeakerIndex(sortedTurns, sortedSegments));
|
|
344
310
|
|
|
345
311
|
const hasSpeakers = $derived(speakerIndex.size > 0);
|
|
346
312
|
|
|
347
|
-
/**
|
|
348
|
-
|
|
349
|
-
* colour per rendered column and reruns on every playhead tick, so this must
|
|
350
|
-
* never be an indexOf scan at call time.
|
|
351
|
-
*/
|
|
352
|
-
const speakerColors = $derived.by(() => {
|
|
353
|
-
const map = new Map<string, string>();
|
|
354
|
-
let i = 0;
|
|
355
|
-
for (const [key, entry] of speakerIndex) {
|
|
356
|
-
map.set(
|
|
357
|
-
key,
|
|
358
|
-
entry.hue != null
|
|
359
|
-
? `oklch(0.72 0.14 ${entry.hue})`
|
|
360
|
-
: SPEAKER_PALETTE[i % SPEAKER_PALETTE.length]
|
|
361
|
-
);
|
|
362
|
-
i++;
|
|
363
|
-
}
|
|
364
|
-
return map;
|
|
365
|
-
});
|
|
313
|
+
/** key → identity colour, resolved ONCE per data change (see video/model.ts). */
|
|
314
|
+
const speakerColors = $derived(buildSpeakerColors(speakerIndex));
|
|
366
315
|
|
|
367
316
|
/** With an envelope the bars carry the hues; the lane is the no-waveform fallback only. */
|
|
368
317
|
const laneCarriesSpeakers = $derived(!hasWave && sortedTurns.length > 0);
|
|
369
318
|
|
|
370
|
-
const effectiveTalkTime = $derived
|
|
371
|
-
if (talkTime.length > 0) {
|
|
372
|
-
return [...talkTime].sort((a, b) => b.seconds - a.seconds);
|
|
373
|
-
}
|
|
374
|
-
const totals = new Map<string, VideoSpeakerTalkTime & { key: string }>();
|
|
375
|
-
const bump = (
|
|
376
|
-
identity: string | null | undefined,
|
|
377
|
-
name: string | null | undefined,
|
|
378
|
-
secs: number
|
|
379
|
-
) => {
|
|
380
|
-
const key = identity ?? name ?? UNKNOWN_KEY;
|
|
381
|
-
const existing = totals.get(key);
|
|
382
|
-
if (existing) existing.seconds += secs;
|
|
383
|
-
else totals.set(key, { key, speakerIdentity: identity ?? null, speakerName: name ?? null, seconds: secs });
|
|
384
|
-
};
|
|
385
|
-
const source = sortedTurns.length > 0 ? sortedTurns : sortedSegments;
|
|
386
|
-
for (const s of source) {
|
|
387
|
-
if ('ad' in s && s.ad) continue;
|
|
388
|
-
bump(s.speakerIdentity, s.speakerName, Math.max(0, s.endSec - s.startSec));
|
|
389
|
-
}
|
|
390
|
-
return [...totals.values()].sort((a, b) => b.seconds - a.seconds);
|
|
391
|
-
});
|
|
319
|
+
const effectiveTalkTime = $derived(buildTalkTime(talkTime, sortedTurns, sortedSegments));
|
|
392
320
|
const talkTimeTotal = $derived(effectiveTalkTime.reduce((n, t) => n + t.seconds, 0) || 1);
|
|
393
321
|
|
|
394
322
|
const activeSegment = $derived(
|
|
395
323
|
sortedSegments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
|
|
396
324
|
);
|
|
397
325
|
|
|
326
|
+
/** The zoom window, as the band module sees it (plain data — band.ts is DOM-free). */
|
|
327
|
+
const bandWindow = $derived<BandWindow>({ start: viewStart, end: viewEnd });
|
|
328
|
+
|
|
329
|
+
/** Everything paintBand reads, resolved in one place (see video/band.ts). */
|
|
330
|
+
const paintState = $derived.by(() => {
|
|
331
|
+
void themeTick; // repaint on theme swap
|
|
332
|
+
return {
|
|
333
|
+
currentTime,
|
|
334
|
+
duration: effectiveDuration,
|
|
335
|
+
peaks,
|
|
336
|
+
chapters: sortedChapters,
|
|
337
|
+
bookmarks: sortedBookmarks,
|
|
338
|
+
turns: toBandTurns(sortedTurns),
|
|
339
|
+
speakerColors,
|
|
340
|
+
laneCarriesSpeakers,
|
|
341
|
+
highlightKey,
|
|
342
|
+
soloKey,
|
|
343
|
+
mutedKeys,
|
|
344
|
+
window: bandWindow,
|
|
345
|
+
hoverChapter,
|
|
346
|
+
colors: {
|
|
347
|
+
accent: getDaisyUIColor('primary'),
|
|
348
|
+
neutral: getDaisyUIColor('base-content'),
|
|
349
|
+
marker: getDaisyUIColor('warning')
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
/** Help-overlay rows: only keys whose feature's data exists are advertised. */
|
|
355
|
+
const availableKeys = $derived(
|
|
356
|
+
PLAYER_KEYS.filter((row) => {
|
|
357
|
+
if (!row.when) return true;
|
|
358
|
+
if (row.when === 'chapters') return sortedChapters.length > 0;
|
|
359
|
+
if (row.when === 'bookmarks') return onAddBookmark != null;
|
|
360
|
+
if (row.when === 'transcript') return sortedSegments.length > 0;
|
|
361
|
+
return true;
|
|
362
|
+
})
|
|
363
|
+
);
|
|
364
|
+
|
|
398
365
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
399
|
-
function speakerKeyOf(s: {
|
|
400
|
-
speakerIdentity?: string | null;
|
|
401
|
-
speakerName?: string | null;
|
|
402
|
-
}): string {
|
|
403
|
-
return s.speakerIdentity ?? s.speakerName ?? UNKNOWN_KEY;
|
|
404
|
-
}
|
|
405
366
|
|
|
406
367
|
/** Explicit hue wins (host-supplied identity colour); otherwise the cycled palette. */
|
|
407
368
|
function speakerColor(key: string): string {
|
|
408
369
|
return speakerColors.get(key) ?? SPEAKER_PALETTE[0];
|
|
409
370
|
}
|
|
410
371
|
|
|
411
|
-
function formatTime(totalSeconds: number, withHours: boolean): string {
|
|
412
|
-
const t = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
|
|
413
|
-
const pad = (n: number) => String(n).padStart(2, '0');
|
|
414
|
-
const s = Math.floor(t % 60);
|
|
415
|
-
if (withHours) {
|
|
416
|
-
return `${Math.floor(t / 3600)}:${pad(Math.floor((t % 3600) / 60))}:${pad(s)}`;
|
|
417
|
-
}
|
|
418
|
-
return `${Math.floor(t / 60)}:${pad(s)}`;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function formatDuration(totalSeconds: number): string {
|
|
422
|
-
const s = Math.round(Math.max(0, totalSeconds));
|
|
423
|
-
const h = Math.floor(s / 3600);
|
|
424
|
-
const m = Math.floor((s % 3600) / 60);
|
|
425
|
-
if (h > 0) return `${h}h ${m}m`;
|
|
426
|
-
if (m > 0) return `${m}m ${s % 60}s`;
|
|
427
|
-
return `${s}s`;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function truncate(s: string, n: number): string {
|
|
431
|
-
return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
372
|
// ── Zoom window: one mapping every position computation goes through ────
|
|
435
|
-
/**
|
|
436
|
-
* The visible window in seconds. Falls back to the whole video until the
|
|
437
|
-
* duration effect has seeded viewEnd, so a first paint that races metadata
|
|
438
|
-
* still maps positions correctly instead of collapsing to zero.
|
|
439
|
-
*/
|
|
440
373
|
function spanSec(): number {
|
|
441
|
-
return
|
|
374
|
+
return bandWindow.end > bandWindow.start
|
|
375
|
+
? bandWindow.end - bandWindow.start
|
|
376
|
+
: effectiveDuration;
|
|
442
377
|
}
|
|
443
|
-
function
|
|
444
|
-
return
|
|
378
|
+
function isZoomedNow(): boolean {
|
|
379
|
+
return isZoomed(bandWindow, effectiveDuration);
|
|
445
380
|
}
|
|
446
381
|
function resetZoom() {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
function secToFrac(sec: number): number {
|
|
451
|
-
const span = spanSec();
|
|
452
|
-
return span > 0 ? (sec - viewStart) / span : 0;
|
|
382
|
+
const w = resetWindow(effectiveDuration);
|
|
383
|
+
viewStart = w.start;
|
|
384
|
+
viewEnd = w.end;
|
|
453
385
|
}
|
|
454
|
-
function fracToSec(frac: number): number {
|
|
455
|
-
return viewStart + frac * spanSec();
|
|
456
|
-
}
|
|
457
|
-
|
|
458
386
|
function flashZoomLabel() {
|
|
459
387
|
const d = effectiveDuration;
|
|
460
388
|
const span = spanSec();
|
|
@@ -480,238 +408,44 @@
|
|
|
480
408
|
if (videoEl) videoEl.currentTime = t;
|
|
481
409
|
}
|
|
482
410
|
|
|
411
|
+
// ── Speed ───────────────────────────────────────────────────────────────
|
|
483
412
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* never clicked.
|
|
413
|
+
* Apply the base rate. No advisors exist yet (mos-5lzns.32 fills the
|
|
414
|
+
* speaker/silence ones) — composeRate collapses them to 1, so today this is
|
|
415
|
+
* exactly `base`. When they land, the composer multiplies and this keeps
|
|
416
|
+
* reporting `base` on the OSD — the contract in video/rate.ts.
|
|
489
417
|
*/
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
if (nearby <= 1) return sec;
|
|
498
|
-
return segmentAt(sec)?.startSec ?? sec;
|
|
418
|
+
function applyRate() {
|
|
419
|
+
const v = videoEl;
|
|
420
|
+
if (!v) return;
|
|
421
|
+
const { rate } = composeRate(baseRate, 1, 1);
|
|
422
|
+
// Only real changes are written — playbackRate is a live audio parameter.
|
|
423
|
+
if (v.playbackRate !== rate) v.playbackRate = rate;
|
|
499
424
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
for (const s of sortedSegments) {
|
|
505
|
-
if (s.startSec <= sec) best = s;
|
|
506
|
-
else break;
|
|
507
|
-
}
|
|
508
|
-
return best;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function chapterAt(sec: number): number {
|
|
512
|
-
let idx = -1;
|
|
513
|
-
for (let i = 0; i < sortedChapters.length; i++) {
|
|
514
|
-
if (sortedChapters[i].startSec <= sec) idx = i;
|
|
515
|
-
else break;
|
|
516
|
-
}
|
|
517
|
-
return idx;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
const BOOKMARK_HIT_PX = 6;
|
|
521
|
-
function nearestBookmark(xCss: number, width: number): VideoBookmark | null {
|
|
522
|
-
if (sortedBookmarks.length === 0 || !width) return null;
|
|
523
|
-
let best: VideoBookmark | null = null;
|
|
524
|
-
let bestD = BOOKMARK_HIT_PX;
|
|
525
|
-
for (const b of sortedBookmarks) {
|
|
526
|
-
const d = Math.abs(secToFrac(b.playbackSec) * width - xCss);
|
|
527
|
-
if (d < bestD) {
|
|
528
|
-
bestD = d;
|
|
529
|
-
best = b;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
return best;
|
|
425
|
+
function stepSpeed(direction: 1 | -1) {
|
|
426
|
+
baseRate = stepRate(baseRate, direction);
|
|
427
|
+
applyRate();
|
|
428
|
+
flashOsd(formatSpeedLabel(baseRate));
|
|
533
429
|
}
|
|
534
430
|
|
|
535
431
|
// ── Canvas ──────────────────────────────────────────────────────────────
|
|
536
|
-
const RAIL_H = 10; // no-waveform fallback: a flat rail instead of an envelope
|
|
537
|
-
|
|
538
|
-
/** Does the active solo/mute filter recede this turn's paint? */
|
|
539
|
-
function filterDims(key: string | null, ad: boolean): boolean {
|
|
540
|
-
if (soloKey !== ALL_KEY) return !(key != null && !ad && key === soloKey);
|
|
541
|
-
return !!(key != null && !ad && mutedKeys.has(key));
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
function draw() {
|
|
545
|
-
const canvas = canvasEl;
|
|
546
|
-
if (!canvas) return;
|
|
547
|
-
const W = canvas.clientWidth;
|
|
548
|
-
const H = canvas.clientHeight;
|
|
549
|
-
if (!W || !H) return;
|
|
550
|
-
const dpr = window.devicePixelRatio || 1;
|
|
551
|
-
if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
|
|
552
|
-
canvas.width = Math.round(W * dpr);
|
|
553
|
-
canvas.height = Math.round(H * dpr);
|
|
554
|
-
}
|
|
555
|
-
const ctx = canvas.getContext('2d');
|
|
556
|
-
if (!ctx) return;
|
|
557
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
558
|
-
ctx.clearRect(0, 0, W, H);
|
|
559
|
-
|
|
560
|
-
const dur = effectiveDuration;
|
|
561
|
-
const accent = getDaisyUIColor('primary');
|
|
562
|
-
const neutral = getDaisyUIColor('base-content');
|
|
563
|
-
const marker = getDaisyUIColor('warning');
|
|
564
|
-
|
|
565
|
-
if (dur <= 0) {
|
|
566
|
-
// No duration yet — paint the empty rail so the band owns its space
|
|
567
|
-
// from the first frame and metadata arriving is never a layout shove.
|
|
568
|
-
ctx.globalAlpha = 0.2;
|
|
569
|
-
ctx.fillStyle = neutral;
|
|
570
|
-
ctx.fillRect(0, (H - RAIL_H) / 2, W, RAIL_H);
|
|
571
|
-
ctx.globalAlpha = 1;
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
const p = peaks;
|
|
575
|
-
const withWave = !!p;
|
|
576
|
-
const n = p?.length ?? 0;
|
|
577
|
-
// viewEnd is seeded by the duration effect; until then the window is the
|
|
578
|
-
// whole video, so a first paint that races metadata still lands correctly.
|
|
579
|
-
const span = viewEnd - viewStart || dur;
|
|
580
|
-
// Chapter boundaries in px — a column straddling one is skipped (the seam).
|
|
581
|
-
const bounds = sortedChapters.slice(1).map((c) => secToFrac(c.startSec) * W);
|
|
582
|
-
const GAP = 4;
|
|
583
|
-
const nowChapter = chapterAt(currentTime);
|
|
584
|
-
|
|
585
|
-
const visiblePeaks = withWave ? Math.max(1, Math.round((span / dur) * n)) : 0;
|
|
586
|
-
const step = withWave ? Math.max(1.5, W / visiblePeaks) : 2;
|
|
587
|
-
const cols = Math.max(1, Math.ceil(W / step));
|
|
588
|
-
const bw = Math.max(1, step - 1);
|
|
589
|
-
|
|
590
|
-
// Speaker sweep: columns advance monotonically in time and turns are
|
|
591
|
-
// sorted by start, so ONE pointer walks the turn list across the whole
|
|
592
|
-
// draw — never a per-column find(). Turns can overlap (an ad read inside
|
|
593
|
-
// a speaker's turn), so the sweep keeps a stack of open turns and the
|
|
594
|
-
// latest start wins, matching the lane's paint order.
|
|
595
|
-
let ti = 0;
|
|
596
|
-
const open: VideoSpeakerTurn[] = [];
|
|
597
|
-
|
|
598
|
-
for (let ci = 0; ci < cols; ci++) {
|
|
599
|
-
const x = ci * step;
|
|
600
|
-
if (bounds.some((b) => Math.abs(x + bw / 2 - b) < GAP)) continue;
|
|
601
|
-
const sec = fracToSec((x + step / 2) / W);
|
|
602
|
-
while (ti < sortedTurns.length && sortedTurns[ti].startSec <= sec) open.push(sortedTurns[ti++]);
|
|
603
|
-
while (open.length && open[open.length - 1].endSec <= sec) open.pop();
|
|
604
|
-
const turn = laneCarriesSpeakers ? null : (open[open.length - 1] ?? null);
|
|
605
|
-
const turnKey = turn && !turn.ad ? speakerKeyOf(turn) : null;
|
|
606
|
-
|
|
607
|
-
const ch = sortedChapters.length ? chapterAt(sec) : -1;
|
|
608
|
-
const played = sec <= currentTime;
|
|
609
|
-
const hovered = ch >= 0 && ch === hoverChapter;
|
|
610
|
-
|
|
611
|
-
// Unplayed floor is 0.38 rather than player.js's 0.3: that value was
|
|
612
|
-
// tuned against the theater's near-black backdrop, and on a light
|
|
613
|
-
// theme's base-200 the envelope washed out at it.
|
|
614
|
-
let alpha: number;
|
|
615
|
-
if (played) alpha = hovered ? 1 : 0.85;
|
|
616
|
-
else if (hovered) alpha = 0.6;
|
|
617
|
-
else if (ch >= 0 && ch === nowChapter) alpha = 0.52;
|
|
618
|
-
else alpha = 0.38;
|
|
619
|
-
|
|
620
|
-
// Legend hover extends onto the envelope: the named speaker at full
|
|
621
|
-
// strength, everything else at 40% of its normal alpha — the
|
|
622
|
-
// played/unplayed ladder survives inside both halves.
|
|
623
|
-
if (highlightKey != null) {
|
|
624
|
-
if (turnKey === highlightKey) alpha = played ? 1 : 0.8;
|
|
625
|
-
else alpha *= 0.4;
|
|
626
|
-
} else if (filterDims(turnKey, !!turn?.ad)) {
|
|
627
|
-
alpha *= 0.4;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
ctx.globalAlpha = alpha;
|
|
631
|
-
if (turn?.ad) ctx.fillStyle = marker;
|
|
632
|
-
else if (turnKey) ctx.fillStyle = speakerColor(turnKey);
|
|
633
|
-
else ctx.fillStyle = played ? accent : neutral;
|
|
634
|
-
|
|
635
|
-
const h = withWave ? Math.max(2, (p as number[])[peakIdx(sec, dur, n)] * (H - 4)) : RAIL_H;
|
|
636
|
-
ctx.fillRect(x, withWave ? H - h : (H - RAIL_H) / 2, bw, h);
|
|
637
|
-
}
|
|
638
|
-
ctx.globalAlpha = 1;
|
|
639
|
-
|
|
640
|
-
// Boundary notches: a hairline in each seam plus a small accent flag on
|
|
641
|
-
// top — the flag reads as a UI marker even when the envelope is dense.
|
|
642
|
-
// Flags within a few px of each other collapse to one so a long video at
|
|
643
|
-
// full zoom-out never smears them into a solid bar.
|
|
644
|
-
ctx.globalAlpha = 0.4;
|
|
645
|
-
ctx.fillStyle = neutral;
|
|
646
|
-
for (const b of bounds) ctx.fillRect(b - 1, 2, 2, H - 4);
|
|
647
|
-
ctx.globalAlpha = 0.95;
|
|
648
|
-
ctx.fillStyle = accent;
|
|
649
|
-
const FLAG_MERGE_PX = 6;
|
|
650
|
-
let lastFlagX = -Infinity;
|
|
651
|
-
for (const b of bounds) {
|
|
652
|
-
if (b - lastFlagX < FLAG_MERGE_PX) continue;
|
|
653
|
-
lastFlagX = b;
|
|
654
|
-
ctx.beginPath();
|
|
655
|
-
ctx.moveTo(b - 3, 0);
|
|
656
|
-
ctx.lineTo(b + 3, 0);
|
|
657
|
-
ctx.lineTo(b, 4);
|
|
658
|
-
ctx.closePath();
|
|
659
|
-
ctx.fill();
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Bookmarks: diamonds pointing up from the BOTTOM edge — the mirror image
|
|
663
|
-
// of the chapter flags on top, so yours and the model's never blur together.
|
|
664
|
-
ctx.fillStyle = marker;
|
|
665
|
-
for (const b of sortedBookmarks) {
|
|
666
|
-
const x = secToFrac(b.playbackSec) * W;
|
|
667
|
-
if (x < -4 || x > W + 4) continue;
|
|
668
|
-
ctx.beginPath();
|
|
669
|
-
ctx.moveTo(x, H);
|
|
670
|
-
ctx.lineTo(x - 3, H - 4);
|
|
671
|
-
ctx.lineTo(x, H - 8);
|
|
672
|
-
ctx.lineTo(x + 3, H - 4);
|
|
673
|
-
ctx.closePath();
|
|
674
|
-
ctx.fill();
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Playhead
|
|
678
|
-
ctx.globalAlpha = 0.9;
|
|
679
|
-
ctx.fillStyle = neutral;
|
|
680
|
-
ctx.fillRect(Math.max(0, Math.min(1, secToFrac(currentTime))) * W - 0.5, 0, 1.5, H);
|
|
681
|
-
ctx.globalAlpha = 1;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function peakIdx(sec: number, dur: number, n: number): number {
|
|
685
|
-
return Math.max(0, Math.min(n - 1, Math.floor((sec / dur) * n)));
|
|
686
|
-
}
|
|
687
|
-
|
|
688
432
|
// Repaint on any input the canvas reads. Deps are listed explicitly and the
|
|
689
|
-
// body is untracked —
|
|
690
|
-
//
|
|
691
|
-
//
|
|
433
|
+
// body is untracked — letting paintState's own reads register as deps is
|
|
434
|
+
// exactly the effect-dep leak that has bitten this library before
|
|
435
|
+
// (PreJoinLobby's acquire loop). paintState itself is a $derived, so it is
|
|
436
|
+
// one dep; canvasEl/canvasW/canvasH are the box deps.
|
|
692
437
|
$effect(() => {
|
|
693
|
-
// canvasEl is a dep, not just a read inside draw(): bind:this lands after
|
|
694
|
-
// the first effect run, so without it the very first paint is skipped and
|
|
695
|
-
// nothing ever re-triggers one.
|
|
696
438
|
void canvasEl;
|
|
697
|
-
void
|
|
698
|
-
void viewStart;
|
|
699
|
-
void viewEnd;
|
|
700
|
-
void hoverChapter;
|
|
701
|
-
void highlightKey;
|
|
702
|
-
void soloKey;
|
|
703
|
-
void mutedKeys;
|
|
704
|
-
void sortedChapters;
|
|
705
|
-
void sortedTurns;
|
|
706
|
-
void sortedBookmarks;
|
|
707
|
-
void speakerColors;
|
|
708
|
-
void laneCarriesSpeakers;
|
|
709
|
-
void peaks;
|
|
710
|
-
void effectiveDuration;
|
|
439
|
+
void paintState;
|
|
711
440
|
void canvasW;
|
|
712
441
|
void canvasH;
|
|
713
|
-
|
|
714
|
-
|
|
442
|
+
untrack(() => {
|
|
443
|
+
const canvas = canvasEl;
|
|
444
|
+
if (!canvas) return;
|
|
445
|
+
const ctx = prepareCanvas(canvas);
|
|
446
|
+
if (!ctx) return;
|
|
447
|
+
paintBand(ctx, canvas.clientWidth, canvas.clientHeight, paintState);
|
|
448
|
+
});
|
|
715
449
|
});
|
|
716
450
|
|
|
717
451
|
// A new duration means the old zoom window may no longer make sense.
|
|
@@ -720,6 +454,17 @@
|
|
|
720
454
|
untrack(() => resetZoom());
|
|
721
455
|
});
|
|
722
456
|
|
|
457
|
+
// A new source shouldn't silently inherit the last video's speed.
|
|
458
|
+
$effect(() => {
|
|
459
|
+
void src;
|
|
460
|
+
untrack(() => {
|
|
461
|
+
if (baseRate !== 1) {
|
|
462
|
+
baseRate = 1;
|
|
463
|
+
}
|
|
464
|
+
applyRate();
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
723
468
|
// The band gets resized by things this component can't see (a panel toggling,
|
|
724
469
|
// the window, the side-transcript breakpoint), so the canvas tracks its own
|
|
725
470
|
// box. Set up from an $effect keyed on canvasEl rather than onMount: bind:this
|
|
@@ -738,12 +483,20 @@
|
|
|
738
483
|
return () => ro.disconnect();
|
|
739
484
|
});
|
|
740
485
|
|
|
486
|
+
// ── Deep link: read once on mount; the start position prefers startAt ───
|
|
487
|
+
// A queued seek never fires on a later source (same one-shot as startAt).
|
|
488
|
+
let urlStartAt: number | null = null;
|
|
489
|
+
let appliedStartAt = false;
|
|
490
|
+
let lastUrlWrite = 0;
|
|
491
|
+
|
|
741
492
|
onMount(() => {
|
|
742
493
|
const stopTheme = watchThemeChanges(() => {
|
|
743
494
|
clearColorCache();
|
|
744
495
|
themeTick++;
|
|
745
496
|
});
|
|
746
497
|
|
|
498
|
+
if (syncUrlTime) urlStartAt = readTimeParam();
|
|
499
|
+
|
|
747
500
|
// Live reduced-motion tracking — drives the autoplay gate. A flip toward
|
|
748
501
|
// "reduce" mid-playback pauses the clip on its current frame; a flip away
|
|
749
502
|
// lets a queued autoplay fire (the effect below owns actually starting it).
|
|
@@ -764,6 +517,22 @@
|
|
|
764
517
|
};
|
|
765
518
|
});
|
|
766
519
|
|
|
520
|
+
/** Keep the URL's ?t= in step with playback — throttled, whole seconds. */
|
|
521
|
+
function syncUrl() {
|
|
522
|
+
if (!syncUrlTime || !videoEl) return;
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
if (now - lastUrlWrite < WRITE_THROTTLE_MS) return;
|
|
525
|
+
lastUrlWrite = now;
|
|
526
|
+
writeTimeParam(videoEl.currentTime);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Unthrottled: seek/pause are the moments the deep link must not be stale. */
|
|
530
|
+
function syncUrlNow() {
|
|
531
|
+
if (!syncUrlTime || !videoEl) return;
|
|
532
|
+
lastUrlWrite = Date.now();
|
|
533
|
+
writeTimeParam(videoEl.currentTime);
|
|
534
|
+
}
|
|
535
|
+
|
|
767
536
|
// Autoplay is applied imperatively (a queued `.play()` that the reduced-motion
|
|
768
537
|
// gate can veto) rather than via the native `autoplay` attribute, so a
|
|
769
538
|
// mid-session preference flip is respected instead of being a mount-only
|
|
@@ -793,17 +562,17 @@
|
|
|
793
562
|
const r = canvas.getBoundingClientRect();
|
|
794
563
|
if (!r.width || spanSec() <= 0) return null;
|
|
795
564
|
const x = e.clientX - r.left;
|
|
796
|
-
return { sec: fracToSec(x / r.width), x, width: r.width };
|
|
565
|
+
return { sec: fracToSec(bandWindow, effectiveDuration, x / r.width), x, width: r.width };
|
|
797
566
|
}
|
|
798
567
|
|
|
799
568
|
function onBandClick(e: MouseEvent) {
|
|
800
569
|
const hit = canvasSec(e);
|
|
801
570
|
if (!hit) return;
|
|
802
|
-
seekTo(biasToSegmentStart(hit.sec));
|
|
571
|
+
seekTo(biasToSegmentStart(hit.sec, sortedSegments));
|
|
803
572
|
}
|
|
804
573
|
|
|
805
574
|
function onBandDblClick() {
|
|
806
|
-
if (!
|
|
575
|
+
if (!isZoomedNow()) return;
|
|
807
576
|
resetZoom();
|
|
808
577
|
flashZoomLabel();
|
|
809
578
|
}
|
|
@@ -813,21 +582,15 @@
|
|
|
813
582
|
const hit = canvasSec(e);
|
|
814
583
|
if (!hit) return;
|
|
815
584
|
e.preventDefault();
|
|
816
|
-
const near = nearestBookmark(hit.x, hit.width);
|
|
585
|
+
const near = nearestBookmark(hit.x, hit.width, sortedBookmarks, bandWindow, effectiveDuration);
|
|
817
586
|
onBookmarkContext(near ? near.playbackSec : hit.sec, near, e.clientX, e.clientY);
|
|
818
587
|
}
|
|
819
588
|
|
|
820
|
-
/** Keep the (centre-anchored) hover card inside the band's own width. */
|
|
821
|
-
const CARD_HALF_PX = 90;
|
|
822
|
-
function clampCardX(x: number, width: number): number {
|
|
823
|
-
return Math.max(Math.min(CARD_HALF_PX, width / 2), Math.min(x, width - CARD_HALF_PX));
|
|
824
|
-
}
|
|
825
|
-
|
|
826
589
|
function onBandMove(e: MouseEvent) {
|
|
827
590
|
const hit = canvasSec(e);
|
|
828
591
|
if (!hit) return;
|
|
829
592
|
const cardX = clampCardX(hit.x, hit.width);
|
|
830
|
-
const bm = nearestBookmark(hit.x, hit.width);
|
|
593
|
+
const bm = nearestBookmark(hit.x, hit.width, sortedBookmarks, bandWindow, effectiveDuration);
|
|
831
594
|
if (bm) {
|
|
832
595
|
// A bookmark under the pointer takes over the card entirely — it's
|
|
833
596
|
// yours, and it outranks the model's chapter guess at this exact spot.
|
|
@@ -841,9 +604,9 @@
|
|
|
841
604
|
if (hoverChapter !== -1) hoverChapter = -1;
|
|
842
605
|
return;
|
|
843
606
|
}
|
|
844
|
-
const ch = chapterAt(hit.sec);
|
|
607
|
+
const ch = chapterAt(hit.sec, sortedChapters);
|
|
845
608
|
const title = ch >= 0 ? sortedChapters[ch]?.title : '';
|
|
846
|
-
const at = segmentAt(hit.sec);
|
|
609
|
+
const at = segmentAt(hit.sec, sortedSegments);
|
|
847
610
|
hover = {
|
|
848
611
|
x: cardX,
|
|
849
612
|
title: title ? `${title} · ${formatTime(hit.sec, useHours)}` : formatTime(hit.sec, useHours),
|
|
@@ -867,8 +630,6 @@
|
|
|
867
630
|
* hover region (distinct from the video, which owns volume), so nothing else
|
|
868
631
|
* here claims the wheel.
|
|
869
632
|
*/
|
|
870
|
-
const ZOOM_FACTOR = 1.3;
|
|
871
|
-
const MIN_WINDOW_SEC = 3;
|
|
872
633
|
function onBandWheel(e: WheelEvent) {
|
|
873
634
|
const d = effectiveDuration;
|
|
874
635
|
const canvas = canvasEl;
|
|
@@ -877,14 +638,14 @@
|
|
|
877
638
|
const w = r.width;
|
|
878
639
|
if (!w) return;
|
|
879
640
|
e.preventDefault();
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
viewStart =
|
|
887
|
-
viewEnd =
|
|
641
|
+
const cursorSec = fracToSec(
|
|
642
|
+
bandWindow.end > 0 ? bandWindow : resetWindow(d),
|
|
643
|
+
d,
|
|
644
|
+
Math.max(0, Math.min(1, (e.clientX - r.left) / w))
|
|
645
|
+
);
|
|
646
|
+
const next = zoomAt(bandWindow.end > 0 ? bandWindow : resetWindow(d), d, cursorSec, e.deltaY);
|
|
647
|
+
viewStart = next.start;
|
|
648
|
+
viewEnd = next.end;
|
|
888
649
|
flashZoomLabel();
|
|
889
650
|
}
|
|
890
651
|
|
|
@@ -897,15 +658,20 @@
|
|
|
897
658
|
else if (e.key === 'ArrowRight') seekTo(currentTime + step);
|
|
898
659
|
else if (e.key === 'Home') seekTo(0);
|
|
899
660
|
else if (e.key === 'End') seekTo(d);
|
|
900
|
-
else if (e.key === 'Escape' &&
|
|
661
|
+
else if (e.key === 'Escape' && isZoomedNow()) resetZoom();
|
|
901
662
|
else return;
|
|
902
663
|
e.preventDefault();
|
|
903
664
|
}
|
|
904
665
|
|
|
905
|
-
// ── Video region: wheel = volume (shift = seek
|
|
666
|
+
// ── Video region: wheel = volume (shift = seek, alt = speed), keyboard map ──
|
|
906
667
|
function onStageWheel(e: WheelEvent) {
|
|
907
668
|
if (!videoEl) return;
|
|
908
669
|
e.preventDefault();
|
|
670
|
+
if (e.altKey) {
|
|
671
|
+
// alt+scroll — speed, the third wheel verb (extension parity)
|
|
672
|
+
stepSpeed(e.deltaY < 0 ? 1 : -1);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
909
675
|
if (e.shiftKey) {
|
|
910
676
|
const delta = e.deltaY < 0 ? 5 : -5;
|
|
911
677
|
seekTo(currentTime + delta);
|
|
@@ -918,14 +684,38 @@
|
|
|
918
684
|
flashOsd(`${Math.round(next * 100)}%`);
|
|
919
685
|
}
|
|
920
686
|
|
|
687
|
+
/**
|
|
688
|
+
* The full keyboard map (video/keys.ts is the single source; `?` renders it).
|
|
689
|
+
* Keys whose feature has no data are bound anyway where harmless, but only
|
|
690
|
+
* ADVERTISED when the data exists — a documented key that does nothing is
|
|
691
|
+
* the one thing a keymap must never be.
|
|
692
|
+
*/
|
|
921
693
|
function onRootKeyDown(e: KeyboardEvent) {
|
|
694
|
+
// The band's slider handler (and the help overlay) own their keys and
|
|
695
|
+
// preventDefault them — without this guard an ArrowLeft with the band
|
|
696
|
+
// focused seeks TWICE (band ±5s, then root ±5s more).
|
|
697
|
+
if (e.defaultPrevented) return;
|
|
922
698
|
const target = e.target as HTMLElement | null;
|
|
923
699
|
const tag = target?.tagName;
|
|
924
700
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
|
|
925
701
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
926
702
|
const v = videoEl;
|
|
927
703
|
if (!v) return;
|
|
704
|
+
|
|
705
|
+
// The help overlay takes over Escape while it's open.
|
|
706
|
+
if (keysOpen) {
|
|
707
|
+
if (e.key === 'Escape' || e.key === '?') {
|
|
708
|
+
e.preventDefault();
|
|
709
|
+
keysOpen = false;
|
|
710
|
+
}
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
928
714
|
switch (e.key) {
|
|
715
|
+
case '?':
|
|
716
|
+
e.preventDefault();
|
|
717
|
+
keysOpen = true;
|
|
718
|
+
return;
|
|
929
719
|
case ' ':
|
|
930
720
|
case 'k':
|
|
931
721
|
v.paused ? v.play() : v.pause();
|
|
@@ -940,6 +730,7 @@
|
|
|
940
730
|
break;
|
|
941
731
|
case 'ArrowUp':
|
|
942
732
|
v.volume = Math.min(1, v.volume + 0.05);
|
|
733
|
+
if (v.volume > 0) v.muted = false;
|
|
943
734
|
flashOsd(`${Math.round(v.volume * 100)}%`);
|
|
944
735
|
break;
|
|
945
736
|
case 'ArrowDown':
|
|
@@ -950,6 +741,62 @@
|
|
|
950
741
|
v.muted = !v.muted;
|
|
951
742
|
flashOsd(v.muted ? 'Muted' : `${Math.round(v.volume * 100)}%`);
|
|
952
743
|
break;
|
|
744
|
+
case '-':
|
|
745
|
+
case '_':
|
|
746
|
+
stepSpeed(-1);
|
|
747
|
+
break;
|
|
748
|
+
case '=':
|
|
749
|
+
case '+':
|
|
750
|
+
stepSpeed(1);
|
|
751
|
+
break;
|
|
752
|
+
case 'Backspace':
|
|
753
|
+
e.preventDefault();
|
|
754
|
+
if (baseRate !== 1) {
|
|
755
|
+
baseRate = 1;
|
|
756
|
+
applyRate();
|
|
757
|
+
}
|
|
758
|
+
flashOsd(formatSpeedLabel(baseRate));
|
|
759
|
+
return;
|
|
760
|
+
case '<':
|
|
761
|
+
// Frame step: pause first; the stepped frame is visible in the video
|
|
762
|
+
// itself, so no OSD — the mpv binding, shifted one key over (,</> are
|
|
763
|
+
// shifted ,/. — the extension moved it there because `,` means settings).
|
|
764
|
+
e.preventDefault();
|
|
765
|
+
v.pause();
|
|
766
|
+
v.currentTime = Math.max(0, v.currentTime - FRAME_STEP_SEC);
|
|
767
|
+
return;
|
|
768
|
+
case '>':
|
|
769
|
+
e.preventDefault();
|
|
770
|
+
v.pause();
|
|
771
|
+
v.currentTime = Math.min(effectiveDuration || Infinity, v.currentTime + FRAME_STEP_SEC);
|
|
772
|
+
return;
|
|
773
|
+
case '[':
|
|
774
|
+
case ']': {
|
|
775
|
+
e.preventDefault();
|
|
776
|
+
const jump = chapterJump(currentTime, sortedChapters, e.key === ']' ? 1 : -1);
|
|
777
|
+
if (jump) {
|
|
778
|
+
seekTo(jump.startSec);
|
|
779
|
+
flashOsd(jump.title || 'Chapter');
|
|
780
|
+
} else {
|
|
781
|
+
flashOsd('No chapters');
|
|
782
|
+
}
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
case 'b':
|
|
786
|
+
case 'B':
|
|
787
|
+
if (onAddBookmark) {
|
|
788
|
+
e.preventDefault();
|
|
789
|
+
submitBookmark();
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
case 't':
|
|
793
|
+
case 'T':
|
|
794
|
+
if (sortedSegments.length > 0) {
|
|
795
|
+
e.preventDefault();
|
|
796
|
+
transcriptVisible = !transcriptVisible;
|
|
797
|
+
flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
|
|
798
|
+
}
|
|
799
|
+
return;
|
|
953
800
|
case 'Home':
|
|
954
801
|
seekTo(0);
|
|
955
802
|
break;
|
|
@@ -962,12 +809,16 @@
|
|
|
962
809
|
e.preventDefault();
|
|
963
810
|
}
|
|
964
811
|
|
|
965
|
-
// ── Playback: startAt + solo-speaker skipping
|
|
966
|
-
let appliedStartAt = false;
|
|
812
|
+
// ── Playback: startAt/deep-link seed + solo-speaker skipping ────────────
|
|
967
813
|
function onLoadedMetadata() {
|
|
968
|
-
if (!appliedStartAt
|
|
969
|
-
|
|
970
|
-
|
|
814
|
+
if (!appliedStartAt) {
|
|
815
|
+
// An explicit startAt prop wins over the URL's ?t= (the host knows its
|
|
816
|
+
// own intent); with syncUrlTime off this is exactly the old behaviour.
|
|
817
|
+
const start = startAt ?? urlStartAt;
|
|
818
|
+
if (start != null && Number.isFinite(start)) {
|
|
819
|
+
appliedStartAt = true;
|
|
820
|
+
seekTo(start);
|
|
821
|
+
}
|
|
971
822
|
}
|
|
972
823
|
}
|
|
973
824
|
|
|
@@ -991,13 +842,13 @@
|
|
|
991
842
|
|
|
992
843
|
function handleTimeUpdate() {
|
|
993
844
|
applySoloSkip();
|
|
845
|
+
syncUrl();
|
|
994
846
|
// Zoomed in and playback walked past the edge of the window — follow it,
|
|
995
847
|
// or you'd watch a frozen slice of waveform while the video plays on.
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
viewEnd = viewStart + span;
|
|
848
|
+
const follow = followPlayhead(bandWindow, effectiveDuration, currentTime);
|
|
849
|
+
if (follow) {
|
|
850
|
+
viewStart = follow.start;
|
|
851
|
+
viewEnd = follow.end;
|
|
1001
852
|
}
|
|
1002
853
|
if (videoEl) onTimeUpdate?.(videoEl);
|
|
1003
854
|
}
|
|
@@ -1039,7 +890,7 @@
|
|
|
1039
890
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
1040
891
|
<div
|
|
1041
892
|
class="vp-root {className}"
|
|
1042
|
-
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0}
|
|
893
|
+
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
|
|
1043
894
|
role="region"
|
|
1044
895
|
aria-label="Video player"
|
|
1045
896
|
tabindex="-1"
|
|
@@ -1059,8 +910,11 @@
|
|
|
1059
910
|
bind:duration
|
|
1060
911
|
ontimeupdate={handleTimeUpdate}
|
|
1061
912
|
onloadedmetadata={onLoadedMetadata}
|
|
913
|
+
onseeked={syncUrlNow}
|
|
914
|
+
onpause={syncUrlNow}
|
|
1062
915
|
{src}
|
|
1063
916
|
{loop}
|
|
917
|
+
crossorigin={crossorigin ?? undefined}
|
|
1064
918
|
muted={effectiveAutoplay || undefined}
|
|
1065
919
|
poster={poster ?? undefined}
|
|
1066
920
|
controls
|
|
@@ -1117,15 +971,15 @@
|
|
|
1117
971
|
{#if laneCarriesSpeakers}
|
|
1118
972
|
{#each sortedTurns as turn, i (turn.startSec + ':' + i)}
|
|
1119
973
|
{@const key = speakerKeyOf(turn)}
|
|
1120
|
-
{@const l = secToFrac(turn.startSec) * 100}
|
|
1121
|
-
{@const r = secToFrac(turn.endSec) * 100}
|
|
1122
|
-
{#if turn.endSec >
|
|
974
|
+
{@const l = secToFrac(bandWindow, effectiveDuration, turn.startSec) * 100}
|
|
975
|
+
{@const r = secToFrac(bandWindow, effectiveDuration, turn.endSec) * 100}
|
|
976
|
+
{#if turn.endSec > bandWindow.start && turn.startSec < (bandWindow.end || effectiveDuration)}
|
|
1123
977
|
<span
|
|
1124
978
|
class="vp-lane-span"
|
|
1125
979
|
class:vp-lane-ad={turn.ad}
|
|
1126
980
|
class:vp-dim={highlightKey != null
|
|
1127
981
|
? highlightKey !== key
|
|
1128
|
-
: filterDims(turn.ad ? null : key, !!turn.ad)}
|
|
982
|
+
: filterDims(turn.ad ? null : key, !!turn.ad, soloKey, mutedKeys)}
|
|
1129
983
|
style="left: {Math.max(0, Math.min(100, l))}%; width: max({Math.max(
|
|
1130
984
|
0,
|
|
1131
985
|
Math.min(100, r) - Math.max(0, l)
|
|
@@ -1163,7 +1017,7 @@
|
|
|
1163
1017
|
|
|
1164
1018
|
<div class="vp-times">
|
|
1165
1019
|
<span>{formatTime(currentTime, useHours)}</span>
|
|
1166
|
-
{#if
|
|
1020
|
+
{#if isZoomedNow()}
|
|
1167
1021
|
<button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
|
|
1168
1022
|
Reset zoom
|
|
1169
1023
|
</button>
|
|
@@ -1254,7 +1108,7 @@
|
|
|
1254
1108
|
{/if}
|
|
1255
1109
|
</div>
|
|
1256
1110
|
|
|
1257
|
-
{#if sortedSegments.length > 0}
|
|
1111
|
+
{#if sortedSegments.length > 0 && transcriptVisible}
|
|
1258
1112
|
<div class="vp-transcript" style="max-height: {transcriptMaxHeight}">
|
|
1259
1113
|
{#each sortedSegments as seg (seg.id)}
|
|
1260
1114
|
<button
|
|
@@ -1263,7 +1117,7 @@
|
|
|
1263
1117
|
class:active={activeSegment?.id === seg.id}
|
|
1264
1118
|
class:vp-dim={highlightKey != null
|
|
1265
1119
|
? highlightKey !== speakerKeyOf(seg)
|
|
1266
|
-
: filterDims(speakerKeyOf(seg), false)}
|
|
1120
|
+
: filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
|
|
1267
1121
|
bind:this={rowRefs[seg.id]}
|
|
1268
1122
|
onclick={() => seekTo(seg.startSec)}
|
|
1269
1123
|
>
|
|
@@ -1276,6 +1130,35 @@
|
|
|
1276
1130
|
{/each}
|
|
1277
1131
|
</div>
|
|
1278
1132
|
{/if}
|
|
1133
|
+
|
|
1134
|
+
{#if keysOpen}
|
|
1135
|
+
<!-- Keyboard help — `?` toggles, Escape/`?` closes. The rows are the
|
|
1136
|
+
PLAYER_KEYS map filtered to keys whose feature's data exists, so the
|
|
1137
|
+
overlay never advertises a key that would do nothing here. -->
|
|
1138
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
1139
|
+
<div class="vp-keys-backdrop" onclick={(e) => { if (e.target === e.currentTarget) keysOpen = false; }}>
|
|
1140
|
+
<div class="vp-keys" role="dialog" aria-label="Keyboard shortcuts">
|
|
1141
|
+
<div class="vp-keys-title">Keyboard</div>
|
|
1142
|
+
<ul class="vp-keys-list">
|
|
1143
|
+
{#each availableKeys as row (row.keys)}
|
|
1144
|
+
<li class="vp-keys-row">
|
|
1145
|
+
<span class="vp-keys-kbd">{row.keys}</span>
|
|
1146
|
+
<span class="vp-keys-desc">{row.description}</span>
|
|
1147
|
+
</li>
|
|
1148
|
+
{/each}
|
|
1149
|
+
</ul>
|
|
1150
|
+
<div class="vp-keys-title">Band focused</div>
|
|
1151
|
+
<ul class="vp-keys-list">
|
|
1152
|
+
{#each BAND_KEYS as row (row.keys)}
|
|
1153
|
+
<li class="vp-keys-row">
|
|
1154
|
+
<span class="vp-keys-kbd">{row.keys}</span>
|
|
1155
|
+
<span class="vp-keys-desc">{row.description}</span>
|
|
1156
|
+
</li>
|
|
1157
|
+
{/each}
|
|
1158
|
+
</ul>
|
|
1159
|
+
</div>
|
|
1160
|
+
</div>
|
|
1161
|
+
{/if}
|
|
1279
1162
|
</div>
|
|
1280
1163
|
|
|
1281
1164
|
<style>
|
|
@@ -1291,6 +1174,7 @@
|
|
|
1291
1174
|
border-color: color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
1292
1175
|
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
1293
1176
|
padding: var(--jatui-space-lg, 12px);
|
|
1177
|
+
position: relative;
|
|
1294
1178
|
}
|
|
1295
1179
|
|
|
1296
1180
|
.vp-root:focus {
|
|
@@ -1346,7 +1230,8 @@
|
|
|
1346
1230
|
background: var(--color-neutral);
|
|
1347
1231
|
}
|
|
1348
1232
|
|
|
1349
|
-
/* Transient wheel readout (volume / seek) — one calm flash,
|
|
1233
|
+
/* Transient wheel/keyboard readout (volume / seek / speed) — one calm flash,
|
|
1234
|
+
then gone. */
|
|
1350
1235
|
.vp-osd {
|
|
1351
1236
|
position: absolute;
|
|
1352
1237
|
top: var(--jatui-space-base, 8px);
|
|
@@ -1713,6 +1598,78 @@
|
|
|
1713
1598
|
min-width: 10rem;
|
|
1714
1599
|
}
|
|
1715
1600
|
|
|
1601
|
+
/* Keyboard help overlay — the `?` surface. Keycaps first, description after;
|
|
1602
|
+
token-only colours, same chrome family as the hover card. */
|
|
1603
|
+
.vp-keys-backdrop {
|
|
1604
|
+
position: absolute;
|
|
1605
|
+
inset: 0;
|
|
1606
|
+
display: flex;
|
|
1607
|
+
align-items: center;
|
|
1608
|
+
justify-content: center;
|
|
1609
|
+
background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
|
|
1610
|
+
z-index: 10;
|
|
1611
|
+
border-radius: inherit;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
.vp-keys {
|
|
1615
|
+
max-width: 22rem;
|
|
1616
|
+
max-height: 90%;
|
|
1617
|
+
overflow-y: auto;
|
|
1618
|
+
padding: var(--jatui-space-lg, 12px);
|
|
1619
|
+
background: var(--color-base-100);
|
|
1620
|
+
border: var(--jatui-border-w, 1px) solid
|
|
1621
|
+
color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
1622
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
1623
|
+
color: var(--color-base-content);
|
|
1624
|
+
box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
.vp-keys-title {
|
|
1628
|
+
font-size: 0.66rem;
|
|
1629
|
+
font-weight: 700;
|
|
1630
|
+
text-transform: uppercase;
|
|
1631
|
+
letter-spacing: 0.04em;
|
|
1632
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
1633
|
+
margin: var(--jatui-space-sm, 4px) 0;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
.vp-keys-title:first-child {
|
|
1637
|
+
margin-top: 0;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
.vp-keys-list {
|
|
1641
|
+
display: flex;
|
|
1642
|
+
flex-direction: column;
|
|
1643
|
+
gap: var(--jatui-space-xs, 2px);
|
|
1644
|
+
margin: 0 0 var(--jatui-space-sm, 4px);
|
|
1645
|
+
padding: 0;
|
|
1646
|
+
list-style: none;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
.vp-keys-row {
|
|
1650
|
+
display: flex;
|
|
1651
|
+
align-items: baseline;
|
|
1652
|
+
justify-content: space-between;
|
|
1653
|
+
gap: var(--jatui-space-md, 6px);
|
|
1654
|
+
font-size: 0.72rem;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
.vp-keys-kbd {
|
|
1658
|
+
font-family: ui-monospace, monospace;
|
|
1659
|
+
font-size: 0.66rem;
|
|
1660
|
+
white-space: nowrap;
|
|
1661
|
+
padding: 0 var(--jatui-space-xs, 2px);
|
|
1662
|
+
border: var(--jatui-border-w, 1px) solid
|
|
1663
|
+
color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
1664
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
1665
|
+
background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
.vp-keys-desc {
|
|
1669
|
+
color: color-mix(in oklch, var(--color-base-content) 75%, transparent);
|
|
1670
|
+
text-align: right;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1716
1673
|
/* Motion is decoration here — every state also has a static read (colour,
|
|
1717
1674
|
position, label), so collapsing it changes nothing functional. */
|
|
1718
1675
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -1726,4 +1683,4 @@
|
|
|
1726
1683
|
transition: none;
|
|
1727
1684
|
}
|
|
1728
1685
|
}
|
|
1729
|
-
</style>
|
|
1686
|
+
</style>
|