@applicaster/quick-brick-core 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/App/NavigationProvider/NavigationProvider.tsx +14 -5
- package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
- package/App/NavigationProvider/navToolbox.ts +224 -0
- package/App/NavigationProvider/utils.ts +21 -0
- package/App/index.tsx +6 -2
- package/App/uiActions/index.ts +13 -0
- package/App/uiActions/muteUnmute/__tests__/assetResolver.test.ts +226 -0
- package/App/uiActions/muteUnmute/__tests__/binding.test.ts +51 -0
- package/App/uiActions/muteUnmute/__tests__/defaultAssets.test.ts +47 -0
- package/App/uiActions/muteUnmute/__tests__/entryState.test.ts +57 -0
- package/App/uiActions/muteUnmute/__tests__/labelResolver.test.ts +35 -0
- package/App/uiActions/muteUnmute/assetResolver.ts +87 -0
- package/App/uiActions/muteUnmute/defaultAssets.ts +44 -0
- package/App/uiActions/muteUnmute/entryState.ts +28 -0
- package/App/uiActions/muteUnmute/index.ts +79 -0
- package/App/uiActions/muteUnmute/labelResolver.ts +15 -0
- package/App/uiActions/muteUnmute/types.ts +18 -0
- package/package.json +8 -8
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
useGetScreenOrientation,
|
|
14
14
|
} from "@applicaster/zapp-react-native-utils/appUtils/orientationHelper";
|
|
15
15
|
import { focusManager } from "@applicaster/zapp-react-native-utils/focusManager/FocusManager";
|
|
16
|
+
import { setXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
|
|
17
|
+
import { registerNavToolbox } from "./navToolbox";
|
|
16
18
|
|
|
17
19
|
import reducer, {
|
|
18
20
|
ACTIONS,
|
|
@@ -760,11 +762,11 @@ export function NavigationProvider({ children }: Props) {
|
|
|
760
762
|
const getStackForPathname = (
|
|
761
763
|
pathname: string
|
|
762
764
|
): NavigationScreenState | undefined => {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
765
|
+
// Topmost match: the active screen if a route is on the stack twice.
|
|
766
|
+
return R.findLast(
|
|
767
|
+
({ route }) => route === pathname,
|
|
768
|
+
state?.stack?.mainStack ?? []
|
|
769
|
+
);
|
|
768
770
|
};
|
|
769
771
|
|
|
770
772
|
const navigator: QuickBrickAppNavigator = React.useMemo(
|
|
@@ -829,6 +831,13 @@ export function NavigationProvider({ children }: Props) {
|
|
|
829
831
|
navigator.state = state;
|
|
830
832
|
}
|
|
831
833
|
|
|
834
|
+
React.useLayoutEffect(() => {
|
|
835
|
+
setXrayNavigator(navigator);
|
|
836
|
+
registerNavToolbox();
|
|
837
|
+
|
|
838
|
+
return () => setXrayNavigator(undefined);
|
|
839
|
+
}, [navigator]);
|
|
840
|
+
|
|
832
841
|
React.useLayoutEffect(() => {
|
|
833
842
|
if (pathname === "/" && appLaunched) {
|
|
834
843
|
goHome(true);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const mockRegister = jest.fn().mockResolvedValue(undefined);
|
|
2
|
+
|
|
3
|
+
// The X-Ray mock (__mocks__/@applicaster/quick-brick-xray) has no toolbox API: give it one.
|
|
4
|
+
require("@applicaster/quick-brick-xray").registerReactToolbox = mockRegister;
|
|
5
|
+
|
|
6
|
+
// Required after the toolbox API is in place: loading them can register the player toolbox.
|
|
7
|
+
const {
|
|
8
|
+
modalStore,
|
|
9
|
+
} = require("@applicaster/zapp-react-native-utils/modalState");
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
setXrayNavigator,
|
|
13
|
+
} = require("@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator");
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
playerManager,
|
|
17
|
+
} = require("@applicaster/zapp-react-native-utils/appUtils/playerManager");
|
|
18
|
+
|
|
19
|
+
const { registerNavToolbox } = require("../navToolbox");
|
|
20
|
+
|
|
21
|
+
const navigator = (overrides = {}) => {
|
|
22
|
+
const nav = {
|
|
23
|
+
getPathname: () => "/river/a/river/b",
|
|
24
|
+
getStack: () => [{ route: "/river/a" }, { route: "/river/a/river/b" }],
|
|
25
|
+
canGoBack: () => true,
|
|
26
|
+
goBack: jest.fn(),
|
|
27
|
+
goHome: jest.fn(),
|
|
28
|
+
getPlayNextOverlay: () => null,
|
|
29
|
+
getModalState: () => modalStore.getState().modalState,
|
|
30
|
+
dismissModal: jest.fn(),
|
|
31
|
+
videoModalState: { visible: false },
|
|
32
|
+
minimiseVideoModal: jest.fn(),
|
|
33
|
+
maximiseVideoModal: jest.fn(),
|
|
34
|
+
closeVideoModal: jest.fn(),
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
setXrayNavigator(nav as unknown as QuickBrickAppNavigator);
|
|
39
|
+
|
|
40
|
+
return nav;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const videoModal = (mode: string) => ({
|
|
44
|
+
videoModalState: { visible: true, mode, item: { id: "v1", title: "V" } },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const call = (name: string) => {
|
|
48
|
+
// Other toolboxes (player) may register first.
|
|
49
|
+
const { tools } = mockRegister.mock.calls.find(
|
|
50
|
+
([toolbox]) => toolbox.name === "nav"
|
|
51
|
+
)[0];
|
|
52
|
+
|
|
53
|
+
return tools.find((tool) => tool.name === name).handler({});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("navToolbox", () => {
|
|
57
|
+
beforeAll(() => registerNavToolbox());
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
modalStore.getState().setModalState({ visible: false, screen: null });
|
|
61
|
+
jest.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws until the NavigationProvider sets the navigator", () => {
|
|
65
|
+
setXrayNavigator(undefined);
|
|
66
|
+
|
|
67
|
+
expect(() => call("stack")).toThrow("Navigation is not available yet");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("goes back one screen", () => {
|
|
71
|
+
const nav = navigator();
|
|
72
|
+
|
|
73
|
+
expect(call("back")).toEqual({ from: "/river/a/river/b", wentBack: true });
|
|
74
|
+
expect(nav.goBack).toHaveBeenCalledWith(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not go back from the root", () => {
|
|
78
|
+
const nav = navigator({ canGoBack: () => false });
|
|
79
|
+
|
|
80
|
+
expect(call("back")).toMatchObject({ wentBack: false });
|
|
81
|
+
expect(nav.goBack).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not pop the screen under a modal or the video modal", () => {
|
|
85
|
+
const nav = navigator(videoModal("MAXIMIZED"));
|
|
86
|
+
|
|
87
|
+
expect(call("back")).toEqual({
|
|
88
|
+
from: "video-modal/v1",
|
|
89
|
+
wentBack: false,
|
|
90
|
+
blockedBy: "video-modal",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
modalStore.getState().openModal({ item: { id: "m" } });
|
|
94
|
+
|
|
95
|
+
expect(call("back")).toMatchObject({ blockedBy: "modal" });
|
|
96
|
+
expect(nav.goBack).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("only dismisses Play Next when it shows", () => {
|
|
100
|
+
const nav = navigator({ getPlayNextOverlay: () => ({}) });
|
|
101
|
+
|
|
102
|
+
expect(call("back")).toMatchObject({
|
|
103
|
+
wentBack: false,
|
|
104
|
+
dismissed: "playNext",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(nav.goBack).toHaveBeenCalledWith(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves a hook screen to cancel itself", () => {
|
|
111
|
+
const nav = navigator({ getPathname: () => "/river/a/hooks/login" });
|
|
112
|
+
|
|
113
|
+
expect(call("back")).toMatchObject({ wentBack: false, blockedBy: "hook" });
|
|
114
|
+
expect(nav.goBack).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("goes home", () => {
|
|
118
|
+
const nav = navigator();
|
|
119
|
+
|
|
120
|
+
expect(call("home")).toEqual({ from: "/river/a/river/b", wentHome: true });
|
|
121
|
+
expect(nav.goHome).toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not go home under a modal or the video modal", () => {
|
|
125
|
+
const nav = navigator(videoModal("MAXIMIZED"));
|
|
126
|
+
|
|
127
|
+
expect(call("home")).toEqual({
|
|
128
|
+
from: "video-modal/v1",
|
|
129
|
+
wentHome: false,
|
|
130
|
+
blockedBy: "video-modal",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
modalStore.getState().openModal({ item: { id: "m" } });
|
|
134
|
+
|
|
135
|
+
expect(call("home")).toMatchObject({ blockedBy: "modal" });
|
|
136
|
+
expect(nav.goHome).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("goes home with the mini player docked", () => {
|
|
140
|
+
const nav = navigator(videoModal("MINIMIZED"));
|
|
141
|
+
|
|
142
|
+
expect(call("home")).toMatchObject({ wentHome: true });
|
|
143
|
+
expect(nav.goHome).toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("dismisses the modal store's modal, not the video modal", () => {
|
|
147
|
+
const nav = navigator(videoModal("MAXIMIZED"));
|
|
148
|
+
|
|
149
|
+
expect(() => call("dismissModal")).toThrow("No modal is open");
|
|
150
|
+
|
|
151
|
+
modalStore.getState().openModal({ item: { id: "m" } });
|
|
152
|
+
|
|
153
|
+
expect(call("dismissModal")).toBe("OK");
|
|
154
|
+
expect(nav.dismissModal).toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("minimises only a maximised video modal", () => {
|
|
158
|
+
const fullscreen = navigator(videoModal("FULLSCREEN"));
|
|
159
|
+
|
|
160
|
+
expect(() => call("minimizeVideoModal")).toThrow("mode is FULLSCREEN");
|
|
161
|
+
expect(fullscreen.minimiseVideoModal).not.toHaveBeenCalled();
|
|
162
|
+
|
|
163
|
+
const maximized = navigator(videoModal("MAXIMIZED"));
|
|
164
|
+
|
|
165
|
+
call("minimizeVideoModal");
|
|
166
|
+
|
|
167
|
+
expect(maximized.minimiseVideoModal).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("enters and leaves fullscreen through the player, which also rotates the phone", () => {
|
|
171
|
+
const invoke = jest.spyOn(playerManager, "invokeHandler");
|
|
172
|
+
const playerContainer = jest.fn();
|
|
173
|
+
|
|
174
|
+
playerManager.on("toggleFullscreen", playerContainer);
|
|
175
|
+
|
|
176
|
+
navigator(videoModal("MAXIMIZED"));
|
|
177
|
+
call("fullscreenVideoModal");
|
|
178
|
+
|
|
179
|
+
expect(invoke).toHaveBeenCalledWith("toggleFullscreen", {
|
|
180
|
+
fullscreen: true,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const fullscreen = navigator(videoModal("FULLSCREEN"));
|
|
184
|
+
|
|
185
|
+
call("maximizeVideoModal");
|
|
186
|
+
|
|
187
|
+
expect(invoke).toHaveBeenCalledWith("toggleFullscreen", {
|
|
188
|
+
fullscreen: false,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(fullscreen.maximiseVideoModal).not.toHaveBeenCalled();
|
|
192
|
+
|
|
193
|
+
playerManager.removeHandler("toggleFullscreen", playerContainer);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does not claim fullscreen without a player, or from the mini player", () => {
|
|
197
|
+
navigator(videoModal("MAXIMIZED"));
|
|
198
|
+
|
|
199
|
+
expect(() => call("fullscreenVideoModal")).toThrow("shows no player");
|
|
200
|
+
|
|
201
|
+
navigator(videoModal("MINIMIZED"));
|
|
202
|
+
|
|
203
|
+
expect(() => call("fullscreenVideoModal")).toThrow("mode is MINIMIZED");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("expands the mini player", () => {
|
|
207
|
+
const nav = navigator(videoModal("MINIMIZED"));
|
|
208
|
+
|
|
209
|
+
call("maximizeVideoModal");
|
|
210
|
+
|
|
211
|
+
expect(nav.maximiseVideoModal).toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reports the video modal", () => {
|
|
215
|
+
navigator(videoModal("MINIMIZED"));
|
|
216
|
+
|
|
217
|
+
expect(call("videoModal")).toMatchObject({
|
|
218
|
+
visible: true,
|
|
219
|
+
mode: "MINIMIZED",
|
|
220
|
+
entry: { id: "v1", title: "V" },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
navigator();
|
|
224
|
+
|
|
225
|
+
expect(call("videoModal")).toMatchObject({ visible: false });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Dimensions } from "react-native";
|
|
2
|
+
import {
|
|
3
|
+
registerXrayToolbox,
|
|
4
|
+
XrayToolSpec,
|
|
5
|
+
} from "@applicaster/zapp-react-native-utils/logger/xrayToolbox";
|
|
6
|
+
import { getXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
|
|
7
|
+
import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
|
|
8
|
+
import { getTopPathname } from "@applicaster/zapp-react-native-ui-components/Decorators/ZappPipesDataConnector/screenToolbox";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* X-Ray `nav.*` toolbox: lets an agent navigate by intent and read or drive the video modal,
|
|
12
|
+
* which is navigation state. The swipes behind some of these (minimising the player, dragging a
|
|
13
|
+
* sheet) are react-native-gesture-handler recognisers that no public API can feed touches to, so
|
|
14
|
+
* the outcome is offered instead. Pairs with `screen.current` to check where an action landed.
|
|
15
|
+
*
|
|
16
|
+
* Registered by the NavigationProvider, which keeps the navigator in a plain module variable
|
|
17
|
+
* (xrayNavigator), so updating it never renders anything.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const requireNavigator = (): QuickBrickAppNavigator => {
|
|
21
|
+
const navigator = getXrayNavigator();
|
|
22
|
+
|
|
23
|
+
if (!navigator) throw new Error("Navigation is not available yet");
|
|
24
|
+
|
|
25
|
+
return navigator;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The player's own fullscreen switch: it also rotates the phone, as the fullscreen button does. */
|
|
29
|
+
const toggleFullscreen = (fullscreen: boolean) => {
|
|
30
|
+
// Only a mounted PlayerContainer handles it: not while the video modal runs its hooks or casts.
|
|
31
|
+
if (!playerManager.hasHandler("toggleFullscreen")) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"The video modal shows no player (hooks running or casting)"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
playerManager.invokeHandler("toggleFullscreen", { fullscreen });
|
|
38
|
+
|
|
39
|
+
return "OK: the player switches on its next tick, check with videoModal";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const requireVideoModal = () => {
|
|
43
|
+
const nav = requireNavigator();
|
|
44
|
+
|
|
45
|
+
if (!nav.videoModalState?.visible) {
|
|
46
|
+
throw new Error("No video modal is open");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return nav;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const tools: XrayToolSpec[] = [
|
|
53
|
+
{
|
|
54
|
+
name: "stack",
|
|
55
|
+
description: "Routes on the navigation stack, root first.",
|
|
56
|
+
handler: () => ({
|
|
57
|
+
routes: requireNavigator()
|
|
58
|
+
.getStack()
|
|
59
|
+
.map(({ route }) => route),
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "back",
|
|
64
|
+
description:
|
|
65
|
+
"Go back one screen, as the nav bar back button does: it pops the stack and does not run " +
|
|
66
|
+
"hardware back listeners (Android back key), so a screen plugin's own back handling is skipped. " +
|
|
67
|
+
"Not while a modal, the video modal (unless docked) or a hook screen is on top: blockedBy says " +
|
|
68
|
+
"which; close it first, or cancel a hook with the device back key. With Play Next showing it " +
|
|
69
|
+
"only dismisses that. Check with screen.current.",
|
|
70
|
+
handler: () => {
|
|
71
|
+
const nav = requireNavigator();
|
|
72
|
+
const from = getTopPathname() ?? nav.getPathname();
|
|
73
|
+
|
|
74
|
+
// A presented layer (hooks-modal/, modal/, video-modal/) is on top: popping the stack would
|
|
75
|
+
// change the screen underneath it, which the user's back never does.
|
|
76
|
+
if (!from.startsWith("/")) {
|
|
77
|
+
return { from, wentBack: false, blockedBy: from.split("/")[0] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (nav.getPlayNextOverlay()) {
|
|
81
|
+
nav.goBack(false);
|
|
82
|
+
|
|
83
|
+
return { from, wentBack: false, dismissed: "playNext" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The hook screen answers the user's back itself (cancels its flow); a pop would skip that.
|
|
87
|
+
if (from.split("/").slice(-2)[0] === "hooks") {
|
|
88
|
+
return { from, wentBack: false, blockedBy: "hook" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!nav.canGoBack()) return { from, wentBack: false };
|
|
92
|
+
|
|
93
|
+
nav.goBack(false);
|
|
94
|
+
|
|
95
|
+
return { from, wentBack: true };
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "home",
|
|
100
|
+
description:
|
|
101
|
+
"Go to the home screen. Not while a modal, the video modal (unless docked) or the hooks modal is " +
|
|
102
|
+
"on top: blockedBy says which; close it first. On TV it does not move focus to the top menu as " +
|
|
103
|
+
"the remote's home does.",
|
|
104
|
+
handler: () => {
|
|
105
|
+
const nav = requireNavigator();
|
|
106
|
+
const from = getTopPathname() ?? nav.getPathname();
|
|
107
|
+
|
|
108
|
+
// goHome only replaces the stack: a presented layer would stay on top of the home screen,
|
|
109
|
+
// and it covers the menu the user would press home in.
|
|
110
|
+
if (!from.startsWith("/")) {
|
|
111
|
+
return { from, wentHome: false, blockedBy: from.split("/")[0] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
nav.goHome();
|
|
115
|
+
|
|
116
|
+
return { from, wentHome: true };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "dismissModal",
|
|
121
|
+
description:
|
|
122
|
+
"Close the open modal screen or bottom sheet, as dragging it down does. Not the video modal: use closeVideoModal.",
|
|
123
|
+
handler: () => {
|
|
124
|
+
const nav = requireNavigator();
|
|
125
|
+
|
|
126
|
+
if (!nav.getModalState()?.visible) throw new Error("No modal is open");
|
|
127
|
+
|
|
128
|
+
nav.dismissModal();
|
|
129
|
+
|
|
130
|
+
return "OK";
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "videoModal",
|
|
135
|
+
description:
|
|
136
|
+
"The video modal: visible, mode (MAXIMIZED, MINIMIZED = mini player, FULLSCREEN, PIP), its entry, " +
|
|
137
|
+
"and the screen orientation.",
|
|
138
|
+
handler: () => {
|
|
139
|
+
const videoModal = requireNavigator().videoModalState;
|
|
140
|
+
const { width, height } = Dimensions.get("window");
|
|
141
|
+
const orientation = width > height ? "landscape" : "portrait";
|
|
142
|
+
|
|
143
|
+
if (!videoModal?.visible) return { visible: false, orientation };
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
visible: true,
|
|
147
|
+
mode: videoModal.mode,
|
|
148
|
+
entry: videoModal.item
|
|
149
|
+
? { id: videoModal.item.id, title: videoModal.item.title }
|
|
150
|
+
: null,
|
|
151
|
+
orientation,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "minimizeVideoModal",
|
|
157
|
+
description:
|
|
158
|
+
"Minimise the maximised video modal to the mini player, as swiping it down does.",
|
|
159
|
+
handler: () => {
|
|
160
|
+
const nav = requireVideoModal();
|
|
161
|
+
const { mode } = nav.videoModalState;
|
|
162
|
+
|
|
163
|
+
// The swipe exists only on the maximised modal; other modes have no way to the mini player.
|
|
164
|
+
if (mode !== "MAXIMIZED") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Only a maximised video modal minimises (mode is ${mode})`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
nav.minimiseVideoModal();
|
|
171
|
+
|
|
172
|
+
return "OK";
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "maximizeVideoModal",
|
|
177
|
+
description: "Expand the mini player or leave fullscreen.",
|
|
178
|
+
handler: () => {
|
|
179
|
+
const nav = requireVideoModal();
|
|
180
|
+
|
|
181
|
+
if (nav.videoModalState.mode === "FULLSCREEN") {
|
|
182
|
+
return toggleFullscreen(false);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
nav.maximiseVideoModal();
|
|
186
|
+
|
|
187
|
+
return "OK";
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "fullscreenVideoModal",
|
|
192
|
+
description:
|
|
193
|
+
"Put the video modal in fullscreen, as the player's fullscreen button does.",
|
|
194
|
+
handler: () => {
|
|
195
|
+
const { mode } = requireVideoModal().videoModalState;
|
|
196
|
+
|
|
197
|
+
// The fullscreen button is on the maximised player, not on the mini player.
|
|
198
|
+
if (mode !== "MAXIMIZED") {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Only a maximised video modal goes fullscreen (mode is ${mode})`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return toggleFullscreen(true);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "closeVideoModal",
|
|
209
|
+
description: "Close the video modal and its player.",
|
|
210
|
+
handler: () => {
|
|
211
|
+
requireVideoModal().closeVideoModal();
|
|
212
|
+
|
|
213
|
+
return "OK";
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
export const registerNavToolbox = () =>
|
|
219
|
+
registerXrayToolbox({
|
|
220
|
+
name: "nav",
|
|
221
|
+
description:
|
|
222
|
+
"Navigate the app (stack, back, home, modals) and drive the video modal.",
|
|
223
|
+
tools,
|
|
224
|
+
});
|
|
@@ -183,6 +183,27 @@ export function getNavigationTarget(
|
|
|
183
183
|
data.entry = targetEntry;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
if (data.entry && !data.screen) {
|
|
187
|
+
const entryType = (data.entry.type as ZappEntryType)?.value;
|
|
188
|
+
const mappedScreenId = contentTypes?.[entryType]?.screen_id;
|
|
189
|
+
|
|
190
|
+
const mappedRiver = mappedScreenId ? rivers?.[mappedScreenId] : undefined;
|
|
191
|
+
|
|
192
|
+
if (!mappedScreenId) {
|
|
193
|
+
logger.error({
|
|
194
|
+
message: `getNavigationTarget: mapped screen ID missing for content type "${entryType}". Check your types mapping or screen configuration.`,
|
|
195
|
+
data: { entry: data.entry },
|
|
196
|
+
});
|
|
197
|
+
} else if (!mappedRiver) {
|
|
198
|
+
logger.error({
|
|
199
|
+
message: `getNavigationTarget: cannot map screen ID "${mappedScreenId}" to a river. Check your types mapping or screen configuration.`,
|
|
200
|
+
data: { entry: data.entry, mappedScreenId },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
data.screen = mappedRiver;
|
|
205
|
+
}
|
|
206
|
+
|
|
186
207
|
return data;
|
|
187
208
|
}
|
|
188
209
|
|
package/App/index.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import { LogicAggregatorContainer } from "./LogicAggregatorContainer";
|
|
|
33
33
|
import { LoadingOverlay } from "./LoadingOverlay";
|
|
34
34
|
import { NotificationToastRenderer } from "./NotificationToastRenderer";
|
|
35
35
|
import { ZappAppWrapper } from "./components/ZappAppWrapper";
|
|
36
|
+
import { registerCoreUIActions } from "./uiActions";
|
|
36
37
|
|
|
37
38
|
interface AppOptions {
|
|
38
39
|
Layout: React.ComponentType;
|
|
@@ -52,8 +53,10 @@ const App = ({
|
|
|
52
53
|
OfflineHandler,
|
|
53
54
|
SplashLoader,
|
|
54
55
|
InteractionManager,
|
|
55
|
-
}: AppOptions): ZappApp =>
|
|
56
|
-
|
|
56
|
+
}: AppOptions): ZappApp => {
|
|
57
|
+
registerCoreUIActions();
|
|
58
|
+
|
|
59
|
+
return React.memo(function ZappApp(props: AppComponentProps) {
|
|
57
60
|
const {
|
|
58
61
|
styles,
|
|
59
62
|
components: { AppContainer },
|
|
@@ -92,6 +95,7 @@ const App = ({
|
|
|
92
95
|
</ZappAppWrapper>
|
|
93
96
|
);
|
|
94
97
|
});
|
|
98
|
+
};
|
|
95
99
|
|
|
96
100
|
export function createQuickBrickApp(options): ZappApp {
|
|
97
101
|
const {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils";
|
|
2
|
+
|
|
3
|
+
import { registerMuteUnmuteUIAction } from "./muteUnmute";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* App-wide UI actions owned by QuickBrick, registered once at startup.
|
|
7
|
+
* Actions scoped to a live object (e.g. player speed) register with their owner.
|
|
8
|
+
*/
|
|
9
|
+
export const registerCoreUIActions = (): (() => void) => {
|
|
10
|
+
const unregisters = isTV() ? [] : [registerMuteUnmuteUIAction()];
|
|
11
|
+
|
|
12
|
+
return () => unregisters.forEach((unregister) => unregister());
|
|
13
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveActionAssets,
|
|
3
|
+
resolveButtonAssets,
|
|
4
|
+
isConfiguredAsset,
|
|
5
|
+
firstConfiguredAsset,
|
|
6
|
+
selectActionAsset,
|
|
7
|
+
toFlavourTuple,
|
|
8
|
+
} from "../assetResolver";
|
|
9
|
+
import { ACTION_DEFAULT_ASSETS, BUTTON_DEFAULT_ASSETS } from "../defaultAssets";
|
|
10
|
+
|
|
11
|
+
const createManifest = require("../../../../../../plugins/mute-unmute-action/manifests/manifest.config");
|
|
12
|
+
|
|
13
|
+
// distinct value per slot, so a resolver mixing up flavours or states is caught.
|
|
14
|
+
// The real defaults are covered by defaultAssets.test.ts
|
|
15
|
+
jest.mock("../defaultAssets", () => {
|
|
16
|
+
const action = (f: string) => ({
|
|
17
|
+
mute: `action_${f}_mute`,
|
|
18
|
+
unmute: `action_${f}_unmute`,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const button = (f: string) => ({
|
|
22
|
+
mute: `button_${f}_mute`,
|
|
23
|
+
unmute: `button_${f}_unmute`,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
ACTION_DEFAULT_ASSETS: { flavour_1: action("1"), flavour_2: action("2") },
|
|
28
|
+
BUTTON_DEFAULT_ASSETS: { flavour_1: button("1"), flavour_2: button("2") },
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const FLAVOURS = ["flavour_1", "flavour_2"];
|
|
33
|
+
const ACTION_STATES = ["mute", "unmute"];
|
|
34
|
+
const BUTTON_STATES = ["mute", "unmute"];
|
|
35
|
+
|
|
36
|
+
const actionKey = (flavour: string, state: string) =>
|
|
37
|
+
`mobile_action_${flavour}_${state}_asset`;
|
|
38
|
+
|
|
39
|
+
const buttonKey = (flavour: string, state: string) =>
|
|
40
|
+
`mobile_button_${flavour}_${state}_asset`;
|
|
41
|
+
|
|
42
|
+
describe("isConfiguredAsset", () => {
|
|
43
|
+
it.each([
|
|
44
|
+
["a URL", "https://x.com/a.png", true],
|
|
45
|
+
["an empty string", "", false],
|
|
46
|
+
["whitespace", " \n", false],
|
|
47
|
+
["undefined", undefined, false],
|
|
48
|
+
["null", null, false],
|
|
49
|
+
["a number", 5, false],
|
|
50
|
+
["an object", {}, false],
|
|
51
|
+
])("%s", (_, value, expected) => {
|
|
52
|
+
expect(isConfiguredAsset(value)).toBe(expected);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("firstConfiguredAsset", () => {
|
|
57
|
+
it("returns the first configured candidate", () => {
|
|
58
|
+
expect(firstConfiguredAsset(undefined, " ", "a", "b")).toBe("a");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns undefined when nothing is configured", () => {
|
|
62
|
+
expect(firstConfiguredAsset(undefined, "", null)).toBeUndefined();
|
|
63
|
+
expect(firstConfiguredAsset()).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("resolveActionAssets", () => {
|
|
68
|
+
it("equals the defaults without configuration", () => {
|
|
69
|
+
expect(resolveActionAssets({ configuration: {} })).toEqual(
|
|
70
|
+
ACTION_DEFAULT_ASSETS
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
FLAVOURS.forEach((flavour) =>
|
|
75
|
+
ACTION_STATES.forEach((state) =>
|
|
76
|
+
it(`overrides ${flavour} ${state} on its own`, () => {
|
|
77
|
+
const result = resolveActionAssets({
|
|
78
|
+
configuration: { [actionKey(flavour, state)]: "plugin.png" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const expected = JSON.parse(JSON.stringify(ACTION_DEFAULT_ASSETS));
|
|
82
|
+
expected[flavour][state] = "plugin.png";
|
|
83
|
+
expect(result).toEqual(expected);
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
it("never returns an empty URI", () => {
|
|
89
|
+
const blank = (keys: string[]) =>
|
|
90
|
+
Object.fromEntries(keys.map((key) => [key, " "]));
|
|
91
|
+
|
|
92
|
+
const configuration = blank(
|
|
93
|
+
FLAVOURS.flatMap((f) => [
|
|
94
|
+
...ACTION_STATES.map((s) => actionKey(f, s)),
|
|
95
|
+
...BUTTON_STATES.map((s) => buttonKey(f, s)),
|
|
96
|
+
])
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const values = [
|
|
100
|
+
...Object.values(
|
|
101
|
+
resolveActionAssets({
|
|
102
|
+
configuration,
|
|
103
|
+
})
|
|
104
|
+
),
|
|
105
|
+
...Object.values(resolveButtonAssets({ configuration })),
|
|
106
|
+
].flatMap((assets) => Object.values(assets));
|
|
107
|
+
|
|
108
|
+
values.forEach((value) => expect(isConfiguredAsset(value)).toBe(true));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("applies a plugin override to its own key and flavour only", () => {
|
|
112
|
+
const result = resolveActionAssets({
|
|
113
|
+
configuration: { [actionKey("flavour_2", "unmute")]: "plugin.png" },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.flavour_2.unmute).toBe("plugin.png");
|
|
117
|
+
expect(result.flavour_2.mute).toBe(ACTION_DEFAULT_ASSETS.flavour_2.mute);
|
|
118
|
+
expect(result.flavour_1).toEqual(ACTION_DEFAULT_ASSETS.flavour_1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("falls through whitespace plugin values", () => {
|
|
122
|
+
const result = resolveActionAssets({
|
|
123
|
+
configuration: {
|
|
124
|
+
[actionKey("flavour_1", "mute")]: "plugin.png",
|
|
125
|
+
[actionKey("flavour_1", "unmute")]: " ",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.flavour_1.mute).toBe("plugin.png");
|
|
130
|
+
|
|
131
|
+
expect(result.flavour_1.unmute).toBe(
|
|
132
|
+
ACTION_DEFAULT_ASSETS.flavour_1.unmute
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("resolveButtonAssets", () => {
|
|
138
|
+
it("equals the defaults without configuration", () => {
|
|
139
|
+
expect(resolveButtonAssets({ configuration: {} })).toEqual(
|
|
140
|
+
BUTTON_DEFAULT_ASSETS
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
FLAVOURS.forEach((flavour) =>
|
|
145
|
+
BUTTON_STATES.forEach((state) =>
|
|
146
|
+
it(`overrides ${flavour} ${state} on its own`, () => {
|
|
147
|
+
const result = resolveButtonAssets({
|
|
148
|
+
configuration: { [buttonKey(flavour, state)]: "plugin.png" },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const expected = JSON.parse(JSON.stringify(BUTTON_DEFAULT_ASSETS));
|
|
152
|
+
expected[flavour][state] = "plugin.png";
|
|
153
|
+
expect(result).toEqual(expected);
|
|
154
|
+
})
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
it("falls back for whitespace values", () => {
|
|
159
|
+
const result = resolveButtonAssets({
|
|
160
|
+
configuration: { [buttonKey("flavour_1", "mute")]: " " },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(result).toEqual(BUTTON_DEFAULT_ASSETS);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("select helpers", () => {
|
|
168
|
+
const action = { mute: "m", unmute: "u" };
|
|
169
|
+
|
|
170
|
+
it.each([
|
|
171
|
+
[false, "m"],
|
|
172
|
+
[true, "u"],
|
|
173
|
+
])("selectActionAsset muted=%p -> %s", (muted, expected) => {
|
|
174
|
+
expect(selectActionAsset(action, muted)).toBe(expected);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("toFlavourTuple", () => {
|
|
179
|
+
it("returns [flavour_1, flavour_2] picks", () => {
|
|
180
|
+
expect(
|
|
181
|
+
toFlavourTuple(
|
|
182
|
+
{ flavour_1: { v: "a" }, flavour_2: { v: "b" } },
|
|
183
|
+
(x) => x.v
|
|
184
|
+
)
|
|
185
|
+
).toEqual(["a", "b"]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("manifest key contract", () => {
|
|
190
|
+
const manifestKeys = createManifest({
|
|
191
|
+
platform: "ios_for_quickbrick",
|
|
192
|
+
version: "1.0.0",
|
|
193
|
+
})
|
|
194
|
+
.custom_configuration_fields.flatMap((group) => group.fields)
|
|
195
|
+
.filter((field) => field.type === "uploader")
|
|
196
|
+
.map((field) => field.key)
|
|
197
|
+
.sort();
|
|
198
|
+
|
|
199
|
+
const readKeys = () => {
|
|
200
|
+
const read = new Set<string>();
|
|
201
|
+
|
|
202
|
+
const configuration = new Proxy(
|
|
203
|
+
{},
|
|
204
|
+
{
|
|
205
|
+
get: (_, key) => {
|
|
206
|
+
read.add(String(key));
|
|
207
|
+
|
|
208
|
+
return undefined;
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
resolveActionAssets({ configuration });
|
|
214
|
+
resolveButtonAssets({ configuration });
|
|
215
|
+
|
|
216
|
+
return [...read].sort();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
it("reads exactly the uploader keys defined in the manifest", () => {
|
|
220
|
+
expect(readKeys()).toEqual(manifestKeys);
|
|
221
|
+
|
|
222
|
+
expect(manifestKeys).toHaveLength(
|
|
223
|
+
FLAVOURS.length * (ACTION_STATES.length + BUTTON_STATES.length)
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
jest.mock("@applicaster/zapp-react-native-utils/pluginUtils", () => ({
|
|
2
|
+
getPluginByIdentifier: () => undefined,
|
|
3
|
+
}));
|
|
4
|
+
|
|
5
|
+
jest.mock(
|
|
6
|
+
"@applicaster/zapp-react-native-utils/reactHooks/usePluginConfiguration",
|
|
7
|
+
() => ({ getPluginConfiguration: () => ({}) })
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
jest.mock(
|
|
11
|
+
"@applicaster/zapp-react-native-ui-components/Components/VideoLive/MuteUnmuteController",
|
|
12
|
+
() => ({
|
|
13
|
+
MUTE_UNMUTE_ACTION_IDENTIFIER: "mute_unmute_action",
|
|
14
|
+
muteUnmuteController: {
|
|
15
|
+
getEntryState: () => ({ muted: true }),
|
|
16
|
+
subscribe: jest.fn(() => jest.fn()),
|
|
17
|
+
toggle: jest.fn(),
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
import { muteUnmuteUIAction } from "../index";
|
|
23
|
+
import { muteUnmuteController } from "@applicaster/zapp-react-native-ui-components/Components/VideoLive/MuteUnmuteController";
|
|
24
|
+
|
|
25
|
+
it("uses bundled visuals on the first read without a configuration plugin", () => {
|
|
26
|
+
expect(muteUnmuteUIAction.initialEntryState({ id: "1" })).toEqual(
|
|
27
|
+
expect.objectContaining({ checked: true, state: 1, label: "Unmute" })
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("delegates the command without a second imperative UI update", () => {
|
|
32
|
+
const updateState = jest.fn();
|
|
33
|
+
muteUnmuteUIAction.invokeAction({ id: "1" }, { updateState });
|
|
34
|
+
expect(muteUnmuteController.toggle).toHaveBeenCalledWith("1");
|
|
35
|
+
expect(updateState).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("hydrates a late subscriber and cleans up its controller subscription", () => {
|
|
39
|
+
const listener = jest.fn();
|
|
40
|
+
const remove = muteUnmuteUIAction.addListener("1", listener);
|
|
41
|
+
|
|
42
|
+
const controllerRemove = jest.mocked(muteUnmuteController.subscribe).mock
|
|
43
|
+
.results[0].value;
|
|
44
|
+
|
|
45
|
+
expect(listener).toHaveBeenCalledWith(
|
|
46
|
+
expect.objectContaining({ checked: true })
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
remove();
|
|
50
|
+
expect(controllerRemove).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Image } from "react-native";
|
|
2
|
+
|
|
3
|
+
jest
|
|
4
|
+
.spyOn(Image, "resolveAssetSource")
|
|
5
|
+
.mockReturnValue({ uri: "asset://mock" } as any);
|
|
6
|
+
|
|
7
|
+
// imports are hoisted above the spy, so the module is loaded after it is set up
|
|
8
|
+
const {
|
|
9
|
+
ACTION_DEFAULT_ASSETS,
|
|
10
|
+
BUTTON_DEFAULT_ASSETS,
|
|
11
|
+
} = require("../defaultAssets");
|
|
12
|
+
|
|
13
|
+
const FLAVOURS = ["flavour_1", "flavour_2"];
|
|
14
|
+
const ACTION_STATES = ["mute", "unmute"];
|
|
15
|
+
const BUTTON_STATES = ["mute", "unmute"];
|
|
16
|
+
|
|
17
|
+
describe("defaultAssets", () => {
|
|
18
|
+
it("defines a non-empty action asset for every flavour and state", () => {
|
|
19
|
+
expect(Object.keys(ACTION_DEFAULT_ASSETS).sort()).toEqual(FLAVOURS);
|
|
20
|
+
|
|
21
|
+
FLAVOURS.forEach((flavour) => {
|
|
22
|
+
const assets = ACTION_DEFAULT_ASSETS[flavour];
|
|
23
|
+
|
|
24
|
+
expect(Object.keys(assets).sort()).toEqual([...ACTION_STATES].sort());
|
|
25
|
+
|
|
26
|
+
ACTION_STATES.forEach((state) => {
|
|
27
|
+
expect(typeof assets[state]).toBe("string");
|
|
28
|
+
expect(assets[state].length).toBeGreaterThan(0);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("defines a non-empty button asset for every flavour and state", () => {
|
|
34
|
+
expect(Object.keys(BUTTON_DEFAULT_ASSETS).sort()).toEqual(FLAVOURS);
|
|
35
|
+
|
|
36
|
+
FLAVOURS.forEach((flavour) => {
|
|
37
|
+
const assets = BUTTON_DEFAULT_ASSETS[flavour];
|
|
38
|
+
|
|
39
|
+
expect(Object.keys(assets).sort()).toEqual([...BUTTON_STATES].sort());
|
|
40
|
+
|
|
41
|
+
BUTTON_STATES.forEach((state) => {
|
|
42
|
+
expect(typeof assets[state]).toBe("string");
|
|
43
|
+
expect(assets[state].length).toBeGreaterThan(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { buildEntryState } from "../entryState";
|
|
2
|
+
|
|
3
|
+
const actionAssets = {
|
|
4
|
+
flavour_1: { mute: "a1_mute", unmute: "a1_unmute" },
|
|
5
|
+
flavour_2: { mute: "a2_mute", unmute: "a2_unmute" },
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const buttonAssets = {
|
|
9
|
+
flavour_1: { mute: "b1_mute", unmute: "b1_unmute" },
|
|
10
|
+
flavour_2: { mute: "b2_mute", unmute: "b2_unmute" },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const labels = { mute: "Mute", unmute: "Unmute" };
|
|
14
|
+
|
|
15
|
+
const build = (muted: boolean) =>
|
|
16
|
+
buildEntryState({ state: { muted }, actionAssets, buttonAssets, labels });
|
|
17
|
+
|
|
18
|
+
describe("buildEntryState", () => {
|
|
19
|
+
it("sound on: Mute icons, state 0", () => {
|
|
20
|
+
expect(build(false)).toEqual({
|
|
21
|
+
state: 0,
|
|
22
|
+
asset: ["a1_mute", "a2_mute"],
|
|
23
|
+
mobileButtonAssets: ["b1_mute", "b2_mute"],
|
|
24
|
+
label: "Mute",
|
|
25
|
+
checked: false,
|
|
26
|
+
visibleInCells: [],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("muted: Unmute icons, state 1", () => {
|
|
31
|
+
expect(build(true)).toEqual({
|
|
32
|
+
state: 1,
|
|
33
|
+
asset: ["a1_unmute", "a2_unmute"],
|
|
34
|
+
mobileButtonAssets: ["b1_unmute", "b2_unmute"],
|
|
35
|
+
label: "Unmute",
|
|
36
|
+
checked: true,
|
|
37
|
+
visibleInCells: [],
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("limits cell actions to the playing instance without removing button assets", () => {
|
|
42
|
+
expect(
|
|
43
|
+
buildEntryState({
|
|
44
|
+
state: { muted: true, activeCellId: "playing-cell" },
|
|
45
|
+
actionAssets,
|
|
46
|
+
buttonAssets,
|
|
47
|
+
labels,
|
|
48
|
+
})
|
|
49
|
+
).toEqual(
|
|
50
|
+
expect.objectContaining({
|
|
51
|
+
visibleInCells: ["playing-cell"],
|
|
52
|
+
mobileButtonAssets: ["b1_unmute", "b2_unmute"],
|
|
53
|
+
label: "Unmute",
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { DEFAULT_LABELS, resolveLabels, selectLabel } from "../labelResolver";
|
|
2
|
+
|
|
3
|
+
describe("labelResolver", () => {
|
|
4
|
+
it("defaults to Mute / Unmute", () => {
|
|
5
|
+
expect(DEFAULT_LABELS).toEqual({ mute: "Mute", unmute: "Unmute" });
|
|
6
|
+
expect(resolveLabels()).toEqual(DEFAULT_LABELS);
|
|
7
|
+
expect(resolveLabels({})).toEqual(DEFAULT_LABELS);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("uses configured values", () => {
|
|
11
|
+
expect(
|
|
12
|
+
resolveLabels({ mute_label: "Silence", unmute_label: "Sound on" })
|
|
13
|
+
).toEqual({ mute: "Silence", unmute: "Sound on" });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("falls back to the default for empty or whitespace values", () => {
|
|
17
|
+
expect(resolveLabels({ mute_label: "", unmute_label: " " })).toEqual(
|
|
18
|
+
DEFAULT_LABELS
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("overrides each label independently", () => {
|
|
23
|
+
expect(resolveLabels({ unmute_label: "Sound on" })).toEqual({
|
|
24
|
+
mute: "Mute",
|
|
25
|
+
unmute: "Sound on",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("selects the label describing the next action", () => {
|
|
30
|
+
const labels = { mute: "M", unmute: "U" };
|
|
31
|
+
|
|
32
|
+
expect(selectLabel(labels, false)).toBe("M");
|
|
33
|
+
expect(selectLabel(labels, true)).toBe("U");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ACTION_DEFAULT_ASSETS, BUTTON_DEFAULT_ASSETS } from "./defaultAssets";
|
|
2
|
+
import type {
|
|
3
|
+
ActionAssets,
|
|
4
|
+
ButtonAssets,
|
|
5
|
+
Flavour,
|
|
6
|
+
MuteUnmuteActionConfiguration,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
export const isConfiguredAsset = (value: unknown): value is string =>
|
|
10
|
+
typeof value === "string" && value.trim().length > 0;
|
|
11
|
+
|
|
12
|
+
export const firstConfiguredAsset = (
|
|
13
|
+
...candidates: unknown[]
|
|
14
|
+
): string | undefined => candidates.find(isConfiguredAsset);
|
|
15
|
+
|
|
16
|
+
// The only place in runtime code that defines the manifest key format.
|
|
17
|
+
const pluginAsset = (
|
|
18
|
+
configuration: MuteUnmuteActionConfiguration | undefined,
|
|
19
|
+
kind: "action" | "button",
|
|
20
|
+
flavour: Flavour,
|
|
21
|
+
state: keyof ButtonAssets
|
|
22
|
+
): unknown => configuration?.[`mobile_${kind}_${flavour}_${state}_asset`];
|
|
23
|
+
|
|
24
|
+
const resolveAsset = (fallback: string, ...candidates: unknown[]): string =>
|
|
25
|
+
firstConfiguredAsset(...candidates) ?? fallback;
|
|
26
|
+
|
|
27
|
+
export const resolveActionAssets = ({
|
|
28
|
+
configuration,
|
|
29
|
+
}: {
|
|
30
|
+
configuration?: MuteUnmuteActionConfiguration;
|
|
31
|
+
}): Record<Flavour, ActionAssets> => {
|
|
32
|
+
const forFlavour = (flavour: Flavour): ActionAssets => {
|
|
33
|
+
const defaults = ACTION_DEFAULT_ASSETS[flavour];
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
mute: resolveAsset(
|
|
37
|
+
defaults.mute,
|
|
38
|
+
pluginAsset(configuration, "action", flavour, "mute")
|
|
39
|
+
),
|
|
40
|
+
unmute: resolveAsset(
|
|
41
|
+
defaults.unmute,
|
|
42
|
+
pluginAsset(configuration, "action", flavour, "unmute")
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
flavour_1: forFlavour("flavour_1"),
|
|
49
|
+
flavour_2: forFlavour("flavour_2"),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const resolveButtonAssets = ({
|
|
54
|
+
configuration,
|
|
55
|
+
}: {
|
|
56
|
+
configuration?: MuteUnmuteActionConfiguration;
|
|
57
|
+
}): Record<Flavour, ButtonAssets> => {
|
|
58
|
+
const forFlavour = (flavour: Flavour): ButtonAssets => {
|
|
59
|
+
const defaults = BUTTON_DEFAULT_ASSETS[flavour];
|
|
60
|
+
|
|
61
|
+
const asset = (state: keyof ButtonAssets) =>
|
|
62
|
+
resolveAsset(
|
|
63
|
+
defaults[state],
|
|
64
|
+
pluginAsset(configuration, "button", flavour, state)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
mute: asset("mute"),
|
|
69
|
+
unmute: asset("unmute"),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
flavour_1: forFlavour("flavour_1"),
|
|
75
|
+
flavour_2: forFlavour("flavour_2"),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const selectActionAsset = (
|
|
80
|
+
assets: ActionAssets,
|
|
81
|
+
muted: boolean
|
|
82
|
+
): string => (muted ? assets.unmute : assets.mute);
|
|
83
|
+
|
|
84
|
+
export const toFlavourTuple = <T>(
|
|
85
|
+
byFlavour: Record<Flavour, T>,
|
|
86
|
+
pick: (assets: T) => string
|
|
87
|
+
): [string, string] => [pick(byFlavour.flavour_1), pick(byFlavour.flavour_2)];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolveDefaultAssetUri } from "@applicaster/zapp-react-native-utils/actionUtils";
|
|
2
|
+
import { getQuickBrickAsset } from "@applicaster/zapp-react-native-utils/quickBrickAssets";
|
|
3
|
+
|
|
4
|
+
import type { ActionAssets, ButtonAssets, Flavour } from "./types";
|
|
5
|
+
|
|
6
|
+
const PLUGIN = "mute_unmute_action";
|
|
7
|
+
|
|
8
|
+
export const ACTION_DEFAULT_ASSETS: Record<Flavour, ActionAssets> = {
|
|
9
|
+
flavour_1: {
|
|
10
|
+
mute: resolveDefaultAssetUri(
|
|
11
|
+
getQuickBrickAsset(PLUGIN, "action/flavour_1/mute.png")
|
|
12
|
+
),
|
|
13
|
+
unmute: resolveDefaultAssetUri(
|
|
14
|
+
getQuickBrickAsset(PLUGIN, "action/flavour_1/unmute.png")
|
|
15
|
+
),
|
|
16
|
+
},
|
|
17
|
+
flavour_2: {
|
|
18
|
+
mute: resolveDefaultAssetUri(
|
|
19
|
+
getQuickBrickAsset(PLUGIN, "action/flavour_2/mute.png")
|
|
20
|
+
),
|
|
21
|
+
unmute: resolveDefaultAssetUri(
|
|
22
|
+
getQuickBrickAsset(PLUGIN, "action/flavour_2/unmute.png")
|
|
23
|
+
),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const BUTTON_DEFAULT_ASSETS: Record<Flavour, ButtonAssets> = {
|
|
28
|
+
flavour_1: {
|
|
29
|
+
mute: resolveDefaultAssetUri(
|
|
30
|
+
getQuickBrickAsset(PLUGIN, "button/flavour_1/mute.png")
|
|
31
|
+
),
|
|
32
|
+
unmute: resolveDefaultAssetUri(
|
|
33
|
+
getQuickBrickAsset(PLUGIN, "button/flavour_1/unmute.png")
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
flavour_2: {
|
|
37
|
+
mute: resolveDefaultAssetUri(
|
|
38
|
+
getQuickBrickAsset(PLUGIN, "button/flavour_2/mute.png")
|
|
39
|
+
),
|
|
40
|
+
unmute: resolveDefaultAssetUri(
|
|
41
|
+
getQuickBrickAsset(PLUGIN, "button/flavour_2/unmute.png")
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { selectActionAsset, toFlavourTuple } from "./assetResolver";
|
|
2
|
+
import { selectLabel } from "./labelResolver";
|
|
3
|
+
import type { ActionAssets, ButtonAssets, Flavour, Labels } from "./types";
|
|
4
|
+
import type { MuteUnmuteEntryState } from "@applicaster/zapp-react-native-ui-components/Components/VideoLive/MuteUnmuteController";
|
|
5
|
+
|
|
6
|
+
type BuildEntryStateArgs = {
|
|
7
|
+
state: MuteUnmuteEntryState;
|
|
8
|
+
actionAssets: Record<Flavour, ActionAssets>;
|
|
9
|
+
buttonAssets: Record<Flavour, ButtonAssets>;
|
|
10
|
+
labels: Labels;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const buildEntryState = ({
|
|
14
|
+
state: { muted, activeCellId },
|
|
15
|
+
actionAssets,
|
|
16
|
+
buttonAssets,
|
|
17
|
+
labels,
|
|
18
|
+
}: BuildEntryStateArgs): CellActionEntryState => ({
|
|
19
|
+
// Legacy player convention: state 1 = muted (shows the Unmute icon).
|
|
20
|
+
state: muted ? 1 : 0,
|
|
21
|
+
asset: toFlavourTuple(actionAssets, (a) => selectActionAsset(a, muted)),
|
|
22
|
+
mobileButtonAssets: toFlavourTuple(buttonAssets, (a) =>
|
|
23
|
+
selectActionAsset(a, muted)
|
|
24
|
+
),
|
|
25
|
+
label: selectLabel(labels, muted),
|
|
26
|
+
checked: muted,
|
|
27
|
+
visibleInCells: activeCellId ? [activeCellId] : [],
|
|
28
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { getPluginByIdentifier } from "@applicaster/zapp-react-native-utils/pluginUtils";
|
|
2
|
+
import { getPluginConfiguration } from "@applicaster/zapp-react-native-utils/reactHooks/usePluginConfiguration";
|
|
3
|
+
import {
|
|
4
|
+
uiActionsRegistry,
|
|
5
|
+
type RegisteredActionValue,
|
|
6
|
+
} from "@applicaster/zapp-react-native-utils/uiActionsRegistrator/registry";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
MUTE_UNMUTE_ACTION_IDENTIFIER,
|
|
10
|
+
muteUnmuteController,
|
|
11
|
+
type MuteUnmuteEntryState,
|
|
12
|
+
} from "@applicaster/zapp-react-native-ui-components/Components/VideoLive/MuteUnmuteController";
|
|
13
|
+
import { resolveActionAssets, resolveButtonAssets } from "./assetResolver";
|
|
14
|
+
import { resolveLabels } from "./labelResolver";
|
|
15
|
+
import { buildEntryState } from "./entryState";
|
|
16
|
+
import type {
|
|
17
|
+
MuteUnmuteActionConfiguration,
|
|
18
|
+
MuteUnmuteActionLocalizations,
|
|
19
|
+
} from "./types";
|
|
20
|
+
|
|
21
|
+
type Assets = Pick<
|
|
22
|
+
Parameters<typeof buildEntryState>[0],
|
|
23
|
+
"actionAssets" | "buttonAssets"
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
let cache: { plugin: unknown; assets: Assets } | null = null;
|
|
27
|
+
|
|
28
|
+
// The `mute_unmute_action` plugin is configuration only: assets and labels.
|
|
29
|
+
// Raw values are read because every field is optional and has a bundled default.
|
|
30
|
+
const getVisuals = () => {
|
|
31
|
+
const plugin = getPluginByIdentifier(MUTE_UNMUTE_ACTION_IDENTIFIER, true);
|
|
32
|
+
|
|
33
|
+
if (!cache || cache.plugin !== plugin) {
|
|
34
|
+
const configuration = (plugin as any)?.configuration as
|
|
35
|
+
| MuteUnmuteActionConfiguration
|
|
36
|
+
| undefined;
|
|
37
|
+
|
|
38
|
+
cache = {
|
|
39
|
+
plugin,
|
|
40
|
+
assets: {
|
|
41
|
+
actionAssets: resolveActionAssets({ configuration }),
|
|
42
|
+
buttonAssets: resolveButtonAssets({ configuration }),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Not cached: the locale can change at runtime.
|
|
48
|
+
const { localizations } = getPluginConfiguration<
|
|
49
|
+
unknown,
|
|
50
|
+
MuteUnmuteActionLocalizations
|
|
51
|
+
>(plugin);
|
|
52
|
+
|
|
53
|
+
return { ...cache.assets, labels: resolveLabels(localizations) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const toEntryState = (state: MuteUnmuteEntryState) =>
|
|
57
|
+
buildEntryState({ state, ...getVisuals() });
|
|
58
|
+
|
|
59
|
+
/** Visual binding: maps controller state to what cells and buttons render. */
|
|
60
|
+
export const muteUnmuteUIAction: RegisteredActionValue = {
|
|
61
|
+
initialEntryState: (item) =>
|
|
62
|
+
toEntryState(muteUnmuteController.getEntryState(String(item.id))),
|
|
63
|
+
addListener: (entryId, listener) => {
|
|
64
|
+
const remove = muteUnmuteController.subscribe(String(entryId), (state) =>
|
|
65
|
+
listener(toEntryState(state))
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
listener(toEntryState(muteUnmuteController.getEntryState(String(entryId))));
|
|
69
|
+
|
|
70
|
+
return remove;
|
|
71
|
+
},
|
|
72
|
+
invokeAction: (item) => muteUnmuteController.toggle(String(item.id)),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const registerMuteUnmuteUIAction = () =>
|
|
76
|
+
uiActionsRegistry.registerAction(
|
|
77
|
+
MUTE_UNMUTE_ACTION_IDENTIFIER,
|
|
78
|
+
muteUnmuteUIAction
|
|
79
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { firstConfiguredAsset } from "./assetResolver";
|
|
2
|
+
import type { Labels, MuteUnmuteActionLocalizations } from "./types";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_LABELS: Labels = { mute: "Mute", unmute: "Unmute" };
|
|
5
|
+
|
|
6
|
+
export const resolveLabels = (
|
|
7
|
+
localizations?: MuteUnmuteActionLocalizations
|
|
8
|
+
): Labels => ({
|
|
9
|
+
mute: firstConfiguredAsset(localizations?.mute_label) ?? DEFAULT_LABELS.mute,
|
|
10
|
+
unmute:
|
|
11
|
+
firstConfiguredAsset(localizations?.unmute_label) ?? DEFAULT_LABELS.unmute,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const selectLabel = (labels: Labels, muted: boolean): string =>
|
|
15
|
+
muted ? labels.unmute : labels.mute;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Flavour = "flavour_1" | "flavour_2";
|
|
2
|
+
|
|
3
|
+
export type ActionAssets = { mute: string; unmute: string };
|
|
4
|
+
|
|
5
|
+
export type ButtonAssets = ActionAssets;
|
|
6
|
+
|
|
7
|
+
export type Labels = { mute: string; unmute: string };
|
|
8
|
+
|
|
9
|
+
type MobileKind = "action" | "button";
|
|
10
|
+
|
|
11
|
+
export type MuteUnmuteActionConfiguration = Partial<
|
|
12
|
+
Record<`mobile_${MobileKind}_${Flavour}_${keyof ButtonAssets}_asset`, string>
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
export type MuteUnmuteActionLocalizations = {
|
|
16
|
+
mute_label?: string;
|
|
17
|
+
unmute_label?: string;
|
|
18
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/quick-brick-core",
|
|
3
|
-
"version": "16.0.0-alpha.
|
|
3
|
+
"version": "16.0.0-alpha.3202041045",
|
|
4
4
|
"description": "Core package for Applicaster's Quick Brick App",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -28,13 +28,13 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/applicaster/quickbrick#readme",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@applicaster/applicaster-types": "16.0.0-alpha.
|
|
32
|
-
"@applicaster/quick-brick-core-plugins": "16.0.0-alpha.
|
|
33
|
-
"@applicaster/zapp-pipes-v2-client": "16.0.0-alpha.
|
|
34
|
-
"@applicaster/zapp-react-native-bridge": "16.0.0-alpha.
|
|
35
|
-
"@applicaster/zapp-react-native-redux": "16.0.0-alpha.
|
|
36
|
-
"@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.
|
|
37
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-alpha.
|
|
31
|
+
"@applicaster/applicaster-types": "16.0.0-alpha.3202041045",
|
|
32
|
+
"@applicaster/quick-brick-core-plugins": "16.0.0-alpha.3202041045",
|
|
33
|
+
"@applicaster/zapp-pipes-v2-client": "16.0.0-alpha.3202041045",
|
|
34
|
+
"@applicaster/zapp-react-native-bridge": "16.0.0-alpha.3202041045",
|
|
35
|
+
"@applicaster/zapp-react-native-redux": "16.0.0-alpha.3202041045",
|
|
36
|
+
"@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.3202041045",
|
|
37
|
+
"@applicaster/zapp-react-native-utils": "16.0.0-alpha.3202041045",
|
|
38
38
|
"atob": "^2.1.2",
|
|
39
39
|
"axios": "^0.28.0",
|
|
40
40
|
"btoa": "^1.2.1",
|