@applicaster/zapp-react-native-ui-components 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.
Files changed (36) hide show
  1. package/Components/MasterCell/DefaultComponents/ActionButton.tsx +13 -0
  2. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +12 -3
  3. package/Components/MasterCell/DefaultComponents/PressableView.tsx +3 -0
  4. package/Components/MasterCell/DefaultComponents/__tests__/ActionButton.test.tsx +288 -0
  5. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +2 -1
  6. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +4 -2
  7. package/Components/MasterCell/__tests__/masterCell.test.js +60 -0
  8. package/Components/MasterCell/index.tsx +28 -4
  9. package/Components/MasterCell/utils/__tests__/withoutFocusedState.test.js +20 -0
  10. package/Components/MasterCell/utils/index.ts +18 -0
  11. package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +40 -0
  12. package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +55 -15
  13. package/Components/ModalComponent/BottomSheetModalContent.tsx +5 -1
  14. package/Components/ModalComponent/__tests__/showTextInput.test.ts +25 -0
  15. package/Components/ModalComponent/showTextInput.ts +2 -0
  16. package/Components/PlayerContainer/PlayerContainer.tsx +22 -5
  17. package/Components/PlayerContainer/__tests__/PlayerContainer.test.tsx +51 -0
  18. package/Components/River/ComponentsMap/ComponentsMap.tsx +4 -1
  19. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
  20. package/Components/VideoLive/LiveImageManager.ts +126 -12
  21. package/Components/VideoLive/MuteUnmuteController.ts +135 -0
  22. package/Components/VideoLive/PlayerLiveImageComponent.tsx +15 -19
  23. package/Components/VideoLive/__tests__/LiveImageMuteState.test.ts +311 -0
  24. package/Components/VideoLive/__tests__/MuteUnmuteController.test.ts +135 -0
  25. package/Components/VideoLive/__tests__/PlayerLiveImageComponent.test.tsx +63 -1
  26. package/Components/VideoModal/PlayerWrapper.tsx +4 -1
  27. package/Components/VideoModal/__tests__/PlayerWrapper.test.tsx +1 -0
  28. package/Components/VideoModal/__tests__/playerWrapperStyle.test.ts +58 -0
  29. package/Components/VideoModal/__tests__/playerWrapperUtils.test.ts +16 -0
  30. package/Components/VideoModal/playerWrapperStyle.ts +21 -3
  31. package/Components/VideoModal/playerWrapperUtils.ts +7 -1
  32. package/Decorators/ZappPipesDataConnector/__tests__/screenToolbox.test.ts +347 -0
  33. package/Decorators/ZappPipesDataConnector/__tests__/screenToolboxUnavailable.test.ts +53 -0
  34. package/Decorators/ZappPipesDataConnector/index.tsx +19 -4
  35. package/Decorators/ZappPipesDataConnector/screenToolbox.ts +384 -0
  36. package/package.json +5 -5
@@ -0,0 +1,135 @@
1
+ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
2
+ import { getUserCellPlayerMutedPreference } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference";
3
+ import {
4
+ LiveImageManager,
5
+ type LiveImageManagerEvent,
6
+ } from "./LiveImageManager";
7
+
8
+ const { log_error } = createLogger({
9
+ category: "MuteUnmuteController",
10
+ subsystem: "MuteUnmuteController",
11
+ });
12
+
13
+ export const MUTE_UNMUTE_ACTION_IDENTIFIER = "mute_unmute_action";
14
+
15
+ export type MuteUnmuteEntryState = {
16
+ muted: boolean;
17
+ activeCellId?: string;
18
+ };
19
+
20
+ export type MuteUnmuteListener = (state: MuteUnmuteEntryState) => void;
21
+
22
+ type StateOptions = { audioMutedByDefault?: boolean };
23
+
24
+ type MuteUnmuteManager = {
25
+ getEntryMuted: (entryId: string) => boolean | undefined;
26
+ getPlayingCellId: (entryId: string) => string | undefined;
27
+ addListener: (data: {
28
+ id: string;
29
+ listener: Record<LiveImageManagerEvent, () => void>;
30
+ }) => () => void;
31
+ muteAll: () => void;
32
+ unmuteAll: () => void;
33
+ };
34
+
35
+ type MuteUnmuteDependencies = {
36
+ manager?: MuteUnmuteManager;
37
+ getMutedPreference?: (audioMutedByDefault?: boolean) => boolean | unknown;
38
+ };
39
+
40
+ const MANAGER_EVENTS: LiveImageManagerEvent[] = [
41
+ "onStart",
42
+ "onEnd",
43
+ "onError",
44
+ "onDestroy",
45
+ "onRegister",
46
+ "onMuteChange",
47
+ "onPlaybackChange",
48
+ ];
49
+
50
+ const sameState = (a: MuteUnmuteEntryState, b: MuteUnmuteEntryState) =>
51
+ a.muted === b.muted && a.activeCellId === b.activeCellId;
52
+
53
+ export class MuteUnmuteController {
54
+ private readonly manager: MuteUnmuteManager;
55
+ private readonly getMutedPreference: NonNullable<
56
+ MuteUnmuteDependencies["getMutedPreference"]
57
+ >;
58
+
59
+ private subscriptionCounter = 0;
60
+
61
+ constructor({ manager, getMutedPreference }: MuteUnmuteDependencies = {}) {
62
+ this.manager = manager ?? LiveImageManager.instance;
63
+
64
+ this.getMutedPreference =
65
+ getMutedPreference ?? getUserCellPlayerMutedPreference;
66
+ }
67
+
68
+ public getEntryState = (
69
+ entryId: string,
70
+ options?: StateOptions
71
+ ): MuteUnmuteEntryState => {
72
+ return {
73
+ activeCellId: this.manager.getPlayingCellId(entryId),
74
+ muted:
75
+ this.manager.getEntryMuted(entryId) ??
76
+ !!this.getMutedPreference(options?.audioMutedByDefault),
77
+ };
78
+ };
79
+
80
+ public toggle = (entryId: string): void => {
81
+ if (this.getEntryState(entryId).muted) {
82
+ this.manager.unmuteAll();
83
+ } else {
84
+ this.manager.muteAll();
85
+ }
86
+ };
87
+
88
+ public subscribe = (
89
+ entryId: string,
90
+ listener: MuteUnmuteListener,
91
+ options?: StateOptions
92
+ ): (() => void) => {
93
+ const id = `mute-unmute-controller:${entryId}:${++this.subscriptionCounter}`;
94
+
95
+ let lastState = this.getEntryState(entryId, options);
96
+ let active = true;
97
+
98
+ const check = () => {
99
+ if (!active) return;
100
+
101
+ const next = this.getEntryState(entryId, options);
102
+
103
+ if (sameState(next, lastState)) return;
104
+
105
+ lastState = next;
106
+
107
+ try {
108
+ listener(next);
109
+ } catch (error) {
110
+ log_error(
111
+ `subscribe: listener for entry ${entryId} threw: ${error?.message}`,
112
+ { error }
113
+ );
114
+ }
115
+ };
116
+
117
+ const managerListeners = Object.fromEntries(
118
+ MANAGER_EVENTS.map((name) => [name, check])
119
+ ) as Record<LiveImageManagerEvent, () => void>;
120
+
121
+ const removeManagerListener = this.manager.addListener({
122
+ id,
123
+ listener: managerListeners,
124
+ });
125
+
126
+ return () => {
127
+ if (!active) return;
128
+
129
+ active = false;
130
+ removeManagerListener();
131
+ };
132
+ };
133
+ }
134
+
135
+ export const muteUnmuteController = new MuteUnmuteController();
@@ -7,12 +7,8 @@ import { AppState, AppStateStatus, View } from "react-native";
7
7
  import { TrackedView } from "../TrackedView";
