@applicaster/zapp-react-native-utils 16.0.0-rc.6 → 16.0.0-rc.60
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/actionsExecutor/ActionExecutor.ts +47 -11
- package/actionsExecutor/ActionExecutorContext.tsx +87 -316
- package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +22 -0
- package/actionsExecutor/actions/__tests__/loadItemsFromSource.test.ts +88 -0
- package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +76 -0
- package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +130 -0
- package/actionsExecutor/actions/__tests__/showToast.test.ts +88 -0
- package/actionsExecutor/actions/appRestart.ts +24 -0
- package/actionsExecutor/actions/confirmDialog.ts +53 -0
- package/actionsExecutor/actions/dismissBottomSheet.ts +22 -0
- package/actionsExecutor/actions/index.ts +34 -0
- package/actionsExecutor/actions/localStorageRemove.ts +27 -0
- package/actionsExecutor/actions/localStorageSet.ts +28 -0
- package/actionsExecutor/actions/localStorageToggleFlag.ts +28 -0
- package/actionsExecutor/actions/navigateToScreen.ts +123 -0
- package/actionsExecutor/actions/openBottomSheet.ts +322 -0
- package/actionsExecutor/actions/refreshComponent.ts +85 -0
- package/actionsExecutor/actions/screenSetVariable.ts +35 -0
- package/actionsExecutor/actions/screenToggleFlag.ts +38 -0
- package/actionsExecutor/actions/sendCloudEvent.ts +93 -0
- package/actionsExecutor/actions/sessionStorageRemove.ts +19 -0
- package/actionsExecutor/actions/sessionStorageSet.ts +20 -0
- package/actionsExecutor/actions/sessionStorageToggleFlag.ts +15 -0
- package/actionsExecutor/actions/showToast.ts +87 -0
- package/actionsExecutor/actions/switchLayout.ts +40 -0
- package/actionsExecutor/consts.ts +23 -0
- package/actionsExecutor/types.ts +59 -0
- package/analyticsUtils/AnalyticsEvents/sendMenuClickEvent.ts +2 -2
- package/analyticsUtils/PlayerAnalyticsManager.ts +12 -2
- package/analyticsUtils/__tests__/analyticsMapper.test.ts +35 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testACP_events.json +1 -1
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testBlockUnlistedParams_rules.json +1 -1
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEmptyRenameKeepsName_events.json +4 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEmptyRenameKeepsName_rules.json +6 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testEventRegex_events.json +3 -9
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testIgnoreOrdering_events.json +18 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testIgnoreOrdering_rules.json +16 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexEventNoFallback_events.json +4 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexEventNoFallback_rules.json +6 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexMultiGroupRename_events.json +4 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexMultiGroupRename_rules.json +6 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexNonNamedFullMatch_events.json +4 -0
- package/analyticsUtils/__tests__/fixtures/analytics_mapper_testRegexNonNamedFullMatch_rules.json +6 -0
- package/analyticsUtils/__tests__/fixtures/index.js +20 -0
- package/analyticsUtils/analyticsMapper.ts +4 -1
- package/analyticsUtils/playerAnalyticsTracker.ts +26 -3
- package/appUtils/HooksManager/index.ts +12 -8
- package/appUtils/contextKeysManager/__tests__/getKey/failure.test.ts +1 -1
- package/appUtils/contextKeysManager/__tests__/removeKey/failure.test.ts +1 -1
- package/appUtils/contextKeysManager/__tests__/setKey/failure/invalidKey.test.ts +4 -4
- package/appUtils/contextKeysManager/index.ts +1 -1
- package/appUtils/contextKeysManager/utils/index.ts +38 -25
- package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +143 -53
- package/appUtils/playerManager/OverlayObserver/__tests__/liveContent.test.ts +205 -0
- package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +49 -0
- package/appUtils/playerManager/OverlayObserver/utils.ts +49 -20
- package/appUtils/playerManager/__tests__/playerContent.test.ts +403 -0
- package/appUtils/playerManager/__tests__/playerFactory.test.ts +150 -0
- package/appUtils/playerManager/__tests__/usePlayerContent.test.tsx +64 -0
- package/appUtils/playerManager/{conts.ts → const.ts} +20 -0
- package/appUtils/playerManager/index.ts +1 -1
- package/appUtils/playerManager/player.ts +115 -3
- package/appUtils/playerManager/playerFactory.ts +18 -35
- package/appUtils/playerManager/playerNative.ts +1 -1
- package/appUtils/playerManager/usePlayerContent.tsx +43 -0
- package/appUtils/playerManager/usePlayerControllerSetup.tsx +1 -1
- package/appUtils/playerManager/userLanguagePreference.ts +1 -1
- package/arrayUtils/__tests__/{anyThruthy.test.ts → anyTruthy.test.ts} +8 -0
- package/arrayUtils/__tests__/arrayUtils.test.ts +165 -108
- package/arrayUtils/__tests__/isEmptyArray.test.ts +11 -0
- package/arrayUtils/__tests__/isFilledArray.test.ts +12 -0
- package/arrayUtils/__tests__/isFirst.test.ts +17 -0
- package/arrayUtils/__tests__/isIndexInRange.test.ts +14 -0
- package/arrayUtils/__tests__/isLast.test.ts +31 -0
- package/arrayUtils/__tests__/makeListOf.test.ts +31 -0
- package/arrayUtils/__tests__/makeListOfIndexes.test.ts +20 -0
- package/arrayUtils/__tests__/reorderByIds.test.ts +50 -0
- package/arrayUtils/__tests__/sample.test.ts +61 -0
- package/arrayUtils/index.ts +136 -20
- package/bottomSheet/__tests__/scrollContext.test.tsx +35 -0
- package/bottomSheet/index.ts +17 -0
- package/cellUtils/index.ts +3 -5
- package/colorUtils/__tests__/isTransparentColor.test.ts +76 -0
- package/colorUtils/__tests__/isValidColor.test.ts +70 -0
- package/colorUtils/index.ts +50 -0
- package/configurationUtils/__tests__/modalBlocksParser.test.ts +107 -0
- package/configurationUtils/manifestKeyParser.ts +38 -9
- package/configurationUtils/modalBlocksParser.ts +178 -0
- package/manifestUtils/__tests__/player.audioControls.test.js +158 -0
- package/manifestUtils/_internals/index.js +6 -0
- package/manifestUtils/defaultManifestConfigurations/generalContent.js +35 -0
- package/manifestUtils/defaultManifestConfigurations/player.js +327 -19
- package/manifestUtils/fieldUtils/__tests__/fieldUtils.test.js +84 -0
- package/manifestUtils/fieldUtils/index.js +125 -0
- package/manifestUtils/index.js +3 -0
- package/manifestUtils/keys.js +9 -0
- package/manifestUtils/mobileAction/button/index.js +16 -0
- package/manifestUtils/mobileAction/container/index.js +3 -1
- package/manifestUtils/mobileAction/groups/defaults.js +3 -1
- package/manifestUtils/modalBlocks.js +304 -0
- package/manifestUtils/platformIsTV.js +1 -0
- package/modalState/ContentViewModel.ts +113 -0
- package/modalState/EditableCollection.ts +17 -0
- package/modalState/EditableCollectionRegistry.ts +51 -0
- package/modalState/ModalOrchestrator.ts +201 -0
- package/modalState/RemoteEditableCollection.ts +227 -0
- package/modalState/__tests__/ContentViewModel.editable.test.ts +69 -0
- package/modalState/__tests__/ContentViewModel.test.ts +165 -0
- package/modalState/__tests__/EditableCollectionRegistry.test.ts +112 -0
- package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +55 -0
- package/modalState/__tests__/RemoteEditableCollection.test.ts +326 -0
- package/modalState/__tests__/useBottomSheetContent.test.ts +349 -0
- package/modalState/index.ts +30 -83
- package/modalState/store.ts +102 -0
- package/modalState/types.ts +137 -0
- package/modalState/useBottomSheetContent.ts +102 -0
- package/numberUtils/__tests__/isMinusZero.test.ts +20 -0
- package/numberUtils/__tests__/isZero.test.ts +27 -0
- package/numberUtils/__tests__/requireNumber.test.ts +50 -0
- package/numberUtils/__tests__/toNumber.test.ts +27 -0
- package/numberUtils/__tests__/toNumberWithDefault.test.ts +64 -0
- package/numberUtils/__tests__/toNumberWithDefaultZero.test.ts +48 -0
- package/numberUtils/index.ts +27 -8
- package/package.json +2 -2
- package/pipesUtils/__tests__/buildUrlWithQuery.test.ts +33 -0
- package/pipesUtils/__tests__/withPipesEndpoint.test.tsx +56 -0
- package/pipesUtils/index.ts +1 -0
- package/pipesUtils/withPipesEndpoint.tsx +54 -0
- package/playerUtils/__tests__/contentDataKeys.test.ts +297 -0
- package/playerUtils/__tests__/resolvePlayerTitleSubtitle.test.ts +180 -0
- package/playerUtils/contentDataKeys.ts +134 -0
- package/playerUtils/index.ts +41 -14
- package/playerUtils/isAudioItem.ts +26 -0
- package/playerUtils/resolvePlayerTitleSubtitle.ts +76 -0
- package/reactHooks/actions/index.ts +51 -1
- package/reactHooks/cell-click/index.ts +18 -10
- package/reactHooks/feed/__mocks__/useMarkPipesDataStale.ts +4 -0
- package/reactHooks/feed/__tests__/useInflatedUrl.test.tsx +25 -0
- package/reactHooks/feed/index.ts +5 -1
- package/reactHooks/feed/useBatchLoading.ts +9 -1
- package/reactHooks/feed/{usePipesCacheReset.ts → useMarkPipesDataStale.ts} +12 -4
- package/reactHooks/index.ts +2 -0
- package/reactHooks/layout/index.ts +1 -1
- package/reactHooks/navigation/__mocks__/index.ts +4 -0
- package/reactHooks/navigation/__tests__/useIsScreenActive.test.ts +42 -0
- package/reactHooks/navigation/index.ts +1 -1
- package/reactHooks/navigation/useIsScreenActive.ts +29 -8
- package/reactHooks/state/useComponentScreenState.ts +1 -1
- package/reactHooks/usePluginConfiguration.ts +4 -1
- package/reactHooks/utils/__tests__/index.test.js +22 -2
- package/reactHooks/utils/index.ts +13 -2
- package/reactHooks/videoModal/hooks/useVideoModalScreenData.tsx +22 -4
- package/riverComponetsMeasurementProvider/index.tsx +12 -8
- package/stringUtils/__tests__/stringUtils.test.js +42 -0
- package/stringUtils/index.ts +2 -2
- package/uiActionsRegistrator/index.ts +203 -0
- package/zappFrameworkUtils/__tests__/localStorageHelper.test.ts +146 -0
- package/zappFrameworkUtils/localStorageHelper.ts +19 -15
- package/appUtils/playerManager/usePlayerTitleSummaryOverlay.tsx +0 -56
- package/reactHooks/feed/__mocks__/usePipesCacheReset.ts +0 -1
- /package/reactHooks/actions/__tests__/{index.test.js → index.test.tsx} +0 -0
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
PipesClientResponseHelper,
|
|
4
4
|
RequestBuilder,
|
|
5
5
|
} from "@applicaster/zapp-pipes-v2-client";
|
|
6
|
+
import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
|
|
6
7
|
import { log_error, log_info } from "./OverlaysObserver";
|
|
7
|
-
import * as R from "ramda";
|
|
8
8
|
import { toNumber, toNumberWithDefault } from "../../../numberUtils";
|
|
9
9
|
|
|
10
|
+
import { findPluginByIdentifier } from "../../../pluginUtils";
|
|
11
|
+
|
|
10
12
|
export const parseTimeToSeconds = (timeStr: string): number => {
|
|
11
13
|
const validationError = new Error("Invalid time format");
|
|
12
14
|
|
|
@@ -40,11 +42,54 @@ export const parseTimeToSeconds = (timeStr: string): number => {
|
|
|
40
42
|
}
|
|
41
43
|
};
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* HACK / TEMPORARY WORKAROUND:
|
|
47
|
+
* Resolves the play_next_feed_url dynamically for OverlaysObserver.
|
|
48
|
+
*
|
|
49
|
+
* WHY THIS IS HACKED HERE:
|
|
50
|
+
* OverlaysObserver was originally designed as a Recommendation Overlay engine (fetching static
|
|
51
|
+
* play_next_feed_url for overlay UI visuals). However, for dynamic queue & continuous audio playback,
|
|
52
|
+
* modifying feeds via `decorateFeed` at load time doesn't work (due to feed caching and mid-playback queue mutations).
|
|
53
|
+
* To avoid mutating feed payloads across the app, we temporarily intercept play_next_feed_url here
|
|
54
|
+
* at runtime to check if QueueManager has active/upcoming queue items.
|
|
55
|
+
*
|
|
56
|
+
* FUTURE ARCHITECTURE / TODO:
|
|
57
|
+
* Move queue/playlist resolution into a dedicated Player/Queue Provider module (or Handler Chain)
|
|
58
|
+
* on playerManager, decoupling Queue playback logic entirely from OverlaysObserver (which should
|
|
59
|
+
* strictly focus on recommendation visuals and chapter markers).
|
|
60
|
+
*/
|
|
61
|
+
export const retrieveFeedUrl = async (
|
|
62
|
+
entry: ZappEntry,
|
|
63
|
+
plugins?: ZappPlugin[]
|
|
64
|
+
): Promise<string> => {
|
|
65
|
+
if (!entry) return null;
|
|
66
|
+
|
|
67
|
+
const availablePlugins = plugins || appStore.get("plugins");
|
|
68
|
+
const queuePlugin = findPluginByIdentifier("queue-action", availablePlugins);
|
|
69
|
+
|
|
70
|
+
if (queuePlugin) {
|
|
71
|
+
try {
|
|
72
|
+
const { QueueManager } = require("@applicaster/queue-action/src/Manager");
|
|
73
|
+
const upcomingQueue = await QueueManager.instance.getUpcomingQueue();
|
|
74
|
+
|
|
75
|
+
if (upcomingQueue && upcomingQueue.length > 0) {
|
|
76
|
+
const queueItemId = entry?.extensions?.queueItemId || entry?.id;
|
|
77
|
+
|
|
78
|
+
return `pipesv2://queue-action/play-next?queueItemId=${queueItemId}`;
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
log_error("retrieveFeedUrl: Failed to resolve QueueManager", e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return entry?.extensions?.play_next_feed_url;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const retrievePlayPreviousFeedUrl = (entry: ZappEntry) =>
|
|
89
|
+
entry?.extensions?.play_previous_feed_url;
|
|
45
90
|
|
|
46
91
|
export const retrieveOverlayDuration = (plugin) =>
|
|
47
|
-
toNumberWithDefault(plugin?.configuration?.overlay_duration
|
|
92
|
+
toNumberWithDefault(15, plugin?.configuration?.overlay_duration);
|
|
48
93
|
|
|
49
94
|
export const retrieveOverlayTriggerOffset = (item: ZappEntry) => {
|
|
50
95
|
return item && item.extensions && item.extensions.overlay_timestamp
|
|
@@ -150,19 +195,3 @@ export const loadFeedAndPrefetchThumbnailImage = async (
|
|
|
150
195
|
|
|
151
196
|
return playNextEntry;
|
|
152
197
|
};
|
|
153
|
-
|
|
154
|
-
export const findPluginByIdentifier = (
|
|
155
|
-
identifier: string,
|
|
156
|
-
plugins: ZappPlugin[]
|
|
157
|
-
): ZappPlugin => {
|
|
158
|
-
if (!identifier) {
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const plugin = R.compose(
|
|
163
|
-
R.find(R.propEq("identifier", identifier)),
|
|
164
|
-
R.values
|
|
165
|
-
)(plugins);
|
|
166
|
-
|
|
167
|
-
return plugin;
|
|
168
|
-
};
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { Player } from "../player";
|
|
2
|
+
import { isTV } from "../../../reactUtils";
|
|
3
|
+
|
|
4
|
+
jest.mock("../../../reactUtils", () => ({
|
|
5
|
+
...jest.requireActual("../../../reactUtils"),
|
|
6
|
+
isTV: jest.fn(() => false),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const mockIsTV = isTV as jest.Mock;
|
|
10
|
+
|
|
11
|
+
const ENTRY = {
|
|
12
|
+
title: "Entry Title",
|
|
13
|
+
summary: "Entry Summary",
|
|
14
|
+
extensions: { programme: "Programme Title" },
|
|
15
|
+
} as unknown as ZappEntry;
|
|
16
|
+
|
|
17
|
+
const LIVE_ENTRY = {
|
|
18
|
+
title: "Live Title",
|
|
19
|
+
summary: "Live Summary",
|
|
20
|
+
} as unknown as ZappEntry;
|
|
21
|
+
|
|
22
|
+
const makePlayer = (configuration: Record<string, any> | null = null) => {
|
|
23
|
+
const player = new Player({ playerId: "test-player" });
|
|
24
|
+
|
|
25
|
+
player.getPluginConfiguration = () => configuration;
|
|
26
|
+
|
|
27
|
+
return player;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("Player content channel", () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
mockIsTV.mockReturnValue(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("resolves the entry fields when nothing is configured", () => {
|
|
36
|
+
const player = makePlayer();
|
|
37
|
+
|
|
38
|
+
player.entry = ENTRY;
|
|
39
|
+
|
|
40
|
+
expect(player.getContent()).toEqual({
|
|
41
|
+
title: "Entry Title",
|
|
42
|
+
summary: "Entry Summary",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("resolves a configured custom data key", () => {
|
|
47
|
+
const player = makePlayer({
|
|
48
|
+
title_data_key_source: "other",
|
|
49
|
+
title_custom_data_key: "extensions.programme",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
player.entry = ENTRY;
|
|
53
|
+
|
|
54
|
+
expect(player.getContent().title).toBe("Programme Title");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to the entry field when the configured key is unresolvable", () => {
|
|
58
|
+
const player = makePlayer({
|
|
59
|
+
title_data_key_source: "other",
|
|
60
|
+
title_custom_data_key: "extensions.nope",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
player.entry = ENTRY;
|
|
64
|
+
|
|
65
|
+
expect(player.getContent().title).toBe("Entry Title");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("emits the resolved content to subscribers when the entry changes", () => {
|
|
69
|
+
const player = makePlayer();
|
|
70
|
+
const received: any[] = [];
|
|
71
|
+
|
|
72
|
+
player
|
|
73
|
+
.getContentObservable()
|
|
74
|
+
.subscribe((content) => received.push(content));
|
|
75
|
+
|
|
76
|
+
player.entry = ENTRY;
|
|
77
|
+
|
|
78
|
+
expect(received[received.length - 1]).toEqual({
|
|
79
|
+
title: "Entry Title",
|
|
80
|
+
summary: "Entry Summary",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("seeds subscribers with resolved content, never the raw entry", () => {
|
|
85
|
+
const player = makePlayer({
|
|
86
|
+
title_data_key_source: "other",
|
|
87
|
+
title_custom_data_key: "extensions.programme",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
player.entry = ENTRY;
|
|
91
|
+
|
|
92
|
+
const received: any[] = [];
|
|
93
|
+
|
|
94
|
+
player
|
|
95
|
+
.getContentObservable()
|
|
96
|
+
.subscribe((content) => received.push(content));
|
|
97
|
+
|
|
98
|
+
expect(received[0].title).toBe("Programme Title");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// The live entry lives on `OverlaysObserver`, not on the player, so these
|
|
102
|
+
// stub the observer rather than constructing a real one (whose constructor
|
|
103
|
+
// pulls in chapter markers, play-next and the plugin store). What is under
|
|
104
|
+
// test here is only how `getContent` and the `entry` setter treat it; the
|
|
105
|
+
// observer's own side - loading the feed and storing the result - is
|
|
106
|
+
// covered in `OverlayObserver/__tests__/liveContent.test.ts`.
|
|
107
|
+
const withLiveEntry = (player: Player, liveEntry: ZappEntry | null) => {
|
|
108
|
+
(player as any).overlaysObserver = {
|
|
109
|
+
liveEntry,
|
|
110
|
+
clearLiveEntry() {
|
|
111
|
+
this.liveEntry = null;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("prefers the observer's live entry over the player entry", () => {
|
|
117
|
+
const player = makePlayer();
|
|
118
|
+
|
|
119
|
+
player.entry = ENTRY;
|
|
120
|
+
withLiveEntry(player, LIVE_ENTRY);
|
|
121
|
+
|
|
122
|
+
expect(player.getContent()).toEqual({
|
|
123
|
+
title: "Live Title",
|
|
124
|
+
summary: "Live Summary",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("clears the live entry when a new entry is set", () => {
|
|
129
|
+
const player = makePlayer();
|
|
130
|
+
|
|
131
|
+
player.entry = ENTRY;
|
|
132
|
+
withLiveEntry(player, LIVE_ENTRY);
|
|
133
|
+
player.entry = ENTRY;
|
|
134
|
+
|
|
135
|
+
expect(player.getContent().title).toBe("Entry Title");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("falls through to the player entry when there is no observer at all", () => {
|
|
139
|
+
const player = makePlayer();
|
|
140
|
+
|
|
141
|
+
player.entry = ENTRY;
|
|
142
|
+
|
|
143
|
+
expect((player as any).overlaysObserver).toBeFalsy();
|
|
144
|
+
expect(player.getContent().title).toBe("Entry Title");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("resolves the cover image from the entry's media group", () => {
|
|
148
|
+
const player = makePlayer();
|
|
149
|
+
|
|
150
|
+
player.entry = {
|
|
151
|
+
title: "Entry Title",
|
|
152
|
+
media_group: [
|
|
153
|
+
{
|
|
154
|
+
type: "image",
|
|
155
|
+
media_item: [
|
|
156
|
+
{ key: "image_other", src: "https://img.test/other.jpg" },
|
|
157
|
+
{ key: "image_base", src: "https://img.test/base.jpg" },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
} as unknown as ZappEntry;
|
|
162
|
+
|
|
163
|
+
expect(player.getContent().imageUrl).toBe("https://img.test/base.jpg");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("falls back to the first media item when there is no image_base", () => {
|
|
167
|
+
const player = makePlayer();
|
|
168
|
+
|
|
169
|
+
player.entry = {
|
|
170
|
+
title: "Entry Title",
|
|
171
|
+
media_group: [
|
|
172
|
+
{
|
|
173
|
+
type: "image",
|
|
174
|
+
media_item: [
|
|
175
|
+
{ key: "image_other", src: "https://img.test/other.jpg" },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
} as unknown as ZappEntry;
|
|
180
|
+
|
|
181
|
+
expect(player.getContent().imageUrl).toBe("https://img.test/other.jpg");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("has no image when the entry carries no media group", () => {
|
|
185
|
+
const player = makePlayer();
|
|
186
|
+
|
|
187
|
+
player.entry = ENTRY;
|
|
188
|
+
|
|
189
|
+
expect(player.getContent().imageUrl).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("takes the cover image from the live entry, not the channel", () => {
|
|
193
|
+
// The programme's own artwork has to travel with its title - otherwise the
|
|
194
|
+
// lock screen would keep showing the channel's cover all day.
|
|
195
|
+
const player = makePlayer();
|
|
196
|
+
|
|
197
|
+
player.entry = {
|
|
198
|
+
title: "Channel",
|
|
199
|
+
media_group: [
|
|
200
|
+
{
|
|
201
|
+
type: "image",
|
|
202
|
+
media_item: [
|
|
203
|
+
{ key: "image_base", src: "https://img.test/channel.jpg" },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
} as unknown as ZappEntry;
|
|
208
|
+
|
|
209
|
+
withLiveEntry(player, {
|
|
210
|
+
title: "Programme",
|
|
211
|
+
media_group: [
|
|
212
|
+
{
|
|
213
|
+
type: "image",
|
|
214
|
+
media_item: [
|
|
215
|
+
{ key: "image_base", src: "https://img.test/programme.jpg" },
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
} as unknown as ZappEntry);
|
|
220
|
+
|
|
221
|
+
expect(player.getContent().imageUrl).toBe("https://img.test/programme.jpg");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("publishes the observer's live entry to subscribers", () => {
|
|
225
|
+
const player = makePlayer();
|
|
226
|
+
const received: any[] = [];
|
|
227
|
+
|
|
228
|
+
player.entry = ENTRY;
|
|
229
|
+
|
|
230
|
+
player
|
|
231
|
+
.getContentObservable()
|
|
232
|
+
.subscribe((content) => received.push(content));
|
|
233
|
+
|
|
234
|
+
withLiveEntry(player, LIVE_ENTRY);
|
|
235
|
+
player.publishContent();
|
|
236
|
+
|
|
237
|
+
expect(received[received.length - 1].title).toBe("Live Title");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("does not resolve while no subscriber exists", () => {
|
|
241
|
+
const player = makePlayer();
|
|
242
|
+
const getConfiguration = jest.fn(() => null);
|
|
243
|
+
|
|
244
|
+
player.getPluginConfiguration = getConfiguration;
|
|
245
|
+
player.entry = ENTRY;
|
|
246
|
+
|
|
247
|
+
expect(getConfiguration).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Which Studio key set backs the resolution is decided inside `getContent()`
|
|
253
|
+
* (via `getPlayerContentDataKeys`). Getting it wrong would make one platform
|
|
254
|
+
* or content type read another's Studio configuration, so each combination is
|
|
255
|
+
* pinned here - including the negative cases that prove the sets never cross.
|
|
256
|
+
*
|
|
257
|
+
* These moved here from `PlayerContainer.test.tsx` when the resolution moved
|
|
258
|
+
* out of the React tree and into the player; the assertions are unchanged.
|
|
259
|
+
*/
|
|
260
|
+
describe("Player content channel: key-set selection", () => {
|
|
261
|
+
const VIDEO_ENTRY = {
|
|
262
|
+
id: "video-entry",
|
|
263
|
+
title: "Entry Title",
|
|
264
|
+
summary: "Entry Summary",
|
|
265
|
+
content: { src: "https://example.com/video.m3u8" },
|
|
266
|
+
extensions: { customTitleKey: "Custom Title From Extensions" },
|
|
267
|
+
} as unknown as ZappEntry;
|
|
268
|
+
|
|
269
|
+
const AUDIO_ENTRY = {
|
|
270
|
+
id: "audio-entry",
|
|
271
|
+
title: "Entry Title",
|
|
272
|
+
summary: "Entry Summary",
|
|
273
|
+
content: { type: "audio", src: "https://example.com/audio.m3u8" },
|
|
274
|
+
extensions: { customTitleKey: "Custom Audio Title From Extensions" },
|
|
275
|
+
} as unknown as ZappEntry;
|
|
276
|
+
|
|
277
|
+
const contentFor = (
|
|
278
|
+
entry: ZappEntry,
|
|
279
|
+
configuration: Record<string, any> | null
|
|
280
|
+
) => {
|
|
281
|
+
const player = makePlayer(configuration);
|
|
282
|
+
|
|
283
|
+
player.entry = entry;
|
|
284
|
+
|
|
285
|
+
return player.getContent();
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
describe("TV video", () => {
|
|
289
|
+
beforeEach(() => mockIsTV.mockReturnValue(true));
|
|
290
|
+
|
|
291
|
+
it("resolves the TV video override to the configured extensions key", () => {
|
|
292
|
+
const content = contentFor(VIDEO_ENTRY, {
|
|
293
|
+
video_player_title_data_key_source: "other",
|
|
294
|
+
video_player_title_custom_data_key: "extensions.customTitleKey",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(content.title).toBe("Custom Title From Extensions");
|
|
298
|
+
// summary was not overridden, so it keeps falling back to entry.summary.
|
|
299
|
+
expect(content.summary).toBe("Entry Summary");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("falls back to the entry title when the configured key does not resolve", () => {
|
|
303
|
+
const content = contentFor(VIDEO_ENTRY, {
|
|
304
|
+
video_player_title_data_key_source: "other",
|
|
305
|
+
video_player_title_custom_data_key: "extensions.doesNotExist",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(content.title).toBe("Entry Title");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("ignores mobile configuration", () => {
|
|
312
|
+
const content = contentFor(VIDEO_ENTRY, {
|
|
313
|
+
title_data_key_source: "other",
|
|
314
|
+
title_custom_data_key: "extensions.customTitleKey",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(content.title).toBe("Entry Title");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("reads the TV video key set, not the mobile one, when both are configured", () => {
|
|
321
|
+
const content = contentFor(VIDEO_ENTRY, {
|
|
322
|
+
video_player_title_data_key_source: "other",
|
|
323
|
+
video_player_title_custom_data_key: "extensions.customTitleKey",
|
|
324
|
+
// A mobile key is also configured, to prove the TV branch ignores it.
|
|
325
|
+
title_data_key_source: "other",
|
|
326
|
+
title_custom_data_key: "extensions.doesNotExist",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(content.title).toBe("Custom Title From Extensions");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("passes the entry fields through when nothing is configured", () => {
|
|
333
|
+
const content = contentFor(VIDEO_ENTRY, {});
|
|
334
|
+
|
|
335
|
+
expect(content.title).toBe("Entry Title");
|
|
336
|
+
expect(content.summary).toBe("Entry Summary");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("TV audio", () => {
|
|
341
|
+
beforeEach(() => mockIsTV.mockReturnValue(true));
|
|
342
|
+
|
|
343
|
+
it("reads the TV audio key set, not the TV video one", () => {
|
|
344
|
+
const content = contentFor(AUDIO_ENTRY, {
|
|
345
|
+
audio_player_title_data_key_source: "other",
|
|
346
|
+
audio_player_title_custom_data_key: "extensions.customTitleKey",
|
|
347
|
+
// Configuring the TV *video* key too, to prove TV audio content reads
|
|
348
|
+
// its own key family instead.
|
|
349
|
+
video_player_title_data_key_source: "other",
|
|
350
|
+
video_player_title_custom_data_key: "extensions.doesNotExist",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(content.title).toBe("Custom Audio Title From Extensions");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("falls back to the entry fields when nothing is configured", () => {
|
|
357
|
+
const content = contentFor(AUDIO_ENTRY, {});
|
|
358
|
+
|
|
359
|
+
expect(content.title).toBe("Entry Title");
|
|
360
|
+
expect(content.summary).toBe("Entry Summary");
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("mobile", () => {
|
|
365
|
+
beforeEach(() => mockIsTV.mockReturnValue(false));
|
|
366
|
+
|
|
367
|
+
it("resolves the default (mobile) key set", () => {
|
|
368
|
+
const content = contentFor(VIDEO_ENTRY, {
|
|
369
|
+
title_data_key_source: "other",
|
|
370
|
+
title_custom_data_key: "extensions.customTitleKey",
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(content.title).toBe("Custom Title From Extensions");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("ignores TV video configuration", () => {
|
|
377
|
+
const content = contentFor(VIDEO_ENTRY, {
|
|
378
|
+
video_player_title_data_key_source: "other",
|
|
379
|
+
video_player_title_custom_data_key: "extensions.customTitleKey",
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(content.title).toBe("Entry Title");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("shares one key set between video and audio", () => {
|
|
386
|
+
const content = contentFor(AUDIO_ENTRY, {
|
|
387
|
+
title_data_key_source: "other",
|
|
388
|
+
title_custom_data_key: "extensions.customTitleKey",
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(content.title).toBe("Custom Audio Title From Extensions");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("ignores TV audio configuration for mobile audio", () => {
|
|
395
|
+
const content = contentFor(AUDIO_ENTRY, {
|
|
396
|
+
audio_player_title_data_key_source: "other",
|
|
397
|
+
audio_player_title_custom_data_key: "extensions.customTitleKey",
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(content.title).toBe("Entry Title");
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const mockAppStoreGet = jest.fn();
|
|
2
|
+
const mockFindPluginByIdentifier = jest.fn();
|
|
3
|
+
|
|
4
|
+
jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
|
|
5
|
+
appStore: { get: (key: string) => mockAppStoreGet(key) },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
jest.mock("@applicaster/zapp-react-native-utils/pluginUtils", () => ({
|
|
9
|
+
findPluginByIdentifier: (id: string, plugins: any) =>
|
|
10
|
+
mockFindPluginByIdentifier(id, plugins),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { PlayerRole } from "../const";
|
|
14
|
+
import { playerFactory } from "../playerFactory";
|
|
15
|
+
|
|
16
|
+
const fakeComponent = () => null;
|
|
17
|
+
|
|
18
|
+
class FakeController {
|
|
19
|
+
receivedConfig: any;
|
|
20
|
+
constructor(config: any) {
|
|
21
|
+
this.receivedConfig = config;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const baseConfig = {
|
|
26
|
+
player: {},
|
|
27
|
+
playerId: "p1",
|
|
28
|
+
autoplay: false,
|
|
29
|
+
entry: { id: "entry-1" } as any,
|
|
30
|
+
muted: false,
|
|
31
|
+
playerPluginId: "test-plugin",
|
|
32
|
+
screenConfig: { foo: "bar" },
|
|
33
|
+
playerRole: PlayerRole.Cell,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const installPluginWithProtocol = (
|
|
37
|
+
protocolReturn: any | ((screenConfig: any, entry: any) => any)
|
|
38
|
+
) => {
|
|
39
|
+
const playerProtocol =
|
|
40
|
+
typeof protocolReturn === "function"
|
|
41
|
+
? protocolReturn
|
|
42
|
+
: () => protocolReturn;
|
|
43
|
+
|
|
44
|
+
mockAppStoreGet.mockImplementation((key: string) =>
|
|
45
|
+
key === "plugins" ? { "test-plugin": {} } : null
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
mockFindPluginByIdentifier.mockReturnValue({
|
|
49
|
+
module: { playerProtocol },
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe("playerFactory", () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
mockAppStoreGet.mockReset();
|
|
56
|
+
mockFindPluginByIdentifier.mockReset();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("resolves with controller and Component when playerProtocol returns synchronously", async () => {
|
|
60
|
+
installPluginWithProtocol({
|
|
61
|
+
Component: fakeComponent,
|
|
62
|
+
controllerClass: FakeController,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const item = await playerFactory(baseConfig);
|
|
66
|
+
|
|
67
|
+
expect(item).not.toBeNull();
|
|
68
|
+
expect(item?.Component).toBe(fakeComponent);
|
|
69
|
+
expect(item?.controller).toBeInstanceOf(FakeController);
|
|
70
|
+
|
|
71
|
+
expect((item?.controller as FakeController).receivedConfig).toBe(
|
|
72
|
+
baseConfig
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("resolves with controller and Component when playerProtocol returns a Promise (delayed import)", async () => {
|
|
77
|
+
installPluginWithProtocol(async () => ({
|
|
78
|
+
Component: fakeComponent,
|
|
79
|
+
controllerClass: FakeController,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const item = await playerFactory(baseConfig);
|
|
83
|
+
|
|
84
|
+
expect(item).not.toBeNull();
|
|
85
|
+
expect(item?.Component).toBe(fakeComponent);
|
|
86
|
+
expect(item?.controller).toBeInstanceOf(FakeController);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("passes screenConfig and entry through to playerProtocol", async () => {
|
|
90
|
+
const playerProtocol = jest.fn(() => ({
|
|
91
|
+
Component: fakeComponent,
|
|
92
|
+
controllerClass: FakeController,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
mockAppStoreGet.mockImplementation((key: string) =>
|
|
96
|
+
key === "plugins" ? { "test-plugin": {} } : null
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
mockFindPluginByIdentifier.mockReturnValue({ module: { playerProtocol } });
|
|
100
|
+
|
|
101
|
+
await playerFactory(baseConfig);
|
|
102
|
+
|
|
103
|
+
expect(playerProtocol).toHaveBeenCalledWith(
|
|
104
|
+
baseConfig.screenConfig,
|
|
105
|
+
baseConfig.entry
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("resolves to null when playerPluginId is missing", async () => {
|
|
110
|
+
expect(
|
|
111
|
+
await playerFactory({ ...baseConfig, playerPluginId: "" } as any)
|
|
112
|
+
).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("resolves to null when appStore has no plugins", async () => {
|
|
116
|
+
mockAppStoreGet.mockReturnValue(null);
|
|
117
|
+
|
|
118
|
+
expect(await playerFactory(baseConfig)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("resolves to null when the plugin has no playerProtocol", async () => {
|
|
122
|
+
mockAppStoreGet.mockImplementation((key: string) =>
|
|
123
|
+
key === "plugins" ? { "test-plugin": {} } : null
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
mockFindPluginByIdentifier.mockReturnValue({ module: {} });
|
|
127
|
+
|
|
128
|
+
expect(await playerFactory(baseConfig)).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("resolves to null when playerProtocol returns without a Component", async () => {
|
|
132
|
+
installPluginWithProtocol({ controllerClass: FakeController });
|
|
133
|
+
|
|
134
|
+
expect(await playerFactory(baseConfig)).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("resolves to null when playerProtocol returns without a controllerClass", async () => {
|
|
138
|
+
installPluginWithProtocol({ Component: fakeComponent });
|
|
139
|
+
|
|
140
|
+
expect(await playerFactory(baseConfig)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects when playerProtocol throws synchronously", async () => {
|
|
144
|
+
installPluginWithProtocol(() => {
|
|
145
|
+
throw new Error("boom");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await expect(playerFactory(baseConfig)).rejects.toThrow("boom");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { renderHook, act } from "@testing-library/react-native";
|
|
2
|
+
import { BehaviorSubject } from "rxjs";
|
|
3
|
+
|
|
4
|
+
import { usePlayerContent } from "../usePlayerContent";
|
|
5
|
+
|
|
6
|
+
const mockPlayer: any = {
|
|
7
|
+
subject: null as BehaviorSubject<any> | null,
|
|
8
|
+
getContent: jest.fn(),
|
|
9
|
+
getContentObservable: jest.fn(),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
jest.mock("../usePlayer", () => ({
|
|
13
|
+
usePlayer: () => mockPlayer,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("usePlayerContent", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
mockPlayer.subject = new BehaviorSubject({
|
|
19
|
+
title: "First Title",
|
|
20
|
+
summary: "First Summary",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
mockPlayer.getContent = jest.fn(() => mockPlayer.subject.getValue());
|
|
24
|
+
|
|
25
|
+
mockPlayer.getContentObservable = jest.fn(() =>
|
|
26
|
+
mockPlayer.subject.asObservable()
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns the player's current content", () => {
|
|
31
|
+
const { result } = renderHook(() => usePlayerContent());
|
|
32
|
+
|
|
33
|
+
expect(result.current).toEqual({
|
|
34
|
+
title: "First Title",
|
|
35
|
+
summary: "First Summary",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("re-renders when the player publishes new content", () => {
|
|
40
|
+
const { result } = renderHook(() => usePlayerContent());
|
|
41
|
+
|
|
42
|
+
act(() => {
|
|
43
|
+
mockPlayer.subject.next({
|
|
44
|
+
title: "Second Title",
|
|
45
|
+
summary: "Second Summary",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.current).toEqual({
|
|
50
|
+
title: "Second Title",
|
|
51
|
+
summary: "Second Summary",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("unsubscribes on unmount", () => {
|
|
56
|
+
const { unmount } = renderHook(() => usePlayerContent());
|
|
57
|
+
|
|
58
|
+
expect(mockPlayer.subject.observed).toBe(true);
|
|
59
|
+
|
|
60
|
+
unmount();
|
|
61
|
+
|
|
62
|
+
expect(mockPlayer.subject.observed).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|