@joewinke/jatui 0.48.0 → 0.50.0

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 CHANGED
@@ -177,7 +177,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
177
177
  | `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 take optional `progress` (0–100): an upload ring draws around the tile and a tiny % occupies the remove-× corner, cross-fading into the × at 100 (same slot, no layout shift; per-tile `role="progressbar"`). | `items: ThumbnailItem[]` (`{id, url?, alt, progress?}`), `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
178
178
  | `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?` |
179
179
  | `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?` |
180
- | `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. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **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?` |
180
+ | `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. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **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). Right-click a transcript line (or `Menu` / `Shift+F10` on a focused one) for the **line menu** — Play from here, Copy text, Copy text with timestamp (`[0:02] text`), Copy link at this time when `buildTimeLink` supplies the URL builder (without it the item is absent; `?t=` shape via `buildTimeUrl`); Escape closes and returns focus to the line, the player's keys are dead while it floats, and in fill theater Esc closes the menu without leaving the theater. 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?`, `transcriptLineMenu?` (true — right-click/`Menu`/`Shift+F10` on a transcript line opens the line menu; `false` leaves transcript lines to the browser's own context menu), `buildTimeLink?` ((sec) → URL for "Copy link at this time"; the host owns the address shape), `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), `theaterMode?` (`'width'` default — the host widens its page \| `'fill'` — the player becomes a fixed full-viewport dialog: `role="dialog"` + `aria-modal`, page scroll locked, Tab trapped, Esc/the button exits and returns focus, largest 16:9 stage that fits, transcript beside it ≥64rem else below, z from `--jatui-z-theater`; a remembered theater never fills on load, only a viewer gesture), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
181
181
  | `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?` |
182
182
  | `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?` |
183
183
  | `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) |
@@ -19,6 +19,10 @@
19
19
  * play/pause, time, speed and volume, so the native control bar (a
20
20
  * second timeline) never renders. Audio has no stage at all
21
21
  * (`kind="audio"`); video keeps its stage at its intrinsic ratio.
22
+ * - video breaks out of the reading column like a wide table (def.wide
23
+ * agrees), and its theater fills the browser window
24
+ * (`theaterMode="fill"`) — an artifact page has no wider layout to
25
+ * switch to. Audio stays in the column.
22
26
  * - resumeKey ties "continue where you left off" to this artifact block,
23
27
  * so a review round survives closing the tab.
24
28
  *
@@ -78,7 +82,7 @@
78
82
  }
79
83
  </script>
80
84
 
81
- <section class="artifact-transcript w-full">
85
+ <section class="artifact-transcript w-full" class:tr-wide={isVideo}>
82
86
  {#if params.title || params.note}
83
87
  <header class="mb-3">
84
88
  {#if params.title}
@@ -109,6 +113,7 @@
109
113
  chapters={vChapters}
110
114
  markers={vMarkers}
111
115
  transcriptPosition={isVideo ? "side" : "below"}
116
+ theaterMode={isVideo ? "fill" : "width"}
112
117
  transcriptMaxHeight="24rem"
113
118
  {resumeKey}
114
119
  />
@@ -167,3 +172,16 @@
167
172
  </section>
168
173
  {/if}
169
174
  </section>
175
+
176
+ <style>
177
+ /* Video: break out of the artifact page's reading column the way a wide
178
+ table does (TableBlock's .tb-wide — the same formula, so the two line
179
+ up): centred on the column, capped at 80rem, 1.5rem inside the page
180
+ view's MEASURED width (--artifact-page-w). Only that page opts in;
181
+ pinned/embedded hosts keep the block in its column. */
182
+ :global([data-artifact-page]) .tr-wide {
183
+ --tr-w: max(100%, min(var(--artifact-page-w, 100%) - 3rem, 80rem));
184
+ width: var(--tr-w);
185
+ margin-inline: calc((100% - var(--tr-w)) / 2);
186
+ }
187
+ </style>
@@ -81,6 +81,9 @@ const def = {
81
81
  component: TranscriptBlock,
82
82
  // mos-8fvt9: the section's Listen + comment sit on the heading row.
83
83
  hostsTools: (p) => Boolean(p.title),
84
+ // jatui-c5uob: video breaks out of the reading column (TranscriptBlock's
85
+ // .tr-wide); audio stays in it. media.type defaults to audio.
86
+ wide: (p) => p.media?.type === "video",
84
87
  example: EXAMPLE,
85
88
  };
86
89
  export default def;
@@ -198,6 +198,7 @@
198
198
  type VideoAdSpan
199
199
  } from '../video/rate';
200
200
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
201
+ import { isContextMenuKey, lineTextWithTime } from '../video/lineMenu';
201
202
  import {
202
203
  biasCells,
203
204
  biasGradient,
@@ -256,6 +257,7 @@
256
257
  } from '../video/presence';
257
258
  import type { ResumePoint } from '../video/advisors/resume-point';
258
259
  import { idleDetectionSupported } from '../video/presence';
260
+ import { lockPageScroll, resolveTheaterMode, trapFillTab, type TheaterMode } from '../video/theaterFill';
259
261
  import {
260
262
  coerce,
261
263
  createSettingsSession,
@@ -274,6 +276,9 @@
274
276
  type VideoSettingsStore
275
277
  } from '../video/settings';
276
278
  import SettingsPanel from '../video/settings/SettingsPanel.svelte';
279
+ import ContextMenu from './ContextMenu.svelte';
280
+ import type { ContextMenuItem } from './ContextMenu.types';
281
+ import { copyText } from '../utils/clipboard';
277
282
 
278
283
  let {
279
284
  src,
@@ -300,7 +305,10 @@
300
305
  transcriptPosition = 'below',
301
306
  transcriptMaxHeight,
302
307
  followResumeMs = FOLLOW_RESUME_IDLE_MS,
308
+ transcriptLineMenu = true,
309
+ buildTimeLink = undefined,
303
310
  theater = $bindable(false),
311
+ theaterMode = 'width',
304
312
  theaterButton = true,
305
313
  onTheaterChange,
306
314
  /**
@@ -483,6 +491,24 @@
483
491
  * play after pause or the "Resume following" pill bring it back).
484
492
  */
485
493
  followResumeMs?: number;
494
+ /**
495
+ * Right-click a transcript line (or Context-menu / Shift+F10 on a
496
+ * focused one) for the line's verbs (extension-library parity,
497
+ * library.js:8458): Play from here, Copy text, Copy text with
498
+ * timestamp — and, when `buildTimeLink` is supplied, Copy link at this
499
+ * time. Default on: the menu only exists where a transcript exists.
500
+ * Pass `false` to leave transcript lines to the browser's own context
501
+ * menu.
502
+ */
503
+ transcriptLineMenu?: boolean;
504
+ /**
505
+ * Builds the shareable URL for "Copy link at this time" — the HOST
506
+ * supplies it because only the host knows its page's address shape
507
+ * (MOS builds `?t=90s` on its video page; `buildTimeUrl` in
508
+ * video/deepLink.ts is exactly that builder). Without it the item is
509
+ * simply not on the menu — the player never guesses a URL.
510
+ */
511
+ buildTimeLink?: (sec: number) => string;
486
512
  /**
487
513
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
488
514
  * only says it wants the page's width; the HOST lays its page out
@@ -495,6 +521,18 @@
495
521
  * host's default; a remembered choice wins.
496
522
  */
497
523
  theater?: boolean;
524
+ /**
525
+ * What theater does (jatui-c5uob). `'width'` (default) is the above:
526
+ * the host widens its page. `'fill'` makes the player itself a fixed
527
+ * full-viewport dialog (`role="dialog"`, `aria-modal`): the page stops
528
+ * scrolling, Tab stays inside the player, Escape or the theater button
529
+ * exits and gives focus back. The video is the largest 16:9 box that
530
+ * fits (other ratios letterbox), a transcript sits beside it from
531
+ * 64rem and below it under that. Fullscreen stays monitor fullscreen.
532
+ * A REMEMBERED theater never opens the overlay on load — only the
533
+ * viewer's own gesture (button, `t`, settings panel) fills the window.
534
+ */
535
+ theaterMode?: TheaterMode;
498
536
  /** Show the theater button + `t` key (video only; default true). */
499
537
  theaterButton?: boolean;
500
538
  /** The viewer toggled theater mode (button, `t`, settings panel). */
@@ -957,6 +995,15 @@
957
995
  let searchMatchIdx = $state(-1);
958
996
  let searchInputEl: HTMLInputElement | undefined = $state();
959
997
 
998
+ // ── Transcript line menu (jatui-3wl5x) ─────────────────────────────
999
+ // Right-click (or Context-menu / Shift+F10 on a focused row) opens the
1000
+ // line's verb menu (extension library.js:8458). ONE panel serves the
1001
+ // whole pane: opening re-points it at the row; closing clears the row.
1002
+ let lineMenuOpen = $state(false);
1003
+ let lineMenuX = $state(0);
1004
+ let lineMenuY = $state(0);
1005
+ let lineMenuSeg = $state<VideoTranscriptSegment | null>(null);
1006
+
960
1007
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
961
1008
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
962
1009
 
@@ -2338,7 +2385,7 @@
2338
2385
  let rootEl: HTMLDivElement | undefined = $state();
2339
2386
 
2340
2387
  /** Land one coerced value: bindables are written, everything else overlays its prop. */
2341
- function applySetting(id: string, v: SettingValue) {
2388
+ function applySetting(id: string, v: SettingValue, source?: 'user' | 'store') {
2342
2389
  if (id === 'night.mode') {
2343
2390
  if (night !== undefined && night !== v) {
2344
2391
  night = v as NightMode;
@@ -2352,6 +2399,7 @@
2352
2399
  }
2353
2400
  if (id === 'player.theater') {
2354
2401
  if (!settingsStore) localTheaterStore.set(id, v);
2402
+ if (source === 'user') fillRequested = v === true;
2355
2403
  if (theater !== v) {
2356
2404
  theater = v as boolean;
2357
2405
  onTheaterChange?.(theater);
@@ -2368,7 +2416,7 @@
2368
2416
  createSettingsSession({
2369
2417
  store,
2370
2418
  onValue: (id, v, source) => {
2371
- applySetting(id, v);
2419
+ applySetting(id, v, source);
2372
2420
  if (source === 'user') onSettingChange?.(id, v);
2373
2421
  }
2374
2422
  })
@@ -2388,6 +2436,31 @@
2388
2436
  const localTheaterStore = localSettingsStore();
2389
2437
  const theaterAvailable = $derived(!isAudio && theaterButton);
2390
2438
  const theaterOn = $derived(theaterAvailable && theater);
2439
+ // Fill mode (jatui-c5uob): the overlay is open only after a viewer
2440
+ // gesture in THIS page — the remembered `player.theater` alone never
2441
+ // covers the page on load. `theaterActive` is what the root and the
2442
+ // button show: in fill mode, whether the overlay is up.
2443
+ const fillMode = $derived(resolveTheaterMode(theaterMode) === 'fill');
2444
+ let fillRequested = $state(false);
2445
+ const filling = $derived(fillMode && theaterOn && fillRequested);
2446
+ const theaterActive = $derived(fillMode ? filling : theaterOn);
2447
+ $effect(() => {
2448
+ if (!theaterOn && fillRequested) fillRequested = false;
2449
+ });
2450
+ // While filling: the page under the overlay doesn't scroll, and focus
2451
+ // goes back where it was when the overlay closes.
2452
+ $effect(() => {
2453
+ if (!filling || typeof document === 'undefined') return;
2454
+ const returnTo = document.activeElement as HTMLElement | null;
2455
+ const release = lockPageScroll(document);
2456
+ const root = untrack(() => rootEl);
2457
+ if (root && !root.contains(document.activeElement)) root.focus();
2458
+ return () => {
2459
+ release();
2460
+ const back = returnTo && returnTo.isConnected ? returnTo : untrack(() => rootEl);
2461
+ back?.focus({ preventScroll: true });
2462
+ };
2463
+ });
2391
2464
  $effect(() => {
2392
2465
  if (!theaterAvailable || settingsStore) return;
2393
2466
  untrack(() => {
@@ -2398,7 +2471,8 @@
2398
2471
  });
2399
2472
  function toggleTheater() {
2400
2473
  if (!theaterAvailable) return;
2401
- const next = !theaterOn;
2474
+ const next = !theaterActive;
2475
+ if (fillMode) fillRequested = next;
2402
2476
  if (settingsSession) settingsSession.change('player.theater', next);
2403
2477
  else applySetting('player.theater', next);
2404
2478
  flashOsd(next ? 'Theater mode' : 'Default view');
@@ -3069,6 +3143,36 @@
3069
3143
  * ADVERTISED when the data exists — a documented key that does nothing is
3070
3144
  * the one thing a keymap must never be.
3071
3145
  */
3146
+ /** Fill theater (jatui-c5uob): Tab stays inside the player, and Escape
3147
+ * exits — after the overlays that own Escape (help, search, settings,
3148
+ * band zoom) have had it, and never while the stage is fullscreen. */
3149
+ function onRootKeyDownFill(e: KeyboardEvent) {
3150
+ if (filling && rootEl) {
3151
+ if (e.key === 'Tab') {
3152
+ trapFillTab(rootEl, e);
3153
+ return;
3154
+ }
3155
+ if (
3156
+ e.key === 'Escape' &&
3157
+ !e.defaultPrevented &&
3158
+ !keysOpen &&
3159
+ !searchOpen &&
3160
+ !settingsOpen &&
3161
+ !isFullscreen &&
3162
+ // The line menu (jatui-3wl5x) owns Escape while it is open — the
3163
+ // menu closes itself, the theater must not leave with it. The
3164
+ // panel's own handler may already have closed it by the time this
3165
+ // bubble-phase handler runs, hence the target check too.
3166
+ !(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]')))
3167
+ ) {
3168
+ e.preventDefault();
3169
+ toggleTheater();
3170
+ return;
3171
+ }
3172
+ }
3173
+ onRootKeyDown(e);
3174
+ }
3175
+
3072
3176
  function onRootKeyDown(e: KeyboardEvent) {
3073
3177
  // The band's slider handler (and the help overlay) own their keys and
3074
3178
  // preventDefault them — without this guard an ArrowLeft with the band
@@ -3080,6 +3184,13 @@
3080
3184
  // Space/Enter on a focused button activates THAT button (play, a
3081
3185
  // speaker chip, a transcript row); toggling playback too would undo it.
3082
3186
  if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
3187
+ // The transcript line menu (jatui-3wl5x) owns the keyboard while it is
3188
+ // open: arrows/Enter drive it (its panel preventDefaults what it uses,
3189
+ // which the defaultPrevented guard above already yields to), Escape
3190
+ // closes it, and nothing else drives the player underneath a floating
3191
+ // menu. Tab is the shared ContextMenu's own behaviour (focus moves out
3192
+ // with the menu still up; click/Escape dismiss it).
3193
+ if (lineMenuOpen) return;
3083
3194
  // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
3084
3195
  // activation) while focus is inside it; without this guard a rail `k`
3085
3196
  // would also toggle play/pause and a rail `j` also seek ±5s.
@@ -3377,6 +3488,70 @@
3377
3488
  seekTo(seg.startSec);
3378
3489
  }
3379
3490
 
3491
+ // ── Transcript line menu (jatui-3wl5x, extension library.js:8458) ────
3492
+ /** Right-click a line: the menu opens at the cursor. The row takes focus
3493
+ * first — right-click alone doesn't move focus in every engine, and
3494
+ * giving it the row means the menu's focus-return lands back on the
3495
+ * line the menu was about (preventScroll: the row is already on screen). */
3496
+ function onRowContextMenu(seg: VideoTranscriptSegment, e: MouseEvent) {
3497
+ if (!transcriptLineMenu) return; // the browser's own menu stays
3498
+ e.preventDefault();
3499
+ rowRefs[seg.id]?.focus({ preventScroll: true });
3500
+ openLineMenu(seg, e.clientX, e.clientY);
3501
+ }
3502
+ /** Context-menu key / Shift+F10 on a focused line: the same menu, anchored
3503
+ * to the row (there is no cursor to place it at). */
3504
+ function onRowMenuKey(seg: VideoTranscriptSegment, e: KeyboardEvent) {
3505
+ if (!transcriptLineMenu || !isContextMenuKey(e)) return;
3506
+ e.preventDefault();
3507
+ const rect = rowRefs[seg.id]?.getBoundingClientRect();
3508
+ openLineMenu(seg, rect?.left ?? 0, rect ? rect.bottom : 0);
3509
+ }
3510
+ function openLineMenu(seg: VideoTranscriptSegment, x: number, y: number) {
3511
+ lineMenuSeg = seg;
3512
+ lineMenuX = x;
3513
+ lineMenuY = y;
3514
+ lineMenuOpen = true;
3515
+ }
3516
+ /** "Play from here": seek to the line's start and play — the verb the
3517
+ * click can't offer (a plain line click only seeks, never unpauses). */
3518
+ function playFromLine(seg: VideoTranscriptSegment) {
3519
+ seekTo(seg.startSec);
3520
+ const v = videoEl;
3521
+ if (v && v.paused) void v.play().catch(() => {});
3522
+ }
3523
+ /** One copy verb for all the payloads; the OSD and the polite region say
3524
+ * what happened — a clipboard write is otherwise invisible. */
3525
+ async function copyLine(label: string, payload: string) {
3526
+ if (!payload) return;
3527
+ const ok = await copyText(payload);
3528
+ flashOsd(ok ? 'Copied' : 'Copy failed');
3529
+ announce(ok ? `${label} copied` : 'Copy failed');
3530
+ }
3531
+ /** The menu's items for the row it is open on. `buildTimeLink` decides
3532
+ * whether the link verb exists at all (the host owns the URL shape). */
3533
+ const lineMenuItems = $derived.by<ContextMenuItem[]>(() => {
3534
+ const seg = lineMenuSeg;
3535
+ if (!seg) return [];
3536
+ const items: ContextMenuItem[] = [
3537
+ { label: 'Play from here', icon: '▶', onSelect: () => playFromLine(seg) },
3538
+ { label: 'Copy text', icon: '⧉', onSelect: () => copyLine('Copy text', seg.text) },
3539
+ {
3540
+ label: 'Copy text with timestamp',
3541
+ icon: '⧉',
3542
+ onSelect: () => copyLine('Copy text with timestamp', lineTextWithTime(seg.text, seg.startSec, useHours))
3543
+ }
3544
+ ];
3545
+ if (buildTimeLink) {
3546
+ items.push({
3547
+ label: 'Copy link at this time',
3548
+ icon: '🔗',
3549
+ onSelect: () => copyLine('Copy link at this time', buildTimeLink(seg.startSec))
3550
+ });
3551
+ }
3552
+ return items;
3553
+ });
3554
+
3380
3555
  // ── Transcript search (jatui-xmtnv) ───────────────────────────────────
3381
3556
  /** `/`: open the box (showing the transcript if it was hidden) and hand
3382
3557
  * it focus once it mounts — the effect below lands it. */
@@ -3514,9 +3689,12 @@
3514
3689
  <div
3515
3690
  class="vp-root {className}"
3516
3691
  bind:this={rootEl}
3517
- class:vp-side={transcriptLayout === 'side' && !theaterOn && sortedSegments.length > 0}
3518
- class:vp-theater={theaterOn}
3519
- data-theater={theaterAvailable ? String(theaterOn) : undefined}
3692
+ class:vp-side={transcriptLayout === 'side' && !theaterActive && sortedSegments.length > 0}
3693
+ class:vp-theater={theaterActive}
3694
+ class:vp-fill={filling}
3695
+ class:vp-fill-transcript={filling && sortedSegments.length > 0 && transcriptLayout !== 'hidden'}
3696
+ data-theater={theaterAvailable ? String(theaterActive) : undefined}
3697
+ data-theater-mode={theaterAvailable ? (fillMode ? 'fill' : 'width') : undefined}
3520
3698
  class:vp-rail-side={railOn}
3521
3699
  class:vp-one-axis={nativeBarHidden}
3522
3700
  class:vp-band-transport={bandTransport}
@@ -3526,10 +3704,11 @@
3526
3704
  class:vp-night-surround={surroundMix > 0.001}
3527
3705
  style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
3528
3706
  data-band-axis={currentBandState.axis}
3529
- role="region"
3530
- aria-label={isAudio ? 'Audio player' : 'Video player'}
3707
+ role={filling ? 'dialog' : 'region'}
3708
+ aria-modal={filling ? 'true' : undefined}
3709
+ aria-label={isAudio ? 'Audio player' : filling ? 'Video player, theater' : 'Video player'}
3531
3710
  tabindex="-1"
3532
- onkeydown={onRootKeyDown}
3711
+ onkeydown={onRootKeyDownFill}
3533
3712
  onpointerdowncapture={maybeAskIdle}
3534
3713
  onpointerupcapture={maybeAskIdle}
3535
3714
  onclickcapture={maybeAskIdle}
@@ -3878,10 +4057,10 @@
3878
4057
  <button
3879
4058
  type="button"
3880
4059
  class="vp-theater-btn"
3881
- class:active={theaterOn}
4060
+ class:active={theaterActive}
3882
4061
  aria-label="Theater mode"
3883
- aria-pressed={theaterOn}
3884
- title="{theaterOn ? 'Default view' : 'Theater mode'} · t"
4062
+ aria-pressed={theaterActive}
4063
+ title="{theaterActive ? (fillMode ? 'Exit theater · t or Esc' : 'Default view · t') : 'Theater mode · t'}"
3885
4064
  onclick={toggleTheater}
3886
4065
  >
3887
4066
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true">
@@ -4227,6 +4406,8 @@
4227
4406
  : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
4228
4407
  use:transcriptRowAction={seg}
4229
4408
  onclick={() => onRowClick(seg)}
4409
+ oncontextmenu={(e) => onRowContextMenu(seg, e)}
4410
+ onkeydown={(e) => onRowMenuKey(seg, e)}
4230
4411
  >
4231
4412
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
4232
4413
  {#if multiSpeaker}
@@ -4258,6 +4439,8 @@
4258
4439
  : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
4259
4440
  use:transcriptRowAction={seg}
4260
4441
  onclick={() => onRowClick(seg)}
4442
+ oncontextmenu={(e) => onRowContextMenu(seg, e)}
4443
+ onkeydown={(e) => onRowMenuKey(seg, e)}
4261
4444
  >
4262
4445
  <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
4263
4446
  {#if multiSpeaker}
@@ -4324,6 +4507,22 @@
4324
4507
  persisted={!!settingsStore}
4325
4508
  />
4326
4509
  {/if}
4510
+
4511
+ {#if transcriptLineMenu && sortedSegments.length > 0}
4512
+ <!-- Transcript line menu (jatui-3wl5x, extension library.js:8458): ONE
4513
+ panel serves the whole pane, re-pointed at the row it was opened
4514
+ from. Rendered inside the root so the fill-theater Tab trap keeps
4515
+ cycling through its items; position:fixed places it at the cursor
4516
+ (or the focused row) wherever this spot sits in the layout. -->
4517
+ <ContextMenu
4518
+ bind:open={lineMenuOpen}
4519
+ x={lineMenuX}
4520
+ y={lineMenuY}
4521
+ items={lineMenuItems}
4522
+ header={lineMenuSeg ? formatTime(lineMenuSeg.startSec, useHours) : undefined}
4523
+ onClose={() => (lineMenuSeg = null)}
4524
+ />
4525
+ {/if}
4327
4526
  </div>
4328
4527
 
4329
4528
  <style>
@@ -4606,6 +4805,137 @@
4606
4805
  }
4607
4806
  }
4608
4807
 
4808
+ /* ── Fill theater (jatui-c5uob) ──────────────────────────────────────
4809
+ theaterMode="fill": the player IS the overlay — fixed to the viewport
4810
+ above the page (z from --jatui-z-theater). The stage is the largest
4811
+ 16:9 box its cell can hold (size container units: 100cqw wide, or
4812
+ 100cqh × 16/9 when the height is what runs out); a source of another
4813
+ ratio letterboxes inside it. A transcript sits beside the video from
4814
+ 64rem and below it under that; the chapter rail keeps its column.
4815
+ Placed after the rail rules so the same-specificity grid wins. */
4816
+ .vp-root.vp-fill {
4817
+ position: fixed;
4818
+ inset: 0;
4819
+ z-index: var(--jatui-z-theater, 9040);
4820
+ display: grid;
4821
+ grid-template-columns: minmax(0, 1fr);
4822
+ grid-template-rows: minmax(0, 1fr);
4823
+ grid-auto-rows: auto;
4824
+ grid-template-areas: 'main' 'rail' 'below' 'transcript';
4825
+ align-items: stretch;
4826
+ gap: var(--jatui-space-lg, 12px);
4827
+ margin: 0;
4828
+ border: 0;
4829
+ border-radius: 0;
4830
+ padding: var(--jatui-space-xl, 16px);
4831
+ background: var(--color-base-300);
4832
+ overflow: hidden;
4833
+ }
4834
+
4835
+ .vp-root.vp-fill > .vp-main {
4836
+ grid-area: main;
4837
+ min-height: 0;
4838
+ }
4839
+
4840
+ .vp-root.vp-fill > .vp-chapters {
4841
+ grid-area: rail;
4842
+ }
4843
+
4844
+ .vp-root.vp-fill > .vp-below-stage {
4845
+ grid-area: below;
4846
+ }
4847
+
4848
+ .vp-root.vp-fill > .vp-transcript-wrap {
4849
+ grid-area: transcript;
4850
+ min-height: 0;
4851
+ max-height: 40vh;
4852
+ max-height: 40dvh;
4853
+ }
4854
+
4855
+ .vp-root.vp-fill .vp-transcript {
4856
+ max-height: none;
4857
+ flex: 1 1 auto;
4858
+ min-height: 0;
4859
+ }
4860
+
4861
+ .vp-fill .vp-stage-wrap {
4862
+ flex: 1 1 0;
4863
+ min-height: 0;
4864
+ container-type: size;
4865
+ display: flex;
4866
+ flex-direction: column;
4867
+ align-items: center;
4868
+ justify-content: center;
4869
+ }
4870
+
4871
+ .vp-fill .vp-stage,
4872
+ .vp-fill .vp-stage.vp-stage-locked {
4873
+ flex: none;
4874
+ width: min(100cqw, 100cqh * 16 / 9);
4875
+ aspect-ratio: 16 / 9;
4876
+ background: var(--color-neutral);
4877
+ border-radius: var(--jatui-radius-field, 0.25rem);
4878
+ overflow: hidden;
4879
+ }
4880
+
4881
+ .vp-fill .vp-stage .vp-video {
4882
+ position: absolute;
4883
+ inset: 0;
4884
+ width: 100%;
4885
+ height: 100%;
4886
+ object-fit: contain;
4887
+ border-radius: 0;
4888
+ }
4889
+
4890
+ @media (min-width: 64rem) {
4891
+ .vp-root.vp-fill.vp-fill-transcript {
4892
+ grid-template-columns: minmax(0, 1fr) minmax(20rem, 28rem);
4893
+ grid-template-rows: minmax(0, 1fr) auto;
4894
+ grid-template-areas:
4895
+ 'main transcript'
4896
+ 'below transcript';
4897
+ }
4898
+
4899
+ .vp-root.vp-fill.vp-rail-side {
4900
+ grid-template-columns: minmax(0, 1fr) minmax(11rem, 14rem);
4901
+ grid-template-rows: minmax(0, 1fr) auto;
4902
+ grid-template-areas:
4903
+ 'main rail'
4904
+ 'below rail';
4905
+ }
4906
+
4907
+ .vp-root.vp-fill.vp-fill-transcript.vp-rail-side {
4908
+ grid-template-columns: minmax(0, 1fr) minmax(20rem, 28rem) minmax(11rem, 14rem);
4909
+ grid-template-areas:
4910
+ 'main transcript rail'
4911
+ 'below transcript rail';
4912
+ }
4913
+
4914
+ .vp-root.vp-fill > .vp-transcript-wrap {
4915
+ max-height: none;
4916
+ }
4917
+
4918
+ .vp-root.vp-fill.vp-rail-side > .vp-chapters {
4919
+ position: static;
4920
+ max-height: none;
4921
+ min-height: 0;
4922
+ overflow-y: auto;
4923
+ }
4924
+ }
4925
+
4926
+ /* Entrance: a short fade, only when motion is welcome. */
4927
+ @media (prefers-reduced-motion: no-preference) {
4928
+ .vp-root.vp-fill {
4929
+ animation: vp-fill-in var(--motion-enter, 200ms) var(--motion-glide, ease-out);
4930
+ }
4931
+ }
4932
+
4933
+ @keyframes vp-fill-in {
4934
+ from {
4935
+ opacity: 0;
4936
+ }
4937
+ }
4938
+
4609
4939
  .vp-stage {
4610
4940
  position: relative;
4611
4941
  }
@@ -75,6 +75,7 @@ import { type NightMode, type NightWindow } from '../video/night/clock';
75
75
  import { type AudioBrokenReason, type AudioState } from '../video/audio';
76
76
  import { type FlipSource, type VideoResumeStore, type WindDownState } from '../video/presence';
77
77
  import type { ResumePoint } from '../video/advisors/resume-point';
78
+ import { type TheaterMode } from '../video/theaterFill';
78
79
  import { type SettingValue, type VideoSettingsStore } from '../video/settings';
79
80
  type $$ComponentProps = {
80
81
  src: string;
@@ -190,6 +191,24 @@ type $$ComponentProps = {
190
191
  * play after pause or the "Resume following" pill bring it back).
191
192
  */
192
193
  followResumeMs?: number;
194
+ /**
195
+ * Right-click a transcript line (or Context-menu / Shift+F10 on a
196
+ * focused one) for the line's verbs (extension-library parity,
197
+ * library.js:8458): Play from here, Copy text, Copy text with
198
+ * timestamp — and, when `buildTimeLink` is supplied, Copy link at this
199
+ * time. Default on: the menu only exists where a transcript exists.
200
+ * Pass `false` to leave transcript lines to the browser's own context
201
+ * menu.
202
+ */
203
+ transcriptLineMenu?: boolean;
204
+ /**
205
+ * Builds the shareable URL for "Copy link at this time" — the HOST
206
+ * supplies it because only the host knows its page's address shape
207
+ * (MOS builds `?t=90s` on its video page; `buildTimeUrl` in
208
+ * video/deepLink.ts is exactly that builder). Without it the item is
209
+ * simply not on the menu — the player never guesses a URL.
210
+ */
211
+ buildTimeLink?: (sec: number) => string;
193
212
  /**
194
213
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
195
214
  * only says it wants the page's width; the HOST lays its page out
@@ -202,6 +221,18 @@ type $$ComponentProps = {
202
221
  * host's default; a remembered choice wins.
203
222
  */
204
223
  theater?: boolean;
224
+ /**
225
+ * What theater does (jatui-c5uob). `'width'` (default) is the above:
226
+ * the host widens its page. `'fill'` makes the player itself a fixed
227
+ * full-viewport dialog (`role="dialog"`, `aria-modal`): the page stops
228
+ * scrolling, Tab stays inside the player, Escape or the theater button
229
+ * exits and gives focus back. The video is the largest 16:9 box that
230
+ * fits (other ratios letterbox), a transcript sits beside it from
231
+ * 64rem and below it under that. Fullscreen stays monitor fullscreen.
232
+ * A REMEMBERED theater never opens the overlay on load — only the
233
+ * viewer's own gesture (button, `t`, settings panel) fills the window.
234
+ */
235
+ theaterMode?: TheaterMode;
205
236
  /** Show the theater button + `t` key (video only; default true). */
206
237
  theaterButton?: boolean;
207
238
  /** The viewer toggled theater mode (button, `t`, settings panel). */
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ export { LISTEN_KEYS } from './listen/keys';
24
24
  export { LISTEN_SPEEDS, SPEED_FLOOR, SPEED_CEIL, clampSpeed, stepSpeed, formatListenSpeed } from './listen/prefs';
25
25
  export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, VideoAdSpan, RateReport, WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource, } from './components/VideoPlayer.svelte';
26
26
  export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
27
+ export type { TheaterMode } from './video/theaterFill';
28
+ export { lockPageScroll, trapFillTab, fillFocusables, resolveTheaterMode } from './video/theaterFill';
27
29
  export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster, type IdlePermission } from './video/presence';
28
30
  export { VIDEO_SETTINGS, SETTING_GROUPS, settingSpec, settingValue, coerce as coerceVideoSetting, memorySettingsStore, panelGroups as videoSettingsGroups, formatSettingValue, type VideoSettingsStore, type SettingSpec as VideoSettingSpec, type SettingValue as VideoSettingValue, type SettingOption as VideoSettingOption, type SettingWhen as VideoSettingWhen, type SettingAvailability as VideoSettingAvailability } from './video/settings';
29
31
  export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ export { LISTEN_SPEEDS, SPEED_FLOOR, SPEED_CEIL, clampSpeed, stepSpeed, formatLi
43
43
  // under `speakerPace` (manual beats measured), for a host drawing its own
44
44
  // per-speaker speed UI.
45
45
  export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
46
+ export { lockPageScroll, trapFillTab, fillFocusables, resolveTheaterMode } from './video/theaterFill';
46
47
  // Presence (mos-5lzns.34): the permission call a host makes from the click
47
48
  // that enables wind-down's desktop signal (the API needs a user gesture),
48
49
  // the default resume store, and the FLIP pair for a host animating its own
@@ -28,6 +28,10 @@
28
28
 
29
29
  /* Border — depth comes from tonal surfaces + 1px borders, not shadows */
30
30
  --jatui-border-w: 1px;
31
+
32
+ /* Stacking — full-viewport surfaces (VideoPlayer's fill theater). A
33
+ host's --z-lightbox tier wins; 9040 matches MediaLightbox's default. */
34
+ --jatui-z-theater: var(--z-lightbox, 9040);
31
35
  }
32
36
 
33
37
  /*
@@ -19,7 +19,8 @@ props + callback surfaces.
19
19
  | `band/` | **.30** (done) | `window.ts` — the zoom-window math (moved out of band.ts, re-exported there; shared by every band layer without an import cycle). `axis.ts` — one-axis four-state arbitration (`resolveAxis`, `BandState`, the churn-guarded microtask `createBandReporter`), `playheadX` (the one "where you are" mapping — ≤0.2px agreement) and `overviewBracket`. `luma.ts` — the light track (`VideoLumaTrack`, the sidecar verbatim) → ribbon (`v² × 0.55`, max-per-interval). `markers.ts` — `VideoMarker` comments (clustered dots) + like heat. |
20
20
  | `keys.ts` | .28 (done) | The keyboard map — `PLAYER_KEYS` (the `?` overlay renders it), `chapterJump`, `FRAME_STEP_SEC`. Every key that exists is in the map; a key not in the map doesn't exist. |
21
21
  | `rate.ts` + `rate/` | .28 contract — **.32** (done) | `rate.ts` is the barrel. `compose.ts` — the contract (`composeRate`, clamp [0.0625, 16], `shouldWriteRate`'s 0.001 deadband with an exact return to base), the `SPEED_STEPS` staircase, `formatSpeedLabel`. `speaker.ts` — props → `advisors/speaker-rate`; manual rates keyed by NAME always beat measured. `silence.ts` — waveform → `advisors/smart-speed` map + boost + the OSD saving claim. `ads.ts` — `adSpans` ∪ `ad: true` turns; skip only when PLAYBACK runs into an ad, never when a seek lands in one. `tick.ts` — the component's own 120ms loop's one-tick decision. None of them write `playbackRate`; they advise. |
22
- | `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
22
+ | `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved) + `buildTimeUrl` (jatui-3wl5x) — the `?t=` URL builder hosts pass as `buildTimeLink` (MOS's "copy link at this time" shape). |
23
+ | `lineMenu.ts` | **jatui-3wl5x** | The transcript line right-click menu's pure pieces: `isContextMenuKey` (the keyboard's right-click — Context-menu key or Shift+F10) and `lineTextWithTime` (`[1:23] text`, the row's own clock). The component owns the DOM — which row opened it, cursor vs row anchoring, focus return — and the menu itself is the shared `ContextMenu`. |
23
24
  | `settings.ts` + `settings/` | **.35** (done) — knobs added by .30–.34 | `settings.ts` is the barrel. `registry.ts` — `VIDEO_SETTINGS`: id, group, label, **mandatory hint**, the prop it drives, the availability gate (`when`); `coerce` never throws (hostile values, throwing `valueOf`, Symbols → undefined). Labels/hints are the single source for the panel AND a host help page. `store.ts` — the host-supplied `VideoSettingsStore` (`get`/`set`, either async; jatui never persists on its own) and `createSettingsSession`: coerced reads, a choice made on screen beats a late read, per-id debounced writes flushed on close. `panel.ts` — `panelGroups` (rows only for features this player has — omitted, never disabled), value formatting, radio stepping. `SettingsPanel.svelte` — the generated dialog (switch / radio group / range, focus trap, Esc/`,` close, focus handed back). `remembered.ts` (jatui-xz6so) — remembered MEDIA state (E12: volume + mute survive a reload), coerced like settings and persisted through the same store split (`settingsStore`, else guarded localStorage) but deliberately never panel rows: remembering is not configuring. In the component a stored choice overlays its prop but never creates a presence-gated feature; `night`/`nightDepth` are written through their bindings and `N`/the moon wheel are saved back. The one permission ask is `sleep.desktopIdle` turned on, from that click. A knob lands WITH its reader. |
24
25
  | `audio.ts` + `audio/` | **.31** (done) | `audio.ts` is the barrel. `rider.ts` — pure: RMS dBFS (silence → null), the 12 dB relative-gated loud estimate (rise 0.08 / forget 0.01), want-gain clamped 0.3–1.35, the asymmetric ride (down 0.15 / up 0.02), limiter constants + `compressorMakeupDb`/`limiterTrim` (DynamicsCompressor's automatic makeup, cancelled by a trim node), `routeSafety` (only blob:/data: or a `crossorigin` load — a same-origin URL may redirect cross-origin and route to SILENCE). `graph.ts` — `createAudioSession`: source → gain → quiet chain → limiter → trim → destination, analyser on the source. Context first, route last: the element is taken only when a feature is on, the source is route-safe and `ctx.state === 'running'` (gestures resume + retry; never navigator.userActivation). After routing it watches: an unsafe src or bit-exact zeros while audibly playing ⇒ `broken` (`unsafe-src` / `silent`) via `onBroken`. A construction throw is a permanent `build-failed` (context closed if the element wasn't taken; kept with source → destination if it was). Level off cancels before it sets; limiter moves are ramped. The element never enters the module — the component passes a `route` closure. |
25
26
  | `night.ts` + `night/` | **.33** (done) | `night.ts` is the barrel. `meter.ts` — 32×18 frame bytes → Rec.709 luma + blown share, asymmetric smoothing, bias-light cells from the same bytes. `levels.ts` — luma → brightness/saturation/warmth/vignette off `NIGHT_FLOOR` 0.34, blind fixed level, the light-theme surround. `clock.ts` — local-clock `auto` window + the `N` auto/on/off cycle (no hyprsunset). `track.ts` — prediction (only ever adds dimming), flash intervals, `composeNight` (darker at once, brighter released per tick — never a jump). |
@@ -34,6 +34,15 @@ export declare function readTimeParam(url?: string | URL | null): number | null;
34
34
  * history API, same second) so the caller can skip the replaceState.
35
35
  */
36
36
  export declare function writeTimeParam(sec: number, url?: string | URL | null): boolean;
37
+ /**
38
+ * Build the shareable URL for a time: the base URL (default: the current
39
+ * page) with its `t` param set — every other query param preserved. This is
40
+ * the builder MOS's "copy link at this time" uses (`?t=90s`) and the shape
41
+ * hosts pass as VideoPlayer's `buildTimeLink` prop. Null when there is no
42
+ * URL to build on (SSR, garbage base) — a builder that can't build returns
43
+ * nothing rather than throwing.
44
+ */
45
+ export declare function buildTimeUrl(sec: number, url?: string | URL | null): string | null;
37
46
  /** Strip the `t` param (e.g. when the host unmounts the player opt-in). */
38
47
  export declare function clearTimeParam(url?: string | URL | null): boolean;
39
48
  /** Throttle for URL writes: at most one replaceState per WRITE_THROTTLE_MS. */
@@ -88,6 +88,26 @@ export function writeTimeParam(sec, url = typeof location !== 'undefined' ? loca
88
88
  history.replaceState(history.state, '', u);
89
89
  return true;
90
90
  }
91
+ /**
92
+ * Build the shareable URL for a time: the base URL (default: the current
93
+ * page) with its `t` param set — every other query param preserved. This is
94
+ * the builder MOS's "copy link at this time" uses (`?t=90s`) and the shape
95
+ * hosts pass as VideoPlayer's `buildTimeLink` prop. Null when there is no
96
+ * URL to build on (SSR, garbage base) — a builder that can't build returns
97
+ * nothing rather than throwing.
98
+ */
99
+ export function buildTimeUrl(sec, url = typeof location !== 'undefined' ? location.href : null) {
100
+ if (!url)
101
+ return null;
102
+ try {
103
+ const u = new URL(String(url), typeof location !== 'undefined' ? location.href : undefined);
104
+ u.searchParams.set('t', formatTimeParam(sec));
105
+ return u.toString();
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
91
111
  /** Strip the `t` param (e.g. when the host unmounts the player opt-in). */
92
112
  export function clearTimeParam(url = typeof location !== 'undefined' ? location.href : null) {
93
113
  if (typeof history === 'undefined' || typeof history.replaceState !== 'function')
@@ -36,6 +36,10 @@ export const PLAYER_KEYS = [
36
36
  { keys: 't', description: 'Theater mode on / off', when: 'theater' },
37
37
  { keys: 'Shift+T', description: 'Cycle transcript layout: side / below / hidden', when: 'transcript' },
38
38
  { keys: '/', description: 'Search transcript', when: 'transcript' },
39
+ // jatui-3wl5x — right-click a line (or the keyboard's right-click on a
40
+ // focused one) for the line menu: play from here, copy text / with time /
41
+ // link at time (the link verb only when the host supplies buildTimeLink).
42
+ { keys: 'Menu / Shift+F10', description: 'On a transcript line: the line menu', when: 'transcript' },
39
43
  // Shift+wheel over the transcript pane steps PLAYBACK line by line —
40
44
  // a plain wheel there scrolls the pane natively and is never claimed.
41
45
  { keys: 'Shift+wheel', description: 'Over the transcript: steps line by line', when: 'transcript' },
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Transcript line right-click menu (jatui-3wl5x — extension-library parity,
3
+ * library.js:8458): right-clicking a transcript line (or Context-menu /
4
+ * Shift+F10 on a focused one) offers the line's verbs — Play from here,
5
+ * Copy text, Copy text with timestamp, and — when the host supplies a URL
6
+ * builder — Copy link at this time (MOS builds `?t=`; `buildTimeUrl` in
7
+ * deepLink.ts is that builder for the host's `buildTimeLink` prop).
8
+ *
9
+ * The pure pieces live here so they stay node-testable; the component owns
10
+ * the DOM: which row opened the menu, where it opens, focus return.
11
+ */
12
+ /**
13
+ * The context-menu key (`e.key === 'ContextMenu'`) or Shift+F10 — the
14
+ * keyboard's right-click. Engines dispatch one or the other depending on
15
+ * the keyboard; plain F10 stays the browser's own menu-bar key.
16
+ */
17
+ export declare function isContextMenuKey(e: {
18
+ key: string;
19
+ shiftKey: boolean;
20
+ }): boolean;
21
+ /**
22
+ * A transcript line quoted with its timestamp — `[1:23] The line text`.
23
+ * The time renders in the row's own clock (hours form on long videos), so
24
+ * what lands on the clipboard is exactly what the viewer read on screen.
25
+ */
26
+ export declare function lineTextWithTime(text: string, startSec: number, useHours: boolean): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Transcript line right-click menu (jatui-3wl5x — extension-library parity,
3
+ * library.js:8458): right-clicking a transcript line (or Context-menu /
4
+ * Shift+F10 on a focused one) offers the line's verbs — Play from here,
5
+ * Copy text, Copy text with timestamp, and — when the host supplies a URL
6
+ * builder — Copy link at this time (MOS builds `?t=`; `buildTimeUrl` in
7
+ * deepLink.ts is that builder for the host's `buildTimeLink` prop).
8
+ *
9
+ * The pure pieces live here so they stay node-testable; the component owns
10
+ * the DOM: which row opened the menu, where it opens, focus return.
11
+ */
12
+ import { formatTime } from './time';
13
+ /**
14
+ * The context-menu key (`e.key === 'ContextMenu'`) or Shift+F10 — the
15
+ * keyboard's right-click. Engines dispatch one or the other depending on
16
+ * the keyboard; plain F10 stays the browser's own menu-bar key.
17
+ */
18
+ export function isContextMenuKey(e) {
19
+ return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey);
20
+ }
21
+ /**
22
+ * A transcript line quoted with its timestamp — `[1:23] The line text`.
23
+ * The time renders in the row's own clock (hours form on long videos), so
24
+ * what lands on the clipboard is exactly what the viewer read on screen.
25
+ */
26
+ export function lineTextWithTime(text, startSec, useHours) {
27
+ return `[${formatTime(startSec, useHours)}] ${text}`;
28
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Browser-fill theater (jatui-c5uob, upstreamed from MOS mos-wyjyh).
3
+ *
4
+ * `theaterMode="fill"` turns VideoPlayer's theater into a fixed,
5
+ * full-viewport dialog instead of asking the host to widen its page. The
6
+ * component owns the markup and CSS; this module holds the DOM plumbing a
7
+ * modal surface needs, kept framework-free so it can be tested on its own:
8
+ *
9
+ * - `resolveTheaterMode` — the prop's never-throws coercion;
10
+ * - `fillFocusables` / `trapFillTab` — Tab and Shift+Tab cycle inside the
11
+ * player while it fills the window;
12
+ * - `lockPageScroll` — the page under the overlay doesn't scroll. Counted,
13
+ * so two filled players (or a player and a host modal using the same
14
+ * lock) release it only when the last one lets go.
15
+ */
16
+ export type TheaterMode = 'width' | 'fill';
17
+ /** Unknown or missing values are today's behaviour: `'width'`. */
18
+ export declare function resolveTheaterMode(v: unknown): TheaterMode;
19
+ /** Tab stops inside `root`, in document order. Hidden, inert and
20
+ * `tabindex="-1"` elements are skipped. */
21
+ export declare function fillFocusables(root: Element): HTMLElement[];
22
+ /**
23
+ * Keep Tab inside `root`. Call it from a keydown handler; it returns true
24
+ * (and preventDefaults) only when it moved focus itself — at the first or
25
+ * last stop, when focus has escaped the root, or when the root has no stops
26
+ * at all. Otherwise the browser's own Tab order is left to run.
27
+ */
28
+ export declare function trapFillTab(root: HTMLElement, e: KeyboardEvent): boolean;
29
+ /**
30
+ * Stop the page from scrolling behind a full-window surface. The scrollbar's
31
+ * width is put back as padding so the page doesn't shift sideways. Returns
32
+ * the release function; calling it twice is harmless.
33
+ */
34
+ export declare function lockPageScroll(doc: Document): () => void;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Browser-fill theater (jatui-c5uob, upstreamed from MOS mos-wyjyh).
3
+ *
4
+ * `theaterMode="fill"` turns VideoPlayer's theater into a fixed,
5
+ * full-viewport dialog instead of asking the host to widen its page. The
6
+ * component owns the markup and CSS; this module holds the DOM plumbing a
7
+ * modal surface needs, kept framework-free so it can be tested on its own:
8
+ *
9
+ * - `resolveTheaterMode` — the prop's never-throws coercion;
10
+ * - `fillFocusables` / `trapFillTab` — Tab and Shift+Tab cycle inside the
11
+ * player while it fills the window;
12
+ * - `lockPageScroll` — the page under the overlay doesn't scroll. Counted,
13
+ * so two filled players (or a player and a host modal using the same
14
+ * lock) release it only when the last one lets go.
15
+ */
16
+ /** Unknown or missing values are today's behaviour: `'width'`. */
17
+ export function resolveTheaterMode(v) {
18
+ return v === 'fill' ? 'fill' : 'width';
19
+ }
20
+ const FOCUSABLE = [
21
+ 'a[href]',
22
+ 'area[href]',
23
+ 'button:not([disabled])',
24
+ 'input:not([disabled]):not([type="hidden"])',
25
+ 'select:not([disabled])',
26
+ 'textarea:not([disabled])',
27
+ 'video[controls]',
28
+ 'audio[controls]',
29
+ '[contenteditable=""]',
30
+ '[contenteditable="true"]',
31
+ '[tabindex]'
32
+ ].join(',');
33
+ /** Tab stops inside `root`, in document order. Hidden, inert and
34
+ * `tabindex="-1"` elements are skipped. */
35
+ export function fillFocusables(root) {
36
+ const out = [];
37
+ for (const el of root.querySelectorAll(FOCUSABLE)) {
38
+ if (el.tabIndex < 0)
39
+ continue;
40
+ if (el.closest('[hidden],[inert]'))
41
+ continue;
42
+ // checkVisibility catches display:none / visibility:hidden ancestors;
43
+ // engines without it (jsdom) count the element as visible.
44
+ const check = el.checkVisibility;
45
+ if (check && !check.call(el, { visibilityProperty: true }))
46
+ continue;
47
+ out.push(el);
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * Keep Tab inside `root`. Call it from a keydown handler; it returns true
53
+ * (and preventDefaults) only when it moved focus itself — at the first or
54
+ * last stop, when focus has escaped the root, or when the root has no stops
55
+ * at all. Otherwise the browser's own Tab order is left to run.
56
+ */
57
+ export function trapFillTab(root, e) {
58
+ if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey)
59
+ return false;
60
+ const stops = fillFocusables(root);
61
+ const active = root.ownerDocument.activeElement;
62
+ const inside = !!active && root.contains(active);
63
+ if (stops.length === 0) {
64
+ e.preventDefault();
65
+ root.focus();
66
+ return true;
67
+ }
68
+ const first = stops[0];
69
+ const last = stops[stops.length - 1];
70
+ let next = null;
71
+ if (!inside || active === root)
72
+ next = e.shiftKey ? last : first;
73
+ else if (e.shiftKey && active === first)
74
+ next = last;
75
+ else if (!e.shiftKey && active === last)
76
+ next = first;
77
+ if (!next)
78
+ return false;
79
+ e.preventDefault();
80
+ next.focus();
81
+ return true;
82
+ }
83
+ const locks = new WeakMap();
84
+ /**
85
+ * Stop the page from scrolling behind a full-window surface. The scrollbar's
86
+ * width is put back as padding so the page doesn't shift sideways. Returns
87
+ * the release function; calling it twice is harmless.
88
+ */
89
+ export function lockPageScroll(doc) {
90
+ const html = doc.documentElement;
91
+ let state = locks.get(doc);
92
+ if (!state) {
93
+ const win = doc.defaultView;
94
+ const gutter = win ? Math.max(0, win.innerWidth - html.clientWidth) : 0;
95
+ state = { count: 0, overflow: html.style.overflow, paddingRight: html.style.paddingRight };
96
+ locks.set(doc, state);
97
+ html.style.overflow = 'hidden';
98
+ if (gutter > 0)
99
+ html.style.paddingRight = `${gutter}px`;
100
+ }
101
+ state.count++;
102
+ let released = false;
103
+ return () => {
104
+ if (released)
105
+ return;
106
+ released = true;
107
+ const s = locks.get(doc);
108
+ if (!s)
109
+ return;
110
+ s.count--;
111
+ if (s.count > 0)
112
+ return;
113
+ html.style.overflow = s.overflow;
114
+ html.style.paddingRight = s.paddingRight;
115
+ locks.delete(doc);
116
+ };
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",