@applicaster/quick-brick-player 16.0.0-rc.8 → 16.0.0-rc.80

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.
Files changed (31) hide show
  1. package/package.json +6 -6
  2. package/src/Player/AudioLayer/AudioDescription.tsx +46 -11
  3. package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +38 -48
  4. package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +87 -0
  5. package/src/Player/AudioLayer/Layout/Controls/index.tsx +34 -14
  6. package/src/Player/AudioLayer/Layout/Controls/utils.ts +40 -0
  7. package/src/Player/AudioLayer/Layout/DockedControls/index.tsx +1 -13
  8. package/src/Player/AudioLayer/Layout/MobileLayout.tsx +0 -4
  9. package/src/Player/AudioLayer/Layout/TabletLandscapeLayout.tsx +0 -4
  10. package/src/Player/AudioLayer/Layout/TabletPortraitLayout.tsx +0 -4
  11. package/src/Player/AudioLayer/Layout/TransportInfo/__tests__/index.test.tsx +257 -0
  12. package/src/Player/AudioLayer/Layout/TransportInfo/index.tsx +69 -8
  13. package/src/Player/AudioLayer/SleepTimerManager.ts +5 -1
  14. package/src/Player/AudioLayer/SpeedManager.ts +4 -0
  15. package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
  16. package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
  17. package/src/Player/AudioLayer/__tests__/SleepTimerManager.test.ts +78 -0
  18. package/src/Player/AudioLayer/__tests__/SpeedManager.test.ts +57 -0
  19. package/src/Player/AudioLayer/__tests__/playerActions.test.ts +369 -0
  20. package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
  21. package/src/Player/AudioLayer/playerActions.ts +294 -0
  22. package/src/Player/AudioLayer/usePlaylistNavigation.ts +134 -0
  23. package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
  24. package/src/Player/PlayerModal/VideoPlayerModal.tsx +18 -1
  25. package/src/Player/PlayerModal/__tests__/VideoPlayerModal.test.tsx +160 -0
  26. package/src/Player/__tests__/getNativeProps.test.ts +115 -0
  27. package/src/Player/__tests__/onTracksChanged.test.ts +192 -0
  28. package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
  29. package/src/Player/index.tsx +147 -40
  30. package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
  31. package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
