@applicaster/quick-brick-player 16.0.0-alpha.6174143047 → 16.0.0-alpha.6409114856

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-alpha.6174143047",
3
+ "version": "16.0.0-alpha.6409114856",
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.2",
39
- "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.9",
40
- "@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.6174143047",
41
- "@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.6174143047",
42
- "@applicaster/zapp-react-native-utils": "16.0.0-alpha.6174143047",
38
+ "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-alpha.6409114856",
39
+ "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.52",
40
+ "@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.6409114856",
41
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.6409114856",
42
+ "@applicaster/zapp-react-native-utils": "16.0.0-alpha.6409114856",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -0,0 +1,66 @@
1
+ import {
2
+ nextTrackButton,
3
+ previousTrackButton,
4
+ usesPlaylistControls,
5
+ } from "../utils";
6
+
7
+ describe("usesPlaylistControls", () => {
8
+ const configuredAs = (layout?: unknown): GetValue =>
9
+ ((key: string) =>
10
+ key === "player_controls_layout" ? layout : null) as unknown as GetValue;
11
+
12
+ it("opts in only for the playlist controls value", () => {
13
+ expect(usesPlaylistControls(configuredAs("playlist_controls"))).toBe(true);
14
+ });
15
+
16
+ it("keeps the original controls for the default value", () => {
17
+ expect(
18
+ usesPlaylistControls(configuredAs("playback_speed_and_sleep_timer"))
19
+ ).toBe(false);
20
+ });
21
+
22
+ it.each([
23
+ ["an undeclared key", undefined],
24
+ ["a null value", null],
25
+ ["an empty string", ""],
26
+ ["an unrecognised value", "something_else"],
27
+ ])("keeps the original controls for %s", (_label, layout) => {
28
+ expect(usesPlaylistControls(configuredAs(layout))).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("track control buttons", () => {
33
+ it("builds a previous button using the previous_track icon", () => {
34
+ const onPress = jest.fn();
35
+
36
+ expect(previousTrackButton({ isTablet: false, onPress })).toMatchObject({
37
+ size: "small",
38
+ icon: "previous_track",
39
+ onPress,
40
+ });
41
+ });
42
+
43
+ it("builds a next button using the next_track icon", () => {
44
+ const onPress = jest.fn();
45
+
46
+ expect(nextTrackButton({ isTablet: false, onPress })).toMatchObject({
47
+ size: "small",
48
+ icon: "next_track",
49
+ onPress,
50
+ });
51
+ });
52
+
53
+ it("propagates the disabled flag", () => {
54
+ expect(
55
+ nextTrackButton({ isTablet: false, onPress: jest.fn(), disabled: true })
56
+ ).toMatchObject({ disabled: true });
57
+
58
+ expect(
59
+ previousTrackButton({
60
+ isTablet: false,
61
+ onPress: jest.fn(),
62
+ disabled: true,
63
+ })
64
+ ).toMatchObject({ disabled: true });
65
+ });
66
+ });
@@ -18,14 +18,18 @@ import { VerticalGutter } from "./VerticalGutter";
18
18
 
19
19
  import {
20
20
  forwardButton,
21
+ nextTrackButton,
21
22
  playbackButton,
23
+ previousTrackButton,
22
24
  rewindButton,
23
25
  skipToLiveButton,
24
26
  skipToStartButton,
25
27
  sleepButton,
26
28
  speedButton,
29
+ usesPlaylistControls,
27
30
  } from "./utils";
28
31
  import { useSleepTimerModal } from "../../useSleepTimerModal";
32
+ import { usePlaylistNavigation } from "../../usePlaylistNavigation";
29
33
 
30
34
  const styles = StyleSheet.create({
31
35
  flex: {
@@ -107,6 +111,13 @@ export const Controls = (props: Props) => {
107
111
  configuration
108
112
  );
109
113
 
114
+ const { hasNext, hasPrevious, goNext, goPrevious } = usePlaylistNavigation(
115
+ entry,
116
+ playerState
117
+ );
118
+
119
+ const isPlaylistControlsLayout = usesPlaylistControls(value);
120
+
110
121
  const timerRemaining =
111
122
  sleepTimerState.timerDate !== 0 && +sleepTimerState.timerDate > Date.now()
112
123
  ? sleepTimerState.timerDate
@@ -203,6 +214,22 @@ export const Controls = (props: Props) => {
203
214
  return [skipToStart, rewind, play, forward, skipToLive];
204
215
  }
205
216
 
217
+ if (isPlaylistControlsLayout) {
218
+ const previous = previousTrackButton({
219
+ isTablet,
220
+ onPress: goPrevious,
221
+ disabled: !hasPrevious,
222
+ });
223
+
224
+ const next = nextTrackButton({
225
+ isTablet,
226
+ onPress: goNext,
227
+ disabled: !hasNext,
228
+ });
229
+
230
+ return [previous, rewind, play, forward, next];
231
+ }
232
+
206
233
  return [speed, rewind, play, forward, sleep];
207
234
  }, [
208
235
  playerState,
@@ -220,6 +247,11 @@ export const Controls = (props: Props) => {
220
247
  playbackSleepName,
221
248
  controller,
222
249
  isLiveShowFullControls,
250
+ isPlaylistControlsLayout,
251
+ hasNext,
252
+ hasPrevious,
253
+ goNext,
254
+ goPrevious,
223
255
  ]);
224
256
 
225
257
  return (
@@ -103,3 +103,29 @@ export const speedButton = (props: ButtonsProps): ControlsButton =>
103
103
 
104
104
  export const sleepButton = (props: ButtonsProps): ControlsButton =>
105
105
  sleepButtonByName(props);
106
+
107
+ // Opt-in: a player plugin that does not declare this key returns undefined,
108
+ // which has to read as the default rather than as an opt-in.
109
+ export const usesPlaylistControls = (value: GetValue): boolean =>
110
+ value("player_controls_layout") === "playlist_controls";
111
+
112
+ type TrackButtonProps = ButtonsProps & { disabled?: boolean };
113
+
114
+ const trackButtonByName = ({
115
+ name,
116
+ onPress,
117
+ style,
118
+ disabled,
119
+ }: TrackButtonProps & { name: string }): ControlsButton => ({
120
+ size: "small",
121
+ icon: name,
122
+ onPress,
123
+ style,
124
+ disabled,
125
+ });
126
+
127
+ export const previousTrackButton = (props: TrackButtonProps): ControlsButton =>
128
+ trackButtonByName({ ...props, name: "previous_track" });
129
+
130
+ export const nextTrackButton = (props: TrackButtonProps): ControlsButton =>
131
+ trackButtonByName({ ...props, name: "next_track" });
@@ -0,0 +1,376 @@
1
+ import { renderHook, act } from "@testing-library/react-native";
2
+ import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
3
+ import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
4
+ import { loadFeedEntry } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
5
+
6
+ import { usePlaylistNavigation } from "../usePlaylistNavigation";
7
+
8
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
9
+ useNavigation: jest.fn(),
10
+ }));
11
+
12
+ jest.mock(
13
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer",
14
+ () => ({ usePlayer: jest.fn() })
15
+ );
16
+
17
+ jest.mock(
18
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils",
19
+ () => ({
20
+ loadFeedEntry: jest.fn(),
21
+ retrieveFeedUrl: (entry) => entry?.extensions?.play_next_feed_url,
22
+ retrievePlayPreviousFeedUrl: (entry) =>
23
+ entry?.extensions?.play_previous_feed_url,
24
+ })
25
+ );
26
+
27
+ const useNavigationMock = useNavigation as jest.Mock;
28
+ const loadFeedEntryMock = loadFeedEntry as jest.Mock;
29
+ const usePlayerMock = usePlayer as jest.Mock;
30
+
31
+ const NEXT_URL = "https://feeds/next.json";
32
+ const PREV_URL = "https://feeds/prev.json";
33
+
34
+ const entryWith = (extensions: Record<string, unknown>): ZappEntry =>
35
+ ({ id: "current", type: { value: "audio" }, extensions }) as ZappEntry;
36
+
37
+ /** Seekable VOD by default; pass 0 for a live stream with no seek window. */
38
+ const stateAt = (
39
+ currentTime: number,
40
+ seekableDuration = 600
41
+ ): QuickBrickPlayer.PlayerState =>
42
+ ({ currentTime, seekableDuration }) as QuickBrickPlayer.PlayerState;
43
+
44
+ const NOT_SEEKABLE = 0;
45
+
46
+ describe("usePlaylistNavigation", () => {
47
+ let replaceTop: jest.Mock;
48
+ let seekTo: jest.Mock;
49
+
50
+ beforeEach(() => {
51
+ replaceTop = jest.fn();
52
+ seekTo = jest.fn();
53
+
54
+ useNavigationMock.mockReturnValue({ replaceTop });
55
+
56
+ usePlayerMock.mockReturnValue({ seekTo });
57
+
58
+ loadFeedEntryMock.mockReset();
59
+ });
60
+
61
+ describe("availability", () => {
62
+ it("reports both directions when both extensions are present", () => {
63
+ const entry = entryWith({
64
+ play_next_feed_url: NEXT_URL,
65
+ play_previous_feed_url: PREV_URL,
66
+ });
67
+
68
+ const { result } = renderHook(() =>
69
+ usePlaylistNavigation(entry, stateAt(0))
70
+ );
71
+
72
+ expect(result.current.hasNext).toBe(true);
73
+ expect(result.current.hasPrevious).toBe(true);
74
+ });
75
+
76
+ it("keeps previous available at the head of a chain when the track is seekable", () => {
77
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
78
+
79
+ const { result } = renderHook(() =>
80
+ usePlaylistNavigation(entry, stateAt(0))
81
+ );
82
+
83
+ expect(result.current.hasNext).toBe(true);
84
+ expect(result.current.hasPrevious).toBe(true);
85
+ });
86
+
87
+ it("reports no previous only when the track cannot seek either", () => {
88
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
89
+
90
+ const { result } = renderHook(() =>
91
+ usePlaylistNavigation(entry, stateAt(0, NOT_SEEKABLE))
92
+ );
93
+
94
+ expect(result.current.hasNext).toBe(true);
95
+ expect(result.current.hasPrevious).toBe(false);
96
+ });
97
+
98
+ it("reports neither direction for a non-seekable entry with no extensions", () => {
99
+ const { result } = renderHook(() =>
100
+ usePlaylistNavigation(entryWith({}), stateAt(0, NOT_SEEKABLE))
101
+ );
102
+
103
+ expect(result.current.hasNext).toBe(false);
104
+ expect(result.current.hasPrevious).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("goNext", () => {
109
+ it("plays the entry resolved from the next feed url", async () => {
110
+ const target = { id: "next" } as ZappEntry;
111
+ loadFeedEntryMock.mockResolvedValue(target);
112
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
113
+
114
+ const { result } = renderHook(() =>
115
+ usePlaylistNavigation(entry, stateAt(0))
116
+ );
117
+
118
+ await act(async () => result.current.goNext());
119
+
120
+ expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
121
+ expect(replaceTop).toHaveBeenCalledWith(target);
122
+ });
123
+
124
+ it("does nothing when there is no next url", async () => {
125
+ const { result } = renderHook(() =>
126
+ usePlaylistNavigation(entryWith({}), stateAt(0))
127
+ );
128
+
129
+ await act(async () => result.current.goNext());
130
+
131
+ expect(loadFeedEntryMock).not.toHaveBeenCalled();
132
+ expect(replaceTop).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("leaves playback untouched when the feed fails to load", async () => {
136
+ loadFeedEntryMock.mockRejectedValue(new Error("network down"));
137
+ const entry = entryWith({ play_next_feed_url: NEXT_URL });
138
+
139
+ const { result } = renderHook(() =>
140
+ usePlaylistNavigation(entry, stateAt(0))
141
+ );
142
+
143
+ await act(async () => result.current.goNext());
144
+
145
+ expect(replaceTop).not.toHaveBeenCalled();
146
+ });
147
+ });
148
+
149
+ describe("goPrevious", () => {
150
+ it("restarts the current track when past the threshold", async () => {
151
+ const entry = entryWith({ play_previous_feed_url: PREV_URL });
152
+
153
+ const { result } = renderHook(() =>
154
+ usePlaylistNavigation(entry, stateAt(10))
155
+ );
156
+
157
+ await act(async () => result.current.goPrevious());
158
+
159
+ expect(seekTo).toHaveBeenCalledWith(0);
160
+ expect(replaceTop).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it("plays the previous entry when before the threshold", async () => {
164
+ const target = { id: "prev" } as ZappEntry;
165
+ loadFeedEntryMock.mockResolvedValue(target);
166
+ const entry = entryWith({ play_previous_feed_url: PREV_URL });
167
+
168
+ const { result } = renderHook(() =>
169
+ usePlaylistNavigation(entry, stateAt(1))
170
+ );
171
+
172
+ await act(async () => result.current.goPrevious());
173
+
174
+ expect(seekTo).not.toHaveBeenCalled();
175
+ expect(replaceTop).toHaveBeenCalledWith(target);
176
+ });
177
+
178
+ it("restarts the track when there is no previous entry to move to", async () => {
179
+ const { result } = renderHook(() =>
180
+ usePlaylistNavigation(
181
+ entryWith({ play_next_feed_url: NEXT_URL }),
182
+ stateAt(390)
183
+ )
184
+ );
185
+
186
+ await act(async () => result.current.goPrevious());
187
+
188
+ expect(seekTo).toHaveBeenCalledWith(0);
189
+ expect(replaceTop).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it("restarts even before the threshold when there is no previous entry", async () => {
193
+ const { result } = renderHook(() =>
194
+ usePlaylistNavigation(entryWith({}), stateAt(1))
195
+ );
196
+
197
+ await act(async () => result.current.goPrevious());
198
+
199
+ expect(seekTo).toHaveBeenCalledWith(0);
200
+ expect(replaceTop).not.toHaveBeenCalled();
201
+ });
202
+
203
+ // usePlayer, not playerManager.getActivePlayer — the latter is always the
204
+ // local player, so restarting would be lost while casting.
205
+ it("restarts through the player that owns playback, not the local one", async () => {
206
+ const receiverSeekTo = jest.fn();
207
+ usePlayerMock.mockReturnValue({ seekTo: receiverSeekTo });
208
+
209
+ const { result } = renderHook(() =>
210
+ usePlaylistNavigation(
211
+ entryWith({ play_previous_feed_url: PREV_URL }),
212
+ stateAt(10)
213
+ )
214
+ );
215
+
216
+ await act(async () => result.current.goPrevious());
217
+
218
+ expect(receiverSeekTo).toHaveBeenCalledWith(0);
219
+ });
220
+
221
+ it("does nothing when there is neither a previous entry nor a seek window", async () => {
222
+ const { result } = renderHook(() =>
223
+ usePlaylistNavigation(entryWith({}), stateAt(10, NOT_SEEKABLE))
224
+ );
225
+
226
+ await act(async () => result.current.goPrevious());
227
+
228
+ expect(seekTo).not.toHaveBeenCalled();
229
+ expect(replaceTop).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it("navigates to the previous entry past the threshold when the stream cannot seek", async () => {
233
+ const target = { id: "prev" } as ZappEntry;
234
+ loadFeedEntryMock.mockResolvedValue(target);
235
+
236
+ const { result } = renderHook(() =>
237
+ usePlaylistNavigation(
238
+ entryWith({ play_previous_feed_url: PREV_URL }),
239
+ stateAt(10, NOT_SEEKABLE)
240
+ )
241
+ );
242
+
243
+ await act(async () => result.current.goPrevious());
244
+
245
+ expect(seekTo).not.toHaveBeenCalled();
246
+ expect(replaceTop).toHaveBeenCalledWith(target);
247
+ });
248
+
249
+ it("restarts past the threshold when the stream is seekable", async () => {
250
+ const { result } = renderHook(() =>
251
+ usePlaylistNavigation(
252
+ entryWith({ play_previous_feed_url: PREV_URL }),
253
+ stateAt(10)
254
+ )
255
+ );
256
+
257
+ await act(async () => result.current.goPrevious());
258
+
259
+ expect(seekTo).toHaveBeenCalledWith(0);
260
+ expect(replaceTop).not.toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ describe("double tap guard", () => {
265
+ const bothDirections = {
266
+ play_next_feed_url: NEXT_URL,
267
+ play_previous_feed_url: PREV_URL,
268
+ };
269
+
270
+ /**
271
+ * Makes the next feed request hang until the returned function is called,
272
+ * so a test can fire a second tap while the first is still in flight.
273
+ */
274
+ const deferredFeed = () => {
275
+ const deferred: { complete?: (entry: ZappEntry) => void } = {};
276
+
277
+ loadFeedEntryMock.mockReturnValue(
278
+ new Promise<ZappEntry>((resolve) => {
279
+ deferred.complete = resolve;
280
+ })
281
+ );
282
+
283
+ return (entry: ZappEntry) => deferred.complete(entry);
284
+ };
285
+
286
+ it("ignores a second tap while the first request is still in flight", async () => {
287
+ const complete = deferredFeed();
288
+
289
+ const { result } = renderHook(() =>
290
+ usePlaylistNavigation(entryWith(bothDirections), stateAt(0))
291
+ );
292
+
293
+ await act(async () => {
294
+ const first = result.current.goNext();
295
+ const second = result.current.goNext();
296
+
297
+ complete({ id: "next" } as ZappEntry);
298
+
299
+ await Promise.all([first, second]);
300
+ });
301
+
302
+ expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
303
+ expect(replaceTop).toHaveBeenCalledTimes(1);
304
+ });
305
+
306
+ it("stops Previous racing an in-flight Next", async () => {
307
+ const complete = deferredFeed();
308
+
309
+ const { result } = renderHook(() =>
310
+ usePlaylistNavigation(entryWith(bothDirections), stateAt(0))
311
+ );
312
+
313
+ await act(async () => {
314
+ const next = result.current.goNext();
315
+ const previous = result.current.goPrevious();
316
+
317
+ complete({ id: "next" } as ZappEntry);
318
+
319
+ await Promise.all([next, previous]);
320
+ });
321
+
322
+ expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
323
+
324
+ expect(loadFeedEntryMock).toHaveBeenCalledWith(
325
+ NEXT_URL,
326
+ expect.anything()
327
+ );
328
+ });
329
+
330
+ it("allows a new navigation once the previous one has completed", async () => {
331
+ loadFeedEntryMock.mockResolvedValue({ id: "next" } as ZappEntry);
332
+
333
+ const { result } = renderHook(() =>
334
+ usePlaylistNavigation(entryWith(bothDirections), stateAt(0))
335
+ );
336
+
337
+ await act(async () => result.current.goNext());
338
+ await act(async () => result.current.goNext());
339
+
340
+ expect(loadFeedEntryMock).toHaveBeenCalledTimes(2);
341
+ expect(replaceTop).toHaveBeenCalledTimes(2);
342
+ });
343
+
344
+ it("releases the guard after a failed request so the control can retry", async () => {
345
+ loadFeedEntryMock
346
+ .mockRejectedValueOnce(new Error("network down"))
347
+ .mockResolvedValueOnce({ id: "next" } as ZappEntry);
348
+
349
+ const { result } = renderHook(() =>
350
+ usePlaylistNavigation(entryWith(bothDirections), stateAt(0))
351
+ );
352
+
353
+ await act(async () => result.current.goNext());
354
+
355
+ expect(replaceTop).not.toHaveBeenCalled();
356
+
357
+ await act(async () => result.current.goNext());
358
+
359
+ expect(replaceTop).toHaveBeenCalledTimes(1);
360
+ });
361
+
362
+ it("does not guard the Previous restart, which is local and instant", async () => {
363
+ loadFeedEntryMock.mockResolvedValue({ id: "prev" } as ZappEntry);
364
+
365
+ const { result } = renderHook(() =>
366
+ usePlaylistNavigation(entryWith(bothDirections), stateAt(10))
367
+ );
368
+
369
+ await act(async () => result.current.goPrevious());
370
+ await act(async () => result.current.goPrevious());
371
+
372
+ expect(seekTo).toHaveBeenCalledTimes(2);
373
+ expect(loadFeedEntryMock).not.toHaveBeenCalled();
374
+ });
375
+ });
376
+ });
@@ -0,0 +1,108 @@
1
+ import * as React from "react";
2
+
3
+ import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
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;
18
+
19
+ export type PlaylistNavigation = {
20
+ hasNext: boolean;
21
+ hasPrevious: boolean;
22
+ goNext: () => Promise<void>;
23
+ goPrevious: () => Promise<void>;
24
+ };
25
+
26
+ /**
27
+ * Each entry points at the feed holding its neighbour, so the chain walks
28
+ * itself and needs no client-side queue.
29
+ */
30
+ export const usePlaylistNavigation = (
31
+ entry: ZappEntry,
32
+ playerState: QuickBrickPlayer.PlayerState
33
+ ): PlaylistNavigation => {
34
+ const navigator = useNavigation();
35
+ // Not playerManager.getActivePlayer(): that is always the local player, so
36
+ // seeking would miss the receiver while casting.
37
+ const player = usePlayer();
38
+
39
+ const nextUrl = retrieveFeedUrl(entry);
40
+ const previousUrl = retrievePlayPreviousFeedUrl(entry);
41
+ const currentTime = playerState?.currentTime ?? 0;
42
+
43
+ const canRestart = (playerState?.seekableDuration ?? 0) > 0;
44
+
45
+ const isNavigatingRef = React.useRef(false);
46
+
47
+ const navigateTo = React.useCallback(
48
+ async (feedUrl: string) => {
49
+ // A second tap mid-flight resolves to the same target, since `entry` has
50
+ // not been replaced yet.
51
+ if (isNavigatingRef.current) {
52
+ return;
53
+ }
54
+
55
+ isNavigatingRef.current = true;
56
+
57
+ try {
58
+ const target = await loadFeedEntry(feedUrl, entry);
59
+
60
+ log_info(`navigateTo: playing "${target.title}", id: ${target.id}`, {
61
+ feedUrl,
62
+ targetId: target.id,
63
+ });
64
+
65
+ navigator.replaceTop(target);
66
+ } catch (error) {
67
+ log_error("navigateTo: could not play the neighbouring entry", {
68
+ error,
69
+ feedUrl,
70
+ });
71
+ } finally {
72
+ // Released on failure too, so one bad request cannot latch the control.
73
+ isNavigatingRef.current = false;
74
+ }
75
+ },
76
+ [entry, navigator]
77
+ );
78
+
79
+ const goNext = React.useCallback(async () => {
80
+ if (!nextUrl) return;
81
+
82
+ await navigateTo(nextUrl);
83
+ }, [nextUrl, navigateTo]);
84
+
85
+ const goPrevious = React.useCallback(async () => {
86
+ // Restarting belongs to the stream, not the chain — so it stays available
87
+ // at the head, where there is nothing to step back to.
88
+ const shouldRestart =
89
+ canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !previousUrl);
90
+
91
+ if (shouldRestart) {
92
+ player?.seekTo(0);
93
+
94
+ return;
95
+ }
96
+
97
+ if (!previousUrl) return;
98
+
99
+ await navigateTo(previousUrl);
100
+ }, [previousUrl, currentTime, canRestart, navigateTo, player]);
101
+
102
+ return {
103
+ hasNext: Boolean(nextUrl),
104
+ hasPrevious: Boolean(previousUrl) || canRestart,
105
+ goNext,
106
+ goPrevious,
107
+ };
108
+ };
@@ -52,7 +52,7 @@ import { TVPlayerView } from "./PlayerView/TVPlayerView";
52
52
  import PlayerModal, { VideoPlayerModal } from "./PlayerModal";
53
53
  import { DockedControls } from "./AudioLayer/Layout/DockedControls";
54
54
  import { FlexImage } from "./AudioLayer/Layout/PlayerImage/FlexImage";
55
- import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/conts";
55
+ import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
56
56
 
57
57
  const isTvOS = isTvOSPlatform();
58
58