@applicaster/quick-brick-player 16.0.0-rc.59 → 16.0.0-rc.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/Player/AudioLayer/AudioDescription.tsx +46 -11
- package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +21 -8
- package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +66 -0
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +32 -6
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +26 -0
- package/src/Player/AudioLayer/Layout/DockedControls/index.tsx +1 -13
- package/src/Player/AudioLayer/Layout/MobileLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletLandscapeLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletPortraitLayout.tsx +0 -4
- package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
- package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +134 -0
- package/src/Player/__tests__/getNativeProps.test.ts +115 -0
- package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
- package/src/Player/index.tsx +70 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@testing-library/react-native";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Covers AudioPlayerWrapper's remaining Title/Subtitle responsibility, which is
|
|
6
|
+
* only lifecycle: keep the manager's live-content polling running while this
|
|
7
|
+
* player is mounted. It carries neither the text (consumers read it from the
|
|
8
|
+
* player manager via `usePlayerContent`) nor any decision about the polling -
|
|
9
|
+
* whether the content is live, where the channel's programme feed lives and
|
|
10
|
+
* how often to poll are all `OverlaysObserver`'s.
|
|
11
|
+
*
|
|
12
|
+
* The rest of the chain is covered where each link lives:
|
|
13
|
+
* - is-live / feed url / interval OverlayObserver/__tests__/liveContent.test.ts
|
|
14
|
+
* - polling -> `observer.liveEntry` OverlayObserver/__tests__/liveContent.test.ts
|
|
15
|
+
* - liveEntry -> published content playerManager/__tests__/playerContent.test.ts
|
|
16
|
+
* - published content -> rendered text AudioLayer/__tests__/AudioDescription.test.tsx
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const mockStartLiveContentUpdates = jest.fn();
|
|
20
|
+
const mockStopLiveContentUpdates = jest.fn();
|
|
21
|
+
|
|
22
|
+
const mockPlayer = {
|
|
23
|
+
getOverlayObservable: () => ({
|
|
24
|
+
startLiveContentUpdates: mockStartLiveContentUpdates,
|
|
25
|
+
stopLiveContentUpdates: mockStopLiveContentUpdates,
|
|
26
|
+
}),
|
|
27
|
+
addListener: jest.fn(() => jest.fn()),
|
|
28
|
+
play: jest.fn(),
|
|
29
|
+
pause: jest.fn(),
|
|
30
|
+
rewind: jest.fn(),
|
|
31
|
+
forward: jest.fn(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let mockIsLive = false;
|
|
35
|
+
|
|
36
|
+
jest.mock(
|
|
37
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer",
|
|
38
|
+
() => ({ usePlayer: () => mockPlayer })
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
jest.mock(
|
|
42
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState",
|
|
43
|
+
() => ({
|
|
44
|
+
usePlayerState: () => ({
|
|
45
|
+
isLive: mockIsLive,
|
|
46
|
+
seekableDuration: 0,
|
|
47
|
+
isReadyToPlay: false,
|
|
48
|
+
isBuffering: false,
|
|
49
|
+
isPaused: false,
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
|
|
55
|
+
playerManager: {
|
|
56
|
+
close: jest.fn(),
|
|
57
|
+
isCasting: jest.fn(() => false),
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
|
|
62
|
+
useNavigation: jest.fn(() => ({ maximiseVideoModal: jest.fn() })),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
jest.mock("@applicaster/zapp-react-native-utils/modalState", () => ({
|
|
66
|
+
useModalStoreState: jest.fn(() => ({ visible: false })),
|
|
67
|
+
dismissModal: jest.fn(),
|
|
68
|
+
openBottomSheetModal: jest.fn(),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
jest.mock("../Layout", () => ({
|
|
72
|
+
__esModule: true,
|
|
73
|
+
default: () => null,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
import { AudioPlayerWrapper } from "../AudioPlayerWrapper";
|
|
77
|
+
|
|
78
|
+
const LIVE_ENTRY = {
|
|
79
|
+
id: "entry-1",
|
|
80
|
+
title: "Channel Title",
|
|
81
|
+
summary: "Channel Summary",
|
|
82
|
+
extensions: {
|
|
83
|
+
live: { channel_src: "https://feed.test/subClass/audio" },
|
|
84
|
+
},
|
|
85
|
+
} as any;
|
|
86
|
+
|
|
87
|
+
const baseProps = {
|
|
88
|
+
configuration: { audio_live_player_update_interval: 30 },
|
|
89
|
+
style: { width: 100, height: 100 } as any,
|
|
90
|
+
entry: LIVE_ENTRY,
|
|
91
|
+
layoutState: { inline: false, docked: false, isModal: false, pip: false },
|
|
92
|
+
playNextData: null,
|
|
93
|
+
children: null,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
describe("AudioPlayerWrapper live content polling", () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
jest.clearAllMocks();
|
|
99
|
+
mockIsLive = true;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("asks the observer to keep live content fresh while mounted", () => {
|
|
103
|
+
render(<AudioPlayerWrapper {...baseProps} />);
|
|
104
|
+
|
|
105
|
+
expect(mockStartLiveContentUpdates).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("stops polling on unmount", () => {
|
|
109
|
+
const { unmount } = render(<AudioPlayerWrapper {...baseProps} />);
|
|
110
|
+
|
|
111
|
+
unmount();
|
|
112
|
+
|
|
113
|
+
expect(mockStopLiveContentUpdates).toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("re-evaluates when the player reports the content is live", () => {
|
|
117
|
+
// `isLive` is unknown until the player loads, so the effect has to run
|
|
118
|
+
// again once it settles - the observer decides what to do with it.
|
|
119
|
+
mockIsLive = false;
|
|
120
|
+
|
|
121
|
+
const { rerender } = render(<AudioPlayerWrapper {...baseProps} />);
|
|
122
|
+
|
|
123
|
+
mockStartLiveContentUpdates.mockClear();
|
|
124
|
+
mockIsLive = true;
|
|
125
|
+
|
|
126
|
+
rerender(<AudioPlayerWrapper {...baseProps} />);
|
|
127
|
+
|
|
128
|
+
expect(mockStartLiveContentUpdates).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,387 @@
|
|
|
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: async (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
|
+
const renderPlaylistNavigation = async (
|
|
47
|
+
entry: ZappEntry,
|
|
48
|
+
playerState: QuickBrickPlayer.PlayerState
|
|
49
|
+
) => {
|
|
50
|
+
const rendered = renderHook(() => usePlaylistNavigation(entry, playerState));
|
|
51
|
+
|
|
52
|
+
await act(async () => {});
|
|
53
|
+
|
|
54
|
+
return rendered;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("usePlaylistNavigation", () => {
|
|
58
|
+
let replaceTop: jest.Mock;
|
|
59
|
+
let seekTo: jest.Mock;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
replaceTop = jest.fn();
|
|
63
|
+
seekTo = jest.fn();
|
|
64
|
+
|
|
65
|
+
useNavigationMock.mockReturnValue({ replaceTop });
|
|
66
|
+
|
|
67
|
+
usePlayerMock.mockReturnValue({ seekTo });
|
|
68
|
+
|
|
69
|
+
loadFeedEntryMock.mockReset();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("availability", () => {
|
|
73
|
+
it("reports both directions when both 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 { result } = await renderPlaylistNavigation(entry, stateAt(0));
|
|
80
|
+
|
|
81
|
+
expect(result.current.hasNext).toBe(true);
|
|
82
|
+
expect(result.current.hasPrevious).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("keeps previous available at the head of a chain when the track is seekable", async () => {
|
|
86
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
87
|
+
|
|
88
|
+
const { result } = await renderPlaylistNavigation(entry, stateAt(0));
|
|
89
|
+
|
|
90
|
+
expect(result.current.hasNext).toBe(true);
|
|
91
|
+
expect(result.current.hasPrevious).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reports no previous only when the track cannot seek either", async () => {
|
|
95
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
96
|
+
|
|
97
|
+
const { result } = await renderPlaylistNavigation(
|
|
98
|
+
entry,
|
|
99
|
+
stateAt(0, NOT_SEEKABLE)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(result.current.hasNext).toBe(true);
|
|
103
|
+
expect(result.current.hasPrevious).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reports neither direction for a non-seekable entry with no extensions", async () => {
|
|
107
|
+
const { result } = await renderPlaylistNavigation(
|
|
108
|
+
entryWith({}),
|
|
109
|
+
stateAt(0, NOT_SEEKABLE)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(result.current.hasNext).toBe(false);
|
|
113
|
+
expect(result.current.hasPrevious).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("goNext", () => {
|
|
118
|
+
it("plays the entry resolved from the next feed url", async () => {
|
|
119
|
+
const target = { id: "next" } as ZappEntry;
|
|
120
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
121
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
122
|
+
|
|
123
|
+
const { result } = await renderPlaylistNavigation(entry, stateAt(0));
|
|
124
|
+
|
|
125
|
+
await act(async () => result.current.goNext());
|
|
126
|
+
|
|
127
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
|
|
128
|
+
expect(replaceTop).toHaveBeenCalledWith(target);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does nothing when there is no next url", async () => {
|
|
132
|
+
const { result } = renderHook(() =>
|
|
133
|
+
usePlaylistNavigation(entryWith({}), stateAt(0))
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await act(async () => result.current.goNext());
|
|
137
|
+
|
|
138
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
139
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("leaves playback untouched when the feed fails to load", async () => {
|
|
143
|
+
loadFeedEntryMock.mockRejectedValue(new Error("network down"));
|
|
144
|
+
const entry = entryWith({ play_next_feed_url: NEXT_URL });
|
|
145
|
+
|
|
146
|
+
const { result } = renderHook(() =>
|
|
147
|
+
usePlaylistNavigation(entry, stateAt(0))
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await act(async () => result.current.goNext());
|
|
151
|
+
|
|
152
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("goPrevious", () => {
|
|
157
|
+
it("restarts the current track when past the threshold", async () => {
|
|
158
|
+
const entry = entryWith({ play_previous_feed_url: PREV_URL });
|
|
159
|
+
|
|
160
|
+
const { result } = renderHook(() =>
|
|
161
|
+
usePlaylistNavigation(entry, stateAt(10))
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await act(async () => result.current.goPrevious());
|
|
165
|
+
|
|
166
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
167
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("plays the previous entry when before the threshold", async () => {
|
|
171
|
+
const target = { id: "prev" } as ZappEntry;
|
|
172
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
173
|
+
const entry = entryWith({ play_previous_feed_url: PREV_URL });
|
|
174
|
+
|
|
175
|
+
const { result } = renderHook(() =>
|
|
176
|
+
usePlaylistNavigation(entry, stateAt(1))
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await act(async () => result.current.goPrevious());
|
|
180
|
+
|
|
181
|
+
expect(seekTo).not.toHaveBeenCalled();
|
|
182
|
+
expect(replaceTop).toHaveBeenCalledWith(target);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("restarts the track when there is no previous entry to move to", async () => {
|
|
186
|
+
const { result } = renderHook(() =>
|
|
187
|
+
usePlaylistNavigation(
|
|
188
|
+
entryWith({ play_next_feed_url: NEXT_URL }),
|
|
189
|
+
stateAt(390)
|
|
190
|
+
)
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await act(async () => result.current.goPrevious());
|
|
194
|
+
|
|
195
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
196
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("restarts even before the threshold when there is no previous entry", async () => {
|
|
200
|
+
const { result } = renderHook(() =>
|
|
201
|
+
usePlaylistNavigation(entryWith({}), stateAt(1))
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await act(async () => result.current.goPrevious());
|
|
205
|
+
|
|
206
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
207
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// usePlayer, not playerManager.getActivePlayer — the latter is always the
|
|
211
|
+
// local player, so restarting would be lost while casting.
|
|
212
|
+
it("restarts through the player that owns playback, not the local one", async () => {
|
|
213
|
+
const receiverSeekTo = jest.fn();
|
|
214
|
+
usePlayerMock.mockReturnValue({ seekTo: receiverSeekTo });
|
|
215
|
+
|
|
216
|
+
const { result } = renderHook(() =>
|
|
217
|
+
usePlaylistNavigation(
|
|
218
|
+
entryWith({ play_previous_feed_url: PREV_URL }),
|
|
219
|
+
stateAt(10)
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await act(async () => result.current.goPrevious());
|
|
224
|
+
|
|
225
|
+
expect(receiverSeekTo).toHaveBeenCalledWith(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does nothing when there is neither a previous entry nor a seek window", async () => {
|
|
229
|
+
const { result } = renderHook(() =>
|
|
230
|
+
usePlaylistNavigation(entryWith({}), stateAt(10, NOT_SEEKABLE))
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
await act(async () => result.current.goPrevious());
|
|
234
|
+
|
|
235
|
+
expect(seekTo).not.toHaveBeenCalled();
|
|
236
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("navigates to the previous entry past the threshold when the stream cannot seek", async () => {
|
|
240
|
+
const target = { id: "prev" } as ZappEntry;
|
|
241
|
+
loadFeedEntryMock.mockResolvedValue(target);
|
|
242
|
+
|
|
243
|
+
const { result } = renderHook(() =>
|
|
244
|
+
usePlaylistNavigation(
|
|
245
|
+
entryWith({ play_previous_feed_url: PREV_URL }),
|
|
246
|
+
stateAt(10, NOT_SEEKABLE)
|
|
247
|
+
)
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
await act(async () => result.current.goPrevious());
|
|
251
|
+
|
|
252
|
+
expect(seekTo).not.toHaveBeenCalled();
|
|
253
|
+
expect(replaceTop).toHaveBeenCalledWith(target);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("restarts past the threshold when the stream is seekable", async () => {
|
|
257
|
+
const { result } = renderHook(() =>
|
|
258
|
+
usePlaylistNavigation(
|
|
259
|
+
entryWith({ play_previous_feed_url: PREV_URL }),
|
|
260
|
+
stateAt(10)
|
|
261
|
+
)
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
await act(async () => result.current.goPrevious());
|
|
265
|
+
|
|
266
|
+
expect(seekTo).toHaveBeenCalledWith(0);
|
|
267
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("double tap guard", () => {
|
|
272
|
+
const bothDirections = {
|
|
273
|
+
play_next_feed_url: NEXT_URL,
|
|
274
|
+
play_previous_feed_url: PREV_URL,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Makes the next feed request hang until the returned function is called,
|
|
279
|
+
* so a test can fire a second tap while the first is still in flight.
|
|
280
|
+
*/
|
|
281
|
+
const deferredFeed = () => {
|
|
282
|
+
const deferred: { complete?: (entry: ZappEntry) => void } = {};
|
|
283
|
+
|
|
284
|
+
loadFeedEntryMock.mockReturnValue(
|
|
285
|
+
new Promise<ZappEntry>((resolve) => {
|
|
286
|
+
deferred.complete = resolve;
|
|
287
|
+
})
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return (entry: ZappEntry) => deferred.complete(entry);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
it("ignores a second tap while the first request is still in flight", async () => {
|
|
294
|
+
const complete = deferredFeed();
|
|
295
|
+
|
|
296
|
+
const { result } = await renderPlaylistNavigation(
|
|
297
|
+
entryWith(bothDirections),
|
|
298
|
+
stateAt(0)
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
await act(async () => {
|
|
302
|
+
const first = result.current.goNext();
|
|
303
|
+
const second = result.current.goNext();
|
|
304
|
+
|
|
305
|
+
complete({ id: "next" } as ZappEntry);
|
|
306
|
+
|
|
307
|
+
await Promise.all([first, second]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
|
|
311
|
+
expect(replaceTop).toHaveBeenCalledTimes(1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("stops Previous racing an in-flight Next", async () => {
|
|
315
|
+
const complete = deferredFeed();
|
|
316
|
+
|
|
317
|
+
const { result } = await renderPlaylistNavigation(
|
|
318
|
+
entryWith(bothDirections),
|
|
319
|
+
stateAt(0)
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await act(async () => {
|
|
323
|
+
const next = result.current.goNext();
|
|
324
|
+
const previous = result.current.goPrevious();
|
|
325
|
+
|
|
326
|
+
complete({ id: "next" } as ZappEntry);
|
|
327
|
+
|
|
328
|
+
await Promise.all([next, previous]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(1);
|
|
332
|
+
|
|
333
|
+
expect(loadFeedEntryMock).toHaveBeenCalledWith(
|
|
334
|
+
NEXT_URL,
|
|
335
|
+
expect.anything()
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("allows a new navigation once the previous one has completed", async () => {
|
|
340
|
+
loadFeedEntryMock.mockResolvedValue({ id: "next" } as ZappEntry);
|
|
341
|
+
|
|
342
|
+
const { result } = await renderPlaylistNavigation(
|
|
343
|
+
entryWith(bothDirections),
|
|
344
|
+
stateAt(0)
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
await act(async () => result.current.goNext());
|
|
348
|
+
await act(async () => result.current.goNext());
|
|
349
|
+
|
|
350
|
+
expect(loadFeedEntryMock).toHaveBeenCalledTimes(2);
|
|
351
|
+
expect(replaceTop).toHaveBeenCalledTimes(2);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("releases the guard after a failed request so the control can retry", async () => {
|
|
355
|
+
loadFeedEntryMock
|
|
356
|
+
.mockRejectedValueOnce(new Error("network down"))
|
|
357
|
+
.mockResolvedValueOnce({ id: "next" } as ZappEntry);
|
|
358
|
+
|
|
359
|
+
const { result } = await renderPlaylistNavigation(
|
|
360
|
+
entryWith(bothDirections),
|
|
361
|
+
stateAt(0)
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
await act(async () => result.current.goNext());
|
|
365
|
+
|
|
366
|
+
expect(replaceTop).not.toHaveBeenCalled();
|
|
367
|
+
|
|
368
|
+
await act(async () => result.current.goNext());
|
|
369
|
+
|
|
370
|
+
expect(replaceTop).toHaveBeenCalledTimes(1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("does not guard the Previous restart, which is local and instant", async () => {
|
|
374
|
+
loadFeedEntryMock.mockResolvedValue({ id: "prev" } as ZappEntry);
|
|
375
|
+
|
|
376
|
+
const { result } = renderHook(() =>
|
|
377
|
+
usePlaylistNavigation(entryWith(bothDirections), stateAt(10))
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
await act(async () => result.current.goPrevious());
|
|
381
|
+
await act(async () => result.current.goPrevious());
|
|
382
|
+
|
|
383
|
+
expect(seekTo).toHaveBeenCalledTimes(2);
|
|
384
|
+
expect(loadFeedEntryMock).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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, 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
|
+
}
|
|
49
|
+
})
|
|
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
|
+
);
|
|
104
|
+
|
|
105
|
+
const goNext = React.useCallback(async () => {
|
|
106
|
+
if (!nextUrl) return;
|
|
107
|
+
|
|
108
|
+
await navigateTo(nextUrl);
|
|
109
|
+
}, [nextUrl, navigateTo]);
|
|
110
|
+
|
|
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);
|
|
116
|
+
|
|
117
|
+
if (shouldRestart) {
|
|
118
|
+
player?.seekTo(0);
|
|
119
|
+
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!previousUrl) return;
|
|
124
|
+
|
|
125
|
+
await navigateTo(previousUrl);
|
|
126
|
+
}, [previousUrl, currentTime, canRestart, navigateTo, player]);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
hasNext: Boolean(nextUrl),
|
|
130
|
+
hasPrevious: Boolean(previousUrl) || canRestart,
|
|
131
|
+
goNext,
|
|
132
|
+
goPrevious,
|
|
133
|
+
};
|
|
134
|
+
};
|