@applicaster/quick-brick-player 16.0.0-rc.9 → 16.0.0-rc.91
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 +87 -0
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +34 -14
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +40 -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/Layout/TransportInfo/__tests__/index.test.tsx +257 -0
- package/src/Player/AudioLayer/Layout/TransportInfo/index.tsx +69 -8
- package/src/Player/AudioLayer/PlaylistNavigationController.ts +381 -0
- 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__/PlaylistNavigationController.test.ts +529 -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 +369 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
- package/src/Player/AudioLayer/playerActions.ts +294 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +76 -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/__tests__/getNativeProps.test.ts +115 -0
- package/src/Player/__tests__/onTracksChanged.test.ts +192 -0
- package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
- package/src/Player/index.tsx +147 -40
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
|
|
2
|
+
import { findPluginByIdentifier } from "@applicaster/zapp-react-native-utils/pluginUtils";
|
|
3
|
+
import { loadFeedEntry } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
|
|
4
|
+
import {
|
|
5
|
+
PlaylistNavigationController,
|
|
6
|
+
RESTART_THRESHOLD_SECONDS,
|
|
7
|
+
} from "../PlaylistNavigationController";
|
|
8
|
+
|
|
9
|
+
jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
|
|
10
|
+
appStore: {
|
|
11
|
+
get: jest.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock("@applicaster/zapp-react-native-utils/pluginUtils", () => ({
|
|
16
|
+
findPluginByIdentifier: jest.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
jest.mock(
|
|
20
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils",
|
|
21
|
+
() => ({
|
|
22
|
+
loadFeedEntry: jest.fn(),
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const appStoreMock = appStore as unknown as { get: jest.Mock };
|
|
27
|
+
const findPluginByIdentifierMock = findPluginByIdentifier as jest.Mock;
|
|
28
|
+
const loadFeedEntryMock = loadFeedEntry as jest.Mock;
|
|
29
|
+
|
|
30
|
+
const NEXT_URL = "https://feeds/next.json";
|
|
31
|
+
const PREV_URL = "https://feeds/prev.json";
|
|
32
|
+
|
|
33
|
+
const entryWith = (extensions: Record<string, unknown>): ZappEntry =>
|
|
34
|
+
({
|
|
35
|
+
id: "current",
|
|
36
|
+
title: "Current Track",
|
|
37
|
+
type: { value: "audio" },
|
|
38
|
+
extensions,
|
|
39
|
+
}) as ZappEntry;
|
|
40
|
+
|
|
41
|
+
const stateAt = (
|
|
42
|
+
currentTime: number,
|
|
43
|
+
seekableDuration = 600
|
|
44
|
+
): QuickBrickPlayer.PlayerState =>
|
|
45
|
+
({ currentTime, seekableDuration }) as QuickBrickPlayer.PlayerState;
|
|
46
|
+
|
|
47
|
+
const NOT_SEEKABLE = 0;
|
|
48
|
+
|
|
49
|
+
describe("PlaylistNavigationController", () => {
|
|
50
|
+
let player: { seekTo: jest.Mock };
|
|
51
|
+
let navigator: { replaceTop: jest.Mock };
|
|
52
|
+
let mockQueueManager: any;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
player = { seekTo: jest.fn() };
|
|
56
|
+
navigator = { replaceTop: jest.fn() };
|
|
57
|
+
loadFeedEntryMock.mockReset();
|
|
58
|
+
|
|
59
|
+
mockQueueManager = {
|
|
60
|
+
isInitialized: jest.fn().mockReturnValue(false),
|
|
61
|
+
instance: {
|
|
62
|
+
getUpcomingQueue: jest.fn().mockResolvedValue([]),
|
|
63
|
+
getHistoryQueue: jest.fn().mockResolvedValue([]),
|
|
64
|
+
addListener: jest.fn().mockReturnValue(jest.fn()),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
appStoreMock.get.mockReturnValue([]);
|
|
69
|
+
findPluginByIdentifierMock.mockReturnValue(null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("static URL feeds (no queue)", () => {
|
|
73
|
+
it("reports both directions available when both static 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 controller = new PlaylistNavigationController({
|
|
80
|
+
entry,
|
|
81
|
+
player,
|
|
82
|
+
navigator,
|
|
83
|
+
playerState: stateAt(0),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await controller.recalculate();
|
|
87
|
+
|
|
88
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
89
|
+
expect(controller.hasPrevious$.getValue()).toBe(true);
|
|
90
|
+
|
|
91
|
+
controller.destroy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps previous available at head of chain when track is seekable", async () => {
|
|
95
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
96
|
+
|
|
97
|
+
const controller = new PlaylistNavigationController({
|
|
98
|
+
entry,
|
|
99
|
+
player,
|
|
100
|
+
navigator,
|
|
101
|
+
playerState: stateAt(0),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await controller.recalculate();
|
|
105
|
+
|
|
106
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
107
|
+
expect(controller.hasPrevious$.getValue()).toBe(true);
|
|
108
|
+
|
|
109
|
+
controller.destroy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reports no previous when track is not seekable and has no previous extension", async () => {
|
|
113
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
114
|
+
|
|
115
|
+
const controller = new PlaylistNavigationController({
|
|
116
|
+
entry,
|
|
117
|
+
player,
|
|
118
|
+
navigator,
|
|
119
|
+
playerState: stateAt(0, NOT_SEEKABLE),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await controller.recalculate();
|
|
123
|
+
|
|
124
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
125
|
+
expect(controller.hasPrevious$.getValue()).toBe(false);
|
|
126
|
+
|
|
127
|
+
controller.destroy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("fetches and plays next remote feed entry on goNext", async () => {
|
|
131
|
+
const target = { id: "next-track", title: "Next" } as ZappEntry;
|
|
132
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
133
|
+
|
|
134
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
135
|
+
|
|
136
|
+
const controller = new PlaylistNavigationController({
|
|
137
|
+
entry,
|
|
138
|
+
player,
|
|
139
|
+
navigator,
|
|
140
|
+
playerState: stateAt(0),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await controller.goNext();
|
|
144
|
+
|
|
145
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
|
|
146
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(target);
|
|
147
|
+
|
|
148
|
+
controller.destroy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("restarts playback when currentTime > RESTART_THRESHOLD_SECONDS", async () => {
|
|
152
|
+
const entry = entryWith({ play_previous_feed_url: PREV_URL });
|
|
153
|
+
|
|
154
|
+
const controller = new PlaylistNavigationController({
|
|
155
|
+
entry,
|
|
156
|
+
player,
|
|
157
|
+
navigator,
|
|
158
|
+
playerState: stateAt(RESTART_THRESHOLD_SECONDS + 1),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await controller.goPrevious();
|
|
162
|
+
|
|
163
|
+
expect(player.seekTo).toHaveBeenCalledWith(0);
|
|
164
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
165
|
+
expect(navigator.replaceTop).not.toHaveBeenCalled();
|
|
166
|
+
|
|
167
|
+
controller.destroy();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("fetches and plays previous remote feed entry when currentTime <= RESTART_THRESHOLD_SECONDS", async () => {
|
|
171
|
+
const target = { id: "prev-track", title: "Previous" } as ZappEntry;
|
|
172
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
173
|
+
|
|
174
|
+
const entry = entryWith({ play_previous_feed_url: PREV_URL });
|
|
175
|
+
|
|
176
|
+
const controller = new PlaylistNavigationController({
|
|
177
|
+
entry,
|
|
178
|
+
player,
|
|
179
|
+
navigator,
|
|
180
|
+
playerState: stateAt(1),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await controller.goPrevious();
|
|
184
|
+
|
|
185
|
+
expect(player.seekTo).not.toHaveBeenCalled();
|
|
186
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(PREV_URL, entry);
|
|
187
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(target);
|
|
188
|
+
|
|
189
|
+
controller.destroy();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("queue navigation (QueueManager active)", () => {
|
|
194
|
+
let queueListener: () => void;
|
|
195
|
+
|
|
196
|
+
beforeEach(() => {
|
|
197
|
+
mockQueueManager.isInitialized.mockReturnValue(true);
|
|
198
|
+
|
|
199
|
+
mockQueueManager.instance.addListener.mockImplementation(
|
|
200
|
+
(cb: () => void) => {
|
|
201
|
+
queueListener = cb;
|
|
202
|
+
|
|
203
|
+
return jest.fn();
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
findPluginByIdentifierMock.mockReturnValue({
|
|
208
|
+
module: { QueueManager: mockQueueManager },
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("reports availability based on upcoming and history queues", async () => {
|
|
213
|
+
const upcomingItem = { id: "next_in_queue" } as ZappEntry;
|
|
214
|
+
const historyItem = { id: "prev_in_queue" } as ZappEntry;
|
|
215
|
+
|
|
216
|
+
mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([
|
|
217
|
+
upcomingItem,
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
mockQueueManager.instance.getHistoryQueue.mockResolvedValue([
|
|
221
|
+
historyItem,
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
const controller = new PlaylistNavigationController({
|
|
225
|
+
entry: entryWith({}),
|
|
226
|
+
player,
|
|
227
|
+
navigator,
|
|
228
|
+
playerState: stateAt(0, NOT_SEEKABLE),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await controller.recalculate();
|
|
232
|
+
|
|
233
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
234
|
+
expect(controller.hasPrevious$.getValue()).toBe(true);
|
|
235
|
+
|
|
236
|
+
controller.destroy();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("navigates directly to upcoming item on goNext without Pipes roundtrip", async () => {
|
|
240
|
+
const nextQueueItem = {
|
|
241
|
+
id: "queue_item_2",
|
|
242
|
+
title: "Second Track",
|
|
243
|
+
} as ZappEntry;
|
|
244
|
+
|
|
245
|
+
mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([
|
|
246
|
+
nextQueueItem,
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
const controller = new PlaylistNavigationController({
|
|
250
|
+
entry: entryWith({}),
|
|
251
|
+
player,
|
|
252
|
+
navigator,
|
|
253
|
+
playerState: stateAt(0),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await controller.goNext();
|
|
257
|
+
|
|
258
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
259
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(nextQueueItem);
|
|
260
|
+
|
|
261
|
+
controller.destroy();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("navigates directly to history item on goPrevious without Pipes roundtrip when <= 3s", async () => {
|
|
265
|
+
const prevQueueItem = {
|
|
266
|
+
id: "queue_item_0",
|
|
267
|
+
title: "Zero Track",
|
|
268
|
+
} as ZappEntry;
|
|
269
|
+
|
|
270
|
+
mockQueueManager.instance.getHistoryQueue.mockResolvedValue([
|
|
271
|
+
prevQueueItem,
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
const controller = new PlaylistNavigationController({
|
|
275
|
+
entry: entryWith({}),
|
|
276
|
+
player,
|
|
277
|
+
navigator,
|
|
278
|
+
playerState: stateAt(1),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await controller.goPrevious();
|
|
282
|
+
|
|
283
|
+
expect(player.seekTo).not.toHaveBeenCalled();
|
|
284
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
285
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(prevQueueItem);
|
|
286
|
+
|
|
287
|
+
controller.destroy();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("recalculates streams when QueueManager listener fires", async () => {
|
|
291
|
+
mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([]);
|
|
292
|
+
mockQueueManager.instance.getHistoryQueue.mockResolvedValue([]);
|
|
293
|
+
|
|
294
|
+
const controller = new PlaylistNavigationController({
|
|
295
|
+
entry: entryWith({}),
|
|
296
|
+
player,
|
|
297
|
+
navigator,
|
|
298
|
+
playerState: stateAt(0, NOT_SEEKABLE),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await controller.recalculate();
|
|
302
|
+
expect(controller.hasNext$.getValue()).toBe(false);
|
|
303
|
+
|
|
304
|
+
// Simulate item enqueued
|
|
305
|
+
mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([
|
|
306
|
+
{ id: "new_item" },
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
queueListener();
|
|
310
|
+
|
|
311
|
+
// Wait a tick for async recalculate
|
|
312
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
313
|
+
|
|
314
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
315
|
+
|
|
316
|
+
controller.destroy();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("prevents duplicate navigation calls while in-flight", async () => {
|
|
320
|
+
const target = { id: "next-track" } as ZappEntry;
|
|
321
|
+
let resolvePromise: (value: ZappEntry) => void;
|
|
322
|
+
|
|
323
|
+
loadFeedEntryMock.mockReturnValue(
|
|
324
|
+
new Promise((resolve) => {
|
|
325
|
+
resolvePromise = resolve;
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
330
|
+
|
|
331
|
+
const controller = new PlaylistNavigationController({
|
|
332
|
+
entry,
|
|
333
|
+
player,
|
|
334
|
+
navigator,
|
|
335
|
+
playerState: stateAt(0),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// First tap starts flight
|
|
339
|
+
const firstTap = controller.goNext();
|
|
340
|
+
// Second tap while in-flight
|
|
341
|
+
const secondTap = controller.goNext();
|
|
342
|
+
|
|
343
|
+
resolvePromise!(target);
|
|
344
|
+
await Promise.all([firstTap, secondTap]);
|
|
345
|
+
|
|
346
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
|
|
347
|
+
expect(navigator.replaceTop).toHaveBeenCalledTimes(1);
|
|
348
|
+
|
|
349
|
+
controller.destroy();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("prevents duplicate goPrevious calls while in-flight", async () => {
|
|
353
|
+
const target = { id: "prev-track" } as ZappEntry;
|
|
354
|
+
let resolvePromise: (value: ZappEntry) => void;
|
|
355
|
+
|
|
356
|
+
loadFeedEntryMock.mockReturnValue(
|
|
357
|
+
new Promise((resolve) => {
|
|
358
|
+
resolvePromise = resolve;
|
|
359
|
+
})
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const entry = entryWith({ play_previous_feed_url: PREV_URL });
|
|
363
|
+
|
|
364
|
+
const controller = new PlaylistNavigationController({
|
|
365
|
+
entry,
|
|
366
|
+
player,
|
|
367
|
+
navigator,
|
|
368
|
+
playerState: stateAt(1),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const firstTap = controller.goPrevious();
|
|
372
|
+
const secondTap = controller.goPrevious();
|
|
373
|
+
|
|
374
|
+
resolvePromise!(target);
|
|
375
|
+
await Promise.all([firstTap, secondTap]);
|
|
376
|
+
|
|
377
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
|
|
378
|
+
expect(navigator.replaceTop).toHaveBeenCalledTimes(1);
|
|
379
|
+
|
|
380
|
+
controller.destroy();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("prioritizes static feed URLs when entry is not from queue", async () => {
|
|
384
|
+
const queueNextItem = { id: "queue_next" } as ZappEntry;
|
|
385
|
+
const queuePrevItem = { id: "queue_prev" } as ZappEntry;
|
|
386
|
+
const staticNextTarget = { id: "static_next" } as ZappEntry;
|
|
387
|
+
const staticPrevTarget = { id: "static_prev" } as ZappEntry;
|
|
388
|
+
|
|
389
|
+
mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([
|
|
390
|
+
queueNextItem,
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
mockQueueManager.instance.getHistoryQueue.mockResolvedValue([
|
|
394
|
+
queuePrevItem,
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
loadFeedEntryMock.mockImplementation((url) =>
|
|
398
|
+
Promise.resolve(url === NEXT_URL ? staticNextTarget : staticPrevTarget)
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const entry = entryWith({
|
|
402
|
+
play_next_feed_url: NEXT_URL,
|
|
403
|
+
play_previous_feed_url: PREV_URL,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const controller = new PlaylistNavigationController({
|
|
407
|
+
entry,
|
|
408
|
+
player,
|
|
409
|
+
navigator,
|
|
410
|
+
playerState: stateAt(1),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await controller.goNext();
|
|
414
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
|
|
415
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(staticNextTarget);
|
|
416
|
+
|
|
417
|
+
await controller.goPrevious();
|
|
418
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(PREV_URL, entry);
|
|
419
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(staticPrevTarget);
|
|
420
|
+
|
|
421
|
+
controller.destroy();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("prioritizes queue navigation when entry is from queue (has queueItemId)", async () => {
|
|
425
|
+
const queueNextItem = { id: "queue_next" } as ZappEntry;
|
|
426
|
+
const queuePrevItem = { id: "queue_prev" } as ZappEntry;
|
|
427
|
+
|
|
428
|
+
mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([
|
|
429
|
+
queueNextItem,
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
mockQueueManager.instance.getHistoryQueue.mockResolvedValue([
|
|
433
|
+
queuePrevItem,
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
const entry = entryWith({
|
|
437
|
+
queueItemId: "current_queue_slot",
|
|
438
|
+
play_next_feed_url: NEXT_URL,
|
|
439
|
+
play_previous_feed_url: PREV_URL,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const controller = new PlaylistNavigationController({
|
|
443
|
+
entry,
|
|
444
|
+
player,
|
|
445
|
+
navigator,
|
|
446
|
+
playerState: stateAt(1),
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await controller.goNext();
|
|
450
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
451
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(queueNextItem);
|
|
452
|
+
|
|
453
|
+
await controller.goPrevious();
|
|
454
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(queuePrevItem);
|
|
455
|
+
|
|
456
|
+
controller.destroy();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("ignores stale recalculate resolution when newer recalculate was called", async () => {
|
|
460
|
+
let resolveFirst: (val: any) => void;
|
|
461
|
+
|
|
462
|
+
const firstPromise = new Promise<{
|
|
463
|
+
upcoming: ZappEntry[];
|
|
464
|
+
history: ZappEntry[];
|
|
465
|
+
}>((resolve) => {
|
|
466
|
+
resolveFirst = resolve;
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const entry = entryWith({});
|
|
470
|
+
|
|
471
|
+
const controller = new PlaylistNavigationController({
|
|
472
|
+
entry,
|
|
473
|
+
player,
|
|
474
|
+
navigator,
|
|
475
|
+
playerState: stateAt(0, NOT_SEEKABLE),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Override getUpcomingQueue to control resolution order
|
|
479
|
+
mockQueueManager.instance.getUpcomingQueue
|
|
480
|
+
.mockReturnValueOnce(firstPromise.then((d) => d.upcoming))
|
|
481
|
+
.mockResolvedValueOnce([{ id: "second_call_item" }]);
|
|
482
|
+
|
|
483
|
+
// First recalculate (will hang until resolveFirst)
|
|
484
|
+
const recalc1 = controller.recalculate();
|
|
485
|
+
|
|
486
|
+
// Second recalculate resolves immediately with hasNext = true
|
|
487
|
+
const recalc2 = controller.recalculate();
|
|
488
|
+
await recalc2;
|
|
489
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
490
|
+
|
|
491
|
+
// Now resolve first with empty array; should NOT overwrite hasNext$ to false
|
|
492
|
+
resolveFirst!({ upcoming: [], history: [] });
|
|
493
|
+
await recalc1;
|
|
494
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
495
|
+
|
|
496
|
+
controller.destroy();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("does not navigate if controller is destroyed while resolving feed entry", async () => {
|
|
500
|
+
let resolvePromise: (value: ZappEntry) => void;
|
|
501
|
+
|
|
502
|
+
loadFeedEntryMock.mockReturnValue(
|
|
503
|
+
new Promise((resolve) => {
|
|
504
|
+
resolvePromise = resolve;
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const entry = entryWith({
|
|
509
|
+
play_next_feed_url: NEXT_URL,
|
|
510
|
+
play_previous_feed_url: PREV_URL,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const controller = new PlaylistNavigationController({
|
|
514
|
+
entry,
|
|
515
|
+
player,
|
|
516
|
+
navigator,
|
|
517
|
+
playerState: stateAt(1),
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const goNextPromise = controller.goNext();
|
|
521
|
+
controller.destroy();
|
|
522
|
+
|
|
523
|
+
resolvePromise!({ id: "target" } as ZappEntry);
|
|
524
|
+
await goNextPromise;
|
|
525
|
+
|
|
526
|
+
expect(navigator.replaceTop).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
});
|
|
@@ -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
|
+
});
|