@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
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
Terminal,
|
|
27
27
|
InterruptLock,
|
|
28
28
|
} from './types';
|
|
29
|
+
import SurfaceBroker, {
|
|
30
|
+
DEFAULT_SURFACE_SWAP_DELAY_MS,
|
|
31
|
+
SurfaceRefusal,
|
|
32
|
+
} from './SurfaceBroker';
|
|
33
|
+
import { Rect, VideoEvent, VideoRefusalReason, VideoSurface } from './surfaces';
|
|
29
34
|
|
|
30
35
|
export interface PresentationWindowOptions {
|
|
31
36
|
windowManager?: PlaylistWindowManager;
|
|
@@ -60,10 +65,73 @@ export interface PresentationWindowOptions {
|
|
|
60
65
|
) => void;
|
|
61
66
|
getTerminal?: () => Terminal | null;
|
|
62
67
|
getOnlineStatus?: () => boolean;
|
|
68
|
+
// Set only on a top-level window of a manager given a surface host. The
|
|
69
|
+
// window then offers its app a video surface.
|
|
70
|
+
surfaceBroker?: SurfaceBroker;
|
|
71
|
+
// How long showing a surface may wait for the host's show() and then for
|
|
72
|
+
// the app to open its hole, together.
|
|
73
|
+
surfaceShowDeadlineMs?: number;
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
// TODO: Automatically inject latest version when building in CI.
|
|
66
|
-
const defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.
|
|
77
|
+
const defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.1/index.html';
|
|
78
|
+
|
|
79
|
+
// The urls a window may open a surface for besides its content url: the
|
|
80
|
+
// Videos app probes for these renditions beside the original.
|
|
81
|
+
const RENDITION_SUFFIXES = ['_4K', '_720p', '_480p'];
|
|
82
|
+
|
|
83
|
+
// A video surface one window holds. A window holds at most one.
|
|
84
|
+
interface WindowSurface {
|
|
85
|
+
// Null while the host is still opening it.
|
|
86
|
+
handle: VideoSurface | null;
|
|
87
|
+
// The app asked for sound.
|
|
88
|
+
wantsSound: boolean;
|
|
89
|
+
firstFrame: boolean;
|
|
90
|
+
// The bounds the app sent after the first frame.
|
|
91
|
+
bounds: Rect | null;
|
|
92
|
+
holeOpen: boolean;
|
|
93
|
+
// The app opened its hole while the window was hidden.
|
|
94
|
+
isHolePreopened: boolean;
|
|
95
|
+
// A swap started the surface before its reveal; the held play then only
|
|
96
|
+
// tells the app.
|
|
97
|
+
isPlayingAhead: boolean;
|
|
98
|
+
// Plays sent to the surface, so a replay seeks to the start.
|
|
99
|
+
plays: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const isValidRect = (value: any): value is Rect =>
|
|
103
|
+
value &&
|
|
104
|
+
typeof value === 'object' &&
|
|
105
|
+
isFiniteNumber(value.x) &&
|
|
106
|
+
isFiniteNumber(value.y) &&
|
|
107
|
+
isFiniteNumber(value.width) &&
|
|
108
|
+
isFiniteNumber(value.height);
|
|
109
|
+
|
|
110
|
+
const isFiniteNumber = (value: any) =>
|
|
111
|
+
typeof value === 'number' && isFinite(value);
|
|
112
|
+
|
|
113
|
+
const roundRect = (rect: Rect): Rect => ({
|
|
114
|
+
x: Math.round(rect.x),
|
|
115
|
+
y: Math.round(rect.y),
|
|
116
|
+
width: Math.round(rect.width),
|
|
117
|
+
height: Math.round(rect.height),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Every string held under a `url` key, at any depth.
|
|
121
|
+
const collectUrls = (value: any, urls: string[] = []): string[] => {
|
|
122
|
+
if (Array.isArray(value)) {
|
|
123
|
+
value.forEach((item) => collectUrls(item, urls));
|
|
124
|
+
} else if (value && typeof value === 'object') {
|
|
125
|
+
Object.keys(value).forEach((key) => {
|
|
126
|
+
if (key === 'url' && typeof value[key] === 'string') {
|
|
127
|
+
urls.push(value[key]);
|
|
128
|
+
} else {
|
|
129
|
+
collectUrls(value[key], urls);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return urls;
|
|
134
|
+
};
|
|
67
135
|
|
|
68
136
|
const isValidNumber = (value: any) =>
|
|
69
137
|
typeof value === 'number' && !isNaN(value);
|
|
@@ -130,6 +198,8 @@ export default class PresentationWindow {
|
|
|
130
198
|
null;
|
|
131
199
|
private chargeRequestCallback: ((request: ChargeRequest) => void) | null =
|
|
132
200
|
null;
|
|
201
|
+
private didPaintSinceLastPlay = false;
|
|
202
|
+
private firstPaintResolvers: Array<() => void> = [];
|
|
133
203
|
|
|
134
204
|
private builderState: BuilderState | undefined;
|
|
135
205
|
private presentation: Presentation;
|
|
@@ -155,6 +225,18 @@ export default class PresentationWindow {
|
|
|
155
225
|
private interruptLocks: Record<string, InterruptLock> = {};
|
|
156
226
|
private previouslyLocked = false;
|
|
157
227
|
|
|
228
|
+
private surface: WindowSurface | null = null;
|
|
229
|
+
// Once a window has been shown without a ready surface, or has given one
|
|
230
|
+
// up, it is offered none from then on and its app keeps <video>.
|
|
231
|
+
private surfaceWithdrawn = false;
|
|
232
|
+
private surfaceShow: Promise<void> | null = null;
|
|
233
|
+
private wakeSurfaceShow: Array<() => void> = [];
|
|
234
|
+
private holeOpenWaiter: (() => void) | null = null;
|
|
235
|
+
private playingWaiter: (() => void) | null = null;
|
|
236
|
+
private isPlayHeld = false;
|
|
237
|
+
private isRemoved = false;
|
|
238
|
+
private frameBackground = '#000000';
|
|
239
|
+
|
|
158
240
|
constructor(private options: PresentationWindowOptions) {
|
|
159
241
|
this.builderState = options.builderState;
|
|
160
242
|
this.presentation = options.presentation;
|
|
@@ -172,7 +254,51 @@ export default class PresentationWindow {
|
|
|
172
254
|
return this.loadLegacyApp();
|
|
173
255
|
}
|
|
174
256
|
|
|
175
|
-
|
|
257
|
+
// Shows the window. A window holding a surface with its first frame and
|
|
258
|
+
// bounds shows the surface first and reveals its frame only once the app
|
|
259
|
+
// has opened its hole; onReveal then runs in the reveal's task. Given
|
|
260
|
+
// showAfterRevealMs by a swap, a surface whose app opened its hole while
|
|
261
|
+
// hidden is played, then revealed, then shown instead. The promise
|
|
262
|
+
// resolves once the frame is revealed and any such show has settled, or
|
|
263
|
+
// the window is removed.
|
|
264
|
+
public show(
|
|
265
|
+
isFocused: boolean = true,
|
|
266
|
+
onReveal?: () => void,
|
|
267
|
+
showAfterRevealMs?: number
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
if (this.surfaceShow) {
|
|
270
|
+
return this.surfaceShow;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const surface = this.surface;
|
|
274
|
+
if (surface && surface.holeOpen) {
|
|
275
|
+
this.showFrame(isFocused);
|
|
276
|
+
return Promise.resolve();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (surface && this.isSurfaceReady(surface)) {
|
|
280
|
+
const showing =
|
|
281
|
+
showAfterRevealMs !== undefined && surface.isHolePreopened
|
|
282
|
+
? this.showSurfaceAfterReveal(
|
|
283
|
+
surface,
|
|
284
|
+
isFocused,
|
|
285
|
+
showAfterRevealMs,
|
|
286
|
+
onReveal
|
|
287
|
+
)
|
|
288
|
+
: this.showSurface(surface, isFocused, onReveal);
|
|
289
|
+
this.surfaceShow = showing.then(() => {
|
|
290
|
+
this.surfaceShow = null;
|
|
291
|
+
});
|
|
292
|
+
return this.surfaceShow;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Shown before its surface was ready: the app keeps <video>.
|
|
296
|
+
this.withdrawSurface();
|
|
297
|
+
this.showFrame(isFocused);
|
|
298
|
+
return Promise.resolve();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private showFrame(isFocused: boolean) {
|
|
176
302
|
const { windowId, renderer } = this.options;
|
|
177
303
|
|
|
178
304
|
// Show the main window and focus it before any child windows. Showing a child
|
|
@@ -241,11 +367,35 @@ export default class PresentationWindow {
|
|
|
241
367
|
this.configurableDurationTimeout = null;
|
|
242
368
|
}
|
|
243
369
|
|
|
370
|
+
// Release anything waiting on a paint signal so callers never hang on a
|
|
371
|
+
// window that is going away.
|
|
372
|
+
this.resolveFirstPaintWaiters();
|
|
373
|
+
|
|
374
|
+
// Release a surface show in progress; a removed window is never revealed.
|
|
375
|
+
this.isRemoved = true;
|
|
376
|
+
this.isPlayHeld = false;
|
|
377
|
+
this.wakeSurfaceShowWaiters();
|
|
378
|
+
|
|
244
379
|
// Remove any child window managers.
|
|
245
380
|
Object.values(this.windowManagers).forEach((wm) => {
|
|
246
381
|
wm.destroy();
|
|
247
382
|
});
|
|
248
383
|
|
|
384
|
+
// The app paints over its hole, then the surface closes, then the hole
|
|
385
|
+
// is reported closed, all before the frame goes.
|
|
386
|
+
const surface = this.surface;
|
|
387
|
+
const { surfaceBroker } = this.options;
|
|
388
|
+
if (surface && surfaceBroker) {
|
|
389
|
+
this.surface = null;
|
|
390
|
+
if (surface.handle) {
|
|
391
|
+
this.sendVideoMessage('video_closing');
|
|
392
|
+
}
|
|
393
|
+
await surfaceBroker.close(windowId);
|
|
394
|
+
if (surface.holeOpen) {
|
|
395
|
+
surfaceBroker.onHole(false);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
249
399
|
// Remove the main window after removing all the child window managers.
|
|
250
400
|
try {
|
|
251
401
|
await renderer.removeWindow(windowId);
|
|
@@ -269,6 +419,19 @@ export default class PresentationWindow {
|
|
|
269
419
|
public play(): void {
|
|
270
420
|
const { applicationVersion, presentation } = this.options;
|
|
271
421
|
|
|
422
|
+
// A window holding a surface plays only once its frame is revealed over
|
|
423
|
+
// an open hole, so its first paint follows the hole opening.
|
|
424
|
+
const surface = this.surface;
|
|
425
|
+
if (surface && !surface.holeOpen) {
|
|
426
|
+
this.isPlayHeld = true;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
this.isPlayHeld = false;
|
|
430
|
+
|
|
431
|
+
// Each play starts a new paint cycle; 'first_painted' arrives once the
|
|
432
|
+
// window has presented a frame after this play.
|
|
433
|
+
this.didPaintSinceLastPlay = false;
|
|
434
|
+
|
|
272
435
|
if (applicationVersion.configurableDuration) {
|
|
273
436
|
const durationMs = Math.max(presentation.duration * 1000, 5000);
|
|
274
437
|
if (this.configurableDurationTimeout) {
|
|
@@ -284,6 +447,20 @@ export default class PresentationWindow {
|
|
|
284
447
|
if (!this.isEmbeddedApp()) {
|
|
285
448
|
this.sendMessageString('play');
|
|
286
449
|
|
|
450
|
+
// A replay seeks to the start rather than reopening, which would cost
|
|
451
|
+
// a new first frame with nothing in the hole meanwhile.
|
|
452
|
+
if (surface && surface.handle) {
|
|
453
|
+
if (surface.isPlayingAhead) {
|
|
454
|
+
surface.isPlayingAhead = false;
|
|
455
|
+
} else {
|
|
456
|
+
if (surface.plays > 0) {
|
|
457
|
+
surface.handle.seek(0);
|
|
458
|
+
}
|
|
459
|
+
surface.handle.play();
|
|
460
|
+
surface.plays += 1;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
287
464
|
// Play any child window managers.
|
|
288
465
|
Object.values(this.windowManagers).forEach((wm) => {
|
|
289
466
|
// We only want to trigger play on child windows the first play of the main window or if
|
|
@@ -314,6 +491,79 @@ export default class PresentationWindow {
|
|
|
314
491
|
this.didReceiveComplete = false;
|
|
315
492
|
}
|
|
316
493
|
|
|
494
|
+
// Resolves when the window reports its first painted frame after the most
|
|
495
|
+
// recent play, or after timeoutMs, whichever comes first. Only app-renderer
|
|
496
|
+
// windows emit the paint signal; anything else exits via the timeout.
|
|
497
|
+
public waitForFirstPaint(timeoutMs: number): Promise<void> {
|
|
498
|
+
if (this.didPaintSinceLastPlay) {
|
|
499
|
+
return Promise.resolve();
|
|
500
|
+
}
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
let settled = false;
|
|
503
|
+
const settle = () => {
|
|
504
|
+
if (settled) return;
|
|
505
|
+
settled = true;
|
|
506
|
+
clearTimeout(timer);
|
|
507
|
+
const index = this.firstPaintResolvers.indexOf(settle);
|
|
508
|
+
if (index !== -1) {
|
|
509
|
+
this.firstPaintResolvers.splice(index, 1);
|
|
510
|
+
}
|
|
511
|
+
resolve();
|
|
512
|
+
};
|
|
513
|
+
const timer = setTimeout(settle, timeoutMs);
|
|
514
|
+
this.firstPaintResolvers.push(settle);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Stop delivering this window's events (complete, error, analytics, charge,
|
|
519
|
+
// interrupt locks) to the window manager. Used when the outgoing window is
|
|
520
|
+
// kept on screen only as a visual backdrop during a swap overlap — a late
|
|
521
|
+
// event from it must not be attributed to the incoming window.
|
|
522
|
+
public detachCallbacks(): void {
|
|
523
|
+
this.readyCallback = null;
|
|
524
|
+
this.completeCallback = null;
|
|
525
|
+
this.errorCallback = null;
|
|
526
|
+
this.enableInterruptLockCallback = null;
|
|
527
|
+
this.disableInterruptLockCallback = null;
|
|
528
|
+
this.analyticsEventCallback = null;
|
|
529
|
+
this.chargeRequestCallback = null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Mute the window's audio channel. On the IFrameRenderer this is best-effort
|
|
533
|
+
// (iframes expose no mute API); BrowserWindow renderers mute for real.
|
|
534
|
+
public mute(): void {
|
|
535
|
+
const { windowId, renderer } = this.options;
|
|
536
|
+
renderer.muteWindow(windowId);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
public wasRemoved(): boolean {
|
|
540
|
+
return this.isRemoved;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// The window holds a video surface, opened or still opening.
|
|
544
|
+
public holdsSurface(): boolean {
|
|
545
|
+
return this.surface !== null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// A swap is taking this window off screen: mute its surface and tell the
|
|
549
|
+
// broker its close follows, or mute the window as today when it has none.
|
|
550
|
+
public muteSurfaceOrFrame(): void {
|
|
551
|
+
const { windowId, surfaceBroker } = this.options;
|
|
552
|
+
const surface = this.surface;
|
|
553
|
+
if (surface && surface.handle && surfaceBroker) {
|
|
554
|
+
surface.handle.setMuted(true);
|
|
555
|
+
surfaceBroker.leaving(windowId);
|
|
556
|
+
} else {
|
|
557
|
+
this.mute();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private resolveFirstPaintWaiters() {
|
|
562
|
+
const resolvers = this.firstPaintResolvers;
|
|
563
|
+
this.firstPaintResolvers = [];
|
|
564
|
+
resolvers.forEach((resolve) => resolve());
|
|
565
|
+
}
|
|
566
|
+
|
|
317
567
|
public onComplete(callback: (() => void) | null) {
|
|
318
568
|
this.completeCallback = callback;
|
|
319
569
|
}
|
|
@@ -345,6 +595,12 @@ export default class PresentationWindow {
|
|
|
345
595
|
if (this.isEmbeddedApp()) {
|
|
346
596
|
return this.didEmbeddedAppLoad;
|
|
347
597
|
}
|
|
598
|
+
// A window holding a surface is ready only once its first frame has
|
|
599
|
+
// arrived and the app has sized its hole from it.
|
|
600
|
+
const surface = this.surface;
|
|
601
|
+
if (surface && !this.isSurfaceReady(surface)) {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
348
604
|
return this.didReceiveReady && this.allChildWindowManagersReady();
|
|
349
605
|
}
|
|
350
606
|
|
|
@@ -636,13 +892,14 @@ export default class PresentationWindow {
|
|
|
636
892
|
|
|
637
893
|
this.logInfo(`Create window '${windowId}'`);
|
|
638
894
|
|
|
895
|
+
this.frameBackground = backgroundColor || '#000000'; // Default to black bg for Raydiant apps
|
|
639
896
|
await this.createAndLoadWindow(
|
|
640
897
|
this.getFullAppRendererUrl({
|
|
641
898
|
appRendererUrl,
|
|
642
899
|
windowId,
|
|
643
900
|
appRendererOfflineSupport,
|
|
644
901
|
}),
|
|
645
|
-
|
|
902
|
+
this.frameBackground,
|
|
646
903
|
this.handleLegacyMessage
|
|
647
904
|
);
|
|
648
905
|
|
|
@@ -766,7 +1023,11 @@ export default class PresentationWindow {
|
|
|
766
1023
|
}
|
|
767
1024
|
|
|
768
1025
|
private sendRenderMessage() {
|
|
769
|
-
const { applicationVersion, isPreview, device } =
|
|
1026
|
+
const { applicationVersion, isPreview, device, surfaceBroker } =
|
|
1027
|
+
this.options;
|
|
1028
|
+
// The offer of a video surface; app renderer 3.2.0 gives the app its
|
|
1029
|
+
// miraVideo prop only when this is set.
|
|
1030
|
+
const surfaceOffer = surfaceBroker ? { nativeVideo: true } : {};
|
|
770
1031
|
// IFrames do not have a mute/unmute API so we are overriding any mute properties
|
|
771
1032
|
// to support muting MZ zones when using the IFrameRenderer (SecondScreen).
|
|
772
1033
|
if (this.forceMute) {
|
|
@@ -786,6 +1047,7 @@ export default class PresentationWindow {
|
|
|
786
1047
|
applicationVersion,
|
|
787
1048
|
device,
|
|
788
1049
|
isPreview,
|
|
1050
|
+
...surfaceOffer,
|
|
789
1051
|
});
|
|
790
1052
|
} else {
|
|
791
1053
|
this.sendMessageString('render', {
|
|
@@ -795,6 +1057,7 @@ export default class PresentationWindow {
|
|
|
795
1057
|
applicationVersion,
|
|
796
1058
|
device,
|
|
797
1059
|
isPreview,
|
|
1060
|
+
...surfaceOffer,
|
|
798
1061
|
});
|
|
799
1062
|
}
|
|
800
1063
|
}
|
|
@@ -1274,6 +1537,12 @@ export default class PresentationWindow {
|
|
|
1274
1537
|
break;
|
|
1275
1538
|
}
|
|
1276
1539
|
|
|
1540
|
+
case 'open_video': {
|
|
1541
|
+
// An embedded app is never offered a video surface.
|
|
1542
|
+
this.refuseVideo('not_offered');
|
|
1543
|
+
break;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1277
1546
|
default: {
|
|
1278
1547
|
this.logWarning(`Received unknown embedded message type: ${type}`);
|
|
1279
1548
|
}
|
|
@@ -1300,6 +1569,12 @@ export default class PresentationWindow {
|
|
|
1300
1569
|
this.handleComplete();
|
|
1301
1570
|
}
|
|
1302
1571
|
|
|
1572
|
+
if (type === 'first_painted') {
|
|
1573
|
+
this.logDebug(`Received 'first_painted' from window '${windowId}'`);
|
|
1574
|
+
this.didPaintSinceLastPlay = true;
|
|
1575
|
+
this.resolveFirstPaintWaiters();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1303
1578
|
if (type === 'enable_interrupt_lock') {
|
|
1304
1579
|
this.logInfo(
|
|
1305
1580
|
`Received 'enable_interrupt_lock' from window '${windowId}'`
|
|
@@ -1339,6 +1614,38 @@ export default class PresentationWindow {
|
|
|
1339
1614
|
}
|
|
1340
1615
|
}
|
|
1341
1616
|
|
|
1617
|
+
if (type === 'open_video') {
|
|
1618
|
+
this.handleOpenVideo(payload);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (type === 'video_bounds') {
|
|
1622
|
+
this.handleVideoBounds(payload);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
if (type === 'video_hole_open') {
|
|
1626
|
+
this.logInfo(`Received 'video_hole_open' from window '${windowId}'`);
|
|
1627
|
+
const surface = this.surface;
|
|
1628
|
+
if (this.holeOpenWaiter) {
|
|
1629
|
+
this.holeOpenWaiter();
|
|
1630
|
+
} else if (
|
|
1631
|
+
surface &&
|
|
1632
|
+
surface.firstFrame &&
|
|
1633
|
+
!surface.holeOpen &&
|
|
1634
|
+
!this.surfaceShow
|
|
1635
|
+
) {
|
|
1636
|
+
// Opened before the window is shown, so a swap may play and reveal
|
|
1637
|
+
// it before showing its surface.
|
|
1638
|
+
surface.isHolePreopened = true;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
if (type === 'close_video') {
|
|
1643
|
+
this.logInfo(`Received 'close_video' from window '${windowId}'`);
|
|
1644
|
+
if (this.surface) {
|
|
1645
|
+
this.dropSurface(this.surface);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1342
1649
|
if (type === 'close_child_window') {
|
|
1343
1650
|
// TODO: Validate close_child_window message payload.
|
|
1344
1651
|
this.logInfo(
|
|
@@ -1381,6 +1688,407 @@ export default class PresentationWindow {
|
|
|
1381
1688
|
}
|
|
1382
1689
|
};
|
|
1383
1690
|
|
|
1691
|
+
private handleOpenVideo(payload?: any) {
|
|
1692
|
+
const { windowId, surfaceBroker, presentation } = this.options;
|
|
1693
|
+
|
|
1694
|
+
this.logInfo(
|
|
1695
|
+
`Received 'open_video' from window '${windowId}', url=${
|
|
1696
|
+
payload && payload.url
|
|
1697
|
+
}`
|
|
1698
|
+
);
|
|
1699
|
+
|
|
1700
|
+
if (!surfaceBroker || this.surfaceWithdrawn || this.isEmbeddedApp()) {
|
|
1701
|
+
this.refuseVideo('not_offered');
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
if (this.surface) {
|
|
1706
|
+
this.logWarning(
|
|
1707
|
+
`Ignoring 'open_video' from window '${windowId}', it already holds a surface`
|
|
1708
|
+
);
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
if (
|
|
1713
|
+
!payload ||
|
|
1714
|
+
typeof payload !== 'object' ||
|
|
1715
|
+
typeof payload.url !== 'string' ||
|
|
1716
|
+
!isValidRect(payload.bounds) ||
|
|
1717
|
+
!this.isOfferedVideoUrl(payload.url)
|
|
1718
|
+
) {
|
|
1719
|
+
this.refuseVideo('not_offered');
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const surface: WindowSurface = {
|
|
1724
|
+
handle: null,
|
|
1725
|
+
wantsSound: payload.muted === false,
|
|
1726
|
+
firstFrame: false,
|
|
1727
|
+
bounds: null,
|
|
1728
|
+
holeOpen: false,
|
|
1729
|
+
isHolePreopened: false,
|
|
1730
|
+
isPlayingAhead: false,
|
|
1731
|
+
plays: 0,
|
|
1732
|
+
};
|
|
1733
|
+
this.surface = surface;
|
|
1734
|
+
|
|
1735
|
+
surfaceBroker
|
|
1736
|
+
.open(
|
|
1737
|
+
windowId,
|
|
1738
|
+
{
|
|
1739
|
+
url: payload.url,
|
|
1740
|
+
bounds: roundRect(payload.bounds),
|
|
1741
|
+
// Always opened muted; sound waits for the show.
|
|
1742
|
+
muted: true,
|
|
1743
|
+
label: presentation.id,
|
|
1744
|
+
},
|
|
1745
|
+
(event) => this.handleSurfaceEvent(surface, event),
|
|
1746
|
+
(reason) => {
|
|
1747
|
+
if (this.surface !== surface) return;
|
|
1748
|
+
this.sendVideoMessage('video_refused', { reason });
|
|
1749
|
+
this.dropSurface(surface);
|
|
1750
|
+
}
|
|
1751
|
+
)
|
|
1752
|
+
.then(
|
|
1753
|
+
(handle) => {
|
|
1754
|
+
if (this.surface !== surface) return;
|
|
1755
|
+
surface.handle = handle;
|
|
1756
|
+
this.sendVideoMessage('video_opened');
|
|
1757
|
+
},
|
|
1758
|
+
(err: unknown) => {
|
|
1759
|
+
if (this.surface !== surface) return;
|
|
1760
|
+
const reason =
|
|
1761
|
+
err instanceof SurfaceRefusal ? err.reason : 'unsupported';
|
|
1762
|
+
// The broker has logged its refusal.
|
|
1763
|
+
if (reason !== 'closed') {
|
|
1764
|
+
this.sendVideoMessage('video_refused', { reason });
|
|
1765
|
+
}
|
|
1766
|
+
this.dropSurface(surface);
|
|
1767
|
+
}
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
private handleVideoBounds(payload?: any) {
|
|
1772
|
+
const surface = this.surface;
|
|
1773
|
+
if (!surface || !surface.handle) return;
|
|
1774
|
+
|
|
1775
|
+
if (!payload || !isValidRect(payload.bounds)) {
|
|
1776
|
+
this.logWarning(`Invalid payload received for 'video_bounds', ignoring`);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
const bounds = roundRect(payload.bounds);
|
|
1781
|
+
surface.handle.setBounds(bounds);
|
|
1782
|
+
|
|
1783
|
+
if (surface.firstFrame) {
|
|
1784
|
+
const wasSized = surface.bounds !== null;
|
|
1785
|
+
surface.bounds = bounds;
|
|
1786
|
+
if (!wasSized) {
|
|
1787
|
+
this.checkIsReadyCallback();
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
private handleSurfaceEvent(surface: WindowSurface, event: VideoEvent) {
|
|
1793
|
+
if (this.surface !== surface) return;
|
|
1794
|
+
// A stall is the host's to ride out, but it reaches the app: an app that
|
|
1795
|
+
// times its own complete holds that timer until the next 'playing'.
|
|
1796
|
+
|
|
1797
|
+
if (event.type === 'first_frame') {
|
|
1798
|
+
surface.firstFrame = true;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
this.sendVideoMessage('video_event', event);
|
|
1802
|
+
|
|
1803
|
+
if (event.type === 'playing' && this.playingWaiter) {
|
|
1804
|
+
this.playingWaiter();
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
if (event.type === 'error') {
|
|
1808
|
+
this.dropSurface(surface);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
private isSurfaceReady(surface: WindowSurface) {
|
|
1813
|
+
return !!surface.handle && surface.firstFrame && surface.bounds !== null;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// Only an http(s) url the presentation holds, or one of its renditions,
|
|
1817
|
+
// opens a surface: it is the url the app would give <video>.
|
|
1818
|
+
private isOfferedVideoUrl(url: string) {
|
|
1819
|
+
if (!/^https?:\/\//i.test(url)) return false;
|
|
1820
|
+
return collectUrls(this.presentation.applicationVariables).some(
|
|
1821
|
+
(offered) =>
|
|
1822
|
+
url === offered ||
|
|
1823
|
+
RENDITION_SUFFIXES.some((suffix) => url === `${offered}${suffix}`)
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Shows a ready surface: the host's show() is awaited, the app is told to
|
|
1828
|
+
// open its hole, and the frame is revealed once it has. One deadline covers
|
|
1829
|
+
// both waits; past it the app keeps <video> and the frame shows as today.
|
|
1830
|
+
private async showSurface(
|
|
1831
|
+
surface: WindowSurface,
|
|
1832
|
+
isFocused: boolean,
|
|
1833
|
+
onReveal?: () => void
|
|
1834
|
+
) {
|
|
1835
|
+
const { surfaceShowDeadlineMs = DEFAULT_SURFACE_SWAP_DELAY_MS } =
|
|
1836
|
+
this.options;
|
|
1837
|
+
const handle = surface.handle as VideoSurface;
|
|
1838
|
+
|
|
1839
|
+
let passDeadline: (value: 'deadline') => void = () => undefined;
|
|
1840
|
+
const deadline = new Promise<'deadline'>((resolve) => {
|
|
1841
|
+
passDeadline = resolve;
|
|
1842
|
+
});
|
|
1843
|
+
const timer = setTimeout(
|
|
1844
|
+
() => passDeadline('deadline'),
|
|
1845
|
+
surfaceShowDeadlineMs
|
|
1846
|
+
);
|
|
1847
|
+
// Resolves when the window is removed or gives its surface up.
|
|
1848
|
+
let wake: () => void = () => undefined;
|
|
1849
|
+
const woken = new Promise<'woken'>((resolve) => {
|
|
1850
|
+
wake = () => resolve('woken');
|
|
1851
|
+
});
|
|
1852
|
+
this.wakeSurfaceShow.push(wake);
|
|
1853
|
+
const holeOpen = new Promise<'hole_open'>((resolve) => {
|
|
1854
|
+
this.holeOpenWaiter = () => resolve('hole_open');
|
|
1855
|
+
});
|
|
1856
|
+
const finish = () => {
|
|
1857
|
+
clearTimeout(timer);
|
|
1858
|
+
this.holeOpenWaiter = null;
|
|
1859
|
+
this.wakeSurfaceShow = this.wakeSurfaceShow.filter((w) => w !== wake);
|
|
1860
|
+
};
|
|
1861
|
+
|
|
1862
|
+
let outcome: 'shown' | 'hole_open' | 'deadline' | 'woken' | 'failed' =
|
|
1863
|
+
await Promise.race([
|
|
1864
|
+
handle.show().then(
|
|
1865
|
+
() => 'shown' as const,
|
|
1866
|
+
() => 'failed' as const
|
|
1867
|
+
),
|
|
1868
|
+
deadline,
|
|
1869
|
+
woken,
|
|
1870
|
+
]);
|
|
1871
|
+
|
|
1872
|
+
if (outcome === 'shown' && this.surface === surface && !this.isRemoved) {
|
|
1873
|
+
if (surface.wantsSound && !this.forceMute) {
|
|
1874
|
+
handle.setMuted(false);
|
|
1875
|
+
}
|
|
1876
|
+
this.sendVideoMessage('video_shown');
|
|
1877
|
+
outcome = await Promise.race([holeOpen, deadline, woken]);
|
|
1878
|
+
}
|
|
1879
|
+
finish();
|
|
1880
|
+
|
|
1881
|
+
if (this.isRemoved) {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if (outcome === 'hole_open' && this.surface === surface) {
|
|
1886
|
+
this.revealOverHole(surface, isFocused, onReveal);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// A show the host rejected falls back as a missed deadline does, under
|
|
1891
|
+
// the host's own answer.
|
|
1892
|
+
if (
|
|
1893
|
+
(outcome === 'deadline' || outcome === 'failed') &&
|
|
1894
|
+
this.surface === surface
|
|
1895
|
+
) {
|
|
1896
|
+
this.refuseVideo(outcome === 'failed' ? 'unsupported' : 'deadline');
|
|
1897
|
+
this.dropSurface(surface);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// The surface is gone: the frame shows and plays as today.
|
|
1901
|
+
this.showFrame(isFocused);
|
|
1902
|
+
this.releaseHeldPlay();
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// A swap's incoming surface whose app opened its hole while hidden. It
|
|
1906
|
+
// plays first, still hidden and muted, and the frame is revealed over the
|
|
1907
|
+
// outgoing surface once it reports playing, so the picture is moving when
|
|
1908
|
+
// it appears and no first frame is held. The surface is shown
|
|
1909
|
+
// showAfterRevealMs later: it reaches the screen sooner than the frame
|
|
1910
|
+
// does, so the two land together. Sound waits for the show, so it never
|
|
1911
|
+
// overlaps the outgoing surface's. A surface that never reports playing is
|
|
1912
|
+
// revealed at the show deadline; past a second deadline on the show the
|
|
1913
|
+
// app keeps <video> in the frame already revealed.
|
|
1914
|
+
private async showSurfaceAfterReveal(
|
|
1915
|
+
surface: WindowSurface,
|
|
1916
|
+
isFocused: boolean,
|
|
1917
|
+
showAfterRevealMs: number,
|
|
1918
|
+
onReveal?: () => void
|
|
1919
|
+
) {
|
|
1920
|
+
const { surfaceShowDeadlineMs = DEFAULT_SURFACE_SWAP_DELAY_MS } =
|
|
1921
|
+
this.options;
|
|
1922
|
+
const handle = surface.handle as VideoSurface;
|
|
1923
|
+
|
|
1924
|
+
const timers: Array<ReturnType<typeof setTimeout>> = [];
|
|
1925
|
+
const after = <T>(ms: number, value: T) =>
|
|
1926
|
+
new Promise<T>((resolve) => {
|
|
1927
|
+
timers.push(setTimeout(() => resolve(value), ms));
|
|
1928
|
+
});
|
|
1929
|
+
// Resolves when the window is removed or gives its surface up.
|
|
1930
|
+
let wake: () => void = () => undefined;
|
|
1931
|
+
const woken = new Promise<'woken'>((resolve) => {
|
|
1932
|
+
wake = () => resolve('woken');
|
|
1933
|
+
});
|
|
1934
|
+
this.wakeSurfaceShow.push(wake);
|
|
1935
|
+
const isHeld = () => !this.isRemoved && this.surface === surface;
|
|
1936
|
+
|
|
1937
|
+
const playing = new Promise<'playing'>((resolve) => {
|
|
1938
|
+
this.playingWaiter = () => resolve('playing');
|
|
1939
|
+
});
|
|
1940
|
+
handle.play();
|
|
1941
|
+
surface.plays += 1;
|
|
1942
|
+
surface.isPlayingAhead = true;
|
|
1943
|
+
|
|
1944
|
+
let isRevealed = false;
|
|
1945
|
+
let outcome:
|
|
1946
|
+
| 'playing'
|
|
1947
|
+
| 'no_playing'
|
|
1948
|
+
| 'show'
|
|
1949
|
+
| 'shown'
|
|
1950
|
+
| 'deadline'
|
|
1951
|
+
| 'failed'
|
|
1952
|
+
| 'woken' = await Promise.race([
|
|
1953
|
+
playing,
|
|
1954
|
+
after(surfaceShowDeadlineMs, 'no_playing' as const),
|
|
1955
|
+
woken,
|
|
1956
|
+
]);
|
|
1957
|
+
this.playingWaiter = null;
|
|
1958
|
+
if (outcome === 'no_playing') {
|
|
1959
|
+
this.logWarning(
|
|
1960
|
+
`No 'playing' from the surface of window '${this.options.windowId}', revealing it anyway`
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
if ((outcome === 'playing' || outcome === 'no_playing') && isHeld()) {
|
|
1964
|
+
isRevealed = true;
|
|
1965
|
+
this.revealOverHole(surface, isFocused, onReveal);
|
|
1966
|
+
outcome = await Promise.race([
|
|
1967
|
+
after(showAfterRevealMs, 'show' as const),
|
|
1968
|
+
woken,
|
|
1969
|
+
]);
|
|
1970
|
+
}
|
|
1971
|
+
if (outcome === 'show' && isHeld()) {
|
|
1972
|
+
outcome = await Promise.race([
|
|
1973
|
+
handle.show().then(
|
|
1974
|
+
() => 'shown' as const,
|
|
1975
|
+
() => 'failed' as const
|
|
1976
|
+
),
|
|
1977
|
+
after(surfaceShowDeadlineMs, 'deadline' as const),
|
|
1978
|
+
woken,
|
|
1979
|
+
]);
|
|
1980
|
+
}
|
|
1981
|
+
timers.forEach((timer) => clearTimeout(timer));
|
|
1982
|
+
this.wakeSurfaceShow = this.wakeSurfaceShow.filter((w) => w !== wake);
|
|
1983
|
+
|
|
1984
|
+
if (this.isRemoved) {
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (outcome === 'shown' && this.surface === surface) {
|
|
1989
|
+
if (surface.wantsSound && !this.forceMute) {
|
|
1990
|
+
handle.setMuted(false);
|
|
1991
|
+
}
|
|
1992
|
+
this.sendVideoMessage('video_shown');
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
if (
|
|
1997
|
+
(outcome === 'deadline' || outcome === 'failed') &&
|
|
1998
|
+
this.surface === surface
|
|
1999
|
+
) {
|
|
2000
|
+
this.refuseVideo(outcome === 'failed' ? 'unsupported' : 'deadline');
|
|
2001
|
+
this.dropSurface(surface);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// The surface went before its reveal: the frame shows and plays as today.
|
|
2005
|
+
if (!isRevealed) {
|
|
2006
|
+
this.showFrame(isFocused);
|
|
2007
|
+
this.releaseHeldPlay();
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// Reveals the frame over its surface's open hole, and runs the caller's
|
|
2012
|
+
// onReveal and any held play in that same task.
|
|
2013
|
+
private revealOverHole(
|
|
2014
|
+
surface: WindowSurface,
|
|
2015
|
+
isFocused: boolean,
|
|
2016
|
+
onReveal?: () => void
|
|
2017
|
+
) {
|
|
2018
|
+
const { windowId, renderer, surfaceBroker } = this.options;
|
|
2019
|
+
surface.holeOpen = true;
|
|
2020
|
+
(surfaceBroker as SurfaceBroker).onHole(true);
|
|
2021
|
+
if (renderer.setWindowBackground) {
|
|
2022
|
+
renderer.setWindowBackground(windowId, 'transparent');
|
|
2023
|
+
}
|
|
2024
|
+
this.showFrame(isFocused);
|
|
2025
|
+
if (onReveal) onReveal();
|
|
2026
|
+
this.releaseHeldPlay();
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// Shown before its surface was ready: any surface it holds closes and the
|
|
2030
|
+
// window is offered none from then on.
|
|
2031
|
+
private withdrawSurface() {
|
|
2032
|
+
this.surfaceWithdrawn = true;
|
|
2033
|
+
const surface = this.surface;
|
|
2034
|
+
if (surface) {
|
|
2035
|
+
this.refuseVideo('not_offered');
|
|
2036
|
+
this.dropSurface(surface);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// The window gives its surface up: closed through the broker, its hole
|
|
2041
|
+
// reported closed and its frame background restored. It holds none from
|
|
2042
|
+
// then on, and counts ready and swaps as any other window.
|
|
2043
|
+
private dropSurface(surface: WindowSurface) {
|
|
2044
|
+
const { windowId, renderer, surfaceBroker } = this.options;
|
|
2045
|
+
if (this.surface !== surface || !surfaceBroker) return;
|
|
2046
|
+
|
|
2047
|
+
this.surface = null;
|
|
2048
|
+
this.surfaceWithdrawn = true;
|
|
2049
|
+
surfaceBroker.close(windowId);
|
|
2050
|
+
|
|
2051
|
+
if (surface.holeOpen) {
|
|
2052
|
+
surface.holeOpen = false;
|
|
2053
|
+
surfaceBroker.onHole(false);
|
|
2054
|
+
if (renderer.setWindowBackground) {
|
|
2055
|
+
renderer.setWindowBackground(windowId, this.frameBackground);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
this.wakeSurfaceShowWaiters();
|
|
2060
|
+
this.checkIsReadyCallback();
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
private wakeSurfaceShowWaiters() {
|
|
2064
|
+
const waiters = this.wakeSurfaceShow;
|
|
2065
|
+
this.wakeSurfaceShow = [];
|
|
2066
|
+
waiters.forEach((wake) => wake());
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
private releaseHeldPlay() {
|
|
2070
|
+
if (this.isPlayHeld) {
|
|
2071
|
+
this.isPlayHeld = false;
|
|
2072
|
+
this.play();
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
private refuseVideo(reason: VideoRefusalReason) {
|
|
2077
|
+
this.logWarning(
|
|
2078
|
+
`Sending 'video_refused' to window '${this.options.windowId}', reason=${reason}`
|
|
2079
|
+
);
|
|
2080
|
+
this.sendVideoMessage('video_refused', { reason });
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// Every video message rides the string channel the app renderer reads.
|
|
2084
|
+
private sendVideoMessage(type: string, payload?: any) {
|
|
2085
|
+
try {
|
|
2086
|
+
this.sendMessageString(type, payload);
|
|
2087
|
+
} catch (err: any) {
|
|
2088
|
+
this.logWarning(`Failed to send '${type}' with '${err.message}'`);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
1384
2092
|
private handleReady() {
|
|
1385
2093
|
this.didReceiveReady = true;
|
|
1386
2094
|
this.checkIsReadyCallback();
|