8
8
  import { useDimensions } from "@applicaster/zapp-react-native-utils/reactHooks/layout";
9
9
 
10
- import {
11
- isApplePlatform,
12
- isTV,
13
- } from "@applicaster/zapp-react-native-utils/reactUtils";
10
+ import { isApplePlatform } from "@applicaster/zapp-react-native-utils/reactUtils";
14
11
  import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
15
- import { getUserCellPlayerMutedPreference } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference";
16
12
  import Image from "@applicaster/zapp-react-native-ui-components/Components/MasterCell/DefaultComponents/Image";
17
13
  import { LiveImage, LiveImageManager, LiveImageType } from "./LiveImageManager";
18
14
 
@@ -57,10 +53,6 @@ const PlayerLiveImageComponent = (props: Props) => {
57
53
  const [mode, setMode] = React.useState(LiveImageType.Image);
58
54
  const [isReadyToPlay, setIsReady] = React.useState(false);
59
55
 
60
- const [muted, setMuted] = React.useState(
61
- isTV() ? false : getUserCellPlayerMutedPreference(props.audioMutedByDefault)
62
- );
63
-
64
56
  const ref = React.useRef(null);
65
57
  const trackViewRef = React.useRef(null);
66
58
 
@@ -121,6 +113,7 @@ const PlayerLiveImageComponent = (props: Props) => {
121
113
 
122
114
  const liveImageItem: LiveImage = React.useMemo(() => {
123
115
  return new LiveImage({
116
+ audioMutedByDefault: props.audioMutedByDefault,
124
117
  playerId,
125
118
  preloadHooks,
126
119
  factoryConfig: {
@@ -132,10 +125,19 @@ const PlayerLiveImageComponent = (props: Props) => {
132
125
  },
133
126
  tag: item.title?.toString(),
134
127
  });
135
- }, [playerId, preloadHooks, playerPluginId, screenConfig, item]);
136
-
137
- // Keep the muted state in sync with user preference and player state
138
- liveImageItem.initiallyMuted = muted;
128
+ }, [
129
+ playerId,
130
+ preloadHooks,
131
+ playerPluginId,
132
+ screenConfig,
133
+ item,
134
+ props.audioMutedByDefault,
135
+ ]);
136
+
137
+ const muted = React.useSyncExternalStore(
138
+ liveImageItem.subscribeMuted,
139
+ liveImageItem.getMuted
140
+ );
139
141
 
140
142
  React.useEffect(() => {
141
143
  liveImageItem.setMode = setModeDebounced;
@@ -202,12 +204,6 @@ const PlayerLiveImageComponent = (props: Props) => {
202
204
  setIsReady(false);
203
205
  }
204
206
  },
205
- onPlayerMute: () => {
206
- setMuted(true);
207
- },
208
- onPlayerUnmute: () => {
209
- setMuted(false);
210
- },
211
207
  onPlayerClose: () => {
212
208
  onLiveImageCompleted();
213
209
  setIsReady(false);
@@ -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
+ });