@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.
- package/Components/MasterCell/DefaultComponents/ActionButton.tsx +13 -0
- package/Components/MasterCell/DefaultComponents/PressableView.tsx +3 -0
- package/Components/MasterCell/DefaultComponents/__tests__/ActionButton.test.tsx +288 -0
- package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +1 -0
- package/Components/VideoLive/LiveImageManager.ts +126 -12
- package/Components/VideoLive/MuteUnmuteController.ts +135 -0
- package/Components/VideoLive/PlayerLiveImageComponent.tsx +15 -19
- package/Components/VideoLive/__tests__/LiveImageMuteState.test.ts +311 -0
- package/Components/VideoLive/__tests__/MuteUnmuteController.test.ts +135 -0
- package/Components/VideoLive/__tests__/PlayerLiveImageComponent.test.tsx +63 -1
- package/package.json +5 -5
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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.
|
|
664
|
+
this.setMuted(true);
|
|
588
665
|
this.player?.mute();
|
|
589
666
|
};
|
|
590
667
|
|
|
591
668
|
unmute = () => {
|
|
592
|
-
this.
|
|
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.
|
|
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
|
-
}, [
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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.
|
|
32
|
-
"@applicaster/zapp-react-native-bridge": "16.0.0-rc.
|
|
33
|
-
"@applicaster/zapp-react-native-redux": "16.0.0-rc.
|
|
34
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-rc.
|
|
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",
|