@applicaster/quick-brick-player 15.1.10-rc.0 → 15.1.11-rc.0

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": "15.1.10-rc.0",
3
+ "version": "15.1.11-rc.0",
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": "15.1.9",
39
39
  "@applicaster/quick-brick-tv-transport-controls": "15.1.9",
40
- "@applicaster/zapp-react-native-tvos-app": "15.1.10-rc.0",
41
- "@applicaster/zapp-react-native-ui-components": "15.1.10-rc.0",
42
- "@applicaster/zapp-react-native-utils": "15.1.10-rc.0",
40
+ "@applicaster/zapp-react-native-tvos-app": "15.1.11-rc.0",
41
+ "@applicaster/zapp-react-native-ui-components": "15.1.11-rc.0",
42
+ "@applicaster/zapp-react-native-utils": "15.1.11-rc.0",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -12,6 +12,7 @@ import {
12
12
  VIDEO_TRANSITION_THRESHOLD,
13
13
  } from "./consts";
14
14
  import { useIsTablet } from "@applicaster/zapp-react-native-utils/reactHooks";
15
+ import { useIsTabletLandscape } from "@applicaster/zapp-react-native-utils/reactHooks/device/useMemoizedIsTablet";
15
16
  import { PROGRESS_BAR_HEIGHT } from "@applicaster/zapp-react-native-ui-components/Components/VideoModal/ModalAnimation/utils";
