@applicaster/quick-brick-player 16.0.0-rc.6 → 16.0.0-rc.61
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 +6 -6
- package/src/Player/AudioLayer/AudioDescription.tsx +46 -11
- package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +38 -48
- package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +66 -0
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +34 -14
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +26 -0
- package/src/Player/AudioLayer/Layout/DockedControls/index.tsx +1 -13
- package/src/Player/AudioLayer/Layout/MobileLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletLandscapeLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletPortraitLayout.tsx +0 -4
- package/src/Player/AudioLayer/SleepTimerManager.ts +5 -1
- package/src/Player/AudioLayer/SpeedManager.ts +4 -0
- package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
- package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -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 +351 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +9 -8
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/__tests__/SleepTimerModalHeader.test.tsx +53 -0
- package/src/Player/AudioLayer/playerActions.ts +280 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +134 -0
- package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
- package/src/Player/__tests__/getNativeProps.test.ts +115 -0
- package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
- package/src/Player/index.tsx +72 -27
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
4
|
+
import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
|
|
5
|
+
import {
|
|
6
|
+
loadFeedEntry,
|
|
7
|
+
retrieveFeedUrl,
|
|
8
|
+
retrievePlayPreviousFeedUrl,
|
|
9
|
+
} from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
|
|
10
|
+
import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
11
|
+
|
|
12
|
+
const { log_error, log_info } = createLogger({
|
|
13
|
+
subsystem: "Player",
|
|
14
|
+
category: "usePlaylistNavigation",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const RESTART_THRESHOLD_SECONDS = 3;
|
|
18
|
+
|
|
19
|
+
export type PlaylistNavigation = {
|
|
20
|
+
hasNext: boolean;
|
|
21
|
+
hasPrevious: boolean;
|
|
22
|
+
goNext: () => Promise<void>;
|
|
23
|
+
goPrevious: () => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Each entry points at the feed holding its neighbour, so the chain walks
|
|
28
|
+
* itself and needs no client-side queue.
|
|
29
|
+
*/
|
|
30
|
+
export const usePlaylistNavigation = (
|
|
31
|
+
entry: ZappEntry,
|
|
32
|
+
playerState: QuickBrickPlayer.PlayerState
|
|
33
|
+
): PlaylistNavigation => {
|
|
34
|
+
const navigator = useNavigation();
|
|
35
|
+
// Not playerManager.getActivePlayer(): that is always the local player, so
|
|
36
|
+
// seeking would miss the receiver while casting.
|
|
37
|
+
const player = usePlayer();
|
|
38
|
+
|
|
39
|
+
const [nextUrl, setNextUrl] = React.useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
|
|
44
|
+
retrieveFeedUrl(entry)
|
|
45
|
+
.then((url) => {
|
|
46
|
+
if (!cancelled) {
|
|
47
|
+
setNextUrl(url ?? null);
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.catch((error) => {
|
|
51
|
+
log_error(
|
|
52
|
+
"usePlaylistNavigation: could not resolve the next feed url",
|
|
53
|
+
{ error }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!cancelled) {
|
|
57
|
+
setNextUrl(null);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [entry]);
|
|
65
|
+
|
|
66
|
+
const previousUrl = retrievePlayPreviousFeedUrl(entry);
|
|
67
|
+
const currentTime = playerState?.currentTime ?? 0;
|
|
68
|
+
|
|
69
|
+
const canRestart = (playerState?.seekableDuration ?? 0) > 0;
|
|
70
|
+
|
|
71
|
+
const isNavigatingRef = React.useRef(false);
|
|
72
|
+
|
|
73
|
+
const navigateTo = React.useCallback(
|
|
74
|
+
async (feedUrl: string) => {
|
|
75
|
+
// A second tap mid-flight resolves to the same target, since `entry` has
|
|
76
|
+
// not been replaced yet.
|
|
77
|
+
if (isNavigatingRef.current) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isNavigatingRef.current = true;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const target = await loadFeedEntry(feedUrl, entry);
|
|
85
|
+
|
|
86
|
+
log_info(`navigateTo: playing "${target.title}", id: ${target.id}`, {
|
|
87
|
+
feedUrl,
|
|
88
|
+
targetId: target.id,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
navigator.replaceTop(target);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
log_error("navigateTo: could not play the neighbouring entry", {
|
|
94
|
+
error,
|
|
95
|
+
feedUrl,
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
// Released on failure too, so one bad request cannot latch the control.
|
|
99
|
+
isNavigatingRef.current = false;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[entry, navigator]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const goNext = React.useCallback(async () => {
|
|
106
|
+
if (!nextUrl) return;
|
|
107
|
+
|
|
108
|
+
await navigateTo(nextUrl);
|
|
109
|
+
}, [nextUrl, navigateTo]);
|
|
110
|
+
|
|
111
|
+
const goPrevious = React.useCallback(async () => {
|
|
112
|
+
// Restarting belongs to the stream, not the chain — so it stays available
|
|
113
|
+
// at the head, where there is nothing to step back to.
|
|
114
|
+
const shouldRestart =
|
|
115
|
+
canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !previousUrl);
|
|
116
|
+
|
|
117
|
+
if (shouldRestart) {
|
|
118
|
+
player?.seekTo(0);
|
|
119
|
+
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!previousUrl) return;
|
|
124
|
+
|
|
125
|
+
await navigateTo(previousUrl);
|
|
126
|
+
}, [previousUrl, currentTime, canRestart, navigateTo, player]);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
hasNext: Boolean(nextUrl),
|
|
130
|
+
hasPrevious: Boolean(previousUrl) || canRestart,
|
|
131
|
+
goNext,
|
|
132
|
+
goPrevious,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
@@ -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
|
() => ({
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import VideoPlayer from "../index";
|
|
2
|
+
import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pins where `getNativeProps()`'s `metadata` object comes from: the player
|
|
6
|
+
* manager's content channel, looked up by `props.playerId`.
|
|
7
|
+
*
|
|
8
|
+
* This deliberately does NOT depend on tree position. An earlier version read
|
|
9
|
+
* the values from `PlayerContainerContext` via `static contextType`, which
|
|
10
|
+
* made native's props depend on `Player` being rendered inside
|
|
11
|
+
* `PlayerContainerContextProvider` - safe only as long as an exhaustive
|
|
12
|
+
* render-site search stayed true. Reading from the manager removes that whole
|
|
13
|
+
* class of fragility, so the tests below assert the value's source rather than
|
|
14
|
+
* a render-site invariant.
|
|
15
|
+
*
|
|
16
|
+
* Lookup is by id rather than `getActivePlayer()` because these props are
|
|
17
|
+
* handed to this component's own native view: they must describe that player
|
|
18
|
+
* even while a cast session is running.
|
|
19
|
+
*
|
|
20
|
+
* The instance is constructed directly (bypassing `render()`/mount) so this
|
|
21
|
+
* exercises the real `getNativeProps()` without paying for a full render of
|
|
22
|
+
* the ~37KB `VideoPlayer` class tree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
|
|
26
|
+
...jest.requireActual("@applicaster/zapp-react-native-utils/appUtils"),
|
|
27
|
+
playerManager: {
|
|
28
|
+
getPlayerWithId: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const entry = {
|
|
33
|
+
id: "1",
|
|
34
|
+
title: "Entry Title",
|
|
35
|
+
summary: "Entry Summary",
|
|
36
|
+
} as unknown as ZappEntry;
|
|
37
|
+
|
|
38
|
+
const baseProps = {
|
|
39
|
+
entry,
|
|
40
|
+
pluginConfiguration: {},
|
|
41
|
+
docked: false,
|
|
42
|
+
fullscreen: false,
|
|
43
|
+
inline: false,
|
|
44
|
+
isModal: false,
|
|
45
|
+
isTabletPortrait: false,
|
|
46
|
+
muted: false,
|
|
47
|
+
playerId: "player-1",
|
|
48
|
+
PlayerComponent: "QuickBrickDefaultPlayerView",
|
|
49
|
+
} as unknown as ConstructorParameters<typeof VideoPlayer>[0];
|
|
50
|
+
|
|
51
|
+
const publishContent = (
|
|
52
|
+
content: {
|
|
53
|
+
title: Option<string | number>;
|
|
54
|
+
summary: Option<string | number>;
|
|
55
|
+
} | null
|
|
56
|
+
) => {
|
|
57
|
+
(playerManager.getPlayerWithId as jest.Mock).mockReturnValue(
|
|
58
|
+
content ? { getContent: () => content } : null
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("Player's getNativeProps() reading Title/Subtitle from the manager", () => {
|
|
63
|
+
it("sends the title/summary the player published", () => {
|
|
64
|
+
publishContent({
|
|
65
|
+
title: "Resolved Title",
|
|
66
|
+
summary: "Resolved Summary",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
70
|
+
|
|
71
|
+
expect(nativeProps.metadata.title).toBe("Resolved Title");
|
|
72
|
+
expect(nativeProps.metadata.subtitle).toBe("Resolved Summary");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("looks the player up by its own id, not the active player", () => {
|
|
76
|
+
publishContent({ title: "Resolved Title", summary: "Resolved Summary" });
|
|
77
|
+
|
|
78
|
+
new VideoPlayer(baseProps).getNativeProps();
|
|
79
|
+
|
|
80
|
+
expect(playerManager.getPlayerWithId).toHaveBeenCalledWith("player-1");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("sends undefined when the player published no text", () => {
|
|
84
|
+
publishContent({ title: null, summary: null });
|
|
85
|
+
|
|
86
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
87
|
+
|
|
88
|
+
expect(nativeProps.metadata.title).toBeUndefined();
|
|
89
|
+
expect(nativeProps.metadata.subtitle).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("sends undefined rather than crashing when no player is registered", () => {
|
|
93
|
+
publishContent(null);
|
|
94
|
+
|
|
95
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
96
|
+
|
|
97
|
+
expect(nativeProps.metadata.title).toBeUndefined();
|
|
98
|
+
expect(nativeProps.metadata.subtitle).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not fall back to entry.title/entry.summary itself - the resolver owns that fallback", () => {
|
|
102
|
+
// `entry.title`/`entry.summary` are "Entry Title"/"Entry Summary" (see
|
|
103
|
+
// `baseProps`); with nothing published, nativeProps must NOT silently pick
|
|
104
|
+
// those up as a second, redundant fallback layer inside `Player`. The
|
|
105
|
+
// entry-field fallback lives in `resolvePlayerContentText`, reached
|
|
106
|
+
// through the player's own `getContent()`.
|
|
107
|
+
publishContent({ title: null, summary: null });
|
|
108
|
+
|
|
109
|
+
const nativeProps = new VideoPlayer(baseProps).getNativeProps();
|
|
110
|
+
|
|
111
|
+
expect(nativeProps.metadata.title).not.toBe(entry.title);
|
|
112
|
+
expect(nativeProps.metadata.subtitle).not.toBe(entry.summary);
|
|
113
|
+
expect(nativeProps.entry).toBe(entry);
|
|
114
|
+
});
|
|
115
|
+
});
|