@joewinke/jatui 0.38.2 → 0.38.3
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/VideoPlayer.svelte +134 -26
- package/dist/components/VideoPlayer.svelte.d.ts +5 -0
- package/dist/video/band.d.ts +12 -0
- package/dist/video/band.js +29 -0
- package/dist/video/transcriptFollow.d.ts +32 -0
- package/dist/video/transcriptFollow.js +80 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -119,7 +119,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
|
|
|
119
119
|
| `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
|
|
120
120
|
| `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
|
|
121
121
|
| `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
|
|
122
|
-
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
122
|
+
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane, a reader scroll pauses it behind a "Jump to current" pill; a hovered chapter lights its whole span on the band), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
123
123
|
| `MediaCapture` | One-tap audio/selfie-video/screen(+mic) recording — a host-agnostic PRIMITIVE. Owns permission requests, MIME negotiation, the record/stop state machine, live self-preview, and mixes screen+mic audio into one track via AudioContext. Never uploads anything — hands the finished clip to `onCapture` as a Blob and the host decides what to do with it. `onCapture` may be async; the component shows "processing" until it resolves. | `onCapture: (result: MediaCaptureResult) => void \| Promise<void>`, `modes?` (default all three), `maxDurationMs?` (default 120000), `label?`, `hint?`, `compact?`, `disabled?` |
|
|
124
124
|
| `MultimodalCaptureField` | "Upload a file, or record yourself going over it" as one input surface — composes `FileDropzone` (capped to one file) with `MediaCapture`. File and recording are independent (either, both, or neither); neither half uploads anything, same "validate and hand back" contract as `FileDropzone`. | `onFileSelected?`, `onFileRemoved?`, `onCapture?`, `onRecordingRemoved?`, `accept?`, `maxFileSize?`, `modes?`, `maxDurationMs?`, `file?`/`fileUrl?`/`fileName?`, `recordingSummary?` |
|
|
125
125
|
| `ListenControl` | "Listen to this page" — a compact pill that expands into a TWO-ROW playback bar over the host's PER-SENTENCE audio (TTS): row 1 = play/pause · chapter chip + current sentence (full remaining width) · ONE settings pill ("1.25× · Sarah" → speed 0.75–2× + voice menu) · close; row 2 = prev/next sentence and −15s/+15s · a document-wide progress bar with chapter ticks (a hatched tail marks the not-yet-synthesized part), time, close. The host supplies an async `source` (sentences/paragraphs with ids → audio URLs, streamed page by page so playback starts on the first chunk while the rest synthesizes; pages merge by id so late URLs fill in) and gets `onSegmentChange(id)` at every boundary for highlight + scroll-follow. Media Session (lock-screen/headset controls, one document-wide position), keyboard scoped to the player (Space/K, ←/→ sentences, Shift+←/→ ±15s, [ / ] speed, Escape), speed+voice persisted in localStorage, `bind:api` for hosts wiring their own global keys, reduced-motion aware, token-only theming, never overflows its host (border-box, `max-width: 100%`, `min-width: 0` chain), fixed sticky mini-bar (same two rows) on narrow screens. Internals in `src/lib/listen/` (one timeline across per-sentence files — `buildTimeline` offsets; see `video/` for the doctrine). Demo synthesizes real WAVs locally. | `source`, `voices?`, `voiceId?` (bindable), `chapters?`, `title?`, `triggerLabel?`, `open?` (bindable), `preload?`, `estimateSec?`, `storageKey?`, `onSegmentChange?`, `onOpenChange?`, `api?` (bindable), `class?`, `children?` (custom trigger) |
|
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
import { listNav } from '../actions/listNav';
|
|
87
87
|
// mos-777hj — the chapter rail's pure state (ends, played checks, progress).
|
|
88
88
|
import { railChapters, activeRailIndex, type RailChapter } from '../video/chapterRail';
|
|
89
|
+
import { scrollRowIntoPane, watchReaderScroll } from '../video/transcriptFollow';
|
|
90
|
+
import JumpToLatest from './basic/JumpToLatest.svelte';
|
|
89
91
|
import type {
|
|
90
92
|
VideoBookmark,
|
|
91
93
|
VideoChapter,
|
|
@@ -272,7 +274,7 @@
|
|
|
272
274
|
talkTime = [],
|
|
273
275
|
startAt = null,
|
|
274
276
|
transcriptPosition = 'below',
|
|
275
|
-
transcriptMaxHeight
|
|
277
|
+
transcriptMaxHeight,
|
|
276
278
|
/**
|
|
277
279
|
* CORS mode for the <video> element — passed straight through as the
|
|
278
280
|
* `crossorigin` attribute. `'anonymous'` asks the server for a
|
|
@@ -431,6 +433,11 @@
|
|
|
431
433
|
/** Seek here once metadata lands (a queued seek never fires on a later source). */
|
|
432
434
|
startAt?: number | null;
|
|
433
435
|
transcriptPosition?: 'below' | 'side';
|
|
436
|
+
/**
|
|
437
|
+
* CSS length bounding the transcript pane's height — it scrolls inside
|
|
438
|
+
* this, never the page. Default: `min(40vh, 28rem)` below the player;
|
|
439
|
+
* beside it (`transcriptPosition: 'side'`), the player column's height.
|
|
440
|
+
*/
|
|
434
441
|
transcriptMaxHeight?: string;
|
|
435
442
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
436
443
|
syncUrlTime?: boolean;
|
|
@@ -799,6 +806,10 @@
|
|
|
799
806
|
let viewStart = $state(0);
|
|
800
807
|
let viewEnd = $state(0); // 0 until a duration is known
|
|
801
808
|
let hoverChapter = $state(-1);
|
|
809
|
+
/** A chapter-rail row under the pointer or keyboard focus (-1 = none). */
|
|
810
|
+
let railHoverChapter = $state(-1);
|
|
811
|
+
/** The band canvas holds KEYBOARD focus (:focus-visible) — light the chapter under the playhead. */
|
|
812
|
+
let bandKeyFocus = $state(false);
|
|
802
813
|
let hover: {
|
|
803
814
|
x: number;
|
|
804
815
|
title: string;
|
|
@@ -848,6 +859,11 @@
|
|
|
848
859
|
});
|
|
849
860
|
|
|
850
861
|
let transcriptEl: HTMLDivElement | null = null;
|
|
862
|
+
/** The reader scrolled the pane themselves — auto-follow waits for "Jump to current". */
|
|
863
|
+
let followPaused = $state(false);
|
|
864
|
+
/** Height of the main column (stage + band), for a side transcript's bound. */
|
|
865
|
+
let mainEl: HTMLDivElement | undefined = $state();
|
|
866
|
+
let mainHeight = $state(0);
|
|
851
867
|
let vpScrollTop = $state(0);
|
|
852
868
|
let vpHeight = $state(0);
|
|
853
869
|
let heightsVersion = $state(0);
|
|
@@ -1041,9 +1057,13 @@
|
|
|
1041
1057
|
scheduleRowMeasure();
|
|
1042
1058
|
});
|
|
1043
1059
|
ro.observe(el);
|
|
1060
|
+
const unwatch = watchReaderScroll(el, () => {
|
|
1061
|
+
if (activeSegment) followPaused = true;
|
|
1062
|
+
});
|
|
1044
1063
|
return {
|
|
1045
1064
|
destroy() {
|
|
1046
1065
|
ro.disconnect();
|
|
1066
|
+
unwatch();
|
|
1047
1067
|
transcriptEl = null;
|
|
1048
1068
|
}
|
|
1049
1069
|
};
|
|
@@ -1092,10 +1112,9 @@
|
|
|
1092
1112
|
if (!railOn || railActive < 0) return;
|
|
1093
1113
|
const row = chapterRailEl?.querySelector<HTMLElement>('.vp-chapter-row.active');
|
|
1094
1114
|
if (!row) return;
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
});
|
|
1115
|
+
// The rail's own overflow only — scrollIntoView would also scroll the
|
|
1116
|
+
// page whenever the rail sits partly off-screen (jatui-ap4oz).
|
|
1117
|
+
if (chapterRailEl) scrollRowIntoPane(chapterRailEl, row, !reduceMotion);
|
|
1099
1118
|
});
|
|
1100
1119
|
const sortedTurns = $derived(sortTurns(speakers));
|
|
1101
1120
|
const sortedSegments = $derived(sortSegments(transcript));
|
|
@@ -1168,6 +1187,21 @@
|
|
|
1168
1187
|
sortedSegments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
|
|
1169
1188
|
);
|
|
1170
1189
|
|
|
1190
|
+
/**
|
|
1191
|
+
* The chapter whose span the band lights (jatui-ap4oz): the one under the
|
|
1192
|
+
* pointer, else a hovered/focused rail row, else — while the band has
|
|
1193
|
+
* keyboard focus — the chapter under the playhead.
|
|
1194
|
+
*/
|
|
1195
|
+
const litChapter = $derived(
|
|
1196
|
+
hoverChapter >= 0
|
|
1197
|
+
? hoverChapter
|
|
1198
|
+
: railHoverChapter >= 0
|
|
1199
|
+
? railHoverChapter
|
|
1200
|
+
: bandKeyFocus
|
|
1201
|
+
? chapterAt(currentTime, sortedChapters)
|
|
1202
|
+
: -1
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1171
1205
|
/** The zoom window, as the band module sees it (plain data — band.ts is DOM-free). */
|
|
1172
1206
|
const bandWindow = $derived<BandWindow>({ start: viewStart, end: viewEnd });
|
|
1173
1207
|
|
|
@@ -1187,7 +1221,7 @@
|
|
|
1187
1221
|
soloKey,
|
|
1188
1222
|
mutedKeys,
|
|
1189
1223
|
window: bandWindow,
|
|
1190
|
-
hoverChapter,
|
|
1224
|
+
hoverChapter: litChapter,
|
|
1191
1225
|
luma: lumaSeries,
|
|
1192
1226
|
comments: socialMarkers.comments,
|
|
1193
1227
|
likes: socialMarkers.likes,
|
|
@@ -2884,28 +2918,64 @@
|
|
|
2884
2918
|
});
|
|
2885
2919
|
|
|
2886
2920
|
// Auto-scroll the active transcript row into view as playback advances.
|
|
2921
|
+
// Only the PANE scrolls (jatui-ap4oz): scrollIntoView also scrolled the
|
|
2922
|
+
// window, pushing the video out of frame on load and on every new line.
|
|
2887
2923
|
// Windowed panes may not have the row rendered: jump to its (estimated)
|
|
2888
2924
|
// offset first, then a follow-up effect lands exactly on the real row once
|
|
2889
|
-
// the window renders it.
|
|
2925
|
+
// the window renders it. A reader who scrolled the pane pauses this until
|
|
2926
|
+
// they press "Jump to current" (or click a line).
|
|
2927
|
+
function followSegment(seg: VideoTranscriptSegment) {
|
|
2928
|
+
const pane = transcriptEl;
|
|
2929
|
+
if (!pane) return;
|
|
2930
|
+
const node = rowRefs[seg.id];
|
|
2931
|
+
if (node) {
|
|
2932
|
+
scrollRowIntoPane(pane, node, !reduceMotion);
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (!transVirtual) return;
|
|
2936
|
+
const idx = segIndexById.get(seg.id);
|
|
2937
|
+
if (idx == null) return;
|
|
2938
|
+
const top = ensureOffsets()[idx] ?? 0;
|
|
2939
|
+
// Row top (in content coords) = pane padding + offset; land it ⅓ down.
|
|
2940
|
+
pane.scrollTop = Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3);
|
|
2941
|
+
pendingAutoScrollId = seg.id;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2890
2944
|
$effect(() => {
|
|
2891
2945
|
const seg = activeSegment;
|
|
2892
2946
|
if (!seg) return;
|
|
2893
2947
|
untrack(() => {
|
|
2894
|
-
|
|
2895
|
-
if (node) {
|
|
2896
|
-
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2897
|
-
return;
|
|
2898
|
-
}
|
|
2899
|
-
if (!transVirtual || !transcriptEl) return;
|
|
2900
|
-
const idx = segIndexById.get(seg.id);
|
|
2901
|
-
if (idx == null) return;
|
|
2902
|
-
const top = ensureOffsets()[idx] ?? 0;
|
|
2903
|
-
// Row top (in content coords) = pane padding + offset; land it ⅓ down.
|
|
2904
|
-
transcriptEl.scrollTop = Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3);
|
|
2905
|
-
pendingAutoScrollId = seg.id;
|
|
2948
|
+
if (!followPaused) followSegment(seg);
|
|
2906
2949
|
});
|
|
2907
2950
|
});
|
|
2908
2951
|
|
|
2952
|
+
function jumpToCurrent() {
|
|
2953
|
+
followPaused = false;
|
|
2954
|
+
if (activeSegment) followSegment(activeSegment);
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
/** Clicking a line is choosing where to be: seek there and follow again. */
|
|
2958
|
+
function onRowClick(seg: VideoTranscriptSegment) {
|
|
2959
|
+
followPaused = false;
|
|
2960
|
+
seekTo(seg.startSec);
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// No active line (seeked past the transcript) ⇒ nothing to jump to.
|
|
2964
|
+
$effect(() => {
|
|
2965
|
+
if (!activeSegment && followPaused) untrack(() => (followPaused = false));
|
|
2966
|
+
});
|
|
2967
|
+
|
|
2968
|
+
// A side transcript is bounded by the player column's height, so the
|
|
2969
|
+
// pane shrinks to fit beside the video instead of growing past it.
|
|
2970
|
+
$effect(() => {
|
|
2971
|
+
const el = mainEl;
|
|
2972
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
2973
|
+
const ro = new ResizeObserver(() => (mainHeight = el.offsetHeight));
|
|
2974
|
+
ro.observe(el);
|
|
2975
|
+
mainHeight = el.offsetHeight;
|
|
2976
|
+
return () => ro.disconnect();
|
|
2977
|
+
});
|
|
2978
|
+
|
|
2909
2979
|
// Exact landing after a windowed jump: the row is now rendered — snap to it.
|
|
2910
2980
|
$effect(() => {
|
|
2911
2981
|
void renderedSegments;
|
|
@@ -2915,7 +2985,7 @@
|
|
|
2915
2985
|
const node = rowRefs[id];
|
|
2916
2986
|
if (!node) return;
|
|
2917
2987
|
pendingAutoScrollId = null;
|
|
2918
|
-
|
|
2988
|
+
if (transcriptEl && !followPaused) scrollRowIntoPane(transcriptEl, node, !reduceMotion);
|
|
2919
2989
|
});
|
|
2920
2990
|
});
|
|
2921
2991
|
|
|
@@ -2961,7 +3031,7 @@
|
|
|
2961
3031
|
onclickcapture={maybeAskIdle}
|
|
2962
3032
|
onkeydowncapture={maybeAskIdle}
|
|
2963
3033
|
>
|
|
2964
|
-
<div class="vp-main">
|
|
3034
|
+
<div class="vp-main" bind:this={mainEl}>
|
|
2965
3035
|
<div
|
|
2966
3036
|
class="vp-stage-wrap"
|
|
2967
3037
|
class:vp-stage-audio={isAudio}
|
|
@@ -3118,6 +3188,8 @@
|
|
|
3118
3188
|
ondblclick={onBandDblClick}
|
|
3119
3189
|
oncontextmenu={onBandContextMenu}
|
|
3120
3190
|
onkeydown={onBandKeyDown}
|
|
3191
|
+
onfocus={(e) => (bandKeyFocus = (e.currentTarget as HTMLElement).matches(':focus-visible'))}
|
|
3192
|
+
onblur={() => (bandKeyFocus = false)}
|
|
3121
3193
|
></canvas>
|
|
3122
3194
|
<!-- Polite announcements for comment jumps/clicks — the dots are
|
|
3123
3195
|
canvas pixels, so without this a comment is invisible to AT. -->
|
|
@@ -3441,6 +3513,10 @@
|
|
|
3441
3513
|
data-chapter-index={i}
|
|
3442
3514
|
aria-current={railActive === i ? 'true' : undefined}
|
|
3443
3515
|
onclick={() => chapterGo(i)}
|
|
3516
|
+
onmouseenter={() => (railHoverChapter = i)}
|
|
3517
|
+
onmouseleave={() => (railHoverChapter = -1)}
|
|
3518
|
+
onfocus={() => (railHoverChapter = i)}
|
|
3519
|
+
onblur={() => (railHoverChapter = -1)}
|
|
3444
3520
|
>
|
|
3445
3521
|
<span class="vp-chapter-head">
|
|
3446
3522
|
{#if ch.status}
|
|
@@ -3485,9 +3561,17 @@
|
|
|
3485
3561
|
{/if}
|
|
3486
3562
|
|
|
3487
3563
|
{#if sortedSegments.length > 0 && transcriptVisible}
|
|
3564
|
+
<!-- Height-bounded (jatui-ap4oz): the pane scrolls itself and never
|
|
3565
|
+
the page, so the video stays in frame. Below the player the bound
|
|
3566
|
+
is `transcriptMaxHeight` (default min(40vh, 28rem)); beside it, the
|
|
3567
|
+
player column's height. -->
|
|
3568
|
+
<div
|
|
3569
|
+
class="vp-transcript-wrap"
|
|
3570
|
+
style:--vp-transcript-max={transcriptMaxHeight}
|
|
3571
|
+
style:--vp-transcript-side-max={!transcriptMaxHeight && mainHeight > 0 ? `${mainHeight}px` : undefined}
|
|
3572
|
+
>
|
|
3488
3573
|
<div
|
|
3489
3574
|
class="vp-transcript"
|
|
3490
|
-
style="max-height: {transcriptMaxHeight}"
|
|
3491
3575
|
use:transcriptPaneAction
|
|
3492
3576
|
onscroll={onTranscriptScroll}
|
|
3493
3577
|
>
|
|
@@ -3506,7 +3590,7 @@
|
|
|
3506
3590
|
? highlightKey !== speakerKeyOf(seg)
|
|
3507
3591
|
: filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
|
|
3508
3592
|
use:transcriptRowAction={seg}
|
|
3509
|
-
onclick={() =>
|
|
3593
|
+
onclick={() => onRowClick(seg)}
|
|
3510
3594
|
>
|
|
3511
3595
|
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3512
3596
|
{#if multiSpeaker}
|
|
@@ -3529,7 +3613,7 @@
|
|
|
3529
3613
|
? highlightKey !== speakerKeyOf(seg)
|
|
3530
3614
|
: filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
|
|
3531
3615
|
use:transcriptRowAction={seg}
|
|
3532
|
-
onclick={() =>
|
|
3616
|
+
onclick={() => onRowClick(seg)}
|
|
3533
3617
|
>
|
|
3534
3618
|
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3535
3619
|
{#if multiSpeaker}
|
|
@@ -3542,6 +3626,12 @@
|
|
|
3542
3626
|
{/each}
|
|
3543
3627
|
{/if}
|
|
3544
3628
|
</div>
|
|
3629
|
+
<JumpToLatest
|
|
3630
|
+
visible={followPaused && activeSegment != null}
|
|
3631
|
+
label="Jump to current"
|
|
3632
|
+
onclick={jumpToCurrent}
|
|
3633
|
+
/>
|
|
3634
|
+
</div>
|
|
3545
3635
|
{/if}
|
|
3546
3636
|
|
|
3547
3637
|
{#if keysOpen}
|
|
@@ -3614,6 +3704,11 @@
|
|
|
3614
3704
|
|
|
3615
3705
|
/* Side layout: transcript beside the video once there's room for both. */
|
|
3616
3706
|
@media (min-width: 64rem) {
|
|
3707
|
+
/* Beside the player: no taller than the player column. */
|
|
3708
|
+
.vp-root.vp-side .vp-transcript {
|
|
3709
|
+
max-height: var(--vp-transcript-max, var(--vp-transcript-side-max, min(40vh, 28rem)));
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3617
3712
|
.vp-root.vp-side {
|
|
3618
3713
|
display: grid;
|
|
3619
3714
|
grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr);
|
|
@@ -3847,7 +3942,7 @@
|
|
|
3847
3942
|
grid-area: below;
|
|
3848
3943
|
}
|
|
3849
3944
|
|
|
3850
|
-
.vp-root.vp-rail-side > .vp-transcript {
|
|
3945
|
+
.vp-root.vp-rail-side > .vp-transcript-wrap {
|
|
3851
3946
|
grid-area: transcript;
|
|
3852
3947
|
}
|
|
3853
3948
|
|
|
@@ -4409,8 +4504,21 @@
|
|
|
4409
4504
|
background: color-mix(in oklch, var(--color-warning) 28%, transparent);
|
|
4410
4505
|
}
|
|
4411
4506
|
|
|
4412
|
-
/* Transcript pane
|
|
4507
|
+
/* Transcript pane. The wrap positions the "Jump to current" pill over
|
|
4508
|
+
the pane and carries the height bound (jatui-ap4oz). */
|
|
4509
|
+
.vp-transcript-wrap {
|
|
4510
|
+
position: relative;
|
|
4511
|
+
display: flex;
|
|
4512
|
+
flex-direction: column;
|
|
4513
|
+
min-width: 0;
|
|
4514
|
+
min-height: 0;
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4413
4517
|
.vp-transcript {
|
|
4518
|
+
/* The bound is the pane's visible box, padding and border included. */
|
|
4519
|
+
box-sizing: border-box;
|
|
4520
|
+
max-height: var(--vp-transcript-max, min(40vh, 28rem));
|
|
4521
|
+
overscroll-behavior: contain;
|
|
4414
4522
|
display: flex;
|
|
4415
4523
|
flex-direction: column;
|
|
4416
4524
|
gap: 1px;
|
|
@@ -163,6 +163,11 @@ type $$ComponentProps = {
|
|
|
163
163
|
/** Seek here once metadata lands (a queued seek never fires on a later source). */
|
|
164
164
|
startAt?: number | null;
|
|
165
165
|
transcriptPosition?: 'below' | 'side';
|
|
166
|
+
/**
|
|
167
|
+
* CSS length bounding the transcript pane's height — it scrolls inside
|
|
168
|
+
* this, never the page. Default: `min(40vh, 28rem)` below the player;
|
|
169
|
+
* beside it (`transcriptPosition: 'side'`), the player column's height.
|
|
170
|
+
*/
|
|
166
171
|
transcriptMaxHeight?: string;
|
|
167
172
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
168
173
|
syncUrlTime?: boolean;
|
package/dist/video/band.d.ts
CHANGED
|
@@ -67,6 +67,7 @@ export interface BandPaintState {
|
|
|
67
67
|
soloKey: string;
|
|
68
68
|
mutedKeys: Set<string>;
|
|
69
69
|
window: BandWindow;
|
|
70
|
+
/** Chapter whose whole span is lit (hover / keyboard focus); -1 = none. */
|
|
70
71
|
hoverChapter: number;
|
|
71
72
|
/** Normalised light track (band/luma.ts); null ⇒ no ribbon. */
|
|
72
73
|
luma?: LumaSeries | null;
|
|
@@ -111,6 +112,17 @@ export declare function prepareCanvas(canvas: HTMLCanvasElement): CanvasRenderin
|
|
|
111
112
|
* the playhead. `W`/`H` are CSS pixels of the canvas box.
|
|
112
113
|
*/
|
|
113
114
|
export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: number, s: BandPaintState): void;
|
|
115
|
+
/** Height of the accent rule along the top of a lit chapter span, px. */
|
|
116
|
+
export declare const LIT_RULE_H = 2;
|
|
117
|
+
/**
|
|
118
|
+
* Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
|
|
119
|
+
* start (or the end of the video) — through the zoom window, clipped to the
|
|
120
|
+
* canvas. Null when there is no such chapter or it is entirely off-screen.
|
|
121
|
+
*/
|
|
122
|
+
export declare function chapterSpanPx(chapters: VideoChapter[], index: number, w: BandWindow, duration: number, width: number): {
|
|
123
|
+
x0: number;
|
|
124
|
+
x1: number;
|
|
125
|
+
} | null;
|
|
114
126
|
/** Turn → BandTurn for the paint state (resolved key, ad flag). */
|
|
115
127
|
export declare function toBandTurns(turns: VideoSpeakerTurn[]): BandTurn[];
|
|
116
128
|
export { ALL_KEY };
|
package/dist/video/band.js
CHANGED
|
@@ -161,6 +161,19 @@ export function paintBand(ctx, W, H, s) {
|
|
|
161
161
|
}
|
|
162
162
|
ctx.globalAlpha = 1;
|
|
163
163
|
}
|
|
164
|
+
// The lit chapter (jatui-ap4oz): a wash over its whole span plus an
|
|
165
|
+
// accent rule along the top edge, under every other layer so the
|
|
166
|
+
// envelope, flags and playhead stay legible on top. Static — nothing
|
|
167
|
+
// animates, so it is reduced-motion safe by construction.
|
|
168
|
+
const lit = chapterSpanPx(s.chapters, s.hoverChapter, s.window, dur, W);
|
|
169
|
+
if (lit) {
|
|
170
|
+
ctx.fillStyle = accent;
|
|
171
|
+
ctx.globalAlpha = 0.14;
|
|
172
|
+
ctx.fillRect(lit.x0, 0, lit.x1 - lit.x0, H);
|
|
173
|
+
ctx.globalAlpha = 0.9;
|
|
174
|
+
ctx.fillRect(lit.x0, 0, lit.x1 - lit.x0, LIT_RULE_H);
|
|
175
|
+
ctx.globalAlpha = 1;
|
|
176
|
+
}
|
|
164
177
|
const p = s.peaks;
|
|
165
178
|
const withWave = !!p;
|
|
166
179
|
const n = p?.length ?? 0;
|
|
@@ -310,6 +323,22 @@ export function paintBand(ctx, W, H, s) {
|
|
|
310
323
|
ctx.fillRect(playheadX(s.currentTime, w, dur, W) - PLAYHEAD_W / 2, 0, PLAYHEAD_W, H);
|
|
311
324
|
ctx.globalAlpha = 1;
|
|
312
325
|
}
|
|
326
|
+
/** Height of the accent rule along the top of a lit chapter span, px. */
|
|
327
|
+
export const LIT_RULE_H = 2;
|
|
328
|
+
/**
|
|
329
|
+
* Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
|
|
330
|
+
* start (or the end of the video) — through the zoom window, clipped to the
|
|
331
|
+
* canvas. Null when there is no such chapter or it is entirely off-screen.
|
|
332
|
+
*/
|
|
333
|
+
export function chapterSpanPx(chapters, index, w, duration, width) {
|
|
334
|
+
const ch = chapters[index];
|
|
335
|
+
if (!ch || duration <= 0 || width <= 0)
|
|
336
|
+
return null;
|
|
337
|
+
const end = chapters[index + 1]?.startSec ?? duration;
|
|
338
|
+
const x0 = Math.max(0, secToFrac(w, duration, ch.startSec) * width);
|
|
339
|
+
const x1 = Math.min(width, secToFrac(w, duration, end) * width);
|
|
340
|
+
return x1 > x0 ? { x0, x1 } : null;
|
|
341
|
+
}
|
|
313
342
|
/** Turn → BandTurn for the paint state (resolved key, ad flag). */
|
|
314
343
|
export function toBandTurns(turns) {
|
|
315
344
|
return turns.map((t) => ({
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript follow (jatui-ap4oz) — keep the active line in view by scrolling
|
|
3
|
+
* the transcript pane ONLY, and stop following the moment the reader scrolls
|
|
4
|
+
* the pane themselves.
|
|
5
|
+
*
|
|
6
|
+
* Why not `scrollIntoView`: it scrolls every scrollable ancestor up to the
|
|
7
|
+
* window, so a pane below the video dragged the whole page and pushed the
|
|
8
|
+
* video out of frame on load and on every new line. Setting the pane's own
|
|
9
|
+
* `scrollTop` cannot move anything else.
|
|
10
|
+
*
|
|
11
|
+
* Reader intent follows createStickToBottom's rules (actions/stickToBottom):
|
|
12
|
+
* wheel, touch drag, scroll keys and a scrollbar grab inside the pane are
|
|
13
|
+
* "the reader is reading", and follow pauses until they ask for it back
|
|
14
|
+
* (the "Jump to current" pill, or clicking a line). Plain `scroll` events are
|
|
15
|
+
* never intent — the follow's own scrolls and the windowing's height
|
|
16
|
+
* corrections fire them too.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
|
|
20
|
+
* coordinates) is fully visible, with `margin` px of breathing room — or null
|
|
21
|
+
* when it already is. "Nearest" semantics: move the least distance.
|
|
22
|
+
*/
|
|
23
|
+
export declare function nearestScrollTop(rowTop: number, rowBottom: number, scrollTop: number, viewHeight: number, margin?: number): number | null;
|
|
24
|
+
/**
|
|
25
|
+
* Scroll `pane` (and nothing else) so `row` is visible. Returns true when it
|
|
26
|
+
* moved. `smooth` is the caller's call — pass false under reduced motion.
|
|
27
|
+
*/
|
|
28
|
+
export declare function scrollRowIntoPane(pane: HTMLElement, row: HTMLElement, smooth: boolean, margin?: number): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Listen for reader scroll intent on `pane`; `onIntent` fires on each one.
|
|
31
|
+
*/
|
|
32
|
+
export declare function watchReaderScroll(pane: HTMLElement, onIntent: () => void): () => void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript follow (jatui-ap4oz) — keep the active line in view by scrolling
|
|
3
|
+
* the transcript pane ONLY, and stop following the moment the reader scrolls
|
|
4
|
+
* the pane themselves.
|
|
5
|
+
*
|
|
6
|
+
* Why not `scrollIntoView`: it scrolls every scrollable ancestor up to the
|
|
7
|
+
* window, so a pane below the video dragged the whole page and pushed the
|
|
8
|
+
* video out of frame on load and on every new line. Setting the pane's own
|
|
9
|
+
* `scrollTop` cannot move anything else.
|
|
10
|
+
*
|
|
11
|
+
* Reader intent follows createStickToBottom's rules (actions/stickToBottom):
|
|
12
|
+
* wheel, touch drag, scroll keys and a scrollbar grab inside the pane are
|
|
13
|
+
* "the reader is reading", and follow pauses until they ask for it back
|
|
14
|
+
* (the "Jump to current" pill, or clicking a line). Plain `scroll` events are
|
|
15
|
+
* never intent — the follow's own scrolls and the windowing's height
|
|
16
|
+
* corrections fire them too.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
|
|
20
|
+
* coordinates) is fully visible, with `margin` px of breathing room — or null
|
|
21
|
+
* when it already is. "Nearest" semantics: move the least distance.
|
|
22
|
+
*/
|
|
23
|
+
export function nearestScrollTop(rowTop, rowBottom, scrollTop, viewHeight, margin = 0) {
|
|
24
|
+
if (viewHeight <= 0)
|
|
25
|
+
return null;
|
|
26
|
+
const top = rowTop - margin;
|
|
27
|
+
const bottom = rowBottom + margin;
|
|
28
|
+
if (top >= scrollTop && bottom <= scrollTop + viewHeight)
|
|
29
|
+
return null;
|
|
30
|
+
// Taller than the pane, or above the view ⇒ align its top.
|
|
31
|
+
if (top < scrollTop || bottom - top > viewHeight)
|
|
32
|
+
return Math.max(0, top);
|
|
33
|
+
return Math.max(0, bottom - viewHeight);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Scroll `pane` (and nothing else) so `row` is visible. Returns true when it
|
|
37
|
+
* moved. `smooth` is the caller's call — pass false under reduced motion.
|
|
38
|
+
*/
|
|
39
|
+
export function scrollRowIntoPane(pane, row, smooth, margin = 8) {
|
|
40
|
+
const p = pane.getBoundingClientRect();
|
|
41
|
+
const r = row.getBoundingClientRect();
|
|
42
|
+
const offset = pane.scrollTop - p.top - pane.clientTop;
|
|
43
|
+
const next = nearestScrollTop(r.top + offset, r.bottom + offset, pane.scrollTop, pane.clientHeight, margin);
|
|
44
|
+
if (next === null || Math.abs(next - pane.scrollTop) < 1)
|
|
45
|
+
return false;
|
|
46
|
+
if (smooth && typeof pane.scrollTo === 'function')
|
|
47
|
+
pane.scrollTo({ top: next, behavior: 'smooth' });
|
|
48
|
+
else
|
|
49
|
+
pane.scrollTop = next;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
// Arrows, Space, Home and End belong to the player's keymap (seek, volume,
|
|
53
|
+
// play) — only the page keys are left to scroll the pane natively.
|
|
54
|
+
const SCROLL_KEYS = new Set(['PageUp', 'PageDown']);
|
|
55
|
+
/**
|
|
56
|
+
* Listen for reader scroll intent on `pane`; `onIntent` fires on each one.
|
|
57
|
+
*/
|
|
58
|
+
export function watchReaderScroll(pane, onIntent) {
|
|
59
|
+
const wheel = () => onIntent();
|
|
60
|
+
const touch = () => onIntent();
|
|
61
|
+
const key = (e) => {
|
|
62
|
+
if (SCROLL_KEYS.has(e.key))
|
|
63
|
+
onIntent();
|
|
64
|
+
};
|
|
65
|
+
// A press on the pane's own box beyond its client width is the scrollbar.
|
|
66
|
+
const down = (e) => {
|
|
67
|
+
if (e.target === pane && e.offsetX > pane.clientWidth)
|
|
68
|
+
onIntent();
|
|
69
|
+
};
|
|
70
|
+
pane.addEventListener('wheel', wheel, { passive: true });
|
|
71
|
+
pane.addEventListener('touchmove', touch, { passive: true });
|
|
72
|
+
pane.addEventListener('keydown', key);
|
|
73
|
+
pane.addEventListener('pointerdown', down);
|
|
74
|
+
return () => {
|
|
75
|
+
pane.removeEventListener('wheel', wheel);
|
|
76
|
+
pane.removeEventListener('touchmove', touch);
|
|
77
|
+
pane.removeEventListener('keydown', key);
|
|
78
|
+
pane.removeEventListener('pointerdown', down);
|
|
79
|
+
};
|
|
80
|
+
}
|