@netless/app-presentation 0.1.13-beta.2 → 0.1.13-beta.4
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-zh.md +18 -0
- package/README.md +22 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.global.js +136 -56
- package/dist/index.js +136 -56
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +136 -56
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/app-presentation.ts +62 -50
- package/src/preload.ts +23 -11
- package/src/runtime-lifecycle.ts +13 -6
- package/src/setup-ready.ts +46 -0
package/src/app-presentation.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
isValidSize,
|
|
37
37
|
} from "./camera-reference";
|
|
38
38
|
import { createPresentationRuntimeTeardown } from "./runtime-lifecycle";
|
|
39
|
+
import { waitForCurrentPageImage } from "./setup-ready";
|
|
39
40
|
import debounce from "lodash/debounce";
|
|
40
41
|
|
|
41
42
|
export type Logger = (...data: any[]) => void;
|
|
@@ -98,8 +99,8 @@ export interface PresentationAppOptions {
|
|
|
98
99
|
/** useClipView is used to set the presentation use clip view, it will be used in the presentation, and the presentation will be use clip view when the app is initialized */
|
|
99
100
|
useClipView?: boolean;
|
|
100
101
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
102
|
+
* First-image warning threshold in ms. Default: 5_000. New lazy hosts
|
|
103
|
+
* await real readiness; legacy/eager hosts resolve on timeout.
|
|
103
104
|
*/
|
|
104
105
|
setupReadyTimeout?: number;
|
|
105
106
|
/**
|
|
@@ -199,44 +200,6 @@ const isBlurDegradationAllowed = (context: AppContext): boolean => {
|
|
|
199
200
|
|
|
200
201
|
const DEFAULT_SETUP_READY_TIMEOUT = 5_000;
|
|
201
202
|
|
|
202
|
-
/**
|
|
203
|
-
* Resolve once the current page background image has loaded (or after
|
|
204
|
-
* `timeoutMs`). `false` means timeout or the app was disposed mid-wait;
|
|
205
|
-
* callers must treat it as "continue loading in background", not an error.
|
|
206
|
-
*/
|
|
207
|
-
const waitForCurrentPageImage = (
|
|
208
|
-
view: View,
|
|
209
|
-
timeoutMs: number,
|
|
210
|
-
isDisposed: () => boolean,
|
|
211
|
-
): Promise<boolean> => {
|
|
212
|
-
return new Promise<boolean>(resolve => {
|
|
213
|
-
let settled = false;
|
|
214
|
-
let pollTimer: number | undefined;
|
|
215
|
-
const settle = (loaded: boolean) => {
|
|
216
|
-
if (settled) return;
|
|
217
|
-
settled = true;
|
|
218
|
-
window.clearTimeout(timeoutTimer);
|
|
219
|
-
if (pollTimer !== undefined) window.clearInterval(pollTimer);
|
|
220
|
-
resolve(loaded);
|
|
221
|
-
};
|
|
222
|
-
const timeoutTimer = window.setTimeout(() => settle(false), timeoutMs);
|
|
223
|
-
const check = () => {
|
|
224
|
-
if (isDisposed()) {
|
|
225
|
-
settle(false);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
const img = findBackgroundImage(view);
|
|
229
|
-
if (img && img.complete && img.naturalWidth > 0) {
|
|
230
|
-
settle(true);
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
check();
|
|
234
|
-
if (!settled) {
|
|
235
|
-
pollTimer = window.setInterval(check, 100);
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
};
|
|
239
|
-
|
|
240
203
|
/**
|
|
241
204
|
* Compare image URLs by resolved absolute href so relative/absolute
|
|
242
205
|
* representations of the same URL do not look different (and to keep the
|
|
@@ -283,7 +246,8 @@ const hasUsableThumbnail = (page: PresentationPage): boolean => {
|
|
|
283
246
|
* hidden and replaced with a static pixel after TeleBox commits the blur.
|
|
284
247
|
*
|
|
285
248
|
* While degraded, every load of a background `<img>` is re-degraded
|
|
286
|
-
* immediately via a capture-phase `load` listener on the
|
|
249
|
+
* immediately via a capture-phase `load` listener on the document, filtered
|
|
250
|
+
* to the current view container
|
|
287
251
|
* (`load` does not bubble but capture works). This covers remote page turns,
|
|
288
252
|
* SDK React re-renders and the SDK's own background-image onError retries —
|
|
289
253
|
* any path that (re)renders the full-size image while the app stays blurred.
|
|
@@ -305,6 +269,29 @@ export const setupBlurThumbnailDegradation = (config: {
|
|
|
305
269
|
// fails, so we don't fight the SDK's background onError retry.
|
|
306
270
|
let blurEpisode = false;
|
|
307
271
|
let reDegradeEnabled = false;
|
|
272
|
+
// Duplicate focus(false) deliveries are unavoidable (multiple sources in
|
|
273
|
+
// the host). Preview-resource clearing is the only non-idempotent DOM op
|
|
274
|
+
// in the blur path, so gate it on this flag until focus restores it.
|
|
275
|
+
let previewSuspended = false;
|
|
276
|
+
// A freshly-created runtime has active resources even if setup completed
|
|
277
|
+
// after the box already lost focus. Track applied resource state instead of
|
|
278
|
+
// reading box.focus, and ignore repeated signals from the host.
|
|
279
|
+
let appliedFocusState = true;
|
|
280
|
+
let logicalFocused = true;
|
|
281
|
+
const activityBlocked = (): boolean => {
|
|
282
|
+
const wm = context.getWindowManager() as any;
|
|
283
|
+
if (wm?.lazySetupInMaximizedMode !== true) return false;
|
|
284
|
+
const host = (context as any).getRuntimeActivity?.();
|
|
285
|
+
// Use the host's complete activity snapshot when available, rather than
|
|
286
|
+
// combining it with a local boxState from a different restore revision.
|
|
287
|
+
if (host) return document.visibilityState === "hidden" || !host.active;
|
|
288
|
+
return (
|
|
289
|
+
document.visibilityState === "hidden" ||
|
|
290
|
+
wm.boxState === "minimized" ||
|
|
291
|
+
wm.attributes?.minimized === true ||
|
|
292
|
+
context.getBoxStatus() === "minimized"
|
|
293
|
+
);
|
|
294
|
+
};
|
|
308
295
|
let boxBlurred = box.$box.classList.contains(boxBlurClass);
|
|
309
296
|
|
|
310
297
|
const setBackgroundSuspended = (suspended: boolean): void => {
|
|
@@ -320,7 +307,7 @@ export const setupBlurThumbnailDegradation = (config: {
|
|
|
320
307
|
const thumbnailURL = page.thumbnail;
|
|
321
308
|
const img = findBackgroundImage(view);
|
|
322
309
|
if (!thumbnailURL || !hasUsableThumbnail(page)) {
|
|
323
|
-
if (boxBlurred) {
|
|
310
|
+
if (boxBlurred || activityBlocked()) {
|
|
324
311
|
setBackgroundSuspended(true);
|
|
325
312
|
if (
|
|
326
313
|
img &&
|
|
@@ -347,8 +334,11 @@ export const setupBlurThumbnailDegradation = (config: {
|
|
|
347
334
|
};
|
|
348
335
|
|
|
349
336
|
const suspendCachedResources = (): void => {
|
|
350
|
-
if (!blurEpisode || !boxBlurred) return;
|
|
351
|
-
|
|
337
|
+
if (!blurEpisode || (!boxBlurred && !activityBlocked())) return;
|
|
338
|
+
if (!previewSuspended) {
|
|
339
|
+
previewSuspended = true;
|
|
340
|
+
app.suspendPreviewResources();
|
|
341
|
+
}
|
|
352
342
|
const page = pages[pageIndex$.value];
|
|
353
343
|
if (!page || !hasUsableThumbnail(page) || reDegradeEnabled) {
|
|
354
344
|
degrade();
|
|
@@ -359,6 +349,7 @@ export const setupBlurThumbnailDegradation = (config: {
|
|
|
359
349
|
if (!blurEpisode) return;
|
|
360
350
|
blurEpisode = false;
|
|
361
351
|
reDegradeEnabled = false;
|
|
352
|
+
previewSuspended = false;
|
|
362
353
|
restore();
|
|
363
354
|
app.preload.resume(pageIndex$.value);
|
|
364
355
|
};
|
|
@@ -397,25 +388,36 @@ export const setupBlurThumbnailDegradation = (config: {
|
|
|
397
388
|
reDegradeEnabled = false;
|
|
398
389
|
};
|
|
399
390
|
|
|
400
|
-
const
|
|
391
|
+
const applyActivity = (): void => {
|
|
392
|
+
const lazy = (context.getWindowManager() as any)?.lazySetupInMaximizedMode === true;
|
|
393
|
+
const isFocused = (!lazy || logicalFocused) && !activityBlocked();
|
|
394
|
+
if (appliedFocusState === isFocused) return;
|
|
395
|
+
if (!isFocused && !isBlurDegradationAllowed(context) && !activityBlocked()) return;
|
|
401
396
|
if (isFocused) {
|
|
402
397
|
restoreFocusedResources();
|
|
403
398
|
} else {
|
|
404
|
-
if (!isBlurDegradationAllowed(context)) return;
|
|
405
399
|
blurEpisode = true;
|
|
406
400
|
reDegradeEnabled = true;
|
|
407
401
|
degrade();
|
|
408
402
|
app.preload.pause();
|
|
409
403
|
suspendCachedResources();
|
|
410
404
|
}
|
|
405
|
+
appliedFocusState = isFocused;
|
|
406
|
+
};
|
|
407
|
+
const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
|
|
408
|
+
logicalFocused = isFocused;
|
|
409
|
+
applyActivity();
|
|
411
410
|
});
|
|
412
411
|
dispose.add(offFocus);
|
|
412
|
+
dispose.add((context.emitter as any).on("runtimeActivity", applyActivity));
|
|
413
|
+
document.addEventListener("visibilitychange", applyActivity);
|
|
414
|
+
dispose.add(() => document.removeEventListener("visibilitychange", applyActivity));
|
|
413
415
|
|
|
414
416
|
const updateBoxBlurred = (blurred: boolean): void => {
|
|
415
417
|
if (boxBlurred === blurred) return;
|
|
416
418
|
boxBlurred = blurred;
|
|
417
419
|
if (blurred) suspendCachedResources();
|
|
418
|
-
else restoreFocusedResources();
|
|
420
|
+
else if (logicalFocused && !activityBlocked()) restoreFocusedResources();
|
|
419
421
|
};
|
|
420
422
|
const onBoxBlur = (): void => updateBoxBlurred(true);
|
|
421
423
|
const onBoxFocus = (): void => updateBoxBlurred(false);
|
|
@@ -1522,9 +1524,19 @@ export const NetlessAppPresentation: NetlessApp<
|
|
|
1522
1524
|
return prepareScenesPromise.then(async () => {
|
|
1523
1525
|
// Keep the serial setup queue honest: resolve only once the current
|
|
1524
1526
|
// page image is actually visible (or the bounded wait gives up).
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1527
|
-
|
|
1527
|
+
const result = await waitForCurrentPageImage(
|
|
1528
|
+
() => findBackgroundImage(view), setupReadyTimeout, () => disposed,
|
|
1529
|
+
{
|
|
1530
|
+
waitForReady: () => Boolean((context as any).waitForActualSetupReady),
|
|
1531
|
+
onTimeout: () => warn(`[Presentation] setup page image still pending after ${setupReadyTimeout}ms`),
|
|
1532
|
+
},
|
|
1533
|
+
);
|
|
1534
|
+
if (disposed) throw new Error("[Presentation] disposed before setup ready");
|
|
1535
|
+
if (result === "error" && (context as any).waitForActualSetupReady) {
|
|
1536
|
+
throw new Error("[Presentation] setup page image failed to load");
|
|
1537
|
+
}
|
|
1538
|
+
if (result === "error" && !disposed) {
|
|
1539
|
+
warn("[Presentation] setup page image failed to load; continuing setup");
|
|
1528
1540
|
}
|
|
1529
1541
|
return controller;
|
|
1530
1542
|
}) as unknown as PresentationController;
|
package/src/preload.ts
CHANGED
|
@@ -38,6 +38,7 @@ export class Preload implements IDisposable {
|
|
|
38
38
|
// 暂停后不再创建预加载链接(app 失焦降级时调用);已插入的 link 会被移除,
|
|
39
39
|
// 挂起的 idle 回调重新进入 touch() 时也会被跳过
|
|
40
40
|
paused: boolean = false;
|
|
41
|
+
private disposed = false;
|
|
41
42
|
constructor(readonly pages: PresentationPage[]) {
|
|
42
43
|
this.preloadMap = new Map(pages.map((e,index) => [index, {
|
|
43
44
|
src: e.src,
|
|
@@ -52,23 +53,23 @@ export class Preload implements IDisposable {
|
|
|
52
53
|
this.destroySomeLink();
|
|
53
54
|
}
|
|
54
55
|
resume(index?: number) {
|
|
55
|
-
if (!this.paused) return;
|
|
56
|
+
if (!this.paused || this.disposed) return;
|
|
56
57
|
this.paused = false;
|
|
57
58
|
this.touch(index ?? this.touchIndex, true);
|
|
58
59
|
}
|
|
59
60
|
touch(index: number, force: boolean = false) {
|
|
60
|
-
if (this.paused) return;
|
|
61
|
+
if (this.paused || this.disposed) return;
|
|
61
62
|
if (index >= this.preloadSize) {
|
|
62
63
|
this.touchIndex = 0;
|
|
63
64
|
} else {
|
|
64
65
|
this.touchIndex = index;
|
|
65
66
|
}
|
|
66
67
|
const value = this.preloadMap.get(this.touchIndex);
|
|
67
|
-
if (value && value.state === ELoadState.unloaded) {
|
|
68
|
+
if (value && (value.state === ELoadState.unloaded || (force && value.state === ELoadState.error))) {
|
|
68
69
|
this.createLink(this.touchIndex, force);
|
|
69
70
|
}
|
|
70
71
|
if (this.loadingLinks.size < Preload.maxLinks) {
|
|
71
|
-
const willLoad = [...this.preloadMap.entries()].find(([i, e]) => i > this.touchIndex && e.state
|
|
72
|
+
const willLoad = [...this.preloadMap.entries()].find(([i, e]) => i > this.touchIndex && e.state === ELoadState.unloaded);
|
|
72
73
|
if (willLoad) {
|
|
73
74
|
this.requestAsyncCallBack(()=>{this.touch(willLoad[0], false)}, 100);
|
|
74
75
|
}
|
|
@@ -100,21 +101,25 @@ export class Preload implements IDisposable {
|
|
|
100
101
|
linkDom.as = 'image';
|
|
101
102
|
linkDom.href = value.src;
|
|
102
103
|
linkDom.dataset.order = index + '';
|
|
103
|
-
document.head.appendChild(linkDom);
|
|
104
104
|
linkDom.onload = () => {
|
|
105
|
+
if (this.loadingLinks.get(index)?.link !== linkDom) return;
|
|
105
106
|
const value = this.preloadMap.get(index);
|
|
106
107
|
if (value) {
|
|
107
108
|
value.state = ELoadState.loaded;
|
|
108
109
|
this.preloadMap.set(index, value);
|
|
109
|
-
|
|
110
|
-
this.loadingLinks.delete(index);
|
|
110
|
+
this.removeLink(index, linkDom);
|
|
111
111
|
this.touch(this.touchIndex + 1, false);
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
linkDom.onerror = () => {
|
|
115
|
+
if (this.loadingLinks.get(index)?.link !== linkDom) return;
|
|
115
116
|
const value = this.preloadMap.get(index);
|
|
116
|
-
if (value
|
|
117
|
-
|
|
117
|
+
if (value) {
|
|
118
|
+
// Preloading is best effort. Do not retry in an error loop;
|
|
119
|
+
// revisiting the page explicitly may retry after network recovery.
|
|
120
|
+
value.state = ELoadState.error;
|
|
121
|
+
this.removeLink(index, linkDom);
|
|
122
|
+
this.touch(this.touchIndex + 1, false);
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
this.loadingLinks.set(index, {
|
|
@@ -123,18 +128,24 @@ export class Preload implements IDisposable {
|
|
|
123
128
|
});
|
|
124
129
|
value.state = ELoadState.unloaded;
|
|
125
130
|
this.preloadMap.set(index, value);
|
|
131
|
+
document.head.appendChild(linkDom);
|
|
126
132
|
return ELoadState.unloaded;
|
|
127
133
|
}
|
|
128
134
|
return ELoadState.error;
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
private removeLink(index: number, link: HTMLLinkElement) {
|
|
138
|
+
link.onload = null;
|
|
139
|
+
link.onerror = null;
|
|
140
|
+
document.head.contains(link) && document.head.removeChild(link);
|
|
141
|
+
this.loadingLinks.delete(index);
|
|
142
|
+
}
|
|
131
143
|
private destroySomeLink(excludeIndex?: number) {
|
|
132
144
|
for (const [index, { link }] of this.loadingLinks) {
|
|
133
145
|
if (excludeIndex !== undefined && index === excludeIndex) {
|
|
134
146
|
continue;
|
|
135
147
|
}
|
|
136
|
-
|
|
137
|
-
this.loadingLinks.delete(index);
|
|
148
|
+
this.removeLink(index, link);
|
|
138
149
|
}
|
|
139
150
|
}
|
|
140
151
|
private async requestAsyncCallBack (callBack:()=>void, timeout:number):Promise<void> {
|
|
@@ -152,6 +163,7 @@ export class Preload implements IDisposable {
|
|
|
152
163
|
callBack();
|
|
153
164
|
}
|
|
154
165
|
dispose() {
|
|
166
|
+
this.disposed = true;
|
|
155
167
|
this.destroySomeLink();
|
|
156
168
|
this.preloadMap.clear();
|
|
157
169
|
}
|
package/src/runtime-lifecycle.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface PresentationRuntimeBox {
|
|
2
|
-
unmountContent(): unknown;
|
|
3
|
-
unmountFooter(): unknown;
|
|
4
|
-
unmountStyles(): unknown;
|
|
2
|
+
unmountContent?(): unknown;
|
|
3
|
+
unmountFooter?(): unknown;
|
|
4
|
+
unmountStyles?(): unknown;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
/** Build an idempotent runtime teardown while preserving the TeleBox shell. */
|
|
@@ -16,9 +16,16 @@ export const createPresentationRuntimeTeardown = (
|
|
|
16
16
|
try {
|
|
17
17
|
dispose();
|
|
18
18
|
} finally {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// A partial host API or a failing mount must not skip other cleanup.
|
|
20
|
+
try {
|
|
21
|
+
box.unmountContent?.();
|
|
22
|
+
} finally {
|
|
23
|
+
try {
|
|
24
|
+
box.unmountFooter?.();
|
|
25
|
+
} finally {
|
|
26
|
+
box.unmountStyles?.();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
22
29
|
}
|
|
23
30
|
};
|
|
24
31
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type SetupImageResult = "loaded" | "error" | "timeout" | "disposed";
|
|
2
|
+
|
|
3
|
+
/** New lazy hosts await actual readiness; legacy hosts retain the bounded wait. */
|
|
4
|
+
export const waitForCurrentPageImage = (
|
|
5
|
+
getImage: () => HTMLImageElement | null,
|
|
6
|
+
timeoutMs: number,
|
|
7
|
+
isDisposed: () => boolean,
|
|
8
|
+
options?: { waitForReady: () => boolean; onTimeout: () => void },
|
|
9
|
+
): Promise<SetupImageResult> => {
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
let settled = false;
|
|
12
|
+
let timedOut = false;
|
|
13
|
+
let pollTimer: number | undefined;
|
|
14
|
+
const settle = (result: SetupImageResult) => {
|
|
15
|
+
if (settled) return;
|
|
16
|
+
settled = true;
|
|
17
|
+
window.clearTimeout(timeoutTimer);
|
|
18
|
+
if (pollTimer !== undefined) window.clearInterval(pollTimer);
|
|
19
|
+
resolve(result);
|
|
20
|
+
};
|
|
21
|
+
const timeoutTimer = window.setTimeout(() => {
|
|
22
|
+
timedOut = true;
|
|
23
|
+
options?.onTimeout();
|
|
24
|
+
if (!options?.waitForReady()) settle("timeout");
|
|
25
|
+
}, timeoutMs);
|
|
26
|
+
const check = () => {
|
|
27
|
+
if (isDisposed()) {
|
|
28
|
+
settle("disposed");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (timedOut && !options?.waitForReady()) { settle("timeout"); return; }
|
|
32
|
+
const img = getImage();
|
|
33
|
+
if (img?.complete) {
|
|
34
|
+
if (img.naturalWidth > 0) {
|
|
35
|
+
settle("loaded");
|
|
36
|
+
} else if (img.currentSrc || img.getAttribute("src")?.trim() || img.getAttribute("srcset")?.trim()) {
|
|
37
|
+
// complete is also true before src is assigned. Only an actual
|
|
38
|
+
// image request with zero decoded width represents a failed load.
|
|
39
|
+
settle("error");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
check();
|
|
44
|
+
if (!settled) pollTimer = window.setInterval(check, 100);
|
|
45
|
+
});
|
|
46
|
+
};
|