@applicaster/quick-brick-player 16.0.0-rc.10 → 16.0.0-rc.101
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 +354 -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 +528 -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 +539 -0
- package/src/Player/AudioLayer/playerActions.ts +294 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +148 -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 +168 -40
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
|
@@ -0,0 +1,539 @@
|
|
|
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
|
+
import { RemoteCommand } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getTransportControls,
|
|
9
|
+
usePlaylistNavigation,
|
|
10
|
+
} from "../usePlaylistNavigation";
|
|
11
|
+
|
|
12
|
+
jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
|
|
13
|
+
useNavigation: jest.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock(
|
|
17
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer",
|
|
18
|
+
() => ({ usePlayer: jest.fn() })
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
jest.mock(
|
|
22
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils",
|
|
23
|
+
() => ({
|
|
24
|
+
loadFeedEntry: jest.fn(),
|
|
25
|
+
retrieveFeedUrl: async (entry) => entry?.extensions?.play_next_feed_url,
|
|
26
|
+
retrievePlayPreviousFeedUrl: (entry) =>
|
|
27
|
+
entry?.extensions?.play_previous_feed_url,
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
jest.mock(
|
|
32
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand",
|
|
33
|
+
() => ({
|
|
34
|
+
PlayerNativeCommandTypes: { setPlayerControls: "setPlayerControls" },
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const useNavigationMock = useNavigation as jest.Mock;
|
|
39
|
+
const loadFeedEntryMock = loadFeedEntry as jest.Mock;
|
|
40
|
+
const usePlayerMock = usePlayer as jest.Mock;
|
|
41
|
+
|
|
42
|
+
const NEXT_URL = "https://feeds/next.json";
|
|
43
|
+
const PREV_URL = "https://feeds/prev.json";
|
|
44
|
+
|
|
45
|
+
const entryWith = (extensions: Record<string, unknown>): ZappEntry =>
|
|
46
|
+
({ id: "current", type: { value: "audio" }, extensions }) as ZappEntry;
|
|
47
|
+
|
|
48
|
+
/** Seekable VOD by default; pass 0 for a live stream with no seek window. */
|
|
49
|
+
const stateAt = (
|
|
50
|
+
currentTime: number,
|
|
51
|
+
seekableDuration = 600
|
|
52
|
+
): QuickBrickPlayer.PlayerState =>
|
|
53
|
+
({ currentTime, seekableDuration }) as QuickBrickPlayer.PlayerState;
|
|
54
|
+
|
|
55
|
+
const NOT_SEEKABLE = 0;
|
|
56
|
+
|
|
57
|
+
const renderPlaylistNavigation = async (
|
|
58
|
+
entry: ZappEntry,
|
|
59
|
+
playerState: QuickBrickPlayer.PlayerState
|
|
60
|
+
) => {
|
|
61
|
+
const rendered = renderHook(() => usePlaylistNavigation(entry, playerState));
|
|
62
|
+
|
|
63
|
+
await act(async () => {});
|
|
64
|
+
|
|
65
|
+
return rendered;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("usePlaylistNavigation", () => {
|
|
69
|
+
let replaceTop: jest.Mock;
|
|
70
|
+
let seekTo: jest.Mock;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
replaceTop = jest.fn();
|
|
74
|
+
seekTo = jest.fn();
|
|
75
|
+
|
|
76
|
+
useNavigationMock.mockReturnValue({ replaceTop });
|
|
77
|
+
|
|
78
|
+
usePlayerMock.mockReturnValue({ seekTo });
|
|
79
|
+
|
|
80
|
+
loadFeedEntryMock.mockReset();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("availability", () => {
|
|
84
|
+
it("reports both directions when both extensions are present", async () => {
|
|
85
|
+
const entry = entryWith({
|
|
86
|
+
play_next_feed_url: NEXT_URL,
|
|
87
|
+
play_previous_feed_url: PREV_URL,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const { result } = await renderPlaylistNavigation(entry, stateAt(0));
|
|
91
|
+
|
|
92
|
+
expect(result.current.hasNext).toBe(true);
|
|
93
|
+
expect(result.current.hasPrevious).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps previous available at the head of a chain when the track is seekable", async () => {
|
|
97
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
98
|
+
|
|
99
|
+
const { result } = await renderPlaylistNavigation(entry, stateAt(0));
|
|
100
|
+
|
|
101
|
+
expect(result.current.hasNext).toBe(true);
|
|
102
|
+
expect(result.current.hasPrevious).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reports no previous only when the track cannot seek either", async () => {
|
|
106
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
107
|
+
|
|
108
|
+
const { result } = await renderPlaylistNavigation(
|
|
109
|
+
entry,
|
|
110
|
+
stateAt(0, NOT_SEEKABLE)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(result.current.hasNext).toBe(true);
|
|
114
|
+
expect(result.current.hasPrevious).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("reports neither direction for a non-seekable entry with no extensions", async () => {
|
|
118
|
+
const { result } = await renderPlaylistNavigation(
|
|
119
|
+
entryWith({}),
|
|
120
|
+
stateAt(0, NOT_SEEKABLE)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.current.hasNext).toBe(false);
|
|
124
|
+
expect(result.current.hasPrevious).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("goNext", () => {
|
|
129
|
+
it("plays the entry resolved from the next feed url", async () => {
|
|
130
|
+
const target = { id: "next" } as ZappEntry;
|
|
131
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
132
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
133
|
+
|
|
134
|
+
const { result } = await renderPlaylistNavigation(entry, stateAt(0));
|
|
135
|
+
|
|
136
|
+
await act(async () => result.current.goNext());
|
|
137
|
+
|
|
138
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
|
|
139
|
+
expect(replaceTop).toHaveBeenCalledWith(target);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does nothing when there is no next url", async () => {
|
|
143
|
+
const { result } = renderHook(() =>
|
|
144
|
+
usePlaylistNavigation(entryWith({}), stateAt(0))
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await act(async () => result.current.goNext());
|
|
148
|
+
|
|
149
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
150
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("leaves playback untouched when the feed fails to load", async () => {
|
|
154
|
+
loadFeedEntryMock.mockRejectedValue(new Error("network down"));
|
|
155
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
156
|
+
|
|
157
|
+
const { result } = renderHook(() =>
|
|
158
|
+
usePlaylistNavigation(entry, stateAt(0))
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await act(async () => result.current.goNext());
|
|
162
|
+
|
|
163
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("goPrevious", () => {
|
|
168
|
+
it("restarts the current track when past the threshold", async () => {
|
|
169
|
+
const entry = entryWith({ play_previous_feed_url: PREV_URL });
|
|
170
|
+
|
|
171
|
+
const { result } = renderHook(() =>
|
|
172
|
+
usePlaylistNavigation(entry, stateAt(10))
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await act(async () => result.current.goPrevious());
|
|
176
|
+
|
|
177
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
178
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("plays the previous entry when before the threshold", async () => {
|
|
182
|
+
const target = { id: "prev" } as ZappEntry;
|
|
183
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
184
|
+
const entry = entryWith({ play_previous_feed_url: PREV_URL });
|
|
185
|
+
|
|
186
|
+
const { result } = renderHook(() =>
|
|
187
|
+
usePlaylistNavigation(entry, stateAt(1))
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await act(async () => result.current.goPrevious());
|
|
191
|
+
|
|
192
|
+
expect(seekTo).not.toHaveBeenCalled();
|
|
193
|
+
expect(replaceTop).toHaveBeenCalledWith(target);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("restarts the track when there is no previous entry to move to", async () => {
|
|
197
|
+
const { result } = renderHook(() =>
|
|
198
|
+
usePlaylistNavigation(
|
|
199
|
+
entryWith({ play_next_feed_url: NEXT_URL }),
|
|
200
|
+
stateAt(390)
|
|
201
|
+
)
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await act(async () => result.current.goPrevious());
|
|
205
|
+
|
|
206
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
207
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("restarts even before the threshold when there is no previous entry", async () => {
|
|
211
|
+
const { result } = renderHook(() =>
|
|
212
|
+
usePlaylistNavigation(entryWith({}), stateAt(1))
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await act(async () => result.current.goPrevious());
|
|
216
|
+
|
|
217
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
218
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// usePlayer, not playerManager.getActivePlayer — the latter is always the
|
|
222
|
+
// local player, so restarting would be lost while casting.
|
|
223
|
+
it("restarts through the player that owns playback, not the local one", async () => {
|
|
224
|
+
const receiverSeekTo = jest.fn();
|
|
225
|
+
usePlayerMock.mockReturnValue({ seekTo: receiverSeekTo });
|
|
226
|
+
|
|
227
|
+
const { result } = renderHook(() =>
|
|
228
|
+
usePlaylistNavigation(
|
|
229
|
+
entryWith({ play_previous_feed_url: PREV_URL }),
|
|
230
|
+
stateAt(10)
|
|
231
|
+
)
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
await act(async () => result.current.goPrevious());
|
|
235
|
+
|
|
236
|
+
expect(receiverSeekTo).toHaveBeenCalledWith(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("does nothing when there is neither a previous entry nor a seek window", async () => {
|
|
240
|
+
const { result } = renderHook(() =>
|
|
241
|
+
usePlaylistNavigation(entryWith({}), stateAt(10, NOT_SEEKABLE))
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
await act(async () => result.current.goPrevious());
|
|
245
|
+
|
|
246
|
+
expect(seekTo).not.toHaveBeenCalled();
|
|
247
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("navigates to the previous entry past the threshold when the stream cannot seek", async () => {
|
|
251
|
+
const target = { id: "prev" } as ZappEntry;
|
|
252
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
253
|
+
|
|
254
|
+
const { result } = renderHook(() =>
|
|
255
|
+
usePlaylistNavigation(
|
|
256
|
+
entryWith({ play_previous_feed_url: PREV_URL }),
|
|
257
|
+
stateAt(10, NOT_SEEKABLE)
|
|
258
|
+
)
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await act(async () => result.current.goPrevious());
|
|
262
|
+
|
|
263
|
+
expect(seekTo).not.toHaveBeenCalled();
|
|
264
|
+
expect(replaceTop).toHaveBeenCalledWith(target);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("restarts past the threshold when the stream is seekable", async () => {
|
|
268
|
+
const { result } = renderHook(() =>
|
|
269
|
+
usePlaylistNavigation(
|
|
270
|
+
entryWith({ play_previous_feed_url: PREV_URL }),
|
|
271
|
+
stateAt(10)
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
await act(async () => result.current.goPrevious());
|
|
276
|
+
|
|
277
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
278
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("double tap guard", () => {
|
|
283
|
+
const bothDirections = {
|
|
284
|
+
play_next_feed_url: NEXT_URL,
|
|
285
|
+
play_previous_feed_url: PREV_URL,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Makes the next feed request hang until the returned function is called,
|
|
290
|
+
* so a test can fire a second tap while the first is still in flight.
|
|
291
|
+
*/
|
|
292
|
+
const deferredFeed = () => {
|
|
293
|
+
const deferred: { complete?: (entry: ZappEntry) => void } = {};
|
|
294
|
+
|
|
295
|
+
loadFeedEntryMock.mockReturnValue(
|
|
296
|
+
new Promise<ZappEntry>((resolve) => {
|
|
297
|
+
deferred.complete = resolve;
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return (entry: ZappEntry) => deferred.complete(entry);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
it("ignores a second tap while the first request is still in flight", async () => {
|
|
305
|
+
const complete = deferredFeed();
|
|
306
|
+
|
|
307
|
+
const { result } = await renderPlaylistNavigation(
|
|
308
|
+
entryWith(bothDirections),
|
|
309
|
+
stateAt(0)
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
await act(async () => {
|
|
313
|
+
const first = result.current.goNext();
|
|
314
|
+
const second = result.current.goNext();
|
|
315
|
+
|
|
316
|
+
complete({ id: "next" } as ZappEntry);
|
|
317
|
+
|
|
318
|
+
await Promise.all([first, second]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
|
|
322
|
+
expect(replaceTop).toHaveBeenCalledTimes(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("stops Previous racing an in-flight Next", async () => {
|
|
326
|
+
const complete = deferredFeed();
|
|
327
|
+
|
|
328
|
+
const { result } = await renderPlaylistNavigation(
|
|
329
|
+
entryWith(bothDirections),
|
|
330
|
+
stateAt(0)
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
await act(async () => {
|
|
334
|
+
const next = result.current.goNext();
|
|
335
|
+
const previous = result.current.goPrevious();
|
|
336
|
+
|
|
337
|
+
complete({ id: "next" } as ZappEntry);
|
|
338
|
+
|
|
339
|
+
await Promise.all([next, previous]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
|
|
343
|
+
|
|
344
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(
|
|
345
|
+
NEXT_URL,
|
|
346
|
+
expect.anything()
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("allows a new navigation once the previous one has completed", async () => {
|
|
351
|
+
loadFeedEntryMock.mockResolvedValue({ id: "next" } as ZappEntry);
|
|
352
|
+
|
|
353
|
+
const { result } = await renderPlaylistNavigation(
|
|
354
|
+
entryWith(bothDirections),
|
|
355
|
+
stateAt(0)
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
await act(async () => result.current.goNext());
|
|
359
|
+
await act(async () => result.current.goNext());
|
|
360
|
+
|
|
361
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(2);
|
|
362
|
+
expect(replaceTop).toHaveBeenCalledTimes(2);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("releases the guard after a failed request so the control can retry", async () => {
|
|
366
|
+
loadFeedEntryMock
|
|
367
|
+
.mockRejectedValueOnce(new Error("network down"))
|
|
368
|
+
.mockResolvedValueOnce({ id: "next" } as ZappEntry);
|
|
369
|
+
|
|
370
|
+
const { result } = await renderPlaylistNavigation(
|
|
371
|
+
entryWith(bothDirections),
|
|
372
|
+
stateAt(0)
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
await act(async () => result.current.goNext());
|
|
376
|
+
|
|
377
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
378
|
+
|
|
379
|
+
await act(async () => result.current.goNext());
|
|
380
|
+
|
|
381
|
+
expect(replaceTop).toHaveBeenCalledTimes(1);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("does not guard the Previous restart, which is local and instant", async () => {
|
|
385
|
+
loadFeedEntryMock.mockResolvedValue({ id: "prev" } as ZappEntry);
|
|
386
|
+
|
|
387
|
+
const { result } = renderHook(() =>
|
|
388
|
+
usePlaylistNavigation(entryWith(bothDirections), stateAt(10))
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await act(async () => result.current.goPrevious());
|
|
392
|
+
await act(async () => result.current.goPrevious());
|
|
393
|
+
|
|
394
|
+
expect(seekTo).toHaveBeenCalledTimes(2);
|
|
395
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("remote commands", () => {
|
|
400
|
+
let listeners: Record<string, QuickBrickPlayer.SharedPlayerCallBacks>;
|
|
401
|
+
const command = jest.fn();
|
|
402
|
+
|
|
403
|
+
const remote = (id: RemoteCommand) =>
|
|
404
|
+
act(async () => {
|
|
405
|
+
Object.values(listeners).forEach((l) => l.onRemoteCommand?.({ id }));
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
listeners = {};
|
|
410
|
+
command.mockClear();
|
|
411
|
+
|
|
412
|
+
usePlayerMock.mockReturnValue({
|
|
413
|
+
playerId: "player-1",
|
|
414
|
+
command,
|
|
415
|
+
seekTo,
|
|
416
|
+
addListener: ({ id, listener }) => {
|
|
417
|
+
listeners[id] = listener;
|
|
418
|
+
|
|
419
|
+
return () => delete listeners[id];
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("plays next when the lock screen / CarPlay next button is pressed", async () => {
|
|
425
|
+
const target = { id: "next" } as ZappEntry;
|
|
426
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
427
|
+
|
|
428
|
+
await renderPlaylistNavigation(
|
|
429
|
+
entryWith({ play_next_feed_url: NEXT_URL }),
|
|
430
|
+
stateAt(0)
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
await remote(RemoteCommand.NextTrack);
|
|
434
|
+
|
|
435
|
+
expect(replaceTop).toHaveBeenCalledWith(target);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("restarts the track on remote previous past the threshold", async () => {
|
|
439
|
+
await renderPlaylistNavigation(entryWith({}), stateAt(10));
|
|
440
|
+
|
|
441
|
+
await remote(RemoteCommand.PreviousTrack);
|
|
442
|
+
|
|
443
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("ignores commands native handles itself", async () => {
|
|
447
|
+
await renderPlaylistNavigation(
|
|
448
|
+
entryWith({ play_next_feed_url: NEXT_URL }),
|
|
449
|
+
stateAt(10)
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
await remote(RemoteCommand.Pause);
|
|
453
|
+
|
|
454
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
455
|
+
expect(seekTo).not.toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("sends native the controls snapshot to show", async () => {
|
|
459
|
+
await renderPlaylistNavigation(
|
|
460
|
+
entryWith({ play_next_feed_url: NEXT_URL }),
|
|
461
|
+
stateAt(0, NOT_SEEKABLE)
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(command).toHaveBeenLastCalledWith("setPlayerControls", {
|
|
465
|
+
transport: [
|
|
466
|
+
RemoteCommand.Play,
|
|
467
|
+
RemoteCommand.Pause,
|
|
468
|
+
RemoteCommand.SkipBackward,
|
|
469
|
+
RemoteCommand.NextTrack,
|
|
470
|
+
],
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("stops listening once unmounted", async () => {
|
|
475
|
+
const { unmount } = await renderPlaylistNavigation(
|
|
476
|
+
entryWith({ play_next_feed_url: NEXT_URL }),
|
|
477
|
+
stateAt(0)
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
unmount();
|
|
481
|
+
|
|
482
|
+
expect(listeners).toEqual({});
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("getTransportControls", () => {
|
|
488
|
+
it("falls back to skip backward and leaves next out when there is no playlist", () => {
|
|
489
|
+
expect(
|
|
490
|
+
getTransportControls({
|
|
491
|
+
hasNext: false,
|
|
492
|
+
hasPrevious: false,
|
|
493
|
+
isLive: false,
|
|
494
|
+
})
|
|
495
|
+
).toEqual([
|
|
496
|
+
RemoteCommand.Play,
|
|
497
|
+
RemoteCommand.Pause,
|
|
498
|
+
RemoteCommand.SkipBackward,
|
|
499
|
+
]);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("leaves next out without a next item, so it shows disabled", () => {
|
|
503
|
+
expect(
|
|
504
|
+
getTransportControls({ hasNext: false, hasPrevious: true, isLive: false })
|
|
505
|
+
).toEqual([
|
|
506
|
+
RemoteCommand.Play,
|
|
507
|
+
RemoteCommand.Pause,
|
|
508
|
+
RemoteCommand.PreviousTrack,
|
|
509
|
+
]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("puts track buttons in place of skip ones", () => {
|
|
513
|
+
expect(
|
|
514
|
+
getTransportControls({ hasNext: true, hasPrevious: true, isLive: false })
|
|
515
|
+
).toEqual([
|
|
516
|
+
RemoteCommand.Play,
|
|
517
|
+
RemoteCommand.Pause,
|
|
518
|
+
RemoteCommand.PreviousTrack,
|
|
519
|
+
RemoteCommand.NextTrack,
|
|
520
|
+
]);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("uses stop and never skips on live", () => {
|
|
524
|
+
expect(
|
|
525
|
+
getTransportControls({ hasNext: false, hasPrevious: false, isLive: true })
|
|
526
|
+
).toEqual([RemoteCommand.Play, RemoteCommand.Stop]);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("keeps track buttons on live, e.g. a list of radio stations", () => {
|
|
530
|
+
expect(
|
|
531
|
+
getTransportControls({ hasNext: true, hasPrevious: true, isLive: true })
|
|
532
|
+
).toEqual([
|
|
533
|
+
RemoteCommand.Play,
|
|
534
|
+
RemoteCommand.Stop,
|
|
535
|
+
RemoteCommand.PreviousTrack,
|
|
536
|
+
RemoteCommand.NextTrack,
|
|
537
|
+
]);
|
|
538
|
+
});
|
|
539
|
+
});
|