@raydiant/playlist-window-manager 0.29.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/IFrameRenderer.d.ts +1 -0
- package/build/IFrameRenderer.js +14 -0
- package/build/IFrameRenderer.test.d.ts +1 -0
- package/build/IFrameRenderer.test.js +70 -0
- package/build/PlaylistWindowManager.d.ts +7 -0
- package/build/PlaylistWindowManager.js +186 -65
- package/build/PlaylistWindowManager.test.d.ts +1 -0
- package/build/PlaylistWindowManager.test.js +1763 -0
- package/build/PresentationWindow.d.ts +37 -1
- package/build/PresentationWindow.js +601 -31
- package/build/PresentationWindow.test.js +509 -0
- package/build/SurfaceBroker.d.ts +30 -0
- package/build/SurfaceBroker.js +311 -0
- package/build/SurfaceBroker.test.d.ts +1 -0
- package/build/SurfaceBroker.test.js +432 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/surfaces.d.ts +52 -0
- package/build/surfaces.js +15 -0
- package/build/testUtils.d.ts +56 -0
- package/build/testUtils.js +327 -5
- package/build/types.d.ts +1 -0
- package/package.json +2 -1
- package/src/IFrameRenderer.test.ts +88 -0
- package/src/IFrameRenderer.ts +21 -0
- package/src/PlaylistWindowManager.test.ts +1466 -0
- package/src/PlaylistWindowManager.ts +164 -20
- package/src/PresentationWindow.test.ts +371 -0
- package/src/PresentationWindow.ts +712 -4
- package/src/SurfaceBroker.test.ts +278 -0
- package/src/SurfaceBroker.ts +281 -0
- package/src/__snapshots__/PlaylistWindowManager.test.ts.snap +26 -0
- package/src/index.ts +1 -0
- package/src/surfaces.ts +82 -0
- package/src/testUtils.ts +335 -1
- package/src/types.ts +5 -0
|
@@ -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
package/src/surfaces.ts
ADDED
|
@@ -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';
|