@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.
@@ -0,0 +1,278 @@
1
+ import SurfaceBroker, {
2
+ SurfaceRefusal,
3
+ SURFACE_FIRST_FRAME_DEADLINE_MS,
4
+ SURFACE_OPEN_DEADLINE_MS,
5
+ } from './SurfaceBroker';
6
+ import { VideoOptions, VideoSurface } from './surfaces';
7
+ import { createFakeSurfaceHost, createLogger } from './testUtils';
8
+
9
+ jest.useFakeTimers();
10
+
11
+ // Lets promise callbacks run under fake timers.
12
+ const settle = async () => {
13
+ for (let i = 0; i < 20; i += 1) {
14
+ await Promise.resolve();
15
+ }
16
+ };
17
+
18
+ const options = (label: string): VideoOptions => ({
19
+ url: `https://cdn.test/${label}.mp4`,
20
+ bounds: { x: 0, y: 0, width: 1920, height: 1080 },
21
+ muted: true,
22
+ label,
23
+ });
24
+
25
+ function setup(hostOpts?: Parameters<typeof createFakeSurfaceHost>[0]) {
26
+ const host = createFakeSurfaceHost(hostOpts);
27
+ const warn = jest.fn();
28
+ const broker = new SurfaceBroker(host.surfaces, createLogger({ warn }));
29
+ const onEvent = jest.fn();
30
+ const onRefused = jest.fn();
31
+ const open = (windowId: string) =>
32
+ broker.open(windowId, options(windowId), onEvent, onRefused);
33
+ const opens = () => host.calls.filter((c) => c.method === 'openVideo');
34
+ const refusalsLogged = (reason: string) =>
35
+ warn.mock.calls.filter(
36
+ ([, context]) => context && context.reason === reason
37
+ ).length;
38
+ return {
39
+ host,
40
+ broker,
41
+ warn,
42
+ onEvent,
43
+ onRefused,
44
+ open,
45
+ opens,
46
+ refusalsLogged,
47
+ };
48
+ }
49
+
50
+ // Records the outcome of an open without letting a rejection go unhandled.
51
+ function track(promise: Promise<VideoSurface>) {
52
+ const result: { surface?: VideoSurface; reason?: string } = {};
53
+ promise.then(
54
+ (surface) => {
55
+ result.surface = surface;
56
+ },
57
+ (err: SurfaceRefusal) => {
58
+ result.reason = err.reason;
59
+ }
60
+ );
61
+ return result;
62
+ }
63
+
64
+ describe('a refusal is answered', () => {
65
+ test.each(['no_decoder', 'unsupported'] as const)(
66
+ 'the host refusing %s is a refusal with that reason, logged once',
67
+ async (reason) => {
68
+ const { open, refusalsLogged } = setup({ refuse: reason });
69
+
70
+ const result = track(open('w1'));
71
+ await settle();
72
+
73
+ expect(result.reason).toBe(reason);
74
+ expect(refusalsLogged(reason)).toBe(1);
75
+ }
76
+ );
77
+
78
+ test('an open past its deadline is refused, and a late handle is closed at once', async () => {
79
+ const { host, open, refusalsLogged } = setup({ hang: true });
80
+
81
+ const result = track(open('w1'));
82
+ await settle();
83
+ expect(result.reason).toBeUndefined();
84
+
85
+ jest.advanceTimersByTime(SURFACE_OPEN_DEADLINE_MS);
86
+ await settle();
87
+ expect(result.reason).toBe('deadline');
88
+ expect(refusalsLogged('deadline')).toBe(1);
89
+
90
+ host.releaseHung();
91
+ await settle();
92
+ const lateId = host.lastId();
93
+ expect(
94
+ host.calls.filter((c) => c.id === lateId).map((c) => c.method)
95
+ ).toEqual(['close']);
96
+ expect(host.liveIds()).toEqual([]);
97
+ });
98
+
99
+ test('a first frame past its deadline closes the surface and is refused', async () => {
100
+ const { host, open, onRefused, onEvent, refusalsLogged } = setup();
101
+
102
+ const result = track(open('w1'));
103
+ await settle();
104
+ expect(result.surface).toBeDefined();
105
+
106
+ jest.advanceTimersByTime(SURFACE_FIRST_FRAME_DEADLINE_MS);
107
+ await settle();
108
+
109
+ expect(onRefused).toHaveBeenCalledWith('deadline');
110
+ expect(refusalsLogged('deadline')).toBe(1);
111
+ expect(host.liveIds()).toEqual([]);
112
+
113
+ // A first frame after the deadline is not forwarded.
114
+ host.emit(host.lastId(), {
115
+ type: 'first_frame',
116
+ width: 1920,
117
+ height: 1080,
118
+ durationS: 10,
119
+ });
120
+ expect(onEvent).not.toHaveBeenCalled();
121
+ });
122
+
123
+ test('a first frame in time is forwarded and stops the deadline', async () => {
124
+ const { host, open, onRefused, onEvent } = setup();
125
+
126
+ track(open('w1'));
127
+ await settle();
128
+ const frame = {
129
+ type: 'first_frame' as const,
130
+ width: 1920,
131
+ height: 1080,
132
+ durationS: 10,
133
+ };
134
+ host.emit(host.lastId(), frame);
135
+ jest.advanceTimersByTime(SURFACE_FIRST_FRAME_DEADLINE_MS * 2);
136
+ await settle();
137
+
138
+ expect(onEvent).toHaveBeenCalledWith(frame);
139
+ expect(onRefused).not.toHaveBeenCalled();
140
+ expect(host.liveIds()).toEqual([host.lastId()]);
141
+ });
142
+
143
+ test('no_decoder while a close is pending is tried once more after it', async () => {
144
+ const { host, broker, open, opens, refusalsLogged } = setup();
145
+
146
+ const first = track(open('w1'));
147
+ await settle();
148
+ const surface = first.surface as VideoSurface;
149
+ // A close the host takes 100 ms to finish.
150
+ const realClose = surface.close.bind(surface);
151
+ (surface as { close: () => Promise<void> }).close = () =>
152
+ new Promise((resolve) => setTimeout(() => resolve(realClose()), 100));
153
+
154
+ // The second open reaches the host before the close starts, and the host
155
+ // answers no_decoder while the close is still pending.
156
+ let rejectOpen: (err: Error) => void = () => undefined;
157
+ const realOpen = host.surfaces.openVideo;
158
+ host.surfaces.openVideo = (opts: VideoOptions) => {
159
+ host.surfaces.openVideo = realOpen;
160
+ host.calls.push({ method: 'openVideo', args: [opts], seq: 0 });
161
+ return new Promise((_, reject) => {
162
+ rejectOpen = reject;
163
+ });
164
+ };
165
+ const second = track(open('w2'));
166
+ await settle();
167
+ broker.close('w1');
168
+ rejectOpen(new Error('no_decoder'));
169
+ await settle();
170
+ expect(opens()).toHaveLength(2);
171
+
172
+ jest.advanceTimersByTime(100);
173
+ await settle();
174
+
175
+ expect(opens()).toHaveLength(3);
176
+ expect(second.surface).toBeDefined();
177
+ expect(refusalsLogged('no_decoder')).toBe(0);
178
+ });
179
+
180
+ test('no_decoder with no close pending is refused at once', async () => {
181
+ const { open, opens, refusalsLogged } = setup({ refuse: 'no_decoder' });
182
+
183
+ const result = track(open('w1'));
184
+ await settle();
185
+
186
+ expect(result.reason).toBe('no_decoder');
187
+ expect(opens()).toHaveLength(1);
188
+ expect(refusalsLogged('no_decoder')).toBe(1);
189
+ });
190
+ });
191
+
192
+ describe('two at most; an open waits for a close', () => {
193
+ test('a third open with none closing or leaving is refused with no host call', async () => {
194
+ const { host, open, opens, refusalsLogged } = setup();
195
+
196
+ track(open('w1'));
197
+ track(open('w2'));
198
+ await settle();
199
+ const third = track(open('w3'));
200
+ await settle();
201
+
202
+ expect(third.reason).toBe('no_decoder');
203
+ expect(opens()).toHaveLength(2);
204
+ expect(refusalsLogged('no_decoder')).toBe(1);
205
+ expect(host.violations).toEqual([]);
206
+ });
207
+
208
+ test('a third open with a surface leaving waits for its close', async () => {
209
+ const { host, broker, open, opens } = setup();
210
+
211
+ track(open('w1'));
212
+ track(open('w2'));
213
+ await settle();
214
+ broker.leaving('w1');
215
+ const third = track(open('w3'));
216
+ await settle();
217
+ expect(opens()).toHaveLength(2);
218
+ expect(third.surface).toBeUndefined();
219
+
220
+ await broker.close('w1');
221
+ await settle();
222
+
223
+ expect(opens()).toHaveLength(3);
224
+ expect(third.surface).toBeDefined();
225
+ expect(host.violations).toEqual([]);
226
+ });
227
+
228
+ test('a third open with a close in flight waits for that close', async () => {
229
+ const { host, broker, open, opens } = setup();
230
+
231
+ const first = track(open('w1'));
232
+ track(open('w2'));
233
+ await settle();
234
+ const surface = first.surface as VideoSurface;
235
+ const realClose = surface.close.bind(surface);
236
+ (surface as { close: () => Promise<void> }).close = () =>
237
+ new Promise((resolve) => setTimeout(() => resolve(realClose()), 100));
238
+
239
+ broker.close('w1');
240
+ const third = track(open('w3'));
241
+ await settle();
242
+ expect(opens()).toHaveLength(2);
243
+
244
+ jest.advanceTimersByTime(100);
245
+ await settle();
246
+
247
+ expect(opens()).toHaveLength(3);
248
+ expect(third.surface).toBeDefined();
249
+ expect(host.violations).toEqual([]);
250
+ });
251
+
252
+ test('an open given up before the host answers closes the handle', async () => {
253
+ const { host, broker, open } = setup({ hang: true });
254
+
255
+ const result = track(open('w1'));
256
+ await settle();
257
+ await broker.close('w1');
258
+ host.releaseHung();
259
+ await settle();
260
+
261
+ expect(result.reason).toBe('closed');
262
+ expect(host.liveIds()).toEqual([]);
263
+ });
264
+
265
+ test('close is idempotent and closeAll closes every surface', async () => {
266
+ const { host, broker, open } = setup();
267
+
268
+ track(open('w1'));
269
+ track(open('w2'));
270
+ await settle();
271
+ await broker.close('w1');
272
+ await broker.close('w1');
273
+ await broker.closeAll();
274
+
275
+ expect(host.liveIds()).toEqual([]);
276
+ expect(host.calls.filter((c) => c.method === 'close')).toHaveLength(2);
277
+ });
278
+ });
@@ -0,0 +1,281 @@
1
+ import { Logger } from './types';
2
+ import {
3
+ SurfaceHost,
4
+ VideoEvent,
5
+ VideoOptions,
6
+ VideoSurface,
7
+ VideoRefusalReason,
8
+ } from './surfaces';
9
+
10
+ // How long the host's openVideo may take to resolve.
11
+ export const SURFACE_OPEN_DEADLINE_MS = 2000;
12
+ // From openVideo resolving to first_frame. Inside the 30-second ready timeout,
13
+ // with time left for the app to fall back to <video> and still be ready.
14
+ export const SURFACE_FIRST_FRAME_DEADLINE_MS = 10000;
15
+ // One playing and one prepared.
16
+ export const MAX_LIVE_SURFACES = 2;
17
+ // How long a surface swap waits for the incoming window's first paint, and
18
+ // how long showing a surface may take, when closeWindowDelayMs is not set:
19
+ // 1000 ms, the closeWindowDelayMs the player passes.
20
+ export const DEFAULT_SURFACE_SWAP_DELAY_MS = 1000;
21
+ // SurfaceHost.showAfterRevealMs when the player sets none. On the Signage
22
+ // Stick (Fire OS 8.4, Amazon WebView 136, portrait) the WebView takes about
23
+ // 80-120 ms longer than a surface to put a reveal on screen: 60 ms left the
24
+ // theme trailing the picture by up to 35 ms, and 100 ms put it ahead by up
25
+ // to 54 ms.
26
+ export const DEFAULT_SHOW_AFTER_REVEAL_MS = 80;
27
+
28
+ export type SurfaceRefusalReason = Exclude<VideoRefusalReason, 'not_offered'>;
29
+
30
+ // Rejects an open. `closed` means the window gave the surface up before the
31
+ // open finished, which needs no answer to the app.
32
+ export class SurfaceRefusal extends Error {
33
+ constructor(public reason: SurfaceRefusalReason | 'closed') {
34
+ super(`Video surface refused: ${reason}`);
35
+ this.name = 'SurfaceRefusal';
36
+ Object.setPrototypeOf(this, SurfaceRefusal.prototype);
37
+ }
38
+ }
39
+
40
+ interface Entry {
41
+ windowId: string;
42
+ label?: string;
43
+ surface: VideoSurface | null;
44
+ state: 'opening' | 'open' | 'closing' | 'closed';
45
+ // A swap has started and this surface's close follows.
46
+ leaving: boolean;
47
+ firstFrameTimer: ReturnType<typeof setTimeout> | null;
48
+ closePromise: Promise<void> | null;
49
+ // Resolves once the surface is closed, or the open is given up.
50
+ closed: Promise<void>;
51
+ markClosed: () => void;
52
+ }
53
+
54
+ // The host rejects with an Error whose message is a short machine code.
55
+ const reasonOf = (err: unknown): SurfaceRefusalReason => {
56
+ if (err instanceof SurfaceRefusal && err.reason !== 'closed') {
57
+ return err.reason;
58
+ }
59
+ const code =
60
+ typeof err === 'string'
61
+ ? err
62
+ : err && typeof err === 'object'
63
+ ? (err as { message?: unknown }).message
64
+ : undefined;
65
+ return code === 'no_decoder' ? 'no_decoder' : 'unsupported';
66
+ };
67
+
68
+ // Holds every video surface one window manager has open, so the manager never
69
+ // asks the host for more than two at once and opens the next only after the
70
+ // outgoing one has closed, leaving the host an idle decoder to reuse.
71
+ export default class SurfaceBroker {
72
+ private entries: Record<string, Entry> = {};
73
+
74
+ constructor(private host: SurfaceHost, private logger: Logger) {}
75
+
76
+ // Resolves once the host has opened a hidden surface. Rejects with a
77
+ // SurfaceRefusal. A first frame later than its deadline closes the surface
78
+ // and reports it through onRefused instead.
79
+ public async open(
80
+ windowId: string,
81
+ opts: VideoOptions,
82
+ onEvent: (e: VideoEvent) => void,
83
+ onRefused: (reason: SurfaceRefusalReason) => void
84
+ ): Promise<VideoSurface> {
85
+ let markClosed = () => undefined as void;
86
+ const closed = new Promise<void>((resolve) => {
87
+ markClosed = resolve;
88
+ });
89
+ const entry: Entry = {
90
+ windowId,
91
+ label: opts.label,
92
+ surface: null,
93
+ state: 'opening',
94
+ leaving: false,
95
+ firstFrameTimer: null,
96
+ closePromise: null,
97
+ closed,
98
+ markClosed,
99
+ };
100
+ this.entries[windowId] = entry;
101
+
102
+ // A close pending or a surface leaving is waited for, so the host has an
103
+ // idle decoder by the time it is asked. Two live with neither is a refusal
104
+ // the host is never asked for.
105
+ for (;;) {
106
+ this.throwIfGivenUp(entry);
107
+ const pending = this.pendingCloses(entry);
108
+ if (pending.length > 0) {
109
+ await Promise.all(pending);
110
+ continue;
111
+ }
112
+ if (this.liveOthers(entry).length >= MAX_LIVE_SURFACES) {
113
+ throw this.refuse(entry, 'no_decoder');
114
+ }
115
+ break;
116
+ }
117
+
118
+ let surface: VideoSurface;
119
+ let retried = false;
120
+ for (;;) {
121
+ this.throwIfGivenUp(entry);
122
+ try {
123
+ surface = await this.openWithDeadline(opts);
124
+ break;
125
+ } catch (err) {
126
+ this.throwIfGivenUp(entry);
127
+ const reason = reasonOf(err);
128
+ const pending = this.pendingCloses(entry);
129
+ // The one retry: a close the page sent is still pending.
130
+ if (reason === 'no_decoder' && !retried && pending.length > 0) {
131
+ retried = true;
132
+ await Promise.all(pending);
133
+ continue;
134
+ }
135
+ throw this.refuse(entry, reason);
136
+ }
137
+ }
138
+
139
+ if (entry.state === 'closed') {
140
+ surface.close();
141
+ throw new SurfaceRefusal('closed');
142
+ }
143
+
144
+ entry.surface = surface;
145
+ entry.state = 'open';
146
+ entry.firstFrameTimer = setTimeout(() => {
147
+ entry.firstFrameTimer = null;
148
+ this.warnRefusal(entry, 'deadline');
149
+ this.close(windowId);
150
+ onRefused('deadline');
151
+ }, SURFACE_FIRST_FRAME_DEADLINE_MS);
152
+
153
+ surface.onEvent((e) => {
154
+ if (entry.state !== 'open') return;
155
+ if (e.type === 'first_frame') {
156
+ if (!entry.firstFrameTimer) return;
157
+ clearTimeout(entry.firstFrameTimer);
158
+ entry.firstFrameTimer = null;
159
+ }
160
+ onEvent(e);
161
+ });
162
+
163
+ return surface;
164
+ }
165
+
166
+ // A swap has started for this window's surface; its close follows.
167
+ public leaving(windowId: string): void {
168
+ const entry = this.entries[windowId];
169
+ if (entry) entry.leaving = true;
170
+ }
171
+
172
+ // Closes the window's surface, or gives up its open. Idempotent.
173
+ public close(windowId: string): Promise<void> {
174
+ const entry = this.entries[windowId];
175
+ if (!entry) return Promise.resolve();
176
+ if (entry.closePromise) return entry.closePromise;
177
+
178
+ if (entry.firstFrameTimer) {
179
+ clearTimeout(entry.firstFrameTimer);
180
+ entry.firstFrameTimer = null;
181
+ }
182
+
183
+ const surface = entry.surface;
184
+ if (!surface) {
185
+ // Still opening: open() closes a handle that resolves from here on.
186
+ this.forget(entry);
187
+ return Promise.resolve();
188
+ }
189
+
190
+ entry.state = 'closing';
191
+ entry.closePromise = surface
192
+ .close()
193
+ .catch((err) => {
194
+ this.logger.warn(
195
+ `Failed to close video surface for window '${windowId}' with '${
196
+ err && err.message
197
+ }'`,
198
+ { windowId, presentationId: entry.label }
199
+ );
200
+ })
201
+ .then(() => this.forget(entry));
202
+ return entry.closePromise;
203
+ }
204
+
205
+ public async closeAll(): Promise<void> {
206
+ await Promise.all(Object.keys(this.entries).map((id) => this.close(id)));
207
+ }
208
+
209
+ // A window's hole opened or closed.
210
+ public onHole(open: boolean): void {
211
+ this.host.onHole(open);
212
+ }
213
+
214
+ private openWithDeadline(opts: VideoOptions): Promise<VideoSurface> {
215
+ return new Promise((resolve, reject) => {
216
+ let settled = false;
217
+ const timer = setTimeout(() => {
218
+ settled = true;
219
+ reject(new SurfaceRefusal('deadline'));
220
+ }, SURFACE_OPEN_DEADLINE_MS);
221
+
222
+ new Promise<VideoSurface>((r) => r(this.host.openVideo(opts))).then(
223
+ (surface) => {
224
+ if (settled) {
225
+ // A late handle never holds a decoder nobody uses.
226
+ surface.close();
227
+ return;
228
+ }
229
+ settled = true;
230
+ clearTimeout(timer);
231
+ resolve(surface);
232
+ },
233
+ (err) => {
234
+ if (settled) return;
235
+ settled = true;
236
+ clearTimeout(timer);
237
+ reject(err);
238
+ }
239
+ );
240
+ });
241
+ }
242
+
243
+ private throwIfGivenUp(entry: Entry) {
244
+ if (entry.state === 'closed') {
245
+ throw new SurfaceRefusal('closed');
246
+ }
247
+ }
248
+
249
+ private liveOthers(entry: Entry): Entry[] {
250
+ return Object.keys(this.entries)
251
+ .map((id) => this.entries[id])
252
+ .filter((e) => e !== entry && e.state !== 'closed');
253
+ }
254
+
255
+ private pendingCloses(entry: Entry): Promise<void>[] {
256
+ return this.liveOthers(entry)
257
+ .filter((e) => e.state === 'closing' || e.leaving)
258
+ .map((e) => e.closed);
259
+ }
260
+
261
+ private refuse(entry: Entry, reason: SurfaceRefusalReason) {
262
+ this.forget(entry);
263
+ this.warnRefusal(entry, reason);
264
+ return new SurfaceRefusal(reason);
265
+ }
266
+
267
+ private warnRefusal(entry: Entry, reason: SurfaceRefusalReason) {
268
+ this.logger.warn(
269
+ `Sending 'video_refused' to window '${entry.windowId}', reason=${reason}`,
270
+ { windowId: entry.windowId, presentationId: entry.label, reason }
271
+ );
272
+ }
273
+
274
+ private forget(entry: Entry) {
275
+ entry.state = 'closed';
276
+ if (this.entries[entry.windowId] === entry) {
277
+ delete this.entries[entry.windowId];
278
+ }
279
+ entry.markClosed();
280
+ }
281
+ }
@@ -0,0 +1,26 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`no surface host a two-presentation swap keeps the renderer call sequence 1`] = `
4
+ Array [
5
+ "createWindow w1",
6
+ "muteWindow w1",
7
+ "loadWindow w1",
8
+ "sendMessageString w1 render",
9
+ "createWindow w2",
10
+ "muteWindow w2",
11
+ "loadWindow w2",
12
+ "sendMessageString w2 render",
13
+ "showWindow w2",
14
+ "unMuteWindow w2",
15
+ "sendMessageString w2 play",
16
+ "showWindow w1",
17
+ "unMuteWindow w1",
18
+ "muteWindow w2",
19
+ "sendMessageString w1 play",
20
+ "createWindow w3",
21
+ "muteWindow w3",
22
+ "loadWindow w3",
23
+ "sendMessageString w3 render",
24
+ "removeWindow w2",
25
+ ]
26
+ `;
package/src/index.ts CHANGED
@@ -11,3 +11,4 @@ export * from './PresentationWindow';
11
11
  export { PresentationWindow };