@@ -0,0 +1,387 @@
1
+ import { renderHook, act } from "@testing-library/react-native";
2
+ import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
3
+ import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
4
+ import { loadFeedEntry } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
5
+
6
+ import { usePlaylistNavigation } from "../usePlaylistNavigation";
7
+
8
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
9
+ useNavigation: jest.fn(),
10
+ }));
11
+
12
+ jest.mock(
13
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer",
14
+ () => ({ usePlayer: jest.fn() })
15
+ );
16
+
17
+ jest.mock(
18
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils",
19
+ () => ({
20
+ loadFeedEntry: jest.fn(),
21
+ retrieveFeedUrl: async (entry) => entry?.extensions?.play_next_feed_url,
22
+ retrievePlayPreviousFeedUrl: (entry) =>
23
+ entry?.extensions?.play_previous_feed_url,
24
+ })
25
+ );
26
+
27
+ const useNavigationMock = useNavigation as jest.Mock;
28
+ const loadFeedEntryMock = loadFeedEntry as jest.Mock;
29
+ const usePlayerMock = usePlayer as jest.Mock;
30
+
31
+ const NEXT_URL = "https://feeds/next.json";
32
+ const PREV_URL = "https://feeds/prev.json";
33
+
34
+ const entryWith = (extensions: Record<string, unknown>): ZappEntry =>
35
+ ({ id: "current", type: { value: "audio" }, extensions }) as ZappEntry;
36
+
37
+ /** Seekable VOD by default; pass 0 for a live stream with no seek window. */
38
+ const stateAt = (
39
+ currentTime: number,
40
+ seekableDuration = 600
41
+ ): QuickBrickPlayer.PlayerState =>
42
+ ({ currentTime, seekableDuration }) as QuickBrickPlayer.PlayerState;
43
+
44
+ const NOT_SEEKABLE = 0;
45
+
46
+ const renderPlaylistNavigation = async (
47
+ entry: ZappEntry,
48
+ playerState: QuickBrickPlayer.PlayerState
49
+ ) => {
50
+ const rendered = renderHook(() => usePlaylistNavigation(entry, playerState));
51
+
52
+ await act(async () => {});
53
+
54
+ return rendered;
55
+ };
56
+
57
+ describe("usePlaylistNavigation", () => {
58
+ let replaceTop: jest.Mock;
59
+ let seekTo: jest.Mock;
60
+
61
+ beforeEach(() => {
62
+ replaceTop = jest.fn();
63
+ seekTo = jest.fn();
64
+
65
+ useNavigationMock.mockReturnValue({ replaceTop });
66
+
67
+ usePlayerMock.mockReturnValue({ seekTo });
68
+
69
+ loadFeedEntryMock.mockReset();
70
+ });
71
+
72
+ describe("availability", () => {
73
+ it("reports both directions when both extensions are present", async () => {
74
+ const entry = entryWith({
75
+ play_next_feed_url: NEXT_URL,
76
+ play_previous_feed_url: PREV_URL,
77
+ });
78
+
79
+ const { result } = await renderPlaylistNavigation(entry, stateAt(0));
80
+
81
+ expect(result.current.hasNext).toBe(true);
82
+ expect(result.current.hasPrevious).toBe(true);
83
+ });
84
+
85
+ it("keeps previous available at the head of a chain when the track is seekable", async () => {
86
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
87
+
88
+ const { result } = await renderPlaylistNavigation(entry, stateAt(0));
89
+
90
+ expect(result.current.hasNext).toBe(true);
91
+ expect(result.current.hasPrevious).toBe(true);
92
+ });
93
+
94
+ it("reports no previous only when the track cannot seek either", async () => {
95
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
96
+
97
+ const { result } = await renderPlaylistNavigation(
98
+ entry,
99
+ stateAt(0, NOT_SEEKABLE)
100
+ );
101
+
102
+ expect(result.current.hasNext).toBe(true);
103
+ expect(result.current.hasPrevious).toBe(false);
104
+ });
105
+
106
+ it("reports neither direction for a non-seekable entry with no extensions", async () => {
107
+ const { result } = await renderPlaylistNavigation(
108
+ entryWith({}),
109
+ stateAt(0, NOT_SEEKABLE)
110
+ );
111
+
112
+ expect(result.current.hasNext).toBe(false);
113
+ expect(result.current.hasPrevious).toBe(false);
114
+ });
115
+ });
116
+
117
+ describe("goNext", () => {
118
+ it("plays the entry resolved from the next feed url", async () => {
119
+ const target = { id: "next" } as ZappEntry;
120
+ loadFeedEntryMock.mockResolvedValue(target);
121
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
122
+
123
+ const { result } = await renderPlaylistNavigation(entry, stateAt(0));
124
+
125
+ await act(async () => result.current.goNext());
126
+
127
+ expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
128
+ expect(replaceTop).toHaveBeenCalledWith(target);
129
+ });
130
+
131
+ it("does nothing when there is no next url", async () => {
132
+ const { result } = renderHook(() =>
133
+ usePlaylistNavigation(entryWith({}), stateAt(0))
134
+ );
135
+
136
+ await act(async () => result.current.goNext());
137
+
138
+ expect(loadFeedEntryMock).not.toHaveBeenCalled();
139
+ expect(replaceTop).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it("leaves playback untouched when the feed fails to load", async () => {
143
+ loadFeedEntryMock.mockRejectedValue(new Error("network down"));
144
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
145
+
146
+ const { result } = renderHook(() =>
147
+ usePlaylistNavigation(entry, stateAt(0))
148
+ );
149
+
150
+ await act(async () => result.current.goNext());
151
+
152
+ expect(replaceTop).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
156
+ describe("goPrevious", () => {
157
+ it("restarts the current track when past the threshold", async () => {
158
+ const entry = entryWith({ play_previous_feed_url: PREV_URL });
159
+
160
+ const { result } = renderHook(() =>
161
+ usePlaylistNavigation(entry, stateAt(10))
162
+ );
163
+
164
+ await act(async () => result.current.goPrevious());
165
+
166
+ expect(seekTo).toHaveBeenCalledWith(0);
167
+ expect(replaceTop).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it("plays the previous entry when before the threshold", async () => {
171
+ const target = { id: "prev" } as ZappEntry;
172
+ loadFeedEntryMock.mockResolvedValue(target);
173
+ const entry = entryWith({ play_previous_feed_url: PREV_URL });
174
+
175
+ const { result } = renderHook(() =>
176
+ usePlaylistNavigation(entry, stateAt(1))
177
+ );
178
+
179
+ await act(async () => result.current.goPrevious());
180
+
181
+ expect(seekTo).not.toHaveBeenCalled();
182
+ expect(replaceTop).toHaveBeenCalledWith(target);
183
+ });
184
+
185
+ it("restarts the track when there is no previous entry to move to", async () => {
186
+ const { result } = renderHook(() =>
187
+ usePlaylistNavigation(
188
+ entryWith({ play_next_feed_url: NEXT_URL }),
189
+ stateAt(390)
190
+ )
191
+ );
192
+
193
+ await act(async () => result.current.goPrevious());
194
+
195
+ expect(seekTo).toHaveBeenCalledWith(0);
196
+ expect(replaceTop).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("restarts even before the threshold when there is no previous entry", async () => {
200
+ const { result } = renderHook(() =>
201
+ usePlaylistNavigation(entryWith({}), stateAt(1))
202
+ );
203
+
204
+ await act(async () => result.current.goPrevious());
205
+
206
+ expect(seekTo).toHaveBeenCalledWith(0);
207
+ expect(replaceTop).not.toHaveBeenCalled();
208
+ });
209
+
210
+ // usePlayer, not playerManager.getActivePlayer — the latter is always the
211
+ // local player, so restarting would be lost while casting.
212
+ it("restarts through the player that owns playback, not the local one", async () => {
213
+ const receiverSeekTo = jest.fn();
214
+ usePlayerMock.mockReturnValue({ seekTo: receiverSeekTo });
215
+
216
+ const { result } = renderHook(() =>
217
+ usePlaylistNavigation(
218
+ entryWith({ play_previous_feed_url: PREV_URL }),
219
+ stateAt(10)
220
+ )
221
+ );
222
+
223
+ await act(async () => result.current.goPrevious());
224
+
225
+ expect(receiverSeekTo).toHaveBeenCalledWith(0);
226
+ });
227
+
228
+ it("does nothing when there is neither a previous entry nor a seek window", async () => {
229
+ const { result } = renderHook(() =>
230
+ usePlaylistNavigation(entryWith({}), stateAt(10, NOT_SEEKABLE))
231
+ );
232
+
233
+ await act(async () => result.current.goPrevious());
234
+
235
+ expect(seekTo).not.toHaveBeenCalled();
236
+ expect(replaceTop).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("navigates to the previous entry past the threshold when the stream cannot seek", async () => {
240
+ const target = { id: "prev" } as ZappEntry;
241
+ loadFeedEntryMock.mockResolvedValue(target);
242
+
243
+ const { result } = renderHook(() =>
244
+ usePlaylistNavigation(
245
+ entryWith({ play_previous_feed_url: PREV_URL }),
246
+ stateAt(10, NOT_SEEKABLE)
247
+ )
248
+ );
249
+
250
+ await act(async () => result.current.goPrevious());
251
+
252
+ expect(seekTo).not.toHaveBeenCalled();
253
+ expect(replaceTop).toHaveBeenCalledWith(target);
254
+ });
255
+
256
+ it("restarts past the threshold when the stream is seekable", async () => {
257
+ const { result } = renderHook(() =>
258
+ usePlaylistNavigation(
259
+ entryWith({ play_previous_feed_url: PREV_URL }),
260
+ stateAt(10)
261
+ )
262
+ );
263
+
264
+ await act(async () => result.current.goPrevious());
265
+
266
+ expect(seekTo).toHaveBeenCalledWith(0);
267
+ expect(replaceTop).not.toHaveBeenCalled();
268
+ });
269
+ });
270
+
271
+ describe("double tap guard", () => {
272
+ const bothDirections = {
273
+ play_next_feed_url: NEXT_URL,
274
+ play_previous_feed_url: PREV_URL,
275
+ };
276
+
277
+ /**
278
+ * Makes the next feed request hang until the returned function is called,
279
+ * so a test can fire a second tap while the first is still in flight.
280
+ */
281
+ const deferredFeed = () => {
282
+ const deferred: { complete?: (entry: ZappEntry) => void } = {};
283
+
284
+ loadFeedEntryMock.mockReturnValue(
285
+ new Promise<ZappEntry>((resolve) => {
286
+ deferred.complete = resolve;
287
+ })
288
+ );
289
+
290
+ return (entry: ZappEntry) => deferred.complete(entry);
291
+ };
292
+
293
+ it("ignores a second tap while the first request is still in flight", async () => {
294
+ const complete = deferredFeed();
295
+
296
+ const { result } = await renderPlaylistNavigation(
297
+ entryWith(bothDirections),
298
+ stateAt(0)
299
+ );
300
+
301
+ await act(async () => {
302
+ const first = result.current.goNext();
303
+ const second = result.current.goNext();
304
+
305
+ complete({ id: "next" } as ZappEntry);
306
+
307
+ await Promise.all([first, second]);
308
+ });
309
+
310
+ expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
311
+ expect(replaceTop).toHaveBeenCalledTimes(1);
312
+ });
313
+
314
+ it("stops Previous racing an in-flight Next", async () => {
315
+ const complete = deferredFeed();
316
+
317
+ const { result } = await renderPlaylistNavigation(
318
+ entryWith(bothDirections),
319
+ stateAt(0)
320
+ );
321
+
322
+ await act(async () => {
323
+ const next = result.current.goNext();
324
+ const previous = result.current.goPrevious();
325
+
326
+ complete({ id: "next" } as ZappEntry);
327
+
328
+ await Promise.all([next, previous]);
329
+ });
330
+
331
+ expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
332
+
333
+ expect(loadFeedEntryMock).toHaveBeenCalledWith(
334
+ NEXT_URL,
335
+ expect.anything()
336
+ );
337
+ });
338
+
339
+ it("allows a new navigation once the previous one has completed", async () => {
340
+ loadFeedEntryMock.mockResolvedValue({ id: "next" } as ZappEntry);
341
+
342
+ const { result } = await renderPlaylistNavigation(
343
+ entryWith(bothDirections),
344
+ stateAt(0)
345
+ );
346
+
347
+ await act(async () => result.current.goNext());
348
+ await act(async () => result.current.goNext());
349
+
350
+ expect(loadFeedEntryMock).toHaveBeenCalledTimes(2);
351
+ expect(replaceTop).toHaveBeenCalledTimes(2);
352
+ });
353
+
354
+ it("releases the guard after a failed request so the control can retry", async () => {
355
+ loadFeedEntryMock
356
+ .mockRejectedValueOnce(new Error("network down"))
357
+ .mockResolvedValueOnce({ id: "next" } as ZappEntry);
358
+
359
+ const { result } = await renderPlaylistNavigation(
360
+ entryWith(bothDirections),
361
+ stateAt(0)
362
+ );
363
+
364
+ await act(async () => result.current.goNext());
365
+
366
+ expect(replaceTop).not.toHaveBeenCalled();
367
+
368
+ await act(async () => result.current.goNext());
369
+
370
+ expect(replaceTop).toHaveBeenCalledTimes(1);
371
+ });
372
+
373
+ it("does not guard the Previous restart, which is local and instant", async () => {
374
+ loadFeedEntryMock.mockResolvedValue({ id: "prev" } as ZappEntry);
375
+
376
+ const { result } = renderHook(() =>
377
+ usePlaylistNavigation(entryWith(bothDirections), stateAt(10))
378
+ );
379
+
380
+ await act(async () => result.current.goPrevious());
381
+ await act(async () => result.current.goPrevious());
382
+
383
+ expect(seekTo).toHaveBeenCalledTimes(2);
384
+ expect(loadFeedEntryMock).not.toHaveBeenCalled();
385
+ });
386
+ });
387
+ });
@@ -0,0 +1,294 @@
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
+ function playbackSpeedEntryState(speed: {
79
+ asset?: string;
80
+ value?: number;
81
+ }): CellActionEntryState {
82
+ const config = getPlayerConfig();
83
+ const assetKey = speed?.asset || formatSpeed(speed?.value ?? 1);
84
+
85
+ return {
86
+ state: 0,
87
+ label: config.playback_speed_title || "Playback Speed",
88
+ // Manifest keys are speed_0_8 / speed_1 / … (same icons as the transport row).
89
+ // There is no playback_speed uploader.
90
+ asset: config[assetKey] || config.speed_1 || "",
91
+ };
92
+ }
93
+
94
+ export async function openPlaybackSpeedAction(
95
+ speedController: SpeedController
96
+ ): Promise<ActionResult> {
97
+ const config = getPlayerConfig();
98
+ const current = speedController.currentSpeed;
99
+
100
+ const items = speedOptions.map((option) =>
101
+ optionToEntry(
102
+ String(option.value),
103
+ option.label,
104
+ PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
105
+ { value: option.value }
106
+ )
107
+ );
108
+
109
+ return actionExecutor.handleAction({
110
+ type: "openBottomSheet",
111
+ options: {
112
+ replace: true,
113
+ header: {
114
+ title: config.playback_speed_title || "Playback Speed",
115
+ },
116
+ content: {
117
+ role: "collection_selector",
118
+ behavior: {
119
+ selectMode: "single",
120
+ currentSelection: [String(current.value)],
121
+ },
122
+ items,
123
+ },
124
+ },
125
+ } as ActionType);
126
+ }
127
+
128
+ export async function openSleepTimerAction(
129
+ sleepController: SleepTimerController
130
+ ): Promise<ActionResult> {
131
+ const config = getPlayerConfig();
132
+ const sleepState = sleepController.currentState;
133
+ const timerRemaining = remainingTimerDate(sleepState.timerDate);
134
+
135
+ const items = getSleepTimerOptions(
136
+ config.playback_sleep_off_label || "Off",
137
+ timerRemaining
138
+ )
139
+ .filter(Boolean)
140
+ .map((option) =>
141
+ optionToEntry(
142
+ String(option.value),
143
+ option.label,
144
+ PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
145
+ { value: option.value, label: option.label }
146
+ )
147
+ );
148
+
149
+ return actionExecutor.handleAction({
150
+ type: "openBottomSheet",
151
+ options: {
152
+ replace: true,
153
+ header: {
154
+ title: config.playback_sleep_title || "Sleep Timer",
155
+ },
156
+ content: {
157
+ role: "collection_selector",
158
+ behavior: {
159
+ selectMode: "single",
160
+ currentSelection: timerRemaining
161
+ ? [String(sleepState.timer.value)]
162
+ : [],
163
+ },
164
+ items,
165
+ },
166
+ },
167
+ } as ActionType);
168
+ }
169
+
170
+ export async function setPlaybackSpeedAction(
171
+ speedController: SpeedController,
172
+ action: ActionType
173
+ ): Promise<ActionResult> {
174
+ const value = toNumber(action.options?.value);
175
+
176
+ if (!Number.isFinite(value)) {
177
+ return ActionResult.Error;
178
+ }
179
+
180
+ await speedController.setPlaybackSpeed({
181
+ asset: formatSpeed(value),
182
+ value,
183
+ });
184
+
185
+ dismissOpenedSheet(action);
186
+
187
+ return ActionResult.Success;
188
+ }
189
+
190
+ export async function setSleepTimerAction(
191
+ sleepController: SleepTimerController,
192
+ action: ActionType
193
+ ): Promise<ActionResult> {
194
+ const value = toNumber(action.options?.value);
195
+
196
+ if (!Number.isFinite(value)) {
197
+ return ActionResult.Error;
198
+ }
199
+
200
+ await sleepController.setSleepTimer({
201
+ label: action.options?.label || String(value),
202
+ value,
203
+ });
204
+
205
+ dismissOpenedSheet(action);
206
+
207
+ return ActionResult.Success;
208
+ }
209
+
210
+ export function registerPlayerActions(options: {
211
+ speedController?: SpeedController;
212
+ sleepController?: SleepTimerController;
213
+ }): () => void {
214
+ const unregisters: Array<() => void> = [];
215
+
216
+ if (options.speedController) {
217
+ const speedController = options.speedController;
218
+
219
+ const speedHandlers: Array<[string, ActionHandler]> = [
220
+ [
221
+ PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
222
+ () => openPlaybackSpeedAction(speedController),
223
+ ],
224
+ [
225
+ PLAYER_ACTION_TYPES.SET_PLAYBACK_SPEED,
226
+ (action) => setPlaybackSpeedAction(speedController, action),
227
+ ],
228
+ ];
229
+
230
+ speedHandlers.forEach(([type, handler]) => {
231
+ actionExecutor.unregisterAction(type);
232
+ unregisters.push(actionExecutor.registerAction(type, handler));
233
+ });
234
+
235
+ unregisters.push(
236
+ uiActionsRegistry.registerAction(
237
+ PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
238
+ {
239
+ initialEntryState: () =>
240
+ playbackSpeedEntryState(speedController.currentSpeed),
241
+ observeEntryState: () =>
242
+ speedController.playbackSpeed$.pipe(
243
+ map((speed) => playbackSpeedEntryState(speed))
244
+ ),
245
+ invokeAction: async () => {
246
+ await actionExecutor.handleAction({
247
+ type: PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
248
+ });
249
+ },
250
+ }
251
+ )
252
+ );
253
+ }
254
+
255
+ if (options.sleepController) {
256
+ const sleepController = options.sleepController;
257
+
258
+ const sleepHandlers: Array<[string, ActionHandler]> = [
259
+ [
260
+ PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
261
+ () => openSleepTimerAction(sleepController),
262
+ ],
263
+ [
264
+ PLAYER_ACTION_TYPES.SET_SLEEP_TIMER,
265
+ (action) => setSleepTimerAction(sleepController, action),
266
+ ],
267
+ ];
268
+
269
+ sleepHandlers.forEach(([type, handler]) => {
270
+ actionExecutor.unregisterAction(type);
271
+ unregisters.push(actionExecutor.registerAction(type, handler));
272
+ });
273
+
274
+ unregisters.push(
275
+ uiActionsRegistry.registerAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER, {
276
+ initialEntryState: () =>
277
+ sleepTimerEntryState(sleepController.currentState),
278
+ observeEntryState: () =>
279
+ sleepController.sleepTimerState$.pipe(
280
+ map((sleepState) => sleepTimerEntryState(sleepState))
281
+ ),
282
+ invokeAction: async () => {
283
+ await actionExecutor.handleAction({
284
+ type: PLAYER_ACTION_TYPES.OPEN_SLEEP_TIMER,
285
+ });
286
+ },
287
+ })
288
+ );
289
+ }
290
+
291
+ return () => {
292
+ unregisters.forEach((unregister) => unregister());
293
+ };
294
+ }