@netless/app-presentation 0.1.13-beta.0 → 0.1.13-beta.1

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.
@@ -96,6 +96,17 @@ export interface PresentationAppOptions {
96
96
  goToPageByClick?: boolean;
97
97
  /** 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 */
98
98
  useClipView?: boolean;
99
+ /**
100
+ * Max time (ms) `setup()` waits for the first visible page image to load
101
+ * before resolving anyway. Default: 5_000.
102
+ */
103
+ setupReadyTimeout?: number;
104
+ /**
105
+ * Blur thumbnail degradation keeps cached (focused-lost) presentations on
106
+ * the low-res `previewURL` thumbnail to save memory. Enabled by default in
107
+ * maximized/minimized states; set `true` to disable it.
108
+ */
109
+ disableBlurThumbnailDegradation?: boolean;
99
110
  }
100
111
 
101
112
  export interface PresentationController {
@@ -154,6 +165,199 @@ const ppt2page = (
154
165
  return { ...size, src: ppt.src, thumbnail: ppt.previewURL, name };
155
166
  };
156
167
 
168
+ /**
169
+ * The whiteboard SDK renders the scene ppt as a real `<img alt="background">`
170
+ * inside the background HTML engine layer of the view. All blur degradation
171
+ * below works directly on that DOM node; no scene API is ever called.
172
+ */
173
+ const findBackgroundImage = (view: View): HTMLImageElement | null => {
174
+ const container = (view as any).divElement as HTMLElement | undefined | null;
175
+ if (!container || typeof container.querySelector !== "function") return null;
176
+ return container.querySelector('img[alt="background"]');
177
+ };
178
+
179
+ /**
180
+ * Degradation only applies when the window cannot be visible: maximized
181
+ * (covered by the top app) or minimized. Normal mode may show several
182
+ * windows side by side, so a blurred app there must keep the full image.
183
+ */
184
+ const isBlurDegradationAllowed = (context: AppContext): boolean => {
185
+ const boxStatus = context.getBoxStatus();
186
+ if (boxStatus) {
187
+ return boxStatus !== "normal";
188
+ }
189
+ const boxState = context.getWindowManager().boxState;
190
+ return boxState !== undefined && boxState !== "normal";
191
+ };
192
+
193
+ const DEFAULT_SETUP_READY_TIMEOUT = 5_000;
194
+
195
+ /**
196
+ * Resolve once the current page background image has loaded (or after
197
+ * `timeoutMs`). `false` means timeout or the app was disposed mid-wait;
198
+ * callers must treat it as "continue loading in background", not an error.
199
+ */
200
+ const waitForCurrentPageImage = (
201
+ view: View,
202
+ timeoutMs: number,
203
+ isDisposed: () => boolean,
204
+ ): Promise<boolean> => {
205
+ return new Promise<boolean>(resolve => {
206
+ let settled = false;
207
+ let pollTimer: number | undefined;
208
+ const settle = (loaded: boolean) => {
209
+ if (settled) return;
210
+ settled = true;
211
+ window.clearTimeout(timeoutTimer);
212
+ if (pollTimer !== undefined) window.clearInterval(pollTimer);
213
+ resolve(loaded);
214
+ };
215
+ const timeoutTimer = window.setTimeout(() => settle(false), timeoutMs);
216
+ const check = () => {
217
+ if (isDisposed()) {
218
+ settle(false);
219
+ return;
220
+ }
221
+ const img = findBackgroundImage(view);
222
+ if (img && img.complete && img.naturalWidth > 0) {
223
+ settle(true);
224
+ }
225
+ };
226
+ check();
227
+ if (!settled) {
228
+ pollTimer = window.setInterval(check, 100);
229
+ }
230
+ });
231
+ };
232
+
233
+ /**
234
+ * Compare image URLs by resolved absolute href so relative/absolute
235
+ * representations of the same URL do not look different (and to keep the
236
+ * load-capture re-degrade loop terminating).
237
+ */
238
+ const resolveImageUrl = (url: string): string => {
239
+ try {
240
+ return new URL(url, window.location.href).href;
241
+ } catch {
242
+ return url;
243
+ }
244
+ };
245
+
246
+ /**
247
+ * Blur thumbnail degradation (see lazy-setup design doc §15): when the app
248
+ * loses focus but stays cached (multi-runtime cache mode), swap the scene
249
+ * background `<img>` src to the low-res `PptDescription.previewURL`
250
+ * (`page.thumbnail`) via a pure DOM change and pause full-image preloading;
251
+ * restore the full image when focus comes back. Skipped when the page has no
252
+ * thumbnail.
253
+ *
254
+ * While degraded, every load of a background `<img>` is re-degraded
255
+ * immediately via a capture-phase `load` listener on the view container
256
+ * (`load` does not bubble but capture works). This covers remote page turns,
257
+ * SDK React re-renders and the SDK's own background-image onError retries —
258
+ * any path that (re)renders the full-size image while the app stays blurred.
259
+ */
260
+ const setupBlurThumbnailDegradation = (config: {
261
+ context: AppContext;
262
+ view: View;
263
+ pages: PresentationPage[];
264
+ pageIndex$: Readable<number>;
265
+ app: Presentation;
266
+ dispose: ReturnType<typeof disposableStore>;
267
+ }): void => {
268
+ const { context, view, pages, pageIndex$, app, dispose } = config;
269
+ // Two separate flags: `blurEpisode` covers the whole blurred period (pages
270
+ // without a thumbnail don't end it, so a later page degrades again), while
271
+ // `reDegradeEnabled` only turns off when the degraded thumbnail itself
272
+ // fails, so we don't fight the SDK's background onError retry.
273
+ let blurEpisode = false;
274
+ let reDegradeEnabled = false;
275
+
276
+ const degrade = (): void => {
277
+ const page = pages[pageIndex$.value];
278
+ if (!page?.thumbnail) return; // keep the full image as-is
279
+ const img = findBackgroundImage(view);
280
+ if (img && resolveImageUrl(img.src) !== resolveImageUrl(page.thumbnail)) {
281
+ img.src = page.thumbnail;
282
+ }
283
+ };
284
+
285
+ const restore = (): void => {
286
+ const page = pages[pageIndex$.value];
287
+ const img = findBackgroundImage(view);
288
+ if (page && img && resolveImageUrl(img.src) !== resolveImageUrl(page.src)) {
289
+ img.src = page.src;
290
+ }
291
+ };
292
+
293
+ const isBackgroundImg = (target: EventTarget | null): boolean => {
294
+ const el = target as HTMLImageElement | null;
295
+ return Boolean(
296
+ el && el.tagName === "IMG" && el.matches?.('img[alt="background"]'),
297
+ );
298
+ };
299
+
300
+ // Re-degrade any background img that finishes loading a full-size image
301
+ // while the app is blurred. Scoped to the view container so the app's own
302
+ // preview-panel thumbnails are untouched.
303
+ const onImgLoad = (ev: Event): void => {
304
+ if (!blurEpisode || !reDegradeEnabled) return;
305
+ if (!isBackgroundImg(ev.target)) return;
306
+ degrade();
307
+ };
308
+
309
+ // The degraded thumbnail failed to load: stop re-degrading for this blur
310
+ // episode so the SDK's onError retry can settle on the full image. Focus
311
+ // still force-restores afterwards.
312
+ const onImgError = (ev: Event): void => {
313
+ if (!blurEpisode || !reDegradeEnabled) return;
314
+ if (!isBackgroundImg(ev.target)) return;
315
+ reDegradeEnabled = false;
316
+ };
317
+
318
+ const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
319
+ if (isFocused) {
320
+ if (blurEpisode) {
321
+ blurEpisode = false;
322
+ reDegradeEnabled = false;
323
+ restore();
324
+ app.preload.resume(pageIndex$.value);
325
+ }
326
+ } else {
327
+ if (!isBlurDegradationAllowed(context)) return;
328
+ blurEpisode = true;
329
+ reDegradeEnabled = true;
330
+ degrade();
331
+ app.preload.pause();
332
+ }
333
+ });
334
+ dispose.add(offFocus);
335
+
336
+ const viewEl = (view as any).divElement as HTMLElement | undefined | null;
337
+ if (viewEl && typeof viewEl.addEventListener === "function") {
338
+ viewEl.addEventListener("load", onImgLoad, true);
339
+ viewEl.addEventListener("error", onImgError, true);
340
+ dispose.add(() => {
341
+ viewEl.removeEventListener("load", onImgLoad, true);
342
+ viewEl.removeEventListener("error", onImgError, true);
343
+ });
344
+ } else {
345
+ // View not mounted yet (should not happen after context.mountView):
346
+ // keep the degradation on blur only, without re-degrade coverage.
347
+ warnOnceMissingViewContainer(view);
348
+ }
349
+ };
350
+
351
+ // One-shot diagnostic for an unexpected missing view container.
352
+ const degradedWarnedViews = new WeakSet<object>();
353
+ const warnOnceMissingViewContainer = (view: View): void => {
354
+ if (degradedWarnedViews.has(view)) return;
355
+ degradedWarnedViews.add(view);
356
+ console.warn(
357
+ "[Presentation]: blur thumbnail degradation runs without re-degrade coverage (view container unavailable)",
358
+ );
359
+ };
360
+
157
361
  const createLogger = (room: Room | undefined): Logger => {
158
362
  if (room && (room as any).logger) {
159
363
  return (...args) => (room as any).logger.info(...args);
@@ -641,6 +845,12 @@ export const NetlessAppPresentation: NetlessApp<
641
845
  app.log = log;
642
846
  app.warn = warn;
643
847
 
848
+ // Setup only touches the current page's full image; the rest of the deck
849
+ // keeps loading through idle callbacks after the setup queue moves on.
850
+ if (pageIndex$.value !== 0) {
851
+ app.preload.touch(pageIndex$.value, true);
852
+ }
853
+
644
854
  const getCameraDiagnosticState = (
645
855
  reason: "initialize" | "moveCamera",
646
856
  requestedCamera?: MoveCameraRequest,
@@ -785,6 +995,10 @@ export const NetlessAppPresentation: NetlessApp<
785
995
  }),
786
996
  );
