@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.
@@ -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
- * Max time (ms) `setup()` waits for the first visible page image to load
102
- * before resolving anyway. Default: 5_000.
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 view container
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
- app.suspendPreviewResources();
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 offFocus = context.emitter.on("focus", (isFocused: boolean) => {
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 loaded = await waitForCurrentPageImage(view, setupReadyTimeout, () => disposed);
1526
- if (!loaded && !disposed) {
1527
- warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
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 !== ELoadState.loaded);
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
- document.head.contains(linkDom) && document.head.removeChild(linkDom);
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?.src) {
117
- linkDom.href = value.src;
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
- document.head.contains(link) && document.head.removeChild(link);
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
  }
@@ -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
- box.unmountContent();
20
- box.unmountFooter();
21
- box.unmountStyles();
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
+ };