@applicaster/quick-brick-player 16.0.0-alpha.1803625751 → 16.0.0-alpha.1816065263

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.1803625751",
3
+ "version": "16.0.0-alpha.1816065263",
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.2",
39
- "@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.9",
40
- "@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.1803625751",
41
- "@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.1803625751",
42
- "@applicaster/zapp-react-native-utils": "16.0.0-alpha.1803625751",
38
+ "@applicaster/quick-brick-mobile-transport-controls": "16.0.0-alpha.1816065263",
39
+ "@applicaster/quick-brick-tv-transport-controls": "16.0.0-alpha.1816065263",
40
+ "@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.1816065263",
41
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.1816065263",
42
+ "@applicaster/zapp-react-native-utils": "16.0.0-alpha.1816065263",
43
43
  "query-string": "7.1.3",
44
44
  "shaka-player": "4.3.5",
45
45
  "typeface-montserrat": "^0.0.54",
@@ -1,10 +1,9 @@
1
1
  import React from "react";
2
2
  import { StyleSheet, Text, TextStyle, View } from "react-native";
3
3
  import { platformSelect } from "@applicaster/zapp-react-native-utils/reactUtils";
4
+ import { usePlayerContent } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerContent";
4
5
 
5
6
  type AudioDescriptionProps = {
6
- title: string | number;
7
- summary: string | number;
8
7
  layoutState: {
9
8
  inline: boolean;
10
9
  docked: boolean;
@@ -96,17 +95,48 @@ const getSummaryStyles = (value: GetValue, prefix: string): TextStyle =>
96
95
  }),
97
96
  }) as const;
98
97
 
99
- const getHeight = (titleLineHeight: number, subtitleLineHeight) =>
100
- titleLineHeight * TITLE_LINES + subtitleLineHeight + TITLE_PADDING_BOTTOM;
98
+ const getHeight = ({
99
+ showTitle,
100
+ showSummary,
101
+ titleLineHeight,
102
+ subtitleLineHeight,
103
+ }: {
104
+ showTitle: boolean;
105
+ showSummary: boolean;
106
+ titleLineHeight: number;
107
+ subtitleLineHeight: number;
108
+ }) => {
109
+ const titleHeight = showTitle
110
+ ? titleLineHeight * TITLE_LINES + TITLE_PADDING_BOTTOM
111
+ : 0;
112
+
113
+ const summaryHeight = showSummary ? subtitleLineHeight : 0;
114
+
115
+ return titleHeight + summaryHeight;
116
+ };
101
117
 
102
118
  export const AudioDescription = (props: AudioDescriptionProps) => {
103
- const { title, summary, layoutState, value, maxWidth } = props;
119
+ const { layoutState, value, maxWidth } = props;
120
+ const { title, summary } = usePlayerContent();
104
121
  const prefix = layoutState.docked ? "docked_player_" : "";
105
122
 
106
123
  const summaryStyles = getSummaryStyles(value, prefix);
107
124
  const titleStyles = getTitleStyles(value, prefix);
108
125
 
109
- const height = getHeight(titleStyles.lineHeight, summaryStyles.lineHeight);
126
+ // Both depend on the Studio toggle alone, never on whether the text has
127
+ // arrived yet. Live audio resolves its title/summary asynchronously (the
128
+ // channel feed is polled after playback starts), so gating on the value
129
+ // would collapse the row on first render and expand it a second later -
130
+ // the control has to hold its place and render an empty line instead.
131
+ const showTitle = Boolean(value(`${prefix}title`));
132
+ const showSummary = Boolean(value(`${prefix}subtitle`));
133
+
134
+ const height = getHeight({
135
+ showTitle,
136
+ showSummary,
137
+ titleLineHeight: titleStyles.lineHeight,
138
+ subtitleLineHeight: summaryStyles.lineHeight,
139
+ });
110
140
 
111
141
  return (
112
142
  <View
@@ -118,15 +148,20 @@ export const AudioDescription = (props: AudioDescriptionProps) => {
118
148
  },
119
149
  ]}
