@applicaster/quick-brick-player 16.0.0-rc.9 → 16.0.0-rc.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/Player/AudioLayer/AudioDescription.tsx +46 -11
- package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +38 -48
- package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +87 -0
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +34 -14
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +40 -0
- package/src/Player/AudioLayer/Layout/DockedControls/index.tsx +1 -13
- package/src/Player/AudioLayer/Layout/MobileLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletLandscapeLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletPortraitLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TransportInfo/__tests__/index.test.tsx +257 -0
- package/src/Player/AudioLayer/Layout/TransportInfo/index.tsx +69 -8
- package/src/Player/AudioLayer/PlaylistNavigationController.ts +381 -0
- package/src/Player/AudioLayer/SleepTimerManager.ts +5 -1
- package/src/Player/AudioLayer/SpeedManager.ts +4 -0
- package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
- package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
- package/src/Player/AudioLayer/__tests__/PlaylistNavigationController.test.ts +529 -0
- package/src/Player/AudioLayer/__tests__/SleepTimerManager.test.ts +78 -0
- package/src/Player/AudioLayer/__tests__/SpeedManager.test.ts +57 -0
- package/src/Player/AudioLayer/__tests__/playerActions.test.ts +369 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
- package/src/Player/AudioLayer/playerActions.ts +294 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +76 -0
- package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
- package/src/Player/PlayerModal/VideoPlayerModal.tsx +18 -1
- package/src/Player/PlayerModal/__tests__/VideoPlayerModal.test.tsx +160 -0
- package/src/Player/__tests__/getNativeProps.test.ts +115 -0
- package/src/Player/__tests__/onTracksChanged.test.ts +192 -0
- package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
- package/src/Player/index.tsx +147 -40
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
|
@@ -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
|
+
}
|
|
@@ -41,6 +41,10 @@ export class SleepTimerController {
|
|
|
41
41
|
return this.sleepTimerStateSubject.asObservable();
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
get currentState(): SleepTimerState {
|
|
45
|
+
return this.sleepTimerStateSubject.getValue();
|
|
46
|
+
}
|
|
47
|
+
|
|
44
48
|
private async scheduleSleep(sleepDate): Promise<boolean> {
|
|
45
49
|
log_debug(`Setting sleep interval to ${sleepDate}`);
|
|
46
50
|
|
|
@@ -118,7 +122,7 @@ export class SleepTimerController {
|
|
|
118
122
|
|
|
119
123
|
log_debug("Sleep state restored");
|
|
120
124
|
|
|
121
|
-
this.setSleepTimer(item as SleepTimer, futureSleepDate);
|
|
125
|
+
await this.setSleepTimer(item as SleepTimer, futureSleepDate);
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
// Will be called if user closed the player explicitly.
|
|
@@ -33,6 +33,10 @@ export class SpeedController {
|
|
|
33
33
|
return this.playbackSpeedSubject.asObservable();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
get currentSpeed(): PlaybackSpeed {
|
|
37
|
+
return this.playbackSpeedSubject.getValue();
|
|
38
|
+
}
|
|
39
|
+
|
|
36
40
|
async loadSpeed() {
|
|
37
41
|
const speed = await localStorage.getItem(SPEED_PREFERENCE_KEY, NAMESPACE);
|
|
38
42
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { StyleSheet } from "react-native";
|
|
3
|
+
import { render } from "@testing-library/react-native";
|
|
4
|
+
|
|
5
|
+
import { AudioDescription } from "../AudioDescription";
|
|
6
|
+
|
|
7
|
+
// `title`/`summary` come from the player manager's content channel rather than
|
|
8
|
+
// from props or context, so the hook is mocked here. Read lazily inside the
|
|
9
|
+
// factory: `jest.mock` is hoisted above the `let`, so capturing eagerly would
|
|
10
|
+
// freeze it at `undefined`.
|
|
11
|
+
let mockContent: {
|
|
12
|
+
title: Option<string | number>;
|
|
13
|
+
summary: Option<string | number>;
|
|
14
|
+
} = { title: "A Title", summary: "A Summary" };
|
|
15
|
+
|
|
16
|
+
jest.mock(
|
|
17
|
+
"@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerContent",
|
|
18
|
+
() => ({ usePlayerContent: () => mockContent })
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockContent = { title: "A Title", summary: "A Summary" };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const layoutState = { inline: false, docked: false, isModal: true };
|
|
26
|
+
|
|
27
|
+
const valueFrom =
|
|
28
|
+
(config: Record<string, unknown>): GetValue =>
|
|
29
|
+
(key: string) =>
|
|
30
|
+
(config[key] ?? null) as never;
|
|
31
|
+
|
|
32
|
+
const renderDescription = (
|
|
33
|
+
config: Record<string, unknown>,
|
|
34
|
+
state = layoutState
|
|
35
|
+
) =>
|
|
36
|
+
render(
|
|
37
|
+
<AudioDescription
|
|
38
|
+
layoutState={state}
|
|
39
|
+
value={valueFrom(config)}
|
|
40
|
+
maxWidth={400}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// The wrapper's style prop is an array, so flatten before reading height.
|
|
45
|
+
const wrapperHeight = (tree) =>
|
|
46
|
+
StyleSheet.flatten(tree.toJSON().props.style).height;
|
|
47
|
+
|
|
48
|
+
describe("AudioDescription", () => {
|
|
49
|
+
it("renders both texts when both toggles are on", () => {
|
|
50
|
+
const { queryByText } = renderDescription({ title: true, subtitle: true });
|
|
51
|
+
|
|
52
|
+
expect(queryByText("A Title")).toBeTruthy();
|
|
53
|
+
expect(queryByText("A Summary")).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("hides the title when the title toggle is off", () => {
|
|
57
|
+
const { queryByText } = renderDescription({ title: false, subtitle: true });
|
|
58
|
+
|
|
59
|
+
expect(queryByText("A Title")).toBeNull();
|
|
60
|
+
expect(queryByText("A Summary")).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("hides the summary when the subtitle toggle is off", () => {
|
|
64
|
+
const { queryByText } = renderDescription({ title: true, subtitle: false });
|
|
65
|
+
|
|
66
|
+
expect(queryByText("A Title")).toBeTruthy();
|
|
67
|
+
expect(queryByText("A Summary")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("renders neither text when both toggles are off", () => {
|
|
71
|
+
const { queryByText } = renderDescription({
|
|
72
|
+
title: false,
|
|
73
|
+
subtitle: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(queryByText("A Title")).toBeNull();
|
|
77
|
+
expect(queryByText("A Summary")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reserves no height when both toggles are off", () => {
|
|
81
|
+
const tree = renderDescription({ title: false, subtitle: false });
|
|
82
|
+
|
|
83
|
+
expect(wrapperHeight(tree)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("reserves less height when the title is hidden than when it is shown", () => {
|
|
87
|
+
const withTitle = renderDescription({ title: true, subtitle: true });
|
|
88
|
+
const withoutTitle = renderDescription({ title: false, subtitle: true });
|
|
89
|
+
|
|
90
|
+
expect(wrapperHeight(withoutTitle)).toBeLessThan(wrapperHeight(withTitle));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reserves less height when the subtitle is hidden than when it is shown", () => {
|
|
94
|
+
const withSubtitle = renderDescription({ title: true, subtitle: true });
|
|
95
|
+
const withoutSubtitle = renderDescription({ title: true, subtitle: false });
|
|
96
|
+
|
|
97
|
+
expect(wrapperHeight(withoutSubtitle)).toBeLessThan(
|
|
98
|
+
wrapperHeight(withSubtitle)
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reads the docked toggle keys when docked", () => {
|
|
103
|
+
const { queryByText } = renderDescription(
|
|
104
|
+
{ docked_player_title: true, docked_player_subtitle: false },
|
|
105
|
+
{ ...layoutState, docked: true }
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(queryByText("A Title")).toBeTruthy();
|
|
109
|
+
expect(queryByText("A Summary")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Live audio resolves its text asynchronously: the channel feed is polled
|
|
114
|
+
* after playback starts, so the first render has no programme title yet. The
|
|
115
|
+
* row must hold its place across that transition instead of collapsing and
|
|
116
|
+
* expanding a second later.
|
|
117
|
+
*/
|
|
118
|
+
describe("layout stability while the text is still missing", () => {
|
|
119
|
+
it("reserves the same height with and without text", () => {
|
|
120
|
+
const withText = renderDescription({ title: true, subtitle: true });
|
|
121
|
+
|
|
122
|
+
mockContent = { title: null, summary: null };
|
|
123
|
+
|
|
124
|
+
const withoutText = renderDescription({ title: true, subtitle: true });
|
|
125
|
+
|
|
126
|
+
expect(wrapperHeight(withoutText)).toBe(wrapperHeight(withText));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("still renders both rows when the text has not arrived", () => {
|
|
130
|
+
mockContent = { title: null, summary: null };
|
|
131
|
+
|
|
132
|
+
const tree = renderDescription({ title: true, subtitle: true });
|
|
133
|
+
|
|
134
|
+
expect(wrapperHeight(tree)).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("reserves height for the summary even when only the summary is missing", () => {
|
|
138
|
+
const withSummary = renderDescription({ title: true, subtitle: true });
|
|
139
|
+
|
|
140
|
+
mockContent = { title: "A Title", summary: null };
|
|
141
|
+
|
|
142
|
+
const withoutSummary = renderDescription({ title: true, subtitle: true });
|
|
143
|
+
|
|
144
|
+
expect(wrapperHeight(withoutSummary)).toBe(wrapperHeight(withSummary));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -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
|
+
});
|