@applicaster/quick-brick-player 16.0.0-rc.9 → 16.0.0-rc.91

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.
Files changed (33) hide show
  1. package/package.json +6 -6
  2. package/src/Player/AudioLayer/AudioDescription.tsx +46 -11
  3. package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +38 -48
  4. package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +87 -0
  5. package/src/Player/AudioLayer/Layout/Controls/index.tsx +34 -14
  6. package/src/Player/AudioLayer/Layout/Controls/utils.ts +40 -0
  7. package/src/Player/AudioLayer/Layout/DockedControls/index.tsx +1 -13
  8. package/src/Player/AudioLayer/Layout/MobileLayout.tsx +0 -4
  9. package/src/Player/AudioLayer/Layout/TabletLandscapeLayout.tsx +0 -4
  10. package/src/Player/AudioLayer/Layout/TabletPortraitLayout.tsx +0 -4
  11. package/src/Player/AudioLayer/Layout/TransportInfo/__tests__/index.test.tsx +257 -0
  12. package/src/Player/AudioLayer/Layout/TransportInfo/index.tsx +69 -8
  13. package/src/Player/AudioLayer/PlaylistNavigationController.ts +381 -0
  14. package/src/Player/AudioLayer/SleepTimerManager.ts +5 -1
  15. package/src/Player/AudioLayer/SpeedManager.ts +4 -0
  16. package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
  17. package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
  18. package/src/Player/AudioLayer/__tests__/PlaylistNavigationController.test.ts +529 -0
  19. package/src/Player/AudioLayer/__tests__/SleepTimerManager.test.ts +78 -0
  20. package/src/Player/AudioLayer/__tests__/SpeedManager.test.ts +57 -0
  21. package/src/Player/AudioLayer/__tests__/playerActions.test.ts +369 -0
  22. package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
  23. package/src/Player/AudioLayer/playerActions.ts +294 -0
  24. package/src/Player/AudioLayer/usePlaylistNavigation.ts +76 -0
  25. package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
  26. package/src/Player/PlayerModal/VideoPlayerModal.tsx +18 -1
  27. package/src/Player/PlayerModal/__tests__/VideoPlayerModal.test.tsx +160 -0
  28. package/src/Player/__tests__/getNativeProps.test.ts +115 -0
  29. package/src/Player/__tests__/onTracksChanged.test.ts +192 -0
  30. package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
  31. package/src/Player/index.tsx +147 -40
  32. package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
  33. package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
