@applicaster/quick-brick-player 16.0.0-alpha.1334507601 → 16.0.0-alpha.1444784746

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.1334507601",
3
+ "version": "16.0.0-alpha.1444784746",
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.73",
38
+ "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.93",
39
39
  "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.69",
40
- "@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.1334507601",
41
- "@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.1334507601",
42
- "@applicaster/zapp-react-native-utils": "16.0.0-alpha.1334507601",
40
+ "@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.1444784746",
41
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.1444784746",
42
+ "@applicaster/zapp-react-native-utils": "16.0.0-alpha.1444784746",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -129,7 +129,17 @@ export class PlaylistNavigationController {
129
129
  try {
130
130
  const plugins = appStore?.get?.("plugins") || [];
131
131
  const queuePlugin = findPluginByIdentifier("queue-action", plugins, true);
132
- const QueueManager = (queuePlugin as any)?.module?.QueueManager;
132
+ const module = (queuePlugin as any)?.module;
133
+ const QueueManager = module?.QueueManager;
134
+ const setupQuickBrickQueue = module?.setupQuickBrickQueue;
135
+
136
+ if (
137
+ QueueManager &&
138
+ !QueueManager.isInitialized?.() &&
139
+ typeof setupQuickBrickQueue === "function"
140
+ ) {
141
+ setupQuickBrickQueue();
142
+ }
133
143
 
134
144
  if (QueueManager?.isInitialized?.()) {
135
145
  return QueueManager.instance;
@@ -192,19 +202,15 @@ export class PlaylistNavigationController {
192
202
  return;
193
203
  }
194
204
 
195
- const isPlayingFromQueue = Boolean(this.entry?.extensions?.queueItemId);
196
205
  const staticNextUrl = this.entry?.extensions?.play_next_feed_url;
197
206
  const staticPrevUrl = this.entry?.extensions?.play_previous_feed_url;
198
207
 
199
- const hasNext = isPlayingFromQueue
200
- ? upcoming.length > 0 || Boolean(staticNextUrl)
201
- : Boolean(staticNextUrl) || upcoming.length > 0;
208
+ const hasNext = upcoming.length > 0 || Boolean(staticNextUrl);
202
209
 
203
210
  const canRestart = (this.playerState?.seekableDuration ?? 0) > 0;
204
211
 
205
- const hasPrevious = isPlayingFromQueue
206
- ? history.length > 0 || Boolean(staticPrevUrl) || canRestart
207
- : Boolean(staticPrevUrl) || history.length > 0 || canRestart;
212
+ const hasPrevious =
213
+ history.length > 0 || Boolean(staticPrevUrl) || canRestart;
208
214
 
209
215
  if (this.hasNext$.getValue() !== hasNext) {
210
216
  this.hasNext$.next(hasNext);
@@ -227,9 +233,7 @@ export class PlaylistNavigationController {
227
233
 
228
234
  if (this.isDestroyed) return;
229
235
 
230
- const isPlayingFromQueue = Boolean(this.entry?.extensions?.queueItemId);
231
-
232
- if (isPlayingFromQueue && upcoming.length > 0) {
236
+ if (upcoming.length > 0) {
233
237
  const nextItem = upcoming[0];
234
238
 
235
239
  log_info(
@@ -259,20 +263,6 @@ export class PlaylistNavigationController {
259
263
  );
260
264
 
261
265
  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
266
  }
277
267
  } catch (error) {
278
268
  log_error("goNext: could not navigate to next entry", { error });
@@ -293,7 +283,6 @@ export class PlaylistNavigationController {
293
283
 
294
284
  if (this.isDestroyed) return;
295
285
 
296
- const isPlayingFromQueue = Boolean(this.entry?.extensions?.queueItemId);
297
286
  const staticPrevUrl = this.entry?.extensions?.play_previous_feed_url;
298
287
 
299
288
  const currentTime = this.playerState?.currentTime ?? 0;
@@ -302,9 +291,7 @@ export class PlaylistNavigationController {
302
291
  const hasQueueHistory = history.length > 0;
303
292
  const hasStaticPrev = Boolean(staticPrevUrl);
304
293
 
305
- const hasTarget = isPlayingFromQueue
306
- ? hasQueueHistory || hasStaticPrev
307
- : hasStaticPrev || hasQueueHistory;
294
+ const hasTarget = hasQueueHistory || hasStaticPrev;
308
295
 
309
296
  const shouldRestart =
310
297
  canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !hasTarget);
@@ -316,7 +303,7 @@ export class PlaylistNavigationController {
316
303
  return;
317
304
  }
318
305
 
319
- if (isPlayingFromQueue && hasQueueHistory) {
306
+ if (hasQueueHistory) {
320
307
  const prevItem = history[history.length - 1];
321
308
 
322
309
  log_info(
@@ -344,20 +331,6 @@ export class PlaylistNavigationController {
344
331
  );
345
332
 
346
333
  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
334
  }
362
335
  } catch (error) {
363
336
  log_error("goPrevious: could not navigate to previous entry", { error });
@@ -380,7 +380,7 @@ describe("PlaylistNavigationController", () => {
380
380
  controller.destroy();
381
381
  });
382
382
 
383
- it("prioritizes static feed URLs when entry is not from queue", async () => {
383
+ it("prioritizes queue navigation over static feed URLs even when entry is not from queue", async () => {
384
384
  const queueNextItem = { id: "queue_next" } as ZappEntry;
385
385
  const queuePrevItem = { id: "queue_prev" } as ZappEntry;
386
386
  const staticNextTarget = { id: "static_next" } as ZappEntry;
@@ -411,30 +411,28 @@ describe("PlaylistNavigationController", () => {
411
411
  });
412
412
 
413
413
  await controller.goNext();
414
- expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
415
- expect(navigator.replaceTop).toHaveBeenCalledWith(staticNextTarget);
414
+ expect(loadFeedEntryMock).not.toHaveBeenCalled();
415
+ expect(navigator.replaceTop).toHaveBeenCalledWith(queueNextItem);
416
416
 
417
417
  await controller.goPrevious();
418
- expect(loadFeedEntryMock).toHaveBeenCalledWith(PREV_URL, entry);
419
- expect(navigator.replaceTop).toHaveBeenCalledWith(staticPrevTarget);
418
+ expect(loadFeedEntryMock).not.toHaveBeenCalled();
419
+ expect(navigator.replaceTop).toHaveBeenCalledWith(queuePrevItem);
420
420
 
421
421
  controller.destroy();
422
422
  });
423
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;
424
+ it("falls back to static feed URLs when queue is empty", async () => {
425
+ const staticNextTarget = { id: "static_next" } as ZappEntry;
426
+ const staticPrevTarget = { id: "static_prev" } as ZappEntry;
427
427
 
428
- mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([
429
- queueNextItem,
430
- ]);
428
+ mockQueueManager.instance.getUpcomingQueue.mockResolvedValue([]);
429
+ mockQueueManager.instance.getHistoryQueue.mockResolvedValue([]);
431
430
 
432
- mockQueueManager.instance.getHistoryQueue.mockResolvedValue([
433
- queuePrevItem,
434
- ]);
431
+ loadFeedEntryMock.mockImplementation((url) =>
432
+ Promise.resolve(url === NEXT_URL ? staticNextTarget : staticPrevTarget)
433
+ );
435
434
 
436
435
  const entry = entryWith({
437
- queueItemId: "current_queue_slot",
438
436
  play_next_feed_url: NEXT_URL,
439
437
  play_previous_feed_url: PREV_URL,
440
438
  });
@@ -447,11 +445,12 @@ describe("PlaylistNavigationController", () => {
447
445
  });
448
446
 
449
447
  await controller.goNext();
450
- expect(loadFeedEntryMock).not.toHaveBeenCalled();
451
- expect(navigator.replaceTop).toHaveBeenCalledWith(queueNextItem);
448
+ expect(loadFeedEntryMock).toHaveBeenCalledWith(NEXT_URL, entry);
449
+ expect(navigator.replaceTop).toHaveBeenCalledWith(staticNextTarget);
452
450
 
453
451
  await controller.goPrevious();
454
- expect(navigator.replaceTop).toHaveBeenCalledWith(queuePrevItem);
452
+ expect(loadFeedEntryMock).toHaveBeenCalledWith(PREV_URL, entry);
453
+ expect(navigator.replaceTop).toHaveBeenCalledWith(staticPrevTarget);
455
454
 
456
455
  controller.destroy();
457
456
  });
@@ -2,8 +2,12 @@ import { renderHook, act } from "@testing-library/react-native";
2
2
  import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
3
3
  import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
4
4
  import { loadFeedEntry } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
5
+ import { RemoteCommand } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
5
6
 
6
- import { usePlaylistNavigation } from "../usePlaylistNavigation";
7
+ import {
8
+ getTransportControls,
9
+ usePlaylistNavigation,
10
+ } from "../usePlaylistNavigation";
7
11
 
8
12
  jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
9
13
  useNavigation: jest.fn(),
@@ -24,6 +28,13 @@ jest.mock(
24
28
  })
25
29
  );
26
30
 
31
+ jest.mock(
32
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand",
33
+ () => ({
34
+ PlayerNativeCommandTypes: { setPlayerControls: "setPlayerControls" },
35
+ })
36
+ );
37
+
27
38
  const useNavigationMock = useNavigation as jest.Mock;
28
39
  const loadFeedEntryMock = loadFeedEntry as jest.Mock;
29
40
  const usePlayerMock = usePlayer as jest.Mock;
@@ -384,4 +395,145 @@ describe("usePlaylistNavigation", () => {
384
395
  expect(loadFeedEntryMock).not.toHaveBeenCalled();
385
396
  });
386
397
  });
398
+
399
+ describe("remote commands", () => {
400
+ let listeners: Record<string, QuickBrickPlayer.SharedPlayerCallBacks>;
401
+ const command = jest.fn();
402
+
403
+ const remote = (id: RemoteCommand) =>
404
+ act(async () => {
405
+ Object.values(listeners).forEach((l) => l.onRemoteCommand?.({ id }));
406
+ });
407
+
408
+ beforeEach(() => {
409
+ listeners = {};
410
+ command.mockClear();
411
+
412
+ usePlayerMock.mockReturnValue({
413
+ playerId: "player-1",
414
+ command,
415
+ seekTo,
416
+ addListener: ({ id, listener }) => {
417
+ listeners[id] = listener;
418
+
419
+ return () => delete listeners[id];
420
+ },
421
+ });
422
+ });
423
+
424
+ it("plays next when the lock screen / CarPlay next button is pressed", async () => {
425
+ const target = { id: "next" } as ZappEntry;
426
+ loadFeedEntryMock.mockResolvedValue(target);
427
+
428
+ await renderPlaylistNavigation(
429
+ entryWith({ play_next_feed_url: NEXT_URL }),
430
+ stateAt(0)
431
+ );
432
+
433
+ await remote(RemoteCommand.NextTrack);
434
+
435
+ expect(replaceTop).toHaveBeenCalledWith(target);
436
+ });
437
+
438
+ it("restarts the track on remote previous past the threshold", async () => {
439
+ await renderPlaylistNavigation(entryWith({}), stateAt(10));
440
+
441
+ await remote(RemoteCommand.PreviousTrack);
442
+
443
+ expect(seekTo).toHaveBeenCalledWith(0);
444
+ });
445
+
446
+ it("ignores commands native handles itself", async () => {
447
+ await renderPlaylistNavigation(
448
+ entryWith({ play_next_feed_url: NEXT_URL }),
449
+ stateAt(10)
450
+ );
451
+
452
+ await remote(RemoteCommand.Pause);
453
+
454
+ expect(replaceTop).not.toHaveBeenCalled();
455
+ expect(seekTo).not.toHaveBeenCalled();
456
+ });
457
+
458
+ it("sends native the controls snapshot to show", async () => {
459
+ await renderPlaylistNavigation(
460
+ entryWith({ play_next_feed_url: NEXT_URL }),
461
+ stateAt(0, NOT_SEEKABLE)
462
+ );
463
+
464
+ expect(command).toHaveBeenLastCalledWith("setPlayerControls", {
465
+ transport: [
466
+ RemoteCommand.Play,
467
+ RemoteCommand.Pause,
468
+ RemoteCommand.SkipBackward,
469
+ RemoteCommand.NextTrack,
470
+ ],
471
+ });
472
+ });
473
+
474
+ it("stops listening once unmounted", async () => {
475
+ const { unmount } = await renderPlaylistNavigation(
476
+ entryWith({ play_next_feed_url: NEXT_URL }),
477
+ stateAt(0)
478
+ );
479
+
480
+ unmount();
481
+
482
+ expect(listeners).toEqual({});
483
+ });
484
+ });
485
+ });
486
+
487
+ describe("getTransportControls", () => {
488
+ it("falls back to skip backward and leaves next out when there is no playlist", () => {
489
+ expect(
490
+ getTransportControls({
491
+ hasNext: false,
492
+ hasPrevious: false,
493
+ isLive: false,
494
+ })
495
+ ).toEqual([
496
+ RemoteCommand.Play,
497
+ RemoteCommand.Pause,
498
+ RemoteCommand.SkipBackward,
499
+ ]);
500
+ });
501
+
502
+ it("leaves next out without a next item, so it shows disabled", () => {
503
+ expect(
504
+ getTransportControls({ hasNext: false, hasPrevious: true, isLive: false })
505
+ ).toEqual([
506
+ RemoteCommand.Play,
507
+ RemoteCommand.Pause,
508
+ RemoteCommand.PreviousTrack,
509
+ ]);
510
+ });
511
+
512
+ it("puts track buttons in place of skip ones", () => {
513
+ expect(
514
+ getTransportControls({ hasNext: true, hasPrevious: true, isLive: false })
515
+ ).toEqual([
516
+ RemoteCommand.Play,
517
+ RemoteCommand.Pause,
518
+ RemoteCommand.PreviousTrack,
519
+ RemoteCommand.NextTrack,
520
+ ]);
521
+ });
522
+
523
+ it("uses stop and never skips on live", () => {
524
+ expect(
525
+ getTransportControls({ hasNext: false, hasPrevious: false, isLive: true })
526
+ ).toEqual([RemoteCommand.Play, RemoteCommand.Stop]);
527
+ });
528
+
529
+ it("keeps track buttons on live, e.g. a list of radio stations", () => {
530
+ expect(
531
+ getTransportControls({ hasNext: true, hasPrevious: true, isLive: true })
532
+ ).toEqual([
533
+ RemoteCommand.Play,
534
+ RemoteCommand.Stop,
535
+ RemoteCommand.PreviousTrack,
536
+ RemoteCommand.NextTrack,
537
+ ]);
538
+ });
387
539
  });
@@ -2,8 +2,45 @@ 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 { PlayerNativeCommandTypes } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerNativeCommand";
6
+ import { RemoteCommand } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
5
7
  import { PlaylistNavigationController } from "./PlaylistNavigationController";
6
8
 
9
+ const REMOTE_COMMANDS_LISTENER_ID = "playlist-navigation-remote-commands";
10
+
11
+ /**
12
+ * Buttons native should expose on the lock screen / Control Center / CarPlay.
13
+ * Native enables exactly these and disables the rest. A track button takes its
14
+ * slot over the skip one, since the system shows only one of them per side.
15
+ * Without a next item next is left out, so the system shows it disabled.
16
+ */
17
+ export const getTransportControls = ({
18
+ hasNext,
19
+ hasPrevious,
20
+ isLive,
21
+ }: {
22
+ hasNext: boolean;
23
+ hasPrevious: boolean;
24
+ isLive: boolean;
25
+ }): RemoteCommand[] => {
26
+ const commands = [
27
+ RemoteCommand.Play,
28
+ isLive ? RemoteCommand.Stop : RemoteCommand.Pause,
29
+ ];
30
+
31
+ if (hasPrevious) {
32
+ commands.push(RemoteCommand.PreviousTrack);
33
+ } else if (!isLive) {
34
+ commands.push(RemoteCommand.SkipBackward);
35
+ }
36
+
37
+ if (hasNext) {
38
+ commands.push(RemoteCommand.NextTrack);
39
+ }
40
+
41
+ return commands;
42
+ };
43
+
7
44
  export type PlaylistNavigation = {
8
45
  hasNext: boolean;
9
46
  hasPrevious: boolean;
@@ -57,6 +94,41 @@ export const usePlaylistNavigation = (
57
94
  };
58
95
  }, [controller]);
59
96
 
97
+ // Lock screen / Control Center / CarPlay next & previous go through the same
98
+ // controller as the on-screen buttons, so JS stays the single source of truth.
99
+ React.useEffect(
100
+ () =>
101
+ player?.addListener?.({
102
+ id: REMOTE_COMMANDS_LISTENER_ID,
103
+ listener: {
104
+ onRemoteCommand: ({ id }) => {
105
+ if (id === RemoteCommand.NextTrack) {
106
+ controller.goNext();
107
+ } else if (id === RemoteCommand.PreviousTrack) {
108
+ controller.goPrevious();
109
+ }
110
+ },
111
+ },
112
+ }),
113
+ [player, controller]
114
+ );
115
+
116
+ const isLive = Boolean(playerState?.isLive);
117
+
118
+ // Full snapshot: native renders exactly this and drops the rest. Each key is
119
+ // one kind of buttons, so they stay separate lists rather than one flat list.
120
+ React.useEffect(() => {
121
+ const controls = {
122
+ transport: getTransportControls({ hasNext, hasPrevious, isLive }),
123
+ // TODO: other button kinds go in as their own keys next to `transport`,
124
+ // e.g. custom / native-drawn buttons ("play next", CarPlay / Android Auto
125
+ // row buttons). Add a composer on Player once more than this hook fills it.
126
+ // actions: [],
127
+ };
128
+
129
+ player?.command?.(PlayerNativeCommandTypes.setPlayerControls, controls);
130
+ }, [player, hasNext, hasPrevious, isLive]);
131
+
60
132
  const goNext = React.useCallback(
61
133
  async () => controller.goNext(),
62
134
  [controller]
@@ -398,6 +398,22 @@ export default class VideoPlayer extends React.Component<
398
398
  RNPlayerManager?.[this.playerPluginId]?.stopBackgroundPlayback?.();
399
399
  };
400
400
 
401
+ command = (command: string, userInfo?: Record<string, any>) => {
402
+ if (
403
+ this.isReactNativeMethodsSupported() &&
404
+ RNPlayerManager?.[this.playerPluginId]?.command &&
405
+ this._root
406
+ ) {
407
+ const node = findNodeHandle(this._root);
408
+
409
+ if (!node) {
410
+ return;
411
+ }
412
+
413
+ RNPlayerManager?.[this.playerPluginId]?.command(node, command, userInfo);
414
+ }
415
+ };
416
+
401
417
  forward = (seekTime = SEEK_TIME) => {
402
418
  const currentTime = this.state.currentTime;
403
419
 
@@ -535,6 +551,10 @@ export default class VideoPlayer extends React.Component<
535
551
  return this.props?.listener?.onError(newError);
536
552
  };
537
553
 
554
+ onRemoteCommand = ({ nativeEvent = null }) => {
555
+ this.props?.listener?.onRemoteCommand?.(nativeEvent);
556
+ };
557
+
538
558
  onPlayerPause = ({ nativeEvent = null }) => {
539
559
  this.setState({ paused: true });
540
560
  this.props?.listener?.onPlayerPause(nativeEvent);
@@ -1069,6 +1089,7 @@ export default class VideoPlayer extends React.Component<
1069
1089
  onPlayerResume: this.onPlayerResume,
1070
1090
  onPlayerSeekComplete: this.onPlayerSeekComplete,
1071
1091
  onPlayerSeekStart: this.onPlayerSeekStart,
1092
+ onRemoteCommand: this.onRemoteCommand,
1072
1093
  };
1073
1094
 
1074
1095
  const lifecycleEvents = {