787
997
 
998
+ if (!options.disableBlurThumbnailDegradation) {
999
+ setupBlurThumbnailDegradation({ context, view, pages, pageIndex$, app, dispose });
1000
+ }
1001
+
788
1002
  const getOriginScale = () => {
789
1003
  const page = app.page();
790
1004
  if (!page || !isValidSize(page)) return 0;
@@ -1207,9 +1421,16 @@ export const NetlessAppPresentation: NetlessApp<
1207
1421
  // Older WindowManager declarations model setup as synchronous even though
1208
1422
  // AppProxy awaits its result. Keep that source compatibility until the new
1209
1423
  // `SetupResult | Promise<SetupResult>` declaration is the minimum version.
1210
- return prepareScenesPromise.then(
1211
- () => controller,
1212
- ) as unknown as PresentationController;
1424
+ const setupReadyTimeout = options.setupReadyTimeout ?? DEFAULT_SETUP_READY_TIMEOUT;
1425
+ return prepareScenesPromise.then(async () => {
1426
+ // Keep the serial setup queue honest: resolve only once the current
1427
+ // page image is actually visible (or the bounded wait gives up).
1428
+ const loaded = await waitForCurrentPageImage(view, setupReadyTimeout, () => disposed);
1429
+ if (!loaded) {
1430
+ warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
1431
+ }
1432
+ return controller;
1433
+ }) as unknown as PresentationController;
1213
1434
  },
