@joewinke/jatui 0.14.0 → 0.16.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/dist/components/VideoPlayer.svelte +843 -19
- package/dist/components/VideoPlayer.svelte.d.ts +103 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +10 -0
- package/dist/video/README.md +2 -2
- package/dist/video/keys.d.ts +5 -5
- package/dist/video/keys.js +5 -4
- package/dist/video/presence/flip.d.ts +145 -0
- package/dist/video/presence/flip.js +253 -0
- package/dist/video/presence/idle.d.ts +82 -0
- package/dist/video/presence/idle.js +145 -0
- package/dist/video/presence/resume.d.ts +76 -0
- package/dist/video/presence/resume.js +139 -0
- package/dist/video/presence/winddown.d.ts +106 -0
- package/dist/video/presence/winddown.js +104 -0
- package/dist/video/presence.d.ts +25 -21
- package/dist/video/presence.js +25 -21
- package/dist/video/settings/SettingsPanel.svelte +414 -0
- package/dist/video/settings/SettingsPanel.svelte.d.ts +16 -0
- package/dist/video/settings/panel.d.ts +24 -0
- package/dist/video/settings/panel.js +58 -0
- package/dist/video/settings/registry.d.ts +82 -0
- package/dist/video/settings/registry.js +331 -0
- package/dist/video/settings/store.d.ts +55 -0
- package/dist/video/settings/store.js +116 -0
- package/dist/video/settings.d.ts +15 -46
- package/dist/video/settings.js +15 -82
- package/package.json +1 -1
|
@@ -5,6 +5,9 @@ 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
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';
|
|
10
|
+
export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
|
|
8
11
|
import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
|
|
9
12
|
import { type BandState } from '../video/band/axis';
|
|
10
13
|
import { type VideoLumaTrack } from '../video/band/luma';
|
|
@@ -12,6 +15,9 @@ import { type VideoMarker } from '../video/band/markers';
|
|
|
12
15
|
import { type RateReport, type VideoAdSpan } from '../video/rate';
|
|
13
16
|
import { type NightMode, type NightWindow } from '../video/night/clock';
|
|
14
17
|
import { type AudioBrokenReason, type AudioState } from '../video/audio';
|
|
18
|
+
import { type FlipSource, type VideoResumeStore, type WindDownState } from '../video/presence';
|
|
19
|
+
import type { ResumePoint } from '../video/advisors/resume-point';
|
|
20
|
+
import { type SettingValue, type VideoSettingsStore } from '../video/settings';
|
|
15
21
|
type $$ComponentProps = {
|
|
16
22
|
src: string;
|
|
17
23
|
poster?: string | null;
|
|
@@ -214,6 +220,103 @@ type $$ComponentProps = {
|
|
|
214
220
|
skipAds?: boolean;
|
|
215
221
|
/** The composed rate changed (real change only). `base` is what the UI shows. */
|
|
216
222
|
onRateChange?: (report: RateReport) => void;
|
|
223
|
+
/**
|
|
224
|
+
* Wind-down (mos-5lzns.34): when nobody is there, the picture and sound
|
|
225
|
+
* fade over 20s and the video pauses, keeping your place. The fade IS
|
|
226
|
+
* the prompt — any input anywhere on the page (or the desktop coming
|
|
227
|
+
* back) cancels it and brings everything straight back. Omitted/false ⇒
|
|
228
|
+
* the feature does not exist on this player: no listeners, no timers,
|
|
229
|
+
* no variables.
|
|
230
|
+
*
|
|
231
|
+
* The evidence of absence is the DESKTOP when `idleDetection` is on and
|
|
232
|
+
* granted (Chromium's Idle Detection API: system-wide idle, and a locked
|
|
233
|
+
* screen skips the wait entirely); otherwise it is PAGE presence — no
|
|
234
|
+
* input on this page for `windDownAfter` minutes — which is weaker
|
|
235
|
+
* evidence (a video legitimately plays for an hour untouched), and is
|
|
236
|
+
* reported as `source: 'page'` so the host can label it.
|
|
237
|
+
*/
|
|
238
|
+
windDown?: boolean;
|
|
239
|
+
/** Minutes of absence before the fade begins, 10–90 (default 30). */
|
|
240
|
+
windDownAfter?: number;
|
|
241
|
+
/**
|
|
242
|
+
* Only wind down while night is armed (default true — falling asleep
|
|
243
|
+
* mid-video is a night problem). Uses the `night` mode when present,
|
|
244
|
+
* else the local-clock auto window.
|
|
245
|
+
*/
|
|
246
|
+
windDownNightOnly?: boolean;
|
|
247
|
+
/**
|
|
248
|
+
* Opt in to the Idle Detection API (Chromium only; permission-gated).
|
|
249
|
+
* The player NEVER prompts without this. Already granted ⇒ it starts at
|
|
250
|
+
* once; still `prompt` ⇒ the permission is requested on the next click
|
|
251
|
+
* or key inside the player (the API needs a user gesture). Better: call
|
|
252
|
+
* `requestIdleDetection()` from the click that enables the setting.
|
|
253
|
+
* Unsupported or denied ⇒ the page-presence fallback, reported.
|
|
254
|
+
*/
|
|
255
|
+
idleDetection?: boolean;
|
|
256
|
+
/** Phase/source changes: `off` | `watching` | `fading` | `paused`, and `desktop` | `page`. */
|
|
257
|
+
onWindDown?: (state: WindDownState) => void;
|
|
258
|
+
/**
|
|
259
|
+
* Resume (mos-5lzns.34): present ⇒ the player remembers where this video
|
|
260
|
+
* was left under this key and returns there when it opens again. An
|
|
261
|
+
* explicit `startAt` or a `?t=` deep link always wins over a stored point.
|
|
262
|
+
*/
|
|
263
|
+
resumeKey?: string | null;
|
|
264
|
+
/**
|
|
265
|
+
* Where resume points live — `{ load(key), save(key, sec | null) }`,
|
|
266
|
+
* either may be async. Default: this browser's localStorage. jatui never
|
|
267
|
+
* calls an API; a host with per-user server state passes its own.
|
|
268
|
+
*/
|
|
269
|
+
resumeStore?: VideoResumeStore;
|
|
270
|
+
/**
|
|
271
|
+
* Snap the resume point back to the nearest sentence / speaker turn /
|
|
272
|
+
* chapter within 15s once that data lands, and say the line you're
|
|
273
|
+
* walking back into (quoted, never paraphrased). Stands down if you've
|
|
274
|
+
* moved the playhead yourself. Default on; off ⇒ the raw second.
|
|
275
|
+
*/
|
|
276
|
+
resumeSnap?: boolean;
|
|
277
|
+
/** The player moved the playhead for a resume: `from` the stored second, to `point`. */
|
|
278
|
+
onResume?: (from: number, point: ResumePoint) => void;
|
|
279
|
+
/**
|
|
280
|
+
* Open like an object (mos-5lzns.34): the clicked card (an element or a
|
|
281
|
+
* rect snapshot). Present ⇒ on mount the stage grows out of that
|
|
282
|
+
* rectangle (FLIP over WAAPI, 260ms) with `openPoster` painted over the
|
|
283
|
+
* video until its first frame decodes. `null` ⇒ the poster only. The
|
|
284
|
+
* poster paints on EVERY path — under prefers-reduced-motion, with
|
|
285
|
+
* `openMotion` off, or with the card scrolled away — because a black
|
|
286
|
+
* rectangle is a bug at any motion setting. Omitted ⇒ nothing changes.
|
|
287
|
+
*/
|
|
288
|
+
openFrom?: FlipSource | null;
|
|
289
|
+
/** The card's (already-loaded) thumbnail. Defaults to `poster`. */
|
|
290
|
+
openPoster?: string | null;
|
|
291
|
+
/** Run the motion half of the open (default true). Off ⇒ poster only. */
|
|
292
|
+
openMotion?: boolean;
|
|
293
|
+
/**
|
|
294
|
+
* Settings (mos-5lzns.35): where the viewer's standing choices live —
|
|
295
|
+
* `{ get(id), set(id, value) }`, either may be async, keyed by the ids
|
|
296
|
+
* in `VIDEO_SETTINGS`. jatui never persists on its own (no
|
|
297
|
+
* localStorage, no API): a host with per-user state passes its own
|
|
298
|
+
* adapter. Every stored value is coerced; junk reads as nothing.
|
|
299
|
+
*
|
|
300
|
+
* A stored choice overrides the matching prop — the prop is the
|
|
301
|
+
* host's DEFAULT, the stored value is the viewer's decision. It never
|
|
302
|
+
* creates a feature the host left out: a stored `audio.level` does
|
|
303
|
+
* nothing on a player with no `loudness` prop. `night.mode` /
|
|
304
|
+
* `night.depth` are written into the bindable `night` / `nightDepth`,
|
|
305
|
+
* and `N` / the moon wheel are saved back through the store.
|
|
306
|
+
*/
|
|
307
|
+
settingsStore?: VideoSettingsStore;
|
|
308
|
+
/**
|
|
309
|
+
* Show the settings panel (default off — inert): a gear in the control
|
|
310
|
+
* strip and the `,` key open it. The panel is generated from the
|
|
311
|
+
* registry and shows only rows for features THIS player has (host
|
|
312
|
+
* opted in and the data exists). Without a `settingsStore`, choices
|
|
313
|
+
* last while the player is mounted.
|
|
314
|
+
*/
|
|
315
|
+
settingsPanel?: boolean;
|
|
316
|
+
/** A host page explaining the settings — linked from the panel's footer. */
|
|
317
|
+
settingsHelpHref?: string | null;
|
|
318
|
+
/** The viewer changed a setting (panel, `N`, the moon wheel). Coerced value. */
|
|
319
|
+
onSettingChange?: (id: string, value: SettingValue) => void;
|
|
217
320
|
class?: string;
|
|
218
321
|
};
|
|
219
322
|
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
package/dist/index.d.ts
CHANGED
|
@@ -12,8 +12,10 @@ 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, VideoAdSpan, RateReport, } 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
16
|
export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
|
|
17
|
+
export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster, type IdlePermission } from './video/presence';
|
|
18
|
+
export { VIDEO_SETTINGS, SETTING_GROUPS, settingSpec, settingValue, coerce as coerceVideoSetting, memorySettingsStore, panelGroups as videoSettingsGroups, formatSettingValue, type VideoSettingsStore, type SettingSpec as VideoSettingSpec, type SettingValue as VideoSettingValue, type SettingOption as VideoSettingOption, type SettingWhen as VideoSettingWhen, type SettingAvailability as VideoSettingAvailability } from './video/settings';
|
|
17
19
|
export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
|
|
18
20
|
export { speakerPace, suggestRates, rateAt, describeRates, type SpeakerPace, type SpeakerPaceRow, type RateMap, type RateTurn, type RateSegment, type SuggestOpts } from './video/advisors/speaker-rate';
|
|
19
21
|
export { snapResume, resumeLabel, type ResumePoint, type ResumeReason, type ResumeCtx, type ResumeOpts } from './video/advisors/resume-point';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,16 @@ export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
|
17
17
|
// under `speakerPace` (manual beats measured), for a host drawing its own
|
|
18
18
|
// per-speaker speed UI.
|
|
19
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';
|
|
25
|
+
// Settings (mos-5lzns.35): the registry is the single source for the
|
|
26
|
+
// player's panel AND a host's help page (labels + hints), the store adapter
|
|
27
|
+
// type a host implements for per-user persistence, and `coerce` for a host
|
|
28
|
+
// that reads the same values elsewhere. jatui never persists settings itself.
|
|
29
|
+
export { VIDEO_SETTINGS, SETTING_GROUPS, settingSpec, settingValue, coerce as coerceVideoSetting, memorySettingsStore, panelGroups as videoSettingsGroups, formatSettingValue } from './video/settings';
|
|
20
30
|
// Pure advisors (mos-5lzns.27): the extension-library self-test modules,
|
|
21
31
|
// ported verbatim. They measure and advise; the player's feature tasks
|
|
22
32
|
// (.31 audio / .32 rate / .34 presence) consume them. qrCanvas is the one
|
package/dist/video/README.md
CHANGED
|
@@ -19,10 +19,10 @@ props + callback surfaces.
|
|
|
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
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
|
-
| `settings.ts` |
|
|
22
|
+
| `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
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** |
|
|
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
|
package/dist/video/keys.d.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* library-app verbs, not player verbs), `/` (transcript search — the pane
|
|
11
|
+
* has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
|
|
12
|
+
* and `,` (settings) with the settings panel (mos-5lzns.35), each gated on
|
|
13
|
+
* the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
export interface PlayerKeyRow {
|
|
16
16
|
/** Keycap label as shown in the help overlay — `/`-separated alternates. */
|
|
@@ -20,7 +20,7 @@ export interface PlayerKeyRow {
|
|
|
20
20
|
* Availability gate: the row is only advertised (and the key only bound)
|
|
21
21
|
* when the feature's data exists. Undefined ⇒ always active.
|
|
22
22
|
*/
|
|
23
|
-
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments' | 'night';
|
|
23
|
+
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings';
|
|
24
24
|
}
|
|
25
25
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
26
26
|
export declare const PLAYER_KEYS: PlayerKeyRow[];
|
package/dist/video/keys.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* library-app verbs, not player verbs), `/` (transcript search — the pane
|
|
11
|
+
* has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
|
|
12
|
+
* and `,` (settings) with the settings panel (mos-5lzns.35), each gated on
|
|
13
|
+
* the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
16
16
|
export const PLAYER_KEYS = [
|
|
@@ -30,6 +30,7 @@ export const PLAYER_KEYS = [
|
|
|
30
30
|
{ keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
|
|
31
31
|
{ keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
|
|
32
32
|
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
33
|
+
{ keys: ',', description: 'Settings', when: 'settings' },
|
|
33
34
|
{ keys: '?', description: 'Toggle this help' }
|
|
34
35
|
];
|
|
35
36
|
/** Band-local keys (band focused): advertised by the band's aria, not the overlay. */
|
|
@@ -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
|
+
}
|