@joewinke/jatui 0.9.0 → 0.10.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/dist/components/VideoPlayer.svelte +371 -1
- package/dist/components/VideoPlayer.svelte.d.ts +44 -1
- package/dist/index.d.ts +1 -1
- package/dist/video/README.md +1 -0
- package/dist/video/band/axis.d.ts +81 -0
- package/dist/video/band/axis.js +106 -0
- package/dist/video/band/drag.d.ts +38 -0
- package/dist/video/band/drag.js +45 -0
- package/dist/video/band/luma.d.ts +62 -0
- package/dist/video/band/luma.js +97 -0
- package/dist/video/band/markers.d.ts +73 -0
- package/dist/video/band/markers.js +146 -0
- package/dist/video/band/memo.d.ts +21 -0
- package/dist/video/band/memo.js +15 -0
- package/dist/video/band/window.d.ts +35 -0
- package/dist/video/band/window.js +61 -0
- package/dist/video/band.d.ts +37 -31
- package/dist/video/band.js +78 -57
- package/dist/video/keys.d.ts +1 -1
- package/dist/video/keys.js +3 -0
- package/dist/video/model.d.ts +1 -1
- package/dist/video/settings.js +6 -5
- package/package.json +2 -2
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
VideoWaveform,
|
|
10
10
|
VideoSpeakerTalkTime
|
|
11
11
|
} from '../video/types';
|
|
12
|
+
// mos-5lzns.30 — one-axis state, the light track, social markers.
|
|
13
|
+
export type { BandAxis, BandState } from '../video/band/axis';
|
|
14
|
+
export type { VideoLumaTrack } from '../video/band/luma';
|
|
15
|
+
export type { VideoMarker } from '../video/band/markers';
|
|
12
16
|
</script>
|
|
13
17
|
|
|
14
18
|
<script lang="ts">
|
|
@@ -90,6 +94,7 @@
|
|
|
90
94
|
UNKNOWN_KEY
|
|
91
95
|
} from '../video/model';
|
|
92
96
|
import {
|
|
97
|
+
bandClusters,
|
|
93
98
|
clampCardX,
|
|
94
99
|
followPlayhead,
|
|
95
100
|
fracToSec,
|
|
@@ -106,6 +111,27 @@
|
|
|
106
111
|
zoomAt,
|
|
107
112
|
type BandWindow
|
|
108
113
|
} from '../video/band';
|
|
114
|
+
import {
|
|
115
|
+
barHidden,
|
|
116
|
+
bandStateOf,
|
|
117
|
+
createBandReporter,
|
|
118
|
+
overviewBracket,
|
|
119
|
+
playheadX,
|
|
120
|
+
type BandState
|
|
121
|
+
} from '../video/band/axis';
|
|
122
|
+
import { normalizeLuma, ribbonColumns, RIBBON_STOPS, type VideoLumaTrack } from '../video/band/luma';
|
|
123
|
+
import {
|
|
124
|
+
commentAt,
|
|
125
|
+
commentJump,
|
|
126
|
+
describeComments,
|
|
127
|
+
MARKER_HIT_PX,
|
|
128
|
+
MARKER_HIT_PX_COARSE,
|
|
129
|
+
sortMarkers,
|
|
130
|
+
splitMarkers,
|
|
131
|
+
type VideoMarker
|
|
132
|
+
} from '../video/band/markers';
|
|
133
|
+
import { dragCancel, dragEnd, dragMove, dragStart, type DragState } from '../video/band/drag';
|
|
134
|
+
import { createBandLayerCache } from '../video/band/memo';
|
|
109
135
|
import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
|
|
110
136
|
import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
|
|
111
137
|
import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
|
|
@@ -150,6 +176,11 @@
|
|
|
150
176
|
* refresh/copy/share picks up where playback left off.
|
|
151
177
|
*/
|
|
152
178
|
syncUrlTime = false,
|
|
179
|
+
luma = null,
|
|
180
|
+
markers = [],
|
|
181
|
+
oneAxis = true,
|
|
182
|
+
onBandChange = undefined,
|
|
183
|
+
onMarkerClick = undefined,
|
|
153
184
|
onTimeUpdate = undefined,
|
|
154
185
|
onAddBookmark = undefined,
|
|
155
186
|
onBookmarkContext = undefined,
|
|
@@ -205,6 +236,41 @@
|
|
|
205
236
|
transcriptMaxHeight?: string;
|
|
206
237
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
207
238
|
syncUrlTime?: boolean;
|
|
239
|
+
/**
|
|
240
|
+
* The light track — the host's pre-scanned brightness sidecar (integers
|
|
241
|
+
* 0–99 at `rate` samples/s; extension-library's `.luma` verbatim).
|
|
242
|
+
* Present ⇒ the band paints the light ribbon under its envelope, so
|
|
243
|
+
* bright passages glow on the timeline before you reach them. Night
|
|
244
|
+
* dimming (mos-5lzns.33) reads the same prop. Garbage or < 4 samples ⇒
|
|
245
|
+
* no ribbon, never a wrong one.
|
|
246
|
+
*/
|
|
247
|
+
luma?: VideoLumaTrack | null;
|
|
248
|
+
/**
|
|
249
|
+
* Social markers on the band: `kind: 'comment'` draws a dot under the
|
|
250
|
+
* chapter flags (hover = the comment, click = seek to its exact second);
|
|
251
|
+
* `kind: 'like'` folds into a heat strip along the bottom edge.
|
|
252
|
+
*/
|
|
253
|
+
markers?: VideoMarker[];
|
|
254
|
+
/**
|
|
255
|
+
* One time axis (default on): while the band has an envelope it OWNS the
|
|
256
|
+
* axis and the native timeline stands down (Chromium; elsewhere it can't
|
|
257
|
+
* be hidden and simply stays); zoomed, the band grows a whole-video
|
|
258
|
+
* overview rail with the window bracketed. Off ⇒ both, permanently.
|
|
259
|
+
*/
|
|
260
|
+
oneAxis?: boolean;
|
|
261
|
+
/**
|
|
262
|
+
* The four-state arbitration, reported on real change only and deferred
|
|
263
|
+
* a microtask (the host's ref to this component may not be set yet when
|
|
264
|
+
* the first report fires). A host that draws its OWN seek bar reads
|
|
265
|
+
* `axis` to decide whether to show it — see video/band/axis.ts.
|
|
266
|
+
*/
|
|
267
|
+
onBandChange?: (state: BandState) => void;
|
|
268
|
+
/**
|
|
269
|
+
* A comment marker was clicked (after the seek to it). Dots within a few
|
|
270
|
+
* px merge into one; `cluster` carries every comment under that dot,
|
|
271
|
+
* earliest first, and `marker` is `cluster[0]` — the one seeked to.
|
|
272
|
+
*/
|
|
273
|
+
onMarkerClick?: (marker: VideoMarker, cluster: VideoMarker[]) => void;
|
|
208
274
|
onTimeUpdate?: (video: HTMLVideoElement) => void;
|
|
209
275
|
/** Present ⇒ the band grows an "add bookmark at current time" control. */
|
|
210
276
|
onAddBookmark?: (playbackSec: number, label: string) => void;
|
|
@@ -226,6 +292,8 @@
|
|
|
226
292
|
// Autoplay forces muted — browsers refuse to autoplay audible video, and a
|
|
227
293
|
// silent presentation clip is the point of the primitive anyway.
|
|
228
294
|
const effectiveAutoplay = $derived(autoplay && !reduceMotion);
|
|
295
|
+
// `(pointer: coarse)` — a fingertip gets a wider comment-dot target.
|
|
296
|
+
let coarsePointer = $state(false);
|
|
229
297
|
|
|
230
298
|
// ── Element refs + media state ──────────────────────────────────────────
|
|
231
299
|
let videoEl: HTMLVideoElement | undefined = $state();
|
|
@@ -250,6 +318,7 @@
|
|
|
250
318
|
text: string;
|
|
251
319
|
thumb: string | null;
|
|
252
320
|
isBookmark: boolean;
|
|
321
|
+
isComment?: boolean;
|
|
253
322
|
} | null = $state(null);
|
|
254
323
|
let zoomLabel = $state('');
|
|
255
324
|
let zoomLabelOn = $state(false);
|
|
@@ -302,6 +371,12 @@
|
|
|
302
371
|
});
|
|
303
372
|
const hasWave = $derived(!!peaks);
|
|
304
373
|
|
|
374
|
+
const lumaSeries = $derived(normalizeLuma(luma));
|
|
375
|
+
// Memo slots for the ribbon / heat / cluster arrays — plain object, never a
|
|
376
|
+
// reactive source (band/memo.ts): a playhead repaint reuses them.
|
|
377
|
+
const layerCache = createBandLayerCache();
|
|
378
|
+
const socialMarkers = $derived(splitMarkers(sortMarkers(markers)));
|
|
379
|
+
|
|
305
380
|
/**
|
|
306
381
|
* Ordered speaker registry, built from turns first then transcript, so the
|
|
307
382
|
* palette index is stable across both panes and the legend.
|
|
@@ -343,14 +418,58 @@
|
|
|
343
418
|
mutedKeys,
|
|
344
419
|
window: bandWindow,
|
|
345
420
|
hoverChapter,
|
|
421
|
+
luma: lumaSeries,
|
|
422
|
+
comments: socialMarkers.comments,
|
|
423
|
+
likes: socialMarkers.likes,
|
|
424
|
+
cache: layerCache,
|
|
346
425
|
colors: {
|
|
347
426
|
accent: getDaisyUIColor('primary'),
|
|
348
427
|
neutral: getDaisyUIColor('base-content'),
|
|
349
|
-
marker: getDaisyUIColor('warning')
|
|
428
|
+
marker: getDaisyUIColor('warning'),
|
|
429
|
+
ribbon: getDaisyUIColor('warning'),
|
|
430
|
+
comment: getDaisyUIColor('info'),
|
|
431
|
+
like: getDaisyUIColor('secondary'),
|
|
432
|
+
surface: getDaisyUIColor('base-100')
|
|
350
433
|
}
|
|
351
434
|
};
|
|
352
435
|
});
|
|
353
436
|
|
|
437
|
+
// ── One time axis (video/band/axis.ts) ─────────────────────────────────
|
|
438
|
+
const currentBandState = $derived<BandState>(
|
|
439
|
+
bandStateOf(hasWave, bandWindow, effectiveDuration, oneAxis)
|
|
440
|
+
);
|
|
441
|
+
const nativeBarHidden = $derived(barHidden(currentBandState.axis));
|
|
442
|
+
/** The overview rail: whole video, window bracketed — only while zoomed. */
|
|
443
|
+
const bracket = $derived(overviewBracket(bandWindow, effectiveDuration));
|
|
444
|
+
const overviewPlayhead = $derived(
|
|
445
|
+
effectiveDuration > 0 ? Math.max(0, Math.min(1, currentTime / effectiveDuration)) : 0
|
|
446
|
+
);
|
|
447
|
+
/** The ribbon on the overview too, as a gradient — FEATURES.md's 120 stops. */
|
|
448
|
+
const overviewRibbon = $derived.by(() => {
|
|
449
|
+
void themeTick;
|
|
450
|
+
if (!lumaSeries || !(effectiveDuration > 0)) return '';
|
|
451
|
+
const alphas = ribbonColumns(lumaSeries, { start: 0, end: effectiveDuration }, effectiveDuration, RIBBON_STOPS);
|
|
452
|
+
const stops = alphas.map(
|
|
453
|
+
(a, i) =>
|
|
454
|
+
`color-mix(in oklch, var(--color-warning) ${(a * 100).toFixed(1)}%, transparent) ${((i / (alphas.length - 1)) * 100).toFixed(2)}%`
|
|
455
|
+
);
|
|
456
|
+
return `linear-gradient(to right, ${stops.join(', ')})`;
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The current four-state arbitration — the extension's `bandState()`.
|
|
461
|
+
* Callable on the instance (`bind:this`) for hosts that prefer to pull.
|
|
462
|
+
*/
|
|
463
|
+
export function bandState(): BandState {
|
|
464
|
+
return { ...currentBandState };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const reportBand = createBandReporter(() => onBandChange);
|
|
468
|
+
$effect(() => {
|
|
469
|
+
const st = currentBandState;
|
|
470
|
+
untrack(() => reportBand(st));
|
|
471
|
+
});
|
|
472
|
+
|
|
354
473
|
/** Help-overlay rows: only keys whose feature's data exists are advertised. */
|
|
355
474
|
const availableKeys = $derived(
|
|
356
475
|
PLAYER_KEYS.filter((row) => {
|
|
@@ -358,6 +477,7 @@
|
|
|
358
477
|
if (row.when === 'chapters') return sortedChapters.length > 0;
|
|
359
478
|
if (row.when === 'bookmarks') return onAddBookmark != null;
|
|
360
479
|
if (row.when === 'transcript') return sortedSegments.length > 0;
|
|
480
|
+
if (row.when === 'comments') return socialMarkers.comments.length > 0;
|
|
361
481
|
return true;
|
|
362
482
|
})
|
|
363
483
|
);
|
|
@@ -508,10 +628,20 @@
|
|
|
508
628
|
mq.addEventListener('change', onChange);
|
|
509
629
|
stopReduce = () => mq.removeEventListener('change', onChange);
|
|
510
630
|
}
|
|
631
|
+
let stopCoarse: (() => void) | undefined;
|
|
632
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
633
|
+
const mq = window.matchMedia('(pointer: coarse)');
|
|
634
|
+
coarsePointer = mq.matches;
|
|
635
|
+
const onChange = () => (coarsePointer = mq.matches);
|
|
636
|
+
mq.addEventListener('change', onChange);
|
|
637
|
+
stopCoarse = () => mq.removeEventListener('change', onChange);
|
|
638
|
+
}
|
|
511
639
|
|
|
512
640
|
return () => {
|
|
513
641
|
stopTheme();
|
|
514
642
|
stopReduce?.();
|
|
643
|
+
stopCoarse?.();
|
|
644
|
+
if (dragRaf) cancelAnimationFrame(dragRaf);
|
|
515
645
|
if (zoomTimer) clearTimeout(zoomTimer);
|
|
516
646
|
if (osdTimer) clearTimeout(osdTimer);
|
|
517
647
|
};
|
|
@@ -565,12 +695,115 @@
|
|
|
565
695
|
return { sec: fracToSec(bandWindow, effectiveDuration, x / r.width), x, width: r.width };
|
|
566
696
|
}
|
|
567
697
|
|
|
698
|
+
/**
|
|
699
|
+
* The comment cluster under the pointer, hit-tested in the band's own
|
|
700
|
+
* window against the SAME memoised clusters the paint drew (clientWidth,
|
|
701
|
+
* like the paint — so the hit test and the dots can never disagree).
|
|
702
|
+
*/
|
|
703
|
+
function commentUnder(hit: { x: number; width: number }) {
|
|
704
|
+
const canvas = canvasEl;
|
|
705
|
+
if (socialMarkers.comments.length === 0 || !canvas || !hit.width) return null;
|
|
706
|
+
const cw = canvas.clientWidth || hit.width;
|
|
707
|
+
const clusters = bandClusters(socialMarkers.comments, bandWindow, effectiveDuration, cw, layerCache);
|
|
708
|
+
return commentAt(hit.x * (cw / hit.width), clusters, coarsePointer ? MARKER_HIT_PX_COARSE : MARKER_HIT_PX);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── Screen-reader announcements (comment jumps / clicks) ───────────────
|
|
712
|
+
let announcement = $state('');
|
|
713
|
+
function announce(text: string) {
|
|
714
|
+
// Clear first so repeating the same comment is announced again.
|
|
715
|
+
announcement = '';
|
|
716
|
+
queueMicrotask(() => (announcement = text));
|
|
717
|
+
}
|
|
718
|
+
function landOnComments(group: VideoMarker[]) {
|
|
719
|
+
seekTo(group[0].atSec);
|
|
720
|
+
announce(describeComments(group, (sec) => formatTime(sec, useHours)));
|
|
721
|
+
}
|
|
722
|
+
|
|
568
723
|
function onBandClick(e: MouseEvent) {
|
|
724
|
+
// The click the browser fires after a drag-scrub's release is not a click.
|
|
725
|
+
if (suppressNextClick) {
|
|
726
|
+
suppressNextClick = false;
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
569
729
|
const hit = canvasSec(e);
|
|
570
730
|
if (!hit) return;
|
|
731
|
+
// A comment is ABOUT its instant: land exactly there, no transcript bias.
|
|
732
|
+
const c = commentUnder(hit);
|
|
733
|
+
if (c) {
|
|
734
|
+
landOnComments(c.markers);
|
|
735
|
+
onMarkerClick?.(c.markers[0], c.markers.slice());
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
571
738
|
seekTo(biasToSegmentStart(hit.sec, sortedSegments));
|
|
572
739
|
}
|
|
573
740
|
|
|
741
|
+
// ── Drag-to-scrub (video/band/drag.ts) ──────────────────────────────────
|
|
742
|
+
// One-axis hides the native timeline, so the band must scrub like it:
|
|
743
|
+
// pointer capture keeps the drag alive off the canvas, seeks are throttled
|
|
744
|
+
// to one per animation frame, and the release lands exactly under the
|
|
745
|
+
// pointer (no transcript bias — a scrub is deliberate).
|
|
746
|
+
let drag: DragState | null = null;
|
|
747
|
+
let dragRaf = 0;
|
|
748
|
+
let suppressNextClick = false;
|
|
749
|
+
|
|
750
|
+
function flushDragSeek() {
|
|
751
|
+
dragRaf = 0;
|
|
752
|
+
if (drag?.pendingSec != null) seekTo(drag.pendingSec);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function onBandPointerDown(e: PointerEvent) {
|
|
756
|
+
if (e.button !== 0 || !canvasEl) return;
|
|
757
|
+
drag = dragStart(e.pointerId, e.clientX, currentTime);
|
|
758
|
+
suppressNextClick = false;
|
|
759
|
+
try {
|
|
760
|
+
canvasEl.setPointerCapture(e.pointerId);
|
|
761
|
+
} catch {
|
|
762
|
+
// capture can fail on a pointer that's already gone — the drag just ends early
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function onBandPointerMove(e: PointerEvent) {
|
|
767
|
+
if (!drag) return;
|
|
768
|
+
const hit = canvasSec(e);
|
|
769
|
+
if (!hit) return;
|
|
770
|
+
const sec = Math.max(0, Math.min(effectiveDuration, hit.sec));
|
|
771
|
+
const next = dragMove(drag, e.pointerId, e.clientX, sec);
|
|
772
|
+
if (next === drag) return;
|
|
773
|
+
drag = next;
|
|
774
|
+
if (!dragRaf) dragRaf = requestAnimationFrame(flushDragSeek);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function finishDrag(e: PointerEvent, cancelled: boolean) {
|
|
778
|
+
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
779
|
+
const hit = canvasSec(e);
|
|
780
|
+
const sec = hit ? Math.max(0, Math.min(effectiveDuration, hit.sec)) : (drag.pendingSec ?? currentTime);
|
|
781
|
+
const out = cancelled ? dragCancel(drag, e.pointerId) : dragEnd(drag, e.pointerId, sec);
|
|
782
|
+
if (dragRaf) {
|
|
783
|
+
cancelAnimationFrame(dragRaf);
|
|
784
|
+
dragRaf = 0;
|
|
785
|
+
}
|
|
786
|
+
drag = null;
|
|
787
|
+
if (canvasEl?.hasPointerCapture?.(e.pointerId)) canvasEl.releasePointerCapture(e.pointerId);
|
|
788
|
+
if (out.seekSec != null) seekTo(out.seekSec);
|
|
789
|
+
suppressNextClick = out.suppressClick;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Overview rail: click seeks, and brings the window along if it lands outside it. */
|
|
793
|
+
function onOverviewClick(e: MouseEvent) {
|
|
794
|
+
const d = effectiveDuration;
|
|
795
|
+
const el = e.currentTarget as HTMLElement;
|
|
796
|
+
const r = el.getBoundingClientRect();
|
|
797
|
+
if (!d || !r.width) return;
|
|
798
|
+
const sec = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d;
|
|
799
|
+
seekTo(sec);
|
|
800
|
+
const follow = followPlayhead(bandWindow, d, sec);
|
|
801
|
+
if (follow) {
|
|
802
|
+
viewStart = follow.start;
|
|
803
|
+
viewEnd = follow.end;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
574
807
|
function onBandDblClick() {
|
|
575
808
|
if (!isZoomedNow()) return;
|
|
576
809
|
resetZoom();
|
|
@@ -604,6 +837,23 @@
|
|
|
604
837
|
if (hoverChapter !== -1) hoverChapter = -1;
|
|
605
838
|
return;
|
|
606
839
|
}
|
|
840
|
+
const c = commentUnder(hit);
|
|
841
|
+
if (c) {
|
|
842
|
+
// Other people's words at this instant — they outrank the model's
|
|
843
|
+
// chapter guess, but not your own bookmark.
|
|
844
|
+
const first = c.markers[0];
|
|
845
|
+
const more = c.markers.length > 1 ? ` · +${c.markers.length - 1}` : '';
|
|
846
|
+
hover = {
|
|
847
|
+
x: cardX,
|
|
848
|
+
title: `${first.author || 'Comment'} · ${formatTime(first.atSec, useHours)}${more}`,
|
|
849
|
+
text: first.text ? truncate(first.text, 90) : '',
|
|
850
|
+
thumb: null,
|
|
851
|
+
isBookmark: false,
|
|
852
|
+
isComment: true
|
|
853
|
+
};
|
|
854
|
+
if (hoverChapter !== -1) hoverChapter = -1;
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
607
857
|
const ch = chapterAt(hit.sec, sortedChapters);
|
|
608
858
|
const title = ch >= 0 ? sortedChapters[ch]?.title : '';
|
|
609
859
|
const at = segmentAt(hit.sec, sortedSegments);
|
|
@@ -782,6 +1032,19 @@
|
|
|
782
1032
|
}
|
|
783
1033
|
return;
|
|
784
1034
|
}
|
|
1035
|
+
case '{':
|
|
1036
|
+
case '}': {
|
|
1037
|
+
if (socialMarkers.comments.length === 0) return;
|
|
1038
|
+
e.preventDefault();
|
|
1039
|
+
const group = commentJump(currentTime, socialMarkers.comments, e.key === '}' ? 1 : -1);
|
|
1040
|
+
if (group) {
|
|
1041
|
+
landOnComments(group);
|
|
1042
|
+
flashOsd(group[0].author || 'Comment');
|
|
1043
|
+
} else {
|
|
1044
|
+
flashOsd(e.key === '}' ? 'No later comments' : 'No earlier comments');
|
|
1045
|
+
}
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
785
1048
|
case 'b':
|
|
786
1049
|
case 'B':
|
|
787
1050
|
if (onAddBookmark) {
|
|
@@ -891,6 +1154,8 @@
|
|
|
891
1154
|
<div
|
|
892
1155
|
class="vp-root {className}"
|
|
893
1156
|
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
|
|
1157
|
+
class:vp-one-axis={nativeBarHidden}
|
|
1158
|
+
data-band-axis={currentBandState.axis}
|
|
894
1159
|
role="region"
|
|
895
1160
|
aria-label="Video player"
|
|
896
1161
|
tabindex="-1"
|
|
@@ -942,6 +1207,26 @@
|
|
|
942
1207
|
onmouseleave={onBandLeave}
|
|
943
1208
|
role="presentation"
|
|
944
1209
|
>
|
|
1210
|
+
<!-- Overview rail (one-axis "overview" state): the whole video, with
|
|
1211
|
+
the zoomed window as an outlined bracket. Lives in the band's
|
|
1212
|
+
reserved top padding, so appearing is never a layout shove, and
|
|
1213
|
+
shares the canvas's exact box so the two axes line up. -->
|
|
1214
|
+
{#if bracket}
|
|
1215
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
1216
|
+
<div
|
|
1217
|
+
class="vp-overview"
|
|
1218
|
+
role="presentation"
|
|
1219
|
+
style={overviewRibbon ? `--vp-ribbon: ${overviewRibbon};` : undefined}
|
|
1220
|
+
onclick={onOverviewClick}
|
|
1221
|
+
>
|
|
1222
|
+
<div class="vp-overview-played" style="width: {overviewPlayhead * 100}%"></div>
|
|
1223
|
+
<div
|
|
1224
|
+
class="vp-overview-bracket"
|
|
1225
|
+
style="left: {bracket.left * 100}%; width: {bracket.width * 100}%"
|
|
1226
|
+
></div>
|
|
1227
|
+
<div class="vp-overview-head" style="left: {overviewPlayhead * 100}%"></div>
|
|
1228
|
+
</div>
|
|
1229
|
+
{/if}
|
|
945
1230
|
<canvas
|
|
946
1231
|
bind:this={canvasEl}
|
|
947
1232
|
class="vp-wave"
|
|
@@ -954,10 +1239,17 @@
|
|
|
954
1239
|
aria-valuenow={Math.round(currentTime)}
|
|
955
1240
|
aria-valuetext={formatTime(currentTime, useHours)}
|
|
956
1241
|
onclick={onBandClick}
|
|
1242
|
+
onpointerdown={onBandPointerDown}
|
|
1243
|
+
onpointermove={onBandPointerMove}
|
|
1244
|
+
onpointerup={(e) => finishDrag(e, false)}
|
|
1245
|
+
onpointercancel={(e) => finishDrag(e, true)}
|
|
957
1246
|
ondblclick={onBandDblClick}
|
|
958
1247
|
oncontextmenu={onBandContextMenu}
|
|
959
1248
|
onkeydown={onBandKeyDown}
|
|
960
1249
|
></canvas>
|
|
1250
|
+
<!-- Polite announcements for comment jumps/clicks — the dots are
|
|
1251
|
+
canvas pixels, so without this a comment is invisible to AT. -->
|
|
1252
|
+
<div class="vp-sr" aria-live="polite" aria-atomic="true">{announcement}</div>
|
|
961
1253
|
|
|
962
1254
|
<!-- Speaker lane. Painted ONLY as the no-waveform fallback: with an
|
|
963
1255
|
envelope its own bars wear the turn colours, and two stacked
|
|
@@ -1003,6 +1295,11 @@
|
|
|
1003
1295
|
<img class="vp-card-thumb" src={hover.thumb} alt="" />
|
|
1004
1296
|
{/if}
|
|
1005
1297
|
<div class="vp-card-title">
|
|
1298
|
+
{#if hover.isComment}
|
|
1299
|
+
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true" class="vp-cm-icon">
|
|
1300
|
+
<path d="M4 4h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9l-5 4V5a1 1 0 0 1 1-1z" fill="currentColor" />
|
|
1301
|
+
</svg>
|
|
1302
|
+
{/if}
|
|
1006
1303
|
{#if hover.isBookmark}
|
|
1007
1304
|
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true" class="vp-bm-icon">
|
|
1008
1305
|
<path d="M6 2a2 2 0 0 0-2 2v18l8-5 8 5V4a2 2 0 0 0-2-2H6z" fill="currentColor" />
|
|
@@ -1266,6 +1563,57 @@
|
|
|
1266
1563
|
opacity: 1;
|
|
1267
1564
|
}
|
|
1268
1565
|
|
|
1566
|
+
/* One time axis: while the band owns it, the native timeline stands down.
|
|
1567
|
+
Only Chromium exposes it; elsewhere this matches nothing and the native
|
|
1568
|
+
bar simply stays (see video/band/axis.ts). */
|
|
1569
|
+
.vp-one-axis .vp-video::-webkit-media-controls-timeline {
|
|
1570
|
+
display: none;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/* Overview rail — sits in the band's padding-top, exactly the canvas's
|
|
1574
|
+
width, so its mapping and the band's share one box. */
|
|
1575
|
+
.vp-overview {
|
|
1576
|
+
position: absolute;
|
|
1577
|
+
top: 1px;
|
|
1578
|
+
left: 0;
|
|
1579
|
+
right: 0;
|
|
1580
|
+
height: 5px;
|
|
1581
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1582
|
+
background: var(--vp-ribbon, none), color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
1583
|
+
cursor: pointer;
|
|
1584
|
+
overflow: visible;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
.vp-overview-played {
|
|
1588
|
+
position: absolute;
|
|
1589
|
+
inset: 0 auto 0 0;
|
|
1590
|
+
border-radius: inherit;
|
|
1591
|
+
background: color-mix(in oklch, var(--color-primary) 70%, transparent);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/* A RANGE of the axis: an outline, never a fill — the played bar under it
|
|
1595
|
+
stays readable, and range ≠ position at a glance. */
|
|
1596
|
+
.vp-overview-bracket {
|
|
1597
|
+
position: absolute;
|
|
1598
|
+
top: -1px;
|
|
1599
|
+
bottom: -1px;
|
|
1600
|
+
box-sizing: border-box;
|
|
1601
|
+
border: 1px solid var(--color-base-content);
|
|
1602
|
+
border-radius: 2px;
|
|
1603
|
+
pointer-events: none;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/* Same line as the canvas playhead: 1.5px, centred on the position. */
|
|
1607
|
+
.vp-overview-head {
|
|
1608
|
+
position: absolute;
|
|
1609
|
+
top: -1px;
|
|
1610
|
+
bottom: -1px;
|
|
1611
|
+
width: 1.5px;
|
|
1612
|
+
transform: translateX(-50%);
|
|
1613
|
+
background: var(--color-base-content);
|
|
1614
|
+
pointer-events: none;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1269
1617
|
.vp-wave {
|
|
1270
1618
|
display: block;
|
|
1271
1619
|
width: 100%;
|
|
@@ -1274,6 +1622,23 @@
|
|
|
1274
1622
|
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1275
1623
|
}
|
|
1276
1624
|
|
|
1625
|
+
/* Horizontal drags belong to the scrubber; vertical still scrolls the page. */
|
|
1626
|
+
.vp-wave {
|
|
1627
|
+
touch-action: pan-y;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
.vp-sr {
|
|
1631
|
+
position: absolute;
|
|
1632
|
+
width: 1px;
|
|
1633
|
+
height: 1px;
|
|
1634
|
+
padding: 0;
|
|
1635
|
+
margin: -1px;
|
|
1636
|
+
overflow: hidden;
|
|
1637
|
+
clip: rect(0 0 0 0);
|
|
1638
|
+
white-space: nowrap;
|
|
1639
|
+
border: 0;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1277
1642
|
.vp-wave:focus-visible {
|
|
1278
1643
|
outline: 2px solid var(--color-primary);
|
|
1279
1644
|
outline-offset: 2px;
|
|
@@ -1382,6 +1747,11 @@
|
|
|
1382
1747
|
flex-shrink: 0;
|
|
1383
1748
|
}
|
|
1384
1749
|
|
|
1750
|
+
.vp-cm-icon {
|
|
1751
|
+
color: var(--color-info);
|
|
1752
|
+
flex-shrink: 0;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1385
1755
|
.vp-card-text {
|
|
1386
1756
|
margin-top: var(--jatui-space-xs, 2px);
|
|
1387
1757
|
color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime } from '../video/types';
|
|
2
|
+
export type { BandAxis, BandState } from '../video/band/axis';
|
|
3
|
+
export type { VideoLumaTrack } from '../video/band/luma';
|
|
4
|
+
export type { VideoMarker } from '../video/band/markers';
|
|
2
5
|
import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
|
|
6
|
+
import { type BandState } from '../video/band/axis';
|
|
7
|
+
import { type VideoLumaTrack } from '../video/band/luma';
|
|
8
|
+
import { type VideoMarker } from '../video/band/markers';
|
|
3
9
|
type $$ComponentProps = {
|
|
4
10
|
src: string;
|
|
5
11
|
poster?: string | null;
|
|
@@ -51,6 +57,41 @@ type $$ComponentProps = {
|
|
|
51
57
|
transcriptMaxHeight?: string;
|
|
52
58
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
53
59
|
syncUrlTime?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The light track — the host's pre-scanned brightness sidecar (integers
|
|
62
|
+
* 0–99 at `rate` samples/s; extension-library's `.luma` verbatim).
|
|
63
|
+
* Present ⇒ the band paints the light ribbon under its envelope, so
|
|
64
|
+
* bright passages glow on the timeline before you reach them. Night
|
|
65
|
+
* dimming (mos-5lzns.33) reads the same prop. Garbage or < 4 samples ⇒
|
|
66
|
+
* no ribbon, never a wrong one.
|
|
67
|
+
*/
|
|
68
|
+
luma?: VideoLumaTrack | null;
|
|
69
|
+
/**
|
|
70
|
+
* Social markers on the band: `kind: 'comment'` draws a dot under the
|
|
71
|
+
* chapter flags (hover = the comment, click = seek to its exact second);
|
|
72
|
+
* `kind: 'like'` folds into a heat strip along the bottom edge.
|
|
73
|
+
*/
|
|
74
|
+
markers?: VideoMarker[];
|
|
75
|
+
/**
|
|
76
|
+
* One time axis (default on): while the band has an envelope it OWNS the
|
|
77
|
+
* axis and the native timeline stands down (Chromium; elsewhere it can't
|
|
78
|
+
* be hidden and simply stays); zoomed, the band grows a whole-video
|
|
79
|
+
* overview rail with the window bracketed. Off ⇒ both, permanently.
|
|
80
|
+
*/
|
|
81
|
+
oneAxis?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The four-state arbitration, reported on real change only and deferred
|
|
84
|
+
* a microtask (the host's ref to this component may not be set yet when
|
|
85
|
+
* the first report fires). A host that draws its OWN seek bar reads
|
|
86
|
+
* `axis` to decide whether to show it — see video/band/axis.ts.
|
|
87
|
+
*/
|
|
88
|
+
onBandChange?: (state: BandState) => void;
|
|
89
|
+
/**
|
|
90
|
+
* A comment marker was clicked (after the seek to it). Dots within a few
|
|
91
|
+
* px merge into one; `cluster` carries every comment under that dot,
|
|
92
|
+
* earliest first, and `marker` is `cluster[0]` — the one seeked to.
|
|
93
|
+
*/
|
|
94
|
+
onMarkerClick?: (marker: VideoMarker, cluster: VideoMarker[]) => void;
|
|
54
95
|
onTimeUpdate?: (video: HTMLVideoElement) => void;
|
|
55
96
|
/** Present ⇒ the band grows an "add bookmark at current time" control. */
|
|
56
97
|
onAddBookmark?: (playbackSec: number, label: string) => void;
|
|
@@ -58,6 +99,8 @@ type $$ComponentProps = {
|
|
|
58
99
|
onBookmarkContext?: (sec: number, existing: VideoBookmark | null, clientX: number, clientY: number) => void;
|
|
59
100
|
class?: string;
|
|
60
101
|
};
|
|
61
|
-
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
|
102
|
+
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
|
103
|
+
bandState: () => BandState;
|
|
104
|
+
}, "">;
|
|
62
105
|
type VideoPlayer = ReturnType<typeof VideoPlayer>;
|
|
63
106
|
export default VideoPlayer;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type { ChapterBlock } from './components/replay/ChapterTimeline.svelte';
|
|
|
12
12
|
export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
|
|
13
13
|
export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
|
|
14
14
|
export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
15
|
-
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, } from './components/VideoPlayer.svelte';
|
|
15
|
+
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, } from './components/VideoPlayer.svelte';
|
|
16
16
|
export { default as Flythrough } from './components/Flythrough.svelte';
|
|
17
17
|
export { default as LessonEmbed } from './components/LessonEmbed.svelte';
|
|
18
18
|
export type { Collection, CollectionCut, CollectionBeat, } from './components/LessonEmbed.svelte';
|
package/dist/video/README.md
CHANGED
|
@@ -15,6 +15,7 @@ props + callback surfaces.
|
|
|
15
15
|
| `time.ts` | .28 (done) | Pure formatting: `formatTime`, `formatDuration`, `truncate`, `aspectRatioCss`. |
|
|
16
16
|
| `model.ts` | .28 (done) | Speaker registry + identity colours + talk-time derivation + the sort normalisations. `ALL_KEY`/`UNKNOWN_KEY` live here. |
|
|
17
17
|
| `band.ts` | .28 (done) — extended by **.30** | The timeline band: zoom window (`BandWindow`, `zoomAt`, `followPlayhead`), hit tests (`segmentAt`, `chapterAt`, `biasToSegmentStart`, `nearestBookmark`), and `paintBand` (canvas). .30 adds one-axis arbitration + the light ribbon; its state lives HERE. |
|
|
18
|
+
| `band/` | **.30** (done) | `window.ts` — the zoom-window math (moved out of band.ts, re-exported there; shared by every band layer without an import cycle). `axis.ts` — one-axis four-state arbitration (`resolveAxis`, `BandState`, the churn-guarded microtask `createBandReporter`), `playheadX` (the one "where you are" mapping — ≤0.2px agreement) and `overviewBracket`. `luma.ts` — the light track (`VideoLumaTrack`, the sidecar verbatim) → ribbon (`v² × 0.55`, max-per-interval). `markers.ts` — `VideoMarker` comments (clustered dots) + like heat. |
|
|
18
19
|
| `keys.ts` | .28 (done) | The keyboard map — `PLAYER_KEYS` (the `?` overlay renders it), `chapterJump`, `FRAME_STEP_SEC`. Every key that exists is in the map; a key not in the map doesn't exist. |
|
|
19
20
|
| `rate.ts` | .28 (done) — filled by **.32** | The rate composer contract (`composeRate`, deadband, clamp), the `SPEED_STEPS` staircase, `formatSpeedLabel`. .32 adds the speaker-pace and silence-map advisors — they never write `playbackRate`; they advise. |
|
|
20
21
|
| `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
|