16
17
  import {
17
18
  SafeAreaView,
@@ -100,7 +101,23 @@ export function VideoPlayerModal({
100
101
  fullscreen || pip
101
102
  );
102
103
 
104
+ /* Landscape is a tablet's regular orientation, so rotating it says nothing
105
+ about the modal mode. useOrientationEffect skips its orientation effects
106
+ for the same reason, via the same predicate. */
107
+ const skipOrientationEffects = useIsTabletLandscape();
108
+
109
+ /* Props are the only reconciliation left once the listener below is skipped,
110
+ and even where it runs, the mount value goes stale: remounting during
111
+ native fullscreen would otherwise strand the modal in that layout. Rotating
112
+ a tablet flips the predicate itself, so reconcile then too — the last
113
+ orientation event may have left the state behind. */
114
+ React.useEffect(() => {
115
+ setFullScreenMode(fullscreen || pip);
116
+ }, [fullscreen, pip, skipOrientationEffects]);
117
+
103
118
  React.useEffect(() => {
119
+ if (skipOrientationEffects) return;
120
+
104
121
  const listener = addOrientationChangeListener(({ toOrientation }) => {
105
122
  /* ignored in collapsed mode non-PiP player */
106
123
  if (collapsed && !pip) return;
@@ -110,7 +127,7 @@ export function VideoPlayerModal({
110
127
  return () => {
111
128
  listener.remove();
112
129
  };
113
- }, [collapsed, pip]);
130
+ }, [collapsed, pip, skipOrientationEffects]);
114
131
 
115
132
  const isFullScreenOrPIP = isFullScreenMode;
116
133
 
@@ -0,0 +1,160 @@
1
+ import React from "react";
2
+ import { Text } from "react-native";
3
+ import { act, render, screen } from "@testing-library/react-native";
4
+
5
+ /**
6
+ * The modal's fullscreen layout is not directly observable, but the collapsed
7
+ * bar is: it renders only while the modal is NOT in that layout. Every case
8
+ * below therefore reads `collapsedBarWrapper` as the proxy for the state under
9
+ * test.
10
+ *
11
+ * Why it matters: while the modal sits in the fullscreen layout, a drag still
12
+ * drives navigation to MINIMIZED, which docks the player and hides the native
13
+ * controls (see Player/index.tsx `shouldUseNativeControls`). If the layout
14
+ * cannot follow the mode back out, the modal is stranded on top of the tab bar
15
+ * with no controls and no way to restore them.
16
+ */
17
+
18
+ let mockIsTabletLandscape = false;
19
+ let orientationListener: ((event: any) => void) | null = null;
20
+ const mockRemove = jest.fn();
21
+
22
+ const mockAddOrientationChangeListener = jest.fn((cb) => {
23
+ orientationListener = cb;
24
+
25
+ return { remove: mockRemove };
26
+ });
27
+
28
+ jest.mock(
29
+ "@applicaster/zapp-react-native-utils/appUtils/orientationHelper",
30
+ () => ({
31
+ addOrientationChangeListener: (cb) => mockAddOrientationChangeListener(cb),
32
+ })
33
+ );
34
+
35
+ jest.mock(
36
+ "@applicaster/zapp-react-native-utils/reactHooks/device/useMemoizedIsTablet",
37
+ () => ({ useIsTabletLandscape: () => mockIsTabletLandscape })
38
+ );
39
+
40
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
41
+ useIsTablet: () => mockIsTabletLandscape,
42
+ }));
43
+
44
+ jest.mock(
45
+ "@applicaster/zapp-react-native-ui-components/Components/VideoModal/utils",
46
+ () => ({ useConfiguration: () => ({ minimised_height: 60 }) })
47
+ );
48
+
49
+ jest.mock(
50
+ "@applicaster/zapp-react-native-ui-components/Components/VideoModal/ModalAnimation/utils",
51
+ () => ({ PROGRESS_BAR_HEIGHT: 3 })
52
+ );
53
+
54
+ jest.mock("react-native-safe-area-context", () => {
55
+ const { View } = require("react-native");
56
+
57
+ return {
58
+ SafeAreaView: View,
59
+ useSafeAreaFrame: () => ({ x: 0, y: 0, width: 1024, height: 768 }),
60
+ useSafeAreaInsets: () => ({ top: 24, bottom: 34, left: 0, right: 0 }),
61
+ };
62
+ });
63
+
64
+ jest.mock("../GestureAnimatedScrollView", () => {
65
+ const { ScrollView } = require("react-native");
66
+
67
+ return { GestureAnimatedScrollView: ScrollView };
68
+ });
69
+
70
+ jest.mock("../styles", () => ({
71
+ useStyles: () => ({
72
+ modal: {},
73
+ modalWrapper: {},
74
+ collapsedModalBg: {},
75
+ modalContent: {},
76
+ modalContentPiP: {},
77
+ expandedContentContainer: {},
78
+ extraContentScrollView: {},
79
+ collapsedBarContainer: {},
80
+ collapsedBarWrapper: {},
81
+ flexNone: {},
82
+ }),
83
+ }));
84
+
85
+ jest.mock("../hooks", () => ({
86
+ useVideoModalState: () => ({
87
+ collapsed: false,
88
+ offset: { current: 700 },
89
+ gestureHandlerProps: {},
90
+ translateY: new (require("react-native").Animated.Value)(0),
91
+ expand: jest.fn(),
92
+ }),
93
+ }));
94
+
95
+ // eslint-disable-next-line import/first
96
+ import { VideoPlayerModal } from "../VideoPlayerModal";
97
+
98
+ const renderModal = (props = {}) =>
99
+ render(
100
+ <VideoPlayerModal
101
+ modal
102
+ content={<Text>content</Text>}
103
+ collapsedContent={<Text>collapsed</Text>}
104
+ style={{ width: 1024, height: 768, tabletLandscapeWidth: 600 }}
105
+ {...props}
106
+ />
107
+ );
108
+
109
+ const collapsedBar = () => screen.queryByTestId("collapsedBarWrapper");
110
+
111
+ beforeEach(() => {
112
+ jest.clearAllMocks();
113
+ orientationListener = null;
114
+ mockIsTabletLandscape = false;
115
+ });
116
+
117
+ describe("VideoPlayerModal fullscreen layout", () => {
118
+ describe("on a tablet in landscape", () => {
119
+ beforeEach(() => {
120
+ mockIsTabletLandscape = true;
121
+ });
122
+
123
+ it("leaves the orientation listener alone, since landscape is its regular orientation", () => {
124
+ renderModal();
125
+
126
+ expect(mockAddOrientationChangeListener).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it("follows the mode back out of fullscreen", () => {
130
+ const { rerender } = renderModal({ fullscreen: true });
131
+
132
+ expect(collapsedBar()).toBeNull();
133
+
134
+ rerender(
135
+ <VideoPlayerModal
136
+ modal
137
+ fullscreen={false}
138
+ content={<Text>content</Text>}
139
+ collapsedContent={<Text>collapsed</Text>}
140
+ style={{ width: 1024, height: 768, tabletLandscapeWidth: 600 }}
141
+ />
142
+ );
143
+
144
+ expect(collapsedBar()).not.toBeNull();
145
+ });
146
+ });
147
+
148
+ describe("on a phone", () => {
149
+ it("still lets orientation drive the layout", () => {
150
+ renderModal();
151
+
152
+ expect(mockAddOrientationChangeListener).toHaveBeenCalled();
153
+ expect(collapsedBar()).not.toBeNull();
154
+
155
+ act(() => orientationListener?.({ toOrientation: 4 }));
156
+
157
+ expect(collapsedBar()).toBeNull();
158
+ });
159
+ });
160
+ });
@@ -0,0 +1,192 @@
1
+ import VideoPlayer from "../index";
2
+ import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils";
3
+
4
+ /**
5
+ * Tests for the track-selection reconciliation logic in `onTracksChanged`.
6
+ *
7
+ * The handler uses `resolveReportedTrackId` to prevent an infinite feedback
8
+ * loop: a transient native "nothing selected" (reported id === undefined)
9
+ * must NOT clear controlled state when the previous track is still available,
10
+ * while genuine track changes must still propagate.
11
+ *
12
+ * Instances are constructed directly (no render) to keep these tests fast and
13
+ * focused on the instance method, mirroring the pattern used in
14
+ * `getNativeProps.test.ts`.
15
+ */
16
+
17
+ jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
18
+ ...jest.requireActual("@applicaster/zapp-react-native-utils/appUtils"),
19
+ playerManager: {
20
+ getPlayerWithId: jest.fn(),
21
+ },
22
+ }));
23
+
24
+ jest.mock(
25
+ "@applicaster/zapp-react-native-utils/playerUtils/configurationUtils",
26
+ () => ({
27
+ parseLanguageTracks: jest.fn(),
28
+ })
29
+ );
30
+
31
+ import { parseLanguageTracks } from "@applicaster/zapp-react-native-utils/playerUtils/configurationUtils";
32
+
33
+ const baseProps = {
34
+ entry: { id: "1", title: "Entry Title", summary: "Entry Summary" },
35
+ pluginConfiguration: {},
36
+ docked: false,
37
+ fullscreen: false,
38
+ inline: false,
39
+ isModal: false,
40
+ isTabletPortrait: false,
41
+ muted: false,
42
+ playerId: "player-1",
43
+ PlayerComponent: "QuickBrickDefaultPlayerView",
44
+ } as unknown as ConstructorParameters<typeof VideoPlayer>[0];
45
+
46
+ const audioTrack = { id: "en-audio", type: "audio" };
47
+ const textTrack = { id: "en-text", type: "text" };
48
+
49
+ /** Helper: call onTracksChanged with a mocked parseLanguageTracks result. */
50
+ function callOnTracksChanged(
51
+ instance: VideoPlayer,
52
+ parsed: {
53
+ audioTracks?: { id: string; type: string }[];
54
+ textTracks?: { id: string; type: string }[];
55
+ audioTrackId?: string | undefined;
56
+ textTrackId?: string | undefined;
57
+ }
58
+ ) {
59
+ (parseLanguageTracks as jest.Mock).mockReturnValue({
60
+ audioTracks: [],
61
+ textTracks: [],
62
+ audioTrackId: undefined,
63
+ textTrackId: undefined,
64
+ ...parsed,
65
+ });
66
+
67
+ instance.onTracksChanged({ nativeEvent: {} });
68
+ }
69
+
70
+ describe("onTracksChanged — resolveReportedTrackId reconciliation", () => {
71
+ beforeEach(() => {
72
+ (playerManager.getPlayerWithId as jest.Mock).mockReturnValue(null);
73
+ });
74
+
75
+ it("sets audioTrackId and textTrackId when native reports real selections", () => {
76
+ const instance = new VideoPlayer(baseProps);
77
+ const setState = jest.spyOn(instance, "setState");
78
+
79
+ callOnTracksChanged(instance, {
80
+ audioTracks: [audioTrack],
81
+ textTracks: [textTrack],
82
+ audioTrackId: audioTrack.id,
83
+ textTrackId: textTrack.id,
84
+ });
85
+
86
+ expect(setState).toHaveBeenCalledWith(
87
+ expect.objectContaining({
88
+ audioTrackId: audioTrack.id,
89
+ textTrackId: textTrack.id,
90
+ }),
91
+ expect.any(Function)
92
+ );
93
+ });
94
+
95
+ it("does NOT clear audioTrackId when native transiently reports undefined and the track is still in the list", () => {
96
+ const instance = new VideoPlayer(baseProps);
97
+ // Establish prior selection state without a React reconciler.
98
+ instance.state = { ...instance.state, audioTrackId: audioTrack.id };
99
+
100
+ const setState = jest.spyOn(instance, "setState");
101
+
102
+ callOnTracksChanged(instance, {
103
+ audioTracks: [audioTrack],
104
+ textTracks: [],
105
+ audioTrackId: undefined, // transient native blip — nothing selected
106
+ textTrackId: undefined,
107
+ });
108
+
109
+ // State must NOT change because the resolved id equals the current id.
110
+ expect(setState).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it("does NOT clear textTrackId when native transiently reports undefined and the track is still in the list", () => {
114
+ const instance = new VideoPlayer(baseProps);
115
+ instance.state = { ...instance.state, textTrackId: textTrack.id };
116
+
117
+ const setState = jest.spyOn(instance, "setState");
118
+
119
+ callOnTracksChanged(instance, {
120
+ audioTracks: [],
121
+ textTracks: [textTrack],
122
+ audioTrackId: undefined,
123
+ textTrackId: undefined,
124
+ });
125
+
126
+ expect(setState).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it("clears the id when the previously-selected track is no longer present in the list", () => {
130
+ const instance = new VideoPlayer(baseProps);
131
+ instance.state = { ...instance.state, audioTrackId: audioTrack.id };
132
+
133
+ const setState = jest.spyOn(instance, "setState");
134
+
135
+ // Track is gone from the available list.
136
+ callOnTracksChanged(instance, {
137
+ audioTracks: [], // track not available anymore
138
+ textTracks: [],
139
+ audioTrackId: undefined,
140
+ textTrackId: undefined,
141
+ });
142
+
143
+ expect(setState).toHaveBeenCalledWith(
144
+ expect.objectContaining({ audioTrackId: null }),
145
+ expect.any(Function)
146
+ );
147
+ });
148
+
149
+ it("propagates a genuine track change to a different id", () => {
150
+ const newTrack = { id: "fr-audio", type: "audio" };
151
+ const instance = new VideoPlayer(baseProps);
152
+ instance.state = { ...instance.state, audioTrackId: audioTrack.id };
153
+
154
+ const setState = jest.spyOn(instance, "setState");
155
+
156
+ callOnTracksChanged(instance, {
157
+ audioTracks: [audioTrack, newTrack],
158
+ textTracks: [],
159
+ audioTrackId: newTrack.id,
160
+ textTrackId: undefined,
161
+ });
162
+
163
+ expect(setState).toHaveBeenCalledWith(
164
+ expect.objectContaining({ audioTrackId: newTrack.id }),
165
+ expect.any(Function)
166
+ );
167
+ });
168
+
169
+ it("calls the listener with resolved ids even when state does not change", () => {
170
+ const instance = new VideoPlayer(baseProps);
171
+ instance.state = { ...instance.state, audioTrackId: audioTrack.id };
172
+
173
+ const onTracksChangedListener = jest.fn();
174
+
175
+ (instance as any).props = {
176
+ ...instance.props,
177
+ listener: { onTracksChanged: onTracksChangedListener },
178
+ };
179
+
180
+ callOnTracksChanged(instance, {
181
+ audioTracks: [audioTrack],
182
+ textTracks: [],
183
+ audioTrackId: undefined, // transient blip
184
+ textTrackId: undefined,
185
+ });
186
+
187
+ // notifyListener is called with the resolved (non-null) audioTrackId
188
+ expect(onTracksChangedListener).toHaveBeenCalledWith(
189
+ expect.objectContaining({ audioTrackId: audioTrack.id })
190
+ );
191
+ });
192
+ });
@@ -148,6 +148,38 @@ type VideoPlayerProps = QuickBrickPlayer.PlayerProps & {
148
148
  children?: React.ReactNode;
149
149
  };
150
150
 
151
+ /**
152
+ * `textTrackId`/`audioTrackId` are both the value the native player *reports* as
153
+ * currently selected AND the *controlled prop* that drives native selection.
154
+ * Because of that dual role, reflecting a reported value straight back down turns
155
+ * a status update into a command.
156
+ *
157
+ * The native player can momentarily report "nothing selected" (index -1, which
158
+ * `parseLanguageTracks` maps to `undefined`) during normal fMP4/ABR segment
159
+ * transitions. Pushing that back down as a "disable"/"clear" command flips native
160
+ * state, which emits a fresh onTracksChanged, which we would echo again — an
161
+ * infinite feedback loop.
162
+ *
163
+ * Keep the current selection when the reported value is a transient drop and the
164
+ * chosen track is still available. A genuine user "Off"/change already updates the
165
+ * controlled state first, so this only ignores unsolicited native status blips.
166
+ */
167
+ const resolveReportedTrackId = (
168
+ reportedId: string | null | undefined,
169
+ currentId: string | null,
170
+ tracks: { id: string }[] = []
171
+ ): string | null => {
172
+ if (reportedId) {
173
+ return reportedId;
174
+ }
175
+
176
+ if (currentId && tracks.some((track) => track?.id === currentId)) {
177
+ return currentId;
178
+ }
179
+
180
+ return null;
181
+ };
182
+
151
183
  export default class VideoPlayer extends React.Component<
152
184
  VideoPlayerProps,
153
185
  QuickBrickPlayer.NativePlayerState & {
@@ -697,20 +729,50 @@ export default class VideoPlayer extends React.Component<
697
729
  const { textTracks, audioTracks, audioTrackId, textTrackId } =
698
730
  parseLanguageTracks(nativeEvent);
699
731
 
700
- this.setState(
701
- {
702
- audioTrackId,
703
- textTrackId,
704
- },
705
- () => {
706
- this.props?.listener?.onTracksChanged?.({
707
- textTracks,
708
- audioTracks,
709
- audioTrackId,
710
- textTrackId,
711
- });
712
- }
732
+ // Resolve the reported selection against the current controlled state so a
733
+ // transient native "nothing selected" doesn't get echoed back as a command
734
+ // and re-trigger native selection (infinite loop, see resolveReportedTrackId).
735
+ const nextAudioTrackId = resolveReportedTrackId(
736
+ audioTrackId,
737
+ this.state.audioTrackId,
738
+ audioTracks
739
+ );
740
+
741
+ const nextTextTrackId = resolveReportedTrackId(
742
+ textTrackId,
743
+ this.state.textTrackId,
744
+ textTracks
713
745
  );
746
+
747
+ const notifyListener = () =>
748
+ this.props?.listener?.onTracksChanged?.({
749
+ textTracks,
750
+ audioTracks,
751
+ audioTrackId: nextAudioTrackId,
752
+ textTrackId: nextTextTrackId,
753
+ });
754
+
755
+ // Only update state (and thus re-send the controlled props) on a real change.
756
+ // Re-rendering with identical prop values would otherwise keep the native
757
+ // event/prop echo alive.
758
+ const stateChanges: {
759
+ audioTrackId?: string | null;
760
+ textTrackId?: string | null;
761
+ } = {};
762
+
763
+ if (nextAudioTrackId !== this.state.audioTrackId) {
764
+ stateChanges.audioTrackId = nextAudioTrackId;
765
+ }
766
+
767
+ if (nextTextTrackId !== this.state.textTrackId) {
768
+ stateChanges.textTrackId = nextTextTrackId;
769
+ }
770
+
771
+ if (Object.keys(stateChanges).length > 0) {
772
+ this.setState(stateChanges as any, notifyListener);
773
+ } else {
774
+ notifyListener();
775
+ }
714
776
  };
715
777
 
716
778
  onVideoProgress = ({ nativeEvent }) => {