@applicaster/zapp-react-native-utils 16.0.0-rc.120 → 16.0.0-rc.122

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,62 @@
1
+ import { Player } from "../player";
2
+
3
+ const entry = (id: string) => ({ id, title: id }) as unknown as ZappEntry;
4
+
5
+ const makePlayer = () => {
6
+ const player = new Player({ playerId: "test-player" });
7
+ player.entry = entry("first");
8
+
9
+ return player;
10
+ };
11
+
12
+ describe("Player per-entry diagnostics", () => {
13
+ it("keeps the latest of onError and onVideoError, and clears it when a new entry loads", () => {
14
+ const player = makePlayer();
15
+ const second = { message: "second" };
16
+
17
+ player.onError({ message: "first" } as any);
18
+ player.onVideoError(second as any);
19
+
20
+ expect(player.lastError).toBe(second);
21
+
22
+ player.entry = entry("second");
23
+
24
+ expect(player.lastError).toBeNull();
25
+ });
26
+
27
+ it("reads the next entry without creating the overlay observer", () => {
28
+ const player = makePlayer();
29
+
30
+ expect(player.getPlayNextEntry()).toBeNull();
31
+
32
+ jest
33
+ .spyOn(player.getOverlayObservable(), "getPlayNextEntry")
34
+ .mockReturnValue(entry("next"));
35
+
36
+ expect(player.getPlayNextEntry()).toEqual(entry("next"));
37
+ });
38
+
39
+ it("keeps the content clock while a client-side ad plays", () => {
40
+ const player = makePlayer();
41
+
42
+ player.onVideoProgress({
43
+ currentTime: 300,
44
+ duration: 1800,
45
+ seekableDuration: 1800,
46
+ });
47
+
48
+ player.onAdBegin({});
49
+
50
+ player.onVideoProgress({
51
+ currentTime: 5,
52
+ duration: 15,
53
+ seekableDuration: 15,
54
+ });
55
+
56
+ expect(player.getPosition()).toBe(5);
57
+ expect(player.getDuration()).toBe(15);
58
+ expect(player.getContentPosition()).toBe(300);
59
+ expect(player.getContentDuration()).toBe(1800);
60
+ expect(player.getSeekableDuration()).toBe(1800);
61
+ });
62
+ });
@@ -18,6 +18,9 @@ const loadToolbox = () => {
18
18
  const player = (overrides = {}) => ({
19
19
  getSeekableDuration: () => 100,
20
20
  getContentPosition: () => 40,
21
+ isLive: () => false,
22
+ getPlayNextEntry: () => null,
23
+ playNextHandler: jest.fn(),
21
24
  seekTo: jest.fn(),
22
25
  ...overrides,
23
26
  });
@@ -30,7 +33,7 @@ const registeredTools = async (active) => {
30
33
  mockXray = { registerReactToolbox: mockRegister };
31
34
  await loadToolbox().registerPlayerToolbox(manager(active));
32
35
 
33
- const { tools } = mockRegister.mock.calls[0][0];
36
+ const { tools } = mockRegister.mock.lastCall[0];
34
37
 
35
38
  return Object.fromEntries(tools.map((tool) => [tool.name, tool.handler]));
36
39
  };
@@ -87,6 +90,187 @@ describe("playerToolbox", () => {
87
90
  );
88
91
  });
89
92
 
