@applicaster/quick-brick-player 16.0.0-rc.56 → 16.0.0-rc.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/quick-brick-player",
3
- "version": "16.0.0-rc.56",
3
+ "version": "16.0.0-rc.58",
4
4
  "description": "Quick Brick Player",
5
5
  "main": "./src/index.ts",
6
6
  "types": "index.d.ts",
@@ -37,9 +37,9 @@
37
37
  "dependencies": {
38
38
  "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.52",
39
39
  "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.52",
40
- "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.56",
41
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.56",
42
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.56",
40
+ "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.58",
41
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.58",
42
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.58",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -12,24 +12,18 @@ import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
12
12
  import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
13
13
  import { PlayNextData } from "@applicaster/zapp-react-native-ui-components/Components/PlayerContainer/PlayerContainer";
14
14
  import { formatSpeed } from "./utils";
15
- import {
16
- getBottomSheetModalDefaultItemIcon,
17
- getBottomSheetModalIconProps,
18
- getBottomSheetModalItemProps,
19
- getBottomSheetModalLabelProps,
20
- getBottomSheetModalSelectedItemIcon,
21
- } from "../Utils";
22
15
  import { useTitleSummaryOverlay } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerTitleSummaryOverlay";
23
-
24
16
  import { toNumberWithDefault } from "@applicaster/zapp-react-native-utils/numberUtils";
25
-
26
17
  import { DEFAULT_SPEED, PlaybackSpeed, SpeedController } from "./SpeedManager";
27
18
  import {
28
19
  dismissModal as dismissBottomSheetModal,
29
- openBottomSheetModal,
30
20
  useModalStoreState,
31
21
  } from "@applicaster/zapp-react-native-utils/modalState";
32
- import { speedOptions } from "../Const/speedOptions";
22
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
23
+ import {
24
+ PLAYER_ACTION_IDENTIFIERS,
25
+ registerPlayerActions,
26
+ } from "./playerActions";
33
27
 
34
28
  const { log_debug } = createLogger({
35
29
  subsystem: "UIComponent",
@@ -103,6 +97,14 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
103
97
  setSpeedManager(player ? new SpeedController(player) : null);
104
98
  }, [player]);
105
99
 
100
+ useEffect(() => {
101
+ if (!speedManager || playerState.isLive) {
102
+ return;
103
+ }
104
+
105
+ return registerPlayerActions({ speedController: speedManager });
106
+ }, [speedManager, playerState.isLive]);
107
+
106
108
  useEffect(() => {
107
109
  if (!player) return;
108
110
 
@@ -201,36 +203,11 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
201
203
  player?.forward(seekDuration);
202
204
  }, [seekDuration, player]);
203
205
 
204
- const onSpeedSelect = React.useCallback(
205
- async (item) => {
206
- await speedManager?.setPlaybackSpeed(item);
207
- },
208
- [speedManager]
209
- );
210
-
211
206
  const onSpeedButtonPress = React.useCallback(() => {
212
- openBottomSheetModal({
213
- modalBottomSheetContentProps: {
214
- items: speedOptions,
215
- current_selection: playbackSpeed,
216
- onPress: onSpeedSelect,
217
- title: configuration.playback_speed_title,
218
- itemProps: getBottomSheetModalItemProps,
219
- iconProps: getBottomSheetModalIconProps,
220
- labelProps: getBottomSheetModalLabelProps,
221
- getSelectedItemIcon: getBottomSheetModalSelectedItemIcon,
222
- getDefaultItemIcon: getBottomSheetModalDefaultItemIcon,
223
- iconPlacement: "right",
224
- },
225
- options: {
226
- supportedOrientations: [
227
- "portrait",
228
- "landscape",
229
- "portrait-upside-down",
230
- ],
231
- },
232
- });
233
- }, [playbackSpeed, onSpeedSelect, configuration.playback_speed_title]);
207
+ uiActionsRegistry
208
+ .getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
209
+ ?.invokeAction(entry);
210
+ }, [entry]);
234
211
 
