@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.
@@ -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.0/index.html';
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
- public show(isFocused: boolean = true): void {
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
- backgroundColor || '#000000', // Default to black bg for Raydiant apps
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 } = this.options;
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();