@applicaster/quick-brick-player 16.0.0-rc.87 → 16.0.0-rc.89

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/quick-brick-player",
3
- "version": "16.0.0-rc.87",
3
+ "version": "16.0.0-rc.89",
4
4
  "description": "Quick Brick Player",
5
5
  "main": "./src/index.ts",
6
6
  "types": "index.d.ts",
@@ -37,9 +37,9 @@
37
37
  "dependencies": {
38
38
  "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.73",
39
39
  "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.69",
40
- "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.87",
41
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.87",
42
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.87",
40
+ "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.89",
41
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.89",
42
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.89",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -0,0 +1,381 @@
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 QueueManager = (queuePlugin as any)?.module?.QueueManager;
133
+
134
+ if (QueueManager?.isInitialized?.()) {
135
+ return QueueManager.instance;
136
+ }
137
+ } catch {
138
+ // Safe fallback when store or plugin is not available
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ private subscribeToQueue(): void {
145
+ const queueManager = this.getQueueManagerInstance();
146
+
147
+ if (queueManager?.addListener) {
148
+ this.unsubscribeQueueListener = queueManager.addListener(() => {
149
+ if (!this.isDestroyed) {
150
+ this.recalculate();
151
+ }
152
+ });
153
+ }
154
+ }
155
+
156
+ private async getQueueData(): Promise<{
157
+ upcoming: ZappEntry[];
158
+ history: ZappEntry[];
159
+ }> {
160
+ const queueManager = this.getQueueManagerInstance();
161
+
162
+ if (!queueManager) {
163
+ return { upcoming: [], history: [] };
164
+ }
165
+
166
+ try {
167
+ const [upcoming, history] = await Promise.all([
168
+ queueManager.getUpcomingQueue?.() ?? [],
169
+ queueManager.getHistoryQueue?.() ?? [],
170
+ ]);
171
+
172
+ return {
173
+ upcoming: Array.isArray(upcoming) ? upcoming : [],
174
+ history: Array.isArray(history) ? history : [],
175
+ };
176
+ } catch (error) {
177
+ log_error("PlaylistNavigationController: Error fetching queue items", {
178
+ error,
179
+ });
180
+
181
+ return { upcoming: [], history: [] };
182
+ }
183
+ }
184
+
185
+ public async recalculate(): Promise<void> {
186
+ if (this.isDestroyed) return;
187
+
188
+ const generation = ++this.recalculateGeneration;
189
+ const { upcoming, history } = await this.getQueueData();
190
+
191
+ if (this.isDestroyed || generation !== this.recalculateGeneration) {
192
+ return;
193
+ }
194
+
195
+ const isPlayingFromQueue = Boolean(this.entry?.extensions?.queueItemId);
196
+ const staticNextUrl = this.entry?.extensions?.play_next_feed_url;
197
+ const staticPrevUrl = this.entry?.extensions?.play_previous_feed_url;
198
+
199
+ const hasNext = isPlayingFromQueue
200
+ ? upcoming.length > 0 || Boolean(staticNextUrl)
201
+ : Boolean(staticNextUrl) || upcoming.length > 0;
202
+
203
+ const canRestart = (this.playerState?.seekableDuration ?? 0) > 0;
204
+
205
+ const hasPrevious = isPlayingFromQueue
206
+ ? history.length > 0 || Boolean(staticPrevUrl) || canRestart
207
+ : Boolean(staticPrevUrl) || history.length > 0 || canRestart;
208
+
209
+ if (this.hasNext$.getValue() !== hasNext) {
210
+ this.hasNext$.next(hasNext);
211
+ }
212
+
213
+ if (this.hasPrevious$.getValue() !== hasPrevious) {
214
+ this.hasPrevious$.next(hasPrevious);
215
+ }
216
+ }
217
+
218
+ public async goNext(): Promise<void> {
219
+ if (this.isNavigating || this.isDestroyed) {
220
+ return;
221
+ }
222
+
223
+ this.isNavigating = true;
224
+
225
+ try {
226
+ const { upcoming } = await this.getQueueData();
227
+
228
+ if (this.isDestroyed) return;
229
+
230
+ const isPlayingFromQueue = Boolean(this.entry?.extensions?.queueItemId);
231
+
232
+ if (isPlayingFromQueue && upcoming.length > 0) {
233
+ const nextItem = upcoming[0];
234
+
235
+ log_info(
236
+ `goNext: playing next item from queue "${nextItem.title}", id: ${nextItem.id}`,
237
+ { targetId: nextItem.id }
238
+ );
239
+
240
+ if (this.isDestroyed) return;
241
+ this.navigator?.replaceTop?.(nextItem);
242
+
243
+ return;
244
+ }
245
+
246
+ const staticNextUrl = this.entry?.extensions?.play_next_feed_url;
247
+
248
+ if (staticNextUrl) {
249
+ const target = await loadFeedEntry(staticNextUrl, this.entry);
250
+
251
+ if (this.isDestroyed) return;
252
+
253
+ log_info(
254
+ `goNext: playing "${target.title}", id: ${target.id} from remote feed`,
255
+ {
256
+ staticNextUrl,
257
+ targetId: target.id,
258
+ }
259
+ );
260
+
261
+ this.navigator?.replaceTop?.(target);
262
+
263
+ return;
264
+ }
265
+
266
+ if (upcoming.length > 0) {
267
+ const nextItem = upcoming[0];
268
+
269
+ log_info(
270
+ `goNext: playing next item from queue "${nextItem.title}", id: ${nextItem.id}`,
271
+ { targetId: nextItem.id }
272
+ );
273
+
274
+ if (this.isDestroyed) return;
275
+ this.navigator?.replaceTop?.(nextItem);
276
+ }
277
+ } catch (error) {
278
+ log_error("goNext: could not navigate to next entry", { error });
279
+ } finally {
280
+ this.isNavigating = false;
281
+ }
282
+ }
283
+
284
+ public async goPrevious(): Promise<void> {
285
+ if (this.isNavigating || this.isDestroyed) {
286
+ return;
287
+ }
288
+
289
+ this.isNavigating = true;
290
+
291
+ try {
292
+ const { history } = await this.getQueueData();
293
+
294
+ if (this.isDestroyed) return;
295
+
296
+ const isPlayingFromQueue = Boolean(this.entry?.extensions?.queueItemId);
297
+ const staticPrevUrl = this.entry?.extensions?.play_previous_feed_url;
298
+
299
+ const currentTime = this.playerState?.currentTime ?? 0;
300
+ const canRestart = (this.playerState?.seekableDuration ?? 0) > 0;
301
+
302
+ const hasQueueHistory = history.length > 0;
303
+ const hasStaticPrev = Boolean(staticPrevUrl);
304
+
305
+ const hasTarget = isPlayingFromQueue
306
+ ? hasQueueHistory || hasStaticPrev
307
+ : hasStaticPrev || hasQueueHistory;
308
+
309
+ const shouldRestart =
310
+ canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !hasTarget);
311
+
312
+ if (shouldRestart) {
313
+ if (this.isDestroyed) return;
314
+ this.player?.seekTo?.(0);
315
+
316
+ return;
317
+ }
318
+
319
+ if (isPlayingFromQueue && hasQueueHistory) {
320
+ const prevItem = history[history.length - 1];
321
+
322
+ log_info(
323
+ `goPrevious: playing previous item from queue "${prevItem.title}", id: ${prevItem.id}`,
324
+ { targetId: prevItem.id }
325
+ );
326
+
327
+ if (this.isDestroyed) return;
328
+ this.navigator?.replaceTop?.(prevItem);
329
+
330
+ return;
331
+ }
332
+
333
+ if (hasStaticPrev) {
334
+ const target = await loadFeedEntry(staticPrevUrl, this.entry);
335
+
336
+ if (this.isDestroyed) return;
337
+
338
+ log_info(
339
+ `goPrevious: playing "${target.title}", id: ${target.id} from remote feed`,
340
+ {
341
+ staticPrevUrl,
342
+ targetId: target.id,
343
+ }
344
+ );
345
+
346
+ this.navigator?.replaceTop?.(target);
347
+
348
+ return;
349
+ }
350
+
351
+ if (hasQueueHistory) {
352
+ const prevItem = history[history.length - 1];
353
+
354
+ log_info(
355
+ `goPrevious: playing previous item from queue "${prevItem.title}", id: ${prevItem.id}`,
356
+ { targetId: prevItem.id }
357
+ );
358
+
359
+ if (this.isDestroyed) return;
360
+ this.navigator?.replaceTop?.(prevItem);
361
+ }
362
+ } catch (error) {
363
+ log_error("goPrevious: could not navigate to previous entry", { error });
364
+ } finally {
365
+ this.isNavigating = false;
366
+ }
367
+ }
368
+
369
+ public destroy(): void {
370
+ this.isDestroyed = true;
371
+ this.recalculateGeneration++;
372
+
373
+ if (this.unsubscribeQueueListener) {
374
+ this.unsubscribeQueueListener();
375
+ this.unsubscribeQueueListener = null;
376
+ }
377
+
378
+ this.hasNext$.complete();
379
+ this.hasPrevious$.complete();
380
+ }
381
+ }
@@ -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
+ });
@@ -2,19 +2,7 @@ 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
- loadFeedEntry,
7
- retrieveFeedUrl,
8
- retrievePlayPreviousFeedUrl,
9
- } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
10
- import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
11
-
12
- const { log_error, log_info } = createLogger({
13
- subsystem: "Player",
14
- category: "usePlaylistNavigation",
15
- });
16
-
17
- const RESTART_THRESHOLD_SECONDS = 3;
5
+ import { PlaylistNavigationController } from "./PlaylistNavigationController";
18
6
 