235
212
  const onPlayerSeek = React.useCallback(
236
213
  (targetTime: number) => {
@@ -2,10 +2,7 @@ import * as React from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
 
4
4
  import { dayjs } from "@applicaster/zapp-react-native-utils/dateUtils";
5
- import {
6
- useIsTablet,
7
- useZStore,
8
- } from "@applicaster/zapp-react-native-utils/reactHooks";
5
+ import { useIsTablet } from "@applicaster/zapp-react-native-utils/reactHooks";
9
6
  import {
10
7
  Controls as MobileTransportControls,
11
8
  ProgressBar,
@@ -100,11 +97,8 @@ export const Controls = (props: Props) => {
100
97
  maxWidth = 320,
101
98
  } = props;
102
99
 
103
- const configuration = useZStore("playerConfiguration");
104
-
105
100
  const { sleepTimerState, onSleepButtonPress } = useSleepTimerModal(
106
- playerManager.getActivePlayer(),
107
- configuration
101
+ playerManager.getActivePlayer()
108
102
  );
109
103
 
110
104
  const timerRemaining =
@@ -41,6 +41,10 @@ export class SleepTimerController {
41
41
  return this.sleepTimerStateSubject.asObservable();
42
42
  }
43
43
 
44
+ get currentState(): SleepTimerState {
45
+ return this.sleepTimerStateSubject.getValue();
46
+ }
47
+
44
48
  private async scheduleSleep(sleepDate): Promise<boolean> {
45
49
  log_debug(`Setting sleep interval to ${sleepDate}`);
46
50
 
@@ -118,7 +122,7 @@ export class SleepTimerController {
118
122
 
119
123
  log_debug("Sleep state restored");
120
124
 
121
- this.setSleepTimer(item as SleepTimer, futureSleepDate);
125
+ await this.setSleepTimer(item as SleepTimer, futureSleepDate);
122
126
  }
123
127
 
124
128
  // Will be called if user closed the player explicitly.
@@ -33,6 +33,10 @@ export class SpeedController {
33
33
  return this.playbackSpeedSubject.asObservable();
34
34
  }
35
35
 
36
+ get currentSpeed(): PlaybackSpeed {
37
+ return this.playbackSpeedSubject.getValue();
38
+ }
39
+
36
40
  async loadSpeed() {
37
41
  const speed = await localStorage.getItem(SPEED_PREFERENCE_KEY, NAMESPACE);
38
42
 
@@ -0,0 +1,78 @@
1
+ import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
2
+ import { SLEEP_TIMER_OFF, SleepTimerController } from "../SleepTimerManager";
3
+
4
+ jest.mock(
5
+ "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage",
6
+ () => ({
7
+ localStorage: {
8
+ getItem: jest.fn(),
9
+ setItem: jest.fn(),
10
+ removeItem: jest.fn(),
11
+ },
12
+ })
13
+ );
14
+
15
+ describe("SleepTimerController", () => {
16
+ const player = {
17
+ startSleepTimer: jest.fn(),
18
+ cancelSleepTimer: jest.fn(),
19
+ };
20
+
21
+ let sleepController: SleepTimerController;
22
+
23
+ beforeEach(() => {
24
+ jest.clearAllMocks();
25
+ (localStorage.setItem as jest.Mock).mockResolvedValue(undefined);
26
+ (localStorage.removeItem as jest.Mock).mockResolvedValue(undefined);
27
+ (localStorage.getItem as jest.Mock).mockResolvedValue(undefined);
28
+ sleepController = new SleepTimerController(player as any);
29
+ });
30
+
31
+ it("starts from the off state", () => {
32
+ expect(sleepController.currentState.timer).toEqual(SLEEP_TIMER_OFF);
33
+ });
34
+
35
+ it("starts a sleep timer on the constructed player", async () => {
36
+ await sleepController.setSleepTimer({
37
+ label: "15 minutes",
38
+ value: 15,
39
+ });
40
+
41
+ expect(player.cancelSleepTimer).toHaveBeenCalled();
42
+ expect(player.startSleepTimer).toHaveBeenCalled();
43
+ expect(sleepController.currentState.timer.value).toBe(15);
44
+ });
45
+
46
+ it("loadSleep restores an unexpired timer", async () => {
47
+ const futureSleepDate = Date.now() + 60 * 1000;
48
+
49
+ (localStorage.getItem as jest.Mock).mockResolvedValue({
50
+ label: "15 minutes",
51
+ value: 15,
52
+ futureSleepDate,
53
+ });
54
+
55
+ player.startSleepTimer.mockClear();
56
+
57
+ await sleepController.loadSleep();
58
+
59
+ expect(player.startSleepTimer).toHaveBeenCalledWith(futureSleepDate);
60
+ expect(sleepController.currentState.timer.value).toBe(15);
61
+ });
62
+
63
+ it("loadSleep drops an expired timer without starting a native timer", async () => {
64
+ (localStorage.getItem as jest.Mock).mockResolvedValue({
65
+ label: "15 minutes",
66
+ value: 15,
67
+ futureSleepDate: Date.now() - 1000,
68
+ });
69
+
70
+ player.startSleepTimer.mockClear();
71
+
72
+ await sleepController.loadSleep();
73
+
74
+ expect(player.startSleepTimer).not.toHaveBeenCalled();
75
+ expect(localStorage.removeItem).toHaveBeenCalled();
76
+ expect(sleepController.currentState.timer).toEqual(SLEEP_TIMER_OFF);
77
+ });
78
+ });
@@ -0,0 +1,57 @@
1
+ import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
2
+ import { DEFAULT_SPEED, SpeedController } from "../SpeedManager";
3
+
4
+ jest.mock(
5
+ "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage",
6
+ () => ({
7
+ localStorage: {
8
+ getItem: jest.fn(),
9
+ setItem: jest.fn(),
10
+ removeItem: jest.fn(),
11
+ },
12
+ })
13
+ );
14
+
15
+ describe("SpeedController", () => {
16
+ const player = { setPlaybackRate: jest.fn() };
17
+ let speedController: SpeedController;
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ (localStorage.setItem as jest.Mock).mockResolvedValue(undefined);
22
+ (localStorage.getItem as jest.Mock).mockResolvedValue(undefined);
23
+ speedController = new SpeedController(player as any);
24
+ });
25
+
26
+ it("requires a player in the constructor", () => {
27
+ expect(speedController.currentSpeed).toEqual(DEFAULT_SPEED);
28
+ });
29
+
30
+ it("applies playback speed on the constructed player", async () => {
31
+ await speedController.setPlaybackSpeed({
32
+ asset: "speed_1_5",
33
+ value: 1.5,
34
+ });
35
+
36
+ expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
37
+
38
+ expect(speedController.currentSpeed).toEqual({
39
+ asset: "speed_1_5",
40
+ value: 1.5,
41
+ });
42
+ });
43
+
44
+ it("loadSpeed restores a stored rate onto the player", async () => {
45
+ (localStorage.getItem as jest.Mock).mockResolvedValue({
46
+ asset: "speed_1_5",
47
+ value: 1.5,
48
+ });
49
+
50
+ player.setPlaybackRate.mockClear();
51
+
52
+ await speedController.loadSpeed();
53
+
54
+ expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
55
+ expect(speedController.currentSpeed.value).toBe(1.5);
56
+ });
57
+ });
@@ -0,0 +1,351 @@
1
+ import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils";
2
+ import {
3
+ actionExecutor,
4
+ ActionResult,
5
+ } from "@applicaster/zapp-react-native-utils/actionsExecutor/ActionExecutor";
6
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
7
+ import { modalOrchestrator } from "@applicaster/zapp-react-native-utils/modalState/ModalOrchestrator";
8
+ import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
9
+ import { firstValueFrom } from "rxjs";
10
+
11
+ import {
12
+ PLAYER_ACTION_IDENTIFIERS,
13
+ PLAYER_ACTION_TYPES,
14
+ registerPlayerActions,
15
+ } from "../playerActions";
16
+ import { SpeedController } from "../SpeedManager";
17
+ import { SleepTimerController } from "../SleepTimerManager";
18
+ import { SleepTimerModalHeader } from "../components/SleepTimerModalHeader";
19
+
20
+ jest.mock("../components/SleepTimerModalHeader", () => ({
21
+ SleepTimerModalHeader: function SleepTimerModalHeader() {
22
+ return null;
23
+ },
24
+ }));
25
+
26
+ jest.mock("../../Const/sleepTimerOptions", () => ({
27
+ getSleepTimerOptions: (offLabel: string, sleepTimerValue: number | null) => [
28
+ sleepTimerValue ? { label: offLabel, value: 0 } : null,
29
+ { label: "5 minutes", value: 5 },
30
+ { label: "15 minutes", value: 15 },
31
+ ],
32
+ }));
33
+
34
+ jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
35
+ playerManager: {
36
+ getActivePlayer: jest.fn(),
37
+ },
38
+ }));
39
+
40
+ jest.mock(
41
+ "@applicaster/zapp-react-native-utils/modalState/ModalOrchestrator",
42
+ () => ({
43
+ modalOrchestrator: {
44
+ close: jest.fn(),
45
+ stackSize: 0,
46
+ },
47
+ })
48
+ );
49
+
50
+ jest.mock(
51
+ "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage",
52
+ () => ({
53
+ localStorage: {
54
+ getItem: jest.fn(),
55
+ setItem: jest.fn(),
56
+ removeItem: jest.fn(),
57
+ },
58
+ })
59
+ );
60
+
61
+ describe("playerActions", () => {
62
+ const openedSheets: ActionType[] = [];
63
+
64
+ let player: {
65
+ getConfig: () => Record<string, string>;
66
+ setPlaybackRate: jest.Mock;
67
+ startSleepTimer: jest.Mock;
68
+ cancelSleepTimer: jest.Mock;
69
+ };
70
+
71
+ let speedController: SpeedController;
72
+ let sleepController: SleepTimerController;
73
+ let unregisterPlayerActions: () => void;
74
+
75
+ beforeEach(() => {
76
+ openedSheets.length = 0;
77
+ jest.clearAllMocks();
78
+ (localStorage.setItem as jest.Mock).mockResolvedValue(undefined);
79
+ (localStorage.removeItem as jest.Mock).mockResolvedValue(undefined);
80
+
81
+ player = {
82
+ getConfig: () => ({
83
+ playback_speed_title: "Playback Speed",
84
+ playback_sleep_title: "Sleep Timer",
85
+ playback_sleep_off_label: "Off",
86
+ sleep_timer: "sleep_timer",
87
+ sleep_timer_active: "sleep_timer_active",
88
+ }),
89
+ setPlaybackRate: jest.fn(),
90
+ startSleepTimer: jest.fn(),
91
+ cancelSleepTimer: jest.fn(),
92
+ };
93
+
94
+ (playerManager.getActivePlayer as jest.Mock).mockReturnValue(player);
95
+ speedController = new SpeedController(player as any);
96
+ sleepController = new SleepTimerController(player as any);
97
+
98
+ [
99
+ "openBottomSheet",
100
+ PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
101
+ PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
102
+ PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
103
+ PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
104
+ ].forEach((type) => actionExecutor.unregisterAction(type));
105
+
106
+ actionExecutor.registerAction("openBottomSheet", async (action) => {
107
+ openedSheets.push(action);
108
+
109
+ return ActionResult.Success;
110
+ });
111
+
112
+ unregisterPlayerActions = registerPlayerActions({
113
+ speedController,
114
+ sleepController,
115
+ });
116
+ });
117
+
118
+ afterEach(() => {
119
+ unregisterPlayerActions?.();
120
+ });
121
+
122
+ it("does not register actions until a player controller is provided", async () => {
123
+ unregisterPlayerActions();
124
+ unregisterPlayerActions = registerPlayerActions({});
125
+
126
+ const result = await actionExecutor.handleAction({
127
+ type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
128
+ options: { value: 1.5, dismissSheet: true },
129
+ });
130
+
131
+ expect(result).toBeUndefined();
132
+ expect(player.setPlaybackRate).not.toHaveBeenCalled();
133
+
134
+ expect(
135
+ uiActionsRegistry.getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
136
+ ).toBeUndefined();
137
+
138
+ expect(
139
+ uiActionsRegistry.getAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
140
+ ).toBeUndefined();
141
+ });
142
+
143
+ it("unregisters player actions when the player goes away", async () => {
144
+ unregisterPlayerActions();
145
+
146
+ expect(
147
+ uiActionsRegistry.getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
148
+ ).toBeUndefined();
149
+
150
+ const result = await actionExecutor.handleAction({
151
+ type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
152
+ options: { value: 1.5 },
153
+ });
154
+
155
+ expect(result).toBeUndefined();
156
+ expect(player.setPlaybackRate).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it("opens playback speed through openBottomSheet with the current speed selected", async () => {
160
+ await speedController.setPlaybackSpeed({
161
+ asset: "speed_1",
162
+ value: 1,
163
+ });
164
+
165
+ const result = await actionExecutor.handleAction({
166
+ type: PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
167
+ });
168
+
169
+ expect(result).toBe(ActionResult.Success);
170
+
171
+ const sheet = openedSheets[0];
172
+
173
+ expect(sheet.options.replace).toBe(true);
174
+ expect(sheet.options.header.title).toBe("Playback Speed");
175
+ expect(sheet.options.content.role).toBe("collection_selector");
176
+ expect(sheet.options.content.behavior.currentSelection).toEqual(["1"]);
177
+
178
+ expect(sheet.options.content.items.map((item) => item.title)).toEqual([
179
+ "0.8x",
180
+ "1x",
181
+ "1.2x",
182
+ "1.5x",
183
+ "2x",
184
+ ]);
185
+
186
+ expect(
187
+ sheet.options.content.items[0].extensions.tap_actions.actions[0].options
188
+ .dismissSheet
189
+ ).toBe(true);
190
+ });
191
+
192
+ it("opens sleep timer with the ticking countdown header", async () => {
193
+ const timerDate = Date.now() + 15 * 60 * 1000;
194
+
195
+ await sleepController.setSleepTimer(
196
+ { label: "15 minutes", value: 15 },
197
+ timerDate
198
+ );
199
+
200
+ const result = await actionExecutor.handleAction({
201
+ type: PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
202
+ });
203
+
204
+ expect(result).toBe(ActionResult.Success);
205
+
206
+ const sheet = openedSheets[0];
207
+
208
+ expect(sheet.options.replace).toBe(true);
209
+ expect(sheet.options.header.title).toBe("Sleep Timer");
210
+ expect(sheet.options.header.component).toBe(SleepTimerModalHeader);
211
+ expect(sheet.options.header.componentProps.timerDate).toBe(timerDate);
212
+ expect(sheet.options.header.subtitle).toBeUndefined();
213
+ expect(sheet.options.content.behavior.currentSelection).toEqual(["15"]);
214
+
215
+ expect(
216
+ sheet.options.content.items[0].extensions.tap_actions.actions[0].type
217
+ ).toBe(PLAYER_ACTION_TYPES.SET_SLEEP_TIMER);
218
+ });
219
+
220
+ it("setPlaybackSpeed applies the rate and closes the sheet when opened from the picker", async () => {
221
+ const result = await actionExecutor.handleAction({
222
+ type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
223
+ options: { value: 1.5, dismissSheet: true },
224
+ });
225
+
226
+ expect(result).toBe(ActionResult.Success);
227
+ expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
228
+ expect(localStorage.setItem).toHaveBeenCalled();
229
+ expect(modalOrchestrator.close).toHaveBeenCalled();
230
+ });
231
+
232
+ it("setSleepTimer starts the timer, writes storage, and closes the sheet", async () => {
233
+ const result = await actionExecutor.handleAction({
234
+ type: PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
235
+ options: { value: 15, label: "15 minutes", dismissSheet: true },
236
+ });
237
+
238
+ expect(result).toBe(ActionResult.Success);
239
+ expect(player.startSleepTimer).toHaveBeenCalled();
240
+ expect(localStorage.setItem).toHaveBeenCalled();
241
+ expect(modalOrchestrator.close).toHaveBeenCalled();
242
+ expect(sleepController.currentState.timer.value).toBe(15);
243
+ });
244
+
245
+ it("setSleepTimer with value 0 cancels the timer", async () => {
246
+ await sleepController.setSleepTimer({
247
+ label: "15 minutes",
248
+ value: 15,
249
+ });
250
+
251
+ player.cancelSleepTimer.mockClear();
252
+ (localStorage.removeItem as jest.Mock).mockClear();
253
+ (modalOrchestrator.close as jest.Mock).mockClear();
254
+
255
+ const result = await actionExecutor.handleAction({
256
+ type: PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
257
+ options: { value: 0, label: "Off", dismissSheet: true },
258
+ });
259
+
260
+ expect(result).toBe(ActionResult.Success);
261
+ expect(player.cancelSleepTimer).toHaveBeenCalled();
262
+ expect(localStorage.removeItem).toHaveBeenCalled();
263
+ expect(sleepController.currentState.timer.value).toBe(0);
264
+ expect(modalOrchestrator.close).toHaveBeenCalled();
265
+ });
266
+
267
+ it.each([undefined, "foo"])(
268
+ "rejects invalid setPlaybackSpeed value %p",
269
+ async (value) => {
270
+ player.setPlaybackRate.mockClear();
271
+ (localStorage.setItem as jest.Mock).mockClear();
272
+
273
+ const result = await actionExecutor.handleAction({
274
+ type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
275
+ options: { value, dismissSheet: true },
276
+ });
277
+
278
+ expect(result).toBe(ActionResult.Error);
279
+ expect(player.setPlaybackRate).not.toHaveBeenCalled();
280
+ expect(localStorage.setItem).not.toHaveBeenCalled();
281
+ expect(modalOrchestrator.close).not.toHaveBeenCalled();
282
+ }
283
+ );
284
+
285
+ it.each([undefined, "foo"])(
286
+ "rejects invalid setSleepTimer value %p",
287
+ async (value) => {
288
+ const result = await actionExecutor.handleAction({
289
+ type: PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
290
+ options: { value, dismissSheet: true },
291
+ });
292
+
293
+ expect(result).toBe(ActionResult.Error);
294
+ expect(player.startSleepTimer).not.toHaveBeenCalled();
295
+ expect(localStorage.setItem).not.toHaveBeenCalled();
296
+ expect(modalOrchestrator.close).not.toHaveBeenCalled();
297
+ }
298
+ );
299
+
300
+ it("does not close an unrelated sheet when setPlaybackSpeed is invoked standalone", async () => {
301
+ const result = await actionExecutor.handleAction({
302
+ type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
303
+ options: { value: 1.5 },
304
+ });
305
+
306
+ expect(result).toBe(ActionResult.Success);
307
+ expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
308
+ expect(modalOrchestrator.close).not.toHaveBeenCalled();
309
+ });
310
+
311
+ it("initialEntryState for sleep-timer reflects an active timer", async () => {
312
+ await sleepController.setSleepTimer({
313
+ label: "15 minutes",
314
+ value: 15,
315
+ });
316
+
317
+ const action = uiActionsRegistry.getAction(
318
+ PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER
319
+ );
320
+
321
+ expect(action.initialEntryState({} as ZappEntry)).toMatchObject({
322
+ state: 1,
323
+ asset: "sleep_timer_active",
324
+ label: "Sleep Timer",
325
+ });
326
+
327
+ const observed = await firstValueFrom(
328
+ action.observeEntryState({} as ZappEntry)
329
+ );
330
+
331
+ expect(observed).toMatchObject({
332
+ state: 1,
333
+ asset: "sleep_timer_active",
334
+ });
335
+ });
336
+
337
+ it("registers UI action identifiers that open the matching sheets", async () => {
338
+ await uiActionsRegistry
339
+ .getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
340
+ .invokeAction({} as ZappEntry);
341
+
342
+ await uiActionsRegistry
343
+ .getAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
344
+ .invokeAction({} as ZappEntry);
345
+
346
+ expect(openedSheets.map((action) => action.options.header.title)).toEqual([
347
+ "Playback Speed",
348
+ "Sleep Timer",
349
+ ]);
350
+ });
351
+ });
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
  import { ModalHeader } from "@applicaster/zapp-react-native-ui-components/Components/ModalComponent/Header";
3
- import { isNil } from "@applicaster/zapp-react-native-utils/utils";
3
+
4
+ type SleepTimerModalHeaderProps = React.ComponentProps<typeof ModalHeader> & {
5
+ timerDate?: number | null;
6
+ };
4
7
 
5
8
  const formatTime = (milliseconds: number) => {
6
9
  const totalSeconds = Math.floor(milliseconds / 1000);
@@ -20,12 +23,10 @@ const formatTime = (milliseconds: number) => {
20
23
  };
21
24
 
22
25
  export const SleepTimerModalHeader = ({
23
- summary,
26
+ timerDate,
24
27
  ...props
25
- }: {
26
- summary?: string;
27
- }) => {
28
- const sleepTimerDate = +summary;
28
+ }: SleepTimerModalHeaderProps) => {
29
+ const sleepTimerDate = timerDate || 0;
29
30
 
30
31
  const [timeLeft, setTimeLeft] = React.useState(
31
32
  sleepTimerDate !== 0 ? sleepTimerDate - Date.now() : 0
@@ -59,8 +60,8 @@ export const SleepTimerModalHeader = ({
59
60
 
60
61
  return (
61
62
  <ModalHeader
62
- summary={!isNil(summary) ? formatTime(timeLeft) : summary}
63
- {...(props as any)}
63
+ {...props}
64
+ summary={showCounter && timeLeft > 0 ? formatTime(timeLeft) : undefined}
64
65
  />
65
66
  );
66
67
  };
@@ -0,0 +1,53 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react-native";
3
+ import { ModalHeader } from "@applicaster/zapp-react-native-ui-components/Components/ModalComponent/Header";
4
+ import { SleepTimerModalHeader } from "../SleepTimerModalHeader";
5
+
6
+ jest.mock(
7
+ "@applicaster/zapp-react-native-ui-components/Components/ModalComponent/Header",
8
+ () => ({
9
+ ModalHeader: jest.fn(() => null),
10
+ })
11
+ );
12
+
13
+ describe("SleepTimerModalHeader", () => {
14
+ const headerProps = {
15
+ dismiss: jest.fn(),
16
+ configuration: {} as React.ComponentProps<
17
+ typeof ModalHeader
18
+ >["configuration"],
19
+ title: "Sleep Timer",
20
+ summary: undefined as string | undefined,
21
+ };
22
+
23
+ beforeEach(() => {
24
+ jest.clearAllMocks();
25
+ });
26
+
27
+ it("passes a MM:SS summary when timerDate is in the future", () => {
28
+ const timerDate = Date.now() + 90 * 1000;
29
+
30
+ render(<SleepTimerModalHeader timerDate={timerDate} {...headerProps} />);
31
+
32
+ expect(ModalHeader).toHaveBeenCalledWith(
33
+ expect.objectContaining({
34
+ title: "Sleep Timer",
35
+ summary: expect.stringMatching(/^\d{2}:\d{2}$/),
36
+ }),
37
+ expect.anything()
38
+ );
39
+ });
40
+
41
+ it("does not show a 00:00 summary when timerDate is in the past", () => {
42
+ render(
43
+ <SleepTimerModalHeader timerDate={Date.now() - 1000} {...headerProps} />
44
+ );
45
+
46
+ expect(ModalHeader).toHaveBeenCalledWith(
47
+ expect.objectContaining({
48
+ summary: undefined,
49
+ }),
50
+ expect.anything()
51
+ );
52
+ });
53
+ });
@@ -0,0 +1,280 @@
1
+ import { map } from "rxjs";
2
+ import {
3
+ actionExecutor,
4
+ ActionResult,
5
+ } from "@applicaster/zapp-react-native-utils/actionsExecutor/ActionExecutor";
6
+ import { ActionHandler } from "@applicaster/zapp-react-native-utils/actionsExecutor/types";
7
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
8
+ import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils";
9
+ import { modalOrchestrator } from "@applicaster/zapp-react-native-utils/modalState/ModalOrchestrator";
10
+ import { toNumber } from "@applicaster/zapp-react-native-utils/numberUtils";
11
+
12
+ import { speedOptions } from "../Const/speedOptions";
13
+ import { getSleepTimerOptions } from "../Const/sleepTimerOptions";
14
+ import { formatSpeed } from "./utils";
15
+ import { SpeedController } from "./SpeedManager";
16
+ import { SleepTimerController, SleepTimerState } from "./SleepTimerManager";
17
+ import { SleepTimerModalHeader } from "./components/SleepTimerModalHeader";
18
+
19
+ export const PLAYER_ACTION_IDENTIFIERS = {
20
+ PLAYBACK_SPEED: "playback-speed",
21
+ SLEEP_TIMER: "sleep-timer",
22
+ } as const;
23
+
24
+ export const PLAYER_ACTION_TYPES = {
25
+ OPEN_PLAYBACK_SPEED: "openPlaybackSpeed",
26
+ OPEN_SLEEP_TIMER: "openSleepTimer",
27
+ SET_PLAYBACK_SPEED: "setPlaybackSpeed",
28
+ SET_SLEEP_TIMER: "setSleepTimer",
29
+ } as const;
30
+
31
+ function getPlayerConfig(): Record<string, any> {
32
+ return playerManager.getActivePlayer()?.getConfig?.() || {};
33
+ }
34
+
35
+ function remainingTimerDate(timerDate: number): number | null {
36
+ return timerDate !== 0 && +timerDate > Date.now() ? timerDate : null;
37
+ }
38
+
39
+ function dismissOpenedSheet(action: ActionType) {
40
+ if (action.options?.dismissSheet) {
41
+ modalOrchestrator.close();
42
+ }
43
+ }
44
+
45
+ function optionToEntry(
46
+ id: string,
47
+ title: string,
48
+ actionType: string,
49
+ options: Record<string, any>
50
+ ): ZappEntry {
51
+ return {
52
+ id,
53
+ title,
54
+ extensions: {
55
+ tap_actions: {
56
+ actions: [
57
+ { type: actionType, options: { ...options, dismissSheet: true } },
58
+ ],
59
+ },
60
+ },
61
+ } as ZappEntry;
62
+ }
63
+
64
+ function sleepTimerEntryState(
65
+ sleepState: SleepTimerState
66
+ ): CellActionEntryState {
67
+ const config = getPlayerConfig();
68
+ const isActive = Boolean(remainingTimerDate(sleepState.timerDate));
69
+
70
+ return {
71
+ state: isActive ? 1 : 0,
72
+ label: config.playback_sleep_title || "Sleep Timer",
73
+ asset: isActive
74
+ ? config.sleep_timer_active || config.sleep_timer || ""
75
+ : config.sleep_timer || "",
76
+ };
77
+ }
78
+
79
+ export async function openPlaybackSpeedAction(
80
+ speedController: SpeedController
81
+ ): Promise<ActionResult> {
82
+ const config = getPlayerConfig();
83
+ const current = speedController.currentSpeed;
84
+
85
+ const items = speedOptions.map((option) =>
86
+ optionToEntry(
87
+ String(option.value),
88
+ option.label,
89
+ PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
90
+ { value: option.value }
91
+ )
92
+ );
93
+
94
+ return actionExecutor.handleAction({
95
+ type: "openBottomSheet",
96
+ options: {
97
+ replace: true,
98
+ header: {
99
+ title: config.playback_speed_title || "Playback Speed",
100
+ },
101
+ content: {
102
+ role: "collection_selector",
103
+ behavior: {
104
+ selectMode: "single",
105
+ currentSelection: [String(current.value)],
106
+ },
107
+ items,
108
+ },
109
+ },
110
+ } as ActionType);
111
+ }
112
+
113
+ export async function openSleepTimerAction(
114
+ sleepController: SleepTimerController
115
+ ): Promise<ActionResult> {
116
+ const config = getPlayerConfig();
117
+ const sleepState = sleepController.currentState;
118
+ const timerRemaining = remainingTimerDate(sleepState.timerDate);
119
+
120
+ const items = getSleepTimerOptions(
121
+ config.playback_sleep_off_label || "Off",
122
+ timerRemaining
123
+ )
124
+ .filter(Boolean)
125
+ .map((option) =>
126
+ optionToEntry(
127
+ String(option.value),
128
+ option.label,
129
+ PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
130
+ { value: option.value, label: option.label }
131
+ )
132
+ );
133
+
134
+ return actionExecutor.handleAction({
135
+ type: "openBottomSheet",
136
+ options: {
137
+ replace: true,
138
+ header: {
139
+ title: config.playback_sleep_title || "Sleep Timer",
140
+ component: SleepTimerModalHeader,
141
+ componentProps: { timerDate: timerRemaining },
142
+ },
143
+ content: {
144
+ role: "collection_selector",
145
+ behavior: {
146
+ selectMode: "single",
147
+ currentSelection: timerRemaining
148
+ ? [String(sleepState.timer.value)]
149
+ : [],
150
+ },
151
+ items,
152
+ },
153
+ },
154
+ } as ActionType);
155
+ }
156
+
157
+ export async function setPlaybackSpeedAction(
158
+ speedController: SpeedController,
159
+ action: ActionType
160
+ ): Promise<ActionResult> {
161
+ const value = toNumber(action.options?.value);
162
+
163
+ if (!Number.isFinite(value)) {
164
+ return ActionResult.Error;
165
+ }
166
+
167
+ await speedController.setPlaybackSpeed({
168
+ asset: formatSpeed(value),
169
+ value,
170
+ });
171
+
172
+ dismissOpenedSheet(action);
173
+
174
+ return ActionResult.Success;
175
+ }
176
+
177
+ export async function setSleepTimerAction(
178
+ sleepController: SleepTimerController,
179
+ action: ActionType
180
+ ): Promise<ActionResult> {
181
+ const value = toNumber(action.options?.value);
182
+
183
+ if (!Number.isFinite(value)) {
184
+ return ActionResult.Error;
185
+ }
186
+
187
+ await sleepController.setSleepTimer({
188
+ label: action.options?.label || String(value),
189
+ value,
190
+ });
191
+
192
+ dismissOpenedSheet(action);
193
+
194
+ return ActionResult.Success;
195
+ }
196
+
197
+ export function registerPlayerActions(options: {
198
+ speedController?: SpeedController;
199
+ sleepController?: SleepTimerController;
200
+ }): () => void {
201
+ const unregisters: Array<() => void> = [];
202
+
203
+ if (options.speedController) {
204
+ const speedController = options.speedController;
205
+
206
+ const speedHandlers: Array<[string, ActionHandler]> = [
207
+ [
208
+ PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
209
+ () => openPlaybackSpeedAction(speedController),
210
+ ],
211
+ [
212
+ PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
213
+ (action) => setPlaybackSpeedAction(speedController, action),
214
+ ],
215
+ ];
216
+
217
+ speedHandlers.forEach(([type, handler]) => {
218
+ actionExecutor.unregisterAction(type);
219
+ unregisters.push(actionExecutor.registerAction(type, handler));
220
+ });
221
+
222
+ unregisters.push(
223
+ uiActionsRegistry.registerAction(
224
+ PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
225
+ {
226
+ initialEntryState: () => ({
227
+ state: 0,
228
+ label: getPlayerConfig().playback_speed_title || "Playback Speed",
229
+ asset: getPlayerConfig().playback_speed || "",
230
+ }),
231
+ invokeAction: async () => {
232
+ await actionExecutor.handleAction({
233
+ type: PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
234
+ });
235
+ },
236
+ }
237
+ )
238
+ );
239
+ }
240
+
241
+ if (options.sleepController) {
242
+ const sleepController = options.sleepController;
243
+
244
+ const sleepHandlers: Array<[string, ActionHandler]> = [
245
+ [
246
+ PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
247
+ () => openSleepTimerAction(sleepController),
248
+ ],
249
+ [
250
+ PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
251
+ (action) => setSleepTimerAction(sleepController, action),
252
+ ],
253
+ ];
254
+
255
+ sleepHandlers.forEach(([type, handler]) => {
256
+ actionExecutor.unregisterAction(type);
257
+ unregisters.push(actionExecutor.registerAction(type, handler));
258
+ });
259
+
260
+ unregisters.push(
261
+ uiActionsRegistry.registerAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER, {
262
+ initialEntryState: () =>
263
+ sleepTimerEntryState(sleepController.currentState),
264
+ observeEntryState: () =>
265
+ sleepController.sleepTimerState$.pipe(
266
+ map((sleepState) => sleepTimerEntryState(sleepState))
267
+ ),
268
+ invokeAction: async () => {
269
+ await actionExecutor.handleAction({
270
+ type: PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
271
+ });
272
+ },
273
+ })
274
+ );
275
+ }
276
+
277
+ return () => {
278
+ unregisters.forEach((unregister) => unregister());
279
+ };
280
+ }
@@ -1,30 +1,19 @@
1
1
  import { usePlayerState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState";
2
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
2
3
  import { useCallback, useEffect, useMemo, useState } from "react";
3
4
  import {
4
5
  SLEEP_TIMER_OFF,
5
6
  SleepTimerController,
6
7
  SleepTimerState,
7
8
  } from "./SleepTimerManager";
8
-
9
- import { openBottomSheetModal } from "@applicaster/zapp-react-native-utils/modalState";
10
-
11
- import { SleepTimerModalHeader } from "./components/SleepTimerModalHeader";
12
- import { getSleepTimerOptions } from "../Const/sleepTimerOptions";
13
9
  import {
14
- getBottomSheetModalLabelProps,
15
- getBottomSheetModalIconProps,
16
- getBottomSheetModalItemProps,
17
- getBottomSheetModalSelectedItemIcon,
18
- selectSleepTitle,
19
- selectSleepOffLabel,
20
- } from "../Utils";
10
+ PLAYER_ACTION_IDENTIFIERS,
11
+ registerPlayerActions,
12
+ } from "./playerActions";
21
13
 
22
- export const useSleepTimerModal = (player, configuration) => {
14
+ export const useSleepTimerModal = (player) => {
23
15
  const playerState = usePlayerState("audio-player-sleep-timer-modal");
24
16
 
25
- const title = configuration(selectSleepTitle);
26
- const offLabel = configuration(selectSleepOffLabel);
27
-
28
17
  const [sleepTimerManager, setSleepTimerManager] =
29
18
  useState<SleepTimerController | null>(null);
30
19
 
@@ -33,19 +22,22 @@ export const useSleepTimerModal = (player, configuration) => {
33
22
  );
34
23
 
35
24
  useEffect(() => {
36
- if (player) {
37
- setSleepTimerManager(new SleepTimerController(player));
38
- } else {
39
- setSleepTimerManager(null);
40
- }
25
+ setSleepTimerManager(player ? new SleepTimerController(player) : null);
41
26
  }, [player]);
42
27
 
43
28
  useEffect(() => {
44
29
  if (playerState?.isLive) {
45
- // Resetting a sleep timer when audio content is live (Timer not supported on live video)
46
- sleepTimerManager.setSleepTimer(SLEEP_TIMER_OFF).catch();
30
+ sleepTimerManager?.setSleepTimer(SLEEP_TIMER_OFF).catch(() => undefined);
31
+ }
32
+ }, [playerState?.isLive, sleepTimerManager]);
33
+
34
+ useEffect(() => {
35
+ if (!sleepTimerManager || playerState.isLive) {
36
+ return;
47
37
  }
48
- }, [playerState?.isLive]);
38
+
39
+ return registerPlayerActions({ sleepController: sleepTimerManager });
40
+ }, [sleepTimerManager, playerState.isLive]);
49
41
 
50
42
  useEffect(() => {
51
43
  if (!playerState.isReadyToPlay) {
@@ -64,40 +56,11 @@ export const useSleepTimerModal = (player, configuration) => {
64
56
  };
65
57
  }, [sleepTimerManager]);
66
58
 
67
- const onSleepSelect = useCallback(
68
- async (item) => {
69
- await sleepTimerManager?.setSleepTimer(item);
70
- },
71
- [sleepTimerManager]
72
- );
73
-
74
59
  const onSleepButtonPress = useCallback(() => {
75
- const timerDate = sleepTimerState.timerDate;
76
-
77
- const timerRemaining =
78
- timerDate !== 0 && +timerDate > Date.now() ? timerDate : null;
79
-
80
- openBottomSheetModal({
81
- modalBottomSheetContentProps: {
82
- headerComponent: SleepTimerModalHeader,
83
- items: getSleepTimerOptions(offLabel, timerRemaining),
84
- onPress: onSleepSelect,
85
- title,
86
- summary: timerRemaining,
87
- itemProps: getBottomSheetModalItemProps,
88
- iconProps: getBottomSheetModalIconProps,
89
- labelProps: getBottomSheetModalLabelProps,
90
- getSelectedItemIcon: getBottomSheetModalSelectedItemIcon,
91
- },
92
- options: {
93
- supportedOrientations: [
94
- "portrait",
95
- "landscape",
96
- "portrait-upside-down",
97
- ],
98
- },
99
- });
100
- }, [sleepTimerState.timerDate, sleepTimerState.timer, title, onSleepSelect]);
60
+ uiActionsRegistry
61
+ .getAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
62
+ ?.invokeAction(player?.entry);
63
+ }, [player]);
101
64
 
102
65
  return useMemo(
103
66
  () => ({