@joewinke/jatui 0.13.0 → 0.15.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.
@@ -4,12 +4,18 @@ export type { VideoLumaTrack } from '../video/band/luma';
4
4
  export type { VideoMarker } from '../video/band/markers';
5
5
  export type { NightMode, NightWindow } from '../video/night/clock';
6
6
  export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
7
+ export type { VideoAdSpan, RateReport } from '../video/rate';
8
+ export type { WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource } from '../video/presence';
9
+ export type { ResumePoint } from '../video/advisors/resume-point';
7
10
  import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
8
11
  import { type BandState } from '../video/band/axis';
9
12
  import { type VideoLumaTrack } from '../video/band/luma';
10
13
  import { type VideoMarker } from '../video/band/markers';
14
+ import { type RateReport, type VideoAdSpan } from '../video/rate';
11
15
  import { type NightMode, type NightWindow } from '../video/night/clock';
12
16
  import { type AudioBrokenReason, type AudioState } from '../video/audio';
17
+ import { type FlipSource, type VideoResumeStore, type WindDownState } from '../video/presence';
18
+ import type { ResumePoint } from '../video/advisors/resume-point';
13
19
  type $$ComponentProps = {
14
20
  src: string;
15
21
  poster?: string | null;
@@ -180,6 +186,108 @@ type $$ComponentProps = {
180
186
  onAudioState?: (state: AudioState) => void;
181
187
  /** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
182
188
  onAudioBroken?: (reason: AudioBrokenReason) => void;
189
+ /**
190
+ * Rate composer (mos-5lzns.32): `rate = base × speaker × silence`. The
191
+ * speed readout only ever reports `base` — the rate a human chose — so it
192
+ * keeps saying `1×` while a gap is taken at 2.2×. Every advisor is off by
193
+ * default and collapses to 1 until its data arrives: off, the element
194
+ * plays at exactly `base`.
195
+ *
196
+ * Smart speed: run the silences between sentences at 2.2×, found off the
197
+ * `waveform` envelope. Speech stays at `base`.
198
+ */
199
+ smartSpeed?: boolean;
200
+ /**
201
+ * Per-speaker pace: level everyone to the median words-per-minute,
202
+ * measured from the diarised `transcript` + `speakers`. Ad reads and
203
+ * gaps between turns play at `base`.
204
+ */
205
+ speakerPace?: boolean;
206
+ /**
207
+ * Hand-set speaker rates keyed by speaker NAME (the legend's display
208
+ * name). They always beat measured ones, and like them apply only while
209
+ * `speakerPace` is on — off means off. Range 0.0625–16.
210
+ */
211
+ speakerRates?: Record<string, number> | null;
212
+ /** Host-detected ad reads. Merged with any `ad: true` speaker turns. */
213
+ adSpans?: VideoAdSpan[] | null;
214
+ /**
215
+ * Skip ad reads: when playback runs into an ad span it jumps to its end
216
+ * (the OSD says so). A seek that lands inside an ad is honoured.
217
+ */
218
+ skipAds?: boolean;
219
+ /** The composed rate changed (real change only). `base` is what the UI shows. */
220
+ onRateChange?: (report: RateReport) => void;
221
+ /**
222
+ * Wind-down (mos-5lzns.34): when nobody is there, the picture and sound
223
+ * fade over 20s and the video pauses, keeping your place. The fade IS
224
+ * the prompt — any input anywhere on the page (or the desktop coming
225
+ * back) cancels it and brings everything straight back. Omitted/false ⇒
226
+ * the feature does not exist on this player: no listeners, no timers,
227
+ * no variables.
228
+ *
229
+ * The evidence of absence is the DESKTOP when `idleDetection` is on and
230
+ * granted (Chromium's Idle Detection API: system-wide idle, and a locked
231
+ * screen skips the wait entirely); otherwise it is PAGE presence — no
232
+ * input on this page for `windDownAfter` minutes — which is weaker
233
+ * evidence (a video legitimately plays for an hour untouched), and is
234
+ * reported as `source: 'page'` so the host can label it.
235
+ */
236
+ windDown?: boolean;
237
+ /** Minutes of absence before the fade begins, 10–90 (default 30). */
238
+ windDownAfter?: number;
239
+ /**
240
+ * Only wind down while night is armed (default true — falling asleep
241
+ * mid-video is a night problem). Uses the `night` mode when present,
242
+ * else the local-clock auto window.
243
+ */
244
+ windDownNightOnly?: boolean;
245
+ /**
246
+ * Opt in to the Idle Detection API (Chromium only; permission-gated).
247
+ * The player NEVER prompts without this. Already granted ⇒ it starts at
248
+ * once; still `prompt` ⇒ the permission is requested on the next click
249
+ * or key inside the player (the API needs a user gesture). Better: call
250
+ * `requestIdleDetection()` from the click that enables the setting.
251
+ * Unsupported or denied ⇒ the page-presence fallback, reported.
252
+ */
253
+ idleDetection?: boolean;
254
+ /** Phase/source changes: `off` | `watching` | `fading` | `paused`, and `desktop` | `page`. */
255
+ onWindDown?: (state: WindDownState) => void;
256
+ /**
257
+ * Resume (mos-5lzns.34): present ⇒ the player remembers where this video
258
+ * was left under this key and returns there when it opens again. An
259
+ * explicit `startAt` or a `?t=` deep link always wins over a stored point.
260
+ */
261
+ resumeKey?: string | null;
262
+ /**
263
+ * Where resume points live — `{ load(key), save(key, sec | null) }`,
264
+ * either may be async. Default: this browser's localStorage. jatui never
265
+ * calls an API; a host with per-user server state passes its own.
266
+ */
267
+ resumeStore?: VideoResumeStore;
268
+ /**
269
+ * Snap the resume point back to the nearest sentence / speaker turn /
270
+ * chapter within 15s once that data lands, and say the line you're
271
+ * walking back into (quoted, never paraphrased). Stands down if you've
272
+ * moved the playhead yourself. Default on; off ⇒ the raw second.
273
+ */
274
+ resumeSnap?: boolean;
275
+ /** The player moved the playhead for a resume: `from` the stored second, to `point`. */
276
+ onResume?: (from: number, point: ResumePoint) => void;
277
+ /**
278
+ * Open like an object (mos-5lzns.34): the clicked card (an element or a
279
+ * rect snapshot). Present ⇒ on mount the stage grows out of that
280
+ * rectangle (FLIP over WAAPI, 260ms) with `openPoster` painted over the
281
+ * video until its first frame decodes. `null` ⇒ the poster only. The
282
+ * poster paints on EVERY path — under prefers-reduced-motion, with
283
+ * `openMotion` off, or with the card scrolled away — because a black
284
+ * rectangle is a bug at any motion setting. Omitted ⇒ nothing changes.
285
+ */
286
+ openFrom?: FlipSource | null;
287
+ /** The card's (already-loaded) thumbnail. Defaults to `poster`. */
288
+ openPoster?: string | null;
289
+ /** Run the motion half of the open (default true). Off ⇒ poster only. */
290
+ openMotion?: boolean;
183
291
  class?: string;
184
292
  };
185
293
  declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
package/dist/index.d.ts CHANGED
@@ -12,7 +12,9 @@ export type { ChapterBlock } from './components/replay/ChapterTimeline.svelte';
12
12
  export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
13
13
  export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
14
14
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
15
- export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, } from './components/VideoPlayer.svelte';
15
+ export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, VideoAdSpan, RateReport, WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource, } from './components/VideoPlayer.svelte';
16
+ export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
17
+ export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster, type IdlePermission } from './video/presence';
16
18
  export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