@@ -0,0 +1,77 @@
1
+ import { toNativeMetadata } from "../index";
2
+
3
+ // `toNativeMetadata` used to be `resolveNativeTitleSubtitle`, which resolved
4
+ // Title/Subtitle from `entry` + `pluginConfiguration` itself (the dot-path /
5
+ // custom-key / fallback logic). That resolution now happens once in
6
+ // `Player.getContent()` (via `resolvePlayerTitleSubtitle`, already covered by
7
+ // `playerUtils/__tests__/resolvePlayerTitleSubtitle.test.ts` and
8
+ // `playerUtils/__tests__/contentDataKeys.test.ts` - custom-key resolution,
9
+ // fallback-when-unresolvable, and entry-non-mutation are all still asserted
10
+ // there). `toNativeMetadata` only adapts the already-resolved
11
+ // `player.getContent()` value into the `metadata` object the native bridge
12
+ // receives, so this file only covers that adaptation: the shape coercion, and
13
+ // the numeric coercion the native bridge specifically needs (it cannot render
14
+ // a number the way `<Text>` can).
15
+ describe("toNativeMetadata", () => {
16
+ it("returns undefined for both fields when title/summary are null (what the player publishes when there is nothing to show)", () => {
17
+ expect(toNativeMetadata({ title: null, summary: null })).toEqual({
18
+ title: undefined,
19
+ subtitle: undefined,
20
+ });
21
+ });
22
+
23
+ it("returns undefined for both fields when content itself is undefined", () => {
24
+ expect(toNativeMetadata(undefined)).toEqual({
25
+ title: undefined,
26
+ subtitle: undefined,
27
+ });
28
+ });
29
+
30
+ it("passes through string title/summary as-is", () => {
31
+ expect(
32
+ toNativeMetadata({
33
+ title: "Resolved Title",
34
+ summary: "Resolved Summary",
35
+ })
36
+ ).toEqual({
37
+ title: "Resolved Title",
38
+ subtitle: "Resolved Summary",
39
+ });
40
+ });
41
+
42
+ it("coerces a numeric title/summary to a string for the native bridge", () => {
43
+ expect(toNativeMetadata({ title: 0, summary: 42 })).toEqual({
44
+ title: "0",
45
+ subtitle: "42",
46
+ });
47
+ });
48
+
49
+ it("handles title and summary independently (one present, one absent)", () => {
50
+ expect(toNativeMetadata({ title: "Only Title", summary: null })).toEqual({
51
+ title: "Only Title",
52
+ subtitle: undefined,
53
+ });
54
+ });
55
+
56
+ it("passes the cover image url through", () => {
57
+ expect(
58
+ toNativeMetadata({
59
+ title: "A Title",
60
+ summary: "A Summary",
61
+ imageUrl: "https://img.test/base.jpg",
62
+ })
63
+ ).toEqual({
64
+ title: "A Title",
65
+ subtitle: "A Summary",
66
+ imageUrl: "https://img.test/base.jpg",
67
+ });
68
+ });
69
+
70
+ it("sends undefined rather than an empty string when there is no image", () => {
71
+ // Native treats an empty URI as a load attempt; `undefined` means "none".
72
+ expect(
73
+ toNativeMetadata({ title: "A Title", summary: null, imageUrl: null })
74
+ .imageUrl
75
+ ).toBeUndefined();
76
+ });
77
+ });
@@ -31,7 +31,6 @@ import {
31
31
  import { PlayNextOverlay } from "./PlayNextOverlay";
32
32
  import { PlayerImageBackground } from "@applicaster/zapp-react-native-ui-components/Components/PlayerImageBackground";
33
33
 
34
- import { GENERAL_EVENT } from "@applicaster/zapp-react-native-utils/analyticsUtils/events";
35
34
  import { parseLanguageTracks } from "@applicaster/zapp-react-native-utils/playerUtils/configurationUtils";
36
35
  import { DEFAULT_COLORS, SEEK_TIME } from "../utils/const";
37
36
  import { logger, getScreenAspectRatio } from "../utils";
@@ -41,7 +40,6 @@ import { getAllAccessibilityProps } from "@applicaster/zapp-react-native-utils/c
41
40
  import { PlayNextData } from "@applicaster/zapp-react-native-ui-components/Components/PlayerContainer/PlayerContainer";
42
41
  import { getTargetScreenDataFromEntry } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useTargetScreenData";
43
42
  import { PlayerAnalyticsManager } from "@applicaster/zapp-react-native-utils/analyticsUtils/PlayerAnalyticsManager";
44
- import { PlayerAnalyticsTrackerI } from "@applicaster/zapp-react-native-utils/analyticsUtils/playerAnalyticsTracker";
45
43
 
46
44
  import { RNPlayerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/nativePlayerManager";
47
45
  import {
@@ -54,7 +52,7 @@ import { TVPlayerView } from "./PlayerView/TVPlayerView";
54
52
  import PlayerModal, { VideoPlayerModal } from "./PlayerModal";
55
53
  import { DockedControls } from "./AudioLayer/Layout/DockedControls";
56
54
  import { FlexImage } from "./AudioLayer/Layout/PlayerImage/FlexImage";
57
- import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/conts";
55
+ import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
58
56
 
59
57
  const isTvOS = isTvOSPlatform();
60
58
 
@@ -71,6 +69,60 @@ const styles = StyleSheet.create({
71
69
  flexImage: { height: "100%", aspectRatio: 1, flex: -1 },
72
70
  });
73
71
 
72
+ /**
73
+ * Coerces the resolver's `Option<string | number>` result into what the
74
+ * native bridge expects: a string, or `undefined` when there is nothing to
75
+ * send.
76
+ */
77
+ const toNativeTextProp = (
78
+ value: Option<string | number>
79
+ ): string | undefined =>
80
+ value === null || value === undefined ? undefined : String(value);
81
+
82
+ export type NativePlayerMetadata = {
83
+ title: string | undefined;
84
+ subtitle: string | undefined;
85
+ imageUrl: string | undefined;
86
+ };
87
+
88
+ /**
89
+ * Adapts the player's resolved Title/Subtitle into the `metadata` object the
90
+ * *native* view receives.
91
+ *
92
+ * The resolution itself (dot-path / key-set logic, entry fallback) does not
93
+ * happen here - the player manager resolves it and publishes it on its content
94
+ * channel (`player.getContent()`). This function only coerces that value's
95
+ * `Option<string | number>` shape into what the native bridge needs: a string,
96
+ * or `undefined` when there is nothing to send.
97
+ *
98
+ * Travels as one `metadata` object rather than two flat props so native has a
99
+ * single place to look for player-facing text, and so adding a field later
100
+ * does not widen the bridge's prop surface again.
101
+ *
102
+ * This intentionally does not touch `entry` - analytics and the downloads
103
+ * feature read `entry.title` / `entry.summary` directly and must keep
104
+ * seeing the original feed values (the downloads feature requires the
105
+ * original entry title). The resolved (possibly customer-configured) text
106
+ * travels in `metadata` instead.
107
+ *
108
+ * Exported as a plain function (rather than inlined as a class method) so
109
+ * it can be unit tested directly without rendering the ~37KB `VideoPlayer`
110
+ * class component.
111
+ */
112
+ export const toNativeMetadata = (
113
+ content: Option<{
114
+ title: Option<string | number>;
115
+ summary: Option<string | number>;
116
+ imageUrl?: Option<string>;
117
+ }>
118
+ ): NativePlayerMetadata => ({
119
+ title: toNativeTextProp(content?.title),
120
+ subtitle: toNativeTextProp(content?.summary),
121
+ // Already a string when present; coerced through the same helper so an
122
+ // empty or nullish value reaches native as `undefined` rather than "".
123
+ imageUrl: toNativeTextProp(content?.imageUrl),
124
+ });
125
+
74
126
  type VideoPlayerProps = QuickBrickPlayer.PlayerProps & {
75
127
  isCasting?: boolean;
76
128
  playNextData?: PlayNextData;
@@ -96,6 +148,38 @@ type VideoPlayerProps = QuickBrickPlayer.PlayerProps & {
96
148
  children?: React.ReactNode;
97
149
  };
98
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
+
99
183
  export default class VideoPlayer extends React.Component<
100
184
  VideoPlayerProps,
101
185
  QuickBrickPlayer.NativePlayerState & {
@@ -164,20 +248,10 @@ export default class VideoPlayer extends React.Component<
164
248
  nativePlayerIsReady: false,
165
249
  };
166
250
 
167
- private _tracker: PlayerAnalyticsTrackerI;
168
- get analyticsTracker() {
169
- if (!this._tracker) {
170
- this._tracker = PlayerAnalyticsManager.getInstance().getTracker(
171
- this.props.playerId
172
- );
173
- }
174
-
175
- return this._tracker;
176
- }
177
-
178
251
  constructor(props) {
179
252
  super(props);
180
253
  this.onVideoLoadCalled = false; // android
254
+ PlayerAnalyticsManager.getInstance();
181
255
 
182
256
  this.takeParentStyle = isWeb()
183
257
  ? { width: "100%", flex: "1 1 auto", objectFit: "contain" }
@@ -191,9 +265,6 @@ export default class VideoPlayer extends React.Component<
191
265
  componentWillUnmount() {
192
266
  this.playbackStarted = false;
193
267
 
194
- this.analyticsTracker.handleAnalyticEvent(GENERAL_EVENT.session_end);
195
- this.onPlayerClosed();
196
-
197
268
  if (isAndroidPlatform()) {
198
269
  this.disablePiP();
199
270
  }
@@ -201,7 +272,6 @@ export default class VideoPlayer extends React.Component<
201
272
 
202
273
  componentDidMount() {
203
274
  this.playbackStarted = false;
204
- this.onPlayerPresented();
205
275
  }
206
276
 
207
277
  shouldComponentUpdate(nextProps) {
@@ -445,14 +515,6 @@ export default class VideoPlayer extends React.Component<
445
515
  });
446
516
  };
447
517
 
448
- onPlayerPresented = () => {
449
- this.analyticsTracker?.handleAnalyticEvent(GENERAL_EVENT.player_presented);
450
- };
451
-
452
- onPlayerClosed = () => {
453
- this.analyticsTracker?.handleAnalyticEvent(GENERAL_EVENT.player_closed);
454
- };
455
-
456
518
  onVideoEnd = () => {
457
519
  this.setState({ displayOverlay: !this.state.displayOverlay }, () => {
458
520
  if (this.state?.displayOverlay) {
@@ -667,20 +729,50 @@ export default class VideoPlayer extends React.Component<
667
729
  const { textTracks, audioTracks, audioTrackId, textTrackId } =
668
730
  parseLanguageTracks(nativeEvent);
669
731
 
670
- this.setState(
671
- {
672
- audioTrackId,
673
- textTrackId,
674
- },
675
- () => {
676
- this.props?.listener?.onTracksChanged?.({
677
- textTracks,
678
- audioTracks,
679
- audioTrackId,
680
- textTrackId,
681
- });
682
- }
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
683
739
  );
740
+
741
+ const nextTextTrackId = resolveReportedTrackId(
742
+ textTrackId,
743
+ this.state.textTrackId,
744
+ textTracks
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
+ }
684
776
  };
685
777
 
686
778
  onVideoProgress = ({ nativeEvent }) => {
@@ -1096,6 +1188,15 @@ export default class VideoPlayer extends React.Component<
1096
1188
  const liveCatchUpEnabled =
1097
1189
  this.props.pluginConfiguration?.liveCatchUpEnabled;
1098
1190
 
1191
+ // Look the player up by id rather than using the active player: these
1192
+ // props are handed to this component's own native player view, so they
1193
+ // must describe that player's content even while a cast session is
1194
+ // running (during which `usePlayer` swaps React consumers over to the
1195
+ // casting receiver).
1196
+ const metadata = toNativeMetadata(
1197
+ playerManager.getPlayerWithId(this.props.playerId)?.getContent()
1198
+ );
1199
+
1099
1200
  const nativeProps = {
1100
1201
  rate: undefined,
1101
1202
  entry,
@@ -1114,6 +1215,7 @@ export default class VideoPlayer extends React.Component<
1114
1215
  liveCatchUpEnabled,
1115
1216
  playerId: this.props.playerId,
1116
1217
  pictureInPictureEnabled,
1218
+ metadata,
1117
1219
  seekStep:
1118
1220
  (this.props.pluginConfiguration?.seek_duration || SEEK_TIME) * 1000,
1119
1221
  accessibilityProps: getAllAccessibilityProps(
@@ -1170,10 +1272,15 @@ export default class VideoPlayer extends React.Component<
1170
1272
  return null;
1171
1273
  }
1172
1274
 
1173
- const TransportControls = isTV()
1275
+ const isTvPlatform = isTV();
1276
+
1277
+ const TransportControls = isTvPlatform
1174
1278
  ? TransportControlsTV
1175
1279
  : TransportControlsMobile;
1176
1280
 
1281
+ // The mobile and TV transport controls read Title/Subtitle from the
1282
+ // player manager's content channel (via `usePlayerContent`) instead of
1283
+ // receiving them as props.
1177
1284
  const needToShowDefaultControls = !(
1178
1285
  (controls && !castAction?.state?.connected) ||
1179
1286
  this.getPlayNextData()
@@ -1,66 +0,0 @@
1
- import React from "react";
2
- import { ModalHeader } from "@applicaster/zapp-react-native-ui-components/Components/ModalComponent/Header";
3
- import { isNil } from "@applicaster/zapp-react-native-utils/utils";
4
-
5
- const formatTime = (milliseconds: number) => {
6
- const totalSeconds = Math.floor(milliseconds / 1000);
7
- const hours = Math.floor(totalSeconds / 3600);
8
- const minutes = Math.floor((totalSeconds % 3600) / 60);
9
- const seconds = totalSeconds % 60;
10
-
11
- if (milliseconds < 3600000) {
12
- return `${minutes.toString().padStart(2, "0")}:${seconds
13
- .toString()
14
- .padStart(2, "0")}`;
15
- }
16
-
17
- return `${hours.toString().padStart(2, "0")}:${minutes
18
- .toString()
19
- .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
20
- };
21
-
22
- export const SleepTimerModalHeader = ({
23
- summary,
24
- ...props
25
- }: {
26
- summary?: string;
27
- }) => {
28
- const sleepTimerDate = +summary;
29
-
30
- const [timeLeft, setTimeLeft] = React.useState(
31
- sleepTimerDate !== 0 ? sleepTimerDate - Date.now() : 0
32
- );
33
-
34
- const showCounter = React.useMemo(() => sleepTimerDate > 0, [sleepTimerDate]);
35
-
36
- React.useLayoutEffect(() => {
37
- if (showCounter && sleepTimerDate !== 0) {
38
- setTimeLeft(sleepTimerDate - Date.now());
39
-
40
- const intervalId = setInterval(() => {
41
- setTimeLeft(() => {
42
- const newTimeLeft = sleepTimerDate - Date.now();
43
-
44
- if (newTimeLeft <= 0) {
45
- clearInterval(intervalId);
46
-
47
- return 0;
48
- } else {
49
- return newTimeLeft;
50
- }
51
- });
52
- }, 1000);
53
-
54
- return () => clearInterval(intervalId);
55
- } else {
56
- setTimeLeft(0);
57
- }
58
- }, [sleepTimerDate, showCounter]);
59
-
60
- return (
61
- <ModalHeader
62
- summary={!isNil(summary) ? formatTime(timeLeft) : summary}
63
- {...(props as any)}
64
- />
65
- );
66
- };
@@ -1 +0,0 @@
1
- export * from "./SleepTimerModalHeader";