@joewinke/jatui 0.52.0 → 0.53.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 +1 -1
- package/dist/components/VideoPlayer.svelte +248 -1
- package/dist/components/VideoPlayer.svelte.d.ts +6 -0
- package/dist/video/README.md +1 -0
- package/dist/video/advisors/README.md +1 -1
- package/dist/video/qrShare.d.ts +55 -0
- package/dist/video/qrShare.js +86 -0
- package/package.json +1 -1
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); `/` opens an in-transcript search (only when a transcript exists and focus is not in a text field; the omnibar yields): matches highlight in place with a live `n / m` count (`aria-live`), Enter/Shift+Enter walk next/previous (seek + the line follows), Esc closes the search first in the Esc layering. 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?` |
|
|
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); `/` opens an in-transcript search (only when a transcript exists and focus is not in a text field; the omnibar yields): matches highlight in place with a live `n / m` count (`aria-live`), Enter/Shift+Enter walk next/previous (seek + the line follows), Esc closes the search first in the Esc layering. 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. A QR button on the transport (when `buildTimeLink` exists) opens a **continue-on-phone** card — the deep link at the current second as a scannable SVG QR; clicking the code copies it (jatui-3k7cp). 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), `qrContinue?` (true — the QR button: the host's `buildTimeLink` at the current second as an SVG QR card, click the code to copy; hidden entirely when no `buildTimeLink` — the player never guesses a URL), `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) |
|
|
@@ -201,6 +201,7 @@
|
|
|
201
201
|
} from '../video/rate';
|
|
202
202
|
import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
|
|
203
203
|
import { isContextMenuKey, lineTextWithTime } from '../video/lineMenu';
|
|
204
|
+
import { qrSymbol, shareLinkAt } from '../video/qrShare';
|
|
204
205
|
import {
|
|
205
206
|
CAPTURE_NO_CORS_MESSAGE,
|
|
206
207
|
CAPTURE_NO_FRAME_MESSAGE,
|
|
@@ -340,6 +341,16 @@
|
|
|
340
341
|
* said, not hidden.
|
|
341
342
|
*/
|
|
342
343
|
onCaptureFrame = undefined,
|
|
344
|
+
/**
|
|
345
|
+
* Continue on phone (jatui-3k7cp, extension-library parity
|
|
346
|
+
* library.js:4635): a QR button on the band transport opens a card with
|
|
347
|
+
* the deep link AT THE CURRENT SECOND as a scannable QR code — the
|
|
348
|
+
* host's `buildTimeLink` builds it (MOS builds its watch URL with
|
|
349
|
+
* `?t=`); clicking the code copies the link. Hidden entirely when the
|
|
350
|
+
* host supplies no `buildTimeLink` — the player never guesses a URL.
|
|
351
|
+
* Default on; `false` removes the button.
|
|
352
|
+
*/
|
|
353
|
+
qrContinue = true,
|
|
343
354
|
theater = $bindable(false),
|
|
344
355
|
theaterMode = 'width',
|
|
345
356
|
theaterButton = true,
|
|
@@ -553,6 +564,12 @@
|
|
|
553
564
|
frameCapture?: boolean;
|
|
554
565
|
/** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
|
|
555
566
|
onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
|
|
567
|
+
/**
|
|
568
|
+
* Continue on phone (jatui-3k7cp) — see the destructure note above.
|
|
569
|
+
* Needs `buildTimeLink`: the URL is the host's (the player never
|
|
570
|
+
* guesses one), so without a builder there is simply no QR button.
|
|
571
|
+
*/
|
|
572
|
+
qrContinue?: boolean;
|
|
556
573
|
/**
|
|
557
574
|
* Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
|
|
558
575
|
* only says it wants the page's width; the HOST lays its page out
|
|
@@ -1062,6 +1079,46 @@
|
|
|
1062
1079
|
captureActions({ clipboardImage: captureClipboardOk, hostHook: onCaptureFrame != null })
|
|
1063
1080
|
);
|
|
1064
1081
|
|
|
1082
|
+
// ── Continue on phone (jatui-3k7cp, extension library.js:4635) ───────
|
|
1083
|
+
let qrOpen = $state(false);
|
|
1084
|
+
let qrBtnEl: HTMLButtonElement | undefined = $state();
|
|
1085
|
+
/** The link the open symbol was built from — frozen at open so the code
|
|
1086
|
+
* under the phone's camera never changes mid-scan. */
|
|
1087
|
+
let qrLink = $state('');
|
|
1088
|
+
/** The second the link was frozen at — the card's own clock. */
|
|
1089
|
+
let qrSec = $state(0);
|
|
1090
|
+
/** Feature on: the host must own the URL (buildTimeLink). Without a
|
|
1091
|
+
* builder there is nothing to encode — the player never guesses one —
|
|
1092
|
+
* so the button simply doesn't exist. */
|
|
1093
|
+
const qrShareOn = $derived(qrContinue && typeof buildTimeLink === 'function');
|
|
1094
|
+
/** The symbol for the frozen link. Null ⇒ the card says the link is
|
|
1095
|
+
* beyond the encoder's ceiling and keeps the copy verb working. */
|
|
1096
|
+
const qrSym = $derived(qrOpen && qrLink ? qrSymbol(qrLink) : null);
|
|
1097
|
+
|
|
1098
|
+
/** Open the QR card: freeze the host's link at the CURRENT SECOND, then
|
|
1099
|
+
* show the card. A builder that answers nothing (or throws) is said on
|
|
1100
|
+
* the OSD — never a silent no-op, never an empty card. */
|
|
1101
|
+
function openQrCard() {
|
|
1102
|
+
const link = shareLinkAt(buildTimeLink, currentTime);
|
|
1103
|
+
if (!link) {
|
|
1104
|
+
flashOsd('No link to share');
|
|
1105
|
+
announce('No link to share — the host did not supply one');
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
qrLink = link;
|
|
1109
|
+
qrSec = Math.max(0, Math.floor(currentTime));
|
|
1110
|
+
qrOpen = true;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Clicking the code copies the link (and closes — the scan or the
|
|
1114
|
+
* paste is the point; the card's job is done). The failure is said. */
|
|
1115
|
+
async function copyQrLink() {
|
|
1116
|
+
const ok = await copyText(qrLink);
|
|
1117
|
+
flashOsd(ok ? 'Link copied' : 'Copy failed');
|
|
1118
|
+
announce(ok ? 'Link copied to clipboard' : 'Copy failed');
|
|
1119
|
+
if (ok) qrOpen = false;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1065
1122
|
let zoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1066
1123
|
let osdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1067
1124
|
|
|
@@ -3286,7 +3343,10 @@
|
|
|
3286
3343
|
// menu closes itself, the theater must not leave with it. The
|
|
3287
3344
|
// panel's own handler may already have closed it by the time this
|
|
3288
3345
|
// bubble-phase handler runs, hence the target check too.
|
|
3289
|
-
!(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]')))
|
|
3346
|
+
!(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]'))) &&
|
|
3347
|
+
// The continue-on-phone card (jatui-3k7cp) owns Escape while it
|
|
3348
|
+
// is open — Escape closes the card, never the theater.
|
|
3349
|
+
!qrOpen
|
|
3290
3350
|
) {
|
|
3291
3351
|
e.preventDefault();
|
|
3292
3352
|
toggleTheater();
|
|
@@ -3315,6 +3375,15 @@
|
|
|
3315
3375
|
// shared ContextMenu's own behaviour (focus moves out with the menu
|
|
3316
3376
|
// still up; click/Escape dismiss it).
|
|
3317
3377
|
if (lineMenuOpen || captureMenuOpen) return;
|
|
3378
|
+
// The continue-on-phone card (jatui-3k7cp) owns Escape while it is
|
|
3379
|
+
// open; nothing else drives the player underneath it.
|
|
3380
|
+
if (qrOpen) {
|
|
3381
|
+
if (e.key === 'Escape') {
|
|
3382
|
+
e.preventDefault();
|
|
3383
|
+
qrOpen = false;
|
|
3384
|
+
}
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
3318
3387
|
// mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
|
|
3319
3388
|
// activation) while focus is inside it; without this guard a rail `k`
|
|
3320
3389
|
// would also toggle play/pause and a rail `j` also seek ±5s.
|
|
@@ -4489,6 +4558,29 @@
|
|
|
4489
4558
|
</svg>
|
|
4490
4559
|
</button>
|
|
4491
4560
|
{/if}
|
|
4561
|
+
{#if qrShareOn}
|
|
4562
|
+
<!-- Continue on phone (jatui-3k7cp): opens the QR card for the
|
|
4563
|
+
host's buildTimeLink at the current second. A share verb,
|
|
4564
|
+
so it sits beside the capture verb at the end of the
|
|
4565
|
+
cluster. Works for audio too — the link is the point,
|
|
4566
|
+
not the picture. -->
|
|
4567
|
+
<button
|
|
4568
|
+
type="button"
|
|
4569
|
+
class="vp-tbtn"
|
|
4570
|
+
bind:this={qrBtnEl}
|
|
4571
|
+
aria-haspopup="dialog"
|
|
4572
|
+
aria-expanded={qrOpen}
|
|
4573
|
+
aria-label="Continue on phone"
|
|
4574
|
+
title="Continue on phone"
|
|
4575
|
+
onclick={openQrCard}
|
|
4576
|
+
>
|
|
4577
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
|
4578
|
+
<!-- QR glyph: three finder squares + a few data modules -->
|
|
4579
|
+
<path d="M3.5 3.5h6v6h-6zM14.5 3.5h6v6h-6zM3.5 14.5h6v6h-6z" />
|
|
4580
|
+
<path d="M14.5 14.5h2.5v2.5h-2.5zM19 14.5h1.5v1.5H19zM14.5 19h1.5v1.5h-1.5zM18.5 18.5h2v2h-2z" fill="currentColor" stroke="none" />
|
|
4581
|
+
</svg>
|
|
4582
|
+
</button>
|
|
4583
|
+
{/if}
|
|
4492
4584
|
{#if !isAudio}
|
|
4493
4585
|
<button
|
|
4494
4586
|
type="button"
|
|
@@ -4900,6 +4992,55 @@
|
|
|
4900
4992
|
header={formatTime(currentTime, useHours)}
|
|
4901
4993
|
/>
|
|
4902
4994
|
{/if}
|
|
4995
|
+
|
|
4996
|
+
{#if qrShareOn && qrOpen}
|
|
4997
|
+
<!-- Continue-on-phone card (jatui-3k7cp, extension library.js:4635):
|
|
4998
|
+
the same overlay family as the `?` help card — centered over the
|
|
4999
|
+
player, big enough to scan from arm's length, backdrop click or
|
|
5000
|
+
Escape closes. The link was FROZEN at open (qrLink), so the code
|
|
5001
|
+
under the phone's camera never changes mid-scan. The code is a real
|
|
5002
|
+
button: click it (or press Enter/Space on it) and the link is on
|
|
5003
|
+
the clipboard. Rendered as SVG <path> data from qrSymbol() — never
|
|
5004
|
+
{@html} of untrusted text. The black-on-white symbol is deliberate:
|
|
5005
|
+
QR contrast rules are about the SCANNER, not the theme, and a
|
|
5006
|
+
theme-tinted code scans far worse in dim rooms. -->
|
|
5007
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
5008
|
+
<div class="vp-qr-backdrop" onclick={(e) => { if (e.target === e.currentTarget) qrOpen = false; }}>
|
|
5009
|
+
<div class="vp-qr-card" role="dialog" aria-label="Continue on phone">
|
|
5010
|
+
<div class="vp-qr-title">Continue on phone</div>
|
|
5011
|
+
<div class="vp-qr-time">{formatTime(qrSec, useHours)}</div>
|
|
5012
|
+
{#if qrSym}
|
|
5013
|
+
<button
|
|
5014
|
+
type="button"
|
|
5015
|
+
class="vp-qr-code"
|
|
5016
|
+
aria-label="Copy the link: {qrLink}"
|
|
5017
|
+
title="Click to copy the link"
|
|
5018
|
+
onclick={copyQrLink}
|
|
5019
|
+
>
|
|
5020
|
+
<svg
|
|
5021
|
+
viewBox="0 0 {qrSym.size} {qrSym.size}"
|
|
5022
|
+
shape-rendering="crispEdges"
|
|
5023
|
+
role="img"
|
|
5024
|
+
aria-hidden="true"
|
|
5025
|
+
>
|
|
5026
|
+
<rect width="{qrSym.size}" height="{qrSym.size}" fill="#ffffff" />
|
|
5027
|
+
<path d={qrSym.path} fill="#000000" />
|
|
5028
|
+
</svg>
|
|
5029
|
+
<span class="vp-qr-hint">Click to copy the link</span>
|
|
5030
|
+
</button>
|
|
5031
|
+
<div class="vp-qr-link" title={qrLink}>{qrLink}</div>
|
|
5032
|
+
{:else}
|
|
5033
|
+
<!-- The encoder's ceiling (version 10) — said, with the copy
|
|
5034
|
+
verb kept working: the QR is the affordance, the link is
|
|
5035
|
+
the point. -->
|
|
5036
|
+
<p class="vp-qr-fallback">
|
|
5037
|
+
This link is too long for a QR code.
|
|
5038
|
+
</p>
|
|
5039
|
+
<button type="button" class="vp-qr-copy" onclick={copyQrLink}>Copy the link instead</button>
|
|
5040
|
+
{/if}
|
|
5041
|
+
</div>
|
|
5042
|
+
</div>
|
|
5043
|
+
{/if}
|
|
4903
5044
|
</div>
|
|
4904
5045
|
|
|
4905
5046
|
<style>
|
|
@@ -6241,6 +6382,112 @@
|
|
|
6241
6382
|
box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
6242
6383
|
}
|
|
6243
6384
|
|
|
6385
|
+
/* ── Continue on phone (jatui-3k7cp) — the QR card ─────────────────── */
|
|
6386
|
+
/* Same overlay family as the `?` help card: centered over the player,
|
|
6387
|
+
backdrop click / Escape closes, focus stays in the root. */
|
|
6388
|
+
.vp-qr-backdrop {
|
|
6389
|
+
position: absolute;
|
|
6390
|
+
inset: 0;
|
|
6391
|
+
display: flex;
|
|
6392
|
+
align-items: center;
|
|
6393
|
+
justify-content: center;
|
|
6394
|
+
background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
|
|
6395
|
+
z-index: 10;
|
|
6396
|
+
border-radius: inherit;
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
.vp-qr-card {
|
|
6400
|
+
display: flex;
|
|
6401
|
+
flex-direction: column;
|
|
6402
|
+
align-items: center;
|
|
6403
|
+
gap: var(--jatui-space-sm, 4px);
|
|
6404
|
+
max-width: min(20rem, 90%);
|
|
6405
|
+
padding: var(--jatui-space-lg, 12px);
|
|
6406
|
+
background: var(--color-base-100);
|
|
6407
|
+
border: var(--jatui-border-w, 1px) solid
|
|
6408
|
+
color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
6409
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
6410
|
+
color: var(--color-base-content);
|
|
6411
|
+
box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
6412
|
+
}
|
|
6413
|
+
|
|
6414
|
+
.vp-qr-title {
|
|
6415
|
+
font-weight: 600;
|
|
6416
|
+
font-size: 0.875rem;
|
|
6417
|
+
}
|
|
6418
|
+
|
|
6419
|
+
.vp-qr-time {
|
|
6420
|
+
font-size: 0.75rem;
|
|
6421
|
+
opacity: 0.65;
|
|
6422
|
+
font-variant-numeric: tabular-nums;
|
|
6423
|
+
}
|
|
6424
|
+
|
|
6425
|
+
/* The code itself is a real button — click copies. The black-on-white
|
|
6426
|
+
symbol is FIXED colour, not theme tokens: QR contrast is about the
|
|
6427
|
+
scanner, not the palette (a theme-tinted code scans badly in dim
|
|
6428
|
+
rooms). Crisp edges keep modules square at every scale. */
|
|
6429
|
+
.vp-qr-code {
|
|
6430
|
+
display: flex;
|
|
6431
|
+
flex-direction: column;
|
|
6432
|
+
align-items: center;
|
|
6433
|
+
gap: var(--jatui-space-xs, 2px);
|
|
6434
|
+
padding: var(--jatui-space-sm, 4px);
|
|
6435
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
6436
|
+
background: transparent;
|
|
6437
|
+
border: none;
|
|
6438
|
+
cursor: pointer;
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6441
|
+
.vp-qr-code:focus-visible {
|
|
6442
|
+
outline: 2px solid var(--color-primary);
|
|
6443
|
+
outline-offset: 2px;
|
|
6444
|
+
}
|
|
6445
|
+
|
|
6446
|
+
.vp-qr-code svg {
|
|
6447
|
+
width: 13rem;
|
|
6448
|
+
height: 13rem;
|
|
6449
|
+
max-width: 100%;
|
|
6450
|
+
display: block;
|
|
6451
|
+
border-radius: 0.25rem;
|
|
6452
|
+
}
|
|
6453
|
+
|
|
6454
|
+
.vp-qr-hint {
|
|
6455
|
+
font-size: 0.6875rem;
|
|
6456
|
+
opacity: 0.6;
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
.vp-qr-link {
|
|
6460
|
+
max-width: 100%;
|
|
6461
|
+
overflow: hidden;
|
|
6462
|
+
text-overflow: ellipsis;
|
|
6463
|
+
white-space: nowrap;
|
|
6464
|
+
font-size: 0.6875rem;
|
|
6465
|
+
opacity: 0.75;
|
|
6466
|
+
direction: rtl;
|
|
6467
|
+
text-align: left;
|
|
6468
|
+
}
|
|
6469
|
+
|
|
6470
|
+
.vp-qr-fallback {
|
|
6471
|
+
font-size: 0.8125rem;
|
|
6472
|
+
opacity: 0.8;
|
|
6473
|
+
margin: var(--jatui-space-sm, 4px) 0 0;
|
|
6474
|
+
}
|
|
6475
|
+
|
|
6476
|
+
.vp-qr-copy {
|
|
6477
|
+
padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
|
|
6478
|
+
border-radius: var(--jatui-radius-field, 0.5rem);
|
|
6479
|
+
border: var(--jatui-border-w, 1px) solid
|
|
6480
|
+
color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
6481
|
+
background: transparent;
|
|
6482
|
+
color: inherit;
|
|
6483
|
+
font-size: 0.8125rem;
|
|
6484
|
+
cursor: pointer;
|
|
6485
|
+
}
|
|
6486
|
+
|
|
6487
|
+
.vp-qr-copy:hover {
|
|
6488
|
+
background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
6489
|
+
}
|
|
6490
|
+
|
|
6244
6491
|
.vp-keys-title {
|
|
6245
6492
|
font-size: 0.66rem;
|
|
6246
6493
|
font-weight: 700;
|
|
@@ -222,6 +222,12 @@ type $$ComponentProps = {
|
|
|
222
222
|
frameCapture?: boolean;
|
|
223
223
|
/** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
|
|
224
224
|
onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
|
|
225
|
+
/**
|
|
226
|
+
* Continue on phone (jatui-3k7cp) — see the destructure note above.
|
|
227
|
+
* Needs `buildTimeLink`: the URL is the host's (the player never
|
|
228
|
+
* guesses one), so without a builder there is simply no QR button.
|
|
229
|
+
*/
|
|
230
|
+
qrContinue?: boolean;
|
|
225
231
|
/**
|
|
226
232
|
* Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
|
|
227
233
|
* only says it wants the page's width; the HOST lays its page out
|
package/dist/video/README.md
CHANGED
|
@@ -22,6 +22,7 @@ props + callback surfaces.
|
|
|
22
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
23
|
| `capture.ts` | **jatui-8nu4g** (done) | The frame-capture menu's pure pieces (extension-library parity, library.js:9262): `captureFilename` (the frame clock as the name — `frame-1m30s.png`, junk times clamp to 0), `clipboardImageSupported` (secure context + `navigator.clipboard.write` + `ClipboardItem` — false ⇒ save-only degrade, the Copy row is OMITTED, never dead), `captureActions` (which verbs the menu carries — save always, copy only where the platform can write an image, poster only when the host supplied `onCaptureFrame`), `frameDrawSize` (null before metadata — a 0×0 canvas is never a valid capture), `isCrossOriginCaptureError` (the SecurityError NAME is the contract — a tainted canvas throws SecurityError out of toBlob/toDataURL itself) and `VideoFrameCapture` (what the host's "set as poster" hook receives: blob, dataUrl, timeSec, width, height, name). The component owns the canvas and the `<video>` (drawImage / toBlob / toDataURL / download / clipboard calls live there); a cross-origin video without CORS is SAID — `CAPTURE_NO_CORS_MESSAGE` on the OSD + polite region, never a silent no-op. |
|
|
24
24
|
| `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`. |
|
|
25
|
+
| `qrShare.ts` | **jatui-3k7cp** | "Continue on phone" pure pieces (extension-library parity, library.js:4635): `qrSymbol` — the QR of a link as SVG PATH DATA over `advisors/qr`'s zero-dep encoder (dark modules run-merged into one `path` the test re-parses; quiet zone 4; null, never a throw, when the text can't be a symbol — the component says "too long" and keeps copy working), and `shareLinkAt` (the host's `buildTimeLink` at the FLOORED current second; every broken shape degrades to null). The URL is the host's — no builder, no button. The component owns the card: centered like the `?` help overlay, link frozen at open so the code under the phone's camera never changes mid-scan, clicking the code copies, SVG only — never `{@html}` of untrusted text. |
|
|
25
26
|
| `transcriptSearch.ts` | **jatui-xmtnv** (E25/E57) | The transcript search + wheel line-scrub pure helpers: `searchHits` (case-insensitive, non-overlapping), `highlightRowHtml` (`<mark>`s around matches, everything else escaped — host transcript text is never markup), `pickMatch` (Enter walks matches from the playhead, wrapping), `lineScrubTarget` + `transcriptWheelDecision`/`effectiveWheelDelta` (Shift+wheel steps a line per ~100px notch; a plain wheel is never claimed). The component owns the box, focus, Esc layering and the `n / m` count. |
|
|
26
27
|
| `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. |
|
|
27
28
|
| `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. |
|
|
@@ -16,5 +16,5 @@
|
|
|
16
16
|
* | `speaker-rate` | each speaker's measured wpm, the rates that even them out | rate composer's `speaker` advisor (.32) |
|
|
17
17
|
* | `resume-point` | the nearest real boundary at-or-before a stored time + the line to say | resume-at-sentence (.34) |
|
|
18
18
|
* | `quiet-listening` | the loudness-compensated tone graph + how much of it is wanted | audio graph (.31) |
|
|
19
|
-
* | `qr` | the deep link as a scannable symbol | "continue on phone" (
|
|
19
|
+
* | `qr` | the deep link as a scannable symbol | "continue on phone" (jatui-3k7cp, via `../qrShare`) |
|
|
20
20
|
*/
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Continue on phone" (jatui-3k7cp — extension-library parity, library.js:4635
|
|
3
|
+
* / lib/qr.js): the video you are watching moves from the desk to a phone
|
|
4
|
+
* mid-sentence. The player shows a QR of the deep link AT THE CURRENT SECOND —
|
|
5
|
+
* the host's `buildTimeLink` builder (the same one the transcript line menu's
|
|
6
|
+
* "Copy link at this time" uses; MOS builds its watch URL with `?t=`) — the
|
|
7
|
+
* viewer points a phone camera at it, and clicking the symbol copies the link.
|
|
8
|
+
*
|
|
9
|
+
* The URL is the HOST's: the player never guesses one, and the whole feature
|
|
10
|
+
* is absent when no builder is supplied. The encoder underneath is the
|
|
11
|
+
* zero-dependency ISO 18004 port in `advisors/qr.ts` (deliberately no npm
|
|
12
|
+
* dependency — see that file's header for why).
|
|
13
|
+
*
|
|
14
|
+
* The pure pieces live here so they stay node-testable; the component owns
|
|
15
|
+
* the DOM: the button, the card, the clipboard write.
|
|
16
|
+
*
|
|
17
|
+
* The symbol is rendered as SVG PATH DATA — dark modules become one `path`
|
|
18
|
+
* element the host stylesheet/theme can never reflow — never `{@html}` of
|
|
19
|
+
* untrusted text.
|
|
20
|
+
*/
|
|
21
|
+
import { type EccLevel } from './advisors/qr';
|
|
22
|
+
/** The spec's minimum quiet zone — 4 modules of light around the symbol. */
|
|
23
|
+
export declare const QR_QUIET_ZONE = 4;
|
|
24
|
+
export interface QrSymbol {
|
|
25
|
+
/** SVG viewBox side in modules: the symbol plus the quiet zone on both sides. */
|
|
26
|
+
size: number;
|
|
27
|
+
/**
|
|
28
|
+
* Path data covering exactly the dark modules, one subpath per horizontal
|
|
29
|
+
* run (`M{x} {y}h{w}v1h-{w}z`), coordinates offset by the quiet zone so
|
|
30
|
+
* the viewBox is `0 0 size size`. Designed to be re-parsed: the unit test
|
|
31
|
+
* reconstructs the grid from this string and compares it against
|
|
32
|
+
* `qrMatrix`'s own modules.
|
|
33
|
+
*/
|
|
34
|
+
path: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The QR symbol for `text` as SVG geometry, or null when it cannot exist —
|
|
38
|
+
* non-string/empty input, or text beyond the encoder's ceiling (version 10
|
|
39
|
+
* at this ECC; ~213 bytes at M). Null, never a throw: the component SAYS
|
|
40
|
+
* "too long" and keeps the copy verb working — a missing symbol must not
|
|
41
|
+
* take the whole action down.
|
|
42
|
+
*/
|
|
43
|
+
export declare function qrSymbol(text: unknown, opts?: {
|
|
44
|
+
ecc?: EccLevel;
|
|
45
|
+
quietZone?: number;
|
|
46
|
+
}): QrSymbol | null;
|
|
47
|
+
/**
|
|
48
|
+
* The shareable link at a playback second, from the host's builder. Floors
|
|
49
|
+
* the second (the link's promise is "picks up at 1:23", not "1:22.87" — same
|
|
50
|
+
* convention as deepLink.ts's writes), and degrades to null rather than
|
|
51
|
+
* throwing: a builder that isn't a function, a non-finite time, a builder
|
|
52
|
+
* that throws, or one that answers empty all mean "no link to share", which
|
|
53
|
+
* the component says instead of opening an empty card.
|
|
54
|
+
*/
|
|
55
|
+
export declare function shareLinkAt(build: unknown, sec: number): string | null;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Continue on phone" (jatui-3k7cp — extension-library parity, library.js:4635
|
|
3
|
+
* / lib/qr.js): the video you are watching moves from the desk to a phone
|
|
4
|
+
* mid-sentence. The player shows a QR of the deep link AT THE CURRENT SECOND —
|
|
5
|
+
* the host's `buildTimeLink` builder (the same one the transcript line menu's
|
|
6
|
+
* "Copy link at this time" uses; MOS builds its watch URL with `?t=`) — the
|
|
7
|
+
* viewer points a phone camera at it, and clicking the symbol copies the link.
|
|
8
|
+
*
|
|
9
|
+
* The URL is the HOST's: the player never guesses one, and the whole feature
|
|
10
|
+
* is absent when no builder is supplied. The encoder underneath is the
|
|
11
|
+
* zero-dependency ISO 18004 port in `advisors/qr.ts` (deliberately no npm
|
|
12
|
+
* dependency — see that file's header for why).
|
|
13
|
+
*
|
|
14
|
+
* The pure pieces live here so they stay node-testable; the component owns
|
|
15
|
+
* the DOM: the button, the card, the clipboard write.
|
|
16
|
+
*
|
|
17
|
+
* The symbol is rendered as SVG PATH DATA — dark modules become one `path`
|
|
18
|
+
* element the host stylesheet/theme can never reflow — never `{@html}` of
|
|
19
|
+
* untrusted text.
|
|
20
|
+
*/
|
|
21
|
+
import { qrMatrix } from './advisors/qr';
|
|
22
|
+
/** The spec's minimum quiet zone — 4 modules of light around the symbol. */
|
|
23
|
+
export const QR_QUIET_ZONE = 4;
|
|
24
|
+
/**
|
|
25
|
+
* The QR symbol for `text` as SVG geometry, or null when it cannot exist —
|
|
26
|
+
* non-string/empty input, or text beyond the encoder's ceiling (version 10
|
|
27
|
+
* at this ECC; ~213 bytes at M). Null, never a throw: the component SAYS
|
|
28
|
+
* "too long" and keeps the copy verb working — a missing symbol must not
|
|
29
|
+
* take the whole action down.
|
|
30
|
+
*/
|
|
31
|
+
export function qrSymbol(text, opts) {
|
|
32
|
+
if (typeof text !== 'string' || text === '')
|
|
33
|
+
return null;
|
|
34
|
+
let quiet = Math.trunc(Number(opts?.quietZone));
|
|
35
|
+
if (!Number.isFinite(quiet) || quiet < 0)
|
|
36
|
+
quiet = QR_QUIET_ZONE;
|
|
37
|
+
quiet = Math.min(quiet, 8);
|
|
38
|
+
let matrix;
|
|
39
|
+
try {
|
|
40
|
+
matrix = qrMatrix(text, { ecc: opts?.ecc ?? 'M' });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const { size, modules } = matrix;
|
|
46
|
+
let path = '';
|
|
47
|
+
for (let y = 0; y < size; y++) {
|
|
48
|
+
const row = modules[y];
|
|
49
|
+
let x = 0;
|
|
50
|
+
while (x < size) {
|
|
51
|
+
if (!row[x]) {
|
|
52
|
+
x++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let run = 1;
|
|
56
|
+
while (x + run < size && row[x + run])
|
|
57
|
+
run++;
|
|
58
|
+
path += `M${x + quiet} ${y + quiet}h${run}v1h-${run}z`;
|
|
59
|
+
x += run;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { size: size + quiet * 2, path };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The shareable link at a playback second, from the host's builder. Floors
|
|
66
|
+
* the second (the link's promise is "picks up at 1:23", not "1:22.87" — same
|
|
67
|
+
* convention as deepLink.ts's writes), and degrades to null rather than
|
|
68
|
+
* throwing: a builder that isn't a function, a non-finite time, a builder
|
|
69
|
+
* that throws, or one that answers empty all mean "no link to share", which
|
|
70
|
+
* the component says instead of opening an empty card.
|
|
71
|
+
*/
|
|
72
|
+
export function shareLinkAt(build, sec) {
|
|
73
|
+
if (typeof build !== 'function')
|
|
74
|
+
return null;
|
|
75
|
+
const s = Number(sec);
|
|
76
|
+
if (!Number.isFinite(s) || s < 0)
|
|
77
|
+
return null;
|
|
78
|
+
let link;
|
|
79
|
+
try {
|
|
80
|
+
link = build(Math.floor(s));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return typeof link === 'string' && link !== '' ? link : null;
|
|
86
|
+
}
|