17
19
  export { speakerPace, suggestRates, rateAt, describeRates, type SpeakerPace, type SpeakerPaceRow, type RateMap, type RateTurn, type RateSegment, type SuggestOpts } from './video/advisors/speaker-rate';
18
20
  export { snapResume, resumeLabel, type ResumePoint, type ResumeReason, type ResumeCtx, type ResumeOpts } from './video/advisors/resume-point';
package/dist/index.js CHANGED
@@ -13,6 +13,15 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
13
13
  // optional so a bare mp4 still gets the full playback UX. TranscriptPlayer
14
14
  // stays exported unchanged — meetings already depends on it.
15
15
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
16
+ // Rate composer (mos-5lzns.32): the same name → rate table the player applies
17
+ // under `speakerPace` (manual beats measured), for a host drawing its own
18
+ // per-speaker speed UI.
19
+ export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
20
+ // Presence (mos-5lzns.34): the permission call a host makes from the click
21
+ // that enables wind-down's desktop signal (the API needs a user gesture),
22
+ // the default resume store, and the FLIP pair for a host animating its own
23
+ // theater shell (e.g. flipClose back onto the card).
24
+ export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster } from './video/presence';
16
25
  // Pure advisors (mos-5lzns.27): the extension-library self-test modules,
17
26
  // ported verbatim. They measure and advise; the player's feature tasks
