@joewinke/jatui 0.38.3 → 0.38.5
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 +331 -26
- package/dist/components/VideoPlayer.svelte.d.ts +24 -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 +10 -0
- package/dist/video/band.js +17 -8
- 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 +37 -8
- package/dist/video/transcriptFollow.js +78 -18
- 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'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane
|
|
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; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) 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,7 +86,12 @@
|
|
|
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 {
|
|
89
|
+
import {
|
|
90
|
+
scrollRowIntoPane,
|
|
91
|
+
watchReaderScroll,
|
|
92
|
+
FOLLOW_RESUME_IDLE_MS,
|
|
93
|
+
type ReaderScrollWatch
|
|
94
|
+
} from '../video/transcriptFollow';
|
|
90
95
|
import JumpToLatest from './basic/JumpToLatest.svelte';
|
|
91
96
|
import type {
|
|
92
97
|
VideoBookmark,
|
|
@@ -241,8 +246,11 @@
|
|
|
241
246
|
import type { ResumePoint } from '../video/advisors/resume-point';
|
|
242
247
|
import { idleDetectionSupported } from '../video/presence';
|
|
243
248
|
import {
|
|
249
|
+
coerce,
|
|
244
250
|
createSettingsSession,
|
|
251
|
+
localSettingsStore,
|
|
245
252
|
panelGroups,
|
|
253
|
+
settingSpec,
|
|
246
254
|
VIDEO_SETTINGS,
|
|
247
255
|
type SettingAvailability,
|
|
248
256
|
type SettingsSession,
|
|
@@ -275,6 +283,10 @@
|
|
|
275
283
|
startAt = null,
|
|
276
284
|
transcriptPosition = 'below',
|
|
277
285
|
transcriptMaxHeight,
|
|
286
|
+
followResumeMs = FOLLOW_RESUME_IDLE_MS,
|
|
287
|
+
theater = $bindable(false),
|
|
288
|
+
theaterButton = true,
|
|
289
|
+
onTheaterChange,
|
|
278
290
|
/**
|
|
279
291
|
* CORS mode for the <video> element — passed straight through as the
|
|
280
292
|
* `crossorigin` attribute. `'anonymous'` asks the server for a
|
|
@@ -439,6 +451,29 @@
|
|
|
439
451
|
* beside it (`transcriptPosition: 'side'`), the player column's height.
|
|
440
452
|
*/
|
|
441
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;
|
|
442
477
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
443
478
|
syncUrlTime?: boolean;
|
|
444
479
|
/**
|
|
@@ -774,6 +809,7 @@
|
|
|
774
809
|
if (!v) return;
|
|
775
810
|
v.muted = !v.muted;
|
|
776
811
|
if (!v.muted && v.volume === 0) v.volume = 0.5;
|
|
812
|
+
showVolumeOsd(v.volume, v.muted);
|
|
777
813
|
}
|
|
778
814
|
function onVolumeInput(e: Event) {
|
|
779
815
|
const v = videoEl;
|
|
@@ -781,6 +817,7 @@
|
|
|
781
817
|
const next = Number((e.currentTarget as HTMLInputElement).value);
|
|
782
818
|
v.volume = Math.min(1, Math.max(0, next));
|
|
783
819
|
v.muted = next === 0;
|
|
820
|
+
showVolumeOsd(v.volume, v.muted);
|
|
784
821
|
}
|
|
785
822
|
function toggleFullscreen() {
|
|
786
823
|
if (typeof document === 'undefined') return;
|
|
@@ -823,6 +860,16 @@
|
|
|
823
860
|
let osd = $state(''); // transient wheel/keyboard readout (volume / seek / speed)
|
|
824
861
|
let osdOn = $state(false);
|
|
825
862
|
|
|
863
|
+
// ── Volume OSD (jatui-pu698) ────────────────────────────────────────────
|
|
864
|
+
// Wheel/keyboard volume feedback: a speaker glyph + horizontal level bar
|
|
865
|
+
// + % value (or "Muted" at 0), floated over the stage. Distinct from the
|
|
866
|
+
// tiny corner readout: a volume change is a physical gesture that deserves
|
|
867
|
+
// to be SEEN, not just a number blink.
|
|
868
|
+
let volOsdOn = $state(false);
|
|
869
|
+
let volOsdVolume = $state(0);
|
|
870
|
+
let volOsdMuted = $state(false);
|
|
871
|
+
let volOsdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
872
|
+
|
|
826
873
|
// ── Speaker filter state ────────────────────────────────────────────────
|
|
827
874
|
let soloKey = $state(ALL_KEY);
|
|
828
875
|
let mutedKeys = $state(new Set<string>());
|
|
@@ -859,8 +906,20 @@
|
|
|
859
906
|
});
|
|
860
907
|
|
|
861
908
|
let transcriptEl: HTMLDivElement | null = null;
|
|
862
|
-
/** The reader scrolled the pane themselves — auto-follow waits
|
|
909
|
+
/** The reader scrolled the pane themselves — auto-follow waits (see transcriptFollow.ts). */
|
|
863
910
|
let followPaused = $state(false);
|
|
911
|
+
/** When the reader last scrolled the pane (performance.now()), for the idle resume. */
|
|
912
|
+
let lastReaderScrollAt = 0;
|
|
913
|
+
let readerWatch: ReaderScrollWatch | null = null;
|
|
914
|
+
/** The player scrolls the pane itself: fence it off from reader intent. */
|
|
915
|
+
function paneScrollTop(pane: HTMLElement, top: number) {
|
|
916
|
+
readerWatch?.programmatic();
|
|
917
|
+
pane.scrollTop = top;
|
|
918
|
+
}
|
|
919
|
+
function scrollPaneTo(pane: HTMLElement, row: HTMLElement) {
|
|
920
|
+
readerWatch?.programmatic();
|
|
921
|
+
return scrollRowIntoPane(pane, row, !reduceMotion);
|
|
922
|
+
}
|
|
864
923
|
/** Height of the main column (stage + band), for a side transcript's bound. */
|
|
865
924
|
let mainEl: HTMLDivElement | undefined = $state();
|
|
866
925
|
let mainHeight = $state(0);
|
|
@@ -890,7 +949,7 @@
|
|
|
890
949
|
transPadTop = 0;
|
|
891
950
|
pendingAutoScrollId = null;
|
|
892
951
|
vpScrollTop = 0;
|
|
893
|
-
if (transcriptEl) transcriptEl
|
|
952
|
+
if (transcriptEl) paneScrollTop(transcriptEl, 0);
|
|
894
953
|
}
|
|
895
954
|
|
|
896
955
|
/** Append-only growth: heights measured for the prefix stay valid, so only
|
|
@@ -1037,7 +1096,7 @@
|
|
|
1037
1096
|
const after = ensureOffsets();
|
|
1038
1097
|
const delta = (after[anchor] ?? beforeTop) - beforeTop;
|
|
1039
1098
|
if (el && delta !== 0 && st >= beforeTop) {
|
|
1040
|
-
el
|
|
1099
|
+
paneScrollTop(el, st + delta); // fires onscroll → vpScrollTop follows
|
|
1041
1100
|
}
|
|
1042
1101
|
}
|
|
1043
1102
|
|
|
@@ -1057,13 +1116,16 @@
|
|
|
1057
1116
|
scheduleRowMeasure();
|
|
1058
1117
|
});
|
|
1059
1118
|
ro.observe(el);
|
|
1060
|
-
const
|
|
1119
|
+
const watch = watchReaderScroll(el, () => {
|
|
1120
|
+
lastReaderScrollAt = performance.now();
|
|
1061
1121
|
if (activeSegment) followPaused = true;
|
|
1062
1122
|
});
|
|
1123
|
+
readerWatch = watch;
|
|
1063
1124
|
return {
|
|
1064
1125
|
destroy() {
|
|
1065
1126
|
ro.disconnect();
|
|
1066
|
-
|
|
1127
|
+
watch();
|
|
1128
|
+
if (readerWatch === watch) readerWatch = null;
|
|
1067
1129
|
transcriptEl = null;
|
|
1068
1130
|
}
|
|
1069
1131
|
};
|
|
@@ -1228,6 +1290,7 @@
|
|
|
1228
1290
|
cache: layerCache,
|
|
1229
1291
|
colors: {
|
|
1230
1292
|
accent: getDaisyUIColor('primary'),
|
|
1293
|
+
chapter: getDaisyUIColor('accent') || getDaisyUIColor('primary'),
|
|
1231
1294
|
neutral: getDaisyUIColor('base-content'),
|
|
1232
1295
|
marker: getDaisyUIColor('warning'),
|
|
1233
1296
|
ribbon: getDaisyUIColor('warning'),
|
|
@@ -1295,6 +1358,7 @@
|
|
|
1295
1358
|
if (row.when === 'comments') return socialMarkers.comments.length > 0;
|
|
1296
1359
|
if (row.when === 'night') return nightPresent;
|
|
1297
1360
|
if (row.when === 'settings') return settingsPanel;
|
|
1361
|
+
if (row.when === 'theater') return theaterAvailable;
|
|
1298
1362
|
return true;
|
|
1299
1363
|
})
|
|
1300
1364
|
);
|
|
@@ -1330,6 +1394,24 @@
|
|
|
1330
1394
|
zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
|
|
1331
1395
|
}
|
|
1332
1396
|
|
|
1397
|
+
// ── Volume OSD (jatui-pu698) ────────────────────────────────────────────
|
|
1398
|
+
// Big, legible volume indicator: speaker glyph + level bar + % ("Muted" at
|
|
1399
|
+
// 0). Replaces the tiny corner badge for wheel/keyboard volume gestures.
|
|
1400
|
+
// Shares `osdTimer` with the plain OSD so only ONE readout floats at a
|
|
1401
|
+
// time, and any pending plain flash is superseded (not queued behind).
|
|
1402
|
+
function showVolumeOsd(volume: number, muted: boolean) {
|
|
1403
|
+
if (volOsdTimer) clearTimeout(volOsdTimer);
|
|
1404
|
+
if (osdTimer) {
|
|
1405
|
+
clearTimeout(osdTimer);
|
|
1406
|
+
osdTimer = null;
|
|
1407
|
+
}
|
|
1408
|
+
osdOn = false; // only one readout at a time
|
|
1409
|
+
volOsdVolume = volume;
|
|
1410
|
+
volOsdMuted = muted;
|
|
1411
|
+
volOsdOn = true;
|
|
1412
|
+
volOsdTimer = setTimeout(() => (volOsdOn = false), 800);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1333
1415
|
function flashOsd(text: string, ms = 900) {
|
|
1334
1416
|
if (osdTimer) clearTimeout(osdTimer);
|
|
1335
1417
|
if (!text || ms <= 0) {
|
|
@@ -1347,6 +1429,7 @@
|
|
|
1347
1429
|
const t = Math.max(0, d > 0 ? Math.min(sec, d) : sec);
|
|
1348
1430
|
currentTime = t; // bind:currentTime pushes this to the element
|
|
1349
1431
|
if (videoEl) videoEl.currentTime = t;
|
|
1432
|
+
resumeFollow();
|
|
1350
1433
|
}
|
|
1351
1434
|
|
|
1352
1435
|
// ── Speed + rate composer (video/rate/*, mos-5lzns.32) ──────────────────
|
|
@@ -2171,6 +2254,14 @@
|
|
|
2171
2254
|
if (night !== undefined) nightDepth = v as number;
|
|
2172
2255
|
return;
|
|
2173
2256
|
}
|
|
2257
|
+
if (id === 'player.theater') {
|
|
2258
|
+
if (!settingsStore) localTheaterStore.set(id, v);
|
|
2259
|
+
if (theater !== v) {
|
|
2260
|
+
theater = v as boolean;
|
|
2261
|
+
onTheaterChange?.(theater);
|
|
2262
|
+
}
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2174
2265
|
userSettings[id] = v;
|
|
2175
2266
|
}
|
|
2176
2267
|
|
|
@@ -2193,6 +2284,30 @@
|
|
|
2193
2284
|
};
|
|
2194
2285
|
});
|
|
2195
2286
|
|
|
2287
|
+
// ── Theater mode (jatui-ee2ty) ──────────────────────────────────────────
|
|
2288
|
+
// Not fullscreen: the host lays its page out full-width off `theater`.
|
|
2289
|
+
// Remembered per viewer as the `player.theater` setting — the host's
|
|
2290
|
+
// settingsStore when given (the session above reads and writes it), else
|
|
2291
|
+
// this browser's localStorage, read once here.
|
|
2292
|
+
const localTheaterStore = localSettingsStore();
|
|
2293
|
+
const theaterAvailable = $derived(!isAudio && theaterButton);
|
|
2294
|
+
const theaterOn = $derived(theaterAvailable && theater);
|
|
2295
|
+
$effect(() => {
|
|
2296
|
+
if (!theaterAvailable || settingsStore) return;
|
|
2297
|
+
untrack(() => {
|
|
2298
|
+
const spec = settingSpec('player.theater');
|
|
2299
|
+
const stored = spec ? coerce(spec, localTheaterStore.get('player.theater')) : undefined;
|
|
2300
|
+
if (stored !== undefined && stored !== theater) applySetting('player.theater', stored);
|
|
2301
|
+
});
|
|
2302
|
+
});
|
|
2303
|
+
function toggleTheater() {
|
|
2304
|
+
if (!theaterAvailable) return;
|
|
2305
|
+
const next = !theaterOn;
|
|
2306
|
+
if (settingsSession) settingsSession.change('player.theater', next);
|
|
2307
|
+
else applySetting('player.theater', next);
|
|
2308
|
+
flashOsd(next ? 'Theater mode' : 'Default view');
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2196
2311
|
/** A viewer choice from outside the panel (N, the moon wheel) — saved like a panel change. */
|
|
2197
2312
|
function rememberSetting(id: string, v: SettingValue) {
|
|
2198
2313
|
settingsSession?.change(id, v);
|
|
@@ -2212,7 +2327,8 @@
|
|
|
2212
2327
|
windDown: windDownProp !== undefined,
|
|
2213
2328
|
idle: windDownProp !== undefined && idleDetectionSupported(),
|
|
2214
2329
|
resume: resumeKey != null,
|
|
2215
|
-
open: openFrom !== undefined
|
|
2330
|
+
open: openFrom !== undefined,
|
|
2331
|
+
theater: theaterAvailable
|
|
2216
2332
|
});
|
|
2217
2333
|
const settingsGroups = $derived(settingsOpen ? panelGroups(settingsAvailable) : []);
|
|
2218
2334
|
/** What the player is doing right now, per id — the panel shows exactly this. */
|
|
@@ -2236,7 +2352,8 @@
|
|
|
2236
2352
|
idleDetection,
|
|
2237
2353
|
resumeSnap,
|
|
2238
2354
|
openMotion,
|
|
2239
|
-
oneAxis
|
|
2355
|
+
oneAxis,
|
|
2356
|
+
theater: theaterOn
|
|
2240
2357
|
};
|
|
2241
2358
|
const out: Record<string, SettingValue> = {};
|
|
2242
2359
|
for (const spec of VIDEO_SETTINGS) {
|
|
@@ -2700,7 +2817,7 @@
|
|
|
2700
2817
|
const next = Math.min(1, Math.max(0, videoEl.volume - Math.sign(e.deltaY) * 0.05));
|
|
2701
2818
|
videoEl.volume = next;
|
|
2702
2819
|
if (next > 0) videoEl.muted = false;
|
|
2703
|
-
|
|
2820
|
+
showVolumeOsd(next, next === 0);
|
|
2704
2821
|
}
|
|
2705
2822
|
|
|
2706
2823
|
/**
|
|
@@ -2766,15 +2883,15 @@
|
|
|
2766
2883
|
case 'ArrowUp':
|
|
2767
2884
|
v.volume = Math.min(1, v.volume + 0.05);
|
|
2768
2885
|
if (v.volume > 0) v.muted = false;
|
|
2769
|
-
|
|
2886
|
+
showVolumeOsd(v.volume, v.muted);
|
|
2770
2887
|
break;
|
|
2771
2888
|
case 'ArrowDown':
|
|
2772
2889
|
v.volume = Math.max(0, v.volume - 0.05);
|
|
2773
|
-
|
|
2890
|
+
showVolumeOsd(v.volume, v.muted);
|
|
2774
2891
|
break;
|
|
2775
2892
|
case 'm':
|
|
2776
2893
|
v.muted = !v.muted;
|
|
2777
|
-
|
|
2894
|
+
showVolumeOsd(v.volume, v.muted);
|
|
2778
2895
|
break;
|
|
2779
2896
|
case '-':
|
|
2780
2897
|
case '_':
|
|
@@ -2838,6 +2955,12 @@
|
|
|
2838
2955
|
}
|
|
2839
2956
|
return;
|
|
2840
2957
|
case 't':
|
|
2958
|
+
// t = theater (YouTube's key); Shift+T = transcript (jatui-ee2ty).
|
|
2959
|
+
if (theaterAvailable) {
|
|
2960
|
+
e.preventDefault();
|
|
2961
|
+
toggleTheater();
|
|
2962
|
+
}
|
|
2963
|
+
return;
|
|
2841
2964
|
case 'T':
|
|
2842
2965
|
if (sortedSegments.length > 0) {
|
|
2843
2966
|
e.preventDefault();
|
|
@@ -2929,7 +3052,7 @@
|
|
|
2929
3052
|
if (!pane) return;
|
|
2930
3053
|
const node = rowRefs[seg.id];
|
|
2931
3054
|
if (node) {
|
|
2932
|
-
|
|
3055
|
+
scrollPaneTo(pane, node);
|
|
2933
3056
|
return;
|
|
2934
3057
|
}
|
|
2935
3058
|
if (!transVirtual) return;
|
|
@@ -2937,7 +3060,7 @@
|
|
|
2937
3060
|
if (idx == null) return;
|
|
2938
3061
|
const top = ensureOffsets()[idx] ?? 0;
|
|
2939
3062
|
// Row top (in content coords) = pane padding + offset; land it ⅓ down.
|
|
2940
|
-
pane
|
|
3063
|
+
paneScrollTop(pane, Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3));
|
|
2941
3064
|
pendingAutoScrollId = seg.id;
|
|
2942
3065
|
}
|
|
2943
3066
|
|
|
@@ -2954,9 +3077,32 @@
|
|
|
2954
3077
|
if (activeSegment) followSegment(activeSegment);
|
|
2955
3078
|
}
|
|
2956
3079
|
|
|
3080
|
+
/**
|
|
3081
|
+
* Choosing where to be — a seek, a chapter, a line, play after pause —
|
|
3082
|
+
* is asking for the transcript back (jatui-ee2ty). The follow effect
|
|
3083
|
+
* lands the new active line; if it didn't change, land it here.
|
|
3084
|
+
*/
|
|
3085
|
+
function resumeFollow() {
|
|
3086
|
+
if (!followPaused) return;
|
|
3087
|
+
followPaused = false;
|
|
3088
|
+
const seg = activeSegment;
|
|
3089
|
+
if (seg) queueMicrotask(() => !followPaused && activeSegment === seg && followSegment(seg));
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// Idle resume: a reader who scrolled away and then left the pane alone for
|
|
3093
|
+
// `followResumeMs` while it plays gets the follow back by itself.
|
|
3094
|
+
$effect(() => {
|
|
3095
|
+
const ms = followResumeMs;
|
|
3096
|
+
if (!followPaused || paused || !(ms > 0)) return;
|
|
3097
|
+
const tick = () => {
|
|
3098
|
+
if (performance.now() - lastReaderScrollAt >= ms) untrack(resumeFollow);
|
|
3099
|
+
};
|
|
3100
|
+
const id = setInterval(tick, Math.min(1000, Math.max(100, ms / 4)));
|
|
3101
|
+
return () => clearInterval(id);
|
|
3102
|
+
});
|
|
3103
|
+
|
|
2957
3104
|
/** Clicking a line is choosing where to be: seek there and follow again. */
|
|
2958
3105
|
function onRowClick(seg: VideoTranscriptSegment) {
|
|
2959
|
-
followPaused = false;
|
|
2960
3106
|
seekTo(seg.startSec);
|
|
2961
3107
|
}
|
|
2962
3108
|
|
|
@@ -2985,7 +3131,7 @@
|
|
|
2985
3131
|
const node = rowRefs[id];
|
|
2986
3132
|
if (!node) return;
|
|
2987
3133
|
pendingAutoScrollId = null;
|
|
2988
|
-
if (transcriptEl && !followPaused)
|
|
3134
|
+
if (transcriptEl && !followPaused) scrollPaneTo(transcriptEl, node);
|
|
2989
3135
|
});
|
|
2990
3136
|
});
|
|
2991
3137
|
|
|
@@ -3013,10 +3159,13 @@
|
|
|
3013
3159
|
<div
|
|
3014
3160
|
class="vp-root {className}"
|
|
3015
3161
|
bind:this={rootEl}
|
|
3016
|
-
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
|
|
3162
|
+
class:vp-side={transcriptPosition === 'side' && !theaterOn && sortedSegments.length > 0 && transcriptVisible}
|
|
3163
|
+
class:vp-theater={theaterOn}
|
|
3164
|
+
data-theater={theaterAvailable ? String(theaterOn) : undefined}
|
|
3017
3165
|
class:vp-rail-side={railOn}
|
|
3018
3166
|
class:vp-one-axis={nativeBarHidden}
|
|
3019
3167
|
class:vp-band-transport={bandTransport}
|
|
3168
|
+
class:vp-vol-flash={volOsdOn}
|
|
3020
3169
|
class:vp-audio={isAudio}
|
|
3021
3170
|
class:vp-night-capable={nightPresent}
|
|
3022
3171
|
class:vp-night-surround={surroundMix > 0.001}
|
|
@@ -3081,6 +3230,7 @@
|
|
|
3081
3230
|
if (nightPresent || guardPresent) nightTick();
|
|
3082
3231
|
}}
|
|
3083
3232
|
onplay={() => {
|
|
3233
|
+
resumeFollow();
|
|
3084
3234
|
if (nightPresent || guardPresent) nightTick();
|
|
3085
3235
|
if (windPhase === 'paused') windCancel();
|
|
3086
3236
|
if (windOn) windProbe();
|
|
@@ -3136,10 +3286,41 @@
|
|
|
3136
3286
|
<div class="vp-wind-veil" aria-hidden="true"></div>
|
|
3137
3287
|
{/if}
|
|
3138
3288
|
<div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
3139
|
-
|
|
3140
|
-
|
|
3289
|
+
<!-- Volume OSD (jatui-pu698): speaker glyph + level bar + %, centred
|
|
3290
|
+
near the top of the stage so a wheel/keyboard volume gesture is
|
|
3291
|
+
SEEN, not just a corner number blink. aria-hidden: the volume
|
|
3292
|
+
slider itself already reports the value to AT. -->
|
|
3293
|
+
<div class="vp-volume-osd" class:on={volOsdOn} aria-hidden="true">
|
|
3294
|
+
<svg
|
|
3295
|
+
class="vp-vol-glyph"
|
|
3296
|
+
viewBox="0 0 24 24"
|
|
3297
|
+
width="24"
|
|
3298
|
+
height="24"
|
|
3299
|
+
fill="none"
|
|
3300
|
+
stroke="currentColor"
|
|
3301
|
+
stroke-width="1.8"
|
|
3302
|
+
stroke-linecap="round"
|
|
3303
|
+
stroke-linejoin="round"
|
|
3304
|
+
>
|
|
3305
|
+
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
|
|
3306
|
+
{#if volOsdMuted || volOsdVolume === 0}
|
|
3307
|
+
<path d="M16 9.5l5 5M21 9.5l-5 5" />
|
|
3308
|
+
{:else}
|
|
3309
|
+
<path d="M15.5 9a4 4 0 0 1 0 6" />
|
|
3310
|
+
{#if volOsdVolume > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
|
|
3311
|
+
{/if}
|
|
3312
|
+
</svg>
|
|
3313
|
+
<span class="vp-vol-bar">
|
|
3314
|
+
<span class="vp-vol-fill" style="width: {Math.round(volOsdVolume * 100)}%"></span>
|
|
3315
|
+
</span>
|
|
3316
|
+
<span class="vp-vol-text">
|
|
3317
|
+
{volOsdMuted || volOsdVolume === 0 ? 'Muted' : `${Math.round(volOsdVolume * 100)}%`}
|
|
3318
|
+
</span>
|
|
3319
|
+
</div>
|
|
3320
|
+
</div>
|
|
3321
|
+
</div>
|
|
3141
3322
|
|
|
3142
|
-
|
|
3323
|
+
<!-- The band: one time axis for the envelope, chapters, speakers and
|
|
3143
3324
|
bookmarks. Its height is reserved from the first frame, so data
|
|
3144
3325
|
arriving late fades into already-owned space. -->
|
|
3145
3326
|
<div
|
|
@@ -3230,7 +3411,34 @@
|
|
|
3230
3411
|
{#if isAudio}
|
|
3231
3412
|
<!-- No stage to float the readout over: it rides the band. -->
|
|
3232
3413
|
<div class="vp-osd vp-osd-band" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
3233
|
-
|
|
3414
|
+
<div class="vp-volume-osd vp-volume-osd-band" class:on={volOsdOn} aria-hidden="true">
|
|
3415
|
+
<svg
|
|
3416
|
+
class="vp-vol-glyph"
|
|
3417
|
+
viewBox="0 0 24 24"
|
|
3418
|
+
width="24"
|
|
3419
|
+
height="24"
|
|
3420
|
+
fill="none"
|
|
3421
|
+
stroke="currentColor"
|
|
3422
|
+
stroke-width="1.8"
|
|
3423
|
+
stroke-linecap="round"
|
|
3424
|
+
stroke-linejoin="round"
|
|
3425
|
+
>
|
|
3426
|
+
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
|
|
3427
|
+
{#if volOsdMuted || volOsdVolume === 0}
|
|
3428
|
+
<path d="M16 9.5l5 5M21 9.5l-5 5" />
|
|
3429
|
+
{:else}
|
|
3430
|
+
<path d="M15.5 9a4 4 0 0 1 0 6" />
|
|
3431
|
+
{#if volOsdVolume > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
|
|
3432
|
+
{/if}
|
|
3433
|
+
</svg>
|
|
3434
|
+
<span class="vp-vol-bar">
|
|
3435
|
+
<span class="vp-vol-fill" style="width: {Math.round(volOsdVolume * 100)}%"></span>
|
|
3436
|
+
</span>
|
|
3437
|
+
<span class="vp-vol-text">
|
|
3438
|
+
{volOsdMuted || volOsdVolume === 0 ? 'Muted' : `${Math.round(volOsdVolume * 100)}%`}
|
|
3439
|
+
</span>
|
|
3440
|
+
</div>
|
|
3441
|
+
{/if}
|
|
3234
3442
|
|
|
3235
3443
|
{#if hover}
|
|
3236
3444
|
<div
|
|
@@ -3285,6 +3493,23 @@
|
|
|
3285
3493
|
</svg>
|
|
3286
3494
|
</button>
|
|
3287
3495
|
{/if}
|
|
3496
|
+
{#if theaterAvailable}
|
|
3497
|
+
<!-- Theater (jatui-ee2ty): asks the host for the page's width —
|
|
3498
|
+
not fullscreen. Lit while on, like the moon. -->
|
|
3499
|
+
<button
|
|
3500
|
+
type="button"
|
|
3501
|
+
class="vp-theater-btn"
|
|
3502
|
+
class:active={theaterOn}
|
|
3503
|
+
aria-label="Theater mode"
|
|
3504
|
+
aria-pressed={theaterOn}
|
|
3505
|
+
title="{theaterOn ? 'Default view' : 'Theater mode'} · t"
|
|
3506
|
+
onclick={toggleTheater}
|
|
3507
|
+
>
|
|
3508
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true">
|
|
3509
|
+
<rect x="2.5" y="7" width="19" height="10" rx="1.5" />
|
|
3510
|
+
</svg>
|
|
3511
|
+
</button>
|
|
3512
|
+
{/if}
|
|
3288
3513
|
{#if settingsPanel}
|
|
3289
3514
|
<button
|
|
3290
3515
|
type="button"
|
|
@@ -3628,7 +3853,8 @@
|
|
|
3628
3853
|
</div>
|
|
3629
3854
|
<JumpToLatest
|
|
3630
3855
|
visible={followPaused && activeSegment != null}
|
|
3631
|
-
label="
|
|
3856
|
+
label="Resume following"
|
|
3857
|
+
text="Resume following"
|
|
3632
3858
|
onclick={jumpToCurrent}
|
|
3633
3859
|
/>
|
|
3634
3860
|
</div>
|
|
@@ -4009,6 +4235,64 @@
|
|
|
4009
4235
|
opacity: 1;
|
|
4010
4236
|
}
|
|
4011
4237
|
|
|
4238
|
+
/* ── Volume OSD (jatui-pu698) ───────────────────────────────────────── */
|
|
4239
|
+
/* Wheel/keyboard volume feedback: speaker glyph + level bar + %, centred
|
|
4240
|
+
near the top of the stage so a gesture is SEEN, not just a corner number
|
|
4241
|
+
blink. Token surface with backdrop so it is legible on any frame. The
|
|
4242
|
+
flash is purely opacity — no slide — so it is reduced-motion safe. */
|
|
4243
|
+
.vp-volume-osd {
|
|
4244
|
+
position: absolute;
|
|
4245
|
+
top: 10%;
|
|
4246
|
+
left: 50%;
|
|
4247
|
+
transform: translateX(-50%);
|
|
4248
|
+
display: flex;
|
|
4249
|
+
align-items: center;
|
|
4250
|
+
gap: 10px;
|
|
4251
|
+
padding: 10px 16px;
|
|
4252
|
+
border-radius: 999px;
|
|
4253
|
+
background: color-mix(in oklch, var(--color-base-100) 85%, transparent);
|
|
4254
|
+
backdrop-filter: blur(8px);
|
|
4255
|
+
color: var(--color-base-content);
|
|
4256
|
+
font-size: 0.9rem;
|
|
4257
|
+
font-weight: 600;
|
|
4258
|
+
font-variant-numeric: tabular-nums;
|
|
4259
|
+
pointer-events: none;
|
|
4260
|
+
opacity: 0;
|
|
4261
|
+
transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
|
|
4262
|
+
z-index: 10;
|
|
4263
|
+
box-shadow: 0 4px 16px color-mix(in oklch, black 20%, transparent);
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
.vp-volume-osd.on {
|
|
4267
|
+
opacity: 1;
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
.vp-vol-glyph {
|
|
4271
|
+
flex: none;
|
|
4272
|
+
opacity: 0.9;
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
.vp-vol-bar {
|
|
4276
|
+
display: block;
|
|
4277
|
+
width: 80px;
|
|
4278
|
+
height: 4px;
|
|
4279
|
+
border-radius: 999px;
|
|
4280
|
+
background: color-mix(in oklch, var(--color-base-content) 20%, transparent);
|
|
4281
|
+
overflow: hidden;
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
.vp-vol-fill {
|
|
4285
|
+
display: block;
|
|
4286
|
+
height: 100%;
|
|
4287
|
+
background: var(--color-primary);
|
|
4288
|
+
border-radius: 999px;
|
|
4289
|
+
transition: width 150ms ease-out;
|
|
4290
|
+
}
|
|
4291
|
+
|
|
4292
|
+
.vp-vol-text {
|
|
4293
|
+
min-width: 3.5rem;
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4012
4296
|
/* The band owns its height from the first frame so a waveform or speaker
|
|
4013
4297
|
turns arriving minutes in fade into space that is already theirs. */
|
|
4014
4298
|
.vp-band {
|
|
@@ -4025,6 +4309,17 @@
|
|
|
4025
4309
|
opacity: 1;
|
|
4026
4310
|
}
|
|
4027
4311
|
|
|
4312
|
+
/* Volume flash (jatui-pu698): reveal the transport and its volume slider
|
|
4313
|
+
on a wheel/keyboard volume change. */
|
|
4314
|
+
.vp-vol-flash .vp-transport {
|
|
4315
|
+
opacity: 1;
|
|
4316
|
+
visibility: visible;
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
.vp-vol-flash .vp-volume {
|
|
4320
|
+
opacity: 1;
|
|
4321
|
+
}
|
|
4322
|
+
|
|
4028
4323
|
/* Audio: no picture, so no stage at all. The element still plays. */
|
|
4029
4324
|
.vp-stage-audio {
|
|
4030
4325
|
display: none;
|
|
@@ -4691,6 +4986,7 @@
|
|
|
4691
4986
|
@media (prefers-reduced-motion: reduce) {
|
|
4692
4987
|
.vp-band,
|
|
4693
4988
|
.vp-osd,
|
|
4989
|
+
.vp-volume-osd,
|
|
4694
4990
|
.vp-zoom,
|
|
4695
4991
|
.vp-lane-span,
|
|
4696
4992
|
.vp-chip,
|
|
@@ -4836,7 +5132,8 @@
|
|
|
4836
5132
|
gap: var(--jatui-space-base, 8px);
|
|
4837
5133
|
}
|
|
4838
5134
|
|
|
4839
|
-
.vp-night-btn
|
|
5135
|
+
.vp-night-btn,
|
|
5136
|
+
.vp-theater-btn {
|
|
4840
5137
|
display: inline-flex;
|
|
4841
5138
|
align-items: center;
|
|
4842
5139
|
justify-content: center;
|
|
@@ -4849,11 +5146,13 @@
|
|
|
4849
5146
|
transition: color var(--motion-move, 300ms) var(--motion-glide, ease-out);
|
|
4850
5147
|
}
|
|
4851
5148
|
|
|
4852
|
-
.vp-night-btn:hover
|
|
5149
|
+
.vp-night-btn:hover,
|
|
5150
|
+
.vp-theater-btn:hover {
|
|
4853
5151
|
color: var(--color-base-content);
|
|
4854
5152
|
}
|
|
4855
5153
|
|
|
4856
|
-
.vp-night-btn:focus-visible
|
|
5154
|
+
.vp-night-btn:focus-visible,
|
|
5155
|
+
.vp-theater-btn:focus-visible {
|
|
4857
5156
|
outline: 2px solid var(--color-primary);
|
|
4858
5157
|
outline-offset: 1px;
|
|
4859
5158
|
}
|
|
@@ -4863,6 +5162,11 @@
|
|
|
4863
5162
|
color: var(--color-warning);
|
|
4864
5163
|
}
|
|
4865
5164
|
|
|
5165
|
+
/* Theater (jatui-ee2ty): lit in the primary while the page is widened. */
|
|
5166
|
+
.vp-theater-btn.active {
|
|
5167
|
+
color: var(--color-primary);
|
|
5168
|
+
}
|
|
5169
|
+
|
|
4866
5170
|
/* Under reduced motion the veil still dims — the dimming IS the feature —
|
|
4867
5171
|
but the long 900ms/1200ms ramps shrink to a short luminance fade. A
|
|
4868
5172
|
change in brightness is not motion, and a hard step every tick would be
|
|
@@ -4881,7 +5185,8 @@
|
|
|
4881
5185
|
transition-duration: 120ms;
|
|
4882
5186
|
}
|
|
4883
5187
|
|
|
4884
|
-
.vp-night-btn
|
|
5188
|
+
.vp-night-btn,
|
|
5189
|
+
.vp-theater-btn {
|
|
4885
5190
|
transition: none;
|
|
4886
5191
|
}
|
|
4887
5192
|
}
|
|
@@ -169,6 +169,29 @@ type $$ComponentProps = {
|
|
|
169
169
|
* beside it (`transcriptPosition: 'side'`), the player column's height.
|
|
170
170
|
*/
|
|
171
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;
|
|
172
195
|
crossorigin?: 'anonymous' | 'use-credentials' | null;
|
|
173
196
|
syncUrlTime?: boolean;
|
|
174
197
|
/**
|
|
@@ -424,6 +447,6 @@ type $$ComponentProps = {
|
|
|
424
447
|
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
|
425
448
|
bandState: () => BandState;
|
|
426
449
|
seekToTime: (sec: number) => void;
|
|
427
|
-
}, "night" | "nightDepth">;
|
|
450
|
+
}, "night" | "theater" | "nightDepth">;
|
|
428
451
|
type VideoPlayer = ReturnType<typeof VideoPlayer>;
|
|
429
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
|
@@ -87,6 +87,12 @@ export interface BandPaintState {
|
|
|
87
87
|
marker: string;
|
|
88
88
|
/** The light ribbon's wash (FEATURES.md: amber). Defaults to `marker`. */
|
|
89
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;
|
|
90
96
|
/** Comment dots. Defaults to `accent`. */
|
|
91
97
|
comment?: string;
|
|
92
98
|
/** Like heat strip. Defaults to `accent`. */
|
|
@@ -114,6 +120,10 @@ export declare function prepareCanvas(canvas: HTMLCanvasElement): CanvasRenderin
|
|
|
114
120
|
export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: number, s: BandPaintState): void;
|
|
115
121
|
/** Height of the accent rule along the top of a lit chapter span, px. */
|
|
116
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;
|
|
117
127
|
/**
|
|
118
128
|
* Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
|
|
119
129
|
* start (or the end of the video) — through the zoom window, clipped to the
|
package/dist/video/band.js
CHANGED
|
@@ -161,17 +161,22 @@ export function paintBand(ctx, W, H, s) {
|
|
|
161
161
|
}
|
|
162
162
|
ctx.globalAlpha = 1;
|
|
163
163
|
}
|
|
164
|
-
// The lit chapter (jatui-ap4oz
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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
|
|
167
168
|
// animates, so it is reduced-motion safe by construction.
|
|
168
169
|
const lit = chapterSpanPx(s.chapters, s.hoverChapter, s.window, dur, W);
|
|
169
170
|
if (lit) {
|
|
170
|
-
|
|
171
|
-
ctx.
|
|
172
|
-
ctx.
|
|
173
|
-
ctx.
|
|
174
|
-
ctx.
|
|
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);
|
|
175
180
|
ctx.globalAlpha = 1;
|
|
176
181
|
}
|
|
177
182
|
const p = s.peaks;
|
|
@@ -325,6 +330,10 @@ export function paintBand(ctx, W, H, s) {
|
|
|
325
330
|
}
|
|
326
331
|
/** Height of the accent rule along the top of a lit chapter span, px. */
|
|
327
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;
|
|
328
337
|
/**
|
|
329
338
|
* Pixel extent [x0, x1) of chapter `index` — its start to the next chapter's
|
|
330
339
|
* start (or the end of the video) — through the zoom window, clipped to the
|
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`).
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* video out of frame on load and on every new line. Setting the pane's own
|
|
9
9
|
* `scrollTop` cannot move anything else.
|
|
10
10
|
*
|
|
11
|
-
* Reader intent
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
18
|
*/
|
|
18
19
|
/**
|
|
19
20
|
* Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
|
|
@@ -26,7 +27,35 @@ export declare function nearestScrollTop(rowTop: number, rowBottom: number, scro
|
|
|
26
27
|
* moved. `smooth` is the caller's call — pass false under reduced motion.
|
|
27
28
|
*/
|
|
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
|
+
}
|
|
29
46
|
/**
|
|
30
|
-
* Listen for reader scroll intent on `pane`; `onIntent` fires on each one
|
|
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()`.
|
|
31
57
|
*/
|
|
32
|
-
export declare function watchReaderScroll(pane: HTMLElement, onIntent: () => void
|
|
58
|
+
export declare function watchReaderScroll(pane: HTMLElement, onIntent: () => void, opts?: {
|
|
59
|
+
windowMs?: number;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
}): ReaderScrollWatch;
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* video out of frame on load and on every new line. Setting the pane's own
|
|
9
9
|
* `scrollTop` cannot move anything else.
|
|
10
10
|
*
|
|
11
|
-
* Reader intent
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
18
|
*/
|
|
18
19
|
/**
|
|
19
20
|
* Where the pane should scroll so a row spanning [rowTop, rowBottom) (content
|
|
@@ -52,29 +53,88 @@ export function scrollRowIntoPane(pane, row, smooth, margin = 8) {
|
|
|
52
53
|
// Arrows, Space, Home and End belong to the player's keymap (seek, volume,
|
|
53
54
|
// play) — only the page keys are left to scroll the pane natively.
|
|
54
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;
|
|
55
62
|
/**
|
|
56
|
-
* Listen for reader scroll intent on `pane`; `onIntent` fires on each one
|
|
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()`.
|
|
57
73
|
*/
|
|
58
|
-
export function watchReaderScroll(pane, onIntent) {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
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
|
+
};
|
|
61
103
|
const key = (e) => {
|
|
62
104
|
if (SCROLL_KEYS.has(e.key))
|
|
63
|
-
|
|
105
|
+
arm();
|
|
64
106
|
};
|
|
65
107
|
// A press on the pane's own box beyond its client width is the scrollbar.
|
|
66
108
|
const down = (e) => {
|
|
67
|
-
if (e.target === pane && e.offsetX > pane.clientWidth)
|
|
68
|
-
|
|
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
|
|
69
119
|
};
|
|
70
|
-
pane.addEventListener('wheel',
|
|
71
|
-
pane.addEventListener('touchmove',
|
|
120
|
+
pane.addEventListener('wheel', arm, { passive: true });
|
|
121
|
+
pane.addEventListener('touchmove', arm, { passive: true });
|
|
72
122
|
pane.addEventListener('keydown', key);
|
|
73
123
|
pane.addEventListener('pointerdown', down);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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);
|
|
77
130
|
pane.removeEventListener('keydown', key);
|
|
78
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;
|
|
79
138
|
};
|
|
139
|
+
return stop;
|
|
80
140
|
}
|