1214
1435
  teardown(context) {
1215
1436
  teardownByContext.get(context)?.();
package/src/preload.ts CHANGED
@@ -35,6 +35,9 @@ export class Preload implements IDisposable {
35
35
  // 当前触摸的索引
36
36
  touchIndex: number = 0;
37
37
  preloadSize: number = 0;
38
+ // 暂停后不再创建预加载链接(app 失焦降级时调用);已插入的 link 会被移除,
39
+ // 挂起的 idle 回调重新进入 touch() 时也会被跳过
40
+ paused: boolean = false;
38
41
  constructor(readonly pages: PresentationPage[]) {
39
42
  this.preloadMap = new Map(pages.map((e,index) => [index, {
40
43
  src: e.src,
@@ -43,7 +46,18 @@ export class Preload implements IDisposable {
43
46
  this.preloadSize = this.preloadMap.size;
44
47
  this.touch(0, true);
45
48
  }
49
+ pause() {
50
+ if (this.paused) return;
51
+ this.paused = true;
52
+ this.destroySomeLink();
53
+ }
54
+ resume(index?: number) {
55
+ if (!this.paused) return;
56
+ this.paused = false;
57
+ this.touch(index ?? this.touchIndex, true);
58
+ }
46
59
  touch(index: number, force: boolean = false) {
60
+ if (this.paused) return;
47
61
  if (index >= this.preloadSize) {
48
62
  this.touchIndex = 0;
49
63
  } else {