@applicaster/quick-brick-core 16.0.0-rc.121 → 16.0.0-rc.123

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/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
- React.memo(function ZappApp(props: AppComponentProps) {
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,73 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { Image } from "react-native";
4
+
5
+ jest
6
+ .spyOn(Image, "resolveAssetSource")
7
+ .mockReturnValue({ uri: "asset://mock" } as any);
8
+
9
+ // imports are hoisted above the spy, so the module is loaded after it is set up
10
+ const {
11
+ ACTION_DEFAULT_ASSETS,
12
+ BUTTON_DEFAULT_ASSETS,
13
+ } = require("../defaultAssets");
14
+
15
+ const FLAVOURS = ["flavour_1", "flavour_2"];
16
+ const ACTION_STATES = ["mute", "unmute"];
17
+ const BUTTON_STATES = ["mute", "unmute"];
18
+
19
+ const EXPECTED_PATHS = FLAVOURS.flatMap((flavour) => [
20
+ ...ACTION_STATES.map((state) => `./assets/action/${flavour}/${state}.png`),
21
+ ...BUTTON_STATES.map((state) => `./assets/button/${flavour}/${state}.png`),
22
+ ]);
23
+
24
+ describe("defaultAssets", () => {
25
+ it("defines a non-empty action asset for every flavour and state", () => {
26
+ expect(Object.keys(ACTION_DEFAULT_ASSETS).sort()).toEqual(FLAVOURS);
27
+
28
+ FLAVOURS.forEach((flavour) => {
29
+ const assets = ACTION_DEFAULT_ASSETS[flavour];
30
+
31
+ expect(Object.keys(assets).sort()).toEqual([...ACTION_STATES].sort());
32
+
33
+ ACTION_STATES.forEach((state) => {
34
+ expect(typeof assets[state]).toBe("string");
35
+ expect(assets[state].length).toBeGreaterThan(0);
36
+ });
37
+ });
38
+ });
39
+
40
+ it("defines a non-empty button asset for every flavour and state", () => {
41
+ expect(Object.keys(BUTTON_DEFAULT_ASSETS).sort()).toEqual(FLAVOURS);
42
+
43
+ FLAVOURS.forEach((flavour) => {
44
+ const assets = BUTTON_DEFAULT_ASSETS[flavour];
45
+
46
+ expect(Object.keys(assets).sort()).toEqual([...BUTTON_STATES].sort());
47
+
48
+ BUTTON_STATES.forEach((state) => {
49
+ expect(typeof assets[state]).toBe("string");
50
+ expect(assets[state].length).toBeGreaterThan(0);
51
+ });
52
+ });
53
+ });
54
+
55
+ it("requires exactly the 8 bundled PNGs by literal path", () => {
56
+ const source = fs.readFileSync(
57
+ path.resolve(__dirname, "..", "defaultAssets.ts"),
58
+ "utf8"
59
+ );
60
+
61
+ const required = [
62
+ ...source.matchAll(/require\("(\.\/assets\/[^"]+)"\)/g),
63
+ ].map((match) => match[1]);
64
+
65
+ expect([...required].sort()).toEqual([...EXPECTED_PATHS].sort());
66
+
67
+ required.forEach((relativePath) => {
68
+ expect(fs.existsSync(path.resolve(__dirname, "..", relativePath))).toBe(
69
+ true
70
+ );
71
+ });
72
+ });
73
+ });
@@ -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,33 @@
1
+ import { resolveDefaultAssetUri } from "@applicaster/zapp-react-native-utils/actionUtils";
2
+
3
+ import type { ActionAssets, ButtonAssets, Flavour } from "./types";
4
+
5
+ export const ACTION_DEFAULT_ASSETS: Record<Flavour, ActionAssets> = {
6
+ flavour_1: {
7
+ mute: resolveDefaultAssetUri(require("./assets/action/flavour_1/mute.png")),
8
+ unmute: resolveDefaultAssetUri(
9
+ require("./assets/action/flavour_1/unmute.png")
10
+ ),
11
+ },
12
+ flavour_2: {
13
+ mute: resolveDefaultAssetUri(require("./assets/action/flavour_2/mute.png")),
14
+ unmute: resolveDefaultAssetUri(
15
+ require("./assets/action/flavour_2/unmute.png")
16
+ ),
17
+ },
18
+ };
19
+
20
+ export const BUTTON_DEFAULT_ASSETS: Record<Flavour, ButtonAssets> = {
21
+ flavour_1: {
22
+ mute: resolveDefaultAssetUri(require("./assets/button/flavour_1/mute.png")),
23
+ unmute: resolveDefaultAssetUri(
24
+ require("./assets/button/flavour_1/unmute.png")
25
+ ),
26
+ },
27
+ flavour_2: {
28
+ mute: resolveDefaultAssetUri(require("./assets/button/flavour_2/mute.png")),
29
+ unmute: resolveDefaultAssetUri(
30
+ require("./assets/button/flavour_2/unmute.png")
31
+ ),
32
+ },
33
+ };
@@ -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-rc.121",
3
+ "version": "16.0.0-rc.123",
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-rc.121",
32
- "@applicaster/quick-brick-core-plugins": "16.0.0-rc.121",
33
- "@applicaster/zapp-pipes-v2-client": "16.0.0-rc.121",
34
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.121",
35
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.121",
36
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.121",
37
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.121",
31
+ "@applicaster/applicaster-types": "16.0.0-rc.123",
32
+ "@applicaster/quick-brick-core-plugins": "16.0.0-rc.123",
33
+ "@applicaster/zapp-pipes-v2-client": "16.0.0-rc.123",
34
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.123",
35
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.123",
36
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.123",
37
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.123",
38
38
  "atob": "^2.1.2",
39
39
  "axios": "^0.28.0",
40
40
  "btoa": "^1.2.1",