@raydiant/playlist-window-manager 0.29.1-rc1 → 0.29.2

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.
@@ -904,7 +904,7 @@ var PlaylistWindowManager = /** @class */ (function (_super) {
904
904
  };
905
905
  PlaylistWindowManager.prototype.swapWindows = function () {
906
906
  return __awaiter(this, void 0, void 0, function () {
907
- var nextPreviewPresentationId, preview, result, nextPresentation, shouldPreload, closeWindowPromise;
907
+ var nextPreviewPresentationId, preview, result, nextPresentation, shouldPreload, closeWindowPromise, windowToClose_1, incomingWindow_1, closeWindowDelayMs_1;
908
908
  var _this = this;
909
909
  return __generator(this, function (_a) {
910
910
  switch (_a.label) {
@@ -943,32 +943,42 @@ var PlaylistWindowManager = /** @class */ (function (_super) {
943
943
  }
944
944
  if (!this.currentWindow) return [3 /*break*/, 3];
945
945
  if (!(this.options.closeWindowDelayMs !== undefined)) return [3 /*break*/, 1];
946
- // Don't await here because this will cause a slight delay when playing the next window.
947
- // Instead we want to wait for window close at the end of this method.
948
- closeWindowPromise = new Promise(function (resolve) {
949
- // Capture the current window reference to close because this.currentWindow will be
950
- // set to the next window before the timeout fires.
951
- var windowToClose = _this.currentWindow;
952
- setTimeout(function () { return __awaiter(_this, void 0, void 0, function () {
953
- return __generator(this, function (_a) {
954
- switch (_a.label) {
955
- case 0:
956
- if (!windowToClose) return [3 /*break*/, 2];
957
- // We can't use this.removeCurrentWindow because the currentWindow reference has already
958
- // changed by the time the timeout fires.
959
- return [4 /*yield*/, windowToClose.remove()];
960
- case 1:
961
- // We can't use this.removeCurrentWindow because the currentWindow reference has already
962
- // changed by the time the timeout fires.
963
- _a.sent();
964
- _a.label = 2;
965
- case 2:
966
- resolve(undefined);
967
- return [2 /*return*/];
968
- }
969
- });
970
- }); }, _this.options.closeWindowDelayMs);
971
- });
946
+ windowToClose_1 = this.currentWindow;
947
+ incomingWindow_1 = this.nextWindow;
948
+ closeWindowDelayMs_1 = this.options.closeWindowDelayMs;
949
+ // The outgoing window stays on screen only as a visual backdrop from
950
+ // here on: stop delivering its events (a late complete or error must
951
+ // not end the incoming presentation) and mute it so audio does not
952
+ // bleed into the incoming presentation during the overlap.
953
+ windowToClose_1.detachCallbacks();
954
+ windowToClose_1.mute();
955
+ closeWindowPromise = (function () { return __awaiter(_this, void 0, void 0, function () {
956
+ return __generator(this, function (_a) {
957
+ switch (_a.label) {
958
+ case 0:
959
+ if (!incomingWindow_1) return [3 /*break*/, 2];
960
+ return [4 /*yield*/, incomingWindow_1.waitForFirstPaint(closeWindowDelayMs_1)];
961
+ case 1:
962
+ _a.sent();
963
+ return [3 /*break*/, 4];
964
+ case 2: return [4 /*yield*/, new Promise(function (resolve) {
965
+ return setTimeout(resolve, closeWindowDelayMs_1);
966
+ })];
967
+ case 3:
968
+ _a.sent();
969
+ _a.label = 4;
970
+ case 4:
971
+ // We can't use this.removeCurrentWindow because the currentWindow
972
+ // reference has already changed by the time the wait finishes.
973
+ return [4 /*yield*/, windowToClose_1.remove()];
974
+ case 5:
975
+ // We can't use this.removeCurrentWindow because the currentWindow
976
+ // reference has already changed by the time the wait finishes.
977
+ _a.sent();
978
+ return [2 /*return*/];
979
+ }
980
+ });
981
+ }); })();
972
982
  return [3 /*break*/, 3];
973
983
  case 1:
974
984
  this.currentWindow.hide();
@@ -54,6 +54,8 @@ export default class PresentationWindow {
54
54
  private disableInterruptLockCallback;
55
55
  private analyticsEventCallback;
56
56
  private chargeRequestCallback;
57
+ private didPaintSinceLastPlay;
58
+ private firstPaintResolvers;
57
59
  private builderState;
58
60
  private presentation;
59
61
  private playlistWindowManagerState;
@@ -81,6 +83,10 @@ export default class PresentationWindow {
81
83
  pause(): void;
82
84
  resume(): void;
83
85
  reset(): void;
86
+ waitForFirstPaint(timeoutMs: number): Promise<void>;
87
+ detachCallbacks(): void;
88
+ mute(): void;
89
+ private resolveFirstPaintWaiters;
84
90
  onComplete(callback: (() => void) | null): void;
85
91
  onError(callback: ((err: Error) => void) | null): void;
86
92
  onEnableInterruptLock(callback: (() => void) | null): void;
@@ -81,7 +81,7 @@ var PlaylistWindowManager_1 = __importDefault(require("./PlaylistWindowManager")
81
81
  var WindowTimeoutError_1 = __importDefault(require("./WindowTimeoutError"));
82
82
  var WindowLoadError_1 = __importDefault(require("./WindowLoadError"));
83
83
  // TODO: Automatically inject latest version when building in CI.
84
- var defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.0/index.html';
84
+ var defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.1/index.html';
85
85
  var isValidNumber = function (value) {
86
86
  return typeof value === 'number' && !isNaN(value);
87
87
  };
@@ -140,6 +140,8 @@ var PresentationWindow = /** @class */ (function () {
140
140
  this.disableInterruptLockCallback = null;
141
141
  this.analyticsEventCallback = null;
142
142
  this.chargeRequestCallback = null;
143
+ this.didPaintSinceLastPlay = false;
144
+ this.firstPaintResolvers = [];
143
145
  this.forceMute = false;
144
146
  this.error = null;
145
147
  this.workerState = {};
@@ -382,6 +384,11 @@ var PresentationWindow = /** @class */ (function () {
382
384
  this.logInfo("Received 'presentation_complete' from window '".concat(windowId, "'"));
383
385
  this.handleComplete();
384
386
  }
387
+ if (type === 'first_painted') {
388
+ this.logDebug("Received 'first_painted' from window '".concat(windowId, "'"));
389
+ this.didPaintSinceLastPlay = true;
390
+ this.resolveFirstPaintWaiters();
391
+ }
385
392
  if (type === 'enable_interrupt_lock') {
386
393
  this.logInfo("Received 'enable_interrupt_lock' from window '".concat(windowId, "'"));
387
394
  this.enableInteruptLock();
@@ -510,6 +517,9 @@ var PresentationWindow = /** @class */ (function () {
510
517
  clearTimeout(this.configurableDurationTimeout);
511
518
  this.configurableDurationTimeout = null;
512
519
  }
520
+ // Release anything waiting on a paint signal so callers never hang on a
521
+ // window that is going away.
522
+ this.resolveFirstPaintWaiters();
513
523
  // Remove any child window managers.
514
524
  Object.values(this.windowManagers).forEach(function (wm) {
515
525
  wm.destroy();
@@ -540,6 +550,9 @@ var PresentationWindow = /** @class */ (function () {
540
550
  PresentationWindow.prototype.play = function () {
541
551
  var _this = this;
542
552
  var _a = this.options, applicationVersion = _a.applicationVersion, presentation = _a.presentation;
553
+ // Each play starts a new paint cycle; 'first_painted' arrives once the
554
+ // window has presented a frame after this play.
555
+ this.didPaintSinceLastPlay = false;
543
556
  if (applicationVersion.configurableDuration) {
544
557
  var durationMs = Math.max(presentation.duration * 1000, 5000);
545
558
  if (this.configurableDurationTimeout) {
@@ -579,6 +592,55 @@ var PresentationWindow = /** @class */ (function () {
579
592
  PresentationWindow.prototype.reset = function () {
580
593
  this.didReceiveComplete = false;
581
594
  };
595
+ // Resolves when the window reports its first painted frame after the most
596
+ // recent play, or after timeoutMs, whichever comes first. Only app-renderer
597
+ // windows emit the paint signal; anything else exits via the timeout.
598
+ PresentationWindow.prototype.waitForFirstPaint = function (timeoutMs) {
599
+ var _this = this;
600
+ if (this.didPaintSinceLastPlay) {
601
+ return Promise.resolve();
602
+ }
603
+ return new Promise(function (resolve) {
604
+ var settled = false;
605
+ var settle = function () {
606
+ if (settled)
607
+ return;
608
+ settled = true;
609
+ clearTimeout(timer);
610
+ var index = _this.firstPaintResolvers.indexOf(settle);
611
+ if (index !== -1) {
612
+ _this.firstPaintResolvers.splice(index, 1);
613
+ }
614
+ resolve();
615
+ };
616
+ var timer = setTimeout(settle, timeoutMs);
617
+ _this.firstPaintResolvers.push(settle);
618
+ });
619
+ };
620
+ // Stop delivering this window's events (complete, error, analytics, charge,
621
+ // interrupt locks) to the window manager. Used when the outgoing window is
622
+ // kept on screen only as a visual backdrop during a swap overlap — a late
623
+ // event from it must not be attributed to the incoming window.
624
+ PresentationWindow.prototype.detachCallbacks = function () {
625
+ this.readyCallback = null;
626
+ this.completeCallback = null;
627
+ this.errorCallback = null;
628
+ this.enableInterruptLockCallback = null;
629
+ this.disableInterruptLockCallback = null;
630
+ this.analyticsEventCallback = null;
631
+ this.chargeRequestCallback = null;
632
+ };
633
+ // Mute the window's audio channel. On the IFrameRenderer this is best-effort
634
+ // (iframes expose no mute API); BrowserWindow renderers mute for real.
635
+ PresentationWindow.prototype.mute = function () {
636
+ var _a = this.options, windowId = _a.windowId, renderer = _a.renderer;
637
+ renderer.muteWindow(windowId);
638
+ };
639
+ PresentationWindow.prototype.resolveFirstPaintWaiters = function () {
640
+ var resolvers = this.firstPaintResolvers;
641
+ this.firstPaintResolvers = [];
642
+ resolvers.forEach(function (resolve) { return resolve(); });
643
+ };
582
644
  PresentationWindow.prototype.onComplete = function (callback) {
583
645
  this.completeCallback = callback;
584
646
  };
@@ -431,3 +431,189 @@ describe('interrupt locks', function () {
431
431
  }); });
432
432
  });
433
433
  // TODO: Backfill tests for all legacy and embedded apps.
434
+ describe('waitForFirstPaint', function () {
435
+ // The default (non-embedded) window resolves load() only after the app
436
+ // reports ready, so drive the handshake through the mock renderer.
437
+ var loadPresentationWindow = function (testRenderer, window) { return __awaiter(void 0, void 0, void 0, function () {
438
+ var loadPromise;
439
+ return __generator(this, function (_a) {
440
+ switch (_a.label) {
441
+ case 0:
442
+ loadPromise = window.load();
443
+ return [4 /*yield*/, delay(0)];
444
+ case 1:
445
+ _a.sent();
446
+ testRenderer.runReceiveMessageCallback('loaded', undefined);
447
+ testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
448
+ return [4 /*yield*/, loadPromise];
449
+ case 2:
450
+ _a.sent();
451
+ return [2 /*return*/];
452
+ }
453
+ });
454
+ }); };
455
+ test('resolves when the window reports first_painted', function () { return __awaiter(void 0, void 0, void 0, function () {
456
+ var testRenderer, window, resolved, wait;
457
+ return __generator(this, function (_a) {
458
+ switch (_a.label) {
459
+ case 0:
460
+ testRenderer = (0, testUtils_1.createRenderer)();
461
+ window = (0, testUtils_1.createPresentationWindow)({
462
+ windowId: 'test-first-paint',
463
+ renderer: testRenderer,
464
+ });
465
+ return [4 /*yield*/, loadPresentationWindow(testRenderer, window)];
466
+ case 1:
467
+ _a.sent();
468
+ resolved = false;
469
+ wait = window.waitForFirstPaint(5000).then(function () {
470
+ resolved = true;
471
+ });
472
+ return [4 /*yield*/, delay(10)];
473
+ case 2:
474
+ _a.sent();
475
+ expect(resolved).toBe(false);
476
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
477
+ return [4 /*yield*/, wait];
478
+ case 3:
479
+ _a.sent();
480
+ expect(resolved).toBe(true);
481
+ return [2 /*return*/];
482
+ }
483
+ });
484
+ }); });
485
+ test('falls back to the timeout when no paint signal arrives', function () { return __awaiter(void 0, void 0, void 0, function () {
486
+ var testRenderer, window;
487
+ return __generator(this, function (_a) {
488
+ switch (_a.label) {
489
+ case 0:
490
+ testRenderer = (0, testUtils_1.createRenderer)();
491
+ window = (0, testUtils_1.createPresentationWindow)({
492
+ windowId: 'test-first-paint-timeout',
493
+ renderer: testRenderer,
494
+ });
495
+ return [4 /*yield*/, loadPresentationWindow(testRenderer, window)];
496
+ case 1:
497
+ _a.sent();
498
+ // Resolves via the timeout even though first_painted never arrives.
499
+ return [4 /*yield*/, window.waitForFirstPaint(20)];
500
+ case 2:
501
+ // Resolves via the timeout even though first_painted never arrives.
502
+ _a.sent();
503
+ return [2 /*return*/];
504
+ }
505
+ });
506
+ }); });
507
+ test('resolves immediately when the window already painted since the last play', function () { return __awaiter(void 0, void 0, void 0, function () {
508
+ var testRenderer, window, resolved;
509
+ return __generator(this, function (_a) {
510
+ switch (_a.label) {
511
+ case 0:
512
+ testRenderer = (0, testUtils_1.createRenderer)();
513
+ window = (0, testUtils_1.createPresentationWindow)({
514
+ windowId: 'test-first-paint-immediate',
515
+ renderer: testRenderer,
516
+ });
517
+ return [4 /*yield*/, loadPresentationWindow(testRenderer, window)];
518
+ case 1:
519
+ _a.sent();
520
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
521
+ resolved = false;
522
+ window.waitForFirstPaint(5000).then(function () {
523
+ resolved = true;
524
+ });
525
+ return [4 /*yield*/, Promise.resolve()];
526
+ case 2:
527
+ _a.sent();
528
+ expect(resolved).toBe(true);
529
+ return [2 /*return*/];
530
+ }
531
+ });
532
+ }); });
533
+ test('play starts a new paint cycle', function () { return __awaiter(void 0, void 0, void 0, function () {
534
+ var testRenderer, window, resolved, wait;
535
+ return __generator(this, function (_a) {
536
+ switch (_a.label) {
537
+ case 0:
538
+ testRenderer = (0, testUtils_1.createRenderer)();
539
+ window = (0, testUtils_1.createPresentationWindow)({
540
+ windowId: 'test-first-paint-replay',
541
+ renderer: testRenderer,
542
+ });
543
+ return [4 /*yield*/, loadPresentationWindow(testRenderer, window)];
544
+ case 1:
545
+ _a.sent();
546
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
547
+ window.play();
548
+ resolved = false;
549
+ wait = window.waitForFirstPaint(5000).then(function () {
550
+ resolved = true;
551
+ });
552
+ return [4 /*yield*/, delay(10)];
553
+ case 2:
554
+ _a.sent();
555
+ expect(resolved).toBe(false);
556
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
557
+ return [4 /*yield*/, wait];
558
+ case 3:
559
+ _a.sent();
560
+ expect(resolved).toBe(true);
561
+ return [2 /*return*/];
562
+ }
563
+ });
564
+ }); });
565
+ test('resolves pending waiters when the window is removed', function () { return __awaiter(void 0, void 0, void 0, function () {
566
+ var testRenderer, window, wait;
567
+ return __generator(this, function (_a) {
568
+ switch (_a.label) {
569
+ case 0:
570
+ testRenderer = (0, testUtils_1.createRenderer)();
571
+ window = (0, testUtils_1.createPresentationWindow)({
572
+ windowId: 'test-first-paint-removed',
573
+ renderer: testRenderer,
574
+ });
575
+ return [4 /*yield*/, loadPresentationWindow(testRenderer, window)];
576
+ case 1:
577
+ _a.sent();
578
+ wait = window.waitForFirstPaint(5000);
579
+ return [4 /*yield*/, window.remove()];
580
+ case 2:
581
+ _a.sent();
582
+ return [4 /*yield*/, wait];
583
+ case 3:
584
+ _a.sent();
585
+ return [2 /*return*/];
586
+ }
587
+ });
588
+ }); });
589
+ });
590
+ describe('detachCallbacks', function () {
591
+ test('stops delivering events to the window manager', function () { return __awaiter(void 0, void 0, void 0, function () {
592
+ var testRenderer, window, loadPromise, onComplete;
593
+ return __generator(this, function (_a) {
594
+ switch (_a.label) {
595
+ case 0:
596
+ testRenderer = (0, testUtils_1.createRenderer)();
597
+ window = (0, testUtils_1.createPresentationWindow)({
598
+ windowId: 'test-detach',
599
+ renderer: testRenderer,
600
+ });
601
+ loadPromise = window.load();
602
+ return [4 /*yield*/, delay(0)];
603
+ case 1:
604
+ _a.sent();
605
+ testRenderer.runReceiveMessageCallback('loaded', undefined);
606
+ testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
607
+ return [4 /*yield*/, loadPromise];
608
+ case 2:
609
+ _a.sent();
610
+ onComplete = jest.fn();
611
+ window.onComplete(onComplete);
612
+ window.detachCallbacks();
613
+ testRenderer.runReceiveMessageCallback('presentation_complete', undefined);
614
+ expect(onComplete).not.toHaveBeenCalled();
615
+ return [2 /*return*/];
616
+ }
617
+ });
618
+ }); });
619
+ });
package/build/preload.js CHANGED
@@ -92,7 +92,9 @@ Object.defineProperty(window, 'removeEventListener', {
92
92
  // Hide the Cast button on video elements to prevent casting from the device.
93
93
  // After trying many different approaches including flags, chrome policies and
94
94
  // blocking ports, the only reliable way to hide the Cast button is via CSS without
95
- // updating every video element with disableRemotePlayback.
95
+ // updating every video element with disableRemotePlayback. This issue has only been
96
+ // observed on older devices like the IntelComputeStick and Vivostick, not newer devices
97
+ // like the N100 or NUC.
96
98
  window.addEventListener('DOMContentLoaded', function () {
97
99
  var style = document.createElement('style');
98
100
  style.textContent = "\n video::-internal-media-controls-overlay-cast-button {\n display: none !important;\n }\n ";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raydiant/playlist-window-manager",
3
- "version": "0.29.1-rc1",
3
+ "version": "0.29.2",
4
4
  "description": "Window manager for playlist playback.",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
@@ -754,21 +754,38 @@ export default class PlaylistWindowManager extends EventEmitter {
754
754
  let closeWindowPromise;
755
755
  if (this.currentWindow) {
756
756
  if (this.options.closeWindowDelayMs !== undefined) {
757
+ // Keep the outgoing window on screen until the incoming one reports
758
+ // its first painted frame. A newly shown window still needs a few
759
+ // frames to rasterize, and removing the outgoing window before that
760
+ // exposes the transparent window stack (a white flash on device).
761
+ // closeWindowDelayMs is the ceiling for windows that never report a
762
+ // paint (embedded apps, error states).
757
763
  // Don't await here because this will cause a slight delay when playing the next window.
758
764
  // Instead we want to wait for window close at the end of this method.
759
- closeWindowPromise = new Promise((resolve) => {
760
- // Capture the current window reference to close because this.currentWindow will be
761
- // set to the next window before the timeout fires.
762
- const windowToClose = this.currentWindow;
763
- setTimeout(async () => {
764
- if (windowToClose) {
765
- // We can't use this.removeCurrentWindow because the currentWindow reference has already
766
- // changed by the time the timeout fires.
767
- await windowToClose.remove();
768
- }
769
- resolve(undefined);
770
- }, this.options.closeWindowDelayMs);
771
- });
765
+ // Capture both window references because this.currentWindow and
766
+ // this.nextWindow change before the wait finishes.
767
+ const windowToClose = this.currentWindow;
768
+ const incomingWindow = this.nextWindow;
769
+ const closeWindowDelayMs = this.options.closeWindowDelayMs;
770
+
771
+ // The outgoing window stays on screen only as a visual backdrop from
772
+ // here on: stop delivering its events (a late complete or error must
773
+ // not end the incoming presentation) and mute it so audio does not
774
+ // bleed into the incoming presentation during the overlap.
775
+ windowToClose.detachCallbacks();
776
+ windowToClose.mute();
777
+ closeWindowPromise = (async () => {
778
+ if (incomingWindow) {
779
+ await incomingWindow.waitForFirstPaint(closeWindowDelayMs);
780
+ } else {
781
+ await new Promise((resolve) =>
782
+ setTimeout(resolve, closeWindowDelayMs)
783
+ );
784
+ }
785
+ // We can't use this.removeCurrentWindow because the currentWindow
786
+ // reference has already changed by the time the wait finishes.
787
+ await windowToClose.remove();
788
+ })();
772
789
  } else {
773
790
  this.currentWindow.hide();
774
791
  await this.removeCurrentWindow();
@@ -454,3 +454,127 @@ describe('interrupt locks', () => {
454
454
  });
455
455
 
456
456
  // TODO: Backfill tests for all legacy and embedded apps.
457
+
458
+ describe('waitForFirstPaint', () => {
459
+ // The default (non-embedded) window resolves load() only after the app
460
+ // reports ready, so drive the handshake through the mock renderer.
461
+ const loadPresentationWindow = async (
462
+ testRenderer: ReturnType<typeof createRenderer>,
463
+ window: ReturnType<typeof createPresentationWindow>
464
+ ) => {
465
+ const loadPromise = window.load();
466
+ await delay(0);
467
+ testRenderer.runReceiveMessageCallback('loaded', undefined);
468
+ testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
469
+ await loadPromise;
470
+ };
471
+
472
+ test('resolves when the window reports first_painted', async () => {
473
+ const testRenderer = createRenderer();
474
+ const window = createPresentationWindow({
475
+ windowId: 'test-first-paint',
476
+ renderer: testRenderer,
477
+ });
478
+ await loadPresentationWindow(testRenderer, window);
479
+
480
+ let resolved = false;
481
+ const wait = window.waitForFirstPaint(5000).then(() => {
482
+ resolved = true;
483
+ });
484
+
485
+ await delay(10);
486
+ expect(resolved).toBe(false);
487
+
488
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
489
+ await wait;
490
+ expect(resolved).toBe(true);
491
+ });
492
+
493
+ test('falls back to the timeout when no paint signal arrives', async () => {
494
+ const testRenderer = createRenderer();
495
+ const window = createPresentationWindow({
496
+ windowId: 'test-first-paint-timeout',
497
+ renderer: testRenderer,
498
+ });
499
+ await loadPresentationWindow(testRenderer, window);
500
+
501
+ // Resolves via the timeout even though first_painted never arrives.
502
+ await window.waitForFirstPaint(20);
503
+ });
504
+
505
+ test('resolves immediately when the window already painted since the last play', async () => {
506
+ const testRenderer = createRenderer();
507
+ const window = createPresentationWindow({
508
+ windowId: 'test-first-paint-immediate',
509
+ renderer: testRenderer,
510
+ });
511
+ await loadPresentationWindow(testRenderer, window);
512
+
513
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
514
+
515
+ let resolved = false;
516
+ window.waitForFirstPaint(5000).then(() => {
517
+ resolved = true;
518
+ });
519
+ await Promise.resolve();
520
+ expect(resolved).toBe(true);
521
+ });
522
+
523
+ test('play starts a new paint cycle', async () => {
524
+ const testRenderer = createRenderer();
525
+ const window = createPresentationWindow({
526
+ windowId: 'test-first-paint-replay',
527
+ renderer: testRenderer,
528
+ });
529
+ await loadPresentationWindow(testRenderer, window);
530
+
531
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
532
+ window.play();
533
+
534
+ let resolved = false;
535
+ const wait = window.waitForFirstPaint(5000).then(() => {
536
+ resolved = true;
537
+ });
538
+ await delay(10);
539
+ expect(resolved).toBe(false);
540
+
541
+ testRenderer.runReceiveMessageCallback('first_painted', undefined);
542
+ await wait;
543
+ expect(resolved).toBe(true);
544
+ });
545
+
546
+ test('resolves pending waiters when the window is removed', async () => {
547
+ const testRenderer = createRenderer();
548
+ const window = createPresentationWindow({
549
+ windowId: 'test-first-paint-removed',
550
+ renderer: testRenderer,
551
+ });
552
+ await loadPresentationWindow(testRenderer, window);
553
+
554
+ const wait = window.waitForFirstPaint(5000);
555
+ await window.remove();
556
+ await wait;
557
+ });
558
+ });
559
+
560
+ describe('detachCallbacks', () => {
561
+ test('stops delivering events to the window manager', async () => {
562
+ const testRenderer = createRenderer();
563
+ const window = createPresentationWindow({
564
+ windowId: 'test-detach',
565
+ renderer: testRenderer,
566
+ });
567
+ const loadPromise = window.load();
568
+ await delay(0);
569
+ testRenderer.runReceiveMessageCallback('loaded', undefined);
570
+ testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
571
+ await loadPromise;
572
+
573
+ const onComplete = jest.fn();
574
+ window.onComplete(onComplete);
575
+ window.detachCallbacks();
576
+
577
+ testRenderer.runReceiveMessageCallback('presentation_complete', undefined);
578
+ expect(onComplete).not.toHaveBeenCalled();
579
+ });
580
+ });
@@ -63,7 +63,7 @@ export interface PresentationWindowOptions {
63
63
  }
64
64
 
65
65
  // TODO: Automatically inject latest version when building in CI.
66
- const defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.0/index.html';
66
+ const defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.1/index.html';
67
67
 
68
68
  const isValidNumber = (value: any) =>
69
69
  typeof value === 'number' && !isNaN(value);
@@ -130,6 +130,8 @@ export default class PresentationWindow {
130
130
  null;
131
131
  private chargeRequestCallback: ((request: ChargeRequest) => void) | null =
132
132
  null;
133
+ private didPaintSinceLastPlay = false;
134
+ private firstPaintResolvers: Array<() => void> = [];
133
135
 
134
136
  private builderState: BuilderState | undefined;
135
137
  private presentation: Presentation;
@@ -241,6 +243,10 @@ export default class PresentationWindow {
241
243
  this.configurableDurationTimeout = null;
242
244
  }
243
245
 
246
+ // Release anything waiting on a paint signal so callers never hang on a
247
+ // window that is going away.
248
+ this.resolveFirstPaintWaiters();
249
+
244
250
  // Remove any child window managers.
245
251
  Object.values(this.windowManagers).forEach((wm) => {
246
252
  wm.destroy();
@@ -269,6 +275,10 @@ export default class PresentationWindow {
269
275
  public play(): void {
270
276
  const { applicationVersion, presentation } = this.options;
271
277
 
278
+ // Each play starts a new paint cycle; 'first_painted' arrives once the
279
+ // window has presented a frame after this play.
280
+ this.didPaintSinceLastPlay = false;
281
+
272
282
  if (applicationVersion.configurableDuration) {
273
283
  const durationMs = Math.max(presentation.duration * 1000, 5000);
274
284
  if (this.configurableDurationTimeout) {
@@ -314,6 +324,57 @@ export default class PresentationWindow {
314
324
  this.didReceiveComplete = false;
315
325
  }
316
326
 
327
+ // Resolves when the window reports its first painted frame after the most
328
+ // recent play, or after timeoutMs, whichever comes first. Only app-renderer
329
+ // windows emit the paint signal; anything else exits via the timeout.
330
+ public waitForFirstPaint(timeoutMs: number): Promise<void> {
331
+ if (this.didPaintSinceLastPlay) {
332
+ return Promise.resolve();
333
+ }
334
+ return new Promise((resolve) => {
335
+ let settled = false;
336
+ const settle = () => {
337
+ if (settled) return;
338
+ settled = true;
339
+ clearTimeout(timer);
340
+ const index = this.firstPaintResolvers.indexOf(settle);
341
+ if (index !== -1) {
342
+ this.firstPaintResolvers.splice(index, 1);
343
+ }
344
+ resolve();
345
+ };
346
+ const timer = setTimeout(settle, timeoutMs);
347
+ this.firstPaintResolvers.push(settle);
348
+ });
349
+ }
350
+
351
+ // Stop delivering this window's events (complete, error, analytics, charge,
352
+ // interrupt locks) to the window manager. Used when the outgoing window is
353
+ // kept on screen only as a visual backdrop during a swap overlap — a late
354
+ // event from it must not be attributed to the incoming window.
355
+ public detachCallbacks(): void {
356
+ this.readyCallback = null;
357
+ this.completeCallback = null;
358
+ this.errorCallback = null;
359
+ this.enableInterruptLockCallback = null;
360
+ this.disableInterruptLockCallback = null;
361
+ this.analyticsEventCallback = null;
362
+ this.chargeRequestCallback = null;
363
+ }
364
+
365
+ // Mute the window's audio channel. On the IFrameRenderer this is best-effort
366
+ // (iframes expose no mute API); BrowserWindow renderers mute for real.
367
+ public mute(): void {
368
+ const { windowId, renderer } = this.options;
369
+ renderer.muteWindow(windowId);
370
+ }
371
+
372
+ private resolveFirstPaintWaiters() {
373
+ const resolvers = this.firstPaintResolvers;
374
+ this.firstPaintResolvers = [];
375
+ resolvers.forEach((resolve) => resolve());
376
+ }
377
+
317
378
  public onComplete(callback: (() => void) | null) {
318
379
  this.completeCallback = callback;
319
380
  }
@@ -1300,6 +1361,12 @@ export default class PresentationWindow {
1300
1361
  this.handleComplete();
1301
1362
  }
1302
1363
 
1364
+ if (type === 'first_painted') {
1365
+ this.logDebug(`Received 'first_painted' from window '${windowId}'`);
1366
+ this.didPaintSinceLastPlay = true;
1367
+ this.resolveFirstPaintWaiters();
1368
+ }
1369
+
1303
1370
  if (type === 'enable_interrupt_lock') {
1304
1371
  this.logInfo(
1305
1372
  `Received 'enable_interrupt_lock' from window '${windowId}'`
package/src/preload.ts CHANGED
@@ -110,7 +110,9 @@ Object.defineProperty(window, 'removeEventListener', {
110
110
  // Hide the Cast button on video elements to prevent casting from the device.
111
111
  // After trying many different approaches including flags, chrome policies and
112
112
  // blocking ports, the only reliable way to hide the Cast button is via CSS without
113
- // updating every video element with disableRemotePlayback.
113
+ // updating every video element with disableRemotePlayback. This issue has only been
114
+ // observed on older devices like the IntelComputeStick and Vivostick, not newer devices
115
+ // like the N100 or NUC.
114
116
  window.addEventListener('DOMContentLoaded', () => {
115
117
  const style = document.createElement('style');
116
118
  style.textContent = `