@applicaster/zapp-react-native-utils 16.0.0-alpha.9235607812 → 16.0.0-alpha.9338540010
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__/playerToolbox.test.ts +97 -0
- package/appUtils/playerManager/__tests__/usePlayerControllerSetup.test.tsx +62 -0
- package/appUtils/playerManager/index.ts +7 -0
- package/appUtils/playerManager/playerToolbox.ts +157 -0
- package/appUtils/playerManager/usePlayerControllerSetup.tsx +6 -0
- 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 +123 -0
- package/configurationUtils/modalBlocksParser.ts +38 -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 +6 -1
- package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
- package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +1 -1
- package/manifestUtils/mobileAction/groups/defaults.js +2 -1
- 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/usePluginConfiguration.ts +84 -46
- package/riverComponetsMeasurementProvider/index.tsx +3 -1
- package/uiActionsRegistrator/__tests__/subscribe.test.ts +26 -0
- package/uiActionsRegistrator/registry.ts +16 -0
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,97 @@
|
|
|
1
|
+
const mockRegister = jest.fn();
|
|
2
|
+
let mockXray: Record<string, unknown> | null = {};
|
|
3
|
+
|
|
4
|
+
jest.mock("../../../logger", () => ({
|
|
5
|
+
getXray: () => mockXray,
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const loadToolbox = () => {
|
|
9
|
+
let module;
|
|
10
|
+
|
|
11
|
+
jest.isolateModules(() => {
|
|
12
|
+
module = require("../playerToolbox");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return module as typeof import("../playerToolbox");
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const player = (overrides = {}) => ({
|
|
19
|
+
getSeekableDuration: () => 100,
|
|
20
|
+
getContentPosition: () => 40,
|
|
21
|
+
seekTo: jest.fn(),
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const manager = (active) =>
|
|
26
|
+
({ getActivePlayer: () => active, isCasting: () => false }) as any;
|
|
27
|
+
|
|
28
|
+
const registeredTools = async (active) => {
|
|
29
|
+
mockRegister.mockResolvedValue(undefined);
|
|
30
|
+
mockXray = { registerReactToolbox: mockRegister };
|
|
31
|
+
await loadToolbox().registerPlayerToolbox(manager(active));
|
|
32
|
+
|
|
33
|
+
const { tools } = mockRegister.mock.calls[0][0];
|
|
34
|
+
|
|
35
|
+
return Object.fromEntries(tools.map((tool) => [tool.name, tool.handler]));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("playerToolbox", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mockRegister.mockReset();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does nothing when X-Ray has no React toolbox API", async () => {
|
|
44
|
+
mockXray = {};
|
|
45
|
+
|
|
46
|
+
await expect(
|
|
47
|
+
loadToolbox().registerPlayerToolbox(manager(null))
|
|
48
|
+
).resolves.toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does nothing when getXray returns null", async () => {
|
|
52
|
+
mockXray = null;
|
|
53
|
+
|
|
54
|
+
await expect(
|
|
55
|
+
loadToolbox().registerPlayerToolbox(manager(null))
|
|
56
|
+
).resolves.toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("swallows a failed registration", async () => {
|
|
60
|
+
mockRegister.mockRejectedValue(new Error("no native bridge"));
|
|
61
|
+
mockXray = { registerReactToolbox: mockRegister };
|
|
62
|
+
|
|
63
|
+
await expect(
|
|
64
|
+
loadToolbox().registerPlayerToolbox(manager(null))
|
|
65
|
+
).resolves.toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("seeks to an absolute position, from the end when negative, clamped", async () => {
|
|
69
|
+
const active = player();
|
|
70
|
+
const tools = await registeredTools(active);
|
|
71
|
+
|
|
72
|
+
expect(tools.seek({ position: 30 })).toEqual({ position: 30 });
|
|
73
|
+
expect(tools.seek({ position: -10 })).toEqual({ position: 90 });
|
|
74
|
+
expect(tools.seek({ position: 500 })).toEqual({ position: 100 });
|
|
75
|
+
expect(tools.seek({ position: -500 })).toEqual({ position: 0 });
|
|
76
|
+
expect(active.seekTo.mock.calls).toEqual([[30], [90], [100], [0]]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("offsets from the current position and refuses zero", async () => {
|
|
80
|
+
const tools = await registeredTools(player());
|
|
81
|
+
|
|
82
|
+
expect(tools.offset({ seconds: 15 })).toEqual({ from: 40, position: 55 });
|
|
83
|
+
expect(tools.offset({ seconds: -60 })).toEqual({ from: 40, position: 0 });
|
|
84
|
+
|
|
85
|
+
expect(() => tools.offset({ seconds: 0 })).toThrow(
|
|
86
|
+
"Ignored zero relative seek"
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("refuses to seek a stream that is not seekable", async () => {
|
|
91
|
+
const tools = await registeredTools(
|
|
92
|
+
player({ getSeekableDuration: () => 0 })
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(() => tools.seek({ position: 10 })).toThrow("Seek is not supported");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react-native";
|
|
2
|
+
|
|
3
|
+
import { usePlayerControllerSetup } from "../usePlayerControllerSetup";
|
|
4
|
+
import { PlayerAnalyticsManager } from "../../../analyticsUtils/PlayerAnalyticsManager";
|
|
5
|
+
|
|
6
|
+
const calls: string[] = [];
|
|
7
|
+
|
|
8
|
+
jest.mock("../index", () => ({
|
|
9
|
+
playerManager: {
|
|
10
|
+
isPlayerRegistered: jest.fn(() => false),
|
|
11
|
+
registerPlayer: jest.fn(() => calls.push("registerPlayer")),
|
|
12
|
+
unregisterPlayer: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock("../../../analyticsUtils/PlayerAnalyticsManager", () => ({
|
|
17
|
+
PlayerAnalyticsManager: {
|
|
18
|
+
getInstance: jest.fn(() => calls.push("analyticsManager")),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
jest.mock("../../../reactHooks/useConfiguration", () => ({
|
|
23
|
+
useConfiguration: () => ({}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock("../../../playerUtils/useValidatePlayerConfig", () => ({
|
|
27
|
+
useValidatePlayerConfig: jest.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
jest.mock("../../../reactUtils", () => ({
|
|
31
|
+
isTV: () => false,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
jest.mock(
|
|
35
|
+
"@applicaster/zapp-react-native-ui-components/Helpers/Platform",
|
|
36
|
+
() => ({
|
|
37
|
+
isAppleTV: () => false,
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
class Controller {
|
|
42
|
+
destroy = jest.fn();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("usePlayerControllerSetup", () => {
|
|
46
|
+
it("creates the analytics manager before the player registers", () => {
|
|
47
|
+
renderHook(() =>
|
|
48
|
+
usePlayerControllerSetup({
|
|
49
|
+
props: { playerId: "player-1", entry: { id: "entry-1" } },
|
|
50
|
+
ControllerType: Controller,
|
|
51
|
+
ref: null,
|
|
52
|
+
manifest: {},
|
|
53
|
+
pluginId: "plugin",
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// The manager attaches analytics to players as they register, so a player
|
|
58
|
+
// registered before it exists would send no player analytics events
|
|
59
|
+
expect(PlayerAnalyticsManager.getInstance).toHaveBeenCalled();
|
|
60
|
+
expect(calls).toEqual(["analyticsManager", "registerPlayer"]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { setUserLanguagePreference } from "./userLanguagePreference";
|
|
12
12
|
import { PlayerRole } from "./const";
|
|
13
13
|
import { RNPlayerManager } from "./nativePlayerManager";
|
|
14
|
+
import { registerPlayerToolbox } from "./playerToolbox";
|
|
14
15
|
|
|
15
16
|
const { log_error, log_debug, log_warning } = createLogger({
|
|
16
17
|
category: "General",
|
|
@@ -54,6 +55,7 @@ export class PlayerManager {
|
|
|
54
55
|
public static getInstance() {
|
|
55
56
|
if (!PlayerManager.instance) {
|
|
56
57
|
PlayerManager.instance = new PlayerManager();
|
|
58
|
+
registerPlayerToolbox(PlayerManager.instance);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
return PlayerManager.instance;
|
|
@@ -395,6 +397,11 @@ export class PlayerManager {
|
|
|
395
397
|
return this.manager.removeHandler(event, handler);
|
|
396
398
|
}
|
|
397
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
|
+
|
|
398
405
|
// TODO: should not be
|
|
399
406
|
public invokeHandler(event, ...args) {
|
|
400
407
|
return this.manager.invokeHandler(event, ...args);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { registerXrayToolbox, XrayToolSpec } from "../../logger/xrayToolbox";
|
|
2
|
+
import type { PlayerManager } from "./index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* X-Ray `player.*` toolbox: lets an agent read and drive the active player without screenshots.
|
|
6
|
+
* Registered by the PlayerManager singleton itself, so it exists wherever players do. Skipped
|
|
7
|
+
* when the installed X-Ray plugin has no React toolbox API.
|
|
8
|
+
*
|
|
9
|
+
* How the player is shown (video modal mode, mini player) is navigation state: see `nav.*`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const toNumber = (value: unknown) =>
|
|
13
|
+
typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
14
|
+
|
|
15
|
+
const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), max);
|
|
16
|
+
|
|
17
|
+
/** The range a seek can reach: the DVR window on live, the content on VOD. */
|
|
18
|
+
const requireSeekable = (player: {
|
|
19
|
+
getSeekableDuration: () => number | undefined;
|
|
20
|
+
}) => {
|
|
21
|
+
const seekable = toNumber(player.getSeekableDuration());
|
|
22
|
+
|
|
23
|
+
if (!seekable) throw new Error("Seek is not supported");
|
|
24
|
+
|
|
25
|
+
return seekable;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const tools = (manager: PlayerManager): XrayToolSpec[] => {
|
|
29
|
+
const requirePlayer = () => {
|
|
30
|
+
const player = manager.getActivePlayer();
|
|
31
|
+
|
|
32
|
+
if (!player) throw new Error("No active player");
|
|
33
|
+
|
|
34
|
+
return player;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
name: "state",
|
|
40
|
+
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.",
|
|
43
|
+
handler: () => {
|
|
44
|
+
const player = manager.getActivePlayer();
|
|
45
|
+
|
|
46
|
+
if (!player) return { active: false, casting: manager.isCasting() };
|
|
47
|
+
|
|
48
|
+
const entry = player.getEntry();
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
active: true,
|
|
52
|
+
playerId: player.playerId,
|
|
53
|
+
entry: entry ? { id: entry.id, title: entry.title } : null,
|
|
54
|
+
position: toNumber(player.getPosition()),
|
|
55
|
+
duration: toNumber(player.getDuration()),
|
|
56
|
+
// isPlaying() only means "not paused", which is also true before the stream starts.
|
|
57
|
+
playing: !!player.isReadyToPlay() && player.isPlaying(),
|
|
58
|
+
buffering: !!player.isBuffering(),
|
|
59
|
+
readyToPlay: !!player.isReadyToPlay(),
|
|
60
|
+
ad: player.isAd(),
|
|
61
|
+
live: !!player.isLive(),
|
|
62
|
+
muted: !!player.getIsMuted(),
|
|
63
|
+
role: player.playerRole,
|
|
64
|
+
casting: manager.isCasting(),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "play",
|
|
70
|
+
description: "Resume the active player.",
|
|
71
|
+
handler: () => {
|
|
72
|
+
requirePlayer().play();
|
|
73
|
+
|
|
74
|
+
return "OK";
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "pause",
|
|
79
|
+
description: "Pause the active player.",
|
|
80
|
+
handler: () => {
|
|
81
|
+
requirePlayer().pause();
|
|
82
|
+
|
|
83
|
+
return "OK";
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "seek",
|
|
88
|
+
description:
|
|
89
|
+
"Seek to an absolute position, as the player's console command does: seconds from the start, " +
|
|
90
|
+
"or from the end when negative. Clamped to the seekable range. Where it lands can differ: " +
|
|
91
|
+
"server-side ads snap back to the break before the target; read player.state afterwards.",
|
|
92
|
+
parameters: [
|
|
93
|
+
{
|
|
94
|
+
name: "position",
|
|
95
|
+
type: "number",
|
|
96
|
+
description:
|
|
97
|
+
"Seconds from the start; negative values count back from the end.",
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
handler: ({ position }) => {
|
|
101
|
+
const player = requirePlayer();
|
|
102
|
+
const seconds = toNumber(position);
|
|
103
|
+
|
|
104
|
+
if (seconds === null) {
|
|
105
|
+
throw new Error("'position' must be a number of seconds");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const seekable = requireSeekable(player);
|
|
109
|
+
|
|
110
|
+
const target =
|
|
111
|
+
seconds >= 0
|
|
112
|
+
? Math.min(seconds, seekable)
|
|
113
|
+
: clamp(seekable + seconds, seekable);
|
|
114
|
+
|
|
115
|
+
player.seekTo(target);
|
|
116
|
+
|
|
117
|
+
return { position: target };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "offset",
|
|
122
|
+
description:
|
|
123
|
+
"Seek relative to the current position, as the player's console command does: positive " +
|
|
124
|
+
"forward, negative back. Clamped to the seekable range.",
|
|
125
|
+
parameters: [
|
|
126
|
+
{
|
|
127
|
+
name: "seconds",
|
|
128
|
+
type: "number",
|
|
129
|
+
description: "Seconds to move: positive forward, negative back.",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
handler: ({ seconds }) => {
|
|
133
|
+
const player = requirePlayer();
|
|
134
|
+
const delta = toNumber(seconds);
|
|
135
|
+
|
|
136
|
+
if (delta === null) throw new Error("'seconds' must be a number");
|
|
137
|
+
|
|
138
|
+
if (delta === 0) throw new Error("Ignored zero relative seek");
|
|
139
|
+
|
|
140
|
+
const seekable = requireSeekable(player);
|
|
141
|
+
const from = toNumber(player.getContentPosition()) ?? 0;
|
|
142
|
+
const target = clamp(from + delta, seekable);
|
|
143
|
+
|
|
144
|
+
player.seekTo(target);
|
|
145
|
+
|
|
146
|
+
return { from, position: target };
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const registerPlayerToolbox = (manager: PlayerManager) =>
|
|
153
|
+
registerXrayToolbox({
|
|
154
|
+
name: "player",
|
|
155
|
+
description: "Inspect and control the active player.",
|
|
156
|
+
tools: tools(manager),
|
|
157
|
+
});
|
|
@@ -8,6 +8,7 @@ import { isAppleTV } from "@applicaster/zapp-react-native-ui-components/Helpers/
|
|
|
8
8
|
import { TVSeekController } from "../../reactHooks/player/TVSeekControlller/TVSeekController";
|
|
9
9
|
import { KeyInputHandler } from "../keyInputHandler/KeyInputHandler";
|
|
10
10
|
import { isTV } from "../../reactUtils";
|
|
11
|
+
import { PlayerAnalyticsManager } from "../../analyticsUtils/PlayerAnalyticsManager";
|
|
11
12
|
|
|
12
13
|
// TODO: Rename to ControllerType
|
|
13
14
|
export const usePlayerControllerSetup = ({
|
|
@@ -68,6 +69,11 @@ export const usePlayerControllerSetup = ({
|
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// The analytics manager attaches its listener when a player registers, so it
|
|
73
|
+
// must exist first; on web the Player wrapper that also creates it renders
|
|
74
|
+
// only after the lazily loaded engine chunk, i.e. after registration.
|
|
75
|
+
PlayerAnalyticsManager.getInstance();
|
|
76
|
+
|
|
71
77
|
playerManager.registerPlayer({
|
|
72
78
|
id: playerId,
|
|
73
79
|
playerController,
|