@swiftbrowser/web 0.1.0 → 0.2.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/README.md +43 -6
- package/app/index.html +11 -0
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +11 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +18 -1
- package/src/landing.ts +2 -2
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +279 -25
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +53 -3
- package/src/main.ts +124 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +2 -1
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +761 -12
- package/src/renderer.ts +584 -36
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +212 -7
- package/src/styles.ts +31 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +564 -21
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Translation framework's page side (docs/ops-protocol.md, "Translation
|
|
3
|
+
* and the on-device language model"): `translate` host requests, answered
|
|
4
|
+
* with the browser's built-in `Translator` (and `LanguageDetector` for an
|
|
5
|
+
* unnamed source language) where the browser has them — Chrome 138 and later
|
|
6
|
+
* on desktop. Without them every pair is unsupported.
|
|
7
|
+
*
|
|
8
|
+
* - `availability` → `{ status: "installed" | "supported" | "unsupported" }`
|
|
9
|
+
* (`available` / `downloadable` or `downloading` / `unavailable`).
|
|
10
|
+
* - `translate` → `{ source, texts }`, or rejects with `unsupported: …`,
|
|
11
|
+
* `unidentified: …` (no source language could be detected) or
|
|
12
|
+
* `download: …` (the pair's model needs a download the session may not ask
|
|
13
|
+
* for). A translator is made once per pair and kept.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { TranslateRequestParams } from './protocol';
|
|
17
|
+
|
|
18
|
+
export type TranslationAvailability = 'available' | 'downloadable' | 'downloading' | 'unavailable';
|
|
19
|
+
|
|
20
|
+
/** A translator for one pair (Chrome's `Translator` instance). */
|
|
21
|
+
export interface TranslatorLike {
|
|
22
|
+
translate(text: string): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The browser's translation facilities; tests substitute them. */
|
|
26
|
+
export interface TranslationHost {
|
|
27
|
+
availability(source: string, target: string): Promise<TranslationAvailability>;
|
|
28
|
+
create(source: string, target: string): Promise<TranslatorLike>;
|
|
29
|
+
/** The most likely language of the text (BCP 47), or null. Absent without a detector. */
|
|
30
|
+
detect?(text: string): Promise<string | null>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TranslateResult = {
|
|
34
|
+
status?: 'installed' | 'supported' | 'unsupported';
|
|
35
|
+
source?: string;
|
|
36
|
+
texts?: string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const STATUS: Record<TranslationAvailability, 'installed' | 'supported' | 'unsupported'> = {
|
|
40
|
+
available: 'installed',
|
|
41
|
+
downloadable: 'supported',
|
|
42
|
+
downloading: 'supported',
|
|
43
|
+
unavailable: 'unsupported',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Translators made so far, per `source>target`. */
|
|
47
|
+
const translators = new Map<string, Promise<TranslatorLike>>();
|
|
48
|
+
|
|
49
|
+
async function sourceLanguage(params: TranslateRequestParams, host: TranslationHost, text: string): Promise<string> {
|
|
50
|
+
if (params.source) return params.source;
|
|
51
|
+
const detected = host.detect && text.trim() ? await host.detect(text) : null;
|
|
52
|
+
if (!detected || detected === 'und') throw new Error('unidentified: the language of the text could not be detected');
|
|
53
|
+
return detected;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function performTranslate(
|
|
57
|
+
params: TranslateRequestParams,
|
|
58
|
+
host: TranslationHost | undefined,
|
|
59
|
+
): Promise<TranslateResult> {
|
|
60
|
+
const target = params.target;
|
|
61
|
+
if (params.action === 'availability') {
|
|
62
|
+
if (!host) return { status: 'unsupported' };
|
|
63
|
+
const source = await sourceLanguage(params, host, params.text ?? '');
|
|
64
|
+
if (source === target) return { status: 'installed' };
|
|
65
|
+
return { status: STATUS[await host.availability(source, target)] ?? 'unsupported' };
|
|
66
|
+
}
|
|
67
|
+
if (params.action !== 'translate') {
|
|
68
|
+
throw new Error(`unsupported: translate action ${String((params as { action: unknown }).action)}`);
|
|
69
|
+
}
|
|
70
|
+
if (!host) throw new Error('unsupported: this browser has no built-in translator');
|
|
71
|
+
const texts = params.texts ?? [];
|
|
72
|
+
const source = await sourceLanguage(params, host, texts.join('\n'));
|
|
73
|
+
if (source === target) return { source, texts };
|
|
74
|
+
const key = `${source}>${target}`;
|
|
75
|
+
let translator = translators.get(key);
|
|
76
|
+
if (!translator) {
|
|
77
|
+
const availability = await host.availability(source, target);
|
|
78
|
+
if (availability === 'unavailable') throw new Error(`unsupported: no translator from ${source} to ${target}`);
|
|
79
|
+
if (availability !== 'available' && params.allowDownload !== true) {
|
|
80
|
+
throw new Error(`download: the ${source} to ${target} model is not installed`);
|
|
81
|
+
}
|
|
82
|
+
translator = host.create(source, target);
|
|
83
|
+
translators.set(key, translator);
|
|
84
|
+
translator.catch(() => translators.delete(key));
|
|
85
|
+
}
|
|
86
|
+
const ready = await translator;
|
|
87
|
+
const translated = await Promise.all(texts.map((text) => (text ? ready.translate(text) : Promise.resolve(''))));
|
|
88
|
+
return { source, texts: translated };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Forgets the translators made so far (tests). */
|
|
92
|
+
export function resetTranslators(): void {
|
|
93
|
+
translators.clear();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface TranslatorGlobal {
|
|
97
|
+
availability(options: { sourceLanguage: string; targetLanguage: string }): Promise<TranslationAvailability>;
|
|
98
|
+
create(options: { sourceLanguage: string; targetLanguage: string }): Promise<TranslatorLike>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface LanguageDetectorGlobal {
|
|
102
|
+
create(): Promise<{ detect(text: string): Promise<{ detectedLanguage: string; confidence: number }[]> }>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Chrome's `Translator` and `LanguageDetector`, where the page has them. */
|
|
106
|
+
export function browserTranslationHost(): TranslationHost | undefined {
|
|
107
|
+
const scope = globalThis as unknown as { Translator?: TranslatorGlobal; LanguageDetector?: LanguageDetectorGlobal };
|
|
108
|
+
const translator = scope.Translator;
|
|
109
|
+
if (!translator || typeof translator.create !== 'function') return undefined;
|
|
110
|
+
const detectorGlobal = scope.LanguageDetector;
|
|
111
|
+
let detector: ReturnType<LanguageDetectorGlobal['create']> | undefined;
|
|
112
|
+
return {
|
|
113
|
+
availability: (sourceLanguage, targetLanguage) => translator.availability({ sourceLanguage, targetLanguage }),
|
|
114
|
+
create: (sourceLanguage, targetLanguage) => translator.create({ sourceLanguage, targetLanguage }),
|
|
115
|
+
...(detectorGlobal && typeof detectorGlobal.create === 'function'
|
|
116
|
+
? {
|
|
117
|
+
detect: async (text: string) => {
|
|
118
|
+
detector ??= detectorGlobal.create();
|
|
119
|
+
const results = await (await detector).detect(text);
|
|
120
|
+
const best = results[0];
|
|
121
|
+
return best && best.confidence >= 0.5 ? best.detectedLanguage : null;
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
: {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/video.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `video` element (docs/ops-protocol.md, "Video and audio"): an HTML
|
|
3
|
+
* `<video>` inside the element's box, driven by the props AVFoundation works
|
|
4
|
+
* out from an `AVPlayer`, reporting back with `video` events.
|
|
5
|
+
*
|
|
6
|
+
* `applyVideo` carries out props: source, mute and volume (before any
|
|
7
|
+
* `play()`, so muted autoplay is allowed), rate, loop, native controls,
|
|
8
|
+
* gravity (CSS `object-fit`), each new `seek` serial once, then plays or
|
|
9
|
+
* pauses. It never talks to Swift itself: what needs reporting is a DOM event
|
|
10
|
+
* on the `<video>` (media events, or this module's `sb:video-*` events),
|
|
11
|
+
* which `VideoEvents`, listening on the renderer root in the capture phase
|
|
12
|
+
* (media events do not bubble), turns into `video` events.
|
|
13
|
+
*
|
|
14
|
+
* Browsers start unmuted playback only after the viewer has interacted with
|
|
15
|
+
* the page; a refused `play()` is reported as `pause` with `reason:
|
|
16
|
+
* "blocked"`, so the player's rate goes back to 0 and the native controls
|
|
17
|
+
* (or the app's own button, a tap) can start it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ElementId, RendererEvent, VideoEvent, VideoProps } from './protocol';
|
|
21
|
+
|
|
22
|
+
interface VideoState {
|
|
23
|
+
/** The props last applied. */
|
|
24
|
+
props: VideoProps | null;
|
|
25
|
+
/** The highest `seek` serial carried out (-1: none yet). */
|
|
26
|
+
seekSerial: number;
|
|
27
|
+
/** The serial of the seek in flight, reported with `seeked`. */
|
|
28
|
+
pendingSeek: number | null;
|
|
29
|
+
/** `performance.now()` of the last `time` event. */
|
|
30
|
+
lastTime: number;
|
|
31
|
+
/** The `requestAnimationFrame` loop reporting at `report`, if running. */
|
|
32
|
+
ticker: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const STATES = new WeakMap<HTMLVideoElement, VideoState>();
|
|
36
|
+
/** `video` elements made so far; `stopVideos` has nothing to do while there are none. */
|
|
37
|
+
let made = 0;
|
|
38
|
+
|
|
39
|
+
/** At most this often (ms) a `timeupdate` becomes a `time` event when no `report` asks for more. */
|
|
40
|
+
const TIME_EVENT_MIN_MS = 100;
|
|
41
|
+
|
|
42
|
+
const SEEKED = 'sb:video-seeked';
|
|
43
|
+
const BLOCKED = 'sb:video-blocked';
|
|
44
|
+
const TICK = 'sb:video-tick';
|
|
45
|
+
|
|
46
|
+
function objectFit(gravity: VideoProps['gravity']): string {
|
|
47
|
+
if (gravity === 'resizeAspectFill') return 'cover';
|
|
48
|
+
if (gravity === 'resize') return 'fill';
|
|
49
|
+
return 'contain';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `<video>` of a `video` element, made on first use. */
|
|
53
|
+
function mediaOf(el: HTMLElement): HTMLVideoElement {
|
|
54
|
+
const existing = el.firstElementChild;
|
|
55
|
+
if (existing instanceof HTMLVideoElement) return existing;
|
|
56
|
+
const video = document.createElement('video');
|
|
57
|
+
video.className = 'sb-video-media';
|
|
58
|
+
video.playsInline = true;
|
|
59
|
+
video.setAttribute('playsinline', '');
|
|
60
|
+
video.setAttribute('webkit-playsinline', '');
|
|
61
|
+
video.preload = 'metadata';
|
|
62
|
+
video.style.cssText = 'display:block;width:100%;height:100%;background:#000;object-fit:contain';
|
|
63
|
+
el.replaceChildren(video);
|
|
64
|
+
STATES.set(video, { props: null, seekSerial: -1, pendingSeek: null, lastTime: 0, ticker: null });
|
|
65
|
+
made += 1;
|
|
66
|
+
return video;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function applyVideo(el: HTMLElement, props: VideoProps): void {
|
|
70
|
+
const video = mediaOf(el);
|
|
71
|
+
const state = STATES.get(video)!;
|
|
72
|
+
state.props = props;
|
|
73
|
+
// Mute first: a muted video may start without a tap.
|
|
74
|
+
const muted = props.muted === true;
|
|
75
|
+
if (video.muted !== muted) video.muted = muted;
|
|
76
|
+
video.defaultMuted = muted;
|
|
77
|
+
const volume = Math.min(1, Math.max(0, typeof props.volume === 'number' ? props.volume : 1));
|
|
78
|
+
if (video.volume !== volume) video.volume = volume;
|
|
79
|
+
const loop = props.loop === true;
|
|
80
|
+
if (video.loop !== loop) video.loop = loop;
|
|
81
|
+
const controls = props.controls === true;
|
|
82
|
+
if (video.controls !== controls) video.controls = controls;
|
|
83
|
+
const fit = objectFit(props.gravity);
|
|
84
|
+
if (video.style.objectFit !== fit) video.style.objectFit = fit;
|
|
85
|
+
el.dataset['sbGravity'] = props.gravity ?? 'resizeAspect';
|
|
86
|
+
|
|
87
|
+
const url = typeof props.url === 'string' ? props.url : '';
|
|
88
|
+
if ((video.getAttribute('src') ?? '') !== url) {
|
|
89
|
+
if (url) {
|
|
90
|
+
video.src = url;
|
|
91
|
+
} else {
|
|
92
|
+
video.removeAttribute('src');
|
|
93
|
+
video.load();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const rate = typeof props.rate === 'number' && props.rate > 0 ? props.rate : 1;
|
|
97
|
+
if (video.playbackRate !== rate) video.playbackRate = rate;
|
|
98
|
+
if (video.defaultPlaybackRate !== rate) video.defaultPlaybackRate = rate;
|
|
99
|
+
|
|
100
|
+
// Each seek serial once (the first props carry the player's position).
|
|
101
|
+
let seeked = false;
|
|
102
|
+
const seek = props.seek;
|
|
103
|
+
if (seek && typeof seek.serial === 'number' && seek.serial !== state.seekSerial) {
|
|
104
|
+
state.seekSerial = seek.serial;
|
|
105
|
+
const time = Math.max(0, typeof seek.time === 'number' && Number.isFinite(seek.time) ? seek.time : 0);
|
|
106
|
+
seeked = true;
|
|
107
|
+
if (video.readyState === 0 || (Math.abs(video.currentTime - time) < 0.001 && !video.ended)) {
|
|
108
|
+
// Nothing to wait for: before the metadata the time is where playback
|
|
109
|
+
// will start; at the time already, the browser fires no `seeked`.
|
|
110
|
+
if (video.currentTime !== time) video.currentTime = time;
|
|
111
|
+
state.pendingSeek = null;
|
|
112
|
+
// After the ops are applied: an event must not reach Swift mid-batch.
|
|
113
|
+
const serial = seek.serial;
|
|
114
|
+
queueMicrotask(() => video.dispatchEvent(new CustomEvent(SEEKED, { detail: { serial } })));
|
|
115
|
+
} else {
|
|
116
|
+
state.pendingSeek = seek.serial;
|
|
117
|
+
video.currentTime = time;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const playing = props.playing === true && url !== '';
|
|
122
|
+
video.preload = playing ? 'auto' : 'metadata';
|
|
123
|
+
if (playing) {
|
|
124
|
+
// An ended video plays again only after a seek (play() would restart it).
|
|
125
|
+
if (video.paused && (seeked || !video.ended)) startPlaying(video);
|
|
126
|
+
} else if (!video.paused) {
|
|
127
|
+
video.pause();
|
|
128
|
+
}
|
|
129
|
+
updateTicker(video, state);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function startPlaying(video: HTMLVideoElement): void {
|
|
133
|
+
let result: Promise<void> | undefined;
|
|
134
|
+
try {
|
|
135
|
+
result = video.play();
|
|
136
|
+
} catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
result?.catch((error: unknown) => {
|
|
140
|
+
// `AbortError`: a pause or a new source interrupted it, which is fine.
|
|
141
|
+
if (error instanceof Error && error.name === 'NotAllowedError') {
|
|
142
|
+
video.dispatchEvent(new CustomEvent(BLOCKED));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** While playing with a `report` interval, `time` events at that pace (else at `timeupdate`'s). */
|
|
148
|
+
function updateTicker(video: HTMLVideoElement, state: VideoState): void {
|
|
149
|
+
const interval = state.props?.report;
|
|
150
|
+
const wanted = typeof interval === 'number' && interval > 0 && state.props?.playing === true;
|
|
151
|
+
if (!wanted) {
|
|
152
|
+
if (state.ticker !== null) cancelAnimationFrame(state.ticker);
|
|
153
|
+
state.ticker = null;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (state.ticker !== null || typeof requestAnimationFrame !== 'function') return;
|
|
157
|
+
const frame = (): void => {
|
|
158
|
+
state.ticker = null;
|
|
159
|
+
const report = state.props?.report;
|
|
160
|
+
if (!video.isConnected || state.props?.playing !== true || typeof report !== 'number' || report <= 0) return;
|
|
161
|
+
if (!video.paused && performance.now() - state.lastTime >= report * 1000) video.dispatchEvent(new CustomEvent(TICK));
|
|
162
|
+
state.ticker = requestAnimationFrame(frame);
|
|
163
|
+
};
|
|
164
|
+
state.ticker = requestAnimationFrame(frame);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Stops the videos in a removed subtree (`el` included): a detached
|
|
169
|
+
* `<video>` would go on playing its sound. Their sources are dropped, which
|
|
170
|
+
* also ends their downloads.
|
|
171
|
+
*/
|
|
172
|
+
export function stopVideos(el: HTMLElement): void {
|
|
173
|
+
if (made === 0) return;
|
|
174
|
+
const videos = el instanceof HTMLVideoElement ? [el] : Array.from(el.querySelectorAll('video'));
|
|
175
|
+
for (const video of videos) {
|
|
176
|
+
const state = STATES.get(video);
|
|
177
|
+
if (!state) continue;
|
|
178
|
+
if (state.ticker !== null) cancelAnimationFrame(state.ticker);
|
|
179
|
+
state.ticker = null;
|
|
180
|
+
state.props = null;
|
|
181
|
+
video.pause();
|
|
182
|
+
video.removeAttribute('src');
|
|
183
|
+
video.load();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Turns media events of the root's `video` elements into `video` events. */
|
|
188
|
+
export class VideoEvents {
|
|
189
|
+
private static readonly TYPES = [
|
|
190
|
+
'loadedmetadata',
|
|
191
|
+
'timeupdate',
|
|
192
|
+
'playing',
|
|
193
|
+
'waiting',
|
|
194
|
+
'pause',
|
|
195
|
+
'play',
|
|
196
|
+
'ended',
|
|
197
|
+
'error',
|
|
198
|
+
'volumechange',
|
|
199
|
+
'ratechange',
|
|
200
|
+
'seeked',
|
|
201
|
+
SEEKED,
|
|
202
|
+
BLOCKED,
|
|
203
|
+
TICK,
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
constructor(
|
|
207
|
+
private readonly root: HTMLElement,
|
|
208
|
+
private readonly send: (event: RendererEvent) => void,
|
|
209
|
+
) {
|
|
210
|
+
for (const type of VideoEvents.TYPES) root.addEventListener(type, this.onEvent, true);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
dispose(): void {
|
|
214
|
+
for (const type of VideoEvents.TYPES) this.root.removeEventListener(type, this.onEvent, true);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private readonly onEvent = (event: Event): void => {
|
|
218
|
+
const video = event.target;
|
|
219
|
+
if (!(video instanceof HTMLVideoElement)) return;
|
|
220
|
+
const state = STATES.get(video);
|
|
221
|
+
const host = video.parentElement;
|
|
222
|
+
const id = Number(host?.dataset['sbId']);
|
|
223
|
+
if (!state || !state.props || !Number.isInteger(id)) return;
|
|
224
|
+
const message = this.message(event, video, state);
|
|
225
|
+
if (message) this.send({ type: 'video', id: id as ElementId, ...message } as VideoEvent);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
private message(event: Event, video: HTMLVideoElement, state: VideoState): Omit<VideoEvent, 'type' | 'id'> | null {
|
|
229
|
+
const props = state.props!;
|
|
230
|
+
const time = round(video.currentTime);
|
|
231
|
+
const url = video.getAttribute('src') ?? '';
|
|
232
|
+
switch (event.type) {
|
|
233
|
+
case 'loadedmetadata':
|
|
234
|
+
return {
|
|
235
|
+
event: 'ready',
|
|
236
|
+
url,
|
|
237
|
+
time,
|
|
238
|
+
duration: Number.isFinite(video.duration) ? round(video.duration) : null,
|
|
239
|
+
width: video.videoWidth,
|
|
240
|
+
height: video.videoHeight,
|
|
241
|
+
};
|
|
242
|
+
case 'timeupdate':
|
|
243
|
+
if (typeof props.report === 'number' && props.report > 0 && !video.paused) return null;
|
|
244
|
+
if (!video.paused && performance.now() - state.lastTime < TIME_EVENT_MIN_MS) return null;
|
|
245
|
+
state.lastTime = performance.now();
|
|
246
|
+
return { event: 'time', time };
|
|
247
|
+
case TICK:
|
|
248
|
+
state.lastTime = performance.now();
|
|
249
|
+
return { event: 'time', time };
|
|
250
|
+
case 'playing':
|
|
251
|
+
return { event: 'status', status: 'playing', time };
|
|
252
|
+
case 'waiting':
|
|
253
|
+
return { event: 'status', status: 'waiting', time };
|
|
254
|
+
case 'pause':
|
|
255
|
+
// The end pauses too; `ended` follows.
|
|
256
|
+
if (video.ended) return null;
|
|
257
|
+
// Paused while the player wants it playing: the viewer did it.
|
|
258
|
+
if (props.playing) return { event: 'pause', time };
|
|
259
|
+
return { event: 'status', status: 'paused', time };
|
|
260
|
+
case 'play':
|
|
261
|
+
// Started while the player wants it paused: the viewer did it.
|
|
262
|
+
return props.playing ? null : { event: 'play', time };
|
|
263
|
+
case BLOCKED:
|
|
264
|
+
return props.playing ? { event: 'pause', reason: 'blocked', time } : null;
|
|
265
|
+
case 'ended':
|
|
266
|
+
return { event: 'ended', url, time };
|
|
267
|
+
case 'error': {
|
|
268
|
+
const error = video.error;
|
|
269
|
+
if (!url || !error) return null;
|
|
270
|
+
return { event: 'error', url, code: error.code, message: error.message ?? '' };
|
|
271
|
+
}
|
|
272
|
+
case 'volumechange': {
|
|
273
|
+
const volume = Math.min(1, Math.max(0, props.volume ?? 1));
|
|
274
|
+
if (video.muted === props.muted && Math.abs(video.volume - volume) < 0.001) return null;
|
|
275
|
+
return { event: 'volume', muted: video.muted, volume: round(video.volume) };
|
|
276
|
+
}
|
|
277
|
+
case 'ratechange':
|
|
278
|
+
if (video.playbackRate === (props.rate > 0 ? props.rate : 1)) return null;
|
|
279
|
+
return { event: 'rate', rate: video.playbackRate };
|
|
280
|
+
case 'seeked': {
|
|
281
|
+
const serial = state.pendingSeek;
|
|
282
|
+
state.pendingSeek = null;
|
|
283
|
+
// Without a seek in flight the viewer scrubbed: report where to.
|
|
284
|
+
return serial === null ? { event: 'time', time } : { event: 'seeked', serial, time };
|
|
285
|
+
}
|
|
286
|
+
case SEEKED: {
|
|
287
|
+
const serial = (event as CustomEvent<{ serial: number }>).detail?.serial;
|
|
288
|
+
return typeof serial === 'number' ? { event: 'seeked', serial, time } : null;
|
|
289
|
+
}
|
|
290
|
+
default:
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Seconds to the millisecond, so events stay short and stable. */
|
|
297
|
+
function round(value: number): number {
|
|
298
|
+
return Math.round(value * 1000) / 1000;
|
|
299
|
+
}
|
package/src/vite-env.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ interface SBLandingApp {
|
|
|
14
14
|
icon?: string;
|
|
15
15
|
/** Languages the module carries locale data for; the page runs it in the viewer's when among them. */
|
|
16
16
|
locales?: string[];
|
|
17
|
+
/** The app's URL schemes (CFBundleURLTypes), whose OAuth redirects the page receives (`SB_URL_SCHEMES`). */
|
|
18
|
+
urlSchemes?: string[];
|
|
19
|
+
/** A CORS proxy (packages/cors-proxy) through which a refused cross-origin GET is retried (src/corsProxy.ts). */
|
|
20
|
+
corsProxy?: string;
|
|
21
|
+
/** Hosts the app's requests may not reach (src/blockedHosts.ts). */
|
|
22
|
+
blockedHosts?: string[];
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/** The registered apps, in registry order. */
|