@applicaster/quick-brick-player 15.1.10 → 15.1.11-alpha.7557142055
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 +4 -4
- package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +17 -39
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +2 -8
- package/src/Player/AudioLayer/SleepTimerManager.ts +5 -1
- package/src/Player/AudioLayer/SpeedManager.ts +4 -0
- package/src/Player/AudioLayer/__tests__/SleepTimerManager.test.ts +78 -0
- package/src/Player/AudioLayer/__tests__/SpeedManager.test.ts +57 -0
- package/src/Player/AudioLayer/__tests__/playerActions.test.ts +342 -0
- package/src/Player/AudioLayer/playerActions.ts +277 -0
- package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
- package/src/Player/PlayerModal/VideoPlayerModal.tsx +18 -1
- package/src/Player/PlayerModal/__tests__/VideoPlayerModal.test.tsx +160 -0
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/quick-brick-player",
|
|
3
|
-
"version": "15.1.
|
|
3
|
+
"version": "15.1.11-alpha.7557142055",
|
|
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": "15.1.9",
|
|
39
39
|
"@applicaster/quick-brick-tv-transport-controls": "15.1.9",
|
|
40
|
-
"@applicaster/zapp-react-native-tvos-app": "15.1.
|
|
41
|
-
"@applicaster/zapp-react-native-ui-components": "15.1.
|
|
42
|
-
"@applicaster/zapp-react-native-utils": "15.1.
|
|
40
|
+
"@applicaster/zapp-react-native-tvos-app": "15.1.11-alpha.7557142055",
|
|
41
|
+
"@applicaster/zapp-react-native-ui-components": "15.1.11-alpha.7557142055",
|
|
42
|
+
"@applicaster/zapp-react-native-utils": "15.1.11-alpha.7557142055",
|
|
43
43
|
"query-string": "7.1.3",
|
|
44
44
|
"shaka-player": "4.3.5",
|
|
45
45
|
"typeface-montserrat": "^0.0.54",
|
|
@@ -12,23 +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
|
|
|
23
16
|
import { toNumberWithDefault } from "@applicaster/zapp-react-native-utils/numberUtils";
|
|
24
|
-
|
|
25
17
|
import { DEFAULT_SPEED, PlaybackSpeed, SpeedController } from "./SpeedManager";
|
|
26
18
|
import {
|
|
27
19
|
dismissModal as dismissBottomSheetModal,
|
|
28
|
-
openBottomSheetModal,
|
|
29
20
|
useModalStoreState,
|
|
30
21
|
} from "@applicaster/zapp-react-native-utils/modalState";
|
|
31
|
-
import {
|
|
22
|
+
import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
23
|
+
import {
|
|
24
|
+
PLAYER_ACTION_IDENTIFIERS,
|
|
25
|
+
registerPlayerActions,
|
|
26
|
+
} from "./playerActions";
|
|
32
27
|
|
|
33
28
|
const { log_debug } = createLogger({
|
|
34
29
|
subsystem: "UIComponent",
|
|
@@ -118,6 +113,14 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
|
|
|
118
113
|
setSpeedManager(player ? new SpeedController(player) : null);
|
|
119
114
|
}, [player]);
|
|
120
115
|
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!speedManager || playerState.isLive) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return registerPlayerActions({ speedController: speedManager });
|
|
122
|
+
}, [speedManager, playerState.isLive]);
|
|
123
|
+
|
|
121
124
|
useEffect(() => {
|
|
122
125
|
if (!player) return;
|
|
123
126
|
|
|
@@ -216,36 +219,11 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
|
|
|
216
219
|
player?.forward(seekDuration);
|
|
217
220
|
}, [seekDuration, player]);
|
|
218
221
|
|
|
219
|
-
const onSpeedSelect = React.useCallback(
|
|
220
|
-
async (item) => {
|
|
221
|
-
await speedManager?.setPlaybackSpeed(item);
|
|
222
|
-
},
|
|
223
|
-
[speedManager]
|
|
224
|
-
);
|
|
225
|
-
|
|
226
222
|
const onSpeedButtonPress = React.useCallback(() => {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
onPress: onSpeedSelect,
|
|
232
|
-
title: configuration.playback_speed_title,
|
|
233
|
-
itemProps: getBottomSheetModalItemProps,
|
|
234
|
-
iconProps: getBottomSheetModalIconProps,
|
|
235
|
-
labelProps: getBottomSheetModalLabelProps,
|
|
236
|
-
getSelectedItemIcon: getBottomSheetModalSelectedItemIcon,
|
|
237
|
-
getDefaultItemIcon: getBottomSheetModalDefaultItemIcon,
|
|
238
|
-
iconPlacement: "right",
|
|
239
|
-
},
|
|
240
|
-
options: {
|
|
241
|
-
supportedOrientations: [
|
|
242
|
-
"portrait",
|
|
243
|
-
"landscape",
|
|
244
|
-
"portrait-upside-down",
|
|
245
|
-
],
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
}, [playbackSpeed, onSpeedSelect, configuration.playback_speed_title]);
|
|
223
|
+
uiActionsRegistry
|
|
224
|
+
.getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
|
|
225
|
+
?.invokeAction(entry);
|
|
226
|
+
}, [entry]);
|
|
249
227
|
|
|
250
228
|
const onPlayerSeek = React.useCallback(
|
|
251
229
|
(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 { hasNext, hasPrevious, goNext, goPrevious } = usePlaylistNavigation(
|
|
@@ -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,342 @@
|
|
|
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
|
+
|
|
19
|
+
jest.mock("../../Const/sleepTimerOptions", () => ({
|
|
20
|
+
getSleepTimerOptions: (offLabel: string, sleepTimerValue: number | null) => [
|
|
21
|
+
sleepTimerValue ? { label: offLabel, value: 0 } : null,
|
|
22
|
+
{ label: "5 minutes", value: 5 },
|
|
23
|
+
{ label: "15 minutes", value: 15 },
|
|
24
|
+
],
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
|
|
28
|
+
playerManager: {
|
|
29
|
+
getActivePlayer: jest.fn(),
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
jest.mock(
|
|
34
|
+
"@applicaster/zapp-react-native-utils/modalState/ModalOrchestrator",
|
|
35
|
+
() => ({
|
|
36
|
+
modalOrchestrator: {
|
|
37
|
+
close: jest.fn(),
|
|
38
|
+
stackSize: 0,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
jest.mock(
|
|
44
|
+
"@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage",
|
|
45
|
+
() => ({
|
|
46
|
+
localStorage: {
|
|
47
|
+
getItem: jest.fn(),
|
|
48
|
+
setItem: jest.fn(),
|
|
49
|
+
removeItem: jest.fn(),
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
describe("playerActions", () => {
|
|
55
|
+
const openedSheets: ActionType[] = [];
|
|
56
|
+
|
|
57
|
+
let player: {
|
|
58
|
+
getConfig: () => Record<string, string>;
|
|
59
|
+
setPlaybackRate: jest.Mock;
|
|
60
|
+
startSleepTimer: jest.Mock;
|
|
61
|
+
cancelSleepTimer: jest.Mock;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let speedController: SpeedController;
|
|
65
|
+
let sleepController: SleepTimerController;
|
|
66
|
+
let unregisterPlayerActions: () => void;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
openedSheets.length = 0;
|
|
70
|
+
jest.clearAllMocks();
|
|
71
|
+
(localStorage.setItem as jest.Mock).mockResolvedValue(undefined);
|
|
72
|
+
(localStorage.removeItem as jest.Mock).mockResolvedValue(undefined);
|
|
73
|
+
|
|
74
|
+
player = {
|
|
75
|
+
getConfig: () => ({
|
|
76
|
+
playback_speed_title: "Playback Speed",
|
|
77
|
+
playback_sleep_title: "Sleep Timer",
|
|
78
|
+
playback_sleep_off_label: "Off",
|
|
79
|
+
sleep_timer: "sleep_timer",
|
|
80
|
+
sleep_timer_active: "sleep_timer_active",
|
|
81
|
+
}),
|
|
82
|
+
setPlaybackRate: jest.fn(),
|
|
83
|
+
startSleepTimer: jest.fn(),
|
|
84
|
+
cancelSleepTimer: jest.fn(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
(playerManager.getActivePlayer as jest.Mock).mockReturnValue(player);
|
|
88
|
+
speedController = new SpeedController(player as any);
|
|
89
|
+
sleepController = new SleepTimerController(player as any);
|
|
90
|
+
|
|
91
|
+
[
|
|
92
|
+
"openBottomSheet",
|
|
93
|
+
PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
|
|
94
|
+
PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
|
|
95
|
+
PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
96
|
+
PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
|
|
97
|
+
].forEach((type) => actionExecutor.unregisterAction(type));
|
|
98
|
+
|
|
99
|
+
actionExecutor.registerAction("openBottomSheet", async (action) => {
|
|
100
|
+
openedSheets.push(action);
|
|
101
|
+
|
|
102
|
+
return ActionResult.Success;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
unregisterPlayerActions = registerPlayerActions({
|
|
106
|
+
speedController,
|
|
107
|
+
sleepController,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
unregisterPlayerActions?.();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does not register actions until a player controller is provided", async () => {
|
|
116
|
+
unregisterPlayerActions();
|
|
117
|
+
unregisterPlayerActions = registerPlayerActions({});
|
|
118
|
+
|
|
119
|
+
const result = await actionExecutor.handleAction({
|
|
120
|
+
type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
121
|
+
options: { value: 1.5, dismissSheet: true },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result).toBeUndefined();
|
|
125
|
+
expect(player.setPlaybackRate).not.toHaveBeenCalled();
|
|
126
|
+
|
|
127
|
+
expect(
|
|
128
|
+
uiActionsRegistry.getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
|
|
129
|
+
).toBeUndefined();
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
uiActionsRegistry.getAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
|
|
133
|
+
).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("unregisters player actions when the player goes away", async () => {
|
|
137
|
+
unregisterPlayerActions();
|
|
138
|
+
|
|
139
|
+
expect(
|
|
140
|
+
uiActionsRegistry.getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
|
|
141
|
+
).toBeUndefined();
|
|
142
|
+
|
|
143
|
+
const result = await actionExecutor.handleAction({
|
|
144
|
+
type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
145
|
+
options: { value: 1.5 },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(result).toBeUndefined();
|
|
149
|
+
expect(player.setPlaybackRate).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("opens playback speed through openBottomSheet with the current speed selected", async () => {
|
|
153
|
+
await speedController.setPlaybackSpeed({
|
|
154
|
+
asset: "speed_1",
|
|
155
|
+
value: 1,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = await actionExecutor.handleAction({
|
|
159
|
+
type: PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result).toBe(ActionResult.Success);
|
|
163
|
+
|
|
164
|
+
const sheet = openedSheets[0];
|
|
165
|
+
|
|
166
|
+
expect(sheet.options.replace).toBe(true);
|
|
167
|
+
expect(sheet.options.header.title).toBe("Playback Speed");
|
|
168
|
+
expect(sheet.options.content.role).toBe("collection_selector");
|
|
169
|
+
expect(sheet.options.content.behavior.currentSelection).toEqual(["1"]);
|
|
170
|
+
|
|
171
|
+
expect(sheet.options.content.items.map((item) => item.title)).toEqual([
|
|
172
|
+
"0.8x",
|
|
173
|
+
"1x",
|
|
174
|
+
"1.2x",
|
|
175
|
+
"1.5x",
|
|
176
|
+
"2x",
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
sheet.options.content.items[0].extensions.tap_actions.actions[0].options
|
|
181
|
+
.dismissSheet
|
|
182
|
+
).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("opens sleep timer with the ticking countdown header", async () => {
|
|
186
|
+
const timerDate = Date.now() + 15 * 60 * 1000;
|
|
187
|
+
|
|
188
|
+
await sleepController.setSleepTimer(
|
|
189
|
+
{ label: "15 minutes", value: 15 },
|
|
190
|
+
timerDate
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const result = await actionExecutor.handleAction({
|
|
194
|
+
type: PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(result).toBe(ActionResult.Success);
|
|
198
|
+
|
|
199
|
+
const sheet = openedSheets[0];
|
|
200
|
+
|
|
201
|
+
expect(sheet.options.replace).toBe(true);
|
|
202
|
+
expect(sheet.options.header.title).toBe("Sleep Timer");
|
|
203
|
+
expect(sheet.options.header.subtitle).toBeUndefined();
|
|
204
|
+
expect(sheet.options.content.behavior.currentSelection).toEqual(["15"]);
|
|
205
|
+
|
|
206
|
+
expect(
|
|
207
|
+
sheet.options.content.items[0].extensions.tap_actions.actions[0].type
|
|
208
|
+
).toBe(PLAYER_ACTION_TYPES.SET_SLEEP_TIMER);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("setPlaybackSpeed applies the rate and closes the sheet when opened from the picker", async () => {
|
|
212
|
+
const result = await actionExecutor.handleAction({
|
|
213
|
+
type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
214
|
+
options: { value: 1.5, dismissSheet: true },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(result).toBe(ActionResult.Success);
|
|
218
|
+
expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
|
|
219
|
+
expect(localStorage.setItem).toHaveBeenCalled();
|
|
220
|
+
expect(modalOrchestrator.close).toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("setSleepTimer starts the timer, writes storage, and closes the sheet", async () => {
|
|
224
|
+
const result = await actionExecutor.handleAction({
|
|
225
|
+
type: PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
|
|
226
|
+
options: { value: 15, label: "15 minutes", dismissSheet: true },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(result).toBe(ActionResult.Success);
|
|
230
|
+
expect(player.startSleepTimer).toHaveBeenCalled();
|
|
231
|
+
expect(localStorage.setItem).toHaveBeenCalled();
|
|
232
|
+
expect(modalOrchestrator.close).toHaveBeenCalled();
|
|
233
|
+
expect(sleepController.currentState.timer.value).toBe(15);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("setSleepTimer with value 0 cancels the timer", async () => {
|
|
237
|
+
await sleepController.setSleepTimer({
|
|
238
|
+
label: "15 minutes",
|
|
239
|
+
value: 15,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
player.cancelSleepTimer.mockClear();
|
|
243
|
+
(localStorage.removeItem as jest.Mock).mockClear();
|
|
244
|
+
(modalOrchestrator.close as jest.Mock).mockClear();
|
|
245
|
+
|
|
246
|
+
const result = await actionExecutor.handleAction({
|
|
247
|
+
type: PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
|
|
248
|
+
options: { value: 0, label: "Off", dismissSheet: true },
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(result).toBe(ActionResult.Success);
|
|
252
|
+
expect(player.cancelSleepTimer).toHaveBeenCalled();
|
|
253
|
+
expect(localStorage.removeItem).toHaveBeenCalled();
|
|
254
|
+
expect(sleepController.currentState.timer.value).toBe(0);
|
|
255
|
+
expect(modalOrchestrator.close).toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it.each([undefined, "foo"])(
|
|
259
|
+
"rejects invalid setPlaybackSpeed value %p",
|
|
260
|
+
async (value) => {
|
|
261
|
+
player.setPlaybackRate.mockClear();
|
|
262
|
+
(localStorage.setItem as jest.Mock).mockClear();
|
|
263
|
+
|
|
264
|
+
const result = await actionExecutor.handleAction({
|
|
265
|
+
type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
266
|
+
options: { value, dismissSheet: true },
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(result).toBe(ActionResult.Error);
|
|
270
|
+
expect(player.setPlaybackRate).not.toHaveBeenCalled();
|
|
271
|
+
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
272
|
+
expect(modalOrchestrator.close).not.toHaveBeenCalled();
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
it.each([undefined, "foo"])(
|
|
277
|
+
"rejects invalid setSleepTimer value %p",
|
|
278
|
+
async (value) => {
|
|
279
|
+
const result = await actionExecutor.handleAction({
|
|
280
|
+
type: PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
|
|
281
|
+
options: { value, dismissSheet: true },
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(result).toBe(ActionResult.Error);
|
|
285
|
+
expect(player.startSleepTimer).not.toHaveBeenCalled();
|
|
286
|
+
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
287
|
+
expect(modalOrchestrator.close).not.toHaveBeenCalled();
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
it("does not close an unrelated sheet when setPlaybackSpeed is invoked standalone", async () => {
|
|
292
|
+
const result = await actionExecutor.handleAction({
|
|
293
|
+
type: PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
294
|
+
options: { value: 1.5 },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result).toBe(ActionResult.Success);
|
|
298
|
+
expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
|
|
299
|
+
expect(modalOrchestrator.close).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("initialEntryState for sleep-timer reflects an active timer", async () => {
|
|
303
|
+
await sleepController.setSleepTimer({
|
|
304
|
+
label: "15 minutes",
|
|
305
|
+
value: 15,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const action = uiActionsRegistry.getAction(
|
|
309
|
+
PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(action.initialEntryState({} as ZappEntry)).toMatchObject({
|
|
313
|
+
state: 1,
|
|
314
|
+
asset: "sleep_timer_active",
|
|
315
|
+
label: "Sleep Timer",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const observed = await firstValueFrom(
|
|
319
|
+
action.observeEntryState({} as ZappEntry)
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(observed).toMatchObject({
|
|
323
|
+
state: 1,
|
|
324
|
+
asset: "sleep_timer_active",
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("registers UI action identifiers that open the matching sheets", async () => {
|
|
329
|
+
await uiActionsRegistry
|
|
330
|
+
.getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
|
|
331
|
+
.invokeAction({} as ZappEntry);
|
|
332
|
+
|
|
333
|
+
await uiActionsRegistry
|
|
334
|
+
.getAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
|
|
335
|
+
.invokeAction({} as ZappEntry);
|
|
336
|
+
|
|
337
|
+
expect(openedSheets.map((action) => action.options.header.title)).toEqual([
|
|
338
|
+
"Playback Speed",
|
|
339
|
+
"Sleep Timer",
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
|
|
18
|
+
export const PLAYER_ACTION_IDENTIFIERS = {
|
|
19
|
+
PLAYBACK_SPEED: "playback-speed",
|
|
20
|
+
SLEEP_TIMER: "sleep-timer",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export const PLAYER_ACTION_TYPES = {
|
|
24
|
+
OPEN_PLAYBACK_SPEED: "openPlaybackSpeed",
|
|
25
|
+
OPEN_SLEEP_TIMER: "openSleepTimer",
|
|
26
|
+
SET_PLAYBACK_SPEED: "setPlaybackSpeed",
|
|
27
|
+
SET_SLEEP_TIMER: "setSleepTimer",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
function getPlayerConfig(): Record<string, any> {
|
|
31
|
+
return playerManager.getActivePlayer()?.getConfig?.() || {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function remainingTimerDate(timerDate: number): number | null {
|
|
35
|
+
return timerDate !== 0 && +timerDate > Date.now() ? timerDate : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function dismissOpenedSheet(action: ActionType) {
|
|
39
|
+
if (action.options?.dismissSheet) {
|
|
40
|
+
modalOrchestrator.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function optionToEntry(
|
|
45
|
+
id: string,
|
|
46
|
+
title: string,
|
|
47
|
+
actionType: string,
|
|
48
|
+
options: Record<string, any>
|
|
49
|
+
): ZappEntry {
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
title,
|
|
53
|
+
extensions: {
|
|
54
|
+
tap_actions: {
|
|
55
|
+
actions: [
|
|
56
|
+
{ type: actionType, options: { ...options, dismissSheet: true } },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
} as ZappEntry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sleepTimerEntryState(
|
|
64
|
+
sleepState: SleepTimerState
|
|
65
|
+
): CellActionEntryState {
|
|
66
|
+
const config = getPlayerConfig();
|
|
67
|
+
const isActive = Boolean(remainingTimerDate(sleepState.timerDate));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
state: isActive ? 1 : 0,
|
|
71
|
+
label: config.playback_sleep_title || "Sleep Timer",
|
|
72
|
+
asset: isActive
|
|
73
|
+
? config.sleep_timer_active || config.sleep_timer || ""
|
|
74
|
+
: config.sleep_timer || "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function openPlaybackSpeedAction(
|
|
79
|
+
speedController: SpeedController
|
|
80
|
+
): Promise<ActionResult> {
|
|
81
|
+
const config = getPlayerConfig();
|
|
82
|
+
const current = speedController.currentSpeed;
|
|
83
|
+
|
|
84
|
+
const items = speedOptions.map((option) =>
|
|
85
|
+
optionToEntry(
|
|
86
|
+
String(option.value),
|
|
87
|
+
option.label,
|
|
88
|
+
PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
89
|
+
{ value: option.value }
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return actionExecutor.handleAction({
|
|
94
|
+
type: "openBottomSheet",
|
|
95
|
+
options: {
|
|
96
|
+
replace: true,
|
|
97
|
+
header: {
|
|
98
|
+
title: config.playback_speed_title || "Playback Speed",
|
|
99
|
+
},
|
|
100
|
+
content: {
|
|
101
|
+
role: "collection_selector",
|
|
102
|
+
behavior: {
|
|
103
|
+
selectMode: "single",
|
|
104
|
+
currentSelection: [String(current.value)],
|
|
105
|
+
},
|
|
106
|
+
items,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
} as ActionType);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function openSleepTimerAction(
|
|
113
|
+
sleepController: SleepTimerController
|
|
114
|
+
): Promise<ActionResult> {
|
|
115
|
+
const config = getPlayerConfig();
|
|
116
|
+
const sleepState = sleepController.currentState;
|
|
117
|
+
const timerRemaining = remainingTimerDate(sleepState.timerDate);
|
|
118
|
+
|
|
119
|
+
const items = getSleepTimerOptions(
|
|
120
|
+
config.playback_sleep_off_label || "Off",
|
|
121
|
+
timerRemaining
|
|
122
|
+
)
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.map((option) =>
|
|
125
|
+
optionToEntry(
|
|
126
|
+
String(option.value),
|
|
127
|
+
option.label,
|
|
128
|
+
PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
|
|
129
|
+
{ value: option.value, label: option.label }
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return actionExecutor.handleAction({
|
|
134
|
+
type: "openBottomSheet",
|
|
135
|
+
options: {
|
|
136
|
+
replace: true,
|
|
137
|
+
header: {
|
|
138
|
+
title: config.playback_sleep_title || "Sleep Timer",
|
|
139
|
+
},
|
|
140
|
+
content: {
|
|
141
|
+
role: "collection_selector",
|
|
142
|
+
behavior: {
|
|
143
|
+
selectMode: "single",
|
|
144
|
+
currentSelection: timerRemaining
|
|
145
|
+
? [String(sleepState.timer.value)]
|
|
146
|
+
: [],
|
|
147
|
+
},
|
|
148
|
+
items,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
} as ActionType);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function setPlaybackSpeedAction(
|
|
155
|
+
speedController: SpeedController,
|
|
156
|
+
action: ActionType
|
|
157
|
+
): Promise<ActionResult> {
|
|
158
|
+
const value = toNumber(action.options?.value);
|
|
159
|
+
|
|
160
|
+
if (!Number.isFinite(value)) {
|
|
161
|
+
return ActionResult.Error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await speedController.setPlaybackSpeed({
|
|
165
|
+
asset: formatSpeed(value),
|
|
166
|
+
value,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
dismissOpenedSheet(action);
|
|
170
|
+
|
|
171
|
+
return ActionResult.Success;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function setSleepTimerAction(
|
|
175
|
+
sleepController: SleepTimerController,
|
|
176
|
+
action: ActionType
|
|
177
|
+
): Promise<ActionResult> {
|
|
178
|
+
const value = toNumber(action.options?.value);
|
|
179
|
+
|
|
180
|
+
if (!Number.isFinite(value)) {
|
|
181
|
+
return ActionResult.Error;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await sleepController.setSleepTimer({
|
|
185
|
+
label: action.options?.label || String(value),
|
|
186
|
+
value,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
dismissOpenedSheet(action);
|
|
190
|
+
|
|
191
|
+
return ActionResult.Success;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function registerPlayerActions(options: {
|
|
195
|
+
speedController?: SpeedController;
|
|
196
|
+
sleepController?: SleepTimerController;
|
|
197
|
+
}): () => void {
|
|
198
|
+
const unregisters: Array<() => void> = [];
|
|
199
|
+
|
|
200
|
+
if (options.speedController) {
|
|
201
|
+
const speedController = options.speedController;
|
|
202
|
+
|
|
203
|
+
const speedHandlers: Array<[string, ActionHandler]> = [
|
|
204
|
+
[
|
|
205
|
+
PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
|
|
206
|
+
() => openPlaybackSpeedAction(speedController),
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
|
|
210
|
+
(action) => setPlaybackSpeedAction(speedController, action),
|
|
211
|
+
],
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
speedHandlers.forEach(([type, handler]) => {
|
|
215
|
+
actionExecutor.unregisterAction(type);
|
|
216
|
+
unregisters.push(actionExecutor.registerAction(type, handler));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
unregisters.push(
|
|
220
|
+
uiActionsRegistry.registerAction(
|
|
221
|
+
PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
|
|
222
|
+
{
|
|
223
|
+
initialEntryState: () => ({
|
|
224
|
+
state: 0,
|
|
225
|
+
label: getPlayerConfig().playback_speed_title || "Playback Speed",
|
|
226
|
+
asset: getPlayerConfig().playback_speed || "",
|
|
227
|
+
}),
|
|
228
|
+
invokeAction: async () => {
|
|
229
|
+
await actionExecutor.handleAction({
|
|
230
|
+
type: PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (options.sleepController) {
|
|
239
|
+
const sleepController = options.sleepController;
|
|
240
|
+
|
|
241
|
+
const sleepHandlers: Array<[string, ActionHandler]> = [
|
|
242
|
+
[
|
|
243
|
+
PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
|
|
244
|
+
() => openSleepTimerAction(sleepController),
|
|
245
|
+
],
|
|
246
|
+
[
|
|
247
|
+
PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
|
|
248
|
+
(action) => setSleepTimerAction(sleepController, action),
|
|
249
|
+
],
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
sleepHandlers.forEach(([type, handler]) => {
|
|
253
|
+
actionExecutor.unregisterAction(type);
|
|
254
|
+
unregisters.push(actionExecutor.registerAction(type, handler));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
unregisters.push(
|
|
258
|
+
uiActionsRegistry.registerAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER, {
|
|
259
|
+
initialEntryState: () =>
|
|
260
|
+
sleepTimerEntryState(sleepController.currentState),
|
|
261
|
+
observeEntryState: () =>
|
|
262
|
+
sleepController.sleepTimerState$.pipe(
|
|
263
|
+
map((sleepState) => sleepTimerEntryState(sleepState))
|
|
264
|
+
),
|
|
265
|
+
invokeAction: async () => {
|
|
266
|
+
await actionExecutor.handleAction({
|
|
267
|
+
type: PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return () => {
|
|
275
|
+
unregisters.forEach((unregister) => unregister());
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
() => ({
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
VIDEO_TRANSITION_THRESHOLD,
|
|
13
13
|
} from "./consts";
|
|
14
14
|
import { useIsTablet } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
15
|
+
import { useIsTabletLandscape } from "@applicaster/zapp-react-native-utils/reactHooks/device/useMemoizedIsTablet";
|
|
15
16
|
import { PROGRESS_BAR_HEIGHT } from "@applicaster/zapp-react-native-ui-components/Components/VideoModal/ModalAnimation/utils";
|
|
16
17
|
import {
|
|
17
18
|
SafeAreaView,
|
|
@@ -100,7 +101,23 @@ export function VideoPlayerModal({
|
|
|
100
101
|
fullscreen || pip
|
|
101
102
|
);
|
|
102
103
|
|
|
104
|
+
/* Landscape is a tablet's regular orientation, so rotating it says nothing
|
|
105
|
+
about the modal mode. useOrientationEffect skips its orientation effects
|
|
106
|
+
for the same reason, via the same predicate. */
|
|
107
|
+
const skipOrientationEffects = useIsTabletLandscape();
|
|
108
|
+
|
|
109
|
+
/* Props are the only reconciliation left once the listener below is skipped,
|
|
110
|
+
and even where it runs, the mount value goes stale: remounting during
|
|
111
|
+
native fullscreen would otherwise strand the modal in that layout. Rotating
|
|
112
|
+
a tablet flips the predicate itself, so reconcile then too — the last
|
|
113
|
+
orientation event may have left the state behind. */
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
setFullScreenMode(fullscreen || pip);
|
|
116
|
+
}, [fullscreen, pip, skipOrientationEffects]);
|
|
117
|
+
|
|
103
118
|
React.useEffect(() => {
|
|
119
|
+
if (skipOrientationEffects) return;
|
|
120
|
+
|
|
104
121
|
const listener = addOrientationChangeListener(({ toOrientation }) => {
|
|
105
122
|
/* ignored in collapsed mode non-PiP player */
|
|
106
123
|
if (collapsed && !pip) return;
|
|
@@ -110,7 +127,7 @@ export function VideoPlayerModal({
|
|
|
110
127
|
return () => {
|
|
111
128
|
listener.remove();
|
|
112
129
|
};
|
|
113
|
-
}, [collapsed, pip]);
|
|
130
|
+
}, [collapsed, pip, skipOrientationEffects]);
|
|
114
131
|
|
|
115
132
|
const isFullScreenOrPIP = isFullScreenMode;
|
|
116
133
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "react-native";
|
|
3
|
+
import { act, render, screen } from "@testing-library/react-native";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The modal's fullscreen layout is not directly observable, but the collapsed
|
|
7
|
+
* bar is: it renders only while the modal is NOT in that layout. Every case
|
|
8
|
+
* below therefore reads `collapsedBarWrapper` as the proxy for the state under
|
|
9
|
+
* test.
|
|
10
|
+
*
|
|
11
|
+
* Why it matters: while the modal sits in the fullscreen layout, a drag still
|
|
12
|
+
* drives navigation to MINIMIZED, which docks the player and hides the native
|
|
13
|
+
* controls (see Player/index.tsx `shouldUseNativeControls`). If the layout
|
|
14
|
+
* cannot follow the mode back out, the modal is stranded on top of the tab bar
|
|
15
|
+
* with no controls and no way to restore them.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
let mockIsTabletLandscape = false;
|
|
19
|
+
let orientationListener: ((event: any) => void) | null = null;
|
|
20
|
+
const mockRemove = jest.fn();
|
|
21
|
+
|
|
22
|
+
const mockAddOrientationChangeListener = jest.fn((cb) => {
|
|
23
|
+
orientationListener = cb;
|
|
24
|
+
|
|
25
|
+
return { remove: mockRemove };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
jest.mock(
|
|
29
|
+
"@applicaster/zapp-react-native-utils/appUtils/orientationHelper",
|
|
30
|
+
() => ({
|
|
31
|
+
addOrientationChangeListener: (cb) => mockAddOrientationChangeListener(cb),
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
jest.mock(
|
|
36
|
+
"@applicaster/zapp-react-native-utils/reactHooks/device/useMemoizedIsTablet",
|
|
37
|
+
() => ({ useIsTabletLandscape: () => mockIsTabletLandscape })
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
|
|
41
|
+
useIsTablet: () => mockIsTabletLandscape,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
jest.mock(
|
|
45
|
+
"@applicaster/zapp-react-native-ui-components/Components/VideoModal/utils",
|
|
46
|
+
() => ({ useConfiguration: () => ({ minimised_height: 60 }) })
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
jest.mock(
|
|
50
|
+
"@applicaster/zapp-react-native-ui-components/Components/VideoModal/ModalAnimation/utils",
|
|
51
|
+
() => ({ PROGRESS_BAR_HEIGHT: 3 })
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
jest.mock("react-native-safe-area-context", () => {
|
|
55
|
+
const { View } = require("react-native");
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
SafeAreaView: View,
|
|
59
|
+
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 1024, height: 768 }),
|
|
60
|
+
useSafeAreaInsets: () => ({ top: 24, bottom: 34, left: 0, right: 0 }),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
jest.mock("../GestureAnimatedScrollView", () => {
|
|
65
|
+
const { ScrollView } = require("react-native");
|
|
66
|
+
|
|
67
|
+
return { GestureAnimatedScrollView: ScrollView };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
jest.mock("../styles", () => ({
|
|
71
|
+
useStyles: () => ({
|
|
72
|
+
modal: {},
|
|
73
|
+
modalWrapper: {},
|
|
74
|
+
collapsedModalBg: {},
|
|
75
|
+
modalContent: {},
|
|
76
|
+
modalContentPiP: {},
|
|
77
|
+
expandedContentContainer: {},
|
|
78
|
+
extraContentScrollView: {},
|
|
79
|
+
collapsedBarContainer: {},
|
|
80
|
+
collapsedBarWrapper: {},
|
|
81
|
+
flexNone: {},
|
|
82
|
+
}),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
jest.mock("../hooks", () => ({
|
|
86
|
+
useVideoModalState: () => ({
|
|
87
|
+
collapsed: false,
|
|
88
|
+
offset: { current: 700 },
|
|
89
|
+
gestureHandlerProps: {},
|
|
90
|
+
translateY: new (require("react-native").Animated.Value)(0),
|
|
91
|
+
expand: jest.fn(),
|
|
92
|
+
}),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// eslint-disable-next-line import/first
|
|
96
|
+
import { VideoPlayerModal } from "../VideoPlayerModal";
|
|
97
|
+
|
|
98
|
+
const renderModal = (props = {}) =>
|
|
99
|
+
render(
|
|
100
|
+
<VideoPlayerModal
|
|
101
|
+
modal
|
|
102
|
+
content={<Text>content</Text>}
|
|
103
|
+
collapsedContent={<Text>collapsed</Text>}
|
|
104
|
+
style={{ width: 1024, height: 768, tabletLandscapeWidth: 600 }}
|
|
105
|
+
{...props}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const collapsedBar = () => screen.queryByTestId("collapsedBarWrapper");
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
jest.clearAllMocks();
|
|
113
|
+
orientationListener = null;
|
|
114
|
+
mockIsTabletLandscape = false;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("VideoPlayerModal fullscreen layout", () => {
|
|
118
|
+
describe("on a tablet in landscape", () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
mockIsTabletLandscape = true;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("leaves the orientation listener alone, since landscape is its regular orientation", () => {
|
|
124
|
+
renderModal();
|
|
125
|
+
|
|
126
|
+
expect(mockAddOrientationChangeListener).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("follows the mode back out of fullscreen", () => {
|
|
130
|
+
const { rerender } = renderModal({ fullscreen: true });
|
|
131
|
+
|
|
132
|
+
expect(collapsedBar()).toBeNull();
|
|
133
|
+
|
|
134
|
+
rerender(
|
|
135
|
+
<VideoPlayerModal
|
|
136
|
+
modal
|
|
137
|
+
fullscreen={false}
|
|
138
|
+
content={<Text>content</Text>}
|
|
139
|
+
collapsedContent={<Text>collapsed</Text>}
|
|
140
|
+
style={{ width: 1024, height: 768, tabletLandscapeWidth: 600 }}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(collapsedBar()).not.toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("on a phone", () => {
|
|
149
|
+
it("still lets orientation drive the layout", () => {
|
|
150
|
+
renderModal();
|
|
151
|
+
|
|
152
|
+
expect(mockAddOrientationChangeListener).toHaveBeenCalled();
|
|
153
|
+
expect(collapsedBar()).not.toBeNull();
|
|
154
|
+
|
|
155
|
+
act(() => orientationListener?.({ toOrientation: 4 }));
|
|
156
|
+
|
|
157
|
+
expect(collapsedBar()).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { ModalHeader } from "@applicaster/zapp-react-native-ui-components/Components/ModalComponent/Header";
|
|
3
|
-
import { isNil } from "@applicaster/zapp-react-native-utils/utils";
|
|
4
|
-
|
|
5
|
-
const formatTime = (milliseconds: number) => {
|
|
6
|
-
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
7
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
8
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
9
|
-
const seconds = totalSeconds % 60;
|
|
10
|
-
|
|
11
|
-
if (milliseconds < 3600000) {
|
|
12
|
-
return `${minutes.toString().padStart(2, "0")}:${seconds
|
|
13
|
-
.toString()
|
|
14
|
-
.padStart(2, "0")}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return `${hours.toString().padStart(2, "0")}:${minutes
|
|
18
|
-
.toString()
|
|
19
|
-
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export const SleepTimerModalHeader = ({
|
|
23
|
-
summary,
|
|
24
|
-
...props
|
|
25
|
-
}: {
|
|
26
|
-
summary?: string;
|
|
27
|
-
}) => {
|
|
28
|
-
const sleepTimerDate = +summary;
|
|
29
|
-
|
|
30
|
-
const [timeLeft, setTimeLeft] = React.useState(
|
|
31
|
-
sleepTimerDate !== 0 ? sleepTimerDate - Date.now() : 0
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const showCounter = React.useMemo(() => sleepTimerDate > 0, [sleepTimerDate]);
|
|
35
|
-
|
|
36
|
-
React.useLayoutEffect(() => {
|
|
37
|
-
if (showCounter && sleepTimerDate !== 0) {
|
|
38
|
-
setTimeLeft(sleepTimerDate - Date.now());
|
|
39
|
-
|
|
40
|
-
const intervalId = setInterval(() => {
|
|
41
|
-
setTimeLeft(() => {
|
|
42
|
-
const newTimeLeft = sleepTimerDate - Date.now();
|
|
43
|
-
|
|
44
|
-
if (newTimeLeft <= 0) {
|
|
45
|
-
clearInterval(intervalId);
|
|
46
|
-
|
|
47
|
-
return 0;
|
|
48
|
-
} else {
|
|
49
|
-
return newTimeLeft;
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
}, 1000);
|
|
53
|
-
|
|
54
|
-
return () => clearInterval(intervalId);
|
|
55
|
-
} else {
|
|
56
|
-
setTimeLeft(0);
|
|
57
|
-
}
|
|
58
|
-
}, [sleepTimerDate, showCounter]);
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<ModalHeader
|
|
62
|
-
summary={!isNil(summary) ? formatTime(timeLeft) : summary}
|
|
63
|
-
{...(props as any)}
|
|
64
|
-
/>
|
|
65
|
-
);
|
|
66
|
-
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "./SleepTimerModalHeader";
|