@applicaster/quick-brick-player 16.0.0-rc.100 → 16.0.0-rc.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/quick-brick-player",
3
- "version": "16.0.0-rc.100",
3
+ "version": "16.0.0-rc.102",
4
4
  "description": "Quick Brick Player",
5
5
  "main": "./src/index.ts",
6
6
  "types": "index.d.ts",
@@ -37,9 +37,9 @@
37
37
  "dependencies": {
38
38
  "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.93",
39
39
  "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.69",
40
- "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.100",
41
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.100",
42
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.100",
40
+ "@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.102",
41
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.102",
42
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.102",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -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 = {