@applicaster/quick-brick-player 16.0.0-alpha.1593888345 → 16.0.0-alpha.1701179921
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 +5 -5
- package/src/Player/AudioLayer/PlaylistNavigationController.ts +354 -0
- package/src/Player/AudioLayer/__tests__/PlaylistNavigationController.test.ts +528 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +153 -1
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +105 -91
- package/src/Player/index.tsx +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/quick-brick-player",
|
|
3
|
-
"version": "16.0.0-alpha.
|
|
3
|
+
"version": "16.0.0-alpha.1701179921",
|
|
4
4
|
"description": "Quick Brick Player",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
},
|
|
36
36
|
"homepage": "https://github.com/applicaster/quickbrick#readme",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.
|
|
38
|
+
"@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.93",
|
|
39
39
|
"@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.69",
|
|
40
|
-
"@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.
|
|
41
|
-
"@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.
|
|
42
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-alpha.
|
|
40
|
+
"@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.1701179921",
|
|
41
|
+
"@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.1701179921",
|
|
42
|
+
"@applicaster/zapp-react-native-utils": "16.0.0-alpha.1701179921",
|
|
43
43
|
"query-string": "7.1.3",
|
|
44
44
|
"shaka-player": "4.3.5",
|
|
45
45
|
"typeface-montserrat": "^0.0.54",
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { BehaviorSubject } from "rxjs";
|
|
2
|
+
import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
|
|
3
|
+
import { findPluginByIdentifier } from "@applicaster/zapp-react-native-utils/pluginUtils";
|
|
4
|
+
import { loadFeedEntry } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
|
|
5
|
+
import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
6
|
+
|
|
7
|
+
const { log_error, log_info } = createLogger({
|
|
8
|
+
subsystem: "Player",
|
|
9
|
+
category: "PlaylistNavigationController",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const RESTART_THRESHOLD_SECONDS = 3;
|
|
13
|
+
|
|
14
|
+
export interface PlayerSeeker {
|
|
15
|
+
seekTo: (time: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PlayerNavigator {
|
|
19
|
+
replaceTop: (entry: ZappEntry) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlaylistNavigationControllerOptions {
|
|
23
|
+
entry: ZappEntry;
|
|
24
|
+
player: PlayerSeeker;
|
|
25
|
+
navigator: PlayerNavigator;
|
|
26
|
+
playerState?: QuickBrickPlayer.PlayerState;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class PlaylistNavigationController {
|
|
30
|
+
public readonly hasNext$: BehaviorSubject<boolean>;
|
|
31
|
+
public readonly hasPrevious$: BehaviorSubject<boolean>;
|
|
32
|
+
|
|
33
|
+
private entry: ZappEntry;
|
|
34
|
+
private player: PlayerSeeker;
|
|
35
|
+
private navigator: PlayerNavigator;
|
|
36
|
+
private playerState: QuickBrickPlayer.PlayerState | undefined;
|
|
37
|
+
private isNavigating = false;
|
|
38
|
+
private unsubscribeQueueListener: (() => void) | null = null;
|
|
39
|
+
private isDestroyed = false;
|
|
40
|
+
private recalculateGeneration = 0;
|
|
41
|
+
|
|
42
|
+
constructor(options: PlaylistNavigationControllerOptions) {
|
|
43
|
+
this.entry = options.entry;
|
|
44
|
+
this.player = options.player;
|
|
45
|
+
this.navigator = options.navigator;
|
|
46
|
+
this.playerState = options.playerState;
|
|
47
|
+
|
|
48
|
+
const staticNextUrl = this.entry?.extensions?.play_next_feed_url;
|
|
49
|
+
const staticPrevUrl = this.entry?.extensions?.play_previous_feed_url;
|
|
50
|
+
const canRestart = (this.playerState?.seekableDuration ?? 0) > 0;
|
|
51
|
+
|
|
52
|
+
this.hasNext$ = new BehaviorSubject<boolean>(Boolean(staticNextUrl));
|
|
53
|
+
|
|
54
|
+
this.hasPrevious$ = new BehaviorSubject<boolean>(
|
|
55
|
+
Boolean(staticPrevUrl) || canRestart
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
this.subscribeToQueue();
|
|
59
|
+
this.recalculate();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public updateContext(
|
|
63
|
+
options: Partial<PlaylistNavigationControllerOptions>
|
|
64
|
+
): void {
|
|
65
|
+
if (options.player) {
|
|
66
|
+
this.player = options.player;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (options.navigator) {
|
|
70
|
+
this.navigator = options.navigator;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let needsRecalculate = false;
|
|
74
|
+
|
|
75
|
+
if (options.entry) {
|
|
76
|
+
const isSameEntry =
|
|
77
|
+
this.entry?.id === options.entry?.id &&
|
|
78
|
+
this.entry?.extensions?.queueItemId ===
|
|
79
|
+
options.entry?.extensions?.queueItemId;
|
|
80
|
+
|
|
81
|
+
if (!isSameEntry) {
|
|
82
|
+
this.entry = options.entry;
|
|
83
|
+
needsRecalculate = true;
|
|
84
|
+
} else {
|
|
85
|
+
this.entry = options.entry;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.playerState) {
|
|
90
|
+
const prevCanRestart = (this.playerState?.seekableDuration ?? 0) > 0;
|
|
91
|
+
const newCanRestart = (options.playerState?.seekableDuration ?? 0) > 0;
|
|
92
|
+
this.playerState = options.playerState;
|
|
93
|
+
|
|
94
|
+
if (prevCanRestart !== newCanRestart) {
|
|
95
|
+
needsRecalculate = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (needsRecalculate) {
|
|
100
|
+
this.recalculate();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public updatePlayerState(playerState?: QuickBrickPlayer.PlayerState): void {
|
|
105
|
+
const prevCanRestart = (this.playerState?.seekableDuration ?? 0) > 0;
|
|
106
|
+
const newCanRestart = (playerState?.seekableDuration ?? 0) > 0;
|
|
107
|
+
this.playerState = playerState;
|
|
108
|
+
|
|
109
|
+
if (prevCanRestart !== newCanRestart) {
|
|
110
|
+
this.recalculate();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public updateEntry(entry: ZappEntry): void {
|
|
115
|
+
if (
|
|
116
|
+
this.entry?.id === entry?.id &&
|
|
117
|
+
this.entry?.extensions?.queueItemId === entry?.extensions?.queueItemId
|
|
118
|
+
) {
|
|
119
|
+
this.entry = entry;
|
|
120
|
+
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.entry = entry;
|
|
125
|
+
this.recalculate();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private getQueueManagerInstance() {
|
|
129
|
+
try {
|
|
130
|
+
const plugins = appStore?.get?.("plugins") || [];
|
|
131
|
+
const queuePlugin = findPluginByIdentifier("queue-action", plugins, true);
|
|
132
|
+
const module = (queuePlugin as any)?.module;
|
|
133
|
+
const QueueManager = module?.QueueManager;
|
|
134
|
+
const setupQuickBrickQueue = module?.setupQuickBrickQueue;
|
|
135
|
+
|
|
136
|
+
if (
|
|
137
|
+
QueueManager &&
|
|
138
|
+
!QueueManager.isInitialized?.() &&
|
|
139
|
+
typeof setupQuickBrickQueue === "function"
|
|
140
|
+
) {
|
|
141
|
+
setupQuickBrickQueue();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (QueueManager?.isInitialized?.()) {
|
|
145
|
+
return QueueManager.instance;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Safe fallback when store or plugin is not available
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private subscribeToQueue(): void {
|
|
155
|
+
const queueManager = this.getQueueManagerInstance();
|
|
156
|
+
|
|
157
|
+
if (queueManager?.addListener) {
|
|
158
|
+
this.unsubscribeQueueListener = queueManager.addListener(() => {
|
|
159
|
+
if (!this.isDestroyed) {
|
|
160
|
+
this.recalculate();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async getQueueData(): Promise<{
|
|
167
|
+
upcoming: ZappEntry[];
|
|
168
|
+
history: ZappEntry[];
|
|
169
|
+
}> {
|
|
170
|
+
const queueManager = this.getQueueManagerInstance();
|
|
171
|
+
|
|
172
|
+
if (!queueManager) {
|
|
173
|
+
return { upcoming: [], history: [] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const [upcoming, history] = await Promise.all([
|
|
178
|
+
queueManager.getUpcomingQueue?.() ?? [],
|
|
179
|
+
queueManager.getHistoryQueue?.() ?? [],
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
upcoming: Array.isArray(upcoming) ? upcoming : [],
|
|
184
|
+
history: Array.isArray(history) ? history : [],
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
log_error("PlaylistNavigationController: Error fetching queue items", {
|
|
188
|
+
error,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return { upcoming: [], history: [] };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public async recalculate(): Promise<void> {
|
|
196
|
+
if (this.isDestroyed) return;
|
|
197
|
+
|
|
198
|
+
const generation = ++this.recalculateGeneration;
|
|
199
|
+
const { upcoming, history } = await this.getQueueData();
|
|
200
|
+
|
|
201
|
+
if (this.isDestroyed || generation !== this.recalculateGeneration) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const staticNextUrl = this.entry?.extensions?.play_next_feed_url;
|
|
206
|
+
const staticPrevUrl = this.entry?.extensions?.play_previous_feed_url;
|
|
207
|
+
|
|
208
|
+
const hasNext = upcoming.length > 0 || Boolean(staticNextUrl);
|
|
209
|
+
|
|
210
|
+
const canRestart = (this.playerState?.seekableDuration ?? 0) > 0;
|
|
211
|
+
|
|
212
|
+
const hasPrevious =
|
|
213
|
+
history.length > 0 || Boolean(staticPrevUrl) || canRestart;
|
|
214
|
+
|
|
215
|
+
if (this.hasNext$.getValue() !== hasNext) {
|
|
216
|
+
this.hasNext$.next(hasNext);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (this.hasPrevious$.getValue() !== hasPrevious) {
|
|
220
|
+
this.hasPrevious$.next(hasPrevious);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
public async goNext(): Promise<void> {
|
|
225
|
+
if (this.isNavigating || this.isDestroyed) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.isNavigating = true;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const { upcoming } = await this.getQueueData();
|
|
233
|
+
|
|
234
|
+
if (this.isDestroyed) return;
|
|
235
|
+
|
|
236
|
+
if (upcoming.length > 0) {
|
|
237
|
+
const nextItem = upcoming[0];
|
|
238
|
+
|
|
239
|
+
log_info(
|
|
240
|
+
`goNext: playing next item from queue "${nextItem.title}", id: ${nextItem.id}`,
|
|
241
|
+
{ targetId: nextItem.id }
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (this.isDestroyed) return;
|
|
245
|
+
this.navigator?.replaceTop?.(nextItem);
|
|
246
|
+
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const staticNextUrl = this.entry?.extensions?.play_next_feed_url;
|
|
251
|
+
|
|
252
|
+
if (staticNextUrl) {
|
|
253
|
+
const target = await loadFeedEntry(staticNextUrl, this.entry);
|
|
254
|
+
|
|
255
|
+
if (this.isDestroyed) return;
|
|
256
|
+
|
|
257
|
+
log_info(
|
|
258
|
+
`goNext: playing "${target.title}", id: ${target.id} from remote feed`,
|
|
259
|
+
{
|
|
260
|
+
staticNextUrl,
|
|
261
|
+
targetId: target.id,
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
this.navigator?.replaceTop?.(target);
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
log_error("goNext: could not navigate to next entry", { error });
|
|
269
|
+
} finally {
|
|
270
|
+
this.isNavigating = false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
public async goPrevious(): Promise<void> {
|
|
275
|
+
if (this.isNavigating || this.isDestroyed) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.isNavigating = true;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const { history } = await this.getQueueData();
|
|
283
|
+
|
|
284
|
+
if (this.isDestroyed) return;
|
|
285
|
+
|
|
286
|
+
const staticPrevUrl = this.entry?.extensions?.play_previous_feed_url;
|
|
287
|
+
|
|
288
|
+
const currentTime = this.playerState?.currentTime ?? 0;
|
|
289
|
+
const canRestart = (this.playerState?.seekableDuration ?? 0) > 0;
|
|
290
|
+
|
|
291
|
+
const hasQueueHistory = history.length > 0;
|
|
292
|
+
const hasStaticPrev = Boolean(staticPrevUrl);
|
|
293
|
+
|
|
294
|
+
const hasTarget = hasQueueHistory || hasStaticPrev;
|
|
295
|
+
|
|
296
|
+
const shouldRestart =
|
|
297
|
+
canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !hasTarget);
|
|
298
|
+
|
|
299
|
+
if (shouldRestart) {
|
|
300
|
+
if (this.isDestroyed) return;
|
|
301
|
+
this.player?.seekTo?.(0);
|
|
302
|
+
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (hasQueueHistory) {
|
|
307
|
+
const prevItem = history[history.length - 1];
|
|
308
|
+
|
|
309
|
+
log_info(
|
|
310
|
+
`goPrevious: playing previous item from queue "${prevItem.title}", id: ${prevItem.id}`,
|
|
311
|
+
{ targetId: prevItem.id }
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (this.isDestroyed) return;
|
|
315
|
+
this.navigator?.replaceTop?.(prevItem);
|
|
316
|
+
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (hasStaticPrev) {
|
|
321
|
+
const target = await loadFeedEntry(staticPrevUrl, this.entry);
|
|
322
|
+
|
|
323
|
+
if (this.isDestroyed) return;
|
|
324
|
+
|
|
325
|
+
log_info(
|
|
326
|
+
`goPrevious: playing "${target.title}", id: ${target.id} from remote feed`,
|
|
327
|
+
{
|
|
328
|
+
staticPrevUrl,
|
|
329
|
+
targetId: target.id,
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
this.navigator?.replaceTop?.(target);
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
log_error("goPrevious: could not navigate to previous entry", { error });
|
|
337
|
+
} finally {
|
|
338
|
+
this.isNavigating = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
public destroy(): void {
|
|
343
|
+
this.isDestroyed = true;
|
|
344
|
+
this.recalculateGeneration++;
|
|
345
|
+
|
|
346
|
+
if (this.unsubscribeQueueListener) {
|
|
347
|
+
this.unsubscribeQueueListener();
|
|
348
|
+
this.unsubscribeQueueListener = null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
this.hasNext$.complete();
|
|
352
|
+
this.hasPrevious$.complete();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,528 @@
|
|
|
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 queue navigation over static feed URLs even 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).not.toHaveBeenCalled();
|
|
415
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(queueNextItem);
|
|
416
|
+
|
|
417
|
+
await controller.goPrevious();
|
|
418
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
419
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(queuePrevItem);
|
|
420
|
+
|
|
421
|
+
controller.destroy();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("falls back to static feed URLs when queue is empty", async () => {
|
|
425
|
+
const staticNextTarget = { id: "static_next" } as ZappEntry;
|
|
426
|
+
const staticPrevTarget = { id: "static_prev" } as ZappEntry;
|
|
427
|
+
|
|
428
|
+
mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([]);
|
|
429
|
+
mockQueueManager.instance.getHistoryQueue.mockResolvedValue([]);
|
|
430
|
+
|
|
431
|
+
loadFeedEntryMock.mockImplementation((url) =>
|
|
432
|
+
Promise.resolve(url === NEXT_URL ? staticNextTarget : staticPrevTarget)
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const entry = entryWith({
|
|
436
|
+
play_next_feed_url: NEXT_URL,
|
|
437
|
+
play_previous_feed_url: PREV_URL,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const controller = new PlaylistNavigationController({
|
|
441
|
+
entry,
|
|
442
|
+
player,
|
|
443
|
+
navigator,
|
|
444
|
+
playerState: stateAt(1),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await controller.goNext();
|
|
448
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
|
|
449
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(staticNextTarget);
|
|
450
|
+
|
|
451
|
+
await controller.goPrevious();
|
|
452
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(PREV_URL, entry);
|
|
453
|
+
expect(navigator.replaceTop).toHaveBeenCalledWith(staticPrevTarget);
|
|
454
|
+
|
|
455
|
+
controller.destroy();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("ignores stale recalculate resolution when newer recalculate was called", async () => {
|
|
459
|
+
let resolveFirst: (val: any) => void;
|
|
460
|
+
|
|
461
|
+
const firstPromise = new Promise<{
|
|
462
|
+
upcoming: ZappEntry[];
|
|
463
|
+
history: ZappEntry[];
|
|
464
|
+
}>((resolve) => {
|
|
465
|
+
resolveFirst = resolve;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const entry = entryWith({});
|
|
469
|
+
|
|
470
|
+
const controller = new PlaylistNavigationController({
|
|
471
|
+
entry,
|
|
472
|
+
player,
|
|
473
|
+
navigator,
|
|
474
|
+
playerState: stateAt(0, NOT_SEEKABLE),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Override getUpcomingQueue to control resolution order
|
|
478
|
+
mockQueueManager.instance.getUpcomingQueue
|
|
479
|
+
.mockReturnValueOnce(firstPromise.then((d) => d.upcoming))
|
|
480
|
+
.mockResolvedValueOnce([{ id: "second_call_item" }]);
|
|
481
|
+
|
|
482
|
+
// First recalculate (will hang until resolveFirst)
|
|
483
|
+
const recalc1 = controller.recalculate();
|
|
484
|
+
|
|
485
|
+
// Second recalculate resolves immediately with hasNext = true
|
|
486
|
+
const recalc2 = controller.recalculate();
|
|
487
|
+
await recalc2;
|
|
488
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
489
|
+
|
|
490
|
+
// Now resolve first with empty array; should NOT overwrite hasNext$ to false
|
|
491
|
+
resolveFirst!({ upcoming: [], history: [] });
|
|
492
|
+
await recalc1;
|
|
493
|
+
expect(controller.hasNext$.getValue()).toBe(true);
|
|
494
|
+
|
|
495
|
+
controller.destroy();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("does not navigate if controller is destroyed while resolving feed entry", async () => {
|
|
499
|
+
let resolvePromise: (value: ZappEntry) => void;
|
|
500
|
+
|
|
501
|
+
loadFeedEntryMock.mockReturnValue(
|
|
502
|
+
new Promise((resolve) => {
|
|
503
|
+
resolvePromise = resolve;
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const entry = entryWith({
|
|
508
|
+
play_next_feed_url: NEXT_URL,
|
|
509
|
+
play_previous_feed_url: PREV_URL,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const controller = new PlaylistNavigationController({
|
|
513
|
+
entry,
|
|
514
|
+
player,
|
|
515
|
+
navigator,
|
|
516
|
+
playerState: stateAt(1),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const goNextPromise = controller.goNext();
|
|
520
|
+
controller.destroy();
|
|
521
|
+
|
|
522
|
+
resolvePromise!({ id: "target" } as ZappEntry);
|
|
523
|
+
await goNextPromise;
|
|
524
|
+
|
|
525
|
+
expect(navigator.replaceTop).not.toHaveBeenCalled();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
});
|
|
@@ -2,8 +2,12 @@ import { renderHook, act } from "@testing-library/react-native";
|
|
|
2
2
|
import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
3
3
|
import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
|
|
4
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";
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getTransportControls,
|
|
9
|
+
usePlaylistNavigation,
|
|
10
|
+
} from "../usePlaylistNavigation";
|
|
7
11
|
|
|
8
12
|
jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
|
|
9
13
|
useNavigation: jest.fn(),
|
|
@@ -24,6 +28,13 @@ jest.mock(
|
|
|
24
28
|
})
|
|
25
29
|
);
|
|
26
30
|
|
|
31
|
+
jest.mock(
|
|
32
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand",
|
|
33
|
+
() => ({
|
|
34
|
+
PlayerNativeCommandTypes: { setPlayerControls: "setPlayerControls" },
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
27
38
|
const useNavigationMock = useNavigation as jest.Mock;
|
|
28
39
|
const loadFeedEntryMock = loadFeedEntry as jest.Mock;
|
|
29
40
|
const usePlayerMock = usePlayer as jest.Mock;
|
|
@@ -384,4 +395,145 @@ describe("usePlaylistNavigation", () => {
|
|
|
384
395
|
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
385
396
|
});
|
|
386
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
|
+
});
|
|
387
539
|
});
|
|
@@ -2,19 +2,44 @@ import * as React from "react";
|
|
|
2
2
|
|
|
3
3
|
import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
4
4
|
import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
retrievePlayPreviousFeedUrl,
|
|
9
|
-
} from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
|
|
10
|
-
import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
5
|
+
import { PlayerNativeCommandTypes } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand";
|
|
6
|
+
import { RemoteCommand } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
|
|
7
|
+
import { PlaylistNavigationController } from "./PlaylistNavigationController";
|
|
11
8
|
|
|
12
|
-
const
|
|
13
|
-
subsystem: "Player",
|
|
14
|
-
category: "usePlaylistNavigation",
|
|
15
|
-
});
|
|
9
|
+
const REMOTE_COMMANDS_LISTENER_ID = "playlist-navigation-remote-commands";
|
|
16
10
|
|
|
17
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Buttons native should expose on the lock screen / Control Center / CarPlay.
|
|
13
|
+
* Native enables exactly these and disables the rest. A track button takes its
|
|
14
|
+
* slot over the skip one, since the system shows only one of them per side.
|
|
15
|
+
* Without a next item next is left out, so the system shows it disabled.
|
|
16
|
+
*/
|
|
17
|
+
export const getTransportControls = ({
|
|
18
|
+
hasNext,
|
|
19
|
+
hasPrevious,
|
|
20
|
+
isLive,
|
|
21
|
+
}: {
|
|
22
|
+
hasNext: boolean;
|
|
23
|
+
hasPrevious: boolean;
|
|
24
|
+
isLive: boolean;
|
|
25
|
+
}): RemoteCommand[] => {
|
|
26
|
+
const commands = [
|
|
27
|
+
RemoteCommand.Play,
|
|
28
|
+
isLive ? RemoteCommand.Stop : RemoteCommand.Pause,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
if (hasPrevious) {
|
|
32
|
+
commands.push(RemoteCommand.PreviousTrack);
|
|
33
|
+
} else if (!isLive) {
|
|
34
|
+
commands.push(RemoteCommand.SkipBackward);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (hasNext) {
|
|
38
|
+
commands.push(RemoteCommand.NextTrack);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return commands;
|
|
42
|
+
};
|
|
18
43
|
|
|
19
44
|
export type PlaylistNavigation = {
|
|
20
45
|
hasNext: boolean;
|
|
@@ -24,8 +49,8 @@ export type PlaylistNavigation = {
|
|
|
24
49
|
};
|
|
25
50
|
|
|
26
51
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
52
|
+
* Thin UI binding hook that subscribes to PlaylistNavigationController's
|
|
53
|
+
* RxJS streams and exposes hasNext, hasPrevious, goNext, and goPrevious.
|
|
29
54
|
*/
|
|
30
55
|
export const usePlaylistNavigation = (
|
|
31
56
|
entry: ZappEntry,
|
|
@@ -36,98 +61,87 @@ export const usePlaylistNavigation = (
|
|
|
36
61
|
// seeking would miss the receiver while casting.
|
|
37
62
|
const player = usePlayer();
|
|
38
63
|
|
|
39
|
-
const [
|
|
64
|
+
const [controller] = React.useState(
|
|
65
|
+
() =>
|
|
66
|
+
new PlaylistNavigationController({
|
|
67
|
+
entry,
|
|
68
|
+
player,
|
|
69
|
+
navigator,
|
|
70
|
+
playerState,
|
|
71
|
+
})
|
|
72
|
+
);
|
|
40
73
|
|
|
41
|
-
React.
|
|
42
|
-
|
|
74
|
+
const [hasNext, setHasNext] = React.useState(() =>
|
|
75
|
+
controller.hasNext$.getValue()
|
|
76
|
+
);
|
|
43
77
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
setNextUrl(url ?? null);
|
|
48
|
-
}
|
|
49
|
-
})
|
|
50
|
-
.catch((error) => {
|
|
51
|
-
log_error(
|
|
52
|
-
"usePlaylistNavigation: could not resolve the next feed url",
|
|
53
|
-
{ error }
|
|
54
|
-
);
|
|
78
|
+
const [hasPrevious, setHasPrevious] = React.useState(() =>
|
|
79
|
+
controller.hasPrevious$.getValue()
|
|
80
|
+
);
|
|
55
81
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
controller.updateContext({ player, navigator, entry, playerState });
|
|
84
|
+
}, [controller, player, navigator, entry, playerState]);
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
const subNext = controller.hasNext$.subscribe(setHasNext);
|
|
88
|
+
const subPrev = controller.hasPrevious$.subscribe(setHasPrevious);
|
|
60
89
|
|
|
61
90
|
return () => {
|
|
62
|
-
|
|
91
|
+
subNext.unsubscribe();
|
|
92
|
+
subPrev.unsubscribe();
|
|
93
|
+
controller.destroy();
|
|
63
94
|
};
|
|
64
|
-
}, [
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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]
|
|
95
|
+
}, [controller]);
|
|
96
|
+
|
|
97
|
+
// Lock screen / Control Center / CarPlay next & previous go through the same
|
|
98
|
+
// controller as the on-screen buttons, so JS stays the single source of truth.
|
|
99
|
+
React.useEffect(
|
|
100
|
+
() =>
|
|
101
|
+
player?.addListener?.({
|
|
102
|
+
id: REMOTE_COMMANDS_LISTENER_ID,
|
|
103
|
+
listener: {
|
|
104
|
+
onRemoteCommand: ({ id }) => {
|
|
105
|
+
if (id === RemoteCommand.NextTrack) {
|
|
106
|
+
controller.goNext();
|
|
107
|
+
} else if (id === RemoteCommand.PreviousTrack) {
|
|
108
|
+
controller.goPrevious();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
[player, controller]
|
|
103
114
|
);
|
|
104
115
|
|
|
105
|
-
const
|
|
106
|
-
if (!nextUrl) return;
|
|
107
|
-
|
|
108
|
-
await navigateTo(nextUrl);
|
|
109
|
-
}, [nextUrl, navigateTo]);
|
|
116
|
+
const isLive = Boolean(playerState?.isLive);
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// Full snapshot: native renders exactly this and drops the rest. Each key is
|
|
119
|
+
// one kind of buttons, so they stay separate lists rather than one flat list.
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
const controls = {
|
|
122
|
+
transport: getTransportControls({ hasNext, hasPrevious, isLive }),
|
|
123
|
+
// TODO: other button kinds go in as their own keys next to `transport`,
|
|
124
|
+
// e.g. custom / native-drawn buttons ("play next", CarPlay / Android Auto
|
|
125
|
+
// row buttons). Add a composer on Player once more than this hook fills it.
|
|
126
|
+
// actions: [],
|
|
127
|
+
};
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
player?.command?.(PlayerNativeCommandTypes.setPlayerControls, controls);
|
|
130
|
+
}, [player, hasNext, hasPrevious, isLive]);
|
|
122
131
|
|
|
123
|
-
|
|
132
|
+
const goNext = React.useCallback(
|
|
133
|
+
async () => controller.goNext(),
|
|
134
|
+
[controller]
|
|
135
|
+
);
|
|
124
136
|
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
const goPrevious = React.useCallback(
|
|
138
|
+
async () => controller.goPrevious(),
|
|
139
|
+
[controller]
|
|
140
|
+
);
|
|
127
141
|
|
|
128
142
|
return {
|
|
129
|
-
hasNext
|
|
130
|
-
hasPrevious
|
|
143
|
+
hasNext,
|
|
144
|
+
hasPrevious,
|
|
131
145
|
goNext,
|
|
132
146
|
goPrevious,
|
|
133
147
|
};
|
package/src/Player/index.tsx
CHANGED
|
@@ -398,6 +398,22 @@ export default class VideoPlayer extends React.Component<
|
|
|
398
398
|
RNPlayerManager?.[this.playerPluginId]?.stopBackgroundPlayback?.();
|
|
399
399
|
};
|
|
400
400
|
|
|
401
|
+
command = (command: string, userInfo?: Record<string, any>) => {
|
|
402
|
+
if (
|
|
403
|
+
this.isReactNativeMethodsSupported() &&
|
|
404
|
+
RNPlayerManager?.[this.playerPluginId]?.command &&
|
|
405
|
+
this._root
|
|
406
|
+
) {
|
|
407
|
+
const node = findNodeHandle(this._root);
|
|
408
|
+
|
|
409
|
+
if (!node) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
RNPlayerManager?.[this.playerPluginId]?.command(node, command, userInfo);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
401
417
|
forward = (seekTime = SEEK_TIME) => {
|
|
402
418
|
const currentTime = this.state.currentTime;
|
|
403
419
|
|
|
@@ -535,6 +551,10 @@ export default class VideoPlayer extends React.Component<
|
|
|
535
551
|
return this.props?.listener?.onError(newError);
|
|
536
552
|
};
|
|
537
553
|
|
|
554
|
+
onRemoteCommand = ({ nativeEvent = null }) => {
|
|
555
|
+
this.props?.listener?.onRemoteCommand?.(nativeEvent);
|
|
556
|
+
};
|
|
557
|
+
|
|
538
558
|
onPlayerPause = ({ nativeEvent = null }) => {
|
|
539
559
|
this.setState({ paused: true });
|
|
540
560
|
this.props?.listener?.onPlayerPause(nativeEvent);
|
|
@@ -1069,6 +1089,7 @@ export default class VideoPlayer extends React.Component<
|
|
|
1069
1089
|
onPlayerResume: this.onPlayerResume,
|
|
1070
1090
|
onPlayerSeekComplete: this.onPlayerSeekComplete,
|
|
1071
1091
|
onPlayerSeekStart: this.onPlayerSeekStart,
|
|
1092
|
+
onRemoteCommand: this.onRemoteCommand,
|
|
1072
1093
|
};
|
|
1073
1094
|
|
|
1074
1095
|
const lifecycleEvents = {
|