120
150
  >
121
- <Text style={[styles.titleText, titleStyles]} numberOfLines={TITLE_LINES}>
122
- {title}
123
- </Text>
124
- {summary ? (
151
+ {showTitle ? (
152
+ <Text
153
+ style={[styles.titleText, titleStyles]}
154
+ numberOfLines={TITLE_LINES}
155
+ >
156
+ {title ?? ""}
157
+ </Text>
158
+ ) : null}
159
+ {showSummary ? (
125
160
  <Text
126
161
  style={[styles.summaryText, summaryStyles]}
127
162
  numberOfLines={SUBTITLE_LINES}
128
163
  >
129
- {summary}
164
+ {summary ?? ""}
130
165
  </Text>
131
166
  ) : null}
132
167
  </View>
@@ -19,7 +19,6 @@ import {
19
19
  getBottomSheetModalLabelProps,
20
20
  getBottomSheetModalSelectedItemIcon,
21
21
  } from "../Utils";
22
- import { useTitleSummaryOverlay } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerTitleSummaryOverlay";
23
22
 
24
23
  import { toNumberWithDefault } from "@applicaster/zapp-react-native-utils/numberUtils";
25
24
 
@@ -89,11 +88,27 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
89
88
  playNextData = null,
90
89
  } = props;
91
90
 
92
- const { title, summary } = useTitleSummaryOverlay(
93
- entry,
94
- playerState?.isLive,
95
- configuration?.audio_live_player_update_interval
96
- );
91
+ // For live audio the programme changes while the entry stays the same, so
92
+ // the player polls the channel's programme feed and republishes its content.
93
+ // Everything about that - whether the content is live at all, where the feed
94
+ // lives, how often to poll - is decided by `OverlaysObserver`, which loads
95
+ // the feed without React. This effect owns only the lifecycle: keep the
96
+ // metadata fresh while this player is mounted. Consumers read the result
97
+ // through `usePlayerContent` like any other player content.
98
+ React.useEffect(() => {
99
+ if (!player) {
100
+ return;
101
+ }
102
+
103
+ const observer = player.getOverlayObservable();
104
+
105
+ observer.startLiveContentUpdates();
106
+
107
+ return () => observer.stopLiveContentUpdates();
108
+ // Keyed on the entry's id rather than the object: starting the updates
109
+ // fires an immediate request, so a re-created `entry` prop with identical
110
+ // contents would re-fetch the feed on every render.
111
+ }, [player, playerState?.isLive, entry?.id]);
97
112
 
98
113
  const [speedManager, setSpeedManager] = useState<SpeedController | null>(
99
114
  null
@@ -282,8 +297,6 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
282
297
  configuration={configuration}
283
298
  dimensions={dimensions}
284
299
  playNextData={playNextData}
285
- title={title}
286
- summary={summary}
287
300
  />
288
301
  </View>
289
302
  </SafeAreaView>
@@ -61,8 +61,6 @@ type Props = {
61
61
  layoutState: QuickBrickPlayer.LayoutState;
62
62
  playerState: QuickBrickPlayer.PlayerState;
63
63
  value: GetValue;
64
- title: string | number;
65
- summary: string | number;
66
64
 
67
65
  playbackSpeedName: string;
68
66
 
@@ -85,8 +83,6 @@ export const Controls = (props: Props) => {
85
83
  layoutState,
86
84
  playerState,
87
85
  value,
88
- title,
89
- summary,
90
86
 
91
87
  playbackSpeedName,
92
88
 
@@ -227,8 +223,6 @@ export const Controls = (props: Props) => {
227
223
  <VerticalGutter />
228
224
 
229
225
  <AudioDescription
230
- title={title}
231
- summary={summary}
232
226
  value={value}
233
227
  layoutState={layoutState}
234
228
  maxWidth={maxWidth}
@@ -18,7 +18,6 @@ import { PlayNextData } from "@applicaster/zapp-react-native-ui-components/Compo
18
18
  import { useSafeAreaInsets } from "react-native-safe-area-context";
19
19
  import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useNavigation";
20
20
  import { getTabBarHeight } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/getTabBarHeight";
21
- import { useTitleSummaryOverlay } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerTitleSummaryOverlay";
22
21
  import { usePlayerState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState";
23
22
  import { useConfiguration } from "@applicaster/zapp-react-native-ui-components/Components/VideoModal/utils";
24
23
  import { noop } from "@applicaster/zapp-react-native-utils/functionUtils";
@@ -200,12 +199,6 @@ export const DockedControls = (props: Props) => {
200
199
  [...Object.values(configuration), playerState.seekableDuration]
201
200
  );
202
201
 
203
- const { title, summary } = useTitleSummaryOverlay(
204
- entry,
205
- playerState?.isLive,
206
- configuration?.audio_live_player_update_interval
207
- );
208
-
209
202
  const isTablet = useIsTablet();
210
203
 
211
204
  const liveButton = Boolean(value("live_badge_enabled"));
@@ -261,11 +254,6 @@ export const DockedControls = (props: Props) => {
261
254
  };
262
255
  }, [bottomTabsVisible, insets.bottom]);
263
256
 
264
- const contentInfoContent = React.useMemo(
265
- () => ({ ...entry, title, summary }),
266
- [title, summary, entry]
267
- );
268
-
269
257
  return (
270
258
  <>
271
259
  <View
@@ -297,7 +285,7 @@ export const DockedControls = (props: Props) => {
297
285
  testID="content-info"
298
286
  style={[styles.contentInfo, backgroundColorStyle]}
299
287
  >
300
- <ContentInfo content={contentInfoContent} value={value} docked />
288
+ <ContentInfo value={value} docked />
301
289
  </View>
302
290
  </View>
303
291
  <View
@@ -78,8 +78,6 @@ export function MobileLayout({
78
78
  onSpeedButtonPress,
79
79
  playbackSpeedName,
80
80
  configuration,
81
- title,
82
- summary,
83
81
  }: Props) {
84
82
  const { docked } = layoutState;
85
83
 
@@ -165,8 +163,6 @@ export function MobileLayout({
165
163
  layoutState={layoutState}
166
164
  playerState={playerState}
167
165
  value={value}
168
- title={title}
169
- summary={summary}
170
166
  playbackSpeedName={playbackSpeedName}
171
167
  onPlaybackButtonPress={onPlaybackButtonPress}
172
168
  onRewindButtonPress={onRewindButtonPress}
@@ -75,8 +75,6 @@ export function TabletLandscapeLayout({
75
75
  onSpeedButtonPress,
76
76
  playbackSpeedName,
77
77
  configuration,
78
- title,
79
- summary,
80
78
  }: Props) {
81
79
  const [imageWidth, setImageWidth] = React.useState(0);
82
80
  const { docked } = layoutState;
@@ -132,8 +130,6 @@ export function TabletLandscapeLayout({
132
130
  layoutState={layoutState}
133
131
  playerState={playerState}
134
132
  value={value}
135
- title={title}
136
- summary={summary}
137
133
  playbackSpeedName={playbackSpeedName}
138
134
  onPlaybackButtonPress={onPlaybackButtonPress}
139
135
  onRewindButtonPress={onRewindButtonPress}
@@ -81,8 +81,6 @@ export function TabletPortraitLayout({
81
81
  onSpeedButtonPress,
82
82
  playbackSpeedName,
83
83
  configuration,
84
- title,
85
- summary,
86
84
  }: Props) {
87
85
  const { docked } = layoutState;
88
86
 
@@ -157,8 +155,6 @@ export function TabletPortraitLayout({
157
155
  layoutState={layoutState}
158
156
  playerState={playerState}
159
157
  value={value}
160
- title={title}
161
- summary={summary}
162
158
  playbackSpeedName={playbackSpeedName}
163
159
  onPlaybackButtonPress={onPlaybackButtonPress}
164
160
  onRewindButtonPress={onRewindButtonPress}
@@ -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
+ });
@@ -0,0 +1,115 @@
1
+ import VideoPlayer from "../index";
2
+ import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils";
3
+
4
+ /**
5
+ * Pins where `getNativeProps()`'s `metadata` object comes from: the player
6
+ * manager's content channel, looked up by `props.playerId`.
7
+ *
8
+ * This deliberately does NOT depend on tree position. An earlier version read
9
+ * the values from `PlayerContainerContext` via `static contextType`, which
10
+ * made native's props depend on `Player` being rendered inside
11
+ * `PlayerContainerContextProvider` - safe only as long as an exhaustive
12
+ * render-site search stayed true. Reading from the manager removes that whole
13
+ * class of fragility, so the tests below assert the value's source rather than
14
+ * a render-site invariant.
15
+ *
16
+ * Lookup is by id rather than `getActivePlayer()` because these props are
17
+ * handed to this component's own native view: they must describe that player
18
+ * even while a cast session is running.
19
+ *
20
+ * The instance is constructed directly (bypassing `render()`/mount) so this
21
+ * exercises the real `getNativeProps()` without paying for a full render of
22
+ * the ~37KB `VideoPlayer` class tree.
23
+ */
24
+
25
+ jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
26
+ ...jest.requireActual("@applicaster/zapp-react-native-utils/appUtils"),
27
+ playerManager: {
28
+ getPlayerWithId: jest.fn(),
29
+ },
30
+ }));
31
+
32
+ const entry = {
33
+ id: "1",
34
+ title: "Entry Title",
35
+ summary: "Entry Summary",
36
+ } as unknown as ZappEntry;
37
+
38
+ const baseProps = {
39
+ entry,
40
+ pluginConfiguration: {},
41
+ docked: false,
42
+ fullscreen: false,
43
+ inline: false,
44
+ isModal: false,
45
+ isTabletPortrait: false,
46
+ muted: false,
47
+ playerId: "player-1",
48
+ PlayerComponent: "QuickBrickDefaultPlayerView",
49
+ } as unknown as ConstructorParameters<typeof VideoPlayer>[0];
50
+
51
+ const publishContent = (
52
+ content: {
53
+ title: Option<string | number>;
54
+ summary: Option<string | number>;
55
+ } | null
56
+ ) => {
57
+ (playerManager.getPlayerWithId as jest.Mock).mockReturnValue(
58
+ content ? { getContent: () => content } : null
59
+ );
60
+ };
61
+
62
+ describe("Player's getNativeProps() reading Title/Subtitle from the manager", () => {
63
+ it("sends the title/summary the player published", () => {
64
+ publishContent({
65
+ title: "Resolved Title",
66
+ summary: "Resolved Summary",
67
+ });
68
+
69
+ const nativeProps = new VideoPlayer(baseProps).getNativeProps();
70
+
71
+ expect(nativeProps.metadata.title).toBe("Resolved Title");
72
+ expect(nativeProps.metadata.subtitle).toBe("Resolved Summary");
73
+ });
74
+
75
+ it("looks the player up by its own id, not the active player", () => {
76
+ publishContent({ title: "Resolved Title", summary: "Resolved Summary" });
77
+
78
+ new VideoPlayer(baseProps).getNativeProps();
79
+
80
+ expect(playerManager.getPlayerWithId).toHaveBeenCalledWith("player-1");
81
+ });
82
+
83
+ it("sends undefined when the player published no text", () => {
84
+ publishContent({ title: null, summary: null });
85
+
86
+ const nativeProps = new VideoPlayer(baseProps).getNativeProps();
87
+
88
+ expect(nativeProps.metadata.title).toBeUndefined();
89
+ expect(nativeProps.metadata.subtitle).toBeUndefined();
90
+ });
91
+
92
+ it("sends undefined rather than crashing when no player is registered", () => {
93
+ publishContent(null);
94
+
95
+ const nativeProps = new VideoPlayer(baseProps).getNativeProps();
96
+
97
+ expect(nativeProps.metadata.title).toBeUndefined();
98
+ expect(nativeProps.metadata.subtitle).toBeUndefined();
99
+ });
100
+
101
+ it("does not fall back to entry.title/entry.summary itself - the resolver owns that fallback", () => {
102
+ // `entry.title`/`entry.summary` are "Entry Title"/"Entry Summary" (see
103
+ // `baseProps`); with nothing published, nativeProps must NOT silently pick
104
+ // those up as a second, redundant fallback layer inside `Player`. The
105
+ // entry-field fallback lives in `resolvePlayerContentText`, reached
106
+ // through the player's own `getContent()`.
107
+ publishContent({ title: null, summary: null });
108
+
109
+ const nativeProps = new VideoPlayer(baseProps).getNativeProps();
110
+
111
+ expect(nativeProps.metadata.title).not.toBe(entry.title);
112
+ expect(nativeProps.metadata.subtitle).not.toBe(entry.summary);
113
+ expect(nativeProps.entry).toBe(entry);
114
+ });
115
+ });
@@ -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
+ });
@@ -52,7 +52,7 @@ import { TVPlayerView } from "./PlayerView/TVPlayerView";
52
52
  import PlayerModal, { VideoPlayerModal } from "./PlayerModal";
53
53
  import { DockedControls } from "./AudioLayer/Layout/DockedControls";
54
54
  import { FlexImage } from "./AudioLayer/Layout/PlayerImage/FlexImage";
55
- import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/conts";
55
+ import { PlayerError } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
56
56
 
57
57
  const isTvOS = isTvOSPlatform();
58
58
 
@@ -69,6 +69,60 @@ const styles = StyleSheet.create({
69
69
  flexImage: { height: "100%", aspectRatio: 1, flex: -1 },
70
70
  });
71
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
+
72
126
  type VideoPlayerProps = QuickBrickPlayer.PlayerProps & {
73
127
  isCasting?: boolean;
74
128
  playNextData?: PlayNextData;
@@ -1072,6 +1126,15 @@ export default class VideoPlayer extends React.Component<
1072
1126
  const liveCatchUpEnabled =
1073
1127
  this.props.pluginConfiguration?.liveCatchUpEnabled;
1074
1128
 
1129
+ // Look the player up by id rather than using the active player: these
1130
+ // props are handed to this component's own native player view, so they
1131
+ // must describe that player's content even while a cast session is
1132
+ // running (during which `usePlayer` swaps React consumers over to the
1133
+ // casting receiver).
1134
+ const metadata = toNativeMetadata(
1135
+ playerManager.getPlayerWithId(this.props.playerId)?.getContent()
1136
+ );
1137
+
1075
1138
  const nativeProps = {
1076
1139
  rate: undefined,
1077
1140
  entry,
@@ -1090,6 +1153,7 @@ export default class VideoPlayer extends React.Component<
1090
1153
  liveCatchUpEnabled,
1091
1154
  playerId: this.props.playerId,
1092
1155
  pictureInPictureEnabled,
1156
+ metadata,
1093
1157
  seekStep:
1094
1158
  (this.props.pluginConfiguration?.seek_duration || SEEK_TIME) * 1000,
1095
1159
  accessibilityProps: getAllAccessibilityProps(
@@ -1146,10 +1210,15 @@ export default class VideoPlayer extends React.Component<
1146
1210
  return null;
1147
1211
  }
1148
1212
 
1149
- const TransportControls = isTV()
1213
+ const isTvPlatform = isTV();
1214
+
1215
+ const TransportControls = isTvPlatform
1150
1216
  ? TransportControlsTV
1151
1217
  : TransportControlsMobile;
1152
1218
 
1219
+ // The mobile and TV transport controls read Title/Subtitle from the
1220
+ // player manager's content channel (via `usePlayerContent`) instead of
1221
+ // receiving them as props.
1153
1222
  const needToShowDefaultControls = !(
1154
1223
  (controls && !castAction?.state?.connected) ||
1155
1224
  this.getPlayNextData()