@netless/app-presentation 0.1.13-beta.3 → 0.1.13

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.
@@ -99,8 +99,8 @@ export interface PresentationAppOptions {
99
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 */
100
100
  useClipView?: boolean;
101
101
  /**
102
- * Max time (ms) `setup()` waits for the first visible page image to load
103
- * 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.
104
104
  */
105
105
  setupReadyTimeout?: number;
106
106
  /**
@@ -263,12 +263,34 @@ export const setupBlurThumbnailDegradation = (config: {
263
263
  const { context, view, pages, pageIndex$, app, dispose } = config;
264
264
  const box = context.getBox();
265
265
  const boxBlurClass = box.wrapClassName("blur");
266
- // Two separate flags: `blurEpisode` covers the whole blurred period (pages
267
- // without a thumbnail don't end it, so a later page degrades again), while
268
- // `reDegradeEnabled` only turns off when the degraded thumbnail itself
269
- // fails, so we don't fight the SDK's background onError retry.
266
+ // A failed thumbnail is pinned only for its page during this blur episode.
270
267
  let blurEpisode = false;
271
- let reDegradeEnabled = false;
268
+ let failedThumbnailPageIndex: number | null = null;
269
+ const canReDegrade = (): boolean =>
270
+ blurEpisode && failedThumbnailPageIndex !== pageIndex$.value;
271
+ // Duplicate focus(false) deliveries are unavoidable (multiple sources in
272
+ // the host). Preview-resource clearing is the only non-idempotent DOM op
273
+ // in the blur path, so gate it on this flag until focus restores it.
274
+ let previewSuspended = false;
275
+ // A freshly-created runtime has active resources even if setup completed
276
+ // after the box already lost focus. Track applied resource state instead of
277
+ // reading box.focus, and ignore repeated signals from the host.
278
+ let appliedFocusState = true;
279
+ let logicalFocused = true;
280
+ const activityBlocked = (): boolean => {
281
+ const wm = context.getWindowManager() as any;
282
+ if (wm?.lazySetupInMaximizedMode !== true) return false;
283
+ const host = (context as any).getRuntimeActivity?.();
284
+ // Use the host's complete activity snapshot when available, rather than
285
+ // combining it with a local boxState from a different restore revision.
286
+ if (host) return document.visibilityState === "hidden" || !host.active;
287
+ return (
288
+ document.visibilityState === "hidden" ||
289
+ wm.boxState === "minimized" ||
290
+ wm.attributes?.minimized === true ||
291
+ context.getBoxStatus() === "minimized"
292
+ );
293
+ };
272
294
  let boxBlurred = box.$box.classList.contains(boxBlurClass);
273
295
 
274
296
  const setBackgroundSuspended = (suspended: boolean): void => {
@@ -284,7 +306,7 @@ export const setupBlurThumbnailDegradation = (config: {
284
306
  const thumbnailURL = page.thumbnail;
285
307
  const img = findBackgroundImage(view);
286
308
  if (!thumbnailURL || !hasUsableThumbnail(page)) {
287
- if (boxBlurred) {
309
+ if (boxBlurred || activityBlocked()) {
288
310
  setBackgroundSuspended(true);
289
311
  if (
290
312
  img &&
@@ -311,10 +333,13 @@ export const setupBlurThumbnailDegradation = (config: {
311
333
  };
312
334
 
313
335
  const suspendCachedResources = (): void => {
314
- if (!blurEpisode || !boxBlurred) return;
315
- app.suspendPreviewResources();
336
+ if (!blurEpisode || (!boxBlurred && !activityBlocked())) return;
337
+ if (!previewSuspended) {
338
+ previewSuspended = true;
339
+ app.suspendPreviewResources();
340
+ }
316
341
  const page = pages[pageIndex$.value];
317
- if (!page || !hasUsableThumbnail(page) || reDegradeEnabled) {
342
+ if (!page || !hasUsableThumbnail(page) || canReDegrade()) {
318
343
  degrade();
319
344
  }
320
345
  };
@@ -322,7 +347,8 @@ export const setupBlurThumbnailDegradation = (config: {
322
347
  const restoreFocusedResources = (): void => {
323
348
  if (!blurEpisode) return;
324
349
  blurEpisode = false;
325
- reDegradeEnabled = false;
350
+ failedThumbnailPageIndex = null;
351
+ previewSuspended = false;
326
352
  restore();
327
353
  app.preload.resume(pageIndex$.value);
328
354
  };
@@ -347,39 +373,55 @@ export const setupBlurThumbnailDegradation = (config: {
347
373
  };
348
374
 
349
375
  const onImgLoad = (ev: Event): void => {
350
- if (!blurEpisode || !reDegradeEnabled) return;
376
+ if (!canReDegrade()) return;
351
377
  if (!ownsTarget(ev.target)) return;
352
378
  degrade();
353
379
  };
354
380
 
355
- // The degraded thumbnail failed to load: stop re-degrading for this blur
356
- // episode so the SDK's onError retry can settle on the full image. Focus
357
- // still force-restores afterwards.
381
+ // Stop re-degrading this page when its thumbnail fails, so the SDK's
382
+ // onError retry can settle on the full image. Other pages remain eligible.
358
383
  const onImgError = (ev: Event): void => {
359
- if (!blurEpisode || !reDegradeEnabled) return;
384
+ if (!canReDegrade()) return;
360
385
  if (!ownsTarget(ev.target)) return;
361
- reDegradeEnabled = false;
386
+ const thumbnailURL = pages[pageIndex$.value]?.thumbnail;
387
+ if (
388
+ thumbnailURL &&
389
+ resolveImageUrl(ev.target.src) === resolveImageUrl(thumbnailURL)
390
+ ) {
391
+ failedThumbnailPageIndex = pageIndex$.value;
392
+ }
362
393
  };
363
394
 
364
- const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
395
+ const applyActivity = (): void => {
396
+ const lazy = (context.getWindowManager() as any)?.lazySetupInMaximizedMode === true;
397
+ const isFocused = (!lazy || logicalFocused) && !activityBlocked();
398
+ if (appliedFocusState === isFocused) return;
399
+ if (!isFocused && !isBlurDegradationAllowed(context) && !activityBlocked()) return;
365
400
  if (isFocused) {
366
401
  restoreFocusedResources();
367
402
  } else {
368
- if (!isBlurDegradationAllowed(context)) return;
369
403
  blurEpisode = true;
370
- reDegradeEnabled = true;
404
+ failedThumbnailPageIndex = null;
371
405
  degrade();
372
406
  app.preload.pause();
373
407
  suspendCachedResources();
374
408
  }
409
+ appliedFocusState = isFocused;
410
+ };
411
+ const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
412
+ logicalFocused = isFocused;
413
+ applyActivity();
375
414
  });
376
415
  dispose.add(offFocus);
416
+ dispose.add((context.emitter as any).on("runtimeActivity", applyActivity));
417
+ document.addEventListener("visibilitychange", applyActivity);
418
+ dispose.add(() => document.removeEventListener("visibilitychange", applyActivity));
377
419
 
378
420
  const updateBoxBlurred = (blurred: boolean): void => {
379
421
  if (boxBlurred === blurred) return;
380
422
  boxBlurred = blurred;
381
423
  if (blurred) suspendCachedResources();
382
- else restoreFocusedResources();
424
+ else if (logicalFocused && !activityBlocked()) restoreFocusedResources();
383
425
  };
384
426
  const onBoxBlur = (): void => updateBoxBlurred(true);
385
427
  const onBoxFocus = (): void => updateBoxBlurred(false);
@@ -409,27 +451,16 @@ export const setupBlurThumbnailDegradation = (config: {
409
451
  });
410
452
  };
411
453
 
412
- const createLogger = (room: Room | undefined): Logger => {
413
- if (room && (room as any).logger) {
414
- return (...args) => (room as any).logger.info(...args);
415
- } else {
416
- return (...args) => console.log(...args);
417
- }
454
+ const getLogger = (context: AppContext) =>
455
+ (context.getWindowManager() as any)?.Logger ?? (context.getRoom() as any)?.logger;
456
+
457
+ const createLogger = (context: AppContext): Logger => {
458
+ return (...args) => getLogger(context)?.info?.(...args);
418
459
  };
419
460
 
420
- const createDiagnosticLogger = (
461
+ export const createDiagnosticLogger = (
421
462
  context: AppContext,
422
463
  ): PresentationDiagnosticLogger => {
423
- const createAppLogger = (context as any).createLogger;
424
- if (typeof createAppLogger === "function") {
425
- return createAppLogger.call(context, "camera", {
426
- debounceTime: 300,
427
- maxWaitTime: 2000,
428
- });
429
- }
430
-
431
- // Compatibility fallback for WindowManager versions without AppContext.createLogger().
432
- const roomLogger = (context.getRoom() as any)?.logger;
433
464
  const prefix = `[Presentation][${context.appId}][camera]`;
434
465
  const emit = (
435
466
  level: "info" | "warn" | "error",
@@ -437,6 +468,7 @@ const createDiagnosticLogger = (
437
468
  ...data: unknown[]
438
469
  ) => {
439
470
  try {
471
+ const roomLogger = getLogger(context);
440
472
  const printer = roomLogger?.[level];
441
473
  if (typeof printer === "function")
442
474
  printer.call(roomLogger, `${prefix}[${event}]`, ...data);
@@ -516,10 +548,8 @@ export const NetlessAppPresentation: NetlessApp<
516
548
 
517
549
  const options = context.getAppOptions() || {};
518
550
  const room = context.getRoom();
519
- const log = options.log || createLogger(room);
520
- const roomLogger = (room as any)?.logger;
521
- const warn: Logger = (...data) =>
522
- roomLogger?.warn ? roomLogger.warn(...data) : log(...data);
551
+ const log = options.log || createLogger(context);
552
+ const warn: Logger = (...data) => getLogger(context)?.warn?.(...data);
523
553
  const configuredOriginSize = context.storage.state.originSize;
524
554
  const originSize = isValidSize(configuredOriginSize)
525
555
  ? {
@@ -889,6 +919,7 @@ export const NetlessAppPresentation: NetlessApp<
889
919
  view,
890
920
  options.thumbnail,
891
921
  options.useClipView,
922
+ error => getLogger(context)?.error?.("[Presentation] thumbnail URL parsing failed", context.appId, error),
892
923
  ),
893
924
  );
894
925
  app.contentDOM.dataset.appPresentationVersion = __VERSION__;
@@ -1484,14 +1515,17 @@ export const NetlessAppPresentation: NetlessApp<
1484
1515
  // `SetupResult | Promise<SetupResult>` declaration is the minimum version.
1485
1516
  const setupReadyTimeout = options.setupReadyTimeout ?? DEFAULT_SETUP_READY_TIMEOUT;
1486
1517
  return prepareScenesPromise.then(async () => {
1487
- // Keep the serial setup queue honest: resolve only once the current
1488
- // page image is actually visible (or the bounded wait gives up).
1518
+ // Wait for the current page image or its first failure. The SDK owns
1519
+ // background-image retries, so a failed request must not fail setup.
1489
1520
  const result = await waitForCurrentPageImage(
1490
1521
  () => findBackgroundImage(view), setupReadyTimeout, () => disposed,
1522
+ {
1523
+ waitForReady: () => Boolean((context as any).waitForActualSetupReady),
1524
+ onTimeout: () => warn(`[Presentation] setup page image still pending after ${setupReadyTimeout}ms`),
1525
+ },
1491
1526
  );
1492
- if (result === "timeout" && !disposed) {
1493
- warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
1494
- } else if (result === "error" && !disposed) {
1527
+ if (disposed) throw new Error("[Presentation] disposed before setup ready");
1528
+ if (result === "error") {
1495
1529
  warn("[Presentation] setup page image failed to load; continuing setup");
1496
1530
  }
1497
1531
  return controller;
@@ -1553,6 +1587,7 @@ function createPresentation(
1553
1587
  view: View,
1554
1588
  thumbnail?: (src: string) => string,
1555
1589
  useClipView?: boolean,
1590
+ onThumbnailError?: (error: unknown) => void,
1556
1591
  ): AppPresentation {
1557
1592
  box.mountStyles(styles);
1558
1593
 
@@ -1561,6 +1596,7 @@ function createPresentation(
1561
1596
  readonly: box.readonly,
1562
1597
  jumpPage,
1563
1598
  thumbnail,
1599
+ onThumbnailError,
1564
1600
  });
1565
1601
  app.box = box;
1566
1602
  box.mountContent(app.contentDOM);
@@ -18,6 +18,7 @@ export interface PresentationConfig {
18
18
  readonly pages: PresentationPage[]
19
19
  readonly readonly?: boolean
20
20
  readonly thumbnail?: (src: string) => string
21
+ readonly onThumbnailError?: (error: unknown) => void
21
22
  }
22
23
 
23
24
  /**
@@ -59,6 +60,7 @@ export class Presentation implements IDisposable<void> {
59
60
  readonly dispose = disposableStore()
60
61
  readonly pages: PresentationPage[]
61
62
  readonly preload: Preload
63
+ readonly onThumbnailError?: (error: unknown) => void
62
64
 
63
65
  dom: Element | DocumentFragment
64
66
  contentDOM: HTMLDivElement
@@ -81,6 +83,7 @@ export class Presentation implements IDisposable<void> {
81
83
  constructor(config: PresentationConfig) {
82
84
  this.pages = config.pages
83
85
  this.preload = new Preload(this.pages)
86
+ this.onThumbnailError = config.onThumbnailError
84
87
  if (config.thumbnail) this.thumbnail = config.thumbnail
85
88
  this.dispose.add(this.preload)
86
89
  this.readonly = config.readonly ?? false
@@ -329,7 +332,11 @@ export class Presentation implements IDisposable<void> {
329
332
  url.searchParams.set('x-oss-process', 'image/resize,l_50')
330
333
  return url.toString()
331
334
  } catch (err) {
332
- console.error(err)
335
+ try {
336
+ this.onThumbnailError?.(err)
337
+ } catch {
338
+ // A diagnostic failure must not change the thumbnail fallback.
339
+ }
333
340
  return src
334
341
  }
335
342
  }
@@ -1,13 +1,15 @@
1
1
  export type SetupImageResult = "loaded" | "error" | "timeout" | "disposed";
2
2
 
3
- /** Wait for a terminal image state without blocking setup indefinitely. */
3
+ /** New lazy hosts await actual readiness; legacy hosts retain the bounded wait. */
4
4
  export const waitForCurrentPageImage = (
5
5
  getImage: () => HTMLImageElement | null,
6
6
  timeoutMs: number,
7
7
  isDisposed: () => boolean,
8
+ options?: { waitForReady: () => boolean; onTimeout: () => void },
8
9
  ): Promise<SetupImageResult> => {
9
10
  return new Promise(resolve => {
10
11
  let settled = false;
12
+ let timedOut = false;
11
13
  let pollTimer: number | undefined;
12
14
  const settle = (result: SetupImageResult) => {
13
15
  if (settled) return;
@@ -16,12 +18,17 @@ export const waitForCurrentPageImage = (
16
18
  if (pollTimer !== undefined) window.clearInterval(pollTimer);
17
19
  resolve(result);
18
20
  };
19
- const timeoutTimer = window.setTimeout(() => settle("timeout"), timeoutMs);
21
+ const timeoutTimer = window.setTimeout(() => {
22
+ timedOut = true;
23
+ options?.onTimeout();
24
+ if (!options?.waitForReady()) settle("timeout");
25
+ }, timeoutMs);
20
26
  const check = () => {
21
27
  if (isDisposed()) {
22
28
  settle("disposed");
23
29
  return;
24
30
  }
31
+ if (timedOut && !options?.waitForReady()) { settle("timeout"); return; }
25
32
  const img = getImage();
26
33
  if (img?.complete) {
27
34
  if (img.naturalWidth > 0) {