@joewinke/jatui 0.38.5 → 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 +269 -3
- 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; 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?` |
|
|
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,
|
|
@@ -887,6 +893,18 @@
|
|
|
887
893
|
let transcriptVisible = $state(true);
|
|
888
894
|
let keysOpen = $state(false);
|
|
889
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
|
+
|
|
890
908
|
let zoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|
891
909
|
let osdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
892
910
|
|
|
@@ -1182,6 +1200,23 @@
|
|
|
1182
1200
|
const sortedSegments = $derived(sortSegments(transcript));
|
|
1183
1201
|
const sortedBookmarks = $derived(sortBookmarks(bookmarks));
|
|
1184
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
|
+
|
|
1185
1220
|
/** Peaks normalised to 0–1 against their own max, so 0–1, 0–99 and 0–255 inputs all work. */
|
|
1186
1221
|
const peaks = $derived.by(() => {
|
|
1187
1222
|
const raw = waveform?.peaks;
|
|
@@ -2809,7 +2844,11 @@
|
|
|
2809
2844
|
return;
|
|
2810
2845
|
}
|
|
2811
2846
|
if (e.shiftKey) {
|
|
2812
|
-
|
|
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;
|
|
2813
2852
|
seekTo(currentTime + delta);
|
|
2814
2853
|
flashOsd(`${delta > 0 ? '+' : ''}${delta}s · ${formatTime(currentTime + delta, useHours)}`);
|
|
2815
2854
|
return;
|
|
@@ -2858,6 +2897,22 @@
|
|
|
2858
2897
|
return;
|
|
2859
2898
|
}
|
|
2860
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
|
+
|
|
2861
2916
|
switch (e.key) {
|
|
2862
2917
|
case '?':
|
|
2863
2918
|
e.preventDefault();
|
|
@@ -2968,6 +3023,16 @@
|
|
|
2968
3023
|
flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
|
|
2969
3024
|
}
|
|
2970
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;
|
|
2971
3036
|
case 'n':
|
|
2972
3037
|
case 'N':
|
|
2973
3038
|
if (night !== undefined) {
|
|
@@ -3106,6 +3171,88 @@
|
|
|
3106
3171
|
seekTo(seg.startSec);
|
|
3107
3172
|
}
|
|
3108
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
|
+
|
|
3109
3256
|
// No active line (seeked past the transcript) ⇒ nothing to jump to.
|
|
3110
3257
|
$effect(() => {
|
|
3111
3258
|
if (!activeSegment && followPaused) untrack(() => (followPaused = false));
|
|
@@ -3795,10 +3942,42 @@
|
|
|
3795
3942
|
style:--vp-transcript-max={transcriptMaxHeight}
|
|
3796
3943
|
style:--vp-transcript-side-max={!transcriptMaxHeight && mainHeight > 0 ? `${mainHeight}px` : undefined}
|
|
3797
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}
|
|
3798
3976
|
<div
|
|
3799
3977
|
class="vp-transcript"
|
|
3800
3978
|
use:transcriptPaneAction
|
|
3801
3979
|
onscroll={onTranscriptScroll}
|
|
3980
|
+
onwheel={onTranscriptWheel}
|
|
3802
3981
|
>
|
|
3803
3982
|
{#if transWindow}
|
|
3804
3983
|
<!-- Windowed: only the rows near the viewport exist in the DOM.
|
|
@@ -3823,7 +4002,15 @@
|
|
|
3823
4002
|
{seg.speakerName ?? 'Unknown speaker'}
|
|
3824
4003
|
</span>
|
|
3825
4004
|
{/if}
|
|
3826
|
-
<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>
|
|
3827
4014
|
</button>
|
|
3828
4015
|
{/each}
|
|
3829
4016
|
<div class="vp-spacer" style="height: {transWindow.padBottom}px" aria-hidden="true"></div>
|
|
@@ -3846,7 +4033,13 @@
|
|
|
3846
4033
|
{seg.speakerName ?? 'Unknown speaker'}
|
|
3847
4034
|
</span>
|
|
3848
4035
|
{/if}
|
|
3849
|
-
<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>
|
|
3850
4043
|
</button>
|
|
3851
4044
|
{/each}
|
|
3852
4045
|
{/if}
|
|
@@ -4909,6 +5102,79 @@
|
|
|
4909
5102
|
}
|
|
4910
5103
|
}
|
|
4911
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
|
+
|
|
4912
5178
|
/* Keyboard help overlay — the `?` surface. Keycaps first, description after;
|
|
4913
5179
|
token-only colours, same chrome family as the hover card. */
|
|
4914
5180
|
.vp-keys-backdrop {
|
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
|
+
}
|