18
27
  // (.31 audio / .32 rate / .34 presence) consume them. qrCanvas is the one
@@ -17,17 +17,17 @@ props + callback surfaces.
17
17
  | `band.ts` | .28 (done) — extended by **.30** | The timeline band: zoom window (`BandWindow`, `zoomAt`, `followPlayhead`), hit tests (`segmentAt`, `chapterAt`, `biasToSegmentStart`, `nearestBookmark`), and `paintBand` (canvas). .30 adds one-axis arbitration + the light ribbon; its state lives HERE. |
18
18
  | `band/` | **.30** (done) | `window.ts` — the zoom-window math (moved out of band.ts, re-exported there; shared by every band layer without an import cycle). `axis.ts` — one-axis four-state arbitration (`resolveAxis`, `BandState`, the churn-guarded microtask `createBandReporter`), `playheadX` (the one "where you are" mapping — ≤0.2px agreement) and `overviewBracket`. `luma.ts` — the light track (`VideoLumaTrack`, the sidecar verbatim) → ribbon (`v² × 0.55`, max-per-interval). `markers.ts` — `VideoMarker` comments (clustered dots) + like heat. |
19
19
  | `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. |
20
- | `rate.ts` | .28 (done) — filled by **.32** | The rate composer contract (`composeRate`, deadband, clamp), the `SPEED_STEPS` staircase, `formatSpeedLabel`. .32 adds the speaker-pace and silence-map advisors — they never write `playbackRate`; they advise. |
20
+ | `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. |
21
21
  | `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
22
22
  | `settings.ts` | shared — knobs added by .30–.34 | `coerce` (never throws, never junk) + `VIDEO_SETTINGS` registry. A knob lands WITH its reader. |
23
23
  | `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. |
24
24
  | `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). |
25
- | `presence.ts` | **.34** | Wind-down (Idle Detection), resume-at-sentence, FLIP open. |
25
+ | `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. |
26
26
  | `advisors/` | **.27 (done)** | The five extension-library pure advisors, ported verbatim: `smart-speed` (silence map + boost), `speaker-rate` (measured wpm + suggested rates), `resume-point` (sentence-snapping + the label), `quiet-listening` (tone-only chain + `amountFor`), `qr` (ISO 18004 encoder v1–10). Each has its own README row in `advisors/README.md`; the `--self-test` suites live on as vitest in `tests/unit/advisors/` (`npm run test:advisors`). |
27
27
 
28
28
  Sub-task map: **.30** scrubbing/one-axis/ribbon/markers → `band.ts` (+ its own
29
29
  paint layers); **.31** audio graph → `audio.ts`; **.32** rate composer →
30
- `rate.ts` advisors; **.33** night → `night.ts`; **.34** presence/wind-down →
30
+ `rate.ts` + `rate/`; **.33** night → `night.ts`; **.34** presence/wind-down →
31
31
  `presence.ts`. **.27** (landed) ports the advisors themselves — .31's chain
32
32
  builder and `amountFor` live in `advisors/quiet-listening`, .32's two advisor
