@applicaster/zapp-react-native-ui-components 16.0.0-rc.122 → 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.
@@ -96,6 +96,14 @@ export const ActionButton = React.memo(function ActionButtonComponent(
96
96
  }, [item?.id, setActionState, actionContext]);
97
97
 
98
98
  if (actionDisabled || !actionContext) return null;
99
+
100
+ if (
101
+ actionState?.visibleInCells &&
102
+ !actionState.visibleInCells.includes(cellUUID)
103
+ ) {
104
+ return null;
105
+ }
106
+
99
107
  const AssetComponent = actionState?.asset;
100
108
 
101
109
  // Default state is not yet ready
@@ -109,6 +117,11 @@ export const ActionButton = React.memo(function ActionButtonComponent(
109
117
  accessibilityLabel={props?.accessibilityLabel || `${item?.id}`}
110
118
  accessibilityHint={props?.accessibilityHint}
111
119
  accessibilityRole="button"
120
+ accessibilityState={
121
+ typeof actionState.checked === "boolean"
122
+ ? { checked: actionState.checked }
123
+ : undefined
124
+ }
112
125
  accessible={!!(props?.testID || props?.accessibilityLabel)}
113
126
  style={props?.style}
114
127
  >
@@ -11,6 +11,7 @@ type Props = {
11
11
  accessibilityHint?: string;
12
12
  onPress?: () => void;
13
13
  testOnly_pressed?: boolean;
14
+ checked?: boolean;
14
15
  };
15
16
 
16
17
  export function PressableView({
@@ -22,11 +23,13 @@ export function PressableView({
22
23
  pressedStyle,
23
24
  onPress,
24
25
  testOnly_pressed = false,
26
+ checked,
25
27
  }: Props) {
26
28
  return (
27
29
  <Pressable
28
30
  testOnly_pressed={testOnly_pressed}
29
31
  onPress={onPress}
32
+ accessibilityState={checked === undefined ? undefined : { checked }}
30
33
  testID={testID}
31
34
  accessibilityLabel={accessibilityLabel}
32
35
  accessibilityHint={accessibilityHint}
@@ -0,0 +1,288 @@
1
+ import React from "react";
2
+ import { act, fireEvent, render } from "@testing-library/react-native";
3
+
4
+ import { ActionButton } from "../ActionButton";
5
+ import { ActionButton as MobileActionButton } from "../mobile/MobileActionButtons/ActionButton";
6
+ import { Text } from "react-native";
7
+
8
+ type EntryStateListener = (state: CellActionEntryState) => void;
9
+
10
+ const PLUGIN_ASSETS = {
11
+ mute: ["plugin_mute_1", "plugin_mute_2"],
12
+ unmute: ["plugin_unmute_1", "plugin_unmute_2"],
13
+ };
14
+
15
+ // Fake of the mute_unmute_action context, following its entry state contract.
16
+ type FakeState = {
17
+ available: boolean;
18
+ muted: boolean;
19
+ visibleInCells?: string[];
20
+ };
21
+
22
+ const createFakeContext = (initial: FakeState) => {
23
+ let current = initial;
24
+ const listeners = new Set<EntryStateListener>();
25
+
26
+ const toEntryState = ({
27
+ available,
28
+ muted,
29
+ visibleInCells,
30
+ }: FakeState): CellActionEntryState => ({
31
+ state: muted ? 1 : 0,
32
+ asset: available
33
+ ? muted
34
+ ? PLUGIN_ASSETS.unmute
35
+ : PLUGIN_ASSETS.mute
36
+ : undefined,
37
+ label: muted ? "Unmute" : "Mute",
38
+ mobileButtonAssets: muted ? PLUGIN_ASSETS.unmute : PLUGIN_ASSETS.mute,
39
+ visibleInCells,
40
+ });
41
+
42
+ const emit = (next: FakeState) => {
43
+ current = next;
44
+ listeners.forEach((listener) => listener(toEntryState(current)));
45
+ };
46
+
47
+ const context = {
48
+ initialEntryState: jest.fn(() => toEntryState(current)),
49
+ addListener: jest.fn((_id: string, listener: EntryStateListener) => {
50
+ listeners.add(listener);
51
+
52
+ return () => listeners.delete(listener);
53
+ }),
54
+ invokeAction: jest.fn((_item, options) => {
55
+ if (!current.available) return;
56
+
57
+ current = { ...current, muted: !current.muted };
58
+ options?.updateState?.(toEntryState(current));
59
+ listeners.forEach((listener) => listener(toEntryState(current)));
60
+ }),
61
+ };
62
+
63
+ return { context, emit, listeners };
64
+ };
65
+
66
+ let mockContext: ReturnType<typeof createFakeContext>["context"];
67
+
68
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks/actions", () => ({
69
+ useActions: jest.fn(() => mockContext),
70
+ }));
71
+
72
+ jest.mock(
73
+ "@applicaster/zapp-react-native-ui-components/Contexts/UIComponentContext",
74
+ () => ({ useUIComponentContext: jest.fn(() => undefined) })
75
+ );
76
+
77
+ jest.mock("../Image", () => {
78
+ const { Text: MockText } = require("react-native");
79
+
80
+ return {
81
+ __esModule: true,
82
+ default: ({ uri }) => <MockText testID="asset">{uri}</MockText>,
83
+ };
84
+ });
85
+
86
+ const item = { id: "entry-1" } as unknown as ZappEntry;
87
+
88
+ it("keeps the standalone button visible and limits actions to the playing cell instance", () => {
89
+ const fake = createFakeContext({
90
+ available: true,
91
+ muted: true,
92
+ visibleInCells: [],
93
+ });
94
+
95
+ mockContext = fake.context;
96
+
97
+ const Label = ({
98
+ children,
99
+ testID,
100
+ }: {
101
+ children?: React.ReactNode;
102
+ testID?: string;
103
+ mobileActionRole?: string;
104
+ }) => <Text testID={testID}>{children}</Text>;
105
+
106
+ const { getByTestId, queryByTestId } = render(
107
+ <>
108
+ {["first", "second"].map((cellUUID) => (
109
+ <ActionButton
110
+ key={cellUUID}
111
+ item={item}
112
+ action={{ identifier: "mute_unmute_action" }}
113
+ cellUUID={cellUUID}
114
+ testID={`cell-${cellUUID}`}
115
+ style={{}}
116
+ />
117
+ ))}
118
+ <MobileActionButton
119
+ action={{ identifier: "mute_unmute_action" }}
120
+ entry={item}
121
+ testID="standalone"
122
+ style={{}}
123
+ focusedStyles={{}}
124
+ >
125
+ <Label mobileActionRole="label">Toggle sound</Label>
126
+ </MobileActionButton>
127
+ </>
128
+ );
129
+
130
+ expect(queryByTestId("cell-first")).toBeNull();
131
+ expect(queryByTestId("cell-second")).toBeNull();
132
+ expect(getByTestId("standalone")).toBeTruthy();
133
+ fireEvent.press(getByTestId("standalone"));
134
+ expect(fake.context.invokeAction).toHaveBeenCalledTimes(1);
135
+
136
+ act(() =>
137
+ fake.emit({ available: true, muted: false, visibleInCells: ["first"] })
138
+ );
139
+
140
+ expect(getByTestId("cell-first")).toBeTruthy();
141
+ expect(queryByTestId("cell-second")).toBeNull();
142
+
143
+ act(() =>
144
+ fake.emit({ available: true, muted: false, visibleInCells: ["second"] })
145
+ );
146
+
147
+ expect(queryByTestId("cell-first")).toBeNull();
148
+ expect(getByTestId("cell-second")).toBeTruthy();
149
+
150
+ act(() => fake.emit({ available: true, muted: false, visibleInCells: [] }));
151
+
152
+ expect(queryByTestId("cell-first")).toBeNull();
153
+ expect(queryByTestId("cell-second")).toBeNull();
154
+ expect(getByTestId("standalone")).toBeTruthy();
155
+ });
156
+
157
+ const renderButton = (
158
+ flavour: "flavour_1" | "flavour_2",
159
+ asset?: React.ComponentProps<typeof ActionButton>["asset"]
160
+ ) =>
161
+ render(
162
+ <ActionButton
163
+ item={item}
164
+ flavour={flavour}
165
+ action={{ identifier: "mute_unmute_action" }}
166
+ asset={asset}
167
+ style={{}}
168
+ testID="mute-button"
169
+ />
170
+ );
171
+
172
+ const cellAsset = {
173
+ props: {},
174
+ src: { active: "cell_selected", inactive: "cell_unselected" },
175
+ style: {},
176
+ type: "Image",
177
+ } as const;
178
+
179
+ describe.each([
180
+ ["flavour_1", 0],
181
+ ["flavour_2", 1],
182
+ ] as const)("mute_unmute_action cell action (%s)", (flavour, index) => {
183
+ let fake: ReturnType<typeof createFakeContext>;
184
+
185
+ const setup = (state: { available: boolean; muted: boolean }, asset?) => {
186
+ fake = createFakeContext(state);
187
+ mockContext = fake.context;
188
+
189
+ return renderButton(flavour, asset);
190
+ };
191
+
192
+ it("renders nothing while the preview has not started", () => {
193
+ const { queryByTestId } = setup({ available: false, muted: false });
194
+
195
+ expect(queryByTestId("mute-button")).toBeNull();
196
+ expect(queryByTestId("asset")).toBeNull();
197
+ });
198
+
199
+ it("shows the Mute icon while the preview plays with sound on", () => {
200
+ const { getByTestId } = setup({ available: true, muted: false });
201
+
202
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
203
+ });
204
+
205
+ it("shows the Unmute icon while the preview plays muted", () => {
206
+ const { getByTestId } = setup({ available: true, muted: true });
207
+
208
+ expect(getByTestId("asset").props.children).toBe(
209
+ PLUGIN_ASSETS.unmute[index]
210
+ );
211
+ });
212
+
213
+ it("toggles the sound and updates the icon on tap", () => {
214
+ const { getByTestId } = setup({ available: true, muted: false });
215
+
216
+ fireEvent.press(getByTestId("mute-button"));
217
+
218
+ expect(fake.context.invokeAction).toHaveBeenCalledTimes(1);
219
+
220
+ expect(getByTestId("asset").props.children).toBe(
221
+ PLUGIN_ASSETS.unmute[index]
222
+ );
223
+
224
+ fireEvent.press(getByTestId("mute-button"));
225
+
226
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
227
+ });
228
+
229
+ it("follows state emitted through addListener", () => {
230
+ const { getByTestId, queryByTestId } = setup({
231
+ available: false,
232
+ muted: false,
233
+ });
234
+
235
+ act(() => fake.emit({ available: true, muted: false }));
236
+
237
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
238
+
239
+ act(() => fake.emit({ available: true, muted: true }));
240
+
241
+ expect(getByTestId("asset").props.children).toBe(
242
+ PLUGIN_ASSETS.unmute[index]
243
+ );
244
+
245
+ expect(queryByTestId("mute-button")).not.toBeNull();
246
+ });
247
+
248
+ it("hides again when the preview stops or another cell starts", () => {
249
+ const { queryByTestId } = setup({ available: true, muted: false });
250
+
251
+ expect(queryByTestId("mute-button")).not.toBeNull();
252
+
253
+ act(() => fake.emit({ available: false, muted: false }));
254
+
255
+ expect(queryByTestId("mute-button")).toBeNull();
256
+ });
257
+
258
+ it("unsubscribes from the context on unmount", () => {
259
+ const { unmount } = setup({ available: true, muted: false });
260
+
261
+ expect(fake.listeners.size).toBe(1);
262
+
263
+ unmount();
264
+
265
+ expect(fake.listeners.size).toBe(0);
266
+ });
267
+
268
+ it("prefers the cell's selected icon when muted (state 1)", () => {
269
+ const { getByTestId } = setup({ available: true, muted: true }, cellAsset);
270
+
271
+ expect(getByTestId("asset").props.children).toBe("cell_selected");
272
+ });
273
+
274
+ it("prefers the cell's unselected icon when sound is on (state 0)", () => {
275
+ const { getByTestId } = setup({ available: true, muted: false }, cellAsset);
276
+
277
+ expect(getByTestId("asset").props.children).toBe("cell_unselected");
278
+ });
279
+
280
+ it("uses the plugin asset when the cell has no icons", () => {
281
+ const { getByTestId } = setup({ available: true, muted: false }, {
282
+ ...cellAsset,
283
+ src: { active: undefined, inactive: undefined },
284
+ } as any);
285
+
286
+ expect(getByTestId("asset").props.children).toBe(PLUGIN_ASSETS.mute[index]);
287
+ });
288
+ });
@@ -169,6 +169,7 @@ export const ActionButton = ({
169
169
  style={[style, props.alignment]}
170
170
  pressedStyle={focusedStyles}
171
171
  onPress={renderProps.onPress}
172
+ checked={renderProps.actionState?.checked}
172
173
  accessibilityLabel={props.accessibilityLabel || `${entry?.id}`}
173
174
  accessibilityHint={props.accessibilityHint}
174
175
  >
@@ -3,7 +3,10 @@ import {
3
3
  PlayerLifecycleListener,
4
4
  playerManager,
5
5
  } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
6
- import { setUserCellPlayerMutedPreference } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference";
6
+ import {
7
+ getUserCellPlayerMutedPreference,
8
+ setUserCellPlayerMutedPreference,
9
+ } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference";
7
10
  import { playerFactory } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory";
8
11
  import { PlayerRole } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
9
12
  import { loggerLiveImageManager } from "./loggerHelper";
@@ -21,13 +24,15 @@ export type LiveImageManagerEvent =
21
24
  | "onEnd"
22
25
  | "onStart"
23
26
  | "onError"
24
- | "onDestroy";
27
+ | "onDestroy"
28
+ | "onRegister"
29
+ | "onPlaybackChange"
30
+ | "onMuteChange";
25
31
 
26
32
  export type LiveImageManagerListenerData = {
27
33
  id: string;
28
- listener: Record<
29
- LiveImageManagerEvent,
30
- (event?: Record<string, any>) => void
34
+ listener: Partial<
35
+ Record<LiveImageManagerEvent, (event?: Record<string, any>) => void>
31
36
  >;
32
37
  };
33
38
 
@@ -54,6 +59,7 @@ type PlayerFactoryConfig = {
54
59
  };
55
60
 
56
61
  type LiveImageProps = {
62
+ audioMutedByDefault?: boolean;
57
63
  playerId: string;
58
64
  setMode?: (type: LiveImageType) => void;
59
65
  preloadHooks?: PreloadHookConfig[];
@@ -81,9 +87,11 @@ export class LiveImageManager implements PlayerLifecycleListener {
81
87
 
82
88
  protected listeners: Record<
83
89
  string,
84
- Record<LiveImageManagerEvent, (event?: Record<string, any>) => void>
90
+ LiveImageManagerListenerData["listener"]
85
91
  > = {};
86
92
 
93
+ private mutedSubscriptions = new Map<LiveImage, () => void>();
94
+
87
95
  constructor() {
88
96
  playerManager.addLifecycleListener(this);
89
97
  this.listeners = {};
@@ -96,8 +104,25 @@ export class LiveImageManager implements PlayerLifecycleListener {
96
104
  }
97
105
 
98
106
  public register = (item: LiveImage): (() => void) => {
107
+ if (!isTV()) {
108
+ const muted = !!getUserCellPlayerMutedPreference(item.getMuted());
109
+
110
+ if (muted !== item.getMuted()) {
111
+ muted ? item.mute() : item.unmute();
112
+ }
113
+ }
114
+
99
115
  this.items.push(item);
116
+
117
+ this.mutedSubscriptions.set(
118
+ item,
119
+ item.subscribeMuted(() =>
120
+ this.invokeListenersUpdate({ callbackName: "onMuteChange" })
121
+ )
122
+ );
123
+
100
124
  log_debug(`register: live image ${item.playerId} - ${item.tag}`);
125
+ this.invokeListenersUpdate({ callbackName: "onRegister" });
101
126
 
102
127
  // TV only Start playing video once registered
103
128
  if (isTV()) {
@@ -109,6 +134,8 @@ export class LiveImageManager implements PlayerLifecycleListener {
109
134
 
110
135
  public unregister = (item: LiveImage) => {
111
136
  log_debug(`unregister: live-image ${item.playerId} - ${item.tag}`);
137
+ this.mutedSubscriptions.get(item)?.();
138
+ this.mutedSubscriptions.delete(item);
112
139
 
113
140
  if (this.currentlyPlaying === item) {
114
141
  this.currentlyPlaying = null;
@@ -172,6 +199,25 @@ export class LiveImageManager implements PlayerLifecycleListener {
172
199
  return this.currentlyPlaying;
173
200
  };
174
201
 
202
+ public getEntryMuted = (entryId: string): boolean | undefined => {
203
+ const matches = (item: LiveImage) => String(item.getEntryId()) === entryId;
204
+
205
+ const item =
206
+ this.currentlyPlaying && matches(this.currentlyPlaying)
207
+ ? this.currentlyPlaying
208
+ : this.items.find(matches);
209
+
210
+ return item?.getMuted();
211
+ };
212
+
213
+ public getPlayingCellId = (entryId: string): string | undefined => {
214
+ const item = this.currentlyPlaying;
215
+
216
+ return item && String(item.getEntryId()) === entryId && item.isPlaying()
217
+ ? item.playerId
218
+ : undefined;
219
+ };
220
+
175
221
  private findNextPlayableItem = () => {
176
222
  if (isTV()) {
177
223
  return this.items[this.items.length - 1];
@@ -302,6 +348,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
302
348
  setUserCellPlayerMutedPreference(true);
303
349
 
304
350
  this.items.forEach((liveImage) => liveImage.mute());
351
+ this.invokeListenersUpdate({ callbackName: "onMuteChange" });
305
352
  };
306
353
 
307
354
  public unmuteAll = () => {
@@ -310,6 +357,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
310
357
  setUserCellPlayerMutedPreference(false);
311
358
 
312
359
  this.items.forEach((liveImage) => liveImage.unmute());
360
+ this.invokeListenersUpdate({ callbackName: "onMuteChange" });
313
361
  };
314
362
 
315
363
  public onViewPositionChanged = (item: LiveImage) => {
@@ -579,20 +627,67 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
579
627
  left: 0,
580
628
  };
581
629
 
582
- // Keeps muted state in sync: updates the initial value for deferred
583
- // player creation and forwards to the player if it already exists.
584
- public initiallyMuted: boolean = true;
630
+ private muted: boolean;
631
+ private mutedListeners = new Set<() => void>();
632
+ private playbackEnded = false;
633
+
634
+ getMuted = (): boolean => this.muted;
635
+
636
+ getEntryId = () => this.factoryConfig.entry.id;
637
+
638
+ isPlaying = (): boolean =>
639
+ !this.playbackEnded &&
640
+ !!this.player?.isReadyToPlay() &&
641
+ this.player.isPlaying();
642
+
643
+ subscribeMuted = (listener: () => void): (() => void) => {
644
+ this.mutedListeners.add(listener);
645
+
646
+ return () => this.mutedListeners.delete(listener);
647
+ };
648
+
649
+ private setMuted = (muted: boolean) => {
650
+ if (this.muted === muted) return;
651
+
652
+ this.muted = muted;
653
+
654
+ this.mutedListeners.forEach((listener) => {
655
+ try {
656
+ listener();
657
+ } catch (error) {
658
+ log_error(`Mute listener failed for ${this.playerId}`, { error });
659
+ }
660
+ });
661
+ };
585
662
 
586
663
  mute = () => {
587
- this.initiallyMuted = true;
664
+ this.setMuted(true);
588
665
  this.player?.mute();
589
666
  };
590
667
 
591
668
  unmute = () => {
592
- this.initiallyMuted = false;
669
+ this.setMuted(false);
593
670
  this.player?.unmute();
594
671
  };
595
672
 
673
+ onPlayerMute = () => this.setMuted(true);
674
+
675
+ onPlayerUnmute = () => this.setMuted(false);
676
+
677
+ onPlayerResume = () => {
678
+ this.playbackEnded = false;
679
+
680
+ LiveImageManager.instance.invokeListenersUpdate({
681
+ callbackName: "onPlaybackChange",
682
+ });
683
+ };
684
+
685
+ onPlayerPause = () => {
686
+ LiveImageManager.instance.invokeListenersUpdate({
687
+ callbackName: "onPlaybackChange",
688
+ });
689
+ };
690
+
596
691
  positionToString() {
597
692
  if (!this.position) {
598
693
  return "position not set";
@@ -617,6 +712,10 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
617
712
  this.preloadHooks = props.preloadHooks;
618
713
  this.factoryConfig = props.factoryConfig;
619
714
  this.tag = props.tag || "untagged";
715
+
716
+ this.muted = isTV()
717
+ ? false
718
+ : !!getUserCellPlayerMutedPreference(props.audioMutedByDefault);
620
719
  }
621
720
 
622
721
  async prepareForPlayback(): Promise<boolean> {
@@ -670,7 +769,7 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
670
769
  playerId: this.factoryConfig.playerId,
671
770
  autoplay: false,
672
771
  entry,
673
- muted: this.initiallyMuted,
772
+ muted: this.muted,
674
773
  playerPluginId: this.factoryConfig.playerPluginId,
675
774
  screenConfig: this.factoryConfig.screenConfig,
676
775
  playerRole: PlayerRole.Cell,
@@ -686,6 +785,11 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
686
785
  this.component = factoryItem.Component;
687
786
  this.player.addListener({ id: "live-image", listener: this });
688
787
 
788
+ // A user toggle may have arrived while the async factory was pending.
789
+ if (this.player.getIsMuted() !== this.muted) {
790
+ this.muted ? this.player.mute() : this.player.unmute();
791
+ }
792
+
689
793
  return true;
690
794
  }
691
795
 
@@ -694,6 +798,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
694
798
  };
695
799
 
696
800
  onLoad = (_event) => {
801
+ this.playbackEnded = false;
802
+
697
803
  log_info(
698
804
  `onLoad: live-image, video loaded, switching to video, ${playerInfo(
699
805
  this.player
@@ -704,6 +810,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
704
810
  };
705
811
 
706
812
  onError = (error: Error) => {
813
+ this.playbackEnded = true;
814
+
707
815
  log_error(
708
816
  `onError: live-image, fail to play, switching back to image. error: ${
709
817
  error?.message
@@ -714,6 +822,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
714
822
  };
715
823
 
716
824
  onVideoError = (error: Error) => {
825
+ this.playbackEnded = true;
826
+
717
827
  log_error(
718
828
  `onVideoError: live-image, fail to play, switching back to image. error: ${
719
829
  error?.message
@@ -724,6 +834,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
724
834
  };
725
835
 
726
836
  onVideoEnd = (_event) => {
837
+ this.playbackEnded = true;
838
+
727
839
  log_info(
728
840
  `onVideoEnd: live-image, video completed, switching back to image, ${playerInfo(
729
841
  this.player
@@ -734,6 +846,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
734
846
  };
735
847
 
736
848
  onPlayerClose = () => {
849
+ this.playbackEnded = true;
850
+
737
851
  log_info(
738
852
  `onPlayerClose: live-image, player closed, switching back to image, ${playerInfo(
739
853
  this.player
@@ -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
+ });
@@ -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.122",
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.122",
32
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.122",
33
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.122",
34
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.122",
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",