@applicaster/zapp-react-native-utils 16.0.0-alpha.3081324238 → 16.0.0-alpha.3202041045
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/actionUtils/index.ts +3 -1
- package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +139 -5
- package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +24 -0
- package/actionsExecutor/actions/navigateToScreen.ts +29 -45
- package/actionsExecutor/actions/openBottomSheet.ts +1 -0
- package/appUtils/playerManager/__tests__/playerDiagnostics.test.ts +62 -0
- package/appUtils/playerManager/__tests__/playerToolbox.test.ts +188 -4
- package/appUtils/playerManager/index.ts +5 -0
- package/appUtils/playerManager/player.ts +15 -0
- package/appUtils/playerManager/playerToolbox.ts +45 -41
- package/cellUtils/__tests__/getButtonsEnabled.test.ts +28 -0
- package/cellUtils/__tests__/getCellNavigationEnabled.test.ts +36 -0
- package/cellUtils/__tests__/getIsCellNavigationEnabled.test.ts +35 -0
- package/cellUtils/__tests__/isCellNavigationEnabled.test.ts +21 -0
- package/cellUtils/index.ts +40 -0
- package/configurationUtils/__tests__/modalBlocksParser.test.ts +60 -0
- package/focusManager/FocusManager.ts +26 -0
- package/focusManager/__tests__/FocusManager.test.ts +9 -0
- package/localizationUtils/index.ts +12 -6
- package/logger/__tests__/xrayToolbox.test.ts +69 -0
- package/logger/xrayToolbox.ts +67 -0
- package/manifestUtils/defaultManifestConfigurations/player.js +8 -0
- package/manifestUtils/keys.js +7 -1
- package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
- package/manifestUtils/mobileAction/groups/defaults.js +1 -0
- package/navigationUtils/xrayNavigator.ts +12 -0
- package/package.json +3 -2
- package/quickBrickAssets/__tests__/index.test.ts +18 -0
- package/quickBrickAssets/index.ts +35 -0
- package/reactHooks/__tests__/getPluginConfiguration.test.ts +82 -0
- package/reactHooks/actions/index.ts +29 -23
- package/reactHooks/usePluginConfiguration.ts +84 -46
- package/riverComponetsMeasurementProvider/index.tsx +3 -1
package/actionUtils/index.ts
CHANGED
|
@@ -3,5 +3,7 @@ import { isTV } from "../reactUtils";
|
|
|
3
3
|
|
|
4
4
|
export const resolveDefaultAssetUri = (source: string | number): string =>
|
|
5
5
|
isTV()
|
|
6
|
-
?
|
|
6
|
+
? typeof source === "number"
|
|
7
|
+
? Image.resolveAssetSource(source)?.uri || ""
|
|
8
|
+
: (source as string)
|
|
7
9
|
: Image.resolveAssetSource(source as number)?.uri || "";
|
|
@@ -83,9 +83,7 @@ describe("navigateToScreen navigationOptions", () => {
|
|
|
83
83
|
);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
// non-push path falls back to replaceTop().
|
|
88
|
-
it("forwards navigationOptions to replaceTop when navigating with an entry", async () => {
|
|
86
|
+
it("forwards navigationOptions to replace when navigating with an entry", async () => {
|
|
89
87
|
const navigator = createNavigator();
|
|
90
88
|
const entry = { id: "entry-id" } as unknown as ZappEntry;
|
|
91
89
|
|
|
@@ -100,12 +98,12 @@ describe("navigateToScreen navigationOptions", () => {
|
|
|
100
98
|
|
|
101
99
|
await createNavigateToScreenAction(navigator, rivers, contentTypes)(action);
|
|
102
100
|
|
|
103
|
-
expect(navigator.
|
|
101
|
+
expect(navigator.replace).toHaveBeenCalledWith(
|
|
104
102
|
{ id: "entry-id", type: { value: "parentLock" } },
|
|
105
103
|
{ pinFlow: "change-pin" }
|
|
106
104
|
);
|
|
107
105
|
|
|
108
|
-
expect(navigator.
|
|
106
|
+
expect(navigator.replaceTop).not.toHaveBeenCalled();
|
|
109
107
|
});
|
|
110
108
|
|
|
111
109
|
// Regression guard: the no-options path must keep working unchanged.
|
|
@@ -131,3 +129,139 @@ describe("navigateToScreen navigationOptions", () => {
|
|
|
131
129
|
expect(result).toBe(ActionResult.Success);
|
|
132
130
|
});
|
|
133
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("navigateToScreen target resolution", () => {
|
|
134
|
+
function run(options, context?) {
|
|
135
|
+
const navigator = createNavigator();
|
|
136
|
+
|
|
137
|
+
const result = createNavigateToScreenAction(
|
|
138
|
+
navigator,
|
|
139
|
+
rivers,
|
|
140
|
+
contentTypes
|
|
141
|
+
)({ type: "navigateToScreen", options }, context);
|
|
142
|
+
|
|
143
|
+
return { navigator, result };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it("overrides entry.type with typeMapping when both are provided", async () => {
|
|
147
|
+
const entry = {
|
|
148
|
+
id: "entry-id",
|
|
149
|
+
type: { value: "video" },
|
|
150
|
+
} as unknown as ZappEntry;
|
|
151
|
+
|
|
152
|
+
const { navigator, result } = run({
|
|
153
|
+
typeMapping: "parentLock",
|
|
154
|
+
navigationAction: "push",
|
|
155
|
+
entry,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(await result).toBe(ActionResult.Success);
|
|
159
|
+
|
|
160
|
+
expect(navigator.push).toHaveBeenCalledWith(
|
|
161
|
+
{ id: "entry-id", type: { value: "parentLock" } },
|
|
162
|
+
undefined
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(entry.type).toEqual({ value: "video" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("navigates with the entry's own type when typeMapping is not provided", async () => {
|
|
169
|
+
const entry = {
|
|
170
|
+
id: "entry-id",
|
|
171
|
+
type: { value: "video" },
|
|
172
|
+
} as unknown as ZappEntry;
|
|
173
|
+
|
|
174
|
+
const { navigator, result } = run({ navigationAction: "push", entry });
|
|
175
|
+
|
|
176
|
+
expect(await result).toBe(ActionResult.Success);
|
|
177
|
+
|
|
178
|
+
expect(navigator.push).toHaveBeenCalledWith(
|
|
179
|
+
{ id: "entry-id", type: { value: "video" } },
|
|
180
|
+
undefined
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("resolves the context entry when entry is @{entry/} and typeMapping is not provided", async () => {
|
|
185
|
+
const entry = {
|
|
186
|
+
id: "context-entry-id",
|
|
187
|
+
type: { value: "video" },
|
|
188
|
+
} as unknown as ZappEntry;
|
|
189
|
+
|
|
190
|
+
const { navigator, result } = run({ entry: "@{entry/}" }, { entry });
|
|
191
|
+
|
|
192
|
+
expect(await result).toBe(ActionResult.Success);
|
|
193
|
+
|
|
194
|
+
expect(navigator.replace).toHaveBeenCalledWith(
|
|
195
|
+
{ id: "context-entry-id", type: { value: "video" } },
|
|
196
|
+
undefined
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns an error when neither typeMapping nor entry type is provided", async () => {
|
|
201
|
+
const entry = { id: "entry-id" } as unknown as ZappEntry;
|
|
202
|
+
|
|
203
|
+
const { navigator, result } = run({ navigationAction: "push", entry });
|
|
204
|
+
|
|
205
|
+
expect(await result).toBe(ActionResult.Error);
|
|
206
|
+
expect(navigator.push).not.toHaveBeenCalled();
|
|
207
|
+
expect(navigator.replace).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns an error when neither typeMapping nor entry is provided", async () => {
|
|
211
|
+
const { navigator, result } = run({ navigationAction: "push" });
|
|
212
|
+
|
|
213
|
+
expect(await result).toBe(ActionResult.Error);
|
|
214
|
+
expect(navigator.push).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns an error when typeMapping has no screen_id mapping", async () => {
|
|
218
|
+
const callback = jest.fn();
|
|
219
|
+
|
|
220
|
+
const { navigator, result } = run(
|
|
221
|
+
{ typeMapping: "unknownType", navigationAction: "push" },
|
|
222
|
+
{ callback }
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(await result).toBe(ActionResult.Error);
|
|
226
|
+
expect(navigator.push).not.toHaveBeenCalled();
|
|
227
|
+
expect(callback).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("returns an error when the mapped screen_id has no river", async () => {
|
|
231
|
+
const navigator = createNavigator();
|
|
232
|
+
const callback = jest.fn();
|
|
233
|
+
|
|
234
|
+
const result = await createNavigateToScreenAction(navigator, rivers, {
|
|
235
|
+
orphan: { screen_id: "missing-screen-id" },
|
|
236
|
+
} as unknown as ZappContentTypes)(
|
|
237
|
+
{
|
|
238
|
+
type: "navigateToScreen",
|
|
239
|
+
options: { typeMapping: "orphan", navigationAction: "push" },
|
|
240
|
+
},
|
|
241
|
+
{ callback }
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(result).toBe(ActionResult.Error);
|
|
245
|
+
expect(navigator.push).not.toHaveBeenCalled();
|
|
246
|
+
expect(callback).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("aborts the action pipeline before navigating to a resolved river", async () => {
|
|
250
|
+
const callback = jest.fn();
|
|
251
|
+
|
|
252
|
+
const { navigator, result } = run(
|
|
253
|
+
{ typeMapping: "parentLock" },
|
|
254
|
+
{ callback }
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(await result).toBe(ActionResult.Success);
|
|
258
|
+
|
|
259
|
+
expect(callback).toHaveBeenCalledWith({
|
|
260
|
+
success: false,
|
|
261
|
+
error: null,
|
|
262
|
+
abort: true,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(navigator.replace).toHaveBeenCalledWith(river, undefined);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -60,6 +60,30 @@ describe("openBottomSheetAction custom content", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it("passes themePluginId through to the name sheet", async () => {
|
|
64
|
+
const CustomContent = function CustomContent() {
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
await openBottomSheetAction({
|
|
69
|
+
type: "openBottomSheet",
|
|
70
|
+
options: {
|
|
71
|
+
header: { title: "Create New Playlist" },
|
|
72
|
+
themePluginId: "playlist-experience",
|
|
73
|
+
content: {
|
|
74
|
+
component: CustomContent,
|
|
75
|
+
props: { buttonLabel: "Create" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
} as any);
|
|
79
|
+
|
|
80
|
+
const args = (openBottomSheetModal as jest.Mock).mock.calls[0][0];
|
|
81
|
+
|
|
82
|
+
expect(args.modalBottomSheetContentProps.themePluginId).toBe(
|
|
83
|
+
"playlist-experience"
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
63
87
|
it("returns Error when neither list data nor content.component is provided", async () => {
|
|
64
88
|
const result = await openBottomSheetAction({
|
|
65
89
|
type: "openBottomSheet",
|
|
@@ -13,7 +13,7 @@ const { log_info, log_error } = createLogger({
|
|
|
13
13
|
*/
|
|
14
14
|
export interface NavigateToScreenOptions {
|
|
15
15
|
/** The screen type to navigate to (from content types mapping) */
|
|
16
|
-
typeMapping
|
|
16
|
+
typeMapping?: string;
|
|
17
17
|
|
|
18
18
|
/** Navigation action: push adds to history, replace replaces current screen */
|
|
19
19
|
navigationAction?: "push" | "replace";
|
|
@@ -58,14 +58,6 @@ export function createNavigateToScreenAction(
|
|
|
58
58
|
action,
|
|
59
59
|
context?: ActionExecutionContext
|
|
60
60
|
): Promise<ActionResult> {
|
|
61
|
-
const screenType = action.options?.typeMapping;
|
|
62
|
-
|
|
63
|
-
if (!screenType) {
|
|
64
|
-
log_error("navigateToScreen: typeMapping option is missing");
|
|
65
|
-
|
|
66
|
-
return ActionResult.Error;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
61
|
const navigationAction = action.options?.navigationAction;
|
|
70
62
|
const entrySource = action.options?.entry;
|
|
71
63
|
const navigationOptions = action.options?.navigationOptions;
|
|
@@ -76,61 +68,53 @@ export function createNavigateToScreenAction(
|
|
|
76
68
|
: entrySource
|
|
77
69
|
: null;
|
|
78
70
|
|
|
79
|
-
|
|
80
|
-
if (typeof entry !== "object") {
|
|
81
|
-
log_error(
|
|
82
|
-
`navigateToScreen: entry option is not an object, entry: ${entry}`
|
|
83
|
-
);
|
|
71
|
+
const screenType = action.options?.typeMapping || entry?.type?.value;
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
73
|
+
const navigate =
|
|
74
|
+
navigationAction === "push" ? navigator.push : navigator.replace;
|
|
75
|
+
|
|
76
|
+
let navigatingEntry: ZappEntry | ZappRiver = entry;
|
|
87
77
|
|
|
78
|
+
if (entry && screenType) {
|
|
88
79
|
log_info(
|
|
89
80
|
`navigateToScreen: navigating to screen type: ${screenType} with entry id: ${entry.id}`
|
|
90
81
|
);
|
|
91
82
|
|
|
92
|
-
|
|
83
|
+
navigatingEntry = {
|
|
93
84
|
...entry,
|
|
94
|
-
type: {
|
|
95
|
-
value: screenType,
|
|
96
|
-
},
|
|
85
|
+
type: { value: screenType },
|
|
97
86
|
};
|
|
87
|
+
} else {
|
|
88
|
+
if (!screenType) {
|
|
89
|
+
log_error(
|
|
90
|
+
"navigateToScreen: options.typeMapping or entry?.type?.value is not provided"
|
|
91
|
+
);
|
|
98
92
|
|
|
99
|
-
|
|
100
|
-
navigator.push(overriddenEntry, navigationOptions);
|
|
101
|
-
} else {
|
|
102
|
-
// Use replaceTop for entries since replace() only accepts ZappRiver
|
|
103
|
-
navigator.replaceTop(overriddenEntry, navigationOptions);
|
|
93
|
+
return ActionResult.Error;
|
|
104
94
|
}
|
|
105
95
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const screenId = contentTypes?.[screenType]?.screen_id || null;
|
|
96
|
+
const screenId = contentTypes?.[screenType]?.screen_id || null;
|
|
97
|
+
const river = rivers?.[screenId];
|
|
110
98
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
99
|
+
if (!screenId) {
|
|
100
|
+
log_error(
|
|
101
|
+
`navigateToScreen: can not resolve screen type mapping: ${screenType}`
|
|
102
|
+
);
|
|
115
103
|
|
|
116
|
-
|
|
117
|
-
|
|
104
|
+
return ActionResult.Error;
|
|
105
|
+
}
|
|
118
106
|
|
|
119
|
-
|
|
107
|
+
if (!river) {
|
|
108
|
+
log_error("navigateToScreen: can not resolve river");
|
|
120
109
|
|
|
121
|
-
|
|
122
|
-
|
|
110
|
+
return ActionResult.Error;
|
|
111
|
+
}
|
|
123
112
|
|
|
124
|
-
|
|
113
|
+
navigatingEntry = river;
|
|
125
114
|
}
|
|
126
115
|
|
|
127
116
|
context?.callback?.({ success: false, error: null, abort: true });
|
|
128
|
-
|
|
129
|
-
if (navigationAction === "push") {
|
|
130
|
-
navigator.push(river, navigationOptions);
|
|
131
|
-
} else {
|
|
132
|
-
navigator.replace(river, navigationOptions);
|
|
133
|
-
}
|
|
117
|
+
navigate(navigatingEntry, navigationOptions);
|
|
134
118
|
|
|
135
119
|
return ActionResult.Success;
|
|
136
120
|
};
|
|
@@ -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.
|
|
36
|
+
const { tools } = mockRegister.mock.lastCall[0];
|
|
34
37
|
|
|
35
38
|
return Object.fromEntries(tools.map((tool) => [tool.name, tool.handler]));
|
|
36
39
|
};
|
|
@@ -45,7 +48,7 @@ describe("playerToolbox", () => {
|
|
|
45
48
|
|
|
46
49
|
await expect(
|
|
47
50
|
loadToolbox().registerPlayerToolbox(manager(null))
|
|
48
|
-
).resolves.
|
|
51
|
+
).resolves.toBe(false);
|
|
49
52
|
});
|
|
50
53
|
|
|
51
54
|
it("does nothing when getXray returns null", async () => {
|
|
@@ -53,7 +56,7 @@ describe("playerToolbox", () => {
|
|
|
53
56
|
|
|
54
57
|
await expect(
|
|
55
58
|
loadToolbox().registerPlayerToolbox(manager(null))
|
|
56
|
-
).resolves.
|
|
59
|
+
).resolves.toBe(false);
|
|
57
60
|
});
|
|
58
61
|
|
|
59
62
|
it("swallows a failed registration", async () => {
|
|
@@ -62,7 +65,7 @@ describe("playerToolbox", () => {
|
|
|
62
65
|
|
|
63
66
|
await expect(
|
|
64
67
|
loadToolbox().registerPlayerToolbox(manager(null))
|
|
65
|
-
).resolves.
|
|
68
|
+
).resolves.toBe(false);
|
|
66
69
|
});
|
|
67
70
|
|
|
68
71
|
it("seeks to an absolute position, from the end when negative, clamped", async () => {
|
|
@@ -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 })
|
|
@@ -397,6 +397,11 @@ export class PlayerManager {
|
|
|
397
397
|
return this.manager.removeHandler(event, handler);
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
/** Whether anything handles `event` now, e.g. a mounted PlayerContainer for "toggleFullscreen". */
|
|
401
|
+
public hasHandler(event: string) {
|
|
402
|
+
return this.manager.handlers[event]?.length > 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
400
405
|
// TODO: should not be
|
|
401
406
|
public invokeHandler(event, ...args) {
|
|
402
407
|
return this.manager.invokeHandler(event, ...args);
|
|
@@ -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,
|