@joewinke/jatui 0.38.2 → 0.38.4
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 +2 -2
- package/dist/components/VideoPlayer.svelte +289 -35
- package/dist/components/VideoPlayer.svelte.d.ts +29 -1
- package/dist/components/basic/JumpToLatest.svelte +4 -0
- package/dist/components/basic/JumpToLatest.svelte.d.ts +2 -0
- package/dist/video/band.d.ts +22 -0
- package/dist/video/band.js +38 -0
- package/dist/video/keys.d.ts +3 -2
- package/dist/video/keys.js +5 -2
- package/dist/video/settings/registry.d.ts +1 -1
- package/dist/video/settings/registry.js +12 -0
- package/dist/video/settings/store.d.ts +8 -0
- package/dist/video/settings/store.js +36 -0
- package/dist/video/transcriptFollow.d.ts +61 -0
- package/dist/video/transcriptFollow.js +140 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -69,7 +69,7 @@ Then import:
|
|
|
69
69
|
| `Empty` | Zero-state surface — v1 thin copy/children block still works; pass `title` for the v2 hero layout (upstreamed from the IDE's EmptyState): named `glyph` (11 curated Heroicons), `size` (sm/md/lg), `pulse` (reduced-motion gated, tinted with `--color-primary`), `icon` snippet override, CTA `actions` snippet, `description`, extra `children` below. | `title` (opts into v2), `description`, `glyph`, `size`, `pulse`, `icon`/`actions` (snippets), `copy`, `class`, `children` |
|
|
70
70
|
| `ErrorState` | Standard "this failed" surface (upstreamed from the IDE's ErrorState) — error-token themed icon, optional Retry button, extra `actions`, collapsible `details` block for raw error output. Centered card mirroring `Empty` v2. | `title`, `message`, `details`, `retryLabel`, `onRetry`, `size`, `actions` (snippet) |
|
|
71
71
|
| `HunkDiffView` | Side-by-side diff with line numbers, red/green backgrounds, strikethrough on removed lines, inline word-level highlights via the `diff` lib. Used for reviewing YAML/text changes per hunk. | `oldText: string \| null`, `newText: string \| null`, `unified?: boolean` |
|
|
72
|
-
| `JumpToLatest` | "Jump to latest" pill for streaming transcripts (pairs with `createStickToBottom`) — shows when the reader has scrolled away from the live edge, badges the unread arrival count, jumps + resumes following on click. Position inside a `relative` wrapper that also holds the scroll element. | `visible`, `unreadCount`, `label`, `onclick` |
|
|
72
|
+
| `JumpToLatest` | "Jump to latest" pill for streaming transcripts (pairs with `createStickToBottom`) — shows when the reader has scrolled away from the live edge, badges the unread arrival count, jumps + resumes following on click. Position inside a `relative` wrapper that also holds the scroll element. | `visible`, `unreadCount`, `label`, `text?` (visible words beside the arrow), `onclick` |
|
|
73
73
|
| `ContextMenu` | Floating right-click (or programmatic) action menu, positioned via `use:floating` at a cursor point. Real `<button>`s, per-item `icon`/`variant`/`disabled`, dividers, click-outside + Escape dismiss, arrow-key/Home/End roving nav (focus starts on the first item). Items may declare `submit: SubmitConfig` to POST to a SvelteKit form action instead of (or in addition to) `onSelect` — a hidden `<form>` is built, requestSubmit()ed, then removed, so `use:enhance` fires exactly like a regular form submission. **Items also support nested `submenu: ContextMenuItem[]`** (right-side popover on hover/focus/ArrowRight, chevron indicator, ArrowLeft/Escape closes just the submenu — submenus open on parent-item focus **without stealing focus into the submenu**; ArrowRight is the explicit "enter the submenu" gesture) **and inline `content: Snippet`** (replaces the default icon+label row with an arbitrary widget — the wrapping button still closes the menu, so widgets that shouldn't must `event.stopPropagation()`). `autoFocus` (default true) can be set false to suppress auto-focus of the first item on open. | `open` (bindable), `x`, `y`, `items: ContextMenuItem[]`, `header?`, `zIndex?`, `autoFocus?`, `onClose?` |
|
|
74
74
|
| `ContextMenuTrigger` | Wraps content with the standard right-click + "⋯" touch affordance, backed by `ContextMenu`. Use instead of hand-rolling `oncontextmenu` + a kebab button per row/card. | `items: ContextMenuItem[]`, `children` (snippet), `header?`, `triggerLabel?`, `hideButton?`, `zIndex?`, `class?` |
|
|
75
75
|
| `Tooltip` | Component-shaped wrapper around the shared `use:tooltip` action — for cases where you can't easily add an attribute to the target element (wrap a chip / badge / icon in the tooltip instead). Warm/cool window + `aria-describedby` are shared with the action. Prefer `use:tooltip` directly when you own the element. | `content: string`, `placement?`, `delayMs?`, `disabled?`, `display?`, `children` (snippet) |
|
|
@@ -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, `Shift+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, t theater, ? 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; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `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,13 @@
|
|
|
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 {
|
|
90
|
+
scrollRowIntoPane,
|
|
91
|
+
watchReaderScroll,
|
|
92
|
+
FOLLOW_RESUME_IDLE_MS,
|
|
93
|
+
type ReaderScrollWatch
|
|
94
|
+
} from '../video/transcriptFollow';
|
|
95
|
+
import JumpToLatest from './basic/JumpToLatest.svelte';
|
|
89
96
|
import type {
|
|
90
97
|
VideoBookmark,
|
|
91
98
|
VideoChapter,
|
|
@@ -239,8 +246,11 @@
|
|
|
239
246
|
import type { ResumePoint } from '../video/advisors/resume-point';
|
|
240
247
|
import { idleDetectionSupported } from '../video/presence';
|
|
241
248
|
import {
|
|
249
|
+
coerce,
|
|
242
250
|
createSettingsSession,
|
|
251
|
+
localSettingsStore,
|
|
243
252
|
panelGroups,
|
|
253
|
+
settingSpec,
|
|
244
254
|
VIDEO_SETTINGS,
|
|
245
255
|
type SettingAvailability,
|
|
246
256
|
type SettingsSession,
|
|
@@ -272,7 +282,11 @@
|
|
|
272
282
|
talkTime = [],
|
|
273
283
|
startAt = null,
|
|
274
284
|
transcriptPosition = 'below',
|
|
275
|
-
transcriptMaxHeight
|
|
285
|
+
transcriptMaxHeight,
|
|
286
|
+
followResumeMs = FOLLOW_RESUME_IDLE_MS,
|
|
287
|
+
theater = $bindable(false),
|
|
288
|
+
theaterButton = true,
|
|
289
|
+
onTheaterChange,
|
|
276
290
|
/**
|
|
277
291
|
* CORS mode for the <video> element — passed straight through as the
|
|
278
292
|
* `crossorigin` attribute. `'anonymous'` asks the server for a
|
|
@@ -431,7 +445,35 @@
|
|
|
431
445
|
/** Seek here once metadata lands (a queued seek never fires on a later source). */
|
|
432
446
|
startAt?: number | null;
|
|
433
447
|
transcriptPosition?: 'below' | 'side';
|
|
448
|
+
/**
|
|
449
|
+
* CSS length bounding the transcript pane's height — it scrolls inside
|
|
450
|
+
* this, never the page. Default: `min(40vh, 28rem)` below the player;
|
|
451
|
+
* beside it (`transcriptPosition: 'side'`), the player column's height.
|
|
452
|
+
*/
|
|
434
453
|
transcriptMaxHeight?: string;
|
|
454
|
+
/**
|
|
455
|
+
* A reader who scrolled the transcript pane pauses its follow; after
|
|
456
|
+
* this long with the pane left alone while playing, follow resumes by
|
|
457
|
+
* itself (ms, default 6000; 0 ⇒ only a seek, a line/chapter click,
|
|
458
|
+
* play after pause or the "Resume following" pill bring it back).
|
|
459
|
+
*/
|
|
460
|
+
followResumeMs?: number;
|
|
461
|
+
/**
|
|
462
|
+
* Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
|
|
463
|
+
* only says it wants the page's width; the HOST lays its page out
|
|
464
|
+
* full-width (`bind:theater` or `onTheaterChange`). The player itself
|
|
465
|
+
* marks its root `vp-theater` / `data-theater` and puts a side
|
|
466
|
+
* transcript below the video. The control-bar button and `t` toggle
|
|
467
|
+
* it; the choice is remembered per viewer under the `player.theater`
|
|
468
|
+
* setting — through `settingsStore` when one is given, else this
|
|
469
|
+
* browser's localStorage (every access guarded). The prop is the
|
|
470
|
+
* host's default; a remembered choice wins.
|
|
471
|
+
*/
|
|
472
|
+
theater?: boolean;
|
|
473
|
+
/** Show the theater button + `t` key (video only; default true). */
|
|
474
|
+
theaterButton?: boolean;
|
|
475
|
+
/** The viewer toggled theater mode (button, `t`, settings panel). */
|
|
476
|
+
onTheaterChange?: (theater: boolean) => void;
|
|
435
477
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
436
478
|
syncUrlTime?: boolean;
|
|
437
479
|
/**
|
|
@@ -799,6 +841,10 @@
|
|
|
799
841
|
let viewStart = $state(0);
|
|
800
842
|
let viewEnd = $state(0); // 0 until a duration is known
|
|
801
843
|
let hoverChapter = $state(-1);
|
|
844
|
+
/** A chapter-rail row under the pointer or keyboard focus (-1 = none). */
|
|
845
|
+
let railHoverChapter = $state(-1);
|
|
846
|
+
/** The band canvas holds KEYBOARD focus (:focus-visible) — light the chapter under the playhead. */
|
|
847
|
+
let bandKeyFocus = $state(false);
|
|
802
848
|
let hover: {
|
|
803
849
|
x: number;
|
|
804
850
|
title: string;
|
|
@@ -848,6 +894,23 @@
|
|
|
848
894
|
});
|
|
849
895
|
|
|
850
896
|
let transcriptEl: HTMLDivElement | null = null;
|
|
897
|
+
/** The reader scrolled the pane themselves — auto-follow waits (see transcriptFollow.ts). */
|
|
898
|
+
let followPaused = $state(false);
|
|
899
|
+
/** When the reader last scrolled the pane (performance.now()), for the idle resume. */
|
|
900
|
+
let lastReaderScrollAt = 0;
|
|
901
|
+
let readerWatch: ReaderScrollWatch | null = null;
|
|
902
|
+
/** The player scrolls the pane itself: fence it off from reader intent. */
|
|
903
|
+
function paneScrollTop(pane: HTMLElement, top: number) {
|
|
904
|
+
readerWatch?.programmatic();
|
|
905
|
+
pane.scrollTop = top;
|
|
906
|
+
}
|
|
907
|
+
function scrollPaneTo(pane: HTMLElement, row: HTMLElement) {
|
|
908
|
+
readerWatch?.programmatic();
|
|
909
|
+
return scrollRowIntoPane(pane, row, !reduceMotion);
|
|
910
|
+
}
|
|
911
|
+
/** Height of the main column (stage + band), for a side transcript's bound. */
|
|
912
|
+
let mainEl: HTMLDivElement | undefined = $state();
|
|
913
|
+
let mainHeight = $state(0);
|
|
851
914
|
let vpScrollTop = $state(0);
|
|
852
915
|
let vpHeight = $state(0);
|
|
853
916
|
let heightsVersion = $state(0);
|
|
@@ -874,7 +937,7 @@
|
|
|
874
937
|
transPadTop = 0;
|
|
875
938
|
pendingAutoScrollId = null;
|
|
876
939
|
vpScrollTop = 0;
|
|
877
|
-
if (transcriptEl) transcriptEl
|
|
940
|
+
if (transcriptEl) paneScrollTop(transcriptEl, 0);
|
|
878
941
|
}
|
|
879
942
|
|
|
880
943
|
/** Append-only growth: heights measured for the prefix stay valid, so only
|
|
@@ -1021,7 +1084,7 @@
|
|
|
1021
1084
|
const after = ensureOffsets();
|
|
1022
1085
|
const delta = (after[anchor] ?? beforeTop) - beforeTop;
|
|
1023
1086
|
if (el && delta !== 0 && st >= beforeTop) {
|
|
1024
|
-
el
|
|
1087
|
+
paneScrollTop(el, st + delta); // fires onscroll → vpScrollTop follows
|
|
1025
1088
|
}
|
|
1026
1089
|
}
|
|
1027
1090
|
|
|
@@ -1041,9 +1104,16 @@
|
|
|
1041
1104
|
scheduleRowMeasure();
|
|
1042
1105
|
});
|
|
1043
1106
|
ro.observe(el);
|
|
1107
|
+
const watch = watchReaderScroll(el, () => {
|
|
1108
|
+
lastReaderScrollAt = performance.now();
|
|
1109
|
+
if (activeSegment) followPaused = true;
|
|
1110
|
+
});
|
|
1111
|
+
readerWatch = watch;
|
|
1044
1112
|
return {
|
|
1045
1113
|
destroy() {
|
|
1046
1114
|
ro.disconnect();
|
|
1115
|
+
watch();
|
|
1116
|
+
if (readerWatch === watch) readerWatch = null;
|
|
1047
1117
|
transcriptEl = null;
|
|
1048
1118
|
}
|
|
1049
1119
|
};
|
|
@@ -1092,10 +1162,9 @@
|
|
|
1092
1162
|
if (!railOn || railActive < 0) return;
|
|
1093
1163
|
const row = chapterRailEl?.querySelector<HTMLElement>('.vp-chapter-row.active');
|
|
1094
1164
|
if (!row) return;
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
});
|
|
1165
|
+
// The rail's own overflow only — scrollIntoView would also scroll the
|
|
1166
|
+
// page whenever the rail sits partly off-screen (jatui-ap4oz).
|
|
1167
|
+
if (chapterRailEl) scrollRowIntoPane(chapterRailEl, row, !reduceMotion);
|
|
1099
1168
|
});
|
|
1100
1169
|
const sortedTurns = $derived(sortTurns(speakers));
|
|
1101
1170
|
const sortedSegments = $derived(sortSegments(transcript));
|
|
@@ -1168,6 +1237,21 @@
|
|
|
1168
1237
|
sortedSegments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
|
|
1169
1238
|
);
|
|
1170
1239
|
|
|
1240
|
+
/**
|
|
1241
|
+
* The chapter whose span the band lights (jatui-ap4oz): the one under the
|
|
1242
|
+
* pointer, else a hovered/focused rail row, else — while the band has
|
|
1243
|
+
* keyboard focus — the chapter under the playhead.
|
|
1244
|
+
*/
|
|
1245
|
+
const litChapter = $derived(
|
|
1246
|
+
hoverChapter >= 0
|
|
1247
|
+
? hoverChapter
|
|
1248
|
+
: railHoverChapter >= 0
|
|
1249
|
+
? railHoverChapter
|
|
1250
|
+
: bandKeyFocus
|
|
1251
|
+
? chapterAt(currentTime, sortedChapters)
|
|
1252
|
+
: -1
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1171
1255
|
/** The zoom window, as the band module sees it (plain data — band.ts is DOM-free). */
|
|
1172
1256
|
const bandWindow = $derived<BandWindow>({ start: viewStart, end: viewEnd });
|
|
1173
1257
|
|
|
@@ -1187,13 +1271,14 @@
|
|
|
1187
1271
|
soloKey,
|
|
1188
1272
|
mutedKeys,
|
|
1189
1273
|
window: bandWindow,
|
|
1190
|
-
hoverChapter,
|
|
1274
|
+
hoverChapter: litChapter,
|
|
1191
1275
|
luma: lumaSeries,
|
|
1192
1276
|
comments: socialMarkers.comments,
|
|
1193
1277
|
likes: socialMarkers.likes,
|
|
1194
1278
|
cache: layerCache,
|
|
1195
1279
|
colors: {
|
|
1196
1280
|
accent: getDaisyUIColor('primary'),
|
|
1281
|
+
chapter: getDaisyUIColor('accent') || getDaisyUIColor('primary'),
|
|
1197
1282
|
neutral: getDaisyUIColor('base-content'),
|
|
1198
1283
|
marker: getDaisyUIColor('warning'),
|
|
1199
1284
|
ribbon: getDaisyUIColor('warning'),
|
|
@@ -1261,6 +1346,7 @@
|
|
|
1261
1346
|
if (row.when === 'comments') return socialMarkers.comments.length > 0;
|
|
1262
1347
|
if (row.when === 'night') return nightPresent;
|
|
1263
1348
|
if (row.when === 'settings') return settingsPanel;
|
|
1349
|
+
if (row.when === 'theater') return theaterAvailable;
|
|
1264
1350
|
return true;
|
|
1265
1351
|
})
|
|
1266
1352
|
);
|
|
@@ -1313,6 +1399,7 @@
|
|
|
1313
1399
|
const t = Math.max(0, d > 0 ? Math.min(sec, d) : sec);
|
|
1314
1400
|
currentTime = t; // bind:currentTime pushes this to the element
|
|
1315
1401
|
if (videoEl) videoEl.currentTime = t;
|
|
1402
|
+
resumeFollow();
|
|
1316
1403
|
}
|
|
1317
1404
|
|
|
1318
1405
|
// ── Speed + rate composer (video/rate/*, mos-5lzns.32) ──────────────────
|
|
@@ -2137,6 +2224,14 @@
|
|
|
2137
2224
|
if (night !== undefined) nightDepth = v as number;
|
|
2138
2225
|
return;
|
|
2139
2226
|
}
|
|
2227
|
+
if (id === 'player.theater') {
|
|
2228
|
+
if (!settingsStore) localTheaterStore.set(id, v);
|
|
2229
|
+
if (theater !== v) {
|
|
2230
|
+
theater = v as boolean;
|
|
2231
|
+
onTheaterChange?.(theater);
|
|
2232
|
+
}
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2140
2235
|
userSettings[id] = v;
|
|
2141
2236
|
}
|
|
2142
2237
|
|
|
@@ -2159,6 +2254,30 @@
|
|
|
2159
2254
|
};
|
|
2160
2255
|
});
|
|
2161
2256
|
|
|
2257
|
+
// ── Theater mode (jatui-ee2ty) ──────────────────────────────────────────
|
|
2258
|
+
// Not fullscreen: the host lays its page out full-width off `theater`.
|
|
2259
|
+
// Remembered per viewer as the `player.theater` setting — the host's
|
|
2260
|
+
// settingsStore when given (the session above reads and writes it), else
|
|
2261
|
+
// this browser's localStorage, read once here.
|
|
2262
|
+
const localTheaterStore = localSettingsStore();
|
|
2263
|
+
const theaterAvailable = $derived(!isAudio && theaterButton);
|
|
2264
|
+
const theaterOn = $derived(theaterAvailable && theater);
|
|
2265
|
+
$effect(() => {
|
|
2266
|
+
if (!theaterAvailable || settingsStore) return;
|
|
2267
|
+
untrack(() => {
|
|
2268
|
+
const spec = settingSpec('player.theater');
|
|
2269
|
+
const stored = spec ? coerce(spec, localTheaterStore.get('player.theater')) : undefined;
|
|
2270
|
+
if (stored !== undefined && stored !== theater) applySetting('player.theater', stored);
|
|
2271
|
+
});
|
|
2272
|
+
});
|
|
2273
|
+
function toggleTheater() {
|
|
2274
|
+
if (!theaterAvailable) return;
|
|
2275
|
+
const next = !theaterOn;
|
|
2276
|
+
if (settingsSession) settingsSession.change('player.theater', next);
|
|
2277
|
+
else applySetting('player.theater', next);
|
|
2278
|
+
flashOsd(next ? 'Theater mode' : 'Default view');
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2162
2281
|
/** A viewer choice from outside the panel (N, the moon wheel) — saved like a panel change. */
|
|
2163
2282
|
function rememberSetting(id: string, v: SettingValue) {
|
|
2164
2283
|
settingsSession?.change(id, v);
|
|
@@ -2178,7 +2297,8 @@
|
|
|
2178
2297
|
windDown: windDownProp !== undefined,
|
|
2179
2298
|
idle: windDownProp !== undefined && idleDetectionSupported(),
|
|
2180
2299
|
resume: resumeKey != null,
|
|
2181
|
-
open: openFrom !== undefined
|
|
2300
|
+
open: openFrom !== undefined,
|
|
2301
|
+
theater: theaterAvailable
|
|
2182
2302
|
});
|
|
2183
2303
|
const settingsGroups = $derived(settingsOpen ? panelGroups(settingsAvailable) : []);
|
|
2184
2304
|
/** What the player is doing right now, per id — the panel shows exactly this. */
|
|
@@ -2202,7 +2322,8 @@
|
|
|
2202
2322
|
idleDetection,
|
|
2203
2323
|
resumeSnap,
|
|
2204
2324
|
openMotion,
|
|
2205
|
-
oneAxis
|
|
2325
|
+
oneAxis,
|
|
2326
|
+
theater: theaterOn
|
|
2206
2327
|
};
|
|
2207
2328
|
const out: Record<string, SettingValue> = {};
|
|
2208
2329
|
for (const spec of VIDEO_SETTINGS) {
|
|
@@ -2804,6 +2925,12 @@
|
|
|
2804
2925
|
}
|
|
2805
2926
|
return;
|
|
2806
2927
|
case 't':
|
|
2928
|
+
// t = theater (YouTube's key); Shift+T = transcript (jatui-ee2ty).
|
|
2929
|
+
if (theaterAvailable) {
|
|
2930
|
+
e.preventDefault();
|
|
2931
|
+
toggleTheater();
|
|
2932
|
+
}
|
|
2933
|
+
return;
|
|
2807
2934
|
case 'T':
|
|
2808
2935
|
if (sortedSegments.length > 0) {
|
|
2809
2936
|
e.preventDefault();
|
|
@@ -2884,28 +3011,87 @@
|
|
|
2884
3011
|
});
|
|
2885
3012
|
|
|
2886
3013
|
// Auto-scroll the active transcript row into view as playback advances.
|
|
3014
|
+
// Only the PANE scrolls (jatui-ap4oz): scrollIntoView also scrolled the
|
|
3015
|
+
// window, pushing the video out of frame on load and on every new line.
|
|
2887
3016
|
// Windowed panes may not have the row rendered: jump to its (estimated)
|
|
2888
3017
|
// offset first, then a follow-up effect lands exactly on the real row once
|
|
2889
|
-
// the window renders it.
|
|
3018
|
+
// the window renders it. A reader who scrolled the pane pauses this until
|
|
3019
|
+
// they press "Jump to current" (or click a line).
|
|
3020
|
+
function followSegment(seg: VideoTranscriptSegment) {
|
|
3021
|
+
const pane = transcriptEl;
|
|
3022
|
+
if (!pane) return;
|
|
3023
|
+
const node = rowRefs[seg.id];
|
|
3024
|
+
if (node) {
|
|
3025
|
+
scrollPaneTo(pane, node);
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
if (!transVirtual) return;
|
|
3029
|
+
const idx = segIndexById.get(seg.id);
|
|
3030
|
+
if (idx == null) return;
|
|
3031
|
+
const top = ensureOffsets()[idx] ?? 0;
|
|
3032
|
+
// Row top (in content coords) = pane padding + offset; land it ⅓ down.
|
|
3033
|
+
paneScrollTop(pane, Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3));
|
|
3034
|
+
pendingAutoScrollId = seg.id;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2890
3037
|
$effect(() => {
|
|
2891
3038
|
const seg = activeSegment;
|
|
2892
3039
|
if (!seg) return;
|
|
2893
3040
|
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;
|
|
3041
|
+
if (!followPaused) followSegment(seg);
|
|
2906
3042
|
});
|
|
2907
3043
|
});
|
|
2908
3044
|
|
|
3045
|
+
function jumpToCurrent() {
|
|
3046
|
+
followPaused = false;
|
|
3047
|
+
if (activeSegment) followSegment(activeSegment);
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/**
|
|
3051
|
+
* Choosing where to be — a seek, a chapter, a line, play after pause —
|
|
3052
|
+
* is asking for the transcript back (jatui-ee2ty). The follow effect
|
|
3053
|
+
* lands the new active line; if it didn't change, land it here.
|
|
3054
|
+
*/
|
|
3055
|
+
function resumeFollow() {
|
|
3056
|
+
if (!followPaused) return;
|
|
3057
|
+
followPaused = false;
|
|
3058
|
+
const seg = activeSegment;
|
|
3059
|
+
if (seg) queueMicrotask(() => !followPaused && activeSegment === seg && followSegment(seg));
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// Idle resume: a reader who scrolled away and then left the pane alone for
|
|
3063
|
+
// `followResumeMs` while it plays gets the follow back by itself.
|
|
3064
|
+
$effect(() => {
|
|
3065
|
+
const ms = followResumeMs;
|
|
3066
|
+
if (!followPaused || paused || !(ms > 0)) return;
|
|
3067
|
+
const tick = () => {
|
|
3068
|
+
if (performance.now() - lastReaderScrollAt >= ms) untrack(resumeFollow);
|
|
3069
|
+
};
|
|
3070
|
+
const id = setInterval(tick, Math.min(1000, Math.max(100, ms / 4)));
|
|
3071
|
+
return () => clearInterval(id);
|
|
3072
|
+
});
|
|
3073
|
+
|
|
3074
|
+
/** Clicking a line is choosing where to be: seek there and follow again. */
|
|
3075
|
+
function onRowClick(seg: VideoTranscriptSegment) {
|
|
3076
|
+
seekTo(seg.startSec);
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
// No active line (seeked past the transcript) ⇒ nothing to jump to.
|
|
3080
|
+
$effect(() => {
|
|
3081
|
+
if (!activeSegment && followPaused) untrack(() => (followPaused = false));
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
// A side transcript is bounded by the player column's height, so the
|
|
3085
|
+
// pane shrinks to fit beside the video instead of growing past it.
|
|
3086
|
+
$effect(() => {
|
|
3087
|
+
const el = mainEl;
|
|
3088
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
3089
|
+
const ro = new ResizeObserver(() => (mainHeight = el.offsetHeight));
|
|
3090
|
+
ro.observe(el);
|
|
3091
|
+
mainHeight = el.offsetHeight;
|
|
3092
|
+
return () => ro.disconnect();
|
|
3093
|
+
});
|
|
3094
|
+
|
|
2909
3095
|
// Exact landing after a windowed jump: the row is now rendered — snap to it.
|
|
2910
3096
|
$effect(() => {
|
|
2911
3097
|
void renderedSegments;
|
|
@@ -2915,7 +3101,7 @@
|
|
|
2915
3101
|
const node = rowRefs[id];
|
|
2916
3102
|
if (!node) return;
|
|
2917
3103
|
pendingAutoScrollId = null;
|
|
2918
|
-
|
|
3104
|
+
if (transcriptEl && !followPaused) scrollPaneTo(transcriptEl, node);
|
|
2919
3105
|
});
|
|
2920
3106
|
});
|
|
2921
3107
|
|
|
@@ -2943,7 +3129,9 @@
|
|
|
2943
3129
|
<div
|
|
2944
3130
|
class="vp-root {className}"
|
|
2945
3131
|
bind:this={rootEl}
|
|
2946
|
-
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
|
|
3132
|
+
class:vp-side={transcriptPosition === 'side' && !theaterOn && sortedSegments.length > 0 && transcriptVisible}
|
|
3133
|
+
class:vp-theater={theaterOn}
|
|
3134
|
+
data-theater={theaterAvailable ? String(theaterOn) : undefined}
|
|
2947
3135
|
class:vp-rail-side={railOn}
|
|
2948
3136
|
class:vp-one-axis={nativeBarHidden}
|
|
2949
3137
|
class:vp-band-transport={bandTransport}
|
|
@@ -2961,7 +3149,7 @@
|
|
|
2961
3149
|
onclickcapture={maybeAskIdle}
|
|
2962
3150
|
onkeydowncapture={maybeAskIdle}
|
|
2963
3151
|
>
|
|
2964
|
-
<div class="vp-main">
|
|
3152
|
+
<div class="vp-main" bind:this={mainEl}>
|
|
2965
3153
|
<div
|
|
2966
3154
|
class="vp-stage-wrap"
|
|
2967
3155
|
class:vp-stage-audio={isAudio}
|
|
@@ -3011,6 +3199,7 @@
|
|
|
3011
3199
|
if (nightPresent || guardPresent) nightTick();
|
|
3012
3200
|
}}
|
|
3013
3201
|
onplay={() => {
|
|
3202
|
+
resumeFollow();
|
|
3014
3203
|
if (nightPresent || guardPresent) nightTick();
|
|
3015
3204
|
if (windPhase === 'paused') windCancel();
|
|
3016
3205
|
if (windOn) windProbe();
|
|
@@ -3118,6 +3307,8 @@
|
|
|
3118
3307
|
ondblclick={onBandDblClick}
|
|
3119
3308
|
oncontextmenu={onBandContextMenu}
|
|
3120
3309
|
onkeydown={onBandKeyDown}
|
|
3310
|
+
onfocus={(e) => (bandKeyFocus = (e.currentTarget as HTMLElement).matches(':focus-visible'))}
|
|
3311
|
+
onblur={() => (bandKeyFocus = false)}
|
|
3121
3312
|
></canvas>
|
|
3122
3313
|
<!-- Polite announcements for comment jumps/clicks — the dots are
|
|
3123
3314
|
canvas pixels, so without this a comment is invisible to AT. -->
|
|
@@ -3213,6 +3404,23 @@
|
|
|
3213
3404
|
</svg>
|
|
3214
3405
|
</button>
|
|
3215
3406
|
{/if}
|
|
3407
|
+
{#if theaterAvailable}
|
|
3408
|
+
<!-- Theater (jatui-ee2ty): asks the host for the page's width —
|
|
3409
|
+
not fullscreen. Lit while on, like the moon. -->
|
|
3410
|
+
<button
|
|
3411
|
+
type="button"
|
|
3412
|
+
class="vp-theater-btn"
|
|
3413
|
+
class:active={theaterOn}
|
|
3414
|
+
aria-label="Theater mode"
|
|
3415
|
+
aria-pressed={theaterOn}
|
|
3416
|
+
title="{theaterOn ? 'Default view' : 'Theater mode'} · t"
|
|
3417
|
+
onclick={toggleTheater}
|
|
3418
|
+
>
|
|
3419
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true">
|
|
3420
|
+
<rect x="2.5" y="7" width="19" height="10" rx="1.5" />
|
|
3421
|
+
</svg>
|
|
3422
|
+
</button>
|
|
3423
|
+
{/if}
|
|
3216
3424
|
{#if settingsPanel}
|
|
3217
3425
|
<button
|
|
3218
3426
|
type="button"
|
|
@@ -3441,6 +3649,10 @@
|
|
|
3441
3649
|
data-chapter-index={i}
|
|
3442
3650
|
aria-current={railActive === i ? 'true' : undefined}
|
|
3443
3651
|
onclick={() => chapterGo(i)}
|
|
3652
|
+
onmouseenter={() => (railHoverChapter = i)}
|
|
3653
|
+
onmouseleave={() => (railHoverChapter = -1)}
|
|
3654
|
+
onfocus={() => (railHoverChapter = i)}
|
|
3655
|
+
onblur={() => (railHoverChapter = -1)}
|
|
3444
3656
|
>
|
|
3445
3657
|
<span class="vp-chapter-head">
|
|
3446
3658
|
{#if ch.status}
|
|
@@ -3485,9 +3697,17 @@
|
|
|
3485
3697
|
{/if}
|
|
3486
3698
|
|
|
3487
3699
|
{#if sortedSegments.length > 0 && transcriptVisible}
|
|
3700
|
+
<!-- Height-bounded (jatui-ap4oz): the pane scrolls itself and never
|
|
3701
|
+
the page, so the video stays in frame. Below the player the bound
|
|
3702
|
+
is `transcriptMaxHeight` (default min(40vh, 28rem)); beside it, the
|
|
3703
|
+
player column's height. -->
|
|
3704
|
+
<div
|
|
3705
|
+
class="vp-transcript-wrap"
|
|
3706
|
+
style:--vp-transcript-max={transcriptMaxHeight}
|
|
3707
|
+
style:--vp-transcript-side-max={!transcriptMaxHeight && mainHeight > 0 ? `${mainHeight}px` : undefined}
|
|
3708
|
+
>
|
|
3488
3709
|
<div
|
|
3489
3710
|
class="vp-transcript"
|
|
3490
|
-
style="max-height: {transcriptMaxHeight}"
|
|
3491
3711
|
use:transcriptPaneAction
|
|
3492
3712
|
onscroll={onTranscriptScroll}
|
|
3493
3713
|
>
|
|
@@ -3506,7 +3726,7 @@
|
|
|
3506
3726
|
? highlightKey !== speakerKeyOf(seg)
|
|
3507
3727
|
: filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
|
|
3508
3728
|
use:transcriptRowAction={seg}
|
|
3509
|
-
onclick={() =>
|
|
3729
|
+
onclick={() => onRowClick(seg)}
|
|
3510
3730
|
>
|
|
3511
3731
|
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3512
3732
|
{#if multiSpeaker}
|
|
@@ -3529,7 +3749,7 @@
|
|
|
3529
3749
|
? highlightKey !== speakerKeyOf(seg)
|
|
3530
3750
|
: filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
|
|
3531
3751
|
use:transcriptRowAction={seg}
|
|
3532
|
-
onclick={() =>
|
|
3752
|
+
onclick={() => onRowClick(seg)}
|
|
3533
3753
|
>
|
|
3534
3754
|
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3535
3755
|
{#if multiSpeaker}
|
|
@@ -3542,6 +3762,13 @@
|
|
|
3542
3762
|
{/each}
|
|
3543
3763
|
{/if}
|
|
3544
3764
|
</div>
|
|
3765
|
+
<JumpToLatest
|
|
3766
|
+
visible={followPaused && activeSegment != null}
|
|
3767
|
+
label="Resume following"
|
|
3768
|
+
text="Resume following"
|
|
3769
|
+
onclick={jumpToCurrent}
|
|
3770
|
+
/>
|
|
3771
|
+
</div>
|
|
3545
3772
|
{/if}
|
|
3546
3773
|
|
|
3547
3774
|
{#if keysOpen}
|
|
@@ -3614,6 +3841,11 @@
|
|
|
3614
3841
|
|
|
3615
3842
|
/* Side layout: transcript beside the video once there's room for both. */
|
|
3616
3843
|
@media (min-width: 64rem) {
|
|
3844
|
+
/* Beside the player: no taller than the player column. */
|
|
3845
|
+
.vp-root.vp-side .vp-transcript {
|
|
3846
|
+
max-height: var(--vp-transcript-max, var(--vp-transcript-side-max, min(40vh, 28rem)));
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3617
3849
|
.vp-root.vp-side {
|
|
3618
3850
|
display: grid;
|
|
3619
3851
|
grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr);
|
|
@@ -3847,7 +4079,7 @@
|
|
|
3847
4079
|
grid-area: below;
|
|
3848
4080
|
}
|
|
3849
4081
|
|
|
3850
|
-
.vp-root.vp-rail-side > .vp-transcript {
|
|
4082
|
+
.vp-root.vp-rail-side > .vp-transcript-wrap {
|
|
3851
4083
|
grid-area: transcript;
|
|
3852
4084
|
}
|
|
3853
4085
|
|
|
@@ -4409,8 +4641,21 @@
|
|
|
4409
4641
|
background: color-mix(in oklch, var(--color-warning) 28%, transparent);
|
|
4410
4642
|
}
|
|
4411
4643
|
|
|
4412
|
-
/* Transcript pane
|
|
4644
|
+
/* Transcript pane. The wrap positions the "Jump to current" pill over
|
|
4645
|
+
the pane and carries the height bound (jatui-ap4oz). */
|
|
4646
|
+
.vp-transcript-wrap {
|
|
4647
|
+
position: relative;
|
|
4648
|
+
display: flex;
|
|
4649
|
+
flex-direction: column;
|
|
4650
|
+
min-width: 0;
|
|
4651
|
+
min-height: 0;
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4413
4654
|
.vp-transcript {
|
|
4655
|
+
/* The bound is the pane's visible box, padding and border included. */
|
|
4656
|
+
box-sizing: border-box;
|
|
4657
|
+
max-height: var(--vp-transcript-max, min(40vh, 28rem));
|
|
4658
|
+
overscroll-behavior: contain;
|
|
4414
4659
|
display: flex;
|
|
4415
4660
|
flex-direction: column;
|
|
4416
4661
|
gap: 1px;
|
|
@@ -4728,7 +4973,8 @@
|
|
|
4728
4973
|
gap: var(--jatui-space-base, 8px);
|
|
4729
4974
|
}
|
|
4730
4975
|
|
|
4731
|
-
.vp-night-btn
|
|
4976
|
+
.vp-night-btn,
|
|
4977
|
+
.vp-theater-btn {
|
|
4732
4978
|
display: inline-flex;
|
|
4733
4979
|
align-items: center;
|
|
4734
4980
|
justify-content: center;
|
|
@@ -4741,11 +4987,13 @@
|
|
|
4741
4987
|
transition: color var(--motion-move, 300ms) var(--motion-glide, ease-out);
|
|
4742
4988
|
}
|
|
4743
4989
|
|
|
4744
|
-
.vp-night-btn:hover
|
|
4990
|
+
.vp-night-btn:hover,
|
|
4991
|
+
.vp-theater-btn:hover {
|
|
4745
4992
|
color: var(--color-base-content);
|
|
4746
4993
|
}
|
|
4747
4994
|
|
|
4748
|
-
.vp-night-btn:focus-visible
|
|
4995
|
+
.vp-night-btn:focus-visible,
|
|
4996
|
+
.vp-theater-btn:focus-visible {
|
|
4749
4997
|
outline: 2px solid var(--color-primary);
|
|
4750
4998
|
outline-offset: 1px;
|
|
4751
4999
|
}
|
|
@@ -4755,6 +5003,11 @@
|
|
|
4755
5003
|
color: var(--color-warning);
|
|
4756
5004
|
}
|
|
4757
5005
|
|
|
5006
|
+
/* Theater (jatui-ee2ty): lit in the primary while the page is widened. */
|
|
5007
|
+
.vp-theater-btn.active {
|
|
5008
|
+
color: var(--color-primary);
|
|
5009
|
+
}
|
|
5010
|
+
|
|
4758
5011
|
/* Under reduced motion the veil still dims — the dimming IS the feature —
|
|
4759
5012
|
but the long 900ms/1200ms ramps shrink to a short luminance fade. A
|
|
4760
5013
|
change in brightness is not motion, and a hard step every tick would be
|
|
@@ -4773,7 +5026,8 @@
|
|
|
4773
5026
|
transition-duration: 120ms;
|
|
4774
5027
|
}
|
|
4775
5028
|
|
|
4776
|
-
.vp-night-btn
|
|
5029
|
+
.vp-night-btn,
|
|
5030
|
+
.vp-theater-btn {
|
|
4777
5031
|
transition: none;
|
|
4778
5032
|
}
|
|
4779
5033
|
}
|
|
@@ -163,7 +163,35 @@ 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;
|
|
172
|
+
/**
|
|
173
|
+
* A reader who scrolled the transcript pane pauses its follow; after
|
|
174
|
+
* this long with the pane left alone while playing, follow resumes by
|
|
175
|
+
* itself (ms, default 6000; 0 ⇒ only a seek, a line/chapter click,
|
|
176
|
+
* play after pause or the "Resume following" pill bring it back).
|
|
177
|
+
*/
|
|
178
|
+
followResumeMs?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
|
|
181
|
+
* only says it wants the page's width; the HOST lays its page out
|
|
182
|
+
* full-width (`bind:theater` or `onTheaterChange`). The player itself
|
|
183
|
+
* marks its root `vp-theater` / `data-theater` and puts a side
|
|
184
|
+
* transcript below the video. The control-bar button and `t` toggle
|
|
185
|
+
* it; the choice is remembered per viewer under the `player.theater`
|
|
186
|
+
* setting — through `settingsStore` when one is given, else this
|
|
187
|
+
* browser's localStorage (every access guarded). The prop is the
|
|
188
|
+
* host's default; a remembered choice wins.
|
|
189
|
+
*/
|
|
190
|
+
theater?: boolean;
|
|
191
|
+
/** Show the theater button + `t` key (video only; default true). */
|
|
192
|
+
theaterButton?: boolean;
|
|
193
|
+
/** The viewer toggled theater mode (button, `t`, settings panel). */
|
|
194
|
+
onTheaterChange?: (theater: boolean) => void;
|
|
167
195
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
168
196
|
syncUrlTime?: boolean;
|
|
169
197
|
/**
|
|
@@ -419,6 +447,6 @@ type $$ComponentProps = {
|
|
|
419
447
|
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
|
420
448
|
bandState: () => BandState;
|
|
421
449
|
seekToTime: (sec: number) => void;
|
|
422
|
-
}, "night" | "nightDepth">;
|
|
450
|
+
}, "night" | "theater" | "nightDepth">;
|
|
423
451
|
type VideoPlayer = ReturnType<typeof VideoPlayer>;
|
|
424
452
|
export default VideoPlayer;
|
|
@@ -19,11 +19,14 @@
|
|
|
19
19
|
visible = false,
|
|
20
20
|
unreadCount = 0,
|
|
21
21
|
label = 'Jump to latest',
|
|
22
|
+
text = '',
|
|
22
23
|
onclick
|
|
23
24
|
}: {
|
|
24
25
|
visible?: boolean;
|
|
25
26
|
unreadCount?: number;
|
|
26
27
|
label?: string;
|
|
28
|
+
/** Visible words beside the arrow (default none — icon only). Keep it a prefix of `label`. */
|
|
29
|
+
text?: string;
|
|
27
30
|
onclick?: () => void;
|
|
28
31
|
} = $props();
|
|
29
32
|
</script>
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
<span class="font-semibold tabular-nums">{unreadCount}</span>
|
|
42
45
|
<span>new</span>
|
|
43
46
|
{/if}
|
|
47
|
+
{#if text}<span>{text}</span>{/if}
|
|
44
48
|
<svg
|
|
45
49
|
xmlns="http://www.w3.org/2000/svg"
|
|
46
50
|
class="size-4"
|
|
@@ -2,6 +2,8 @@ type $$ComponentProps = {
|
|
|
2
2
|
visible?: boolean;
|
|
3
3
|
unreadCount?: number;
|
|
4
4
|
label?: string;
|
|
5
|
+
/** Visible words beside the arrow (default none — icon only). Keep it a prefix of `label`. */
|
|
6
|
+
text?: string;
|
|
5
7
|
onclick?: () => void;
|
|
6
8
|
};
|
|
7
9
|
declare const JumpToLatest: import("svelte").Component<$$ComponentProps, {}, "">;
|
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;
|
|
@@ -86,6 +87,12 @@ export interface BandPaintState {
|
|
|
86
87
|
marker: string;
|
|
87
88
|
/** The light ribbon's wash (FEATURES.md: amber). Defaults to `marker`. */
|
|
88
89
|
ribbon?: string;
|
|
90
|
+
/**
|
|
91
|
+
* The hovered chapter's span (jatui-ee2ty) — the theme's `accent`, so
|
|
92
|
+
* it reads as a different colour from the played (primary) envelope.
|
|
93
|
+
* Defaults to `accent` (this state's primary).
|
|
94
|
+
*/
|
|
95
|
+
chapter?: string;
|
|
89
96
|
/** Comment dots. Defaults to `accent`. */
|
|
90
97
|
comment?: string;
|
|
91
98
|
/** Like heat strip. Defaults to `accent`. */
|
|
@@ -111,6 +118,21 @@ export declare function prepareCanvas(canvas: HTMLCanvasElement): CanvasRenderin
|
|
|
111
118
|
* the playhead. `W`/`H` are CSS pixels of the canvas box.
|
|
112
119
|
*/
|
|
113
120
|
export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: number, s: BandPaintState): void;
|
|
121
|
+
/** Height of the accent rule along the top of a lit chapter span, px. */
|
|
122
|
+
export declare const LIT_RULE_H = 2;
|
|
123
|
+
/** Width of the full-height edge lines at a lit chapter's bounds, px. */
|
|
124
|
+
export declare const LIT_EDGE_W = 2;
|
|
125
|
+
/** Opacity of the lit chapter's fill — clearly visible on light and dark themes. */
|
|
126
|
+
export declare const LIT_FILL_ALPHA = 0.35;
|
|
127
|
+
/**
|
|
128
|
+
* Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
|
|
129
|
+
* start (or the end of the video) — through the zoom window, clipped to the
|
|
130
|
+
* canvas. Null when there is no such chapter or it is entirely off-screen.
|
|
131
|
+
*/
|
|
132
|
+
export declare function chapterSpanPx(chapters: VideoChapter[], index: number, w: BandWindow, duration: number, width: number): {
|
|
133
|
+
x0: number;
|
|
134
|
+
x1: number;
|
|
135
|
+
} | null;
|
|
114
136
|
/** Turn → BandTurn for the paint state (resolved key, ad flag). */
|
|
115
137
|
export declare function toBandTurns(turns: VideoSpeakerTurn[]): BandTurn[];
|
|
116
138
|
export { ALL_KEY };
|
package/dist/video/band.js
CHANGED
|
@@ -161,6 +161,24 @@ export function paintBand(ctx, W, H, s) {
|
|
|
161
161
|
}
|
|
162
162
|
ctx.globalAlpha = 1;
|
|
163
163
|
}
|
|
164
|
+
// The lit chapter (jatui-ap4oz, strengthened in jatui-ee2ty): the whole
|
|
165
|
+
// column in the chapter colour at LIT_FILL_ALPHA, full-height edge lines
|
|
166
|
+
// at its bounds and a rule along the top — under every other layer so
|
|
167
|
+
// the envelope, flags and playhead stay legible on top. Static — nothing
|
|
168
|
+
// animates, so it is reduced-motion safe by construction.
|
|
169
|
+
const lit = chapterSpanPx(s.chapters, s.hoverChapter, s.window, dur, W);
|
|
170
|
+
if (lit) {
|
|
171
|
+
const w = lit.x1 - lit.x0;
|
|
172
|
+
ctx.fillStyle = s.colors.chapter ?? accent;
|
|
173
|
+
ctx.globalAlpha = LIT_FILL_ALPHA;
|
|
174
|
+
ctx.fillRect(lit.x0, 0, w, H);
|
|
175
|
+
ctx.globalAlpha = 0.95;
|
|
176
|
+
ctx.fillRect(lit.x0, 0, w, LIT_RULE_H);
|
|
177
|
+
const e = Math.min(LIT_EDGE_W, w / 2);
|
|
178
|
+
ctx.fillRect(lit.x0, 0, e, H);
|
|
179
|
+
ctx.fillRect(lit.x1 - e, 0, e, H);
|
|
180
|
+
ctx.globalAlpha = 1;
|
|
181
|
+
}
|
|
164
182
|
const p = s.peaks;
|
|
165
183
|
const withWave = !!p;
|
|
166
184
|
const n = p?.length ?? 0;
|
|
@@ -310,6 +328,26 @@ export function paintBand(ctx, W, H, s) {
|
|
|
310
328
|
ctx.fillRect(playheadX(s.currentTime, w, dur, W) - PLAYHEAD_W / 2, 0, PLAYHEAD_W, H);
|
|
311
329
|
ctx.globalAlpha = 1;
|
|
312
330
|
}
|
|
331
|
+
/** Height of the accent rule along the top of a lit chapter span, px. */
|
|
332
|
+
export const LIT_RULE_H = 2;
|
|
333
|
+
/** Width of the full-height edge lines at a lit chapter's bounds, px. */
|
|
334
|
+
export const LIT_EDGE_W = 2;
|
|
335
|
+
/** Opacity of the lit chapter's fill — clearly visible on light and dark themes. */
|
|
336
|
+
export const LIT_FILL_ALPHA = 0.35;
|
|
337
|
+
/**
|
|
338
|
+
* Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
|
|
339
|
+
* start (or the end of the video) — through the zoom window, clipped to the
|
|
340
|
+
* canvas. Null when there is no such chapter or it is entirely off-screen.
|
|
341
|
+
*/
|
|
342
|
+
export function chapterSpanPx(chapters, index, w, duration, width) {
|
|
343
|
+
const ch = chapters[index];
|
|
344
|
+
if (!ch || duration <= 0 || width <= 0)
|
|
345
|
+
return null;
|
|
346
|
+
const end = chapters[index + 1]?.startSec ?? duration;
|
|
347
|
+
const x0 = Math.max(0, secToFrac(w, duration, ch.startSec) * width);
|
|
348
|
+
const x1 = Math.min(width, secToFrac(w, duration, end) * width);
|
|
349
|
+
return x1 > x0 ? { x0, x1 } : null;
|
|
350
|
+
}
|
|
313
351
|
/** Turn → BandTurn for the paint state (resolved key, ad flag). */
|
|
314
352
|
export function toBandTurns(turns) {
|
|
315
353
|
return turns.map((t) => ({
|
package/dist/video/keys.d.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Map parity is with extension-library's theater handler (FEATURES.md):
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
|
-
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript
|
|
8
|
+
* speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
|
|
9
|
+
* (t is theater mode, YouTube's key — jatui-ee2ty), ?
|
|
9
10
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
11
|
* library-app verbs, not player verbs), `/` (transcript search — the pane
|
|
11
12
|
* has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
|
|
@@ -20,7 +21,7 @@ export interface PlayerKeyRow {
|
|
|
20
21
|
* Availability gate: the row is only advertised (and the key only bound)
|
|
21
22
|
* when the feature's data exists. Undefined ⇒ always active.
|
|
22
23
|
*/
|
|
23
|
-
when?: 'chapters' | 'rail' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings';
|
|
24
|
+
when?: 'chapters' | 'rail' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings' | 'theater';
|
|
24
25
|
}
|
|
25
26
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
26
27
|
export declare const PLAYER_KEYS: PlayerKeyRow[];
|
package/dist/video/keys.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Map parity is with extension-library's theater handler (FEATURES.md):
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
|
-
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript
|
|
8
|
+
* speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
|
|
9
|
+
* (t is theater mode, YouTube's key — jatui-ee2ty), ?
|
|
9
10
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
11
|
* library-app verbs, not player verbs), `/` (transcript search — the pane
|
|
11
12
|
* has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
|
|
@@ -30,7 +31,9 @@ export const PLAYER_KEYS = [
|
|
|
30
31
|
// and "people's reactions to it" sit under one hand (mos-5lzns.30).
|
|
31
32
|
{ keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
|
|
32
33
|
{ keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
|
|
33
|
-
|
|
34
|
+
// t = theater, YouTube's key; the transcript moved to Shift+T (jatui-ee2ty).
|
|
35
|
+
{ keys: 't', description: 'Theater mode on / off', when: 'theater' },
|
|
36
|
+
{ keys: 'Shift+T', description: 'Show / hide the transcript', when: 'transcript' },
|
|
34
37
|
{ keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
|
|
35
38
|
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
36
39
|
{ keys: ',', description: 'Settings', when: 'settings' },
|
|
@@ -37,7 +37,7 @@ export type SettingValue = boolean | number | string;
|
|
|
37
37
|
* a switch that does nothing. The component resolves them (see
|
|
38
38
|
* `SettingAvailability`); the registry only names them.
|
|
39
39
|
*/
|
|
40
|
-
export type SettingWhen = 'waveform' | 'speakers' | 'ads' | 'loudness' | 'quiet' | 'limiter' | 'night' | 'nightLuma' | 'flashes' | 'windDown' | 'idle' | 'resume' | 'open';
|
|
40
|
+
export type SettingWhen = 'waveform' | 'speakers' | 'ads' | 'loudness' | 'quiet' | 'limiter' | 'night' | 'nightLuma' | 'flashes' | 'windDown' | 'idle' | 'resume' | 'open' | 'theater';
|
|
41
41
|
export type SettingAvailability = Partial<Record<SettingWhen, boolean>>;
|
|
42
42
|
export interface SettingOption {
|
|
43
43
|
value: string;
|
|
@@ -281,6 +281,18 @@ export const VIDEO_SETTINGS = [
|
|
|
281
281
|
hint: 'One time axis instead of two. The waveform is always on screen and already seeks, so the progress bar stands down and hands it the job — and comes back as an overview while the waveform is zoomed. Turn this off to keep both bars.',
|
|
282
282
|
owner: 'mos-5lzns.30',
|
|
283
283
|
wired: true
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'player.theater',
|
|
287
|
+
group: 'Playback',
|
|
288
|
+
label: 'Theater mode',
|
|
289
|
+
type: 'bool',
|
|
290
|
+
default: false,
|
|
291
|
+
prop: 'theater',
|
|
292
|
+
when: 'theater',
|
|
293
|
+
hint: "Widens the player to the page's full width — not fullscreen, so the rest of the page is still there below it — and puts a side transcript underneath. t toggles it, and the player remembers your choice.",
|
|
294
|
+
owner: 'jatui-ee2ty',
|
|
295
|
+
wired: true
|
|
284
296
|
}
|
|
285
297
|
];
|
|
286
298
|
const BY_ID = new Map(VIDEO_SETTINGS.map((s) => [s.id, s]));
|
|
@@ -46,6 +46,14 @@ export interface SettingsSessionOpts {
|
|
|
46
46
|
}
|
|
47
47
|
export declare const SETTINGS_WRITE_DELAY_MS = 400;
|
|
48
48
|
export declare function createSettingsSession(opts: SettingsSessionOpts): SettingsSession;
|
|
49
|
+
/**
|
|
50
|
+
* This browser's localStorage as a settings store, one entry per id under
|
|
51
|
+
* `prefix` (jatui-ee2ty) — the fallback VideoPlayer uses for `player.theater`
|
|
52
|
+
* when the host gives no `settingsStore`. Every access is guarded: no
|
|
53
|
+
* storage (SSR, a private window, blocked site data) reads as nothing stored
|
|
54
|
+
* and drops writes; it never throws.
|
|
55
|
+
*/
|
|
56
|
+
export declare function localSettingsStore(prefix?: string): VideoSettingsStore;
|
|
49
57
|
/**
|
|
50
58
|
* A plain in-memory store — for demos and tests, and for a host that wants
|
|
51
59
|
* to hold the values itself (read them back with `.values`).
|
|
@@ -100,6 +100,42 @@ export function createSettingsSession(opts) {
|
|
|
100
100
|
}
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* This browser's localStorage as a settings store, one entry per id under
|
|
105
|
+
* `prefix` (jatui-ee2ty) — the fallback VideoPlayer uses for `player.theater`
|
|
106
|
+
* when the host gives no `settingsStore`. Every access is guarded: no
|
|
107
|
+
* storage (SSR, a private window, blocked site data) reads as nothing stored
|
|
108
|
+
* and drops writes; it never throws.
|
|
109
|
+
*/
|
|
110
|
+
export function localSettingsStore(prefix = 'jatui.video.') {
|
|
111
|
+
const ls = () => {
|
|
112
|
+
try {
|
|
113
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
get(id) {
|
|
121
|
+
try {
|
|
122
|
+
const raw = ls()?.getItem(prefix + id);
|
|
123
|
+
return raw == null ? undefined : JSON.parse(raw);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
set(id, v) {
|
|
130
|
+
try {
|
|
131
|
+
ls()?.setItem(prefix + id, JSON.stringify(v));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* full or blocked — the choice lasts while the player does */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
103
139
|
/**
|
|
104
140
|
* A plain in-memory store — for demos and tests, and for a host that wants
|
|
105
141
|
* to hold the values itself (read them back with `.values`).
|
|
@@ -0,0 +1,61 @@
|
|
|
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 (jatui-ee2ty): a wheel, touch drag, page key or scrollbar
|
|
12
|
+
* grab inside the pane counts only when the PANE's own scrollTop moves as
|
|
13
|
+
* its result — a wheel over the pane that scrolls the page is not reading
|
|
14
|
+
* the transcript. Follow then pauses until it is asked back (the "Resume
|
|
15
|
+
* following" pill, clicking a line, a seek, play after pause) or the reader
|
|
16
|
+
* has left the pane alone for a few seconds while it plays. The follow's own
|
|
17
|
+
* scrolls and the windowing's height corrections are never intent.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
|
|
21
|
+
* coordinates) is fully visible, with `margin` px of breathing room — or null
|
|
22
|
+
* when it already is. "Nearest" semantics: move the least distance.
|
|
23
|
+
*/
|
|
24
|
+
export declare function nearestScrollTop(rowTop: number, rowBottom: number, scrollTop: number, viewHeight: number, margin?: number): number | null;
|
|
25
|
+
/**
|
|
26
|
+
* Scroll `pane` (and nothing else) so `row` is visible. Returns true when it
|
|
27
|
+
* moved. `smooth` is the caller's call — pass false under reduced motion.
|
|
28
|
+
*/
|
|
29
|
+
export declare function scrollRowIntoPane(pane: HTMLElement, row: HTMLElement, smooth: boolean, margin?: number): boolean;
|
|
30
|
+
/** How long after a gesture a pane scroll still counts as its result, ms. */
|
|
31
|
+
export declare const INTENT_WINDOW_MS = 300;
|
|
32
|
+
/** How long a programmatic (follow) scroll may take to land, ms — smooth scrolls animate. */
|
|
33
|
+
export declare const PROGRAMMATIC_WINDOW_MS = 700;
|
|
34
|
+
/** Default idle time in a paused pane, while playing, before follow resumes itself, ms. */
|
|
35
|
+
export declare const FOLLOW_RESUME_IDLE_MS = 6000;
|
|
36
|
+
export interface ReaderScrollWatch {
|
|
37
|
+
/** Stop listening. */
|
|
38
|
+
(): void;
|
|
39
|
+
/**
|
|
40
|
+
* The player is about to scroll the pane itself (follow, windowed jump).
|
|
41
|
+
* Scrolls until it lands — or until the reader's next gesture, which the
|
|
42
|
+
* browser lets interrupt it — are never intent.
|
|
43
|
+
*/
|
|
44
|
+
programmatic(): void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Listen for reader scroll intent on `pane`; `onIntent` fires on each one
|
|
48
|
+
* (jatui-ee2ty).
|
|
49
|
+
*
|
|
50
|
+
* A gesture alone is NOT intent. Wheel, touch drag, the page keys and a
|
|
51
|
+
* scrollbar grab only ARM the watch with the pane's scrollTop at that
|
|
52
|
+
* moment; intent is the pane's OWN scrollTop then moving within
|
|
53
|
+
* `INTENT_WINDOW_MS` (or while the scrollbar is held). A wheel over the pane
|
|
54
|
+
* that chains to the page — the pane at its boundary, or the browser latched
|
|
55
|
+
* onto the window — leaves scrollTop where it was, so follow stays on. The
|
|
56
|
+
* player's own scrolls are fenced off with `programmatic()`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function watchReaderScroll(pane: HTMLElement, onIntent: () => void, opts?: {
|
|
59
|
+
windowMs?: number;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
}): ReaderScrollWatch;
|
|
@@ -0,0 +1,140 @@
|
|
|
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 (jatui-ee2ty): a wheel, touch drag, page key or scrollbar
|
|
12
|
+
* grab inside the pane counts only when the PANE's own scrollTop moves as
|
|
13
|
+
* its result — a wheel over the pane that scrolls the page is not reading
|
|
14
|
+
* the transcript. Follow then pauses until it is asked back (the "Resume
|
|
15
|
+
* following" pill, clicking a line, a seek, play after pause) or the reader
|
|
16
|
+
* has left the pane alone for a few seconds while it plays. The follow's own
|
|
17
|
+
* scrolls and the windowing's height corrections are never intent.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
|
|
21
|
+
* coordinates) is fully visible, with `margin` px of breathing room — or null
|
|
22
|
+
* when it already is. "Nearest" semantics: move the least distance.
|
|
23
|
+
*/
|
|
24
|
+
export function nearestScrollTop(rowTop, rowBottom, scrollTop, viewHeight, margin = 0) {
|
|
25
|
+
if (viewHeight <= 0)
|
|
26
|
+
return null;
|
|
27
|
+
const top = rowTop - margin;
|
|
28
|
+
const bottom = rowBottom + margin;
|
|
29
|
+
if (top >= scrollTop && bottom <= scrollTop + viewHeight)
|
|
30
|
+
return null;
|
|
31
|
+
// Taller than the pane, or above the view ⇒ align its top.
|
|
32
|
+
if (top < scrollTop || bottom - top > viewHeight)
|
|
33
|
+
return Math.max(0, top);
|
|
34
|
+
return Math.max(0, bottom - viewHeight);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Scroll `pane` (and nothing else) so `row` is visible. Returns true when it
|
|
38
|
+
* moved. `smooth` is the caller's call — pass false under reduced motion.
|
|
39
|
+
*/
|
|
40
|
+
export function scrollRowIntoPane(pane, row, smooth, margin = 8) {
|
|
41
|
+
const p = pane.getBoundingClientRect();
|
|
42
|
+
const r = row.getBoundingClientRect();
|
|
43
|
+
const offset = pane.scrollTop - p.top - pane.clientTop;
|
|
44
|
+
const next = nearestScrollTop(r.top + offset, r.bottom + offset, pane.scrollTop, pane.clientHeight, margin);
|
|
45
|
+
if (next === null || Math.abs(next - pane.scrollTop) < 1)
|
|
46
|
+
return false;
|
|
47
|
+
if (smooth && typeof pane.scrollTo === 'function')
|
|
48
|
+
pane.scrollTo({ top: next, behavior: 'smooth' });
|
|
49
|
+
else
|
|
50
|
+
pane.scrollTop = next;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// Arrows, Space, Home and End belong to the player's keymap (seek, volume,
|
|
54
|
+
// play) — only the page keys are left to scroll the pane natively.
|
|
55
|
+
const SCROLL_KEYS = new Set(['PageUp', 'PageDown']);
|
|
56
|
+
/** How long after a gesture a pane scroll still counts as its result, ms. */
|
|
57
|
+
export const INTENT_WINDOW_MS = 300;
|
|
58
|
+
/** How long a programmatic (follow) scroll may take to land, ms — smooth scrolls animate. */
|
|
59
|
+
export const PROGRAMMATIC_WINDOW_MS = 700;
|
|
60
|
+
/** Default idle time in a paused pane, while playing, before follow resumes itself, ms. */
|
|
61
|
+
export const FOLLOW_RESUME_IDLE_MS = 6000;
|
|
62
|
+
/**
|
|
63
|
+
* Listen for reader scroll intent on `pane`; `onIntent` fires on each one
|
|
64
|
+
* (jatui-ee2ty).
|
|
65
|
+
*
|
|
66
|
+
* A gesture alone is NOT intent. Wheel, touch drag, the page keys and a
|
|
67
|
+
* scrollbar grab only ARM the watch with the pane's scrollTop at that
|
|
68
|
+
* moment; intent is the pane's OWN scrollTop then moving within
|
|
69
|
+
* `INTENT_WINDOW_MS` (or while the scrollbar is held). A wheel over the pane
|
|
70
|
+
* that chains to the page — the pane at its boundary, or the browser latched
|
|
71
|
+
* onto the window — leaves scrollTop where it was, so follow stays on. The
|
|
72
|
+
* player's own scrolls are fenced off with `programmatic()`.
|
|
73
|
+
*/
|
|
74
|
+
export function watchReaderScroll(pane, onIntent, opts = {}) {
|
|
75
|
+
const windowMs = opts.windowMs ?? INTENT_WINDOW_MS;
|
|
76
|
+
const now = opts.now ?? (() => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
|
|
77
|
+
let armedUntil = -Infinity;
|
|
78
|
+
let armedTop = 0;
|
|
79
|
+
let held = false;
|
|
80
|
+
let progUntil = -Infinity;
|
|
81
|
+
const armed = () => held || now() <= armedUntil;
|
|
82
|
+
const arm = () => {
|
|
83
|
+
// The reader's gesture interrupts any smooth follow scroll in flight:
|
|
84
|
+
// from here on, movement is theirs.
|
|
85
|
+
progUntil = -Infinity;
|
|
86
|
+
if (!armed())
|
|
87
|
+
armedTop = pane.scrollTop;
|
|
88
|
+
armedUntil = now() + windowMs;
|
|
89
|
+
};
|
|
90
|
+
const onScroll = () => {
|
|
91
|
+
if (now() <= progUntil) {
|
|
92
|
+
armedTop = pane.scrollTop; // the player moved it — re-base
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!armed())
|
|
96
|
+
return;
|
|
97
|
+
if (Math.abs(pane.scrollTop - armedTop) >= 1) {
|
|
98
|
+
armedUntil = -Infinity;
|
|
99
|
+
armedTop = pane.scrollTop;
|
|
100
|
+
onIntent();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const key = (e) => {
|
|
104
|
+
if (SCROLL_KEYS.has(e.key))
|
|
105
|
+
arm();
|
|
106
|
+
};
|
|
107
|
+
// A press on the pane's own box beyond its client width is the scrollbar.
|
|
108
|
+
const down = (e) => {
|
|
109
|
+
if (e.target === pane && e.offsetX > pane.clientWidth) {
|
|
110
|
+
arm();
|
|
111
|
+
held = true;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const up = () => {
|
|
115
|
+
if (!held)
|
|
116
|
+
return;
|
|
117
|
+
held = false;
|
|
118
|
+
armedUntil = now() + windowMs; // the last drag frame's scroll still counts
|
|
119
|
+
};
|
|
120
|
+
pane.addEventListener('wheel', arm, { passive: true });
|
|
121
|
+
pane.addEventListener('touchmove', arm, { passive: true });
|
|
122
|
+
pane.addEventListener('keydown', key);
|
|
123
|
+
pane.addEventListener('pointerdown', down);
|
|
124
|
+
pane.addEventListener('scroll', onScroll, { passive: true });
|
|
125
|
+
if (typeof window !== 'undefined')
|
|
126
|
+
window.addEventListener('pointerup', up);
|
|
127
|
+
const stop = (() => {
|
|
128
|
+
pane.removeEventListener('wheel', arm);
|
|
129
|
+
pane.removeEventListener('touchmove', arm);
|
|
130
|
+
pane.removeEventListener('keydown', key);
|
|
131
|
+
pane.removeEventListener('pointerdown', down);
|
|
132
|
+
pane.removeEventListener('scroll', onScroll);
|
|
133
|
+
if (typeof window !== 'undefined')
|
|
134
|
+
window.removeEventListener('pointerup', up);
|
|
135
|
+
});
|
|
136
|
+
stop.programmatic = () => {
|
|
137
|
+
progUntil = now() + PROGRAMMATIC_WINDOW_MS;
|
|
138
|
+
};
|
|
139
|
+
return stop;
|
|
140
|
+
}
|