19
7
  export type PlaylistNavigation = {
20
8
  hasNext: boolean;
@@ -24,8 +12,8 @@ export type PlaylistNavigation = {
24
12
  };
25
13
 
26
14
  /**
27
- * Each entry points at the feed holding its neighbour, so the chain walks
28
- * itself and needs no client-side queue.
15
+ * Thin UI binding hook that subscribes to PlaylistNavigationController's
16
+ * RxJS streams and exposes hasNext, hasPrevious, goNext, and goPrevious.
29
17
  */
30
18
  export const usePlaylistNavigation = (
31
19
  entry: ZappEntry,
@@ -36,98 +24,52 @@ export const usePlaylistNavigation = (
36
24
  // seeking would miss the receiver while casting.
37
25
  const player = usePlayer();
38
26
 
39
- const [nextUrl, setNextUrl] = React.useState<string | null>(null);
40
-
41
- React.useEffect(() => {
42
- let cancelled = false;
43
-
44
- retrieveFeedUrl(entry)
45
- .then((url) => {
46
- if (!cancelled) {
47
- setNextUrl(url ?? null);
48
- }
27
+ const [controller] = React.useState(
28
+ () =>
29
+ new PlaylistNavigationController({
30
+ entry,
31
+ player,
32
+ navigator,
33
+ playerState,
49
34
  })
50
- .catch((error) => {
51
- log_error(
52
- "usePlaylistNavigation: could not resolve the next feed url",
53
- { error }
54
- );
55
-
56
- if (!cancelled) {
57
- setNextUrl(null);
58
- }
59
- });
60
-
61
- return () => {
62
- cancelled = true;
63
- };
64
- }, [entry]);
65
-
66
- const previousUrl = retrievePlayPreviousFeedUrl(entry);
67
- const currentTime = playerState?.currentTime ?? 0;
68
-
69
- const canRestart = (playerState?.seekableDuration ?? 0) > 0;
70
-
71
- const isNavigatingRef = React.useRef(false);
72
-
73
- const navigateTo = React.useCallback(
74
- async (feedUrl: string) => {
75
- // A second tap mid-flight resolves to the same target, since `entry` has
76
- // not been replaced yet.
77
- if (isNavigatingRef.current) {
78
- return;
79
- }
80
-
81
- isNavigatingRef.current = true;
82
-
83
- try {
84
- const target = await loadFeedEntry(feedUrl, entry);
85
-
86
- log_info(`navigateTo: playing "${target.title}", id: ${target.id}`, {
87
- feedUrl,
88
- targetId: target.id,
89
- });
90
-
91
- navigator.replaceTop(target);
92
- } catch (error) {
93
- log_error("navigateTo: could not play the neighbouring entry", {
94
- error,
95
- feedUrl,
96
- });
97
- } finally {
98
- // Released on failure too, so one bad request cannot latch the control.
99
- isNavigatingRef.current = false;
100
- }
101
- },
102
- [entry, navigator]
103
35
  );
104
36
 
105
- const goNext = React.useCallback(async () => {
106
- if (!nextUrl) return;
37
+ const [hasNext, setHasNext] = React.useState(() =>
38
+ controller.hasNext$.getValue()
39
+ );
107
40
 
108
- await navigateTo(nextUrl);
109
- }, [nextUrl, navigateTo]);
41
+ const [hasPrevious, setHasPrevious] = React.useState(() =>
42
+ controller.hasPrevious$.getValue()
43
+ );
110
44
 
111
- const goPrevious = React.useCallback(async () => {
112
- // Restarting belongs to the stream, not the chain — so it stays available
113
- // at the head, where there is nothing to step back to.
114
- const shouldRestart =
115
- canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !previousUrl);
45
+ React.useEffect(() => {
46
+ controller.updateContext({ player, navigator, entry, playerState });
47
+ }, [controller, player, navigator, entry, playerState]);
116
48
 
117
- if (shouldRestart) {
118
- player?.seekTo(0);
49
+ React.useEffect(() => {
50
+ const subNext = controller.hasNext$.subscribe(setHasNext);
51
+ const subPrev = controller.hasPrevious$.subscribe(setHasPrevious);
119
52
 
120
- return;
121
- }
53
+ return () => {
54
+ subNext.unsubscribe();
55
+ subPrev.unsubscribe();
56
+ controller.destroy();
57
+ };
58
+ }, [controller]);
122
59
 
123
- if (!previousUrl) return;
60
+ const goNext = React.useCallback(
61
+ async () => controller.goNext(),
62
+ [controller]
63
+ );
124
64
 
125
- await navigateTo(previousUrl);
126
- }, [previousUrl, currentTime, canRestart, navigateTo, player]);
65
+ const goPrevious = React.useCallback(
66
+ async () => controller.goPrevious(),
67
+ [controller]
68
+ );
127
69
 
128
70
  return {
129
- hasNext: Boolean(nextUrl),
130
- hasPrevious: Boolean(previousUrl) || canRestart,
71
+ hasNext,
72
+ hasPrevious,
131
73
  goNext,
132
74
  goPrevious,
133
75
  };