12
12
 
13
13
  export * from './types';
14
+ export * from './surfaces';
@@ -0,0 +1,82 @@
1
+ // The video contract's types, copied verbatim (exported, and kept out of
2
+ // prettier so the copy stays diffable against the source) from the device
3
+ // video contract that the Signage Stick's host builds to. A surface is a video
4
+ // the host draws outside the browser, under the page, in a hole the page
5
+ // clears.
6
+
7
+ // prettier-ignore
8
+ export type VideoOptions = {
9
+ url: string; // fetched through the native cache like any page request
10
+ bounds: Rect; // the hole the page clears, in CSS px of the main frame,
11
+ // converted with the view's own page scale, never the
12
+ // platform density; the host fills it edge to edge. The
13
+ // page re-sends them on its resize
14
+ fit?: 'cover' | 'stretch'; // cover (default): the picture fills the hole, cropped;
15
+ // stretch: scaled to it. A letterbox is the page's: it
16
+ // sizes the hole to the picture from first_frame
17
+ loop?: boolean; // default false
18
+ muted?: boolean; // default true; the page unmutes when it owns the audio
19
+ label?: string; // the page's own name for the surface, written beside
20
+ // the host's id in its logs and events
21
+ };
22
+
23
+ // prettier-ignore
24
+ export interface VideoSurface {
25
+ readonly id: number; // the host's handle, for logs
26
+ show(opts?: { fadeMs?: number }): Promise<void>; // last shown is on top. Resolves
27
+ // once on screen, after any fade
28
+ hide(opts?: { fadeMs?: number }): Promise<void>; // the picture stays decoded, so a
29
+ // later show costs no first frame
30
+ play(): Promise<void>; // from the current position; a new surface starts at 0
31
+ pause(): Promise<void>;
32
+ seek(seconds: number): Promise<void>;
33
+ setBounds(r: Rect): Promise<void>;
34
+ setMuted(m: boolean): Promise<void>;
35
+ close(): Promise<void>; // frees the decoder slot. Idempotent, and every call on
36
+ // a closed handle resolves and does nothing
37
+ onEvent(fn: (e: VideoEvent) => void): void; // one listener; the last set wins.
38
+ // Events raised before any listener is set are kept
39
+ // and handed to the first one, in order
40
+ }
41
+
42
+ // prettier-ignore
43
+ export type Rect = { x: number; y: number; width: number; height: number };
44
+ // prettier-ignore
45
+ export type VideoEvent =
46
+ | { type: 'first_frame'; width: number; height: number; durationS: number | null }
47
+ // null: a stream, or a container that states no length
48
+ | { type: 'playing' } // after play, and after a stall ends
49
+ | { type: 'stalled' } // the decoder is waiting on bytes
50
+ | { type: 'ended' } // never fires with loop
51
+ | { type: 'error'; reason: 'fetch' | 'decode' | 'unsupported' | 'closed' };
52
+
53
+ // What the player hands the window manager when its host can draw a surface.
54
+ export interface SurfaceHost {
55
+ // The host's openVideo, bound by the player.
56
+ openVideo(opts: VideoOptions): Promise<VideoSurface>;
57
+ // A window's hole opened or closed. Called once with true and once with
58
+ // false per surface, so the player can count open holes.
59
+ onHole(open: boolean): void;
60
+ // On a swap whose incoming app opened its hole while hidden, how long
61
+ // after the incoming frame's reveal its surface is shown, so the picture
62
+ // and the frame's theme reach the screen together. A surface puts a change
63
+ // on screen sooner than the WebView, by an amount that depends on the
64
+ // device, so the player sets this per device class. Default 80.
65
+ showAfterRevealMs?: number;
66
+ }
67
+
68
+ // The messages an app sends to ask for, size, reveal and give up a surface.
69
+ export const VIDEO_REQUESTS = [
70
+ 'open_video',
71
+ 'video_bounds',
72
+ 'video_hole_open',
73
+ 'close_video',
74
+ ];
75
+
76
+ // Why the window manager answered an app's request with video_refused. The
77
+ // app keeps <video> on every one.
78
+ export type VideoRefusalReason =
79
+ | 'no_decoder'
80
+ | 'unsupported'
81
+ | 'deadline'
82
+ | 'not_offered';