@applicaster/zapp-react-native-ui-components 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.
@@ -0,0 +1,311 @@
1
+ import { Player } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/player";
2
+ import { playerFactory } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory";
3
+ import { LiveImage, LiveImageManager } from "../LiveImageManager";
4
+ import { MuteUnmuteController } from "../MuteUnmuteController";
5
+ import * as reactUtils from "@applicaster/zapp-react-native-utils/reactUtils";
6
+
7
+ jest.mock(
8
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory",
9
+ () => ({ playerFactory: jest.fn() })
10
+ );
11
+
12
+ let mockPreference: boolean | undefined;
13
+
14
+ jest.mock(
15
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference",
16
+ () => ({
17
+ getUserCellPlayerMutedPreference: (defaultMuted = true) =>
18
+ mockPreference ?? defaultMuted,
19
+ setUserCellPlayerMutedPreference: (muted: boolean) => {
20
+ mockPreference = muted;
21
+ },
22
+ })
23
+ );
24
+
25
+ const createImage = (audioMutedByDefault = true) =>
26
+ new LiveImage({
27
+ audioMutedByDefault,
28
+ playerId: "preview",
29
+ tag: "preview",
30
+ factoryConfig: {
31
+ player: { current: null },
32
+ playerId: "preview",
33
+ playerPluginId: "test-player",
34
+ screenConfig: {},
35
+ entry: { id: "entry" },
36
+ },
37
+ });
38
+
39
+ const factory = jest.mocked(playerFactory);
40
+
41
+ beforeEach(() => {
42
+ mockPreference = undefined;
43
+ factory.mockReset();
44
+
45
+ factory.mockImplementation(async (props) => ({
46
+ controller: new Player(props),
47
+ Component: () => null,
48
+ }));
49
+ });
50
+
51
+ it("keeps a pre-playback toggle in the domain snapshot and factory input", async () => {
52
+ const image = createImage();
53
+ const listener = jest.fn();
54
+ const remove = image.subscribeMuted(listener);
55
+
56
+ image.unmute();
57
+ image.unmute();
58
+
59
+ expect(image.getMuted()).toBe(false);
60
+ expect(listener).toHaveBeenCalledTimes(1);
61
+
62
+ await image.prepareForPlayback();
63
+
64
+ expect(factory).toHaveBeenCalledWith(
65
+ expect.objectContaining({ muted: false })
66
+ );
67
+
68
+ expect(image.getPlayer()?.getIsMuted()).toBe(false);
69
+
70
+ remove();
71
+ image.mute();
72
+ expect(listener).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it.each([true, false])(
76
+ "reconciles a toggle during async creation from muted=%s",
77
+ async (initialMuted) => {
78
+ let finishFactory!: () => void;
79
+
80
+ factory.mockImplementation(
81
+ (props) =>
82
+ new Promise((resolve) => {
83
+ finishFactory = () =>
84
+ resolve({
85
+ controller: new Player(props),
86
+ Component: () => null,
87
+ });
88
+ })
89
+ );
90
+
91
+ const image = createImage(initialMuted);
92
+ const preparation = image.prepareForPlayback();
93
+
94
+ initialMuted ? image.unmute() : image.mute();
95
+ finishFactory();
96
+ await preparation;
97
+
98
+ expect(image.getMuted()).toBe(!initialMuted);
99
+ expect(image.getPlayer()?.getIsMuted()).toBe(!initialMuted);
100
+ }
101
+ );
102
+
103
+ it("publishes direct player mute changes through the same snapshot", async () => {
104
+ const image = createImage();
105
+ await image.prepareForPlayback();
106
+ const listener = jest.fn();
107
+ image.subscribeMuted(listener);
108
+
109
+ image.getPlayer()?.unmute();
110
+ expect(image.getMuted()).toBe(false);
111
+ image.getPlayer()?.mute();
112
+ expect(image.getMuted()).toBe(true);
113
+ expect(listener).toHaveBeenCalledTimes(2);
114
+ });
115
+
116
+ it("uses the registered cell default for action reads and the first toggle", () => {
117
+ const manager = new LiveImageManager();
118
+ const controller = new MuteUnmuteController({ manager });
119
+ const listener = jest.fn();
120
+ controller.subscribe("entry", listener);
121
+
122
+ const image = createImage(false);
123
+ const unregister = manager.register(image);
124
+
125
+ expect(listener).toHaveBeenLastCalledWith({ muted: false });
126
+ expect(controller.getEntryState("entry")).toEqual({ muted: false });
127
+
128
+ controller.toggle("entry");
129
+
130
+ expect(mockPreference).toBe(true);
131
+ expect(image.getMuted()).toBe(true);
132
+ expect(listener).toHaveBeenLastCalledWith({ muted: true });
133
+
134
+ unregister();
135
+ listener.mockClear();
136
+ image.unmute();
137
+ expect(listener).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("updates every deferred preview when another cell toggles the session", () => {
141
+ const manager = new LiveImageManager();
142
+ const controller = new MuteUnmuteController({ manager });
143
+ const first = createImage();
144
+ const second = createImage();
145
+ const removeFirst = manager.register(first);
146
+ const removeSecond = manager.register(second);
147
+
148
+ controller.toggle("entry");
149
+
150
+ expect(first.getMuted()).toBe(false);
151
+ expect(second.getMuted()).toBe(false);
152
+ expect(controller.getEntryState("entry")).toEqual({ muted: false });
153
+
154
+ removeFirst();
155
+ removeSecond();
156
+ });
157
+
158
+ it("applies a session toggle that happens between construction and registration", () => {
159
+ const manager = new LiveImageManager();
160
+ const image = createImage();
161
+
162
+ manager.unmuteAll();
163
+ const unregister = manager.register(image);
164
+
165
+ expect(image.getMuted()).toBe(false);
166
+ expect(manager.getEntryMuted("entry")).toBe(false);
167
+ unregister();
168
+ });
169
+
170
+ it("keeps TV previews initially unmuted regardless of the mobile preference", () => {
171
+ const isTV = jest.spyOn(reactUtils, "isTV").mockReturnValue(true);
172
+ mockPreference = true;
173
+
174
+ expect(createImage().getMuted()).toBe(false);
175
+ isTV.mockRestore();
176
+ });
177
+
178
+ it("continues notifying and updating the player if a mute subscriber throws", async () => {
179
+ const image = createImage();
180
+ await image.prepareForPlayback();
181
+
182
+ image.subscribeMuted(() => {
183
+ throw new Error("broken subscriber");
184
+ });
185
+
186
+ const listener = jest.fn();
187
+ image.subscribeMuted(listener);
188
+
189
+ image.unmute();
190
+
191
+ expect(listener).toHaveBeenCalledTimes(1);
192
+ expect(image.getPlayer()?.getIsMuted()).toBe(false);
193
+ });
194
+
195
+ it("reports playback only after readiness and hides on pause, end, close and errors", async () => {
196
+ const image = createImage();
197
+ expect(image.isPlaying()).toBe(false);
198
+ await image.prepareForPlayback();
199
+ const player = image.getPlayer()!;
200
+ expect(image.isPlaying()).toBe(false);
201
+
202
+ player.onVideoLoad({ currentTime: 0, duration: 30, seekableDuration: 30 });
203
+ player.onPlayerResume({});
204
+ expect(image.isPlaying()).toBe(true);
205
+
206
+ player.onPlayerPause({});
207
+ expect(image.isPlaying()).toBe(false);
208
+ player.onPlayerResume({});
209
+ expect(image.isPlaying()).toBe(true);
210
+
211
+ player.onVideoEnd({});
212
+ // Player.onVideoEnd does not clear readiness or mark the controller paused.
213
+ expect(player.isReadyToPlay()).toBe(true);
214
+ expect(player.isPlaying()).toBe(true);
215
+ expect(image.isPlaying()).toBe(false);
216
+
217
+ player.onPlayerResume({});
218
+ expect(image.isPlaying()).toBe(true);
219
+ player.onError(new Error("preview failed"));
220
+ expect(image.isPlaying()).toBe(false);
221
+
222
+ player.onPlayerResume({});
223
+ player.onVideoError(new Error("video failed"));
224
+ expect(image.isPlaying()).toBe(false);
225
+
226
+ player.onPlayerResume({});
227
+ player.onPlayerClose();
228
+ expect(image.isPlaying()).toBe(false);
229
+ });
230
+
231
+ it("scopes playback visibility to the current cell and original entry id", async () => {
232
+ const manager = new LiveImageManager();
233
+ const image = createImage();
234
+ const unregister = manager.register(image);
235
+ manager.playLiveImage(image);
236
+ await image.prepareForPlayback();
237
+ const player = image.getPlayer()!;
238
+
239
+ // Preload may substitute the entry passed to the player controller.
240
+ jest.spyOn(player, "getEntry").mockReturnValue({ id: "processed-entry" });
241
+ player.onVideoLoad({ currentTime: 0, duration: 30, seekableDuration: 30 });
242
+ player.onPlayerResume({});
243
+
244
+ expect(manager.getPlayingCellId("entry")).toBe("preview");
245
+ expect(manager.getPlayingCellId("processed-entry")).toBeUndefined();
246
+ player.onVideoEnd({});
247
+ expect(manager.getPlayingCellId("entry")).toBeUndefined();
248
+ unregister();
249
+ });
250
+
251
+ it("delivers real player transitions through manager-only action subscriptions", async () => {
252
+ const manager = LiveImageManager.instance;
253
+ const controller = new MuteUnmuteController({ manager });
254
+ const image = createImage();
255
+ const unregister = manager.register(image);
256
+ manager.playLiveImage(image);
257
+ await image.prepareForPlayback();
258
+ const player = image.getPlayer()!;
259
+ const listener = jest.fn();
260
+ const unsubscribe = controller.subscribe("entry", listener);
261
+
262
+ player.onVideoLoad({ currentTime: 0, duration: 30, seekableDuration: 30 });
263
+ player.onPlayerResume({});
264
+
265
+ expect(listener).toHaveBeenLastCalledWith({
266
+ muted: true,
267
+ activeCellId: "preview",
268
+ });
269
+
270
+ player.unmute();
271
+
272
+ expect(listener).toHaveBeenLastCalledWith({
273
+ muted: false,
274
+ activeCellId: "preview",
275
+ });
276
+
277
+ player.onPlayerPause({});
278
+
279
+ expect(listener).toHaveBeenLastCalledWith({
280
+ muted: false,
281
+ activeCellId: undefined,
282
+ });
283
+
284
+ player.onPlayerResume({});
285
+
286
+ expect(listener).toHaveBeenLastCalledWith({
287
+ muted: false,
288
+ activeCellId: "preview",
289
+ });
290
+
291
+ player.onVideoEnd({});
292
+
293
+ expect(listener).toHaveBeenLastCalledWith({
294
+ muted: false,
295
+ activeCellId: undefined,
296
+ });
297
+
298
+ unsubscribe();
299
+ unregister();
300
+ });
301
+
302
+ it("notifies session-only toggles through the real manager exactly once", () => {
303
+ const manager = new LiveImageManager();
304
+ const controller = new MuteUnmuteController({ manager });
305
+ const listener = jest.fn();
306
+ const unsubscribe = controller.subscribe("entry", listener);
307
+ controller.toggle("entry");
308
+ expect(listener).toHaveBeenCalledTimes(1);
309
+ expect(listener).toHaveBeenCalledWith({ muted: false });
310
+ unsubscribe();
311
+ });
@@ -0,0 +1,135 @@
1
+ import { MuteUnmuteController } from "../MuteUnmuteController";
2
+ import type { LiveImageManagerListenerData } from "../LiveImageManager";
3
+
4
+ jest.mock("../LiveImageManager", () => ({
5
+ LiveImageManager: { instance: {} },
6
+ }));
7
+
8
+ const setup = () => {
9
+ let preference = true;
10
+ const listeners = new Map<string, LiveImageManagerListenerData["listener"]>();
11
+
12
+ const emit = (event: keyof LiveImageManagerListenerData["listener"]) =>
13
+ listeners.forEach((listener) => listener[event]?.());
14
+
15
+ const manager = {
16
+ getEntryMuted: jest.fn((): boolean | undefined => undefined),
17
+ getPlayingCellId: jest.fn((): string | undefined => undefined),
18
+ addListener: jest.fn(({ id, listener }: LiveImageManagerListenerData) => {
19
+ listeners.set(id, listener);
20
+
21
+ return () => listeners.delete(id);
22
+ }),
23
+ muteAll: jest.fn(() => {
24
+ preference = true;
25
+ emit("onMuteChange");
26
+ }),
27
+ unmuteAll: jest.fn(() => {
28
+ preference = false;
29
+ emit("onMuteChange");
30
+ }),
31
+ };
32
+
33
+ const controller = new MuteUnmuteController({
34
+ manager,
35
+ getMutedPreference: (cellDefault) => cellDefault ?? preference,
36
+ });
37
+
38
+ return { controller, manager, listeners, emit };
39
+ };
40
+
41
+ it("uses the registered preview snapshot, otherwise the session/cell default", () => {
42
+ const { controller, manager } = setup();
43
+ expect(controller.getEntryState("1").muted).toBe(true);
44
+
45
+ expect(
46
+ controller.getEntryState("1", { audioMutedByDefault: false }).muted
47
+ ).toBe(false);
48
+
49
+ manager.getEntryMuted.mockReturnValue(false);
50
+ expect(controller.getEntryState("1").muted).toBe(false);
51
+ controller.toggle("1");
52
+ expect(manager.muteAll).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it("toggles and notifies all entries without any registered preview", () => {
56
+ const { controller, manager } = setup();
57
+ const first = jest.fn();
58
+ const second = jest.fn();
59
+ controller.subscribe("1", first);
60
+ controller.subscribe("2", second);
61
+ controller.toggle("1");
62
+ expect(manager.unmuteAll).toHaveBeenCalledTimes(1);
63
+ expect(first).toHaveBeenCalledTimes(1);
64
+ expect(second).toHaveBeenCalledWith({ muted: false });
65
+ controller.toggle("1");
66
+ expect(first).toHaveBeenLastCalledWith({ muted: true });
67
+ });
68
+
69
+ it.each([
70
+ "onRegister",
71
+ "onMuteChange",
72
+ "onStart",
73
+ "onEnd",
74
+ "onError",
75
+ "onDestroy",
76
+ ] as const)(
77
+ "reads domain state on %s and filters unchanged notifications",
78
+ (event) => {
79
+ const { controller, manager, emit } = setup();
80
+ const listener = jest.fn();
81
+ controller.subscribe("1", listener);
82
+ expect(listener).not.toHaveBeenCalled();
83
+ manager.getEntryMuted.mockReturnValue(false);
84
+ emit(event);
85
+ emit(event);
86
+ expect(listener).toHaveBeenCalledTimes(1);
87
+ expect(listener).toHaveBeenCalledWith({ muted: false });
88
+ }
89
+ );
90
+
91
+ it("follows pause, resume and duplicate-entry cell transfers through the manager", () => {
92
+ const { controller, manager, emit } = setup();
93
+ const listener = jest.fn();
94
+ controller.subscribe("1", listener);
95
+
96
+ for (const cell of ["first-cell", undefined, "second-cell"]) {
97
+ manager.getPlayingCellId.mockReturnValue(cell);
98
+ emit("onPlaybackChange");
99
+
100
+ expect(listener).toHaveBeenLastCalledWith({
101
+ muted: true,
102
+ activeCellId: cell,
103
+ });
104
+ }
105
+ });
106
+
107
+ it("cleans up independent subscribers idempotently", () => {
108
+ const { controller, manager, listeners, emit } = setup();
109
+ const first = jest.fn();
110
+ const second = jest.fn();
111
+ const remove = controller.subscribe("1", first);
112
+ controller.subscribe("1", second);
113
+ expect(listeners.size).toBe(2);
114
+ remove();
115
+ remove();
116
+ manager.getEntryMuted.mockReturnValue(false);
117
+ emit("onMuteChange");
118
+ expect(first).not.toHaveBeenCalled();
119
+ expect(second).toHaveBeenCalledTimes(1);
120
+ expect(listeners.size).toBe(1);
121
+ });
122
+
123
+ it("isolates throwing subscribers", () => {
124
+ const { controller, manager, emit } = setup();
125
+ const second = jest.fn();
126
+
127
+ controller.subscribe("1", () => {
128
+ throw new Error("boom");
129
+ });
130
+
131
+ controller.subscribe("1", second);
132
+ manager.getEntryMuted.mockReturnValue(false);
133
+ expect(() => emit("onMuteChange")).not.toThrow();
134
+ expect(second).toHaveBeenCalledTimes(1);
135
+ });
@@ -1,10 +1,11 @@
1
1
  /* eslint-disable react/display-name */
2
2
  import * as React from "react";
3
- import { render } from "@testing-library/react-native";
3
+ import { act, render } from "@testing-library/react-native";
4
4
  import * as PlayerFactory from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory";
5
5
  import { ThemeContext } from "@applicaster/zapp-react-native-utils/theme";
6
6
  import * as navigationHooks from "@applicaster/zapp-react-native-utils/reactHooks/navigation";
7
7
  import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
8
+ import { LiveImageManager, LiveImageType } from "../LiveImageManager";
8
9
 
9
10
  import { PlayerLiveImage } from "../PlayerLiveImageComponent";
10
11
  import * as useNavigationHooks from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useNavigation";
@@ -153,4 +154,65 @@ describe("PlayerLiveImageComponent", () => {
153
154
  const isPlayerRegistered = playerManager.isPlayerRegistered(props.playerId);
154
155
  expect(isPlayerRegistered).toBe(false);
155
156
  });
157
+
158
+ it("renders domain mute state after a pre-playback toggle and later player events", async () => {
159
+ jest.useFakeTimers();
160
+ const nativeView = jest.fn((_props: { muted: boolean }) => null);
161
+
162
+ const Player = jest.requireActual(
163
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/player"
164
+ ).Player;
165
+
166
+ const factory = jest
167
+ .spyOn(PlayerFactory, "playerFactory")
168
+ .mockImplementation(async (props) => ({
169
+ controller: new Player(props),
170
+ Component: nativeView,
171
+ }));
172
+
173
+ const register = jest.spyOn(LiveImageManager.instance, "register");
174
+
175
+ const view = render(
176
+ <Wrapper>
177
+ <PlayerLiveImage {...defaultProps} audioMutedByDefault />
178
+ </Wrapper>
179
+ );
180
+
181
+ const image = register.mock.calls[register.mock.calls.length - 1][0];
182
+
183
+ act(() => image.unmute());
184
+
185
+ view.rerender(
186
+ <Wrapper>
187
+ <PlayerLiveImage
188
+ {...defaultProps}
189
+ audioMutedByDefault
190
+ style={{ opacity: 1 }}
191
+ />
192
+ </Wrapper>
193
+ );
194
+
195
+ expect(image.getMuted()).toBe(false);
196
+
197
+ await act(async () => {
198
+ await image.prepareForPlayback();
199
+ image.setMode?.(LiveImageType.Video);
200
+ jest.advanceTimersByTime(1000);
201
+ });
202
+
203
+ expect(
204
+ nativeView.mock.calls[nativeView.mock.calls.length - 1][0].muted
205
+ ).toBe(false);
206
+
207
+ act(() => image.getPlayer()?.mute());
208
+
209
+ expect(
210
+ nativeView.mock.calls[nativeView.mock.calls.length - 1][0].muted
211
+ ).toBe(true);
212
+
213
+ view.unmount();
214
+ register.mockRestore();
215
+ factory.mockRestore();
216
+ jest.useRealTimers();
217
+ });
156
218
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "16.0.0-rc.121",
3
+ "version": "16.0.0-rc.123",
4
4
  "description": "Applicaster Zapp React Native ui components for the Quick Brick App",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -28,10 +28,10 @@
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/zapp-react-native-bridge": "16.0.0-rc.121",
33
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.121",
34
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.121",
31
+ "@applicaster/applicaster-types": "16.0.0-rc.123",
32
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.123",
33
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.123",
34
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.123",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "react-native-sortables": "1.7.1",