@raydiant/playlist-window-manager 0.29.1 → 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.
- package/build/PlaylistWindowManager.js +37 -27
- package/build/PresentationWindow.d.ts +6 -0
- package/build/PresentationWindow.js +63 -1
- package/build/PresentationWindow.test.js +186 -0
- package/package.json +1 -1
- package/src/PlaylistWindowManager.ts +30 -13
- package/src/PresentationWindow.test.ts +124 -0
- package/src/PresentationWindow.ts +68 -1
|
@@ -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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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.
|
|
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/package.json
CHANGED
|
@@ -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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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.
|
|
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}'`
|