@raydiant/playlist-window-manager 0.29.2 → 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 +150 -39
- package/build/PlaylistWindowManager.test.d.ts +1 -0
- package/build/PlaylistWindowManager.test.js +1763 -0
- package/build/PresentationWindow.d.ts +31 -1
- package/build/PresentationWindow.js +538 -30
- package/build/PresentationWindow.test.js +323 -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 +134 -7
- package/src/PresentationWindow.test.ts +247 -0
- package/src/PresentationWindow.ts +644 -3
- 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,11 +65,74 @@ 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
77
|
const defaultAppRendererUrl = 'https://apps.raydiant.com/3.1.1/index.html';
|
|
67
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
|
+
};
|
|
135
|
+
|
|
68
136
|
const isValidNumber = (value: any) =>
|
|
69
137
|
typeof value === 'number' && !isNaN(value);
|
|
70
138
|
|
|
@@ -157,6 +225,18 @@ export default class PresentationWindow {
|
|
|
157
225
|
private interruptLocks: Record<string, InterruptLock> = {};
|
|
158
226
|
private previouslyLocked = false;
|
|
159
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
|
+
|
|
160
240
|
constructor(private options: PresentationWindowOptions) {
|
|
161
241
|
this.builderState = options.builderState;
|
|
162
242
|
this.presentation = options.presentation;
|
|
@@ -174,7 +254,51 @@ export default class PresentationWindow {
|
|
|
174
254
|
return this.loadLegacyApp();
|
|
175
255
|
}
|
|
176
256
|
|
|
177
|
-
|
|
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) {
|
|
178
302
|
const { windowId, renderer } = this.options;
|
|
179
303
|
|
|
180
304
|
// Show the main window and focus it before any child windows. Showing a child
|
|
@@ -247,11 +371,31 @@ export default class PresentationWindow {
|
|
|
247
371
|
// window that is going away.
|
|
248
372
|
this.resolveFirstPaintWaiters();
|
|
249
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
|
+
|
|
250
379
|
// Remove any child window managers.
|
|
251
380
|
Object.values(this.windowManagers).forEach((wm) => {
|
|
252
381
|
wm.destroy();
|
|
253
382
|
});
|
|
254
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
|
+
|
|
255
399
|
// Remove the main window after removing all the child window managers.
|
|
256
400
|
try {
|
|
257
401
|
await renderer.removeWindow(windowId);
|
|
@@ -275,6 +419,15 @@ export default class PresentationWindow {
|
|
|
275
419
|
public play(): void {
|
|
276
420
|
const { applicationVersion, presentation } = this.options;
|
|
277
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
|
+
|
|
278
431
|
// Each play starts a new paint cycle; 'first_painted' arrives once the
|
|
279
432
|
// window has presented a frame after this play.
|
|
280
433
|
this.didPaintSinceLastPlay = false;
|
|
@@ -294,6 +447,20 @@ export default class PresentationWindow {
|
|
|
294
447
|
if (!this.isEmbeddedApp()) {
|
|
295
448
|
this.sendMessageString('play');
|
|
296
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
|
+
|
|
297
464
|
// Play any child window managers.
|
|
298
465
|
Object.values(this.windowManagers).forEach((wm) => {
|
|
299
466
|
// We only want to trigger play on child windows the first play of the main window or if
|
|
@@ -369,6 +536,28 @@ export default class PresentationWindow {
|
|
|
369
536
|
renderer.muteWindow(windowId);
|
|
370
537
|
}
|
|
371
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
|
+
|
|
372
561
|
private resolveFirstPaintWaiters() {
|
|
373
562
|
const resolvers = this.firstPaintResolvers;
|
|
374
563
|
this.firstPaintResolvers = [];
|
|
@@ -406,6 +595,12 @@ export default class PresentationWindow {
|
|
|
406
595
|
if (this.isEmbeddedApp()) {
|
|
407
596
|
return this.didEmbeddedAppLoad;
|
|
408
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
|
+
}
|
|
409
604
|
return this.didReceiveReady && this.allChildWindowManagersReady();
|
|
410
605
|
}
|
|
411
606
|
|
|
@@ -697,13 +892,14 @@ export default class PresentationWindow {
|
|
|
697
892
|
|
|
698
893
|
this.logInfo(`Create window '${windowId}'`);
|
|
699
894
|
|
|
895
|
+
this.frameBackground = backgroundColor || '#000000'; // Default to black bg for Raydiant apps
|
|
700
896
|
await this.createAndLoadWindow(
|
|
701
897
|
this.getFullAppRendererUrl({
|
|
702
898
|
appRendererUrl,
|
|
703
899
|
windowId,
|
|
704
900
|
appRendererOfflineSupport,
|
|
705
901
|
}),
|
|
706
|
-
|
|
902
|
+
this.frameBackground,
|
|
707
903
|
this.handleLegacyMessage
|
|
708
904
|
);
|
|
709
905
|
|
|
@@ -827,7 +1023,11 @@ export default class PresentationWindow {
|
|
|
827
1023
|
}
|
|
828
1024
|
|
|
829
1025
|
private sendRenderMessage() {
|
|
830
|
-
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 } : {};
|
|
831
1031
|
// IFrames do not have a mute/unmute API so we are overriding any mute properties
|
|
832
1032
|
// to support muting MZ zones when using the IFrameRenderer (SecondScreen).
|
|
833
1033
|
if (this.forceMute) {
|
|
@@ -847,6 +1047,7 @@ export default class PresentationWindow {
|
|
|
847
1047
|
applicationVersion,
|
|
848
1048
|
device,
|
|
849
1049
|
isPreview,
|
|
1050
|
+
...surfaceOffer,
|
|
850
1051
|
});
|
|
851
1052
|
} else {
|
|
852
1053
|
this.sendMessageString('render', {
|
|
@@ -856,6 +1057,7 @@ export default class PresentationWindow {
|
|
|
856
1057
|
applicationVersion,
|
|
857
1058
|
device,
|
|
858
1059
|
isPreview,
|
|
1060
|
+
...surfaceOffer,
|
|
859
1061
|
});
|
|
860
1062
|
}
|
|
861
1063
|
}
|
|
@@ -1335,6 +1537,12 @@ export default class PresentationWindow {
|
|
|
1335
1537
|
break;
|
|
1336
1538
|
}
|
|
1337
1539
|
|
|
1540
|
+
case 'open_video': {
|
|
1541
|
+
// An embedded app is never offered a video surface.
|
|
1542
|
+
this.refuseVideo('not_offered');
|
|
1543
|
+
break;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1338
1546
|
default: {
|
|
1339
1547
|
this.logWarning(`Received unknown embedded message type: ${type}`);
|
|
1340
1548
|
}
|
|
@@ -1406,6 +1614,38 @@ export default class PresentationWindow {
|
|
|
1406
1614
|
}
|
|
1407
1615
|
}
|
|
1408
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
|
+
|
|
1409
1649
|
if (type === 'close_child_window') {
|
|
1410
1650
|
// TODO: Validate close_child_window message payload.
|
|
1411
1651
|
this.logInfo(
|
|
@@ -1448,6 +1688,407 @@ export default class PresentationWindow {
|
|
|
1448
1688
|
}
|
|
1449
1689
|
};
|
|
1450
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
|
+
|
|
1451
2092
|
private handleReady() {
|
|
1452
2093
|
this.didReceiveReady = true;
|
|
1453
2094
|
this.checkIsReadyCallback();
|