93
+ describe("state", () => {
94
+ const playing = (overrides = {}, entry: any = { id: "e1", title: "T" }) =>
95
+ player({
96
+ playerId: "p1",
97
+ playerRole: "Primary",
98
+ getEntry: () => entry,
99
+ getPosition: () => 40,
100
+ getDuration: () => 100,
101
+ getContentDuration: () => 100,
102
+ isReadyToPlay: () => true,
103
+ isPlaying: () => true,
104
+ isBuffering: () => false,
105
+ isAd: () => false,
106
+ isLive: () => false,
107
+ getIsMuted: () => false,
108
+ ...overrides,
109
+ });
110
+
111
+ const state = async (overrides = {}, entry?: any) =>
112
+ (await registeredTools(playing(overrides, entry))).state();
113
+
114
+ it("reports inactive when nothing plays", async () => {
115
+ expect((await registeredTools(null)).state()).toEqual({
116
+ active: false,
117
+ casting: false,
118
+ });
119
+ });
120
+
121
+ it("keeps the existing fields and adds the content clock for a VOD", async () => {
122
+ expect(await state()).toEqual({
123
+ active: true,
124
+ playerId: "p1",
125
+ entry: { id: "e1", title: "T" },
126
+ position: 40,
127
+ duration: 100,
128
+ playing: true,
129
+ buffering: false,
130
+ readyToPlay: true,
131
+ ad: false,
132
+ live: false,
133
+ muted: false,
134
+ role: "Primary",
135
+ plugin: null,
136
+ engine: null,
137
+ casting: false,
138
+ seekableDuration: 100,
139
+ contentPosition: 40,
140
+ contentDuration: 100,
141
+ drm: null,
142
+ lastError: null,
143
+ next: null,
144
+ });
145
+ });
146
+
147
+ it("reports the content's clock next to the ad's during a client-side ad", async () => {
148
+ const result = await state({
149
+ isAd: () => true,
150
+ getPosition: () => 5,
151
+ getDuration: () => 15,
152
+ getContentPosition: () => 300,
153
+ getContentDuration: () => 1800,
154
+ });
155
+
156
+ expect(result).toMatchObject({
157
+ ad: true,
158
+ position: 5,
159
+ duration: 15,
160
+ contentPosition: 300,
161
+ contentDuration: 1800,
162
+ });
163
+ });
164
+
165
+ it("reports non-finite numbers as null", async () => {
166
+ expect(
167
+ await state({
168
+ getSeekableDuration: () => NaN,
169
+ getContentPosition: () => null,
170
+ getContentDuration: () => Infinity,
171
+ })
172
+ ).toMatchObject({
173
+ seekableDuration: null,
174
+ contentPosition: null,
175
+ contentDuration: null,
176
+ });
177
+ });
178
+
179
+ it("reports the player plugin and its web engine", async () => {
180
+ expect(
181
+ await state({
182
+ playerPluginId: "QuickBrickPlayerPlugin",
183
+ engine: "hls.js",
184
+ })
185
+ ).toMatchObject({ plugin: "QuickBrickPlayerPlugin", engine: "hls.js" });
186
+ });
187
+
188
+ it("lists the entry's DRM key systems, or null", async () => {
189
+ const drm = { widevine: { license_url: "w" }, playready: {} };
190
+
191
+ expect((await state({}, { id: "e", extensions: { drm } })).drm).toEqual([
192
+ "widevine",
193
+ "playready",
194
+ ]);
195
+
196
+ expect((await state({}, { id: "e", extensions: { drm: {} } })).drm).toBe(
197
+ null
198
+ );
199
+
200
+ expect((await state({}, { id: "e", extensions: {} })).drm).toBe(null);
201
+ expect((await state({}, null)).drm).toBe(null);
202
+ });
203
+
204
+ it("reports no last error as null", async () => {
205
+ expect((await state({ lastError: null })).lastError).toBeNull();
206
+ });
207
+ });
208
+
209
+ describe("play next", () => {
210
+ const nextEntry = { id: "n1", title: "Next", extensions: {} };
211
+
212
+ const stateWith = async (overrides) =>
213
+ (
214
+ await registeredTools(
215
+ player({
216
+ getEntry: () => null,
217
+ getPosition: () => 0,
218
+ getDuration: () => 0,
219
+ getContentDuration: () => 0,
220
+ isReadyToPlay: () => true,
221
+ isPlaying: () => true,
222
+ isBuffering: () => false,
223
+ isAd: () => false,
224
+ getIsMuted: () => false,
225
+ ...overrides,
226
+ })
227
+ )
228
+ ).state();
229
+
230
+ it("reports the entry Play Next would play", async () => {
231
+ expect(
232
+ (await stateWith({ getPlayNextEntry: () => nextEntry })).next
233
+ ).toEqual({ id: "n1", title: "Next" });
234
+ });
235
+
236
+ it("reports no next entry as null", async () => {
237
+ expect((await stateWith({})).next).toBeNull();
238
+ });
239
+
240
+ it("plays the next entry through the player's Play Next action", async () => {
241
+ const active = player({ getPlayNextEntry: () => nextEntry });
242
+ const tools = await registeredTools(active);
243
+
244
+ expect(tools.next()).toEqual({ id: "n1", title: "Next" });
245
+ expect(active.playNextHandler).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ it("fails without a next entry", async () => {
249
+ const active = player();
250
+ const tools = await registeredTools(active);
251
+
252
+ expect(() => tools.next()).toThrow("No next item");
253
+ expect(active.playNextHandler).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it("fails when no Play Next action is registered", async () => {
257
+ const tools = await registeredTools(
258
+ player({
259
+ getPlayNextEntry: () => nextEntry,
260
+ playNextHandler: null,
261
+ })
262
+ );
263
+
264
+ expect(() => tools.next()).toThrow("No next item");
265
+ });
266
+
267
+ it("fails without an active player", async () => {
268
+ const tools = await registeredTools(null);
269
+
270
+ expect(() => tools.next()).toThrow("No active player");
271
+ });
272
+ });
273
+
90
274
  it("refuses to seek a stream that is not seekable", async () => {
91
275
  const tools = await registeredTools(
92
276
  player({ getSeekableDuration: () => 0 })
@@ -76,6 +76,12 @@ export class Player {
76
76
  private overlaysObserver?: OverlaysObserver | null;
77
77
  private entryObservable?: BehaviorSubject<ZappEntry> | null;
78
78
  private contentSubject?: BehaviorSubject<PlayerContent> | null;
79
+ // Read by the X-Ray `player` toolbox: the last onError/onVideoError of the current entry,
80
+ // and the Play Next action of the PlayerContainer showing this player.
81
+ public lastError: unknown = null;
82
+ public playNextHandler: (() => void) | null = null;
83
+ // The web engine the default player picked (hls.js, dash.js, shaka, video.js); null elsewhere.
84
+ public engine: string | null = null;
79
85
 
80
86
  protected listeners: {
81
87
  [key: string]: QuickBrickPlayer.SharedPlayerCallBacks;
@@ -89,6 +95,7 @@ export class Player {
89
95
 
90
96
  set entry(newEntry: ZappEntry | null) {
91
97
  this._entry = newEntry;
98
+ this.lastError = null;
92
99
  // The live entry is scoped to the entry it overlays - see
93
100
  // `OverlaysObserver.clearLiveEntry`. No-op when there is no observer,
94
101
  // which is every non-live player.
@@ -271,6 +278,10 @@ export class Player {
271
278
 
272
279
  getEntry = (): ZappEntry | null => this.entry;
273
280
 
281
+ /** The entry Play Next would play now; null without one. Never creates the overlay observer. */
282
+ getPlayNextEntry = (): ZappEntry | null =>
283
+ this.overlaysObserver?.getPlayNextEntry() ?? null;
284
+
274
285
  logState = (text, additionalParams = {}) => {
275
286
  const isSeeking = this.playerState.seekPosition !== null;
276
287
 
@@ -492,6 +503,8 @@ export class Player {
492
503
  };
493
504
 
494
505
  onVideoError = (error: Error) => {
506
+ this.lastError = error;
507
+
495
508
  this.invokeListenersUpdate({
496
509
  callbackName: SharedPlayerCallBacksKeys.OnVideoError,
497
510
  event: error,
@@ -499,6 +512,8 @@ export class Player {
499
512
  };
500
513
 
501
514
  onError = (error: Error) => {
515
+ this.lastError = error;
516
+
502
517
  this.invokeListenersUpdate({
503
518
  callbackName: SharedPlayerCallBacksKeys.OnError,
504
519
  event: error,
@@ -14,6 +14,12 @@ const toNumber = (value: unknown) =>
14
14
 
15
15
  const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), max);
16
16
 
17
+ /** Key systems the entry declares (widevine, playready, fairplay...). */
18
+ const drmKeySystems = (drm: unknown) =>
19
+ drm && typeof drm === "object" && Object.keys(drm).length
20
+ ? Object.keys(drm)
21
+ : null;
22
+
17
23
  /** The range a seek can reach: the DVR window on live, the content on VOD. */
18
24
  const requireSeekable = (player: {
19
25
  getSeekableDuration: () => number | undefined;
@@ -38,14 +44,21 @@ const tools = (manager: PlayerManager): XrayToolSpec[] => {
38
44
  {
39
45
  name: "state",
40
46
  description:
41
- "The active player: entry, position and duration in seconds, playing/buffering/ad/live/muted, " +
42
- "role (Primary, Cell, Chromecast) and casting. active: false when nothing plays.",
47
+ "The active player: entry, position and duration in seconds (the ad's while an ad plays), " +
48
+ "playing/buffering/ad/live/muted, role (Primary, Cell, Chromecast) and casting; in seconds, " +
49
+ "seekableDuration and contentPosition/contentDuration (the content's, even during ads); " +
50
+ "drm key systems, lastError {message} and next {id, title}, each null when absent; " +
51
+ "plugin (player plugin id) and engine (the default player's web engine, else null). " +
52
+ "active: false when nothing plays.",
43
53
  handler: () => {
44
54
  const player = manager.getActivePlayer();
45
55
 
46
56
  if (!player) return { active: false, casting: manager.isCasting() };
47
57
 
48
58
  const entry = player.getEntry();
59
+ const seekableDuration = toNumber(player.getSeekableDuration());
60
+ const next = player.getPlayNextEntry();
61
+ const error: any = player.lastError;
49
62
 
50
63
  return {
51
64
  active: true,
@@ -61,7 +74,18 @@ const tools = (manager: PlayerManager): XrayToolSpec[] => {
61
74
  live: !!player.isLive(),
62
75
  muted: !!player.getIsMuted(),
63
76
  role: player.playerRole,
77
+ plugin: player.playerPluginId ?? null,
78
+ engine: player.engine ?? null,
64
79
  casting: manager.isCasting(),
80
+ seekableDuration,
81
+ contentPosition: toNumber(player.getContentPosition()),
82
+ contentDuration: toNumber(player.getContentDuration()),
83
+ drm: drmKeySystems(entry?.extensions?.drm),
84
+ lastError:
85
+ error == null
86
+ ? null
87
+ : { message: String(error?.message ?? error?.error ?? error) },
88
+ next: next ? { id: next.id, title: next.title } : null,
65
89
  };
66
90
  },
67
91
  },
@@ -83,6 +107,21 @@ const tools = (manager: PlayerManager): XrayToolSpec[] => {
83
107
  return "OK";
84
108
  },
85
109
  },
110
+ {
111
+ name: "next",
112
+ description:
113
+ "Play the next item, as the Play Next overlay does. Fails when there is none.",
114
+ handler: () => {
115
+ const player = requirePlayer();
116
+ const next = player.getPlayNextEntry();
117
+
118
+ if (!next || !player.playNextHandler) throw new Error("No next item");
119
+
120
+ player.playNextHandler();
121
+
122
+ return { id: next.id, title: next.title };
123
+ },
124
+ },
86
125
  {
87
126
  name: "seek",
88
127
  description:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-rc.120",
3
+ "version": "16.0.0-rc.122",
4
4
  "description": "Applicaster Zapp React Native utilities package",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "homepage": "https://github.com/applicaster/quickbrick#readme",
29
29
  "dependencies": {
30
- "@applicaster/applicaster-types": "16.0.0-rc.120",
30
+ "@applicaster/applicaster-types": "16.0.0-rc.122",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",