@raydiant/playlist-window-manager 0.29.1 → 0.30.0
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/IFrameRenderer.d.ts +1 -0
- package/build/IFrameRenderer.js +14 -0
- package/build/IFrameRenderer.test.d.ts +1 -0
- package/build/IFrameRenderer.test.js +70 -0
- package/build/PlaylistWindowManager.d.ts +7 -0
- package/build/PlaylistWindowManager.js +186 -65
- package/build/PlaylistWindowManager.test.d.ts +1 -0
- package/build/PlaylistWindowManager.test.js +1763 -0
- package/build/PresentationWindow.d.ts +37 -1
- package/build/PresentationWindow.js +601 -31
- package/build/PresentationWindow.test.js +509 -0
- package/build/SurfaceBroker.d.ts +30 -0
- package/build/SurfaceBroker.js +311 -0
- package/build/SurfaceBroker.test.d.ts +1 -0
- package/build/SurfaceBroker.test.js +432 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/surfaces.d.ts +52 -0
- package/build/surfaces.js +15 -0
- package/build/testUtils.d.ts +56 -0
- package/build/testUtils.js +327 -5
- package/build/types.d.ts +1 -0
- package/package.json +2 -1
- package/src/IFrameRenderer.test.ts +88 -0
- package/src/IFrameRenderer.ts +21 -0
- package/src/PlaylistWindowManager.test.ts +1466 -0
- package/src/PlaylistWindowManager.ts +164 -20
- package/src/PresentationWindow.test.ts +371 -0
- package/src/PresentationWindow.ts +712 -4
- package/src/SurfaceBroker.test.ts +278 -0
- package/src/SurfaceBroker.ts +281 -0
- package/src/__snapshots__/PlaylistWindowManager.test.ts.snap +26 -0
- package/src/index.ts +1 -0
- package/src/surfaces.ts +82 -0
- package/src/testUtils.ts +335 -1
- package/src/types.ts +5 -0
|
@@ -19,6 +19,11 @@ import PlaylistWindowManagerState from './PlaylistWindowManagerState';
|
|
|
19
19
|
import WindowUnresponsiveError from './WindowUnresponsiveError';
|
|
20
20
|
import WindowTimeoutError from './WindowTimeoutError';
|
|
21
21
|
import WindowLoadError from './WindowLoadError';
|
|
22
|
+
import SurfaceBroker, {
|
|
23
|
+
DEFAULT_SHOW_AFTER_REVEAL_MS,
|
|
24
|
+
DEFAULT_SURFACE_SWAP_DELAY_MS,
|
|
25
|
+
} from './SurfaceBroker';
|
|
26
|
+
import { SurfaceHost } from './surfaces';
|
|
22
27
|
|
|
23
28
|
export interface PlaylistWindowManagerOptions {
|
|
24
29
|
// The rule engine
|
|
@@ -83,6 +88,12 @@ export interface PlaylistWindowManagerOptions {
|
|
|
83
88
|
|
|
84
89
|
// Called when a window needs to check if the device is online.
|
|
85
90
|
getOnlineStatus?: () => boolean;
|
|
91
|
+
|
|
92
|
+
// The host's video surface, bound by the player, when the host can draw
|
|
93
|
+
// one. Absent, nothing changes: apps are offered no surface and keep
|
|
94
|
+
// <video>. A zone's manager is never given one. Its showAfterRevealMs is
|
|
95
|
+
// the device's calibration for a swap's reveal; see SurfaceHost.
|
|
96
|
+
surfaces?: SurfaceHost;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
// Wait at least 3 seconds before restarting or moving onto the next window
|
|
@@ -118,6 +129,10 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
118
129
|
private isFocused: boolean = false;
|
|
119
130
|
|
|
120
131
|
private workerState: WorkerState = {};
|
|
132
|
+
private surfaceBroker: SurfaceBroker | null = null;
|
|
133
|
+
// The surface swap the next window's preload waits to finish. A newer
|
|
134
|
+
// preload or the next window's removal clears it, cancelling that preload.
|
|
135
|
+
private pendingPreload: Promise<void> | null = null;
|
|
121
136
|
|
|
122
137
|
constructor(private options: PlaylistWindowManagerOptions) {
|
|
123
138
|
super();
|
|
@@ -127,6 +142,9 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
127
142
|
this.bounds = options.bounds;
|
|
128
143
|
this.themeOverride = options.themeOverride;
|
|
129
144
|
this.forceMute = options.forceMute || false;
|
|
145
|
+
if (options.surfaces) {
|
|
146
|
+
this.surfaceBroker = new SurfaceBroker(options.surfaces, options.logger);
|
|
147
|
+
}
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
private currentPresentationId(): string | null {
|
|
@@ -357,6 +375,10 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
357
375
|
clearTimeout(this.currentPresentationCompleteTimeout);
|
|
358
376
|
}
|
|
359
377
|
await Promise.all([this.removeCurrentWindow(), this.removeNextWindow()]);
|
|
378
|
+
if (this.surfaceBroker) {
|
|
379
|
+
// Also closes a surface whose window is still leaving a swap.
|
|
380
|
+
await this.surfaceBroker.closeAll();
|
|
381
|
+
}
|
|
360
382
|
}
|
|
361
383
|
|
|
362
384
|
public updateBounds(bounds: WindowBounds) {
|
|
@@ -571,9 +593,29 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
571
593
|
workerState: this.workerState,
|
|
572
594
|
getTerminal: this.options.getTerminal,
|
|
573
595
|
getOnlineStatus: this.options.getOnlineStatus,
|
|
596
|
+
...(this.surfaceBroker
|
|
597
|
+
? {
|
|
598
|
+
surfaceBroker: this.surfaceBroker,
|
|
599
|
+
surfaceShowDeadlineMs: this.surfaceSwapDelayMs(),
|
|
600
|
+
}
|
|
601
|
+
: {}),
|
|
574
602
|
});
|
|
575
603
|
}
|
|
576
604
|
|
|
605
|
+
private showAfterRevealMs() {
|
|
606
|
+
const { surfaces } = this.options;
|
|
607
|
+
return surfaces && surfaces.showAfterRevealMs !== undefined
|
|
608
|
+
? surfaces.showAfterRevealMs
|
|
609
|
+
: DEFAULT_SHOW_AFTER_REVEAL_MS;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private surfaceSwapDelayMs() {
|
|
613
|
+
const { closeWindowDelayMs } = this.options;
|
|
614
|
+
return closeWindowDelayMs !== undefined
|
|
615
|
+
? closeWindowDelayMs
|
|
616
|
+
: DEFAULT_SURFACE_SWAP_DELAY_MS;
|
|
617
|
+
}
|
|
618
|
+
|
|
577
619
|
private async loadCurrentWindow() {
|
|
578
620
|
const { logger } = this.options;
|
|
579
621
|
|
|
@@ -620,6 +662,10 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
620
662
|
private async preloadNextWindow() {
|
|
621
663
|
const { logger } = this.options;
|
|
622
664
|
|
|
665
|
+
// A publish, a preview change or a complete preloads now, in place of a
|
|
666
|
+
// preload still waiting for its surface swap.
|
|
667
|
+
this.pendingPreload = null;
|
|
668
|
+
|
|
623
669
|
if (!this.nextPresentation) {
|
|
624
670
|
logger.warn(
|
|
625
671
|
`Attempted to preload next window but nextPresentation not set`
|
|
@@ -699,6 +745,7 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
699
745
|
}
|
|
700
746
|
|
|
701
747
|
private async removeNextWindow() {
|
|
748
|
+
this.pendingPreload = null;
|
|
702
749
|
if (this.nextWindow) {
|
|
703
750
|
await this.nextWindow.remove();
|
|
704
751
|
}
|
|
@@ -742,33 +789,62 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
742
789
|
// Clear the pending interrupt whenever we swap the current window for the next one.
|
|
743
790
|
this.hasPendingInterrupt = false;
|
|
744
791
|
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
792
|
+
// Only a manager given a surface host, swapping a window that holds a
|
|
793
|
+
// surface, takes the surface path; every other swap runs today's code.
|
|
794
|
+
const isSurfaceSwap =
|
|
795
|
+
!!this.surfaceBroker &&
|
|
796
|
+
((!!this.currentWindow && this.currentWindow.holdsSurface()) ||
|
|
797
|
+
(!!this.nextWindow && this.nextWindow.holdsSurface()));
|
|
798
|
+
|
|
799
|
+
let closeWindowPromise;
|
|
800
|
+
if (isSurfaceSwap) {
|
|
801
|
+
closeWindowPromise = this.swapWithSurfaces(
|
|
802
|
+
this.currentWindow,
|
|
803
|
+
this.nextWindow
|
|
804
|
+
);
|
|
805
|
+
} else if (this.nextWindow) {
|
|
806
|
+
// Next window is ready, show the next one, hide the current window, and
|
|
807
|
+
// then remove the current window. Show the next window before hiding the
|
|
808
|
+
// current to avoid flashes when rendering with BrowserWindows on device.
|
|
749
809
|
this.nextWindow.show(this.isFocused);
|
|
750
810
|
}
|
|
751
811
|
|
|
752
812
|
// If current window isn't defined we don't need to hide it but we still
|
|
753
813
|
// want to make the next window the new current window.
|
|
754
|
-
|
|
755
|
-
if (this.currentWindow) {
|
|
814
|
+
if (!isSurfaceSwap && this.currentWindow) {
|
|
756
815
|
if (this.options.closeWindowDelayMs !== undefined) {
|
|
816
|
+
// Keep the outgoing window on screen until the incoming one reports
|
|
817
|
+
// its first painted frame. A newly shown window still needs a few
|
|
818
|
+
// frames to rasterize, and removing the outgoing window before that
|
|
819
|
+
// exposes the transparent window stack (a white flash on device).
|
|
820
|
+
// closeWindowDelayMs is the ceiling for windows that never report a
|
|
821
|
+
// paint (embedded apps, error states).
|
|
757
822
|
// Don't await here because this will cause a slight delay when playing the next window.
|
|
758
823
|
// 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
|
-
|
|
824
|
+
// Capture both window references because this.currentWindow and
|
|
825
|
+
// this.nextWindow change before the wait finishes.
|
|
826
|
+
const windowToClose = this.currentWindow;
|
|
827
|
+
const incomingWindow = this.nextWindow;
|
|
828
|
+
const closeWindowDelayMs = this.options.closeWindowDelayMs;
|
|
829
|
+
|
|
830
|
+
// The outgoing window stays on screen only as a visual backdrop from
|
|
831
|
+
// here on: stop delivering its events (a late complete or error must
|
|
832
|
+
// not end the incoming presentation) and mute it so audio does not
|
|
833
|
+
// bleed into the incoming presentation during the overlap.
|
|
834
|
+
windowToClose.detachCallbacks();
|
|
835
|
+
windowToClose.mute();
|
|
836
|
+
closeWindowPromise = (async () => {
|
|
837
|
+
if (incomingWindow) {
|
|
838
|
+
await incomingWindow.waitForFirstPaint(closeWindowDelayMs);
|
|
839
|
+
} else {
|
|
840
|
+
await new Promise((resolve) =>
|
|
841
|
+
setTimeout(resolve, closeWindowDelayMs)
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
// We can't use this.removeCurrentWindow because the currentWindow
|
|
845
|
+
// reference has already changed by the time the wait finishes.
|
|
846
|
+
await windowToClose.remove();
|
|
847
|
+
})();
|
|
772
848
|
} else {
|
|
773
849
|
this.currentWindow.hide();
|
|
774
850
|
await this.removeCurrentWindow();
|
|
@@ -797,7 +873,16 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
797
873
|
this.playCurrentWindow();
|
|
798
874
|
|
|
799
875
|
// Preload the next presentation if there is one.
|
|
800
|
-
|
|
876
|
+
let preloadAfterSwap: Promise<void> | null = null;
|
|
877
|
+
if (shouldPreload && isSurfaceSwap) {
|
|
878
|
+
// Once the swap has finished, so the next surface's decoder does not
|
|
879
|
+
// start during it: on the Stick a preload started in the swap held the
|
|
880
|
+
// outgoing clip's last frame for about 70 ms. The next window is the
|
|
881
|
+
// current one now.
|
|
882
|
+
this.nextWindow = null;
|
|
883
|
+
preloadAfterSwap = closeWindowPromise as Promise<void>;
|
|
884
|
+
this.pendingPreload = preloadAfterSwap;
|
|
885
|
+
} else if (shouldPreload) {
|
|
801
886
|
this.preloadNextWindow();
|
|
802
887
|
} else {
|
|
803
888
|
// Set next window to null to reset preload state. We don't want to
|
|
@@ -809,6 +894,58 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
809
894
|
if (closeWindowPromise) {
|
|
810
895
|
await closeWindowPromise;
|
|
811
896
|
}
|
|
897
|
+
|
|
898
|
+
if (preloadAfterSwap && this.pendingPreload === preloadAfterSwap) {
|
|
899
|
+
this.preloadNextWindow();
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// The swap when either window holds a video surface. The outgoing surface
|
|
904
|
+
// is muted and marked leaving here, in the swap's own task; the rest runs
|
|
905
|
+
// on the two windows held here, so a publish, a preview check or a preload
|
|
906
|
+
// error during its waits acts on the new next window, never these two.
|
|
907
|
+
private swapWithSurfaces(
|
|
908
|
+
outgoing: PresentationWindow | null,
|
|
909
|
+
incoming: PresentationWindow | null
|
|
910
|
+
): Promise<void> {
|
|
911
|
+
const delayMs = this.surfaceSwapDelayMs();
|
|
912
|
+
|
|
913
|
+
if (outgoing) {
|
|
914
|
+
// A late complete or error from the outgoing window must not end the
|
|
915
|
+
// incoming presentation, and two surfaces never sound at once.
|
|
916
|
+
outgoing.detachCallbacks();
|
|
917
|
+
outgoing.muteSurfaceOrFrame();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return (async () => {
|
|
921
|
+
let isOutgoingHidden = false;
|
|
922
|
+
if (incoming) {
|
|
923
|
+
// The new is shown before the old is hidden: an incoming surface's
|
|
924
|
+
// frame is revealed over its open hole and the outgoing frame hidden
|
|
925
|
+
// in that same task, so neither the outgoing paint-over nor its theme
|
|
926
|
+
// shows through the incoming hole.
|
|
927
|
+
await incoming.show(
|
|
928
|
+
this.isFocused,
|
|
929
|
+
() => {
|
|
930
|
+
if (outgoing) {
|
|
931
|
+
outgoing.hide();
|
|
932
|
+
isOutgoingHidden = true;
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
this.showAfterRevealMs()
|
|
936
|
+
);
|
|
937
|
+
// A removed incoming window never paints.
|
|
938
|
+
if (outgoing && !incoming.wasRemoved()) {
|
|
939
|
+
await incoming.waitForFirstPaint(delayMs);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (outgoing) {
|
|
943
|
+
if (!isOutgoingHidden) {
|
|
944
|
+
outgoing.hide();
|
|
945
|
+
}
|
|
946
|
+
await outgoing.remove();
|
|
947
|
+
}
|
|
948
|
+
})();
|
|
812
949
|
}
|
|
813
950
|
|
|
814
951
|
private evalRuleEngine(warnIfPreloadMismatch = true): EvalRuleEngineResult {
|
|
@@ -922,6 +1059,13 @@ export default class PlaylistWindowManager extends EventEmitter {
|
|
|
922
1059
|
// Clear current window error flag.
|
|
923
1060
|
this.currentWindowError = null;
|
|
924
1061
|
|
|
1062
|
+
// A complete while the next window's preload waits for its surface swap
|
|
1063
|
+
// preloads it now, and the current window restarts while it loads, as
|
|
1064
|
+
// for any next window not yet ready.
|
|
1065
|
+
if (this.pendingPreload && this.nextPresentation) {
|
|
1066
|
+
this.preloadNextWindow();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
925
1069
|
// If not locked or paused, check if we can move to the next presentation.
|
|
926
1070
|
if (
|
|
927
1071
|
!this.isLocked() &&
|
|
@@ -5,7 +5,12 @@ import {
|
|
|
5
5
|
createRenderer,
|
|
6
6
|
createPlaylistWindowManagerState,
|
|
7
7
|
createRuleEngineState,
|
|
8
|
+
createFakeSurfaceHost,
|
|
9
|
+
createLogger,
|
|
8
10
|
} from './testUtils';
|
|
11
|
+
import * as re from '@raydiant/playlist-rule-engine';
|
|
12
|
+
import SurfaceBroker from './SurfaceBroker';
|
|
13
|
+
import { ApplicationVersion } from './types';
|
|
9
14
|
|
|
10
15
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
16
|
|
|
@@ -454,3 +459,369 @@ describe('interrupt locks', () => {
|
|
|
454
459
|
});
|
|
455
460
|
|
|
456
461
|
// TODO: Backfill tests for all legacy and embedded apps.
|
|
462
|
+
|
|
463
|
+
describe('waitForFirstPaint', () => {
|
|
464
|
+
// The default (non-embedded) window resolves load() only after the app
|
|
465
|
+
// reports ready, so drive the handshake through the mock renderer.
|
|
466
|
+
const loadPresentationWindow = async (
|
|
467
|
+
testRenderer: ReturnType<typeof createRenderer>,
|
|
468
|
+
window: ReturnType<typeof createPresentationWindow>
|
|
469
|
+
) => {
|
|
470
|
+
const loadPromise = window.load();
|
|
471
|
+
await delay(0);
|
|
472
|
+
testRenderer.runReceiveMessageCallback('loaded', undefined);
|
|
473
|
+
testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
|
|
474
|
+
await loadPromise;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
test('resolves when the window reports first_painted', async () => {
|
|
478
|
+
const testRenderer = createRenderer();
|
|
479
|
+
const window = createPresentationWindow({
|
|
480
|
+
windowId: 'test-first-paint',
|
|
481
|
+
renderer: testRenderer,
|
|
482
|
+
});
|
|
483
|
+
await loadPresentationWindow(testRenderer, window);
|
|
484
|
+
|
|
485
|
+
let resolved = false;
|
|
486
|
+
const wait = window.waitForFirstPaint(5000).then(() => {
|
|
487
|
+
resolved = true;
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
await delay(10);
|
|
491
|
+
expect(resolved).toBe(false);
|
|
492
|
+
|
|
493
|
+
testRenderer.runReceiveMessageCallback('first_painted', undefined);
|
|
494
|
+
await wait;
|
|
495
|
+
expect(resolved).toBe(true);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('falls back to the timeout when no paint signal arrives', async () => {
|
|
499
|
+
const testRenderer = createRenderer();
|
|
500
|
+
const window = createPresentationWindow({
|
|
501
|
+
windowId: 'test-first-paint-timeout',
|
|
502
|
+
renderer: testRenderer,
|
|
503
|
+
});
|
|
504
|
+
await loadPresentationWindow(testRenderer, window);
|
|
505
|
+
|
|
506
|
+
// Resolves via the timeout even though first_painted never arrives.
|
|
507
|
+
await window.waitForFirstPaint(20);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('resolves immediately when the window already painted since the last play', async () => {
|
|
511
|
+
const testRenderer = createRenderer();
|
|
512
|
+
const window = createPresentationWindow({
|
|
513
|
+
windowId: 'test-first-paint-immediate',
|
|
514
|
+
renderer: testRenderer,
|
|
515
|
+
});
|
|
516
|
+
await loadPresentationWindow(testRenderer, window);
|
|
517
|
+
|
|
518
|
+
testRenderer.runReceiveMessageCallback('first_painted', undefined);
|
|
519
|
+
|
|
520
|
+
let resolved = false;
|
|
521
|
+
window.waitForFirstPaint(5000).then(() => {
|
|
522
|
+
resolved = true;
|
|
523
|
+
});
|
|
524
|
+
await Promise.resolve();
|
|
525
|
+
expect(resolved).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('play starts a new paint cycle', async () => {
|
|
529
|
+
const testRenderer = createRenderer();
|
|
530
|
+
const window = createPresentationWindow({
|
|
531
|
+
windowId: 'test-first-paint-replay',
|
|
532
|
+
renderer: testRenderer,
|
|
533
|
+
});
|
|
534
|
+
await loadPresentationWindow(testRenderer, window);
|
|
535
|
+
|
|
536
|
+
testRenderer.runReceiveMessageCallback('first_painted', undefined);
|
|
537
|
+
window.play();
|
|
538
|
+
|
|
539
|
+
let resolved = false;
|
|
540
|
+
const wait = window.waitForFirstPaint(5000).then(() => {
|
|
541
|
+
resolved = true;
|
|
542
|
+
});
|
|
543
|
+
await delay(10);
|
|
544
|
+
expect(resolved).toBe(false);
|
|
545
|
+
|
|
546
|
+
testRenderer.runReceiveMessageCallback('first_painted', undefined);
|
|
547
|
+
await wait;
|
|
548
|
+
expect(resolved).toBe(true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('resolves pending waiters when the window is removed', async () => {
|
|
552
|
+
const testRenderer = createRenderer();
|
|
553
|
+
const window = createPresentationWindow({
|
|
554
|
+
windowId: 'test-first-paint-removed',
|
|
555
|
+
renderer: testRenderer,
|
|
556
|
+
});
|
|
557
|
+
await loadPresentationWindow(testRenderer, window);
|
|
558
|
+
|
|
559
|
+
const wait = window.waitForFirstPaint(5000);
|
|
560
|
+
await window.remove();
|
|
561
|
+
await wait;
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('detachCallbacks', () => {
|
|
566
|
+
test('stops delivering events to the window manager', async () => {
|
|
567
|
+
const testRenderer = createRenderer();
|
|
568
|
+
const window = createPresentationWindow({
|
|
569
|
+
windowId: 'test-detach',
|
|
570
|
+
renderer: testRenderer,
|
|
571
|
+
});
|
|
572
|
+
const loadPromise = window.load();
|
|
573
|
+
await delay(0);
|
|
574
|
+
testRenderer.runReceiveMessageCallback('loaded', undefined);
|
|
575
|
+
testRenderer.runReceiveMessageCallback('presentation_ready', undefined);
|
|
576
|
+
await loadPromise;
|
|
577
|
+
|
|
578
|
+
const onComplete = jest.fn();
|
|
579
|
+
window.onComplete(onComplete);
|
|
580
|
+
window.detachCallbacks();
|
|
581
|
+
|
|
582
|
+
testRenderer.runReceiveMessageCallback('presentation_complete', undefined);
|
|
583
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe('video surfaces', () => {
|
|
588
|
+
const CONTENT_URL = 'https://cdn.test/clip.mp4';
|
|
589
|
+
|
|
590
|
+
function setupVideoWindow(
|
|
591
|
+
opts: {
|
|
592
|
+
withHost?: boolean;
|
|
593
|
+
applicationVariables?: Record<string, unknown>;
|
|
594
|
+
applicationVersion?: Partial<ApplicationVersion>;
|
|
595
|
+
} = {}
|
|
596
|
+
) {
|
|
597
|
+
const testRenderer = createRenderer();
|
|
598
|
+
const host = createFakeSurfaceHost();
|
|
599
|
+
const warn = jest.fn();
|
|
600
|
+
const logger = createLogger({ warn });
|
|
601
|
+
const window = createPresentationWindow({
|
|
602
|
+
windowId: 'video-window',
|
|
603
|
+
presentation: createPresentation({
|
|
604
|
+
id: 'p1',
|
|
605
|
+
applicationVariables: opts.applicationVariables ?? {
|
|
606
|
+
content: { url: CONTENT_URL, filename: 'clip.mp4' },
|
|
607
|
+
},
|
|
608
|
+
}),
|
|
609
|
+
applicationVersion: createApplicationVersion(opts.applicationVersion),
|
|
610
|
+
renderer: testRenderer,
|
|
611
|
+
logger,
|
|
612
|
+
surfaceBroker:
|
|
613
|
+
opts.withHost === false
|
|
614
|
+
? undefined
|
|
615
|
+
: new SurfaceBroker(host.surfaces, logger),
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const messages = () =>
|
|
619
|
+
testRenderer.sendMessageString.mock.calls.map(([, message]) =>
|
|
620
|
+
JSON.parse(message)
|
|
621
|
+
);
|
|
622
|
+
const refusals = () =>
|
|
623
|
+
messages()
|
|
624
|
+
.filter((m) => m.type === 'video_refused')
|
|
625
|
+
.map((m) => m.payload.reason);
|
|
626
|
+
const refusalsLogged = () =>
|
|
627
|
+
warn.mock.calls.filter(([message]) => /video_refused/.test(message))
|
|
628
|
+
.length;
|
|
629
|
+
const openVideo = (url: string) =>
|
|
630
|
+
testRenderer.runReceiveMessageCallback('open_video', {
|
|
631
|
+
url,
|
|
632
|
+
bounds: { x: 0.4, y: 10.6, width: 1919.6, height: 1080 },
|
|
633
|
+
muted: false,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
testRenderer,
|
|
638
|
+
host,
|
|
639
|
+
window,
|
|
640
|
+
messages,
|
|
641
|
+
refusals,
|
|
642
|
+
refusalsLogged,
|
|
643
|
+
openVideo,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
test('open_video reaches the host, and render carries nativeVideo, only with a host', async () => {
|
|
648
|
+
const offered = setupVideoWindow();
|
|
649
|
+
offered.window.load();
|
|
650
|
+
await delay(0);
|
|
651
|
+
offered.testRenderer.runReceiveMessageCallback('loaded', undefined);
|
|
652
|
+
expect(
|
|
653
|
+
offered.messages().find((m) => m.type === 'render').payload.nativeVideo
|
|
654
|
+
).toBe(true);
|
|
655
|
+
|
|
656
|
+
offered.openVideo(CONTENT_URL);
|
|
657
|
+
await delay(0);
|
|
658
|
+
expect(
|
|
659
|
+
offered.host.calls.filter((c) => c.method === 'openVideo')[0].args[0]
|
|
660
|
+
).toEqual({
|
|
661
|
+
url: CONTENT_URL,
|
|
662
|
+
bounds: { x: 0, y: 11, width: 1920, height: 1080 },
|
|
663
|
+
muted: true,
|
|
664
|
+
label: 'p1',
|
|
665
|
+
});
|
|
666
|
+
expect(offered.messages().map((m) => m.type)).toContain('video_opened');
|
|
667
|
+
await offered.window.remove();
|
|
668
|
+
|
|
669
|
+
const unoffered = setupVideoWindow({ withHost: false });
|
|
670
|
+
unoffered.window.load();
|
|
671
|
+
await delay(0);
|
|
672
|
+
unoffered.testRenderer.runReceiveMessageCallback('loaded', undefined);
|
|
673
|
+
expect(
|
|
674
|
+
unoffered.messages().find((m) => m.type === 'render').payload
|
|
675
|
+
).not.toHaveProperty('nativeVideo');
|
|
676
|
+
|
|
677
|
+
unoffered.openVideo(CONTENT_URL);
|
|
678
|
+
expect(unoffered.refusals()).toEqual(['not_offered']);
|
|
679
|
+
expect(unoffered.refusalsLogged()).toBe(1);
|
|
680
|
+
await unoffered.window.remove();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('a rendition beside the presentation url opens a surface', async () => {
|
|
684
|
+
const { window, host, openVideo, refusals } = setupVideoWindow();
|
|
685
|
+
window.load();
|
|
686
|
+
await delay(0);
|
|
687
|
+
|
|
688
|
+
openVideo(`${CONTENT_URL}_720p`);
|
|
689
|
+
await delay(0);
|
|
690
|
+
|
|
691
|
+
expect(host.calls.filter((c) => c.method === 'openVideo')).toHaveLength(1);
|
|
692
|
+
expect(refusals()).toEqual([]);
|
|
693
|
+
await window.remove();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test.each([
|
|
697
|
+
['a url the presentation does not hold', 'https://cdn.test/other.mp4'],
|
|
698
|
+
['a url beside it with another suffix', `${CONTENT_URL}_1080p`],
|
|
699
|
+
])('%s is refused not_offered', async (_, url) => {
|
|
700
|
+
const { window, host, openVideo, refusals, refusalsLogged } =
|
|
701
|
+
setupVideoWindow();
|
|
702
|
+
window.load();
|
|
703
|
+
await delay(0);
|
|
704
|
+
|
|
705
|
+
openVideo(url);
|
|
706
|
+
await delay(0);
|
|
707
|
+
|
|
708
|
+
expect(refusals()).toEqual(['not_offered']);
|
|
709
|
+
expect(refusalsLogged()).toBe(1);
|
|
710
|
+
expect(host.calls).toEqual([]);
|
|
711
|
+
await window.remove();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test('a url that is not http(s) is refused not_offered', async () => {
|
|
715
|
+
const { window, host, openVideo, refusals } = setupVideoWindow({
|
|
716
|
+
applicationVariables: { content: { url: 'blob:https://cdn.test/1' } },
|
|
717
|
+
});
|
|
718
|
+
window.load();
|
|
719
|
+
await delay(0);
|
|
720
|
+
|
|
721
|
+
openVideo('blob:https://cdn.test/1');
|
|
722
|
+
await delay(0);
|
|
723
|
+
|
|
724
|
+
expect(refusals()).toEqual(['not_offered']);
|
|
725
|
+
expect(host.calls).toEqual([]);
|
|
726
|
+
await window.remove();
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('an embedded app is refused not_offered', async () => {
|
|
730
|
+
const { window, host, testRenderer, refusals, refusalsLogged } =
|
|
731
|
+
setupVideoWindow({
|
|
732
|
+
applicationVersion: { embeddedUrlFormat: 'https://embedded.test/' },
|
|
733
|
+
});
|
|
734
|
+
await window.load();
|
|
735
|
+
|
|
736
|
+
testRenderer.runReceiveMessageCallback('open_video', {
|
|
737
|
+
url: CONTENT_URL,
|
|
738
|
+
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
|
739
|
+
});
|
|
740
|
+
await delay(0);
|
|
741
|
+
|
|
742
|
+
expect(refusals()).toEqual(['not_offered']);
|
|
743
|
+
expect(refusalsLogged()).toBe(1);
|
|
744
|
+
expect(host.calls).toEqual([]);
|
|
745
|
+
await window.remove();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("a zone's windows are offered no surface and refused not_offered", async () => {
|
|
749
|
+
const testRenderer = createRenderer();
|
|
750
|
+
const host = createFakeSurfaceHost();
|
|
751
|
+
const logger = createLogger();
|
|
752
|
+
const zonePresentation = createPresentation({
|
|
753
|
+
id: 'z1',
|
|
754
|
+
applicationDeploymentId: 'zone-app',
|
|
755
|
+
applicationVariables: { content: { url: CONTENT_URL } },
|
|
756
|
+
});
|
|
757
|
+
const window = createPresentationWindow({
|
|
758
|
+
windowId: 'multizone',
|
|
759
|
+
presentation: createPresentation({
|
|
760
|
+
id: 'mz',
|
|
761
|
+
r: {
|
|
762
|
+
resource: { updatedAt: 'updatedAt', createdAt: 'createdAt' },
|
|
763
|
+
presentationsPlaylists: [{ playlistId: 'zone-playlist' }],
|
|
764
|
+
},
|
|
765
|
+
}),
|
|
766
|
+
playlistWindowManagerState: createPlaylistWindowManagerState({
|
|
767
|
+
presentations: { z1: zonePresentation },
|
|
768
|
+
applicationVersions: {
|
|
769
|
+
'zone-app': createApplicationVersion({ id: 'zone-app' }),
|
|
770
|
+
},
|
|
771
|
+
}),
|
|
772
|
+
ruleEngineState: createRuleEngineState(
|
|
773
|
+
{
|
|
774
|
+
'zone-playlist': {
|
|
775
|
+
id: 'zone-playlist',
|
|
776
|
+
mode: re.PlaylistMode.Sequential,
|
|
777
|
+
items: [{ presentationId: 'z1' }],
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
{ z1: { id: 'z1', applicationName: 'videos' } }
|
|
781
|
+
),
|
|
782
|
+
renderer: testRenderer,
|
|
783
|
+
logger,
|
|
784
|
+
surfaceBroker: new SurfaceBroker(host.surfaces, logger),
|
|
785
|
+
});
|
|
786
|
+
window.load();
|
|
787
|
+
await delay(0);
|
|
788
|
+
|
|
789
|
+
testRenderer.runReceiveMessageCallback('open_child_window', {
|
|
790
|
+
windowId: 'zone1',
|
|
791
|
+
playlistId: 'zone-playlist',
|
|
792
|
+
x: 0,
|
|
793
|
+
y: 0,
|
|
794
|
+
width: 960,
|
|
795
|
+
height: 1080,
|
|
796
|
+
isMuted: false,
|
|
797
|
+
isFocused: false,
|
|
798
|
+
});
|
|
799
|
+
await delay(0);
|
|
800
|
+
|
|
801
|
+
// The renderer's callbacks now reach the zone's window.
|
|
802
|
+
const zoneWindowId = testRenderer.createWindow.mock.calls[1][0].windowId;
|
|
803
|
+
testRenderer.runReceiveMessageCallback('loaded', undefined);
|
|
804
|
+
const zoneMessages = () =>
|
|
805
|
+
testRenderer.sendMessageString.mock.calls
|
|
806
|
+
.filter(([windowId]) => windowId === zoneWindowId)
|
|
807
|
+
.map(([, message]) => JSON.parse(message));
|
|
808
|
+
expect(
|
|
809
|
+
zoneMessages().find((m) => m.type === 'render').payload
|
|
810
|
+
).not.toHaveProperty('nativeVideo');
|
|
811
|
+
|
|
812
|
+
testRenderer.runReceiveMessageCallback('open_video', {
|
|
813
|
+
url: CONTENT_URL,
|
|
814
|
+
bounds: { x: 0, y: 0, width: 960, height: 1080 },
|
|
815
|
+
muted: false,
|
|
816
|
+
});
|
|
817
|
+
await delay(0);
|
|
818
|
+
|
|
819
|
+
expect(
|
|
820
|
+
zoneMessages()
|
|
821
|
+
.filter((m) => m.type === 'video_refused')
|
|
822
|
+
.map((m) => m.payload.reason)
|
|
823
|
+
).toEqual(['not_offered']);
|
|
824
|
+
expect(host.calls).toEqual([]);
|
|
825
|
+
await window.remove();
|
|
826
|
+
});
|
|
827
|
+
});
|