@joewinke/jatui 0.43.0 → 0.45.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 +2 -1
- package/dist/artifact-blocks/blocks/callout/block.js +5 -1
- package/dist/artifact-blocks/blocks/checklist/block.js +10 -5
- package/dist/artifact-blocks/blocks/table/block.js +9 -3
- package/dist/components/ImageReveal.svelte +353 -0
- package/dist/components/ImageReveal.svelte.d.ts +31 -0
- package/dist/components/VideoPlayer.svelte +218 -2
- package/dist/components/VideoPlayer.svelte.d.ts +5 -0
- package/dist/components/imageReveal.d.ts +51 -0
- package/dist/components/imageReveal.js +83 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/video/README.md +1 -1
- package/dist/video/settings/remembered.d.ts +30 -0
- package/dist/video/settings/remembered.js +44 -0
- package/dist/video/settings.d.ts +3 -0
- package/dist/video/settings.js +3 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -92,6 +92,7 @@ Then import:
|
|
|
92
92
|
| `LoadingSpinner` | Spinner animation | `size`, `color` |
|
|
93
93
|
| `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
|
|
94
94
|
| `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
|
|
95
|
+
| `ImageReveal` | Holds an image slot while the image doesn't exist yet (a logo lookup, an AI generation): a token-tinted ripple (`role="status"`, `aria-busy`) while `src` is null and while the bytes decode, then blur-to-sharp on `--motion-*` + `onReveal`. `src=''` or a failed load → calm initials from `alt` (or an image glyph) — never a broken `<img>`. Ripple is `.jatui-motion-ambient`: calm / reduced motion = static tint, instant swap. Clean-room (BoardUI's image-gen effect is Pro). Pure helpers: `imageRevealPhase`, `imageRevealInitials`. | `src` (`string \| null`), `alt`, `shape` (`square\|rounded\|circle`), `size` (`xs…xl` \| px \| CSS \| `fill`), `tone` (semantic or CSS colour), `label`, `initials?`, `fit?`, `phase` (bindable, read-only), `onReveal?(src)`, `onError?(src)`, `fallback?` (snippet) |
|
|
95
96
|
| `Empty` | Zero-state surface — v1 thin copy/children block still works; pass `title` for the v2 hero layout (upstreamed from the IDE's EmptyState): named `glyph` (11 curated Heroicons), `size` (sm/md/lg), `pulse` (reduced-motion gated, tinted with `--color-primary`), `icon` snippet override, CTA `actions` snippet, `description`, extra `children` below. | `title` (opts into v2), `description`, `glyph`, `size`, `pulse`, `icon`/`actions` (snippets), `copy`, `class`, `children` |
|
|
96
97
|
| `ErrorState` | Standard "this failed" surface (upstreamed from the IDE's ErrorState) — error-token themed icon, optional Retry button, extra `actions`, collapsible `details` block for raw error output. Centered card mirroring `Empty` v2. | `title`, `message`, `details`, `retryLabel`, `onRetry`, `size`, `actions` (snippet) |
|
|
97
98
|
| `HunkDiffView` | Side-by-side diff with line numbers, red/green backgrounds, strikethrough on removed lines, inline word-level highlights via the `diff` lib. Used for reviewing YAML/text changes per hunk. | `oldText: string \| null`, `newText: string \| null`, `unified?: boolean` |
|
|
@@ -176,7 +177,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
|
|
|
176
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 |
|
|
177
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?` |
|
|
178
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?` |
|
|
179
|
-
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
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
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?` |
|
|
181
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?` |
|
|
182
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) |
|
|
@@ -15,7 +15,11 @@ const EXAMPLE = {
|
|
|
15
15
|
meta: [
|
|
16
16
|
{ label: "asked by", value: "Support lead" },
|
|
17
17
|
{ label: "priority", value: "P1", tone: "warning" },
|
|
18
|
-
|
|
18
|
+
// https://example.com on purpose (like primaryAction below): the docs-site
|
|
19
|
+
// showcase renders this example and the prerender crawler follows every
|
|
20
|
+
// root-relative href — a fictional /tasks route 404s and fails the build
|
|
21
|
+
// (jatui-bg4u5). Off-origin https is skipped by the crawler.
|
|
22
|
+
{ label: "task", value: "ops-12ab3", href: "https://example.com/tasks?task=ops-12ab3" },
|
|
19
23
|
],
|
|
20
24
|
primaryAction: { label: "Open the training plan", url: "https://example.com/training" },
|
|
21
25
|
sections: [
|
|
@@ -4,11 +4,16 @@ import { checklistSchema, } from "./schema.js";
|
|
|
4
4
|
const EXAMPLE = {
|
|
5
5
|
title: "2026-10-08 deploy: UAT",
|
|
6
6
|
teamMode: true,
|
|
7
|
+
// Row urls point at https://example.com ON PURPOSE: the docs-site showcase
|
|
8
|
+
// renders this example and SvelteKit's prerender crawler follows every
|
|
9
|
+
// root-relative href (fictional /admin/... paths 404 and fail the build —
|
|
10
|
+
// jatui-bg4u5). Off-origin https is skipped by the crawler and renders the
|
|
11
|
+
// external/new-tab branch, so the example still demos test links.
|
|
7
12
|
rows: [
|
|
8
13
|
{
|
|
9
14
|
id: "board-day-move",
|
|
10
15
|
title: "Scheduling board day-move gestures + bulk move",
|
|
11
|
-
url: "/admin/scheduling/board",
|
|
16
|
+
url: "https://example.com/admin/scheduling/board",
|
|
12
17
|
owner: "LateClay769",
|
|
13
18
|
note: "Drag a card to another day (spring arrows), then bulk-move 3 cards; expect the undo toast.",
|
|
14
19
|
status: "untested",
|
|
@@ -16,7 +21,7 @@ const EXAMPLE = {
|
|
|
16
21
|
{
|
|
17
22
|
id: "team-roles",
|
|
18
23
|
title: "Per-member app roles (multi-role, primary)",
|
|
19
|
-
url: "/admin/team/members",
|
|
24
|
+
url: "https://example.com/admin/team/members",
|
|
20
25
|
owner: "jw",
|
|
21
26
|
note: "Give a member two roles, change the primary, sign in as them and check the sidebar.",
|
|
22
27
|
status: "pass",
|
|
@@ -24,7 +29,7 @@ const EXAMPLE = {
|
|
|
24
29
|
{
|
|
25
30
|
id: "blocks-phase1",
|
|
26
31
|
title: "Blocks artifacts render at /a/<slug>",
|
|
27
|
-
url: "/a/blocks-smoke",
|
|
32
|
+
url: "https://example.com/a/blocks-smoke",
|
|
28
33
|
owner: "agent",
|
|
29
34
|
note: "Publish a markdown + stats + callout page as blocks; open its URL signed in.",
|
|
30
35
|
status: "untested",
|
|
@@ -32,14 +37,14 @@ const EXAMPLE = {
|
|
|
32
37
|
{
|
|
33
38
|
id: "caller-id-trusthub",
|
|
34
39
|
title: "Caller ID card shows real Trust Hub caller-NAME state",
|
|
35
|
-
url: "/admin/voice/calls",
|
|
40
|
+
url: "https://example.com/admin/voice/calls",
|
|
36
41
|
owner: "agent",
|
|
37
42
|
status: "blocked",
|
|
38
43
|
},
|
|
39
44
|
{
|
|
40
45
|
id: "pe-booking-confirm",
|
|
41
46
|
title: "Booking confirmation email shows the visit window",
|
|
42
|
-
url: "/admin/scheduling/bookings",
|
|
47
|
+
url: "https://example.com/admin/scheduling/bookings",
|
|
43
48
|
owner: "agent",
|
|
44
49
|
note: "Fixed the timezone on the window; book a slot and check the email.",
|
|
45
50
|
status: "ready",
|
|
@@ -61,6 +61,12 @@ const def = {
|
|
|
61
61
|
],
|
|
62
62
|
},
|
|
63
63
|
],
|
|
64
|
+
// Example links point at https://example.com ON PURPOSE: the docs-site
|
|
65
|
+
// showcase renders this example and SvelteKit's prerender crawler follows
|
|
66
|
+
// every root-relative href (a fictional /admin/... path 404s and fails the
|
|
67
|
+
// build — jatui-bg4u5). Off-origin https is skipped by the crawler
|
|
68
|
+
// (is_root_relative) and renders the external/new-tab link branch, so the
|
|
69
|
+
// example still demos link columns without breaking prerender.
|
|
64
70
|
rows: [
|
|
65
71
|
{
|
|
66
72
|
invoice: "INV-1042",
|
|
@@ -69,7 +75,7 @@ const def = {
|
|
|
69
75
|
due: "2026-09-14",
|
|
70
76
|
days: 42,
|
|
71
77
|
status: "overdue",
|
|
72
|
-
url: "/admin/billing/invoices/INV-1042",
|
|
78
|
+
url: "https://example.com/admin/billing/invoices/INV-1042",
|
|
73
79
|
},
|
|
74
80
|
{
|
|
75
81
|
invoice: "INV-1043",
|
|
@@ -78,7 +84,7 @@ const def = {
|
|
|
78
84
|
due: "2026-10-02",
|
|
79
85
|
days: 6,
|
|
80
86
|
status: "current",
|
|
81
|
-
url: "/admin/billing/invoices/INV-1043",
|
|
87
|
+
url: "https://example.com/admin/billing/invoices/INV-1043",
|
|
82
88
|
},
|
|
83
89
|
{
|
|
84
90
|
invoice: "INV-1044",
|
|
@@ -87,7 +93,7 @@ const def = {
|
|
|
87
93
|
due: "2026-10-11",
|
|
88
94
|
days: 0,
|
|
89
95
|
status: "sent",
|
|
90
|
-
url: "/admin/billing/invoices/INV-1044",
|
|
96
|
+
url: "https://example.com/admin/billing/invoices/INV-1044",
|
|
91
97
|
},
|
|
92
98
|
],
|
|
93
99
|
pageSize: 50,
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ImageReveal — a frame that shows a "generating" ripple until its image
|
|
3
|
+
exists, then brings the image in blur-to-sharp. Built for slots whose image
|
|
4
|
+
arrives late: a logo being looked up, an AI generation, a thumbnail still
|
|
5
|
+
rendering.
|
|
6
|
+
|
|
7
|
+
src null / undefined → generating: ripple on the tone, role="status",
|
|
8
|
+
aria-busy
|
|
9
|
+
src set, not decoded → still the ripple (no half-loaded image is shown)
|
|
10
|
+
src loaded → blur + slight scale → sharp, ripple fades; onReveal
|
|
11
|
+
src '' or onerror → calm initials (from alt) or an image glyph — never a
|
|
12
|
+
broken image
|
|
13
|
+
|
|
14
|
+
Colours are one tone mixed with color-mix (no hex). The ripple is a
|
|
15
|
+
decorative loop: .jatui-motion-ambient, and under data-motion="calm" or
|
|
16
|
+
prefers-reduced-motion it becomes a static tint and the image swaps in
|
|
17
|
+
without the blur.
|
|
18
|
+
|
|
19
|
+
Clean-room (jatui-vui9q): BoardUI's image-generation effect is a Pro item;
|
|
20
|
+
nothing from it was read or copied.
|
|
21
|
+
-->
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
import type { Snippet } from 'svelte';
|
|
24
|
+
import {
|
|
25
|
+
IMAGE_REVEAL_RADIUS,
|
|
26
|
+
imageRevealBusy,
|
|
27
|
+
imageRevealInitials,
|
|
28
|
+
imageRevealPhase,
|
|
29
|
+
imageRevealSize,
|
|
30
|
+
imageRevealTone,
|
|
31
|
+
type ImageRevealPhase,
|
|
32
|
+
type ImageRevealShape,
|
|
33
|
+
type ImageRevealSizeName,
|
|
34
|
+
type ImageRevealToneName
|
|
35
|
+
} from './imageReveal';
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
/** null / undefined = still generating. '' = nothing to show (fallback). */
|
|
39
|
+
src?: string | null;
|
|
40
|
+
/** Alt text for the image; also the source of the fallback initials. */
|
|
41
|
+
alt?: string;
|
|
42
|
+
shape?: ImageRevealShape;
|
|
43
|
+
/** Named size, px number, any CSS length, or 'fill' (100% width). Always square. */
|
|
44
|
+
size?: ImageRevealSizeName | number | (string & {});
|
|
45
|
+
/** DaisyUI semantic name or any CSS colour. */
|
|
46
|
+
tone?: ImageRevealToneName | (string & {});
|
|
47
|
+
/** Screen-reader status while generating. */
|
|
48
|
+
label?: string;
|
|
49
|
+
/** Override the letters derived from `alt`. '' forces the glyph. */
|
|
50
|
+
initials?: string;
|
|
51
|
+
/** object-fit for the image. Logos usually want 'contain'. */
|
|
52
|
+
fit?: 'cover' | 'contain';
|
|
53
|
+
/** Read-only: the current phase. */
|
|
54
|
+
phase?: ImageRevealPhase;
|
|
55
|
+
/** Fires once per src, when that image has loaded. */
|
|
56
|
+
onReveal?: (src: string) => void;
|
|
57
|
+
/** Fires once per src, when that image failed to load. */
|
|
58
|
+
onError?: (src: string) => void;
|
|
59
|
+
/** Replaces the initials / glyph fallback. */
|
|
60
|
+
fallback?: Snippet;
|
|
61
|
+
class?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let {
|
|
65
|
+
src = null,
|
|
66
|
+
alt = '',
|
|
67
|
+
shape = 'rounded',
|
|
68
|
+
size = 'md',
|
|
69
|
+
tone = 'primary',
|
|
70
|
+
label = 'Generating image',
|
|
71
|
+
initials,
|
|
72
|
+
fit = 'cover',
|
|
73
|
+
phase = $bindable('generating'),
|
|
74
|
+
onReveal,
|
|
75
|
+
onError,
|
|
76
|
+
fallback,
|
|
77
|
+
class: className = ''
|
|
78
|
+
}: Props = $props();
|
|
79
|
+
|
|
80
|
+
let loadedSrc = $state<string | null>(null);
|
|
81
|
+
let failedSrc = $state<string | null>(null);
|
|
82
|
+
let img = $state<HTMLImageElement | null>(null);
|
|
83
|
+
|
|
84
|
+
const current = $derived(imageRevealPhase({ src, loadedSrc, failedSrc }));
|
|
85
|
+
const busy = $derived(imageRevealBusy(current));
|
|
86
|
+
const letters = $derived(initials ?? imageRevealInitials(alt));
|
|
87
|
+
|
|
88
|
+
$effect.pre(() => {
|
|
89
|
+
phase = current;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function loaded(from: string) {
|
|
93
|
+
if (from !== src || loadedSrc === from) return;
|
|
94
|
+
loadedSrc = from;
|
|
95
|
+
onReveal?.(from);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function failed(from: string) {
|
|
99
|
+
if (from !== src || failedSrc === from) return;
|
|
100
|
+
failedSrc = from;
|
|
101
|
+
onError?.(from);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// An image served from cache (or loaded before hydration) can finish before
|
|
105
|
+
// the load listener exists, so read its state once it is in the DOM.
|
|
106
|
+
$effect(() => {
|
|
107
|
+
if (!img || !src) return;
|
|
108
|
+
const el = img;
|
|
109
|
+
const from = src;
|
|
110
|
+
if (el.complete) {
|
|
111
|
+
if (el.naturalWidth > 0) loaded(from);
|
|
112
|
+
else if (el.currentSrc || el.getAttribute('src')) failed(from);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<div
|
|
118
|
+
class="jatui-image-reveal is-{current} {className}"
|
|
119
|
+
style:--jatui-ir-tone={imageRevealTone(tone)}
|
|
120
|
+
style:--jatui-ir-size={imageRevealSize(size)}
|
|
121
|
+
style:--jatui-ir-radius={IMAGE_REVEAL_RADIUS[shape]}
|
|
122
|
+
aria-busy={busy}
|
|
123
|
+
data-phase={current}
|
|
124
|
+
>
|
|
125
|
+
<span class="jatui-ir-status" role="status">{busy ? label : ''}</span>
|
|
126
|
+
|
|
127
|
+
{#if current !== 'fallback'}
|
|
128
|
+
<div class="jatui-ir-ripple" class:jatui-motion-ambient={busy} aria-hidden="true">
|
|
129
|
+
<span class="jatui-ir-ring"></span>
|
|
130
|
+
<span class="jatui-ir-ring"></span>
|
|
131
|
+
<span class="jatui-ir-ring"></span>
|
|
132
|
+
<span class="jatui-ir-core"></span>
|
|
133
|
+
</div>
|
|
134
|
+
{/if}
|
|
135
|
+
|
|
136
|
+
{#if src && current !== 'fallback'}
|
|
137
|
+
{#key src}
|
|
138
|
+
<img
|
|
139
|
+
bind:this={img}
|
|
140
|
+
class="jatui-ir-img"
|
|
141
|
+
class:is-shown={current === 'revealed'}
|
|
142
|
+
style:object-fit={fit}
|
|
143
|
+
{src}
|
|
144
|
+
{alt}
|
|
145
|
+
decoding="async"
|
|
146
|
+
onload={() => loaded(src!)}
|
|
147
|
+
onerror={() => failed(src!)}
|
|
148
|
+
/>
|
|
149
|
+
{/key}
|
|
150
|
+
{:else if current === 'fallback'}
|
|
151
|
+
{#if fallback}
|
|
152
|
+
<div class="jatui-ir-fallback">{@render fallback()}</div>
|
|
153
|
+
{:else if letters}
|
|
154
|
+
<div class="jatui-ir-fallback" role={alt ? 'img' : undefined} aria-label={alt || undefined}>
|
|
155
|
+
<span class="jatui-ir-initials" aria-hidden="true">{letters}</span>
|
|
156
|
+
</div>
|
|
157
|
+
{:else}
|
|
158
|
+
<div class="jatui-ir-fallback" role={alt ? 'img' : undefined} aria-label={alt || undefined}>
|
|
159
|
+
<svg class="jatui-ir-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
160
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
161
|
+
<circle cx="9" cy="10" r="1.75" />
|
|
162
|
+
<path d="m21 16-5-5-9 9" stroke-linecap="round" stroke-linejoin="round" />
|
|
163
|
+
</svg>
|
|
164
|
+
</div>
|
|
165
|
+
{/if}
|
|
166
|
+
{/if}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<style>
|
|
170
|
+
.jatui-image-reveal {
|
|
171
|
+
--jatui-ir-reveal: calc(var(--motion-move, 300ms) * 2);
|
|
172
|
+
position: relative;
|
|
173
|
+
display: inline-grid;
|
|
174
|
+
place-items: center;
|
|
175
|
+
width: var(--jatui-ir-size);
|
|
176
|
+
max-width: 100%;
|
|
177
|
+
aspect-ratio: 1;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
isolation: isolate;
|
|
180
|
+
container-type: inline-size;
|
|
181
|
+
border-radius: var(--jatui-ir-radius);
|
|
182
|
+
background: color-mix(in oklab, var(--jatui-ir-tone) 7%, var(--color-base-100, Canvas));
|
|
183
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--jatui-ir-tone) 14%, transparent);
|
|
184
|
+
vertical-align: middle;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.jatui-ir-status {
|
|
188
|
+
position: absolute;
|
|
189
|
+
width: 1px;
|
|
190
|
+
height: 1px;
|
|
191
|
+
margin: -1px;
|
|
192
|
+
overflow: hidden;
|
|
193
|
+
clip-path: inset(50%);
|
|
194
|
+
white-space: nowrap;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---- generating: ripple ---- */
|
|
198
|
+
.jatui-ir-ripple {
|
|
199
|
+
position: absolute;
|
|
200
|
+
inset: 0;
|
|
201
|
+
display: grid;
|
|
202
|
+
place-items: center;
|
|
203
|
+
background: radial-gradient(
|
|
204
|
+
circle at 50% 50%,
|
|
205
|
+
color-mix(in oklab, var(--jatui-ir-tone) 16%, transparent) 0%,
|
|
206
|
+
transparent 70%
|
|
207
|
+
);
|
|
208
|
+
transition: opacity var(--jatui-ir-reveal) var(--motion-glide, ease-out);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.jatui-ir-ring,
|
|
212
|
+
.jatui-ir-core {
|
|
213
|
+
grid-area: 1 / 1;
|
|
214
|
+
border-radius: 9999px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.jatui-ir-ring {
|
|
218
|
+
width: 90%;
|
|
219
|
+
aspect-ratio: 1;
|
|
220
|
+
border: max(1px, 2cqi) solid
|
|
221
|
+
color-mix(in oklab, var(--jatui-ir-tone) 45%, transparent);
|
|
222
|
+
opacity: 0;
|
|
223
|
+
transform: scale(0.2);
|
|
224
|
+
animation: jatui-ir-ripple 2400ms var(--motion-glide, ease-out) infinite;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.jatui-ir-ring:nth-child(2) {
|
|
228
|
+
animation-delay: 800ms;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.jatui-ir-ring:nth-child(3) {
|
|
232
|
+
animation-delay: 1600ms;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.jatui-ir-core {
|
|
236
|
+
width: 22%;
|
|
237
|
+
aspect-ratio: 1;
|
|
238
|
+
background: color-mix(in oklab, var(--jatui-ir-tone) 55%, transparent);
|
|
239
|
+
filter: blur(4cqi);
|
|
240
|
+
animation: jatui-ir-breathe 2400ms ease-in-out infinite;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@keyframes jatui-ir-ripple {
|
|
244
|
+
0% {
|
|
245
|
+
opacity: 0;
|
|
246
|
+
transform: scale(0.2);
|
|
247
|
+
}
|
|
248
|
+
15% {
|
|
249
|
+
opacity: 1;
|
|
250
|
+
}
|
|
251
|
+
100% {
|
|
252
|
+
opacity: 0;
|
|
253
|
+
transform: scale(1.35);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@keyframes jatui-ir-breathe {
|
|
258
|
+
0%,
|
|
259
|
+
100% {
|
|
260
|
+
opacity: 0.55;
|
|
261
|
+
transform: scale(0.85);
|
|
262
|
+
}
|
|
263
|
+
50% {
|
|
264
|
+
opacity: 1;
|
|
265
|
+
transform: scale(1.1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ---- reveal ---- */
|
|
270
|
+
.jatui-ir-img {
|
|
271
|
+
position: absolute;
|
|
272
|
+
inset: 0;
|
|
273
|
+
width: 100%;
|
|
274
|
+
height: 100%;
|
|
275
|
+
opacity: 0;
|
|
276
|
+
filter: blur(calc(12cqi + 4px));
|
|
277
|
+
transform: scale(1.06);
|
|
278
|
+
transition:
|
|
279
|
+
opacity var(--jatui-ir-reveal) var(--motion-glide, ease-out),
|
|
280
|
+
filter var(--jatui-ir-reveal) var(--motion-glide, ease-out),
|
|
281
|
+
transform var(--jatui-ir-reveal) var(--motion-glide, ease-out);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.jatui-ir-img.is-shown {
|
|
285
|
+
opacity: 1;
|
|
286
|
+
filter: none;
|
|
287
|
+
transform: none;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.is-revealed .jatui-ir-ripple {
|
|
291
|
+
opacity: 0;
|
|
292
|
+
visibility: hidden;
|
|
293
|
+
transition:
|
|
294
|
+
opacity var(--jatui-ir-reveal) var(--motion-glide, ease-out),
|
|
295
|
+
visibility 0s linear var(--jatui-ir-reveal);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.is-revealed {
|
|
299
|
+
background: transparent;
|
|
300
|
+
box-shadow: none;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ---- fallback ---- */
|
|
304
|
+
.jatui-ir-fallback {
|
|
305
|
+
position: absolute;
|
|
306
|
+
inset: 0;
|
|
307
|
+
display: grid;
|
|
308
|
+
place-items: center;
|
|
309
|
+
color: color-mix(in oklab, var(--jatui-ir-tone) 75%, var(--color-base-content, CanvasText));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.jatui-ir-initials {
|
|
313
|
+
font-weight: 600;
|
|
314
|
+
font-size: 36cqi;
|
|
315
|
+
line-height: 1;
|
|
316
|
+
letter-spacing: 0.02em;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.jatui-ir-glyph {
|
|
320
|
+
width: 42%;
|
|
321
|
+
height: 42%;
|
|
322
|
+
opacity: 0.7;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ---- calm / reduced motion: static tint, instant swap ---- */
|
|
326
|
+
:global([data-motion='calm']) .jatui-ir-ring,
|
|
327
|
+
:global([data-motion='calm']) .jatui-ir-core,
|
|
328
|
+
.jatui-image-reveal:global([data-motion='calm']) .jatui-ir-ring,
|
|
329
|
+
.jatui-image-reveal:global([data-motion='calm']) .jatui-ir-core {
|
|
330
|
+
animation: none;
|
|
331
|
+
}
|
|
332
|
+
:global([data-motion='calm']) .jatui-ir-ring,
|
|
333
|
+
.jatui-image-reveal:global([data-motion='calm']) .jatui-ir-ring {
|
|
334
|
+
opacity: 0;
|
|
335
|
+
}
|
|
336
|
+
:global([data-motion='calm']) .jatui-image-reveal,
|
|
337
|
+
.jatui-image-reveal:global([data-motion='calm']) {
|
|
338
|
+
--jatui-ir-reveal: 0s;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@media (prefers-reduced-motion: reduce) {
|
|
342
|
+
.jatui-image-reveal {
|
|
343
|
+
--jatui-ir-reveal: 0s;
|
|
344
|
+
}
|
|
345
|
+
.jatui-ir-ring,
|
|
346
|
+
.jatui-ir-core {
|
|
347
|
+
animation: none;
|
|
348
|
+
}
|
|
349
|
+
.jatui-ir-ring {
|
|
350
|
+
opacity: 0;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
</style>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type ImageRevealPhase, type ImageRevealShape, type ImageRevealSizeName, type ImageRevealToneName } from './imageReveal';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** null / undefined = still generating. '' = nothing to show (fallback). */
|
|
5
|
+
src?: string | null;
|
|
6
|
+
/** Alt text for the image; also the source of the fallback initials. */
|
|
7
|
+
alt?: string;
|
|
8
|
+
shape?: ImageRevealShape;
|
|
9
|
+
/** Named size, px number, any CSS length, or 'fill' (100% width). Always square. */
|
|
10
|
+
size?: ImageRevealSizeName | number | (string & {});
|
|
11
|
+
/** DaisyUI semantic name or any CSS colour. */
|
|
12
|
+
tone?: ImageRevealToneName | (string & {});
|
|
13
|
+
/** Screen-reader status while generating. */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Override the letters derived from `alt`. '' forces the glyph. */
|
|
16
|
+
initials?: string;
|
|
17
|
+
/** object-fit for the image. Logos usually want 'contain'. */
|
|
18
|
+
fit?: 'cover' | 'contain';
|
|
19
|
+
/** Read-only: the current phase. */
|
|
20
|
+
phase?: ImageRevealPhase;
|
|
21
|
+
/** Fires once per src, when that image has loaded. */
|
|
22
|
+
onReveal?: (src: string) => void;
|
|
23
|
+
/** Fires once per src, when that image failed to load. */
|
|
24
|
+
onError?: (src: string) => void;
|
|
25
|
+
/** Replaces the initials / glyph fallback. */
|
|
26
|
+
fallback?: Snippet;
|
|
27
|
+
class?: string;
|
|
28
|
+
}
|
|
29
|
+
declare const ImageReveal: import("svelte").Component<Props, {}, "phase">;
|
|
30
|
+
type ImageReveal = ReturnType<typeof ImageReveal>;
|
|
31
|
+
export default ImageReveal;
|
|
@@ -66,6 +66,11 @@
|
|
|
66
66
|
* · The keyboard map (video/keys.ts) is the single source the keydown
|
|
67
67
|
* handler and the `?` overlay both read — a key that exists is documented,
|
|
68
68
|
* a documented key exists.
|
|
69
|
+
* · Extension parity (mos-telti, jatui-xz6so): a double-click on the picture
|
|
70
|
+
* toggles fullscreen (E10, where the player owns the controls); a buffering
|
|
71
|
+
* spinner covers `waiting` (E13); volume and mute persist across reloads
|
|
72
|
+
* through the settings store (E12 — remembered media state, never panel
|
|
73
|
+
* rows).
|
|
69
74
|
*
|
|
70
75
|
* Internals live in `src/lib/video/` (band / keys / rate / deepLink /
|
|
71
76
|
* settings + types/time/model; audio, night and presence are contracts for
|
|
@@ -257,7 +262,10 @@
|
|
|
257
262
|
localSettingsStore,
|
|
258
263
|
panelGroups,
|
|
259
264
|
settingSpec,
|
|
265
|
+
SETTINGS_WRITE_DELAY_MS,
|
|
260
266
|
VIDEO_SETTINGS,
|
|
267
|
+
REMEMBERED_MUTED,
|
|
268
|
+
REMEMBERED_VOLUME,
|
|
261
269
|
type SettingAvailability,
|
|
262
270
|
type SettingsSession,
|
|
263
271
|
type SettingValue,
|
|
@@ -799,11 +807,25 @@
|
|
|
799
807
|
let volumeLevel = $state(1);
|
|
800
808
|
let mutedNow = $state(false);
|
|
801
809
|
let isFullscreen = $state(false);
|
|
810
|
+
// E13 (jatui-xz6so) — extension library.js:5843: playback is waiting for
|
|
811
|
+
// data. `waiting` raises it; `playing`/`canplay` clear it (canplay also
|
|
812
|
+
// ends a stall the viewer paused through).
|
|
813
|
+
let buffering = $state(false);
|
|
802
814
|
function syncVolume() {
|
|
803
815
|
if (!videoEl) return;
|
|
804
816
|
volumeLevel = videoEl.volume;
|
|
805
817
|
mutedNow = videoEl.muted;
|
|
806
818
|
}
|
|
819
|
+
/** E12 (jatui-xz6so): the user moved volume or mute. Mirrored into state
|
|
820
|
+
* here, and remembered where media state is persisted — directly from the
|
|
821
|
+
* gesture, NOT via the element's volumechange, which engines may delay or
|
|
822
|
+
* batch past the moment the next store read resolves. A gesture made
|
|
823
|
+
* before that read resolves wins (the settings session's rule). */
|
|
824
|
+
function userVolumeGesture() {
|
|
825
|
+
syncVolume();
|
|
826
|
+
mediaTouched = true;
|
|
827
|
+
rememberMediaState();
|
|
828
|
+
}
|
|
807
829
|
function togglePlay() {
|
|
808
830
|
const v = videoEl;
|
|
809
831
|
if (!v) return;
|
|
@@ -816,6 +838,7 @@
|
|
|
816
838
|
v.muted = !v.muted;
|
|
817
839
|
if (!v.muted && v.volume === 0) v.volume = 0.5;
|
|
818
840
|
showVolumeOsd(v.volume, v.muted);
|
|
841
|
+
userVolumeGesture();
|
|
819
842
|
}
|
|
820
843
|
function onVolumeInput(e: Event) {
|
|
821
844
|
const v = videoEl;
|
|
@@ -824,12 +847,23 @@
|
|
|
824
847
|
v.volume = Math.min(1, Math.max(0, next));
|
|
825
848
|
v.muted = next === 0;
|
|
826
849
|
showVolumeOsd(v.volume, v.muted);
|
|
850
|
+
userVolumeGesture();
|
|
827
851
|
}
|
|
828
852
|
function toggleFullscreen() {
|
|
829
853
|
if (typeof document === 'undefined') return;
|
|
830
854
|
if (document.fullscreenElement) void document.exitFullscreen?.();
|
|
831
855
|
else void stageWrapEl?.requestFullscreen?.();
|
|
832
856
|
}
|
|
857
|
+
/** E10 (jatui-xz6so) — extension library.js:5836: a double-click on the
|
|
858
|
+
* picture toggles fullscreen. Bound only where the player owns the
|
|
859
|
+
* controls: with native controls the browser already does this on the
|
|
860
|
+
* video itself, and a second toggle would fight it. The double-click's
|
|
861
|
+
* two clicks toggle play and un-toggle it, so playback ends where it
|
|
862
|
+
* started — only the view changes. */
|
|
863
|
+
function onStageDblClick(e: MouseEvent) {
|
|
864
|
+
e.preventDefault();
|
|
865
|
+
toggleFullscreen();
|
|
866
|
+
}
|
|
833
867
|
/** The speed button: the listening speeds people actually pick, in
|
|
834
868
|
* order, wrapping back to 1×. `-`/`=` still walk the fine staircase. */
|
|
835
869
|
const SPEED_CYCLE = [1, 1.25, 1.5, 1.75, 2];
|
|
@@ -2038,6 +2072,15 @@
|
|
|
2038
2072
|
// mid-session preference flip is respected instead of being a mount-only
|
|
2039
2073
|
// decision. The `.play()` promise can reject (no user gesture, tab backgrounded)
|
|
2040
2074
|
// — that's fine, the poster stays and the controls remain.
|
|
2075
|
+
//
|
|
2076
|
+
// The mandated mute is set HERE, right before the play, and nowhere else:
|
|
2077
|
+
// it used to ride the element as `muted={effectiveAutoplay || undefined}`,
|
|
2078
|
+
// which Svelte compiles into the stage's SHARED template effect — re-run by
|
|
2079
|
+
// every unrelated state change (clock, OSD, aria values) — and each re-run
|
|
2080
|
+
// reset `video.muted` to false, silently un-muting the viewer a moment
|
|
2081
|
+
// after every mute (jatui-xz6so: surfaced by volume/mute persistence, which
|
|
2082
|
+
// needs the choice to stick long enough to save). A mute the viewer makes
|
|
2083
|
+
// on an autoplaying clip now stays made.
|
|
2041
2084
|
let didAutoplay = false;
|
|
2042
2085
|
$effect(() => {
|
|
2043
2086
|
const v = videoEl;
|
|
@@ -2343,6 +2386,100 @@
|
|
|
2343
2386
|
flashOsd(next ? 'Theater mode' : 'Default view');
|
|
2344
2387
|
}
|
|
2345
2388
|
|
|
2389
|
+
// ── Remembered volume & mute (E12, jatui-xz6so) ──────────────────────
|
|
2390
|
+
// Extension library.js:5768 — the volume level and the mute choice survive
|
|
2391
|
+
// a reload, persisted through the same store the settings registry writes
|
|
2392
|
+
// (the host's `settingsStore`, else this browser's localStorage — the
|
|
2393
|
+
// theater split; `settings/remembered.ts` holds the ids and the
|
|
2394
|
+
// never-throws coercion). Two write paths: the player's own gestures hook
|
|
2395
|
+
// directly (userVolumeGesture — an engine may delay or batch volumechange
|
|
2396
|
+
// past the moment a pending store read resolves, and the gesture must win),
|
|
2397
|
+
// and the element's `volumechange` covers the one path the player can't
|
|
2398
|
+
// see — the native bar's own volume control. The writes that are the
|
|
2399
|
+
// PLAYER's, not the user's, never persist: the wind-down fade's ~400
|
|
2400
|
+
// volume steps, and the load-time restore below.
|
|
2401
|
+
const localMediaStore = localSettingsStore();
|
|
2402
|
+
let mediaRestoring = false;
|
|
2403
|
+
let mediaTouched = false;
|
|
2404
|
+
let mediaWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2405
|
+
|
|
2406
|
+
function onVolumeChange() {
|
|
2407
|
+
syncVolume();
|
|
2408
|
+
// A restore writes first and explains later; the fade's writes are ours.
|
|
2409
|
+
if (mediaRestoring || windPhase === 'fading') return;
|
|
2410
|
+
mediaTouched = true;
|
|
2411
|
+
rememberMediaState();
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/** A burst of wheel steps or a dragged slider is one write, not one per tick. */
|
|
2415
|
+
function rememberMediaState() {
|
|
2416
|
+
const store = settingsStore ?? localMediaStore;
|
|
2417
|
+
if (mediaWriteTimer) clearTimeout(mediaWriteTimer);
|
|
2418
|
+
mediaWriteTimer = setTimeout(() => flushMediaState(store), SETTINGS_WRITE_DELAY_MS);
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
function flushMediaState(store: VideoSettingsStore) {
|
|
2422
|
+
mediaWriteTimer = null;
|
|
2423
|
+
const v = videoEl;
|
|
2424
|
+
if (!v) return;
|
|
2425
|
+
// Onto the 0.05 gesture grid, so 0.35000000000000003 never reaches a store.
|
|
2426
|
+
writeRemembered(store, REMEMBERED_VOLUME.id, Math.round(v.volume * 20) / 20);
|
|
2427
|
+
writeRemembered(store, REMEMBERED_MUTED.id, v.muted);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function writeRemembered(store: VideoSettingsStore, id: string, value: SettingValue) {
|
|
2431
|
+
try {
|
|
2432
|
+
const r = store.set(id, value);
|
|
2433
|
+
if (r && typeof (r as Promise<void>).catch === 'function') (r as Promise<void>).catch(() => {});
|
|
2434
|
+
} catch {
|
|
2435
|
+
/* a store that throws is a store that didn't save — never an error in the player */
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// The load-time read, once per store identity: a junk or throwing store
|
|
2440
|
+
// reads as nothing stored, and a choice made before the read resolves wins
|
|
2441
|
+
// (the settings session's rule, mirrored here). An autoplaying clip keeps
|
|
2442
|
+
// the browser-mandated mute — unmuting it is the viewer's gesture; the
|
|
2443
|
+
// restored level is already in place for when they do.
|
|
2444
|
+
$effect(() => {
|
|
2445
|
+
const store = settingsStore ?? localMediaStore;
|
|
2446
|
+
let cancelled = false;
|
|
2447
|
+
untrack(() => {
|
|
2448
|
+
void Promise.all([store.get(REMEMBERED_VOLUME.id), store.get(REMEMBERED_MUTED.id)])
|
|
2449
|
+
.then(([rawVolume, rawMuted]) => {
|
|
2450
|
+
if (cancelled || mediaTouched) return;
|
|
2451
|
+
applyRememberedMedia(REMEMBERED_VOLUME.coerce(rawVolume), REMEMBERED_MUTED.coerce(rawMuted));
|
|
2452
|
+
})
|
|
2453
|
+
.catch(() => {});
|
|
2454
|
+
});
|
|
2455
|
+
return () => {
|
|
2456
|
+
cancelled = true;
|
|
2457
|
+
};
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
function applyRememberedMedia(volume: number | undefined, muted: boolean | undefined) {
|
|
2461
|
+
const v = videoEl;
|
|
2462
|
+
if (!v) return;
|
|
2463
|
+
mediaRestoring = true;
|
|
2464
|
+
if (volume !== undefined) v.volume = volume;
|
|
2465
|
+
if (muted !== undefined && !effectiveAutoplay) v.muted = muted;
|
|
2466
|
+
// The element's volumechange may fire synchronously or be queued — cover
|
|
2467
|
+
// both before the restore's own writes can read as a user choice.
|
|
2468
|
+
setTimeout(() => (mediaRestoring = false), 0);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// Unmount sends whatever is pending, like the session's flush. The store
|
|
2472
|
+
// captured here is the one the pending writes were destined for.
|
|
2473
|
+
$effect(() => {
|
|
2474
|
+
const store = settingsStore ?? localMediaStore;
|
|
2475
|
+
return () => {
|
|
2476
|
+
if (mediaWriteTimer) {
|
|
2477
|
+
clearTimeout(mediaWriteTimer);
|
|
2478
|
+
flushMediaState(store);
|
|
2479
|
+
}
|
|
2480
|
+
};
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2346
2483
|
/** A viewer choice from outside the panel (N, the moon wheel) — saved like a panel change. */
|
|
2347
2484
|
function rememberSetting(id: string, v: SettingValue) {
|
|
2348
2485
|
settingsSession?.change(id, v);
|
|
@@ -2857,6 +2994,7 @@
|
|
|
2857
2994
|
videoEl.volume = next;
|
|
2858
2995
|
if (next > 0) videoEl.muted = false;
|
|
2859
2996
|
showVolumeOsd(next, next === 0);
|
|
2997
|
+
userVolumeGesture();
|
|
2860
2998
|
}
|
|
2861
2999
|
|
|
2862
3000
|
/**
|
|
@@ -2939,14 +3077,17 @@
|
|
|
2939
3077
|
v.volume = Math.min(1, v.volume + 0.05);
|
|
2940
3078
|
if (v.volume > 0) v.muted = false;
|
|
2941
3079
|
showVolumeOsd(v.volume, v.muted);
|
|
3080
|
+
userVolumeGesture();
|
|
2942
3081
|
break;
|
|
2943
3082
|
case 'ArrowDown':
|
|
2944
3083
|
v.volume = Math.max(0, v.volume - 0.05);
|
|
2945
3084
|
showVolumeOsd(v.volume, v.muted);
|
|
3085
|
+
userVolumeGesture();
|
|
2946
3086
|
break;
|
|
2947
3087
|
case 'm':
|
|
2948
3088
|
v.muted = !v.muted;
|
|
2949
3089
|
showVolumeOsd(v.volume, v.muted);
|
|
3090
|
+
userVolumeGesture();
|
|
2950
3091
|
break;
|
|
2951
3092
|
case '-':
|
|
2952
3093
|
case '_':
|
|
@@ -3363,14 +3504,18 @@
|
|
|
3363
3504
|
style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
|
|
3364
3505
|
style:--vp-wind-down={windProgress > 0 ? windProgress.toFixed(3) : undefined}
|
|
3365
3506
|
onwheel={onStageWheel}
|
|
3507
|
+
ondblclick={bandTransport && !isAudio ? onStageDblClick : undefined}
|
|
3366
3508
|
>
|
|
3367
3509
|
<video
|
|
3368
3510
|
bind:this={videoEl}
|
|
3369
3511
|
bind:currentTime
|
|
3370
3512
|
bind:duration
|
|
3371
3513
|
bind:paused
|
|
3372
|
-
onvolumechange={
|
|
3514
|
+
onvolumechange={onVolumeChange}
|
|
3373
3515
|
ontimeupdate={handleTimeUpdate}
|
|
3516
|
+
onwaiting={() => (buffering = true)}
|
|
3517
|
+
onplaying={() => (buffering = false)}
|
|
3518
|
+
oncanplay={() => (buffering = false)}
|
|
3374
3519
|
onloadedmetadata={onLoadedMetadata}
|
|
3375
3520
|
onseeked={() => {
|
|
3376
3521
|
syncUrlNow();
|
|
@@ -3395,7 +3540,6 @@
|
|
|
3395
3540
|
{src}
|
|
3396
3541
|
{loop}
|
|
3397
3542
|
crossorigin={crossorigin ?? undefined}
|
|
3398
|
-
muted={effectiveAutoplay || undefined}
|
|
3399
3543
|
poster={isAudio ? undefined : (poster ?? undefined)}
|
|
3400
3544
|
controls={!bandTransport}
|
|
3401
3545
|
playsinline
|
|
@@ -3432,6 +3576,27 @@
|
|
|
3432
3576
|
OSD, which carries the hint. Gone the instant progress is 0. -->
|
|
3433
3577
|
<div class="vp-wind-veil" aria-hidden="true"></div>
|
|
3434
3578
|
{/if}
|
|
3579
|
+
{#if buffering && !isAudio}
|
|
3580
|
+
<!-- Buffering spinner (E13, jatui-xz6so): playback is waiting for
|
|
3581
|
+
data — extension library.js:5843. Decorative (aria-hidden): the
|
|
3582
|
+
media element and the timeline already report the stall to AT.
|
|
3583
|
+
After the night and wind-down veils in DOM order, so a dimmed
|
|
3584
|
+
picture never hides it. -->
|
|
3585
|
+
<div class="vp-buffering" aria-hidden="true">
|
|
3586
|
+
<svg
|
|
3587
|
+
class="vp-buffering-ring"
|
|
3588
|
+
viewBox="0 0 24 24"
|
|
3589
|
+
width="26"
|
|
3590
|
+
height="26"
|
|
3591
|
+
fill="none"
|
|
3592
|
+
stroke="currentColor"
|
|
3593
|
+
stroke-width="2.5"
|
|
3594
|
+
stroke-linecap="round"
|
|
3595
|
+
>
|
|
3596
|
+
<path d="M21 12a9 9 0 1 1-9-9" />
|
|
3597
|
+
</svg>
|
|
3598
|
+
</div>
|
|
3599
|
+
{/if}
|
|
3435
3600
|
<div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
3436
3601
|
<!-- Volume OSD (jatui-pu698): speaker glyph + level bar + %, centred
|
|
3437
3602
|
near the top of the stage so a wheel/keyboard volume gesture is
|
|
@@ -4486,6 +4651,47 @@
|
|
|
4486
4651
|
min-width: 3.5rem;
|
|
4487
4652
|
}
|
|
4488
4653
|
|
|
4654
|
+
/* ── Buffering spinner (E13, jatui-xz6so) ─────────────────────────── */
|
|
4655
|
+
/* Playback is waiting for data: a small ring, dead centre of the stage,
|
|
4656
|
+
on the same token surface as the volume OSD so it is legible on any
|
|
4657
|
+
frame. Appearing is an opacity fade only — reduced-motion safe — and
|
|
4658
|
+
the ROTATION alone is gated off under prefers-reduced-motion (a static
|
|
4659
|
+
ring still reads as "working"; nothing slides or scales). */
|
|
4660
|
+
.vp-buffering {
|
|
4661
|
+
position: absolute;
|
|
4662
|
+
top: 50%;
|
|
4663
|
+
left: 50%;
|
|
4664
|
+
transform: translate(-50%, -50%);
|
|
4665
|
+
display: grid;
|
|
4666
|
+
place-items: center;
|
|
4667
|
+
width: 56px;
|
|
4668
|
+
height: 56px;
|
|
4669
|
+
border-radius: 999px;
|
|
4670
|
+
background: color-mix(in oklch, var(--color-base-100) 85%, transparent);
|
|
4671
|
+
backdrop-filter: blur(8px);
|
|
4672
|
+
color: var(--color-base-content);
|
|
4673
|
+
pointer-events: none;
|
|
4674
|
+
z-index: 10;
|
|
4675
|
+
box-shadow: 0 4px 16px color-mix(in oklch, black 20%, transparent);
|
|
4676
|
+
animation: vp-buffer-in var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
4677
|
+
}
|
|
4678
|
+
|
|
4679
|
+
.vp-buffering-ring {
|
|
4680
|
+
animation: vp-buffer-spin 0.9s linear infinite;
|
|
4681
|
+
}
|
|
4682
|
+
|
|
4683
|
+
@keyframes vp-buffer-spin {
|
|
4684
|
+
to {
|
|
4685
|
+
transform: rotate(360deg);
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
|
|
4689
|
+
@keyframes vp-buffer-in {
|
|
4690
|
+
from {
|
|
4691
|
+
opacity: 0;
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4489
4695
|
/* The band owns its height from the first frame so a waveform or speaker
|
|
4490
4696
|
turns arriving minutes in fade into space that is already theirs. */
|
|
4491
4697
|
.vp-band {
|
|
@@ -5264,6 +5470,16 @@
|
|
|
5264
5470
|
transition: none;
|
|
5265
5471
|
}
|
|
5266
5472
|
|
|
5473
|
+
/* E13: the spinner's fade-in and its rotation both stand down — a
|
|
5474
|
+
static ring still reads as "working". */
|
|
5475
|
+
.vp-buffering {
|
|
5476
|
+
animation: none;
|
|
5477
|
+
}
|
|
5478
|
+
|
|
5479
|
+
.vp-buffering-ring {
|
|
5480
|
+
animation: none;
|
|
5481
|
+
}
|
|
5482
|
+
|
|
5267
5483
|
.vp-play:hover,
|
|
5268
5484
|
.vp-play:active {
|
|
5269
5485
|
transform: none;
|
|
@@ -46,6 +46,11 @@ export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/set
|
|
|
46
46
|
* · The keyboard map (video/keys.ts) is the single source the keydown
|
|
47
47
|
* handler and the `?` overlay both read — a key that exists is documented,
|
|
48
48
|
* a documented key exists.
|
|
49
|
+
* · Extension parity (mos-telti, jatui-xz6so): a double-click on the picture
|
|
50
|
+
* toggles fullscreen (E10, where the player owns the controls); a buffering
|
|
51
|
+
* spinner covers `waiting` (E13); volume and mute persist across reloads
|
|
52
|
+
* through the settings store (E12 — remembered media state, never panel
|
|
53
|
+
* rows).
|
|
49
54
|
*
|
|
50
55
|
* Internals live in `src/lib/video/` (band / keys / rate / deepLink /
|
|
51
56
|
* settings + types/time/model; audio, night and presence are contracts for
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers behind ImageReveal: which phase the frame is in, the initials
|
|
3
|
+
* fallback, and the size / shape / tone lookups. No DOM, so
|
|
4
|
+
* tests/unit/imageReveal.test.ts runs them in node.
|
|
5
|
+
*
|
|
6
|
+
* Clean-room (jatui-vui9q): BoardUI's "AI Image Generation" item is in the
|
|
7
|
+
* Pro tier, so no BoardUI source was read or copied.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* generating — no src yet (`null`/`undefined`): the ripple runs.
|
|
11
|
+
* loading — a src arrived but the bytes haven't decoded: the ripple keeps
|
|
12
|
+
* running, the image waits underneath at blur + opacity 0.
|
|
13
|
+
* revealed — the image loaded: blur-to-sharp, the ripple fades out.
|
|
14
|
+
* fallback — `src === ''` or the image failed: calm initials / empty glyph.
|
|
15
|
+
*/
|
|
16
|
+
export type ImageRevealPhase = 'generating' | 'loading' | 'revealed' | 'fallback';
|
|
17
|
+
export type ImageRevealShape = 'square' | 'rounded' | 'circle';
|
|
18
|
+
export type ImageRevealSizeName = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'fill';
|
|
19
|
+
/** DaisyUI semantic colour names accepted as a `tone`. */
|
|
20
|
+
export type ImageRevealToneName = 'primary' | 'secondary' | 'accent' | 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
|
21
|
+
export interface ImageRevealState {
|
|
22
|
+
src: string | null | undefined;
|
|
23
|
+
/** The src whose load event fired (null if none has). */
|
|
24
|
+
loadedSrc: string | null;
|
|
25
|
+
/** The src whose error event fired (null if none has). */
|
|
26
|
+
failedSrc: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Phase from the current src plus what the browser reported for it. Load and
|
|
30
|
+
* error are keyed by src, so a new src starts over at `loading` without the
|
|
31
|
+
* component having to reset flags.
|
|
32
|
+
*/
|
|
33
|
+
export declare function imageRevealPhase({ src, loadedSrc, failedSrc }: ImageRevealState): ImageRevealPhase;
|
|
34
|
+
/** True while the frame is still waiting for something to show. */
|
|
35
|
+
export declare function imageRevealBusy(phase: ImageRevealPhase): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Up to two letters from the alt text: "Acme Corp" → "AC", "acme" → "A",
|
|
38
|
+
* "the-north-face" → "TN". Symbols and digits-only words are skipped, so a
|
|
39
|
+
* logo alt like "Acme logo" gives "AL" — callers who want "A" pass
|
|
40
|
+
* `initials` explicitly.
|
|
41
|
+
*/
|
|
42
|
+
export declare function imageRevealInitials(alt: string | null | undefined): string;
|
|
43
|
+
export declare const IMAGE_REVEAL_SIZES: Record<Exclude<ImageRevealSizeName, 'fill'>, number>;
|
|
44
|
+
/**
|
|
45
|
+
* A CSS length for the frame's width. Named sizes and numbers are px; any
|
|
46
|
+
* other string (`'10rem'`, `'50%'`) passes through; `'fill'` is 100%.
|
|
47
|
+
*/
|
|
48
|
+
export declare function imageRevealSize(size: ImageRevealSizeName | number | (string & {})): string;
|
|
49
|
+
export declare const IMAGE_REVEAL_RADIUS: Record<ImageRevealShape, string>;
|
|
50
|
+
/** A semantic name → its DaisyUI variable; any other string is a CSS colour. */
|
|
51
|
+
export declare function imageRevealTone(tone: ImageRevealToneName | (string & {})): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers behind ImageReveal: which phase the frame is in, the initials
|
|
3
|
+
* fallback, and the size / shape / tone lookups. No DOM, so
|
|
4
|
+
* tests/unit/imageReveal.test.ts runs them in node.
|
|
5
|
+
*
|
|
6
|
+
* Clean-room (jatui-vui9q): BoardUI's "AI Image Generation" item is in the
|
|
7
|
+
* Pro tier, so no BoardUI source was read or copied.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Phase from the current src plus what the browser reported for it. Load and
|
|
11
|
+
* error are keyed by src, so a new src starts over at `loading` without the
|
|
12
|
+
* component having to reset flags.
|
|
13
|
+
*/
|
|
14
|
+
export function imageRevealPhase({ src, loadedSrc, failedSrc }) {
|
|
15
|
+
if (src == null)
|
|
16
|
+
return 'generating';
|
|
17
|
+
if (src.trim() === '' || failedSrc === src)
|
|
18
|
+
return 'fallback';
|
|
19
|
+
if (loadedSrc === src)
|
|
20
|
+
return 'revealed';
|
|
21
|
+
return 'loading';
|
|
22
|
+
}
|
|
23
|
+
/** True while the frame is still waiting for something to show. */
|
|
24
|
+
export function imageRevealBusy(phase) {
|
|
25
|
+
return phase === 'generating' || phase === 'loading';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Up to two letters from the alt text: "Acme Corp" → "AC", "acme" → "A",
|
|
29
|
+
* "the-north-face" → "TN". Symbols and digits-only words are skipped, so a
|
|
30
|
+
* logo alt like "Acme logo" gives "AL" — callers who want "A" pass
|
|
31
|
+
* `initials` explicitly.
|
|
32
|
+
*/
|
|
33
|
+
export function imageRevealInitials(alt) {
|
|
34
|
+
if (!alt)
|
|
35
|
+
return '';
|
|
36
|
+
const words = alt
|
|
37
|
+
.split(/[\s\-_./]+/)
|
|
38
|
+
.map((w) => w.match(/\p{L}/u)?.[0] ?? '')
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
return words
|
|
41
|
+
.slice(0, 2)
|
|
42
|
+
.join('')
|
|
43
|
+
.toLocaleUpperCase();
|
|
44
|
+
}
|
|
45
|
+
export const IMAGE_REVEAL_SIZES = {
|
|
46
|
+
xs: 32,
|
|
47
|
+
sm: 48,
|
|
48
|
+
md: 64,
|
|
49
|
+
lg: 96,
|
|
50
|
+
xl: 128
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* A CSS length for the frame's width. Named sizes and numbers are px; any
|
|
54
|
+
* other string (`'10rem'`, `'50%'`) passes through; `'fill'` is 100%.
|
|
55
|
+
*/
|
|
56
|
+
export function imageRevealSize(size) {
|
|
57
|
+
if (typeof size === 'number')
|
|
58
|
+
return `${size}px`;
|
|
59
|
+
if (size === 'fill')
|
|
60
|
+
return '100%';
|
|
61
|
+
if (size in IMAGE_REVEAL_SIZES)
|
|
62
|
+
return `${IMAGE_REVEAL_SIZES[size]}px`;
|
|
63
|
+
return size;
|
|
64
|
+
}
|
|
65
|
+
export const IMAGE_REVEAL_RADIUS = {
|
|
66
|
+
square: '0',
|
|
67
|
+
rounded: 'var(--jatui-radius-box, var(--radius-box, 0.5rem))',
|
|
68
|
+
circle: '9999px'
|
|
69
|
+
};
|
|
70
|
+
const TONE_NAMES = new Set([
|
|
71
|
+
'primary',
|
|
72
|
+
'secondary',
|
|
73
|
+
'accent',
|
|
74
|
+
'neutral',
|
|
75
|
+
'info',
|
|
76
|
+
'success',
|
|
77
|
+
'warning',
|
|
78
|
+
'error'
|
|
79
|
+
]);
|
|
80
|
+
/** A semantic name → its DaisyUI variable; any other string is a CSS colour. */
|
|
81
|
+
export function imageRevealTone(tone) {
|
|
82
|
+
return TONE_NAMES.has(tone) ? `var(--color-${tone})` : tone;
|
|
83
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -400,3 +400,6 @@ export { default as SettingsSection } from './components/settings/SettingsSectio
|
|
|
400
400
|
export { default as SettingsRow } from './components/settings/SettingsRow.svelte';
|
|
401
401
|
export type { SettingsRowContext } from './components/settings/SettingsRow.svelte';
|
|
402
402
|
export * from './data/grid/index';
|
|
403
|
+
export { default as ImageReveal } from './components/ImageReveal.svelte';
|
|
404
|
+
export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
|
|
405
|
+
export type { ImageRevealPhase, ImageRevealShape, ImageRevealSizeName, ImageRevealToneName, ImageRevealState } from './components/imageReveal';
|
package/dist/index.js
CHANGED
|
@@ -435,3 +435,5 @@ export { default as SegmentedControl } from './components/SegmentedControl.svelt
|
|
|
435
435
|
export { default as SettingsSection } from './components/settings/SettingsSection.svelte';
|
|
436
436
|
export { default as SettingsRow } from './components/settings/SettingsRow.svelte';
|
|
437
437
|
export * from './data/grid/index';
|
|
438
|
+
export { default as ImageReveal } from './components/ImageReveal.svelte';
|
|
439
|
+
export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
|
package/dist/video/README.md
CHANGED
|
@@ -20,7 +20,7 @@ props + callback surfaces.
|
|
|
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
22
|
| `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
|
|
23
|
-
| `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). 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. |
|
|
23
|
+
| `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
24
|
| `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
25
|
| `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). |
|
|
26
26
|
| `presence.ts` + `presence/` | **.34** (done) | `presence.ts` is the barrel. `winddown.ts` — pure: `shouldBeginWindDown` (a locked screen skips the wait; a desktop source with no reading holds — never guess), the 20s linear fade levels (volume `1−p`, picture `1−0.92p`), the hint (the page source says it is guessing from less). `idle.ts` — Chromium's Idle Detection API, feature-detected (`IdleDetector` + secure context); `startDesktopWatch` never prompts (start() rejects unless already granted ⇒ page fallback), `requestIdleDetection` is the ONE prompting call and the player makes it only with `idleDetection` on, from a gesture inside it. `resume.ts` — the host-supplied `VideoResumeStore` (localStorage default; jatui never calls an API), the data → advisor mapping, and `planResumeSnap` (wait for boundaries, drop once the user moved > 2.5s, skip `exact`/`start`); the snapping itself is `advisors/resume-point`, not re-derived. `flip.ts` — `open-transition.js` ported: FLIP over WAAPI, two keyframes, origin restated in both, poster on every path, a no-op under prefers-reduced-motion except the poster, `flipClose` holds and the next open releases on every path. |
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remembered media state (E12, jatui-xz6so) — extension-library's player
|
|
3
|
+
* settings registry (library.js:5768) ported: the volume level and the mute
|
|
4
|
+
* choice survive a reload.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT rows in `VIDEO_SETTINGS` (mos-5lzns.35's doctrine stands:
|
|
7
|
+
* remembering is not configuring — a settings panel that lists the current
|
|
8
|
+
* volume reads like a debug dump, and every player already carries a volume
|
|
9
|
+
* control on its transport or the native bar). They ARE coerced like settings
|
|
10
|
+
* and persisted through the SAME plumbing: the host's `VideoSettingsStore`
|
|
11
|
+
* when one is given, else this browser's `localSettingsStore()` — the exact
|
|
12
|
+
* split `player.theater` uses. The storage ids sit in the same `player.*`
|
|
13
|
+
* namespace, so a host's store keys stay one family.
|
|
14
|
+
*/
|
|
15
|
+
export interface RememberedSpec<T> {
|
|
16
|
+
/** Stable storage key — a host's store is keyed by this. Never rename. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** What a player uses when the store holds nothing usable. */
|
|
19
|
+
default: T;
|
|
20
|
+
/**
|
|
21
|
+
* A stored value that can't be used (junk, wrong shape, a throwing
|
|
22
|
+
* `valueOf`) reads as nothing stored — never junk, never a throw. Same
|
|
23
|
+
* discipline as the registry's `coerce`.
|
|
24
|
+
*/
|
|
25
|
+
coerce(raw: unknown): T | undefined;
|
|
26
|
+
}
|
|
27
|
+
/** The playback level, 0–1 on the player's 0.05 gesture grid. */
|
|
28
|
+
export declare const REMEMBERED_VOLUME: RememberedSpec<number>;
|
|
29
|
+
/** Whether playback starts muted. */
|
|
30
|
+
export declare const REMEMBERED_MUTED: RememberedSpec<boolean>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remembered media state (E12, jatui-xz6so) — extension-library's player
|
|
3
|
+
* settings registry (library.js:5768) ported: the volume level and the mute
|
|
4
|
+
* choice survive a reload.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT rows in `VIDEO_SETTINGS` (mos-5lzns.35's doctrine stands:
|
|
7
|
+
* remembering is not configuring — a settings panel that lists the current
|
|
8
|
+
* volume reads like a debug dump, and every player already carries a volume
|
|
9
|
+
* control on its transport or the native bar). They ARE coerced like settings
|
|
10
|
+
* and persisted through the SAME plumbing: the host's `VideoSettingsStore`
|
|
11
|
+
* when one is given, else this browser's `localSettingsStore()` — the exact
|
|
12
|
+
* split `player.theater` uses. The storage ids sit in the same `player.*`
|
|
13
|
+
* namespace, so a host's store keys stay one family.
|
|
14
|
+
*/
|
|
15
|
+
/** The playback level, 0–1 on the player's 0.05 gesture grid. */
|
|
16
|
+
export const REMEMBERED_VOLUME = {
|
|
17
|
+
id: 'player.volume',
|
|
18
|
+
default: 1,
|
|
19
|
+
coerce(raw) {
|
|
20
|
+
try {
|
|
21
|
+
// Only numbers and numeric strings: Number() on an object runs its
|
|
22
|
+
// valueOf/toString, and on a Symbol it throws — same guard as the registry.
|
|
23
|
+
const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
|
|
24
|
+
if (!Number.isFinite(n))
|
|
25
|
+
return undefined;
|
|
26
|
+
return Math.min(1, Math.max(0, n));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Whether playback starts muted. */
|
|
34
|
+
export const REMEMBERED_MUTED = {
|
|
35
|
+
id: 'player.muted',
|
|
36
|
+
default: false,
|
|
37
|
+
coerce(raw) {
|
|
38
|
+
if (raw === true || raw === 1 || raw === '1' || raw === 'true')
|
|
39
|
+
return true;
|
|
40
|
+
if (raw === false || raw === 0 || raw === '0' || raw === 'false')
|
|
41
|
+
return false;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
};
|
package/dist/video/settings.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* session that reads/writes through it. jatui never persists on its own.
|
|
10
10
|
* · `panel.ts` — which rows a given player can offer, and how values read.
|
|
11
11
|
* · `SettingsPanel.svelte` — the panel, generated wholly from the registry.
|
|
12
|
+
* · `remembered.ts` — remembered media state (volume, mute — E12), coerced
|
|
13
|
+
* like settings and persisted through the same store, but never panel rows.
|
|
12
14
|
*
|
|
13
15
|
* Nothing under src/lib/video/ reads a setting directly: the component maps
|
|
14
16
|
* each value onto the prop it names and the feature reads that — the
|
|
@@ -17,3 +19,4 @@
|
|
|
17
19
|
export * from './settings/registry';
|
|
18
20
|
export * from './settings/store';
|
|
19
21
|
export * from './settings/panel';
|
|
22
|
+
export * from './settings/remembered';
|
package/dist/video/settings.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* session that reads/writes through it. jatui never persists on its own.
|
|
10
10
|
* · `panel.ts` — which rows a given player can offer, and how values read.
|
|
11
11
|
* · `SettingsPanel.svelte` — the panel, generated wholly from the registry.
|
|
12
|
+
* · `remembered.ts` — remembered media state (volume, mute — E12), coerced
|
|
13
|
+
* like settings and persisted through the same store, but never panel rows.
|
|
12
14
|
*
|
|
13
15
|
* Nothing under src/lib/video/ reads a setting directly: the component maps
|
|
14
16
|
* each value onto the prop it names and the feature reads that — the
|
|
@@ -17,3 +19,4 @@
|
|
|
17
19
|
export * from './settings/registry';
|
|
18
20
|
export * from './settings/store';
|
|
19
21
|
export * from './settings/panel';
|
|
22
|
+
export * from './settings/remembered';
|