@joewinke/jatui 0.38.4 → 0.38.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/dist/components/VideoPlayer.svelte +436 -11
- package/dist/video/keys.d.ts +5 -5
- package/dist/video/keys.js +9 -5
- package/dist/video/transcriptSearch.d.ts +80 -0
- package/dist/video/transcriptSearch.js +153 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -119,7 +119,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
|
|
|
119
119
|
| `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
|
|
120
120
|
| `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
|
|
121
121
|
| `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
|
|
122
|
-
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `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?` |
|
|
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; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). 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) |
|
|
@@ -167,6 +167,12 @@
|
|
|
167
167
|
import { dragCancel, dragEnd, dragMove, dragStart, type DragState } from '../video/band/drag';
|
|
168
168
|
import { createBandLayerCache } from '../video/band/memo';
|
|
169
169
|
import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
|
|
170
|
+
import {
|
|
171
|
+
highlightRowHtml,
|
|
172
|
+
lineScrubTarget,
|
|
173
|
+
pickMatch,
|
|
174
|
+
transcriptWheelDecision
|
|
175
|
+
} from '../video/transcriptSearch';
|
|
170
176
|
import {
|
|
171
177
|
adMaxStep,
|
|
172
178
|
adSkipTarget,
|
|
@@ -809,6 +815,7 @@
|
|
|
809
815
|
if (!v) return;
|
|
810
816
|
v.muted = !v.muted;
|
|
811
817
|
if (!v.muted && v.volume === 0) v.volume = 0.5;
|
|
818
|
+
showVolumeOsd(v.volume, v.muted);
|
|
812
819
|
}
|
|
813
820
|
function onVolumeInput(e: Event) {
|
|
814
821
|
const v = videoEl;
|
|
@@ -816,6 +823,7 @@
|
|
|
816
823
|
const next = Number((e.currentTarget as HTMLInputElement).value);
|
|
817
824
|
v.volume = Math.min(1, Math.max(0, next));
|
|
818
825
|
v.muted = next === 0;
|
|
826
|
+
showVolumeOsd(v.volume, v.muted);
|
|
819
827
|
}
|
|
820
828
|
function toggleFullscreen() {
|
|
821
829
|
if (typeof document === 'undefined') return;
|
|
@@ -858,6 +866,16 @@
|
|
|
858
866
|
let osd = $state(''); // transient wheel/keyboard readout (volume / seek / speed)
|
|
859
867
|
let osdOn = $state(false);
|
|
860
868
|
|
|
869
|
+
// ── Volume OSD (jatui-pu698) ────────────────────────────────────────────
|
|
870
|
+
// Wheel/keyboard volume feedback: a speaker glyph + horizontal level bar
|
|
871
|
+
// + % value (or "Muted" at 0), floated over the stage. Distinct from the
|
|
872
|
+
// tiny corner readout: a volume change is a physical gesture that deserves
|
|
873
|
+
// to be SEEN, not just a number blink.
|
|
874
|
+
let volOsdOn = $state(false);
|
|
875
|
+
let volOsdVolume = $state(0);
|
|
876
|
+
let volOsdMuted = $state(false);
|
|
877
|
+
let volOsdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
878
|
+
|
|
861
879
|
// ── Speaker filter state ────────────────────────────────────────────────
|
|
862
880
|
let soloKey = $state(ALL_KEY);
|
|
863
881
|
let mutedKeys = $state(new Set<string>());
|
|
@@ -875,6 +893,18 @@
|
|
|
875
893
|
let transcriptVisible = $state(true);
|
|
876
894
|
let keysOpen = $state(false);
|
|
877
895
|
|
|
896
|
+
// ── Transcript search (jatui-xmtnv) ────────────────────────────────
|
|
897
|
+
// `/` opens a search box over the transcript pane; matches highlight in
|
|
898
|
+
// place, Enter jumps to the next match (Shift+Enter back), Esc closes.
|
|
899
|
+
// The page omnibar's own `/` yields while focus is inside the player:
|
|
900
|
+
// the claiming keydown's propagation is stopped, and the search box
|
|
901
|
+
// stops its own keydowns so typing never leaks into the player map or
|
|
902
|
+
// the page.
|
|
903
|
+
let searchOpen = $state(false);
|
|
904
|
+
let searchQuery = $state('');
|
|
905
|
+
let searchMatchIdx = $state(-1);
|
|
906
|
+
let searchInputEl: HTMLInputElement | undefined = $state();
|
|
907
|
+
|
|
878
908
|
let zoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|
879
909
|
let osdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
880
910
|
|
|
@@ -1170,6 +1200,23 @@
|
|
|
1170
1200
|
const sortedSegments = $derived(sortSegments(transcript));
|
|
1171
1201
|
const sortedBookmarks = $derived(sortBookmarks(bookmarks));
|
|
1172
1202
|
|
|
1203
|
+
// ── Transcript search deriveds (jatui-xmtnv) ─────────────────────────
|
|
1204
|
+
/** Live only while the box is open with a non-empty query. */
|
|
1205
|
+
const searchActive = $derived(searchOpen && searchQuery.trim().length > 0);
|
|
1206
|
+
/** Matching rows in the pane's own play order — count and jump order
|
|
1207
|
+
* agree with what's on screen. */
|
|
1208
|
+
const searchMatches = $derived.by(() => {
|
|
1209
|
+
if (!searchActive) return [];
|
|
1210
|
+
const q = searchQuery.trim().toLowerCase();
|
|
1211
|
+
return sortedSegments.filter((s) => s.text.toLowerCase().includes(q));
|
|
1212
|
+
});
|
|
1213
|
+
/** The match the Enter-cursor is on — wears the distinct "current" mark. */
|
|
1214
|
+
const searchCurrentId = $derived(
|
|
1215
|
+
searchMatchIdx >= 0 && searchMatchIdx < searchMatches.length
|
|
1216
|
+
? searchMatches[searchMatchIdx].id
|
|
1217
|
+
: null
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1173
1220
|
/** Peaks normalised to 0–1 against their own max, so 0–1, 0–99 and 0–255 inputs all work. */
|
|
1174
1221
|
const peaks = $derived.by(() => {
|
|
1175
1222
|
const raw = waveform?.peaks;
|
|
@@ -1382,6 +1429,24 @@
|
|
|
1382
1429
|
zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
|
|
1383
1430
|
}
|
|
1384
1431
|
|
|
1432
|
+
// ── Volume OSD (jatui-pu698) ────────────────────────────────────────────
|
|
1433
|
+
// Big, legible volume indicator: speaker glyph + level bar + % ("Muted" at
|
|
1434
|
+
// 0). Replaces the tiny corner badge for wheel/keyboard volume gestures.
|
|
1435
|
+
// Shares `osdTimer` with the plain OSD so only ONE readout floats at a
|
|
1436
|
+
// time, and any pending plain flash is superseded (not queued behind).
|
|
1437
|
+
function showVolumeOsd(volume: number, muted: boolean) {
|
|
1438
|
+
if (volOsdTimer) clearTimeout(volOsdTimer);
|
|
1439
|
+
if (osdTimer) {
|
|
1440
|
+
clearTimeout(osdTimer);
|
|
1441
|
+
osdTimer = null;
|
|
1442
|
+
}
|
|
1443
|
+
osdOn = false; // only one readout at a time
|
|
1444
|
+
volOsdVolume = volume;
|
|
1445
|
+
volOsdMuted = muted;
|
|
1446
|
+
volOsdOn = true;
|
|
1447
|
+
volOsdTimer = setTimeout(() => (volOsdOn = false), 800);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1385
1450
|
function flashOsd(text: string, ms = 900) {
|
|
1386
1451
|
if (osdTimer) clearTimeout(osdTimer);
|
|
1387
1452
|
if (!text || ms <= 0) {
|
|
@@ -2779,7 +2844,11 @@
|
|
|
2779
2844
|
return;
|
|
2780
2845
|
}
|
|
2781
2846
|
if (e.shiftKey) {
|
|
2782
|
-
|
|
2847
|
+
// Chrome on Linux/Windows and macOS turn Shift+wheel into a
|
|
2848
|
+
// HORIZONTAL scroll, so deltaY is 0 and the value sits in deltaX.
|
|
2849
|
+
// Use the effective delta to drive the seek step direction.
|
|
2850
|
+
const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
|
|
2851
|
+
const delta = d < 0 ? 5 : -5;
|
|
2783
2852
|
seekTo(currentTime + delta);
|
|
2784
2853
|
flashOsd(`${delta > 0 ? '+' : ''}${delta}s · ${formatTime(currentTime + delta, useHours)}`);
|
|
2785
2854
|
return;
|
|
@@ -2787,7 +2856,7 @@
|
|
|
2787
2856
|
const next = Math.min(1, Math.max(0, videoEl.volume - Math.sign(e.deltaY) * 0.05));
|
|
2788
2857
|
videoEl.volume = next;
|
|
2789
2858
|
if (next > 0) videoEl.muted = false;
|
|
2790
|
-
|
|
2859
|
+
showVolumeOsd(next, next === 0);
|
|
2791
2860
|
}
|
|
2792
2861
|
|
|
2793
2862
|
/**
|
|
@@ -2828,6 +2897,22 @@
|
|
|
2828
2897
|
return;
|
|
2829
2898
|
}
|
|
2830
2899
|
|
|
2900
|
+
// The transcript search (jatui-xmtnv) owns Escape while it's open —
|
|
2901
|
+
// even when focus has left its input — and re-focuses on `/`; every
|
|
2902
|
+
// other key waits so typing never drives the player.
|
|
2903
|
+
if (searchOpen) {
|
|
2904
|
+
if (e.key === 'Escape') {
|
|
2905
|
+
e.preventDefault();
|
|
2906
|
+
e.stopPropagation();
|
|
2907
|
+
closeSearch();
|
|
2908
|
+
} else if (e.key === '/') {
|
|
2909
|
+
e.preventDefault();
|
|
2910
|
+
e.stopPropagation(); // the omnibar yields while focus is inside the player
|
|
2911
|
+
searchInputEl?.focus();
|
|
2912
|
+
}
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2831
2916
|
switch (e.key) {
|
|
2832
2917
|
case '?':
|
|
2833
2918
|
e.preventDefault();
|
|
@@ -2853,15 +2938,15 @@
|
|
|
2853
2938
|
case 'ArrowUp':
|
|
2854
2939
|
v.volume = Math.min(1, v.volume + 0.05);
|
|
2855
2940
|
if (v.volume > 0) v.muted = false;
|
|
2856
|
-
|
|
2941
|
+
showVolumeOsd(v.volume, v.muted);
|
|
2857
2942
|
break;
|
|
2858
2943
|
case 'ArrowDown':
|
|
2859
2944
|
v.volume = Math.max(0, v.volume - 0.05);
|
|
2860
|
-
|
|
2945
|
+
showVolumeOsd(v.volume, v.muted);
|
|
2861
2946
|
break;
|
|
2862
2947
|
case 'm':
|
|
2863
2948
|
v.muted = !v.muted;
|
|
2864
|
-
|
|
2949
|
+
showVolumeOsd(v.volume, v.muted);
|
|
2865
2950
|
break;
|
|
2866
2951
|
case '-':
|
|
2867
2952
|
case '_':
|
|
@@ -2938,6 +3023,16 @@
|
|
|
2938
3023
|
flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
|
|
2939
3024
|
}
|
|
2940
3025
|
return;
|
|
3026
|
+
case '/':
|
|
3027
|
+
// Transcript search (jatui-xmtnv). The omnibar yields: focus is
|
|
3028
|
+
// inside the player, so this keydown never reaches the page's
|
|
3029
|
+
// own `/` handler (stopPropagation below the player root).
|
|
3030
|
+
if (sortedSegments.length > 0) {
|
|
3031
|
+
e.preventDefault();
|
|
3032
|
+
e.stopPropagation();
|
|
3033
|
+
openSearch();
|
|
3034
|
+
}
|
|
3035
|
+
return;
|
|
2941
3036
|
case 'n':
|
|
2942
3037
|
case 'N':
|
|
2943
3038
|
if (night !== undefined) {
|
|
@@ -3076,6 +3171,88 @@
|
|
|
3076
3171
|
seekTo(seg.startSec);
|
|
3077
3172
|
}
|
|
3078
3173
|
|
|
3174
|
+
// ── Transcript search (jatui-xmtnv) ───────────────────────────────────
|
|
3175
|
+
/** `/`: open the box (showing the transcript if it was hidden) and hand
|
|
3176
|
+
* it focus once it mounts — the effect below lands it. */
|
|
3177
|
+
function openSearch() {
|
|
3178
|
+
if (sortedSegments.length === 0) return;
|
|
3179
|
+
transcriptVisible = true;
|
|
3180
|
+
searchOpen = true;
|
|
3181
|
+
searchMatchIdx = -1;
|
|
3182
|
+
}
|
|
3183
|
+
/** Esc / the × button: close, clear, and give the keys back to the map. */
|
|
3184
|
+
function closeSearch() {
|
|
3185
|
+
searchOpen = false;
|
|
3186
|
+
searchQuery = '';
|
|
3187
|
+
searchMatchIdx = -1;
|
|
3188
|
+
rootEl?.focus();
|
|
3189
|
+
}
|
|
3190
|
+
/** Enter (next) / Shift+Enter (previous), wrapping. With no cursor yet a
|
|
3191
|
+
* search opened mid-talk starts from the match at/after the playhead. */
|
|
3192
|
+
function jumpSearch(direction: 1 | -1) {
|
|
3193
|
+
const matches = searchMatches;
|
|
3194
|
+
if (matches.length === 0) return;
|
|
3195
|
+
searchMatchIdx = pickMatch(
|
|
3196
|
+
matches.map((m) => m.startSec),
|
|
3197
|
+
currentTime,
|
|
3198
|
+
direction,
|
|
3199
|
+
searchMatchIdx
|
|
3200
|
+
);
|
|
3201
|
+
seekTo(matches[searchMatchIdx].startSec);
|
|
3202
|
+
}
|
|
3203
|
+
/** The box owns its keys — never leak into the player map or the page
|
|
3204
|
+
* omnibar while it's open (typing `k` must not pause, `/` must not
|
|
3205
|
+
* re-open the omnibar). */
|
|
3206
|
+
function onSearchKeydown(e: KeyboardEvent) {
|
|
3207
|
+
e.stopPropagation();
|
|
3208
|
+
if (e.key === 'Enter') {
|
|
3209
|
+
e.preventDefault();
|
|
3210
|
+
jumpSearch(e.shiftKey ? -1 : 1);
|
|
3211
|
+
} else if (e.key === 'Escape') {
|
|
3212
|
+
e.preventDefault();
|
|
3213
|
+
closeSearch();
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
// Focus + select the box when it opens (the input mounts with it).
|
|
3217
|
+
$effect(() => {
|
|
3218
|
+
if (searchOpen && searchInputEl) {
|
|
3219
|
+
searchInputEl.focus();
|
|
3220
|
+
searchInputEl.select();
|
|
3221
|
+
}
|
|
3222
|
+
});
|
|
3223
|
+
|
|
3224
|
+
// ── Transcript wheel scrub (jatui-xmtnv) ─────────────────────────────────
|
|
3225
|
+
// A plain wheel over the transcript pane scrolls it natively — exactly as
|
|
3226
|
+
// before (the reader's own scroll, follow-intent logic untouched —
|
|
3227
|
+
// jatui-ap4oz/ee2ty); preventDefault is NEVER taken for it, so the page is
|
|
3228
|
+
// never trapped. Shift+wheel steps PLAYBACK one line at a time (extension
|
|
3229
|
+
// library.js:8580 parity, matching Shift+wheel over the video = seek): the
|
|
3230
|
+
// pure decision in video/transcriptSearch.ts accumulates deltas so one
|
|
3231
|
+
// notch is one line — a mouse wheel's ~100px notches step exactly once
|
|
3232
|
+
// each and a trackpad's stream of small deltas never flies through the
|
|
3233
|
+
// transcript. Seeking makes the stepped line active, so the pane follows
|
|
3234
|
+
// it (the follow's own scrolls are never intent).
|
|
3235
|
+
let lineWheelAccum = 0;
|
|
3236
|
+
function onTranscriptWheel(e: WheelEvent) {
|
|
3237
|
+
const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
|
|
3238
|
+
const decision = transcriptWheelDecision(
|
|
3239
|
+
e.shiftKey,
|
|
3240
|
+
sortedSegments.length > 0,
|
|
3241
|
+
d,
|
|
3242
|
+
lineWheelAccum
|
|
3243
|
+
);
|
|
3244
|
+
if (!decision.claimed) return; // plain wheel = native pane scroll
|
|
3245
|
+
e.preventDefault();
|
|
3246
|
+
lineWheelAccum = decision.nextAccum;
|
|
3247
|
+
if (!decision.shouldStep) return;
|
|
3248
|
+
const target = lineScrubTarget(
|
|
3249
|
+
currentTime,
|
|
3250
|
+
sortedSegments.map((s) => s.startSec),
|
|
3251
|
+
decision.direction
|
|
3252
|
+
);
|
|
3253
|
+
if (target != null) seekTo(target);
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3079
3256
|
// No active line (seeked past the transcript) ⇒ nothing to jump to.
|
|
3080
3257
|
$effect(() => {
|
|
3081
3258
|
if (!activeSegment && followPaused) untrack(() => (followPaused = false));
|
|
@@ -3135,6 +3312,7 @@
|
|
|
3135
3312
|
class:vp-rail-side={railOn}
|
|
3136
3313
|
class:vp-one-axis={nativeBarHidden}
|
|
3137
3314
|
class:vp-band-transport={bandTransport}
|
|
3315
|
+
class:vp-vol-flash={volOsdOn}
|
|
3138
3316
|
class:vp-audio={isAudio}
|
|
3139
3317
|
class:vp-night-capable={nightPresent}
|
|
3140
3318
|
class:vp-night-surround={surroundMix > 0.001}
|
|
@@ -3255,10 +3433,41 @@
|
|
|
3255
3433
|
<div class="vp-wind-veil" aria-hidden="true"></div>
|
|
3256
3434
|
{/if}
|
|
3257
3435
|
<div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
3258
|
-
|
|
3259
|
-
|
|
3436
|
+
<!-- Volume OSD (jatui-pu698): speaker glyph + level bar + %, centred
|
|
3437
|
+
near the top of the stage so a wheel/keyboard volume gesture is
|
|
3438
|
+
SEEN, not just a corner number blink. aria-hidden: the volume
|
|
3439
|
+
slider itself already reports the value to AT. -->
|
|
3440
|
+
<div class="vp-volume-osd" class:on={volOsdOn} aria-hidden="true">
|
|
3441
|
+
<svg
|
|
3442
|
+
class="vp-vol-glyph"
|
|
3443
|
+
viewBox="0 0 24 24"
|
|
3444
|
+
width="24"
|
|
3445
|
+
height="24"
|
|
3446
|
+
fill="none"
|
|
3447
|
+
stroke="currentColor"
|
|
3448
|
+
stroke-width="1.8"
|
|
3449
|
+
stroke-linecap="round"
|
|
3450
|
+
stroke-linejoin="round"
|
|
3451
|
+
>
|
|
3452
|
+
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
|
|
3453
|
+
{#if volOsdMuted || volOsdVolume === 0}
|
|
3454
|
+
<path d="M16 9.5l5 5M21 9.5l-5 5" />
|
|
3455
|
+
{:else}
|
|
3456
|
+
<path d="M15.5 9a4 4 0 0 1 0 6" />
|
|
3457
|
+
{#if volOsdVolume > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
|
|
3458
|
+
{/if}
|
|
3459
|
+
</svg>
|
|
3460
|
+
<span class="vp-vol-bar">
|
|
3461
|
+
<span class="vp-vol-fill" style="width: {Math.round(volOsdVolume * 100)}%"></span>
|
|
3462
|
+
</span>
|
|
3463
|
+
<span class="vp-vol-text">
|
|
3464
|
+
{volOsdMuted || volOsdVolume === 0 ? 'Muted' : `${Math.round(volOsdVolume * 100)}%`}
|
|
3465
|
+
</span>
|
|
3466
|
+
</div>
|
|
3467
|
+
</div>
|
|
3468
|
+
</div>
|
|
3260
3469
|
|
|
3261
|
-
|
|
3470
|
+
<!-- The band: one time axis for the envelope, chapters, speakers and
|
|
3262
3471
|
bookmarks. Its height is reserved from the first frame, so data
|
|
3263
3472
|
arriving late fades into already-owned space. -->
|
|
3264
3473
|
<div
|
|
@@ -3349,7 +3558,34 @@
|
|
|
3349
3558
|
{#if isAudio}
|
|
3350
3559
|
<!-- No stage to float the readout over: it rides the band. -->
|
|
3351
3560
|
<div class="vp-osd vp-osd-band" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
3352
|
-
|
|
3561
|
+
<div class="vp-volume-osd vp-volume-osd-band" class:on={volOsdOn} aria-hidden="true">
|
|
3562
|
+
<svg
|
|
3563
|
+
class="vp-vol-glyph"
|
|
3564
|
+
viewBox="0 0 24 24"
|
|
3565
|
+
width="24"
|
|
3566
|
+
height="24"
|
|
3567
|
+
fill="none"
|
|
3568
|
+
stroke="currentColor"
|
|
3569
|
+
stroke-width="1.8"
|
|
3570
|
+
stroke-linecap="round"
|
|
3571
|
+
stroke-linejoin="round"
|
|
3572
|
+
>
|
|
3573
|
+
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
|
|
3574
|
+
{#if volOsdMuted || volOsdVolume === 0}
|
|
3575
|
+
<path d="M16 9.5l5 5M21 9.5l-5 5" />
|
|
3576
|
+
{:else}
|
|
3577
|
+
<path d="M15.5 9a4 4 0 0 1 0 6" />
|
|
3578
|
+
{#if volOsdVolume > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
|
|
3579
|
+
{/if}
|
|
3580
|
+
</svg>
|
|
3581
|
+
<span class="vp-vol-bar">
|
|
3582
|
+
<span class="vp-vol-fill" style="width: {Math.round(volOsdVolume * 100)}%"></span>
|
|
3583
|
+
</span>
|
|
3584
|
+
<span class="vp-vol-text">
|
|
3585
|
+
{volOsdMuted || volOsdVolume === 0 ? 'Muted' : `${Math.round(volOsdVolume * 100)}%`}
|
|
3586
|
+
</span>
|
|
3587
|
+
</div>
|
|
3588
|
+
{/if}
|
|
3353
3589
|
|
|
3354
3590
|
{#if hover}
|
|
3355
3591
|
<div
|
|
@@ -3706,10 +3942,42 @@
|
|
|
3706
3942
|
style:--vp-transcript-max={transcriptMaxHeight}
|
|
3707
3943
|
style:--vp-transcript-side-max={!transcriptMaxHeight && mainHeight > 0 ? `${mainHeight}px` : undefined}
|
|
3708
3944
|
>
|
|
3945
|
+
{#if searchOpen}
|
|
3946
|
+
<!-- Transcript search (jatui-xmtnv): `/` opened it. Enter jumps to
|
|
3947
|
+
the next match (Shift+Enter back), Esc closes; the count is
|
|
3948
|
+
aria-live so a screen reader hears match N of M. -->
|
|
3949
|
+
<div class="vp-search">
|
|
3950
|
+
<input
|
|
3951
|
+
class="vp-search-input"
|
|
3952
|
+
type="text"
|
|
3953
|
+
placeholder="Search transcript"
|
|
3954
|
+
aria-label="Search transcript"
|
|
3955
|
+
spellcheck="false"
|
|
3956
|
+
bind:value={searchQuery}
|
|
3957
|
+
bind:this={searchInputEl}
|
|
3958
|
+
oninput={() => (searchMatchIdx = -1)}
|
|
3959
|
+
onkeydown={onSearchKeydown}
|
|
3960
|
+
/>
|
|
3961
|
+
<span class="vp-search-count" aria-live="polite">
|
|
3962
|
+
{searchMatches.length === 0
|
|
3963
|
+
? (searchActive ? 'No matches' : '')
|
|
3964
|
+
: searchMatchIdx >= 0
|
|
3965
|
+
? `${searchMatchIdx + 1} / ${searchMatches.length}`
|
|
3966
|
+
: `${searchMatches.length}`}
|
|
3967
|
+
</span>
|
|
3968
|
+
<button
|
|
3969
|
+
type="button"
|
|
3970
|
+
class="vp-search-close"
|
|
3971
|
+
aria-label="Close search"
|
|
3972
|
+
onclick={closeSearch}
|
|
3973
|
+
>×</button>
|
|
3974
|
+
</div>
|
|
3975
|
+
{/if}
|
|
3709
3976
|
<div
|
|
3710
3977
|
class="vp-transcript"
|
|
3711
3978
|
use:transcriptPaneAction
|
|
3712
3979
|
onscroll={onTranscriptScroll}
|
|
3980
|
+
onwheel={onTranscriptWheel}
|
|
3713
3981
|
>
|
|
3714
3982
|
{#if transWindow}
|
|
3715
3983
|
<!-- Windowed: only the rows near the viewport exist in the DOM.
|
|
@@ -3734,7 +4002,15 @@
|
|
|
3734
4002
|
{seg.speakerName ?? 'Unknown speaker'}
|
|
3735
4003
|
</span>
|
|
3736
4004
|
{/if}
|
|
3737
|
-
<span class="vp-row-text">
|
|
4005
|
+
<span class="vp-row-text">
|
|
4006
|
+
{#if searchActive}
|
|
4007
|
+
<!-- Search marks are safe HTML: highlightRowHtml escapes
|
|
4008
|
+
everything but its own <mark> tags (jatui-xmtnv). -->
|
|
4009
|
+
{@html highlightRowHtml(seg.text, searchQuery, searchCurrentId === seg.id)}
|
|
4010
|
+
{:else}
|
|
4011
|
+
{seg.text}
|
|
4012
|
+
{/if}
|
|
4013
|
+
</span>
|
|
3738
4014
|
</button>
|
|
3739
4015
|
{/each}
|
|
3740
4016
|
<div class="vp-spacer" style="height: {transWindow.padBottom}px" aria-hidden="true"></div>
|
|
@@ -3757,7 +4033,13 @@
|
|
|
3757
4033
|
{seg.speakerName ?? 'Unknown speaker'}
|
|
3758
4034
|
</span>
|
|
3759
4035
|
{/if}
|
|
3760
|
-
<span class="vp-row-text">
|
|
4036
|
+
<span class="vp-row-text">
|
|
4037
|
+
{#if searchActive}
|
|
4038
|
+
{@html highlightRowHtml(seg.text, searchQuery, searchCurrentId === seg.id)}
|
|
4039
|
+
{:else}
|
|
4040
|
+
{seg.text}
|
|
4041
|
+
{/if}
|
|
4042
|
+
</span>
|
|
3761
4043
|
</button>
|
|
3762
4044
|
{/each}
|
|
3763
4045
|
{/if}
|
|
@@ -4146,6 +4428,64 @@
|
|
|
4146
4428
|
opacity: 1;
|
|
4147
4429
|
}
|
|
4148
4430
|
|
|
4431
|
+
/* ── Volume OSD (jatui-pu698) ───────────────────────────────────────── */
|
|
4432
|
+
/* Wheel/keyboard volume feedback: speaker glyph + level bar + %, centred
|
|
4433
|
+
near the top of the stage so a gesture is SEEN, not just a corner number
|
|
4434
|
+
blink. Token surface with backdrop so it is legible on any frame. The
|
|
4435
|
+
flash is purely opacity — no slide — so it is reduced-motion safe. */
|
|
4436
|
+
.vp-volume-osd {
|
|
4437
|
+
position: absolute;
|
|
4438
|
+
top: 10%;
|
|
4439
|
+
left: 50%;
|
|
4440
|
+
transform: translateX(-50%);
|
|
4441
|
+
display: flex;
|
|
4442
|
+
align-items: center;
|
|
4443
|
+
gap: 10px;
|
|
4444
|
+
padding: 10px 16px;
|
|
4445
|
+
border-radius: 999px;
|
|
4446
|
+
background: color-mix(in oklch, var(--color-base-100) 85%, transparent);
|
|
4447
|
+
backdrop-filter: blur(8px);
|
|
4448
|
+
color: var(--color-base-content);
|
|
4449
|
+
font-size: 0.9rem;
|
|
4450
|
+
font-weight: 600;
|
|
4451
|
+
font-variant-numeric: tabular-nums;
|
|
4452
|
+
pointer-events: none;
|
|
4453
|
+
opacity: 0;
|
|
4454
|
+
transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
|
|
4455
|
+
z-index: 10;
|
|
4456
|
+
box-shadow: 0 4px 16px color-mix(in oklch, black 20%, transparent);
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
.vp-volume-osd.on {
|
|
4460
|
+
opacity: 1;
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
.vp-vol-glyph {
|
|
4464
|
+
flex: none;
|
|
4465
|
+
opacity: 0.9;
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
.vp-vol-bar {
|
|
4469
|
+
display: block;
|
|
4470
|
+
width: 80px;
|
|
4471
|
+
height: 4px;
|
|
4472
|
+
border-radius: 999px;
|
|
4473
|
+
background: color-mix(in oklch, var(--color-base-content) 20%, transparent);
|
|
4474
|
+
overflow: hidden;
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
.vp-vol-fill {
|
|
4478
|
+
display: block;
|
|
4479
|
+
height: 100%;
|
|
4480
|
+
background: var(--color-primary);
|
|
4481
|
+
border-radius: 999px;
|
|
4482
|
+
transition: width 150ms ease-out;
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
.vp-vol-text {
|
|
4486
|
+
min-width: 3.5rem;
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4149
4489
|
/* The band owns its height from the first frame so a waveform or speaker
|
|
4150
4490
|
turns arriving minutes in fade into space that is already theirs. */
|
|
4151
4491
|
.vp-band {
|
|
@@ -4162,6 +4502,17 @@
|
|
|
4162
4502
|
opacity: 1;
|
|
4163
4503
|
}
|
|
4164
4504
|
|
|
4505
|
+
/* Volume flash (jatui-pu698): reveal the transport and its volume slider
|
|
4506
|
+
on a wheel/keyboard volume change. */
|
|
4507
|
+
.vp-vol-flash .vp-transport {
|
|
4508
|
+
opacity: 1;
|
|
4509
|
+
visibility: visible;
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
.vp-vol-flash .vp-volume {
|
|
4513
|
+
opacity: 1;
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4165
4516
|
/* Audio: no picture, so no stage at all. The element still plays. */
|
|
4166
4517
|
.vp-stage-audio {
|
|
4167
4518
|
display: none;
|
|
@@ -4751,6 +5102,79 @@
|
|
|
4751
5102
|
}
|
|
4752
5103
|
}
|
|
4753
5104
|
|
|
5105
|
+
/* Transcript search (jatui-xmtnv): a slim bar over the pane's top edge.
|
|
5106
|
+
Focus ring on the input only; marks tint with the primary token, and
|
|
5107
|
+
the cursor's match is filled so "which match am I on" is legible at a
|
|
5108
|
+
glance. */
|
|
5109
|
+
.vp-search {
|
|
5110
|
+
position: absolute;
|
|
5111
|
+
top: 0;
|
|
5112
|
+
left: 0;
|
|
5113
|
+
right: 0;
|
|
5114
|
+
z-index: 3;
|
|
5115
|
+
display: flex;
|
|
5116
|
+
align-items: center;
|
|
5117
|
+
gap: var(--jatui-space-sm, 4px);
|
|
5118
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-md, 6px);
|
|
5119
|
+
background: var(--color-base-100);
|
|
5120
|
+
border: var(--jatui-border-w, 1px) solid
|
|
5121
|
+
color-mix(in oklch, var(--color-primary) 45%, transparent);
|
|
5122
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
5123
|
+
}
|
|
5124
|
+
|
|
5125
|
+
.vp-search-input {
|
|
5126
|
+
flex: 1 1 auto;
|
|
5127
|
+
min-width: 0;
|
|
5128
|
+
background: none;
|
|
5129
|
+
border: none;
|
|
5130
|
+
outline: none;
|
|
5131
|
+
color: var(--color-base-content);
|
|
5132
|
+
font-size: 0.8125rem;
|
|
5133
|
+
padding: 0;
|
|
5134
|
+
}
|
|
5135
|
+
|
|
5136
|
+
.vp-search-input::placeholder {
|
|
5137
|
+
color: color-mix(in oklch, var(--color-base-content) 45%, transparent);
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
.vp-search-count {
|
|
5141
|
+
flex: none;
|
|
5142
|
+
font-family: ui-monospace, monospace;
|
|
5143
|
+
font-size: 0.66rem;
|
|
5144
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
5145
|
+
}
|
|
5146
|
+
|
|
5147
|
+
.vp-search-close {
|
|
5148
|
+
flex: none;
|
|
5149
|
+
background: none;
|
|
5150
|
+
border: none;
|
|
5151
|
+
color: var(--color-base-content);
|
|
5152
|
+
cursor: pointer;
|
|
5153
|
+
padding: 0 var(--jatui-space-sm, 4px);
|
|
5154
|
+
font-size: 0.9rem;
|
|
5155
|
+
line-height: 1;
|
|
5156
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5159
|
+
.vp-search-close:hover {
|
|
5160
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
5161
|
+
}
|
|
5162
|
+
|
|
5163
|
+
/* Match highlight inside a transcript row — an <mark> tag, so UA styling
|
|
5164
|
+
is overridden with the token surface. The cursor's match is filled with
|
|
5165
|
+
the primary token; the rest is a softer tint. */
|
|
5166
|
+
.vp-row-text :global(mark.vp-search-mark) {
|
|
5167
|
+
background: color-mix(in oklch, var(--color-primary) 20%, transparent);
|
|
5168
|
+
color: inherit;
|
|
5169
|
+
border-radius: 2px;
|
|
5170
|
+
padding: 0 1px;
|
|
5171
|
+
}
|
|
5172
|
+
|
|
5173
|
+
.vp-row-text :global(mark.vp-search-mark.current) {
|
|
5174
|
+
background: var(--color-primary);
|
|
5175
|
+
color: var(--color-primary-content);
|
|
5176
|
+
}
|
|
5177
|
+
|
|
4754
5178
|
/* Keyboard help overlay — the `?` surface. Keycaps first, description after;
|
|
4755
5179
|
token-only colours, same chrome family as the hover card. */
|
|
4756
5180
|
.vp-keys-backdrop {
|
|
@@ -4828,6 +5252,7 @@
|
|
|
4828
5252
|
@media (prefers-reduced-motion: reduce) {
|
|
4829
5253
|
.vp-band,
|
|
4830
5254
|
.vp-osd,
|
|
5255
|
+
.vp-volume-osd,
|
|
4831
5256
|
.vp-zoom,
|
|
4832
5257
|
.vp-lane-span,
|
|
4833
5258
|
.vp-chip,
|
package/dist/video/keys.d.ts
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
|
|
9
9
|
* (t is theater mode, YouTube's key — jatui-ee2ty), ?
|
|
10
|
-
* shortcuts, Home/End
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* and `,` (settings) with the settings panel
|
|
14
|
-
* the host opting in.
|
|
10
|
+
* shortcuts, Home/End, and `/` transcript search (the pane has none yet).
|
|
11
|
+
* Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
|
|
12
|
+
* not player verbs). N (night cycle) landed with the night module
|
|
13
|
+
* (mos-5lzns.33) and `,` (settings) with the settings panel
|
|
14
|
+
* (mos-5lzns.35), each gated on the host opting in.
|
|
15
15
|
*/
|
|
16
16
|
export interface PlayerKeyRow {
|
|
17
17
|
/** Keycap label as shown in the help overlay — `/`-separated alternates. */
|
package/dist/video/keys.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, Shift+T transcript
|
|
9
9
|
* (t is theater mode, YouTube's key — jatui-ee2ty), ?
|
|
10
|
-
* shortcuts, Home/End
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* and `,` (settings) with the settings panel
|
|
14
|
-
* the host opting in.
|
|
10
|
+
* shortcuts, Home/End, and `/` transcript search (the pane has none yet).
|
|
11
|
+
* Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
|
|
12
|
+
* not player verbs). N (night cycle) landed with the night module
|
|
13
|
+
* (mos-5lzns.33) and `,` (settings) with the settings panel
|
|
14
|
+
* (mos-5lzns.35), each gated on the host opting in.
|
|
15
15
|
*/
|
|
16
16
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
17
17
|
export const PLAYER_KEYS = [
|
|
@@ -34,6 +34,10 @@ export const PLAYER_KEYS = [
|
|
|
34
34
|
// t = theater, YouTube's key; the transcript moved to Shift+T (jatui-ee2ty).
|
|
35
35
|
{ keys: 't', description: 'Theater mode on / off', when: 'theater' },
|
|
36
36
|
{ keys: 'Shift+T', description: 'Show / hide the transcript', when: 'transcript' },
|
|
37
|
+
{ keys: '/', description: 'Search transcript', when: 'transcript' },
|
|
38
|
+
// Shift+wheel over the transcript pane steps PLAYBACK line by line —
|
|
39
|
+
// a plain wheel there scrolls the pane natively and is never claimed.
|
|
40
|
+
{ keys: 'Shift+wheel', description: 'Over the transcript: steps line by line', when: 'transcript' },
|
|
37
41
|
{ keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
|
|
38
42
|
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
39
43
|
{ keys: ',', description: 'Settings', when: 'settings' },
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript search + wheel line-scrub (jatui-xmtnv — extension-library
|
|
3
|
+
* parity, mos-telti). Pure, DOM-free helpers the component calls; no module
|
|
4
|
+
* here reads settings or touches the <video> element (the advisor doctrine,
|
|
5
|
+
* src/lib/video/README.md).
|
|
6
|
+
*
|
|
7
|
+
* Two features:
|
|
8
|
+
*
|
|
9
|
+
* 1. `/` opens a search box over the transcript pane. Matches highlight in
|
|
10
|
+
* place; Enter jumps to the next match (Shift+Enter the previous one,
|
|
11
|
+
* wrapping); Esc closes. The page omnibar's own `/` yields while focus is
|
|
12
|
+
* inside the player — the component stops the keydown's propagation when
|
|
13
|
+
* it claims the key.
|
|
14
|
+
* 2. The wheel over the transcript pane steps PLAYBACK one line at a time —
|
|
15
|
+
* not the pane's scroll. Deltas accumulate so one notch is one line: a
|
|
16
|
+
* mouse wheel's ~100px notches step exactly once each, and a trackpad's
|
|
17
|
+
* stream of small deltas never flies through the transcript.
|
|
18
|
+
*/
|
|
19
|
+
/** Wheel delta that equals one line step. A mouse notch is ~100–120; a
|
|
20
|
+
* trackpad emits many small deltas — accumulating keeps one notch = one
|
|
21
|
+
* line without making trackpad scrub feel stuck. */
|
|
22
|
+
export declare const WHEEL_LINE_NOTCH = 50;
|
|
23
|
+
/** Escaping for the {@html} path: transcript text is host data, never markup. */
|
|
24
|
+
export declare function escapeHtml(text: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Case-insensitive, non-overlapping `[start, end)` ranges of every occurrence
|
|
27
|
+
* of `query` in `text`. An empty (or whitespace-only) query matches nothing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function searchHits(text: string, query: string): Array<{
|
|
30
|
+
start: number;
|
|
31
|
+
end: number;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* HTML for one transcript row's text with `<mark>` around every match —
|
|
35
|
+
* safe to feed to `{@html}`: the surrounding text is escaped, only the mark
|
|
36
|
+
* tags are ours. `current` (the Enter-cursor's row) wears a second class so
|
|
37
|
+
* the player can style the match you're ON distinctly from the rest.
|
|
38
|
+
*/
|
|
39
|
+
export declare function highlightRowHtml(text: string, query: string, current: boolean): string;
|
|
40
|
+
/**
|
|
41
|
+
* The segment to land on when the wheel steps playback one line. Wheel-down
|
|
42
|
+
* (direction 1) seeks to the NEXT line's start; wheel-up (−1) re-plays the
|
|
43
|
+
* line the playhead is already in when it is more than `grace` seconds past
|
|
44
|
+
* its start (one up-notch replays what you just heard), otherwise the
|
|
45
|
+
* previous line. Returns null at the transcript's edges.
|
|
46
|
+
*/
|
|
47
|
+
export declare function lineScrubTarget(currentTime: number, starts: ArrayLike<number>, direction: 1 | -1, grace?: number): number | null;
|
|
48
|
+
/** What the transcript pane's wheel handler should do with one wheel event. */
|
|
49
|
+
export interface TranscriptWheelDecision {
|
|
50
|
+
/** True when the wheel is the shift verb — the handler must preventDefault. */
|
|
51
|
+
claimed: boolean;
|
|
52
|
+
/** True when the accumulated delta crossed a notch: step a line now. */
|
|
53
|
+
shouldStep: boolean;
|
|
54
|
+
/** Step direction: 1 = next line (wheel down), −1 = previous (wheel up). */
|
|
55
|
+
direction: 1 | -1;
|
|
56
|
+
/** The accumulator to carry to the next wheel event. */
|
|
57
|
+
nextAccum: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Decide one transcript-pane wheel event. A plain wheel (no shift) or a
|
|
61
|
+
* pane with no transcript is NEVER claimed — native scroll, accumulator
|
|
62
|
+
* untouched. A shift wheel accumulates deltas; crossing {@link WHEEL_LINE_NOTCH}
|
|
63
|
+
* spends the accumulator (back to 0) and steps one line in the sign's
|
|
64
|
+
* direction.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* The effective wheel delta for a shift-modified gesture. Chrome on
|
|
68
|
+
* Linux/Windows and macOS turn Shift+wheel into a HORIZONTAL scroll:
|
|
69
|
+
* `e.deltaY` is 0 and the value sits in `e.deltaX`. Headless browsers don't
|
|
70
|
+
* swap, so tests only checking `deltaY` pass while a real browser never
|
|
71
|
+
* steps. Every shift verb must read this fallback.
|
|
72
|
+
*/
|
|
73
|
+
export declare function effectiveWheelDelta(deltaY: number, deltaX: number): number;
|
|
74
|
+
export declare function transcriptWheelDecision(shiftKey: boolean, hasSegments: boolean, deltaY: number, accum: number): TranscriptWheelDecision;
|
|
75
|
+
/** Which match Enter/Shift+Enter lands on. With no cursor yet, the first
|
|
76
|
+
* match at/after the playhead (before it for backwards) — a search opened
|
|
77
|
+
* mid-talk starts where you are, not at the top. Afterwards, the cursor
|
|
78
|
+
* walks the match list and wraps at its ends. Returns the index, or −1 when
|
|
79
|
+
* there are no matches. */
|
|
80
|
+
export declare function pickMatch(matchStarts: ArrayLike<number>, currentTime: number, direction: 1 | -1, cursor: number): number;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript search + wheel line-scrub (jatui-xmtnv — extension-library
|
|
3
|
+
* parity, mos-telti). Pure, DOM-free helpers the component calls; no module
|
|
4
|
+
* here reads settings or touches the <video> element (the advisor doctrine,
|
|
5
|
+
* src/lib/video/README.md).
|
|
6
|
+
*
|
|
7
|
+
* Two features:
|
|
8
|
+
*
|
|
9
|
+
* 1. `/` opens a search box over the transcript pane. Matches highlight in
|
|
10
|
+
* place; Enter jumps to the next match (Shift+Enter the previous one,
|
|
11
|
+
* wrapping); Esc closes. The page omnibar's own `/` yields while focus is
|
|
12
|
+
* inside the player — the component stops the keydown's propagation when
|
|
13
|
+
* it claims the key.
|
|
14
|
+
* 2. The wheel over the transcript pane steps PLAYBACK one line at a time —
|
|
15
|
+
* not the pane's scroll. Deltas accumulate so one notch is one line: a
|
|
16
|
+
* mouse wheel's ~100px notches step exactly once each, and a trackpad's
|
|
17
|
+
* stream of small deltas never flies through the transcript.
|
|
18
|
+
*/
|
|
19
|
+
/** Wheel delta that equals one line step. A mouse notch is ~100–120; a
|
|
20
|
+
* trackpad emits many small deltas — accumulating keeps one notch = one
|
|
21
|
+
* line without making trackpad scrub feel stuck. */
|
|
22
|
+
export const WHEEL_LINE_NOTCH = 50;
|
|
23
|
+
/** Escaping for the {@html} path: transcript text is host data, never markup. */
|
|
24
|
+
export function escapeHtml(text) {
|
|
25
|
+
return text
|
|
26
|
+
.replaceAll('&', '&')
|
|
27
|
+
.replaceAll('<', '<')
|
|
28
|
+
.replaceAll('>', '>')
|
|
29
|
+
.replaceAll('"', '"')
|
|
30
|
+
.replaceAll("'", ''');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Case-insensitive, non-overlapping `[start, end)` ranges of every occurrence
|
|
34
|
+
* of `query` in `text`. An empty (or whitespace-only) query matches nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function searchHits(text, query) {
|
|
37
|
+
const q = query.trim().toLowerCase();
|
|
38
|
+
if (!q)
|
|
39
|
+
return [];
|
|
40
|
+
const lower = text.toLowerCase();
|
|
41
|
+
const hits = [];
|
|
42
|
+
let from = 0;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const at = lower.indexOf(q, from);
|
|
45
|
+
if (at < 0)
|
|
46
|
+
break;
|
|
47
|
+
hits.push({ start: at, end: at + q.length });
|
|
48
|
+
from = at + q.length;
|
|
49
|
+
}
|
|
50
|
+
return hits;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* HTML for one transcript row's text with `<mark>` around every match —
|
|
54
|
+
* safe to feed to `{@html}`: the surrounding text is escaped, only the mark
|
|
55
|
+
* tags are ours. `current` (the Enter-cursor's row) wears a second class so
|
|
56
|
+
* the player can style the match you're ON distinctly from the rest.
|
|
57
|
+
*/
|
|
58
|
+
export function highlightRowHtml(text, query, current) {
|
|
59
|
+
const hits = searchHits(text, query);
|
|
60
|
+
if (hits.length === 0)
|
|
61
|
+
return escapeHtml(text);
|
|
62
|
+
const cls = current ? 'vp-search-mark current' : 'vp-search-mark';
|
|
63
|
+
let out = '';
|
|
64
|
+
let at = 0;
|
|
65
|
+
for (const hit of hits) {
|
|
66
|
+
out += escapeHtml(text.slice(at, hit.start));
|
|
67
|
+
out += `<mark class="${cls}">${escapeHtml(text.slice(hit.start, hit.end))}</mark>`;
|
|
68
|
+
at = hit.end;
|
|
69
|
+
}
|
|
70
|
+
out += escapeHtml(text.slice(at));
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The segment to land on when the wheel steps playback one line. Wheel-down
|
|
75
|
+
* (direction 1) seeks to the NEXT line's start; wheel-up (−1) re-plays the
|
|
76
|
+
* line the playhead is already in when it is more than `grace` seconds past
|
|
77
|
+
* its start (one up-notch replays what you just heard), otherwise the
|
|
78
|
+
* previous line. Returns null at the transcript's edges.
|
|
79
|
+
*/
|
|
80
|
+
export function lineScrubTarget(currentTime, starts, direction, grace = 0.5) {
|
|
81
|
+
const n = starts.length;
|
|
82
|
+
if (n === 0)
|
|
83
|
+
return null;
|
|
84
|
+
// Index of the line the playhead is in (the last start ≤ time).
|
|
85
|
+
let idx = 0;
|
|
86
|
+
for (let i = 0; i < n; i++) {
|
|
87
|
+
if (starts[i] <= currentTime)
|
|
88
|
+
idx = i;
|
|
89
|
+
else
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (direction === 1) {
|
|
93
|
+
return idx + 1 < n ? starts[idx + 1] : null;
|
|
94
|
+
}
|
|
95
|
+
if (currentTime - starts[idx] > grace)
|
|
96
|
+
return starts[idx];
|
|
97
|
+
return idx > 0 ? starts[idx - 1] : null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Decide one transcript-pane wheel event. A plain wheel (no shift) or a
|
|
101
|
+
* pane with no transcript is NEVER claimed — native scroll, accumulator
|
|
102
|
+
* untouched. A shift wheel accumulates deltas; crossing {@link WHEEL_LINE_NOTCH}
|
|
103
|
+
* spends the accumulator (back to 0) and steps one line in the sign's
|
|
104
|
+
* direction.
|
|
105
|
+
*/
|
|
106
|
+
// ── transcriptWheelDecision ───────────────────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* The effective wheel delta for a shift-modified gesture. Chrome on
|
|
109
|
+
* Linux/Windows and macOS turn Shift+wheel into a HORIZONTAL scroll:
|
|
110
|
+
* `e.deltaY` is 0 and the value sits in `e.deltaX`. Headless browsers don't
|
|
111
|
+
* swap, so tests only checking `deltaY` pass while a real browser never
|
|
112
|
+
* steps. Every shift verb must read this fallback.
|
|
113
|
+
*/
|
|
114
|
+
export function effectiveWheelDelta(deltaY, deltaX) {
|
|
115
|
+
return deltaY !== 0 ? deltaY : deltaX;
|
|
116
|
+
}
|
|
117
|
+
export function transcriptWheelDecision(shiftKey, hasSegments, deltaY, accum) {
|
|
118
|
+
if (!shiftKey || !hasSegments) {
|
|
119
|
+
return { claimed: false, shouldStep: false, direction: 1, nextAccum: accum };
|
|
120
|
+
}
|
|
121
|
+
const next = accum + deltaY;
|
|
122
|
+
if (Math.abs(next) < WHEEL_LINE_NOTCH) {
|
|
123
|
+
return { claimed: true, shouldStep: false, direction: next > 0 ? 1 : -1, nextAccum: next };
|
|
124
|
+
}
|
|
125
|
+
return { claimed: true, shouldStep: true, direction: next > 0 ? 1 : -1, nextAccum: 0 };
|
|
126
|
+
}
|
|
127
|
+
/** Which match Enter/Shift+Enter lands on. With no cursor yet, the first
|
|
128
|
+
* match at/after the playhead (before it for backwards) — a search opened
|
|
129
|
+
* mid-talk starts where you are, not at the top. Afterwards, the cursor
|
|
130
|
+
* walks the match list and wraps at its ends. Returns the index, or −1 when
|
|
131
|
+
* there are no matches. */
|
|
132
|
+
export function pickMatch(matchStarts, currentTime, direction, cursor) {
|
|
133
|
+
const n = matchStarts.length;
|
|
134
|
+
if (n === 0)
|
|
135
|
+
return -1;
|
|
136
|
+
if (cursor < 0 || cursor >= n) {
|
|
137
|
+
if (direction === 1) {
|
|
138
|
+
for (let i = 0; i < n; i++)
|
|
139
|
+
if (matchStarts[i] >= currentTime)
|
|
140
|
+
return i;
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
let idx = 0;
|
|
144
|
+
for (let i = 0; i < n; i++) {
|
|
145
|
+
if (matchStarts[i] <= currentTime)
|
|
146
|
+
idx = i;
|
|
147
|
+
else
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
return idx;
|
|
151
|
+
}
|
|
152
|
+
return (cursor + direction + n) % n;
|
|
153
|
+
}
|