33
33
  modules in `advisors/smart-speed` + `advisors/speaker-rate`, .34's snapping in
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Open like an object (mos-5lzns.34) — extension-library
3
+ * `lib/open-transition.js`, ported. The card you pointed at becomes the
4
+ * thing you're watching.
5
+ *
6
+ * Opening a video read as a cut, for two different reasons:
7
+ * · the rectangle is BLACK because <video> paints its own opaque letterbox
8
+ * before it has a frame — fix: paint the card's (already-loaded) thumbnail
9
+ * over that box on the same frame the player appears, so there is no
10
+ * instant at which the player is empty;
11
+ * · it is a CUT because nothing carries the eye — fix: move the shell from
12
+ * the card's rectangle to its own.
13
+ *
14
+ * flipOpen({ from, shell, poster?, hasPoster?, duration? }) -> Promise<void>
15
+ * flipClose({ to, shell, duration? }) -> Promise<void>
16
+ * clearPoster(shell) — call on the video's `loadeddata`
17
+ *
18
+ * FLIP (read both rects, invert with a transform, play), never an animated
19
+ * box: width/height/top/left is a layout property per frame. WAAPI, never
20
+ * CSS classes: it works with no stylesheet cooperation and never writes the
21
+ * `style` attribute, so "no inline styles left behind" is structural — the
22
+ * `will-change` hint is reclaimed by name on finish AND cancel.
23
+ *
24
+ * `transformOrigin: 0 0` is restated in BOTH keyframes (a property named in
25
+ * only one keyframe interpolates toward the default and silently breaks the
26
+ * translate/scale pair). Two keyframes, never three: `easing` is the
27
+ * effect's timing function, so a hand-placed offset lands somewhere other
28
+ * than where it reads. The scale is non-uniform on purpose — matching the
29
+ * card's outline exactly is what hides the seam.
30
+ *
31
+ * Every entry point is a no-op returning a resolved Promise when it has
32
+ * nothing honest to animate — no source, a source scrolled off screen, an
33
+ * unmeasurable shell, `prefers-reduced-motion`, or geometry already
34
+ * congruent within 1px / 1%. THE POSTER IS THE EXCEPTION: it is painted on
35
+ * every one of those paths, because a black rectangle is a bug at any
36
+ * motion setting, not a flourish someone opted out of.
37
+ *
38
+ * `flipClose` holds its end state (`fill: forwards`) — snapping back to full
39
+ * size on the last frame would flash the whole player right before the host
40
+ * hides it. The hold is released by the next `flipOpen` on EVERY path,
41
+ * including the no-op ones, so a shell reopened under reduced motion can
42
+ * never sit shrunk at the last card's position.
43
+ *
44
+ * The one DOM-touching module in presence/ besides idle.ts; its globals are
45
+ * injectable (`g`) so node:test drives it with fakes.
46
+ */
47
+ /** The app's one state-change curve. */
48
+ export declare const FLIP_EASE = "cubic-bezier(.25, 1, .5, 1)";
49
+ export declare const FLIP_OPEN_MS = 260;
50
+ export declare const FLIP_CLOSE_MS = 200;
51
+ /** Opacity the shell starts at: shallow with a picture on screen, deeper without. */
52
+ export declare const FLIP_FADE_WITH_POSTER = 0.7;
53
+ export declare const FLIP_FADE_BARE = 0.25;
54
+ export declare const FLIP_POSTER_CLASS = "jatui-flip-poster";
55
+ /** Anything with a rectangle: an Element, a DOMRect, or a snapshot of one. */
56
+ export type FlipSource = {
57
+ getBoundingClientRect(): {
58
+ left: number;
59
+ top: number;
60
+ width: number;
61
+ height: number;
62
+ };
63
+ } | {
64
+ left: number;
65
+ top: number;
66
+ width: number;
67
+ height: number;
68
+ };
69
+ export interface FlipRect {
70
+ left: number;
71
+ top: number;
72
+ width: number;
73
+ height: number;
74
+ }
75
+ type Globals = {
76
+ matchMedia?: (q: string) => {
77
+ matches: boolean;
78
+ };
79
+ innerWidth?: number;
80
+ innerHeight?: number;
81
+ document?: {
82
+ createElement(tag: string): unknown;
83
+ };
84
+ getComputedStyle?: (el: unknown) => {
85
+ position?: string;
86
+ borderRadius?: string;
87
+ };
88
+ };
89
+ /**
90
+ * Duck-types an Element or a rect snapshot (a virtualised grid may hand over
91
+ * a rect it measured before the card went away). Zero-sized or non-finite ⇒
92
+ * null — never divide a scale factor by nothing.
93
+ */
94
+ export declare function rectOf(x: unknown): FlipRect | null;
95
+ /** Any part of the rect inside the viewport. Unknown viewport ⇒ assume visible. */
96
+ export declare function onScreen(r: FlipRect, vw: unknown, vh: unknown): boolean;
97
+ /**
98
+ * The inversion — "put my corner on the card's corner, shrink to its size".
99
+ * Null when the boxes already coincide (within 1px / 1%) or can't be scaled.
100
+ */
101
+ export declare function flipGeometry(visual: FlipRect, layout: FlipRect): {
102
+ dx: number;
103
+ dy: number;
104
+ sx: number;
105
+ sy: number;
106
+ } | null;
107
+ export declare function awayFrame(g: {
108
+ dx: number;
109
+ dy: number;
110
+ sx: number;
111
+ sy: number;
112
+ }): Keyframe;
113
+ export declare function homeFrame(): Keyframe;
114
+ /**
115
+ * A poster string → a safe `background-image` value, or "" for none. A bare
116
+ * URL containing a quote, paren or backslash emits nothing rather than a
117
+ * broken declaration that would take the whole rule with it.
118
+ */
119
+ export declare function posterBackground(poster: unknown): string;
120
+ /** Remove a poster painted by flipOpen (and the position it may have needed). */
121
+ export declare function clearPoster(shell: unknown): void;
122
+ /**
123
+ * Grow `shell` out of `from`'s rectangle. `poster` (a URL or CSS image) is
124
+ * painted into the shell on EVERY path; a host that paints its own poster
125
+ * passes `hasPoster: true` instead so the fade depth matches.
126
+ */
127
+ export declare function flipOpen(opts?: {
128
+ from?: FlipSource | null;
129
+ shell?: unknown;
130
+ poster?: string | null;
131
+ hasPoster?: boolean;
132
+ duration?: number;
133
+ g?: Globals;
134
+ }): Promise<void>;
135
+ /**
136
+ * Shrink `shell` back onto `to` and HOLD there (the host hides it after).
137
+ * No opacity: the last frame before the host hides the shell IS the card.
138
+ */
139
+ export declare function flipClose(opts?: {
140
+ to?: FlipSource | null;
141
+ shell?: unknown;
142
+ duration?: number;
143
+ g?: Globals;
144
+ }): Promise<void>;
145
+ export {};
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Open like an object (mos-5lzns.34) — extension-library
3
+ * `lib/open-transition.js`, ported. The card you pointed at becomes the
4
+ * thing you're watching.
5
+ *
6
+ * Opening a video read as a cut, for two different reasons:
7
+ * · the rectangle is BLACK because <video> paints its own opaque letterbox
8
+ * before it has a frame — fix: paint the card's (already-loaded) thumbnail
9
+ * over that box on the same frame the player appears, so there is no
10
+ * instant at which the player is empty;
11
+ * · it is a CUT because nothing carries the eye — fix: move the shell from
12
+ * the card's rectangle to its own.
13
+ *
14
+ * flipOpen({ from, shell, poster?, hasPoster?, duration? }) -> Promise<void>
15
+ * flipClose({ to, shell, duration? }) -> Promise<void>
16
+ * clearPoster(shell) — call on the video's `loadeddata`
17
+ *
18
+ * FLIP (read both rects, invert with a transform, play), never an animated
19
+ * box: width/height/top/left is a layout property per frame. WAAPI, never
20
+ * CSS classes: it works with no stylesheet cooperation and never writes the
21
+ * `style` attribute, so "no inline styles left behind" is structural — the
22
+ * `will-change` hint is reclaimed by name on finish AND cancel.
23
+ *
24
+ * `transformOrigin: 0 0` is restated in BOTH keyframes (a property named in
25
+ * only one keyframe interpolates toward the default and silently breaks the
26
+ * translate/scale pair). Two keyframes, never three: `easing` is the
27
+ * effect's timing function, so a hand-placed offset lands somewhere other
28
+ * than where it reads. The scale is non-uniform on purpose — matching the
29
+ * card's outline exactly is what hides the seam.
30
+ *
31
+ * Every entry point is a no-op returning a resolved Promise when it has
32
+ * nothing honest to animate — no source, a source scrolled off screen, an
33
+ * unmeasurable shell, `prefers-reduced-motion`, or geometry already
34
+ * congruent within 1px / 1%. THE POSTER IS THE EXCEPTION: it is painted on
35
+ * every one of those paths, because a black rectangle is a bug at any
36
+ * motion setting, not a flourish someone opted out of.
37
+ *
38
+ * `flipClose` holds its end state (`fill: forwards`) — snapping back to full
39
+ * size on the last frame would flash the whole player right before the host
40
+ * hides it. The hold is released by the next `flipOpen` on EVERY path,
41
+ * including the no-op ones, so a shell reopened under reduced motion can
42
+ * never sit shrunk at the last card's position.
43
+ *
44
+ * The one DOM-touching module in presence/ besides idle.ts; its globals are
45
+ * injectable (`g`) so node:test drives it with fakes.
46
+ */
47
+ /** The app's one state-change curve. */
48
+ export const FLIP_EASE = 'cubic-bezier(.25, 1, .5, 1)';
49
+ export const FLIP_OPEN_MS = 260;
50
+ export const FLIP_CLOSE_MS = 200;
51
+ /** Opacity the shell starts at: shallow with a picture on screen, deeper without. */
52
+ export const FLIP_FADE_WITH_POSTER = 0.7;
53
+ export const FLIP_FADE_BARE = 0.25;
54
+ export const FLIP_POSTER_CLASS = 'jatui-flip-poster';
55
+ const G = () => (typeof globalThis === 'undefined' ? {} : globalThis);
56
+ // Keyed on the shell, not a field on an element the host owns.
57
+ const running = new WeakMap();
58
+ function reducedMotion(g) {
59
+ try {
60
+ return typeof g.matchMedia === 'function' && g.matchMedia('(prefers-reduced-motion: reduce)').matches;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ function animatable(el) {
67
+ return !!el && typeof el.animate === 'function' && typeof el.getBoundingClientRect === 'function';
68
+ }
69
+ /**
70
+ * Duck-types an Element or a rect snapshot (a virtualised grid may hand over
71
+ * a rect it measured before the card went away). Zero-sized or non-finite ⇒
72
+ * null — never divide a scale factor by nothing.
73
+ */
74
+ export function rectOf(x) {
75
+ if (!x || typeof x !== 'object')
76
+ return null;
77
+ const src = x;
78
+ let r;
79
+ try {
80
+ r =
81
+ typeof src.getBoundingClientRect === 'function'
82
+ ? src.getBoundingClientRect()
83
+ : src;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ if (!r)
89
+ return null;
90
+ const width = Number(r.width);
91
+ const height = Number(r.height);
92
+ const left = Number(r.left);
93
+ const top = Number(r.top);
94
+ if (!(width > 0) || !(height > 0) || !Number.isFinite(left) || !Number.isFinite(top))
95
+ return null;
96
+ return { left, top, width, height };
97
+ }
98
+ /** Any part of the rect inside the viewport. Unknown viewport ⇒ assume visible. */
99
+ export function onScreen(r, vw, vh) {
100
+ const w = Number(vw);
101
+ const h = Number(vh);
102
+ if (!Number.isFinite(w) || !Number.isFinite(h))
103
+ return true;
104
+ return r.left < w && r.top < h && r.left + r.width > 0 && r.top + r.height > 0;
105
+ }
106
+ /**
107
+ * The inversion — "put my corner on the card's corner, shrink to its size".
108
+ * Null when the boxes already coincide (within 1px / 1%) or can't be scaled.
109
+ */
110
+ export function flipGeometry(visual, layout) {
111
+ const sx = visual.width / layout.width;
112
+ const sy = visual.height / layout.height;
113
+ const dx = visual.left - layout.left;
114
+ const dy = visual.top - layout.top;
115
+ if (![sx, sy, dx, dy].every(Number.isFinite) || !(sx > 0) || !(sy > 0))
116
+ return null;
117
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(sx - 1) < 0.01 && Math.abs(sy - 1) < 0.01)
118
+ return null;
119
+ return { dx, dy, sx, sy };
120
+ }
121
+ export function awayFrame(g) {
122
+ return { transformOrigin: '0 0', transform: `translate(${g.dx}px, ${g.dy}px) scale(${g.sx}, ${g.sy})` };
123
+ }
124
+ export function homeFrame() {
125
+ return { transformOrigin: '0 0', transform: 'none' };
126
+ }
127
+ /**
128
+ * A poster string → a safe `background-image` value, or "" for none. A bare
129
+ * URL containing a quote, paren or backslash emits nothing rather than a
130
+ * broken declaration that would take the whole rule with it.
131
+ */
132
+ export function posterBackground(poster) {
133
+ if (typeof poster !== 'string')
134
+ return '';
135
+ const v = poster.trim();
136
+ if (!v)
137
+ return '';
138
+ if (/^(url\(|none$|(-webkit-)?(linear|radial|conic)-gradient\(|(-webkit-)?image-set\()/i.test(v)) {
139
+ return v.toLowerCase() === 'none' ? '' : v;
140
+ }
141
+ if (/["'()\\]/.test(v))
142
+ return '';
143
+ return `url("${v}")`;
144
+ }
145
+ /** Remove a poster painted by flipOpen (and the position it may have needed). */
146
+ export function clearPoster(shell) {
147
+ const s = shell;
148
+ if (!s || typeof s.querySelector !== 'function')
149
+ return;
150
+ const el = s.querySelector('.' + FLIP_POSTER_CLASS);
151
+ if (!el)
152
+ return;
153
+ if (el.dataset?.restorePosition === '1')
154
+ s.style.position = '';
155
+ el.remove?.();
156
+ }
157
+ function paintPoster(shell, poster, g) {
158
+ if (!shell || typeof shell.appendChild !== 'function' || !g.document)
159
+ return false;
160
+ clearPoster(shell); // a posterless reopen must not keep the last video's picture
161
+ const image = posterBackground(poster);
162
+ if (!image)
163
+ return false;
164
+ const el = g.document.createElement('div');
165
+ el.className = FLIP_POSTER_CLASS;
166
+ el.setAttribute('aria-hidden', 'true');
167
+ el.style.cssText =
168
+ 'position:absolute;inset:0;z-index:0;pointer-events:none;' +
169
+ 'background-repeat:no-repeat;background-position:center;background-size:contain;';
170
+ el.style.backgroundImage = image;
171
+ const cs = g.getComputedStyle;
172
+ if (typeof cs === 'function' && cs(shell).position === 'static') {
173
+ shell.style.position = 'relative';
174
+ el.dataset.restorePosition = '1';
175
+ }
176
+ shell.appendChild(el);
177
+ return true;
178
+ }
179
+ function release(shell) {
180
+ const prev = running.get(shell);
181
+ if (!prev)
182
+ return;
183
+ running.delete(shell);
184
+ try {
185
+ prev.cancel();
186
+ }
187
+ catch {
188
+ /* already gone */
189
+ }
190
+ shell.style.willChange = '';
191
+ }
192
+ function play(shell, frames, duration, hold) {
193
+ const anim = shell.animate(frames, { duration, easing: FLIP_EASE, fill: hold ? 'forwards' : 'none' });
194
+ running.set(shell, anim);
195
+ shell.style.willChange = 'transform'; // promoted for the quarter second it earns it, never longer
196
+ const settle = () => {
197
+ if (running.get(shell) !== anim)
198
+ return; // a later open already owns the shell
199
+ if (!hold)
200
+ running.delete(shell);
201
+ shell.style.willChange = '';
202
+ };
203
+ return anim.finished.then(settle, settle);
204
+ }
205
+ const durationOf = (v, fallback) => {
206
+ const n = Number(v);
207
+ return Number.isFinite(n) && n > 0 ? n : fallback;
208
+ };
209
+ /**
210
+ * Grow `shell` out of `from`'s rectangle. `poster` (a URL or CSS image) is
211
+ * painted into the shell on EVERY path; a host that paints its own poster
212
+ * passes `hasPoster: true` instead so the fade depth matches.
213
+ */
214
+ export function flipOpen(opts = {}) {
215
+ const g = opts.g ?? G();
216
+ const shell = opts.shell;
217
+ const painted = paintPoster(shell, opts.poster, g) || opts.hasPoster === true;
218
+ if (!animatable(shell))
219
+ return Promise.resolve();
220
+ release(shell); // on every path — a held close must never survive a reopen
221
+ if (reducedMotion(g))
222
+ return Promise.resolve();
223
+ const fromRect = rectOf(opts.from);
224
+ const shellRect = rectOf(shell);
225
+ if (!fromRect || !shellRect || !onScreen(fromRect, g.innerWidth, g.innerHeight))
226
+ return Promise.resolve();
227
+ const geo = flipGeometry(fromRect, shellRect);
228
+ if (!geo)
229
+ return Promise.resolve();
230
+ const fade = painted ? FLIP_FADE_WITH_POSTER : FLIP_FADE_BARE;
231
+ return play(shell, [{ ...awayFrame(geo), opacity: fade }, { ...homeFrame(), opacity: 1 }], durationOf(opts.duration, FLIP_OPEN_MS), false);
232
+ }
233
+ /**
234
+ * Shrink `shell` back onto `to` and HOLD there (the host hides it after).
235
+ * No opacity: the last frame before the host hides the shell IS the card.
236
+ */
237
+ export function flipClose(opts = {}) {
238
+ const g = opts.g ?? G();
239
+ const shell = opts.shell;
240
+ if (!animatable(shell))
241
+ return Promise.resolve();
242
+ release(shell);
243
+ if (reducedMotion(g))
244
+ return Promise.resolve();
245
+ const toRect = rectOf(opts.to);
246
+ const shellRect = rectOf(shell);
247
+ if (!toRect || !shellRect || !onScreen(toRect, g.innerWidth, g.innerHeight))
248
+ return Promise.resolve();
249
+ const geo = flipGeometry(toRect, shellRect);
250
+ if (!geo)
251
+ return Promise.resolve();
252
+ return play(shell, [homeFrame(), awayFrame(geo)], durationOf(opts.duration, FLIP_CLOSE_MS), true);
253
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The desktop signal for wind-down (mos-5lzns.34): Chromium's Idle Detection
3
+ * API (`IdleDetector`) — system-wide user idle + screen locked, the browser's
4
+ * near-exact equivalent of the extension's `hyprctl cursorpos` + `hyprlock`.
5
+ *
6
+ * Three rules, all load-bearing:
7
+ *
8
+ * 1. **Feature-detect, never assume.** `IdleDetector` exists only in
9
+ * Chromium, only in a secure context, and only where the
10
+ * `idle-detection` permissions policy allows it (a cross-origin iframe
11
+ * without `allow="idle-detection"` is refused). Every entry point here
12
+ * fails toward "no desktop signal", which the caller turns into the
13
+ * labelled page-presence fallback — never into a guess.
14
+ * 2. **Never prompt uninvited.** `requestIdleDetection()` is the ONLY call
15
+ * that can surface a permission prompt, and it is only ever made because
16
+ * the host opted in (the player's `idleDetection` prop). It needs a user
17
+ * gesture (transient activation) — call it from the click that enables
18
+ * the setting. `startDesktopWatch()` never asks: it starts only when the
19
+ * permission is already granted.
20
+ * 3. **The caller decides.** Like the extension's host (`desktop_idle()`
21
+ * reports two raw facts and keeps no state), this reports `{ idle,
22
+ * locked }` and nothing else. How long the video has been playing, and
23
+ * whether that adds up to absence, is the component's call
24
+ * (./winddown.ts).
25
+ *
26
+ * The global is injectable (`g`) so node:test can drive a fake detector —
27
+ * the same seam the audio graph uses for AudioContext.
28
+ */
29
+ import type { DesktopSignal } from './winddown';
30
+ /** The API's floor: `threshold` below 60s is rejected by the browser. */
31
+ export declare const IDLE_THRESHOLD_MIN_MS = 60000;
32
+ export type IdlePermission = 'granted' | 'denied' | 'prompt' | 'unsupported';
33
+ type Globals = {
34
+ IdleDetector?: unknown;
35
+ isSecureContext?: boolean;
36
+ navigator?: {
37
+ permissions?: {
38
+ query?: (d: {
39
+ name: string;
40
+ }) => Promise<{
41
+ state: string;
42
+ }>;
43
+ };
44
+ };
45
+ AbortController?: typeof AbortController;
46
+ };
47
+ /** Does this browser have the Idle Detection API at all? (Chromium, secure context.) */
48
+ export declare function idleDetectionSupported(g?: Globals | undefined): boolean;
49
+ /**
50
+ * The current permission state WITHOUT prompting. `'unsupported'` when the
51
+ * API or the Permissions API is missing, or the query throws (an unknown
52
+ * permission name throws in browsers that don't know it).
53
+ */
54
+ export declare function idleDetectionPermission(g?: Globals | undefined): Promise<IdlePermission>;
55
+ /**
56
+ * Ask for the permission. MUST be called from a user gesture, and only
57
+ * because the user opted in — this is the one call that can show a prompt.
58
+ * Resolves `'granted'` / `'denied'`, or `'unsupported'` (no API, or the
59
+ * browser refused to ask: no activation, policy-blocked frame).
60
+ */
61
+ export declare function requestIdleDetection(g?: Globals | undefined): Promise<Exclude<IdlePermission, 'prompt'>>;
62
+ export interface DesktopWatch {
63
+ /** Resolves true once the detector is running; false if it could not start. */
64
+ ready: Promise<boolean>;
65
+ /** Latest reading, or null before the first one. */
66
+ read(): DesktopSignal | null;
67
+ stop(): void;
68
+ }
69
+ /** Map the API's two strings onto the two facts. Unknown ⇒ null (no reading). */
70
+ export declare function toDesktopSignal(userState: unknown, screenState: unknown): DesktopSignal | null;
71
+ /**
72
+ * Start watching the desktop. Never prompts: `start()` rejects with
73
+ * NotAllowedError unless the permission is already granted, and that
74
+ * rejection becomes `ready → false`. `thresholdMs` is clamped up to the
75
+ * API's 60s floor. `onChange` fires with each new reading.
76
+ */
77
+ export declare function startDesktopWatch(opts: {
78
+ thresholdMs: number;
79
+ onChange: (signal: DesktopSignal | null) => void;
80
+ g?: Globals;
81
+ }): DesktopWatch;
82
+ export {};