@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.
@@ -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
- 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) {
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
- backgroundColor || '#000000', // Default to black bg for Raydiant apps
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 } = 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 } : {};
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();