@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.
- package/dist/components/VideoPlayer.svelte +779 -16
- package/dist/components/VideoPlayer.svelte.d.ts +108 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +9 -0
- package/dist/video/README.md +3 -3
- 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/rate/ads.d.ts +36 -0
- package/dist/video/rate/ads.js +59 -0
- package/dist/video/rate/compose.d.ts +82 -0
- package/dist/video/rate/compose.js +114 -0
- package/dist/video/rate/silence.d.ts +27 -0
- package/dist/video/rate/silence.js +48 -0
- package/dist/video/rate/speaker.d.ts +54 -0
- package/dist/video/rate/speaker.js +94 -0
- package/dist/video/rate/tick.d.ts +38 -0
- package/dist/video/rate/tick.js +15 -0
- package/dist/video/rate.d.ts +20 -63
- package/dist/video/rate.js +20 -82
- package/dist/video/settings.js +25 -2
- package/package.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
/** The API's floor: `threshold` below 60s is rejected by the browser. */
|
|
30
|
+
export const IDLE_THRESHOLD_MIN_MS = 60_000;
|
|
31
|
+
function ctorOf(g) {
|
|
32
|
+
if (!g || typeof g !== 'object')
|
|
33
|
+
return null;
|
|
34
|
+
if (g.isSecureContext === false)
|
|
35
|
+
return null;
|
|
36
|
+
const C = g.IdleDetector;
|
|
37
|
+
return typeof C === 'function' ? C : null;
|
|
38
|
+
}
|
|
39
|
+
const defaultGlobals = () => typeof globalThis === 'undefined' ? undefined : globalThis;
|
|
40
|
+
/** Does this browser have the Idle Detection API at all? (Chromium, secure context.) */
|
|
41
|
+
export function idleDetectionSupported(g = defaultGlobals()) {
|
|
42
|
+
return ctorOf(g) !== null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The current permission state WITHOUT prompting. `'unsupported'` when the
|
|
46
|
+
* API or the Permissions API is missing, or the query throws (an unknown
|
|
47
|
+
* permission name throws in browsers that don't know it).
|
|
48
|
+
*/
|
|
49
|
+
export async function idleDetectionPermission(g = defaultGlobals()) {
|
|
50
|
+
if (!ctorOf(g))
|
|
51
|
+
return 'unsupported';
|
|
52
|
+
const query = g?.navigator?.permissions?.query;
|
|
53
|
+
if (typeof query !== 'function')
|
|
54
|
+
return 'unsupported';
|
|
55
|
+
try {
|
|
56
|
+
const r = await query.call(g.navigator.permissions, { name: 'idle-detection' });
|
|
57
|
+
const s = r?.state;
|
|
58
|
+
return s === 'granted' || s === 'denied' || s === 'prompt' ? s : 'unsupported';
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return 'unsupported';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Ask for the permission. MUST be called from a user gesture, and only
|
|
66
|
+
* because the user opted in — this is the one call that can show a prompt.
|
|
67
|
+
* Resolves `'granted'` / `'denied'`, or `'unsupported'` (no API, or the
|
|
68
|
+
* browser refused to ask: no activation, policy-blocked frame).
|
|
69
|
+
*/
|
|
70
|
+
export async function requestIdleDetection(g = defaultGlobals()) {
|
|
71
|
+
const C = ctorOf(g);
|
|
72
|
+
if (!C || typeof C.requestPermission !== 'function')
|
|
73
|
+
return 'unsupported';
|
|
74
|
+
try {
|
|
75
|
+
const s = await C.requestPermission();
|
|
76
|
+
return s === 'granted' ? 'granted' : 'denied';
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return 'unsupported';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Map the API's two strings onto the two facts. Unknown ⇒ null (no reading). */
|
|
83
|
+
export function toDesktopSignal(userState, screenState) {
|
|
84
|
+
if (userState !== 'active' && userState !== 'idle')
|
|
85
|
+
return null;
|
|
86
|
+
return { idle: userState === 'idle', locked: screenState === 'locked' };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Start watching the desktop. Never prompts: `start()` rejects with
|
|
90
|
+
* NotAllowedError unless the permission is already granted, and that
|
|
91
|
+
* rejection becomes `ready → false`. `thresholdMs` is clamped up to the
|
|
92
|
+
* API's 60s floor. `onChange` fires with each new reading.
|
|
93
|
+
*/
|
|
94
|
+
export function startDesktopWatch(opts) {
|
|
95
|
+
const g = opts.g ?? defaultGlobals();
|
|
96
|
+
const C = ctorOf(g);
|
|
97
|
+
let latest = null;
|
|
98
|
+
let stopped = false;
|
|
99
|
+
if (!C)
|
|
100
|
+
return { ready: Promise.resolve(false), read: () => null, stop: () => { } };
|
|
101
|
+
const AC = g?.AbortController ?? (typeof AbortController === 'function' ? AbortController : undefined);
|
|
102
|
+
const abort = AC ? new AC() : null;
|
|
103
|
+
let detector = null;
|
|
104
|
+
const onChange = () => {
|
|
105
|
+
if (stopped || !detector)
|
|
106
|
+
return;
|
|
107
|
+
latest = toDesktopSignal(detector.userState, detector.screenState);
|
|
108
|
+
opts.onChange(latest);
|
|
109
|
+
};
|
|
110
|
+
const t = Number(opts.thresholdMs);
|
|
111
|
+
const threshold = Math.max(IDLE_THRESHOLD_MIN_MS, Number.isFinite(t) ? t : IDLE_THRESHOLD_MIN_MS);
|
|
112
|
+
const ready = (async () => {
|
|
113
|
+
try {
|
|
114
|
+
detector = new C();
|
|
115
|
+
detector.addEventListener('change', onChange);
|
|
116
|
+
await detector.start({ threshold, signal: abort?.signal });
|
|
117
|
+
if (stopped)
|
|
118
|
+
return false;
|
|
119
|
+
onChange(); // the state as of start — no change event is owed for it
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
detector?.removeEventListener?.('change', onChange);
|
|
124
|
+
detector = null;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return {
|
|
129
|
+
ready,
|
|
130
|
+
read: () => latest,
|
|
131
|
+
stop() {
|
|
132
|
+
if (stopped)
|
|
133
|
+
return;
|
|
134
|
+
stopped = true;
|
|
135
|
+
latest = null;
|
|
136
|
+
try {
|
|
137
|
+
abort?.abort();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* already aborted */
|
|
141
|
+
}
|
|
142
|
+
detector?.removeEventListener?.('change', onChange);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume at a sentence — the component-side glue (mos-5lzns.34) around
|
|
3
|
+
* `advisors/resume-point` (.27). The snapping itself — CLOSEST wins, the 15s
|
|
4
|
+
* ceiling, ads never a landing, the quoted line — lives in the advisor and
|
|
5
|
+
* is NOT re-derived here. This file only:
|
|
6
|
+
*
|
|
7
|
+
* · defines the host-supplied persistence adapter (`VideoResumeStore`) and
|
|
8
|
+
* its localStorage default — jatui never calls an API; a host that keeps
|
|
9
|
+
* resume points server-side (MOS's `video_user_state`) passes its own;
|
|
10
|
+
* · maps the player's `startSec/endSec` data onto the advisor's extension
|
|
11
|
+
* spellings (`start/end/text/speaker`, `name/ad`, `start/title`);
|
|
12
|
+
* · holds the two library.js integration decisions as pure functions:
|
|
13
|
+
* (1) the snap runs when the boundaries LAND, not at open, and
|
|
14
|
+
* (2) it stands down if the user has taken the wheel (> 2.5s from the
|
|
15
|
+
* stored point by the time the data arrives).
|
|
16
|
+
*/
|
|
17
|
+
import { type ResumeCtx, type ResumePoint } from '../advisors/resume-point';
|
|
18
|
+
import type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment } from '../types';
|
|
19
|
+
/** You moved the playhead yourself — a snap now would yank it out from under you. */
|
|
20
|
+
export declare const RESUME_WHEEL_SEC = 2.5;
|
|
21
|
+
/** The advisor's hard ceiling, passed explicitly so the contract is visible here. */
|
|
22
|
+
export declare const RESUME_MAX_BACK = 15;
|
|
23
|
+
/** How often playback writes the position while it plays. */
|
|
24
|
+
export declare const RESUME_SAVE_MS = 5000;
|
|
25
|
+
/** Below this there is nothing to resume (the advisor's START_FLOOR). */
|
|
26
|
+
export declare const RESUME_FLOOR_SEC = 2;
|
|
27
|
+
/** Within this of the end, the video is finished — clear the point. */
|
|
28
|
+
export declare const RESUME_TAIL_SEC = 10;
|
|
29
|
+
/**
|
|
30
|
+
* Where resume points live. Host-supplied; either method may be async. A
|
|
31
|
+
* `save(key, null)` clears the point (the video was finished, or never
|
|
32
|
+
* really started). Both are called defensively: a throw or a rejection is
|
|
33
|
+
* swallowed and reads as "nothing stored".
|
|
34
|
+
*/
|
|
35
|
+
export interface VideoResumeStore {
|
|
36
|
+
load(key: string): number | null | undefined | Promise<number | null | undefined>;
|
|
37
|
+
save(key: string, sec: number | null): void | Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The default store: this browser's localStorage, one entry per key. Every
|
|
41
|
+
* access is guarded — private mode, a full quota, or SSR degrade to "nothing
|
|
42
|
+
* stored", never to a thrown error in the player.
|
|
43
|
+
*/
|
|
44
|
+
export declare function localResumeStore(storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null): VideoResumeStore;
|
|
45
|
+
/** A stored value → a resumable second, or null. Never throws. */
|
|
46
|
+
export declare function coerceResumeSec(raw: unknown): number | null;
|
|
47
|
+
/**
|
|
48
|
+
* What to persist for a position: the second itself, or null (clear) when
|
|
49
|
+
* the video is effectively unstarted or finished. Junk ⇒ undefined (write
|
|
50
|
+
* nothing — never overwrite a good point with garbage).
|
|
51
|
+
*/
|
|
52
|
+
export declare function resumeToSave(t: unknown, duration: unknown): number | null | undefined;
|
|
53
|
+
/** The player's data → the advisor's input (extension spellings). */
|
|
54
|
+
export declare function toResumeCtx(transcript: readonly VideoTranscriptSegment[] | null | undefined, speakers: readonly VideoSpeakerTurn[] | null | undefined, chapters: readonly VideoChapter[] | null | undefined): ResumeCtx;
|
|
55
|
+
/** Is there anything to snap to yet? The snap waits for this. */
|
|
56
|
+
export declare function hasBoundaries(ctx: ResumeCtx): boolean;
|
|
57
|
+
/** What the player should do with a pending snap. */
|
|
58
|
+
export type ResumeSnapPlan = {
|
|
59
|
+
kind: 'wait';
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'drop';
|
|
62
|
+
why: 'wheel' | 'no-move';
|
|
63
|
+
} | {
|
|
64
|
+
kind: 'seek';
|
|
65
|
+
point: ResumePoint;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The library.js `applyResumeSnap`, pure. `stored` is the second the player
|
|
69
|
+
* resumed at; `current` where the playhead is now.
|
|
70
|
+
* · no boundaries yet ⇒ wait (it runs when they land, not at open);
|
|
71
|
+
* · the user moved > 2.5s ⇒ drop, never yank the playhead;
|
|
72
|
+
* · "exact"/"start", or a landing that isn't meaningfully earlier ⇒ drop
|
|
73
|
+
* (no seek, and so no label — a toast on a no-op trains you to ignore it);
|
|
74
|
+
* · otherwise seek to the advisor's point and say its line.
|
|
75
|
+
*/
|
|
76
|
+
export declare function planResumeSnap(stored: number, current: number, ctx: ResumeCtx): ResumeSnapPlan;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume at a sentence — the component-side glue (mos-5lzns.34) around
|
|
3
|
+
* `advisors/resume-point` (.27). The snapping itself — CLOSEST wins, the 15s
|
|
4
|
+
* ceiling, ads never a landing, the quoted line — lives in the advisor and
|
|
5
|
+
* is NOT re-derived here. This file only:
|
|
6
|
+
*
|
|
7
|
+
* · defines the host-supplied persistence adapter (`VideoResumeStore`) and
|
|
8
|
+
* its localStorage default — jatui never calls an API; a host that keeps
|
|
9
|
+
* resume points server-side (MOS's `video_user_state`) passes its own;
|
|
10
|
+
* · maps the player's `startSec/endSec` data onto the advisor's extension
|
|
11
|
+
* spellings (`start/end/text/speaker`, `name/ad`, `start/title`);
|
|
12
|
+
* · holds the two library.js integration decisions as pure functions:
|
|
13
|
+
* (1) the snap runs when the boundaries LAND, not at open, and
|
|
14
|
+
* (2) it stands down if the user has taken the wheel (> 2.5s from the
|
|
15
|
+
* stored point by the time the data arrives).
|
|
16
|
+
*/
|
|
17
|
+
import { snapResume } from '../advisors/resume-point';
|
|
18
|
+
/** You moved the playhead yourself — a snap now would yank it out from under you. */
|
|
19
|
+
export const RESUME_WHEEL_SEC = 2.5;
|
|
20
|
+
/** The advisor's hard ceiling, passed explicitly so the contract is visible here. */
|
|
21
|
+
export const RESUME_MAX_BACK = 15;
|
|
22
|
+
/** How often playback writes the position while it plays. */
|
|
23
|
+
export const RESUME_SAVE_MS = 5_000;
|
|
24
|
+
/** Below this there is nothing to resume (the advisor's START_FLOOR). */
|
|
25
|
+
export const RESUME_FLOOR_SEC = 2;
|
|
26
|
+
/** Within this of the end, the video is finished — clear the point. */
|
|
27
|
+
export const RESUME_TAIL_SEC = 10;
|
|
28
|
+
const LS_PREFIX = 'jatui.video.resume:';
|
|
29
|
+
/**
|
|
30
|
+
* The default store: this browser's localStorage, one entry per key. Every
|
|
31
|
+
* access is guarded — private mode, a full quota, or SSR degrade to "nothing
|
|
32
|
+
* stored", never to a thrown error in the player.
|
|
33
|
+
*/
|
|
34
|
+
export function localResumeStore(storage) {
|
|
35
|
+
const ls = () => {
|
|
36
|
+
if (storage !== undefined)
|
|
37
|
+
return storage;
|
|
38
|
+
try {
|
|
39
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
load(key) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = ls()?.getItem(LS_PREFIX + key);
|
|
49
|
+
return coerceResumeSec(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
save(key, sec) {
|
|
56
|
+
try {
|
|
57
|
+
const s = ls();
|
|
58
|
+
if (!s)
|
|
59
|
+
return;
|
|
60
|
+
if (sec == null)
|
|
61
|
+
s.removeItem(LS_PREFIX + key);
|
|
62
|
+
else
|
|
63
|
+
s.setItem(LS_PREFIX + key, String(Math.round(sec * 10) / 10));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* quota / private mode — resume is a convenience, never an error */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** A stored value → a resumable second, or null. Never throws. */
|
|
72
|
+
export function coerceResumeSec(raw) {
|
|
73
|
+
if (raw == null || raw === '')
|
|
74
|
+
return null;
|
|
75
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
76
|
+
return Number.isFinite(n) && n >= RESUME_FLOOR_SEC ? n : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* What to persist for a position: the second itself, or null (clear) when
|
|
80
|
+
* the video is effectively unstarted or finished. Junk ⇒ undefined (write
|
|
81
|
+
* nothing — never overwrite a good point with garbage).
|
|
82
|
+
*/
|
|
83
|
+
export function resumeToSave(t, duration) {
|
|
84
|
+
const s = Number(t);
|
|
85
|
+
if (!Number.isFinite(s) || s < 0)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (s < RESUME_FLOOR_SEC)
|
|
88
|
+
return null;
|
|
89
|
+
const d = Number(duration);
|
|
90
|
+
if (Number.isFinite(d) && d > 0 && s >= d - Math.min(RESUME_TAIL_SEC, d * 0.03 + 1))
|
|
91
|
+
return null;
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
/** The player's data → the advisor's input (extension spellings). */
|
|
95
|
+
export function toResumeCtx(transcript, speakers, chapters) {
|
|
96
|
+
return {
|
|
97
|
+
transcript: (transcript ?? []).map((s) => ({
|
|
98
|
+
start: s.startSec,
|
|
99
|
+
end: s.endSec,
|
|
100
|
+
text: s.text,
|
|
101
|
+
speaker: s.speakerName ?? undefined
|
|
102
|
+
})),
|
|
103
|
+
speakers: (speakers ?? []).map((t) => ({
|
|
104
|
+
start: t.startSec,
|
|
105
|
+
end: t.endSec,
|
|
106
|
+
name: t.speakerName ?? undefined,
|
|
107
|
+
ad: t.ad === true
|
|
108
|
+
})),
|
|
109
|
+
chapters: (chapters ?? []).map((c) => ({ start: c.startSec, title: c.title }))
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Is there anything to snap to yet? The snap waits for this. */
|
|
113
|
+
export function hasBoundaries(ctx) {
|
|
114
|
+
const n = (x) => (Array.isArray(x) ? x.length : 0);
|
|
115
|
+
return n(ctx.transcript) + n(ctx.speakers) + n(ctx.chapters) > 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The library.js `applyResumeSnap`, pure. `stored` is the second the player
|
|
119
|
+
* resumed at; `current` where the playhead is now.
|
|
120
|
+
* · no boundaries yet ⇒ wait (it runs when they land, not at open);
|
|
121
|
+
* · the user moved > 2.5s ⇒ drop, never yank the playhead;
|
|
122
|
+
* · "exact"/"start", or a landing that isn't meaningfully earlier ⇒ drop
|
|
123
|
+
* (no seek, and so no label — a toast on a no-op trains you to ignore it);
|
|
124
|
+
* · otherwise seek to the advisor's point and say its line.
|
|
125
|
+
*/
|
|
126
|
+
export function planResumeSnap(stored, current, ctx) {
|
|
127
|
+
if (!Number.isFinite(stored) || !Number.isFinite(current))
|
|
128
|
+
return { kind: 'drop', why: 'no-move' };
|
|
129
|
+
if (Math.abs(current - stored) > RESUME_WHEEL_SEC)
|
|
130
|
+
return { kind: 'drop', why: 'wheel' };
|
|
131
|
+
if (!hasBoundaries(ctx))
|
|
132
|
+
return { kind: 'wait' };
|
|
133
|
+
const point = snapResume(stored, ctx, { maxBack: RESUME_MAX_BACK });
|
|
134
|
+
if (point.reason === 'exact' || point.reason === 'start')
|
|
135
|
+
return { kind: 'drop', why: 'no-move' };
|
|
136
|
+
if (point.at >= stored - 0.05)
|
|
137
|
+
return { kind: 'drop', why: 'no-move' };
|
|
138
|
+
return { kind: 'seek', point };
|
|
139
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wind-down — the pure half (mos-5lzns.34). extension-library FEATURES.md
|
|
3
|
+
* §Wind-down, re-based from `hyprctl cursorpos` + `hyprlock` onto the
|
|
4
|
+
* browser's two signals (see ./idle.ts for how they are gathered).
|
|
5
|
+
*
|
|
6
|
+
* The signal question is the whole design. Page input cannot answer it on
|
|
7
|
+
* its own: a video legitimately plays for two hours with nobody touching a
|
|
8
|
+
* key, so "no input here" describes watching exactly as well as it
|
|
9
|
+
* describes sleeping. The DESKTOP can answer it — Chromium's Idle Detection
|
|
10
|
+
* API reports system-wide user idle and a locked screen. Everywhere else the
|
|
11
|
+
* player falls back to page presence, and says so (`source: 'page'`): the
|
|
12
|
+
* behaviour is the same, the evidence is weaker, and the host must be able
|
|
13
|
+
* to label it as such.
|
|
14
|
+
*
|
|
15
|
+
* It never just stops. `WIND_DOWN_FADE_MS` of frames: volume falls linearly
|
|
16
|
+
* to zero while a neutral veil over the picture rises to `0.92·p` — a veil,
|
|
17
|
+
* never the video's own opacity, which would let whatever is behind the
|
|
18
|
+
* player (the page, the bias light) show through and BRIGHTEN it. Any input
|
|
19
|
+
* cancels, restores the saved volume, and playback carries on. The fade IS
|
|
20
|
+
* the prompt — a gentler question than a modal asking whether you are alive.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here touches the element or reads a setting; the component passes
|
|
23
|
+
* the knobs in and applies what comes back.
|
|
24
|
+
*/
|
|
25
|
+
/** The fade itself — twenty seconds, and the fade is the prompt. */
|
|
26
|
+
export declare const WIND_DOWN_FADE_MS = 20000;
|
|
27
|
+
/** The veil's opacity at the end of the fade (the picture keeps 1 − 0.92). */
|
|
28
|
+
export declare const WIND_DOWN_DEPTH = 0.92;
|
|
29
|
+
/** Minutes of absence before the fade begins — the extension's `sleep.after`. */
|
|
30
|
+
export declare const WIND_DOWN_AFTER_DEFAULT = 30;
|
|
31
|
+
export declare const WIND_DOWN_AFTER_MIN = 10;
|
|
32
|
+
export declare const WIND_DOWN_AFTER_MAX = 90;
|
|
33
|
+
/**
|
|
34
|
+
* How often the component re-asks the question while armed and playing.
|
|
35
|
+
* Shorter than the extension's 60s because the answer is now local and
|
|
36
|
+
* free; the decision still only ever acts on a threshold of minutes.
|
|
37
|
+
*/
|
|
38
|
+
export declare const WIND_DOWN_PROBE_MS = 15000;
|
|
39
|
+
/**
|
|
40
|
+
* Where the evidence of absence came from.
|
|
41
|
+
* · `desktop` — Idle Detection API: system-wide idle + screen lock. Full fidelity.
|
|
42
|
+
* · `page` — no input on this page. Lower fidelity, labelled as such.
|
|
43
|
+
*/
|
|
44
|
+
export type PresenceSource = 'desktop' | 'page';
|
|
45
|
+
/** The desktop's two raw facts (IdleDetector's userState/screenState). */
|
|
46
|
+
export interface DesktopSignal {
|
|
47
|
+
idle: boolean;
|
|
48
|
+
locked: boolean;
|
|
49
|
+
}
|
|
50
|
+
export type WindDownPhase = 'off' | 'watching' | 'fading' | 'paused';
|
|
51
|
+
/** Reported to the host on real change only. */
|
|
52
|
+
export interface WindDownState {
|
|
53
|
+
phase: WindDownPhase;
|
|
54
|
+
/** Which evidence the player is using right now; null when the feature is off. */
|
|
55
|
+
source: PresenceSource | null;
|
|
56
|
+
}
|
|
57
|
+
/** Clamp a minutes knob to the declared range; junk falls back to the default. */
|
|
58
|
+
export declare function windDownAfterMs(minutes: unknown): number;
|
|
59
|
+
export interface WindDownInput {
|
|
60
|
+
/** Feature on, and (if night-only) night armed. */
|
|
61
|
+
armed: boolean;
|
|
62
|
+
playing: boolean;
|
|
63
|
+
/** Already fading or already paused by us — nothing more to begin. */
|
|
64
|
+
active: boolean;
|
|
65
|
+
source: PresenceSource;
|
|
66
|
+
/** Latest desktop facts; null when the desktop source has no reading yet. */
|
|
67
|
+
desktop: DesktopSignal | null;
|
|
68
|
+
/** ms since the last input on the page. */
|
|
69
|
+
quietMs: number;
|
|
70
|
+
/** The threshold, ms (windDownAfterMs). */
|
|
71
|
+
afterMs: number;
|
|
72
|
+
/**
|
|
73
|
+
* The document is hidden. Page presence holds while it is: silence on a
|
|
74
|
+
* background tab says nothing about whether you are at the machine —
|
|
75
|
+
* you may be busy in another one. The desktop source ignores it (a
|
|
76
|
+
* locked screen hides the document, and that is exactly its case).
|
|
77
|
+
*/
|
|
78
|
+
hidden?: boolean;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Should the fade begin now? Never guesses: a desktop source with no reading
|
|
82
|
+
* yet holds (the extension's "no desktop signal → never wind down"), and
|
|
83
|
+
* junk numbers hold.
|
|
84
|
+
*
|
|
85
|
+
* A locked screen skips the wait entirely — that is not an inference, it is
|
|
86
|
+
* a statement. Desktop idle already carries the threshold (the detector is
|
|
87
|
+
* started with it), so `idle` alone is sufficient there.
|
|
88
|
+
*/
|
|
89
|
+
export declare function shouldBeginWindDown(i: WindDownInput): boolean;
|
|
90
|
+
/** A desktop reading that says someone is there — cancels a fade in progress. */
|
|
91
|
+
export declare function desktopPresent(d: DesktopSignal | null | undefined): boolean;
|
|
92
|
+
/** Fade progress 0–1 from elapsed ms. Junk ⇒ 0 (nothing faded). */
|
|
93
|
+
export declare function windDownProgress(elapsedMs: unknown, fadeMs?: number): number;
|
|
94
|
+
/**
|
|
95
|
+
* The levels at progress p: the volume factor (× the volume saved at the
|
|
96
|
+
* start of the fade), the veil's opacity, and what that leaves of the
|
|
97
|
+
* picture. Linear on purpose — the extension's, and a sleeping viewer is not
|
|
98
|
+
* tracking a curve.
|
|
99
|
+
*/
|
|
100
|
+
export declare function windDownLevels(p: unknown): {
|
|
101
|
+
volume: number;
|
|
102
|
+
veil: number;
|
|
103
|
+
picture: number;
|
|
104
|
+
};
|
|
105
|
+
/** The visible surface. The page source says it is guessing from less. */
|
|
106
|
+
export declare function windDownHint(phase: 'fading' | 'paused', source: PresenceSource): string;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wind-down — the pure half (mos-5lzns.34). extension-library FEATURES.md
|
|
3
|
+
* §Wind-down, re-based from `hyprctl cursorpos` + `hyprlock` onto the
|
|
4
|
+
* browser's two signals (see ./idle.ts for how they are gathered).
|
|
5
|
+
*
|
|
6
|
+
* The signal question is the whole design. Page input cannot answer it on
|
|
7
|
+
* its own: a video legitimately plays for two hours with nobody touching a
|
|
8
|
+
* key, so "no input here" describes watching exactly as well as it
|
|
9
|
+
* describes sleeping. The DESKTOP can answer it — Chromium's Idle Detection
|
|
10
|
+
* API reports system-wide user idle and a locked screen. Everywhere else the
|
|
11
|
+
* player falls back to page presence, and says so (`source: 'page'`): the
|
|
12
|
+
* behaviour is the same, the evidence is weaker, and the host must be able
|
|
13
|
+
* to label it as such.
|
|
14
|
+
*
|
|
15
|
+
* It never just stops. `WIND_DOWN_FADE_MS` of frames: volume falls linearly
|
|
16
|
+
* to zero while a neutral veil over the picture rises to `0.92·p` — a veil,
|
|
17
|
+
* never the video's own opacity, which would let whatever is behind the
|
|
18
|
+
* player (the page, the bias light) show through and BRIGHTEN it. Any input
|
|
19
|
+
* cancels, restores the saved volume, and playback carries on. The fade IS
|
|
20
|
+
* the prompt — a gentler question than a modal asking whether you are alive.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here touches the element or reads a setting; the component passes
|
|
23
|
+
* the knobs in and applies what comes back.
|
|
24
|
+
*/
|
|
25
|
+
/** The fade itself — twenty seconds, and the fade is the prompt. */
|
|
26
|
+
export const WIND_DOWN_FADE_MS = 20_000;
|
|
27
|
+
/** The veil's opacity at the end of the fade (the picture keeps 1 − 0.92). */
|
|
28
|
+
export const WIND_DOWN_DEPTH = 0.92;
|
|
29
|
+
/** Minutes of absence before the fade begins — the extension's `sleep.after`. */
|
|
30
|
+
export const WIND_DOWN_AFTER_DEFAULT = 30;
|
|
31
|
+
export const WIND_DOWN_AFTER_MIN = 10;
|
|
32
|
+
export const WIND_DOWN_AFTER_MAX = 90;
|
|
33
|
+
/**
|
|
34
|
+
* How often the component re-asks the question while armed and playing.
|
|
35
|
+
* Shorter than the extension's 60s because the answer is now local and
|
|
36
|
+
* free; the decision still only ever acts on a threshold of minutes.
|
|
37
|
+
*/
|
|
38
|
+
export const WIND_DOWN_PROBE_MS = 15_000;
|
|
39
|
+
/** Clamp a minutes knob to the declared range; junk falls back to the default. */
|
|
40
|
+
export function windDownAfterMs(minutes) {
|
|
41
|
+
const n = typeof minutes === 'number' ? minutes : Number(minutes);
|
|
42
|
+
const m = Number.isFinite(n)
|
|
43
|
+
? Math.min(WIND_DOWN_AFTER_MAX, Math.max(WIND_DOWN_AFTER_MIN, n))
|
|
44
|
+
: WIND_DOWN_AFTER_DEFAULT;
|
|
45
|
+
return m * 60_000;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Should the fade begin now? Never guesses: a desktop source with no reading
|
|
49
|
+
* yet holds (the extension's "no desktop signal → never wind down"), and
|
|
50
|
+
* junk numbers hold.
|
|
51
|
+
*
|
|
52
|
+
* A locked screen skips the wait entirely — that is not an inference, it is
|
|
53
|
+
* a statement. Desktop idle already carries the threshold (the detector is
|
|
54
|
+
* started with it), so `idle` alone is sufficient there.
|
|
55
|
+
*/
|
|
56
|
+
export function shouldBeginWindDown(i) {
|
|
57
|
+
if (!i || !i.armed || !i.playing || i.active)
|
|
58
|
+
return false;
|
|
59
|
+
if (i.source === 'desktop') {
|
|
60
|
+
const d = i.desktop;
|
|
61
|
+
if (!d)
|
|
62
|
+
return false;
|
|
63
|
+
return d.locked === true || d.idle === true;
|
|
64
|
+
}
|
|
65
|
+
if (i.hidden === true)
|
|
66
|
+
return false;
|
|
67
|
+
const quiet = Number(i.quietMs);
|
|
68
|
+
const after = Number(i.afterMs);
|
|
69
|
+
if (!Number.isFinite(quiet) || !Number.isFinite(after) || after <= 0)
|
|
70
|
+
return false;
|
|
71
|
+
return quiet >= after;
|
|
72
|
+
}
|
|
73
|
+
/** A desktop reading that says someone is there — cancels a fade in progress. */
|
|
74
|
+
export function desktopPresent(d) {
|
|
75
|
+
return !!d && d.idle === false && d.locked === false;
|
|
76
|
+
}
|
|
77
|
+
/** Fade progress 0–1 from elapsed ms. Junk ⇒ 0 (nothing faded). */
|
|
78
|
+
export function windDownProgress(elapsedMs, fadeMs = WIND_DOWN_FADE_MS) {
|
|
79
|
+
const e = Number(elapsedMs);
|
|
80
|
+
const f = Number(fadeMs);
|
|
81
|
+
if (!Number.isFinite(e) || e <= 0 || !Number.isFinite(f) || f <= 0)
|
|
82
|
+
return 0;
|
|
83
|
+
return Math.min(1, e / f);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The levels at progress p: the volume factor (× the volume saved at the
|
|
87
|
+
* start of the fade), the veil's opacity, and what that leaves of the
|
|
88
|
+
* picture. Linear on purpose — the extension's, and a sleeping viewer is not
|
|
89
|
+
* tracking a curve.
|
|
90
|
+
*/
|
|
91
|
+
export function windDownLevels(p) {
|
|
92
|
+
const n = Number(p);
|
|
93
|
+
const q = Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
|
94
|
+
const veil = WIND_DOWN_DEPTH * q;
|
|
95
|
+
return { volume: 1 - q, veil, picture: 1 - veil };
|
|
96
|
+
}
|
|
97
|
+
/** The visible surface. The page source says it is guessing from less. */
|
|
98
|
+
export function windDownHint(phase, source) {
|
|
99
|
+
if (phase === 'paused')
|
|
100
|
+
return 'Paused — you were away';
|
|
101
|
+
return source === 'desktop'
|
|
102
|
+
? 'Winding down — move anything to stay'
|
|
103
|
+
: 'Winding down (no activity on this page) — move anything to stay';
|
|
104
|
+
}
|
package/dist/video/presence.d.ts
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Presence
|
|
3
|
-
*
|
|
2
|
+
* Presence (mos-5lzns.34) — the barrel. Three behaviours about whether you
|
|
3
|
+
* are there and where you were, each an OPTIONAL VideoPlayer prop that is
|
|
4
|
+
* inert when absent (extension-library FEATURES.md §Wind-down, §Resume at a
|
|
5
|
+
* sentence, §Open like an object):
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
+
* · `presence/winddown.ts` — pure: the begin decision, fade levels, hint.
|
|
8
|
+
* The desktop says nobody is there → picture and sound fade over 20s and
|
|
9
|
+
* the video pauses. The fade IS the prompt; any input cancels it.
|
|
10
|
+
* · `presence/idle.ts` — the desktop signal: Chromium's Idle Detection API,
|
|
11
|
+
* feature-detected, never prompting unless the host opted in (and then
|
|
12
|
+
* only from a gesture). Elsewhere the player falls back to page presence,
|
|
13
|
+
* reported as `source: 'page'` so the host can label it.
|
|
14
|
+
* · `presence/resume.ts` — resume-at-sentence glue around .27's
|
|
15
|
+
* `advisors/resume-point` (the snapping is NOT duplicated here): the
|
|
16
|
+
* host-supplied store (localStorage default), the data mapping, and the
|
|
17
|
+
* two integration rules — snap when the boundaries land, stand down once
|
|
18
|
+
* the user has taken the wheel.
|
|
19
|
+
* · `presence/flip.ts` — FLIP open/close over WAAPI with the poster painted
|
|
20
|
+
* on every path; a no-op under prefers-reduced-motion except the poster.
|
|
7
21
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* · resume-at-sentence — snap a resume point back to the nearest sentence /
|
|
12
|
-
* speaker turn / chapter within 15s (CLOSEST wins, not most-specific);
|
|
13
|
-
* quote the line, never paraphrase; stand down if the user has taken the
|
|
14
|
-
* wheel (>2.5s from the stored point). Runs when the transcript lands, not
|
|
15
|
-
* at open.
|
|
16
|
-
* · FLIP open — the clicked card's rectangle → the player shell, WAAPI, two
|
|
17
|
-
* keyframes, transform-origin top-left, poster painted under the video on
|
|
18
|
-
* EVERY path (a black rectangle is a bug at any motion setting); no-op
|
|
19
|
-
* under prefers-reduced-motion except the poster.
|
|
20
|
-
*
|
|
21
|
-
* All three are advisors/mechanics: they read no settings, touch no
|
|
22
|
-
* <video> outside the callbacks the component passes, and fail toward doing
|
|
23
|
-
* nothing.
|
|
22
|
+
* Advisors and mechanics: they read no settings and touch no <video>; the
|
|
23
|
+
* component passes knobs in and applies what comes back, and every one of
|
|
24
|
+
* them fails toward doing nothing.
|
|
24
25
|
*/
|
|
25
|
-
export
|
|
26
|
+
export { WIND_DOWN_FADE_MS, WIND_DOWN_DEPTH, WIND_DOWN_AFTER_DEFAULT, WIND_DOWN_AFTER_MIN, WIND_DOWN_AFTER_MAX, WIND_DOWN_PROBE_MS, windDownAfterMs, shouldBeginWindDown, desktopPresent, windDownProgress, windDownLevels, windDownHint, type PresenceSource, type DesktopSignal, type WindDownPhase, type WindDownState, type WindDownInput } from './presence/winddown';
|
|
27
|
+
export { IDLE_THRESHOLD_MIN_MS, idleDetectionSupported, idleDetectionPermission, requestIdleDetection, startDesktopWatch, toDesktopSignal, type IdlePermission, type DesktopWatch } from './presence/idle';
|
|
28
|
+
export { RESUME_WHEEL_SEC, RESUME_MAX_BACK, RESUME_SAVE_MS, RESUME_FLOOR_SEC, RESUME_TAIL_SEC, localResumeStore, coerceResumeSec, resumeToSave, toResumeCtx, hasBoundaries, planResumeSnap, type VideoResumeStore, type ResumeSnapPlan } from './presence/resume';
|
|
29
|
+
export { FLIP_EASE, FLIP_OPEN_MS, FLIP_CLOSE_MS, FLIP_FADE_WITH_POSTER, FLIP_FADE_BARE, FLIP_POSTER_CLASS, flipOpen, flipClose, clearPoster, posterBackground, rectOf, onScreen, flipGeometry, awayFrame, homeFrame, type FlipSource, type FlipRect } from './presence/flip';
|