@applicaster/quick-brick-player 16.0.0-rc.8 → 16.0.0-rc.80

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 (31) 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/SleepTimerManager.ts +5 -1
  14. package/src/Player/AudioLayer/SpeedManager.ts +4 -0
  15. package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
  16. package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
  17. package/src/Player/AudioLayer/__tests__/SleepTimerManager.test.ts +78 -0
  18. package/src/Player/AudioLayer/__tests__/SpeedManager.test.ts +57 -0
  19. package/src/Player/AudioLayer/__tests__/playerActions.test.ts +369 -0
  20. package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
  21. package/src/Player/AudioLayer/playerActions.ts +294 -0
  22. package/src/Player/AudioLayer/usePlaylistNavigation.ts +134 -0
  23. package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
  24. package/src/Player/PlayerModal/VideoPlayerModal.tsx +18 -1
  25. package/src/Player/PlayerModal/__tests__/VideoPlayerModal.test.tsx +160 -0
  26. package/src/Player/__tests__/getNativeProps.test.ts +115 -0
  27. package/src/Player/__tests__/onTracksChanged.test.ts +192 -0
  28. package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
  29. package/src/Player/index.tsx +147 -40
  30. package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
  31. package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
@@ -0,0 +1,134 @@
1
+ import * as React from "react";
2
+
3
+ import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
4
+ import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
5
+ import {
6
+ loadFeedEntry,
7
+ retrieveFeedUrl,
8
+ retrievePlayPreviousFeedUrl,
9
+ } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/OverlayObserver/utils";
10
+ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
11
+
12
+ const { log_error, log_info } = createLogger({
13
+ subsystem: "Player",
14
+ category: "usePlaylistNavigation",
15
+ });
16
+
17
+ const RESTART_THRESHOLD_SECONDS = 3;
18
+
19
+ export type PlaylistNavigation = {
20
+ hasNext: boolean;
21
+ hasPrevious: boolean;
22
+ goNext: () => Promise<void>;
23
+ goPrevious: () => Promise<void>;
24
+ };
25
+
26
+ /**
27
+ * Each entry points at the feed holding its neighbour, so the chain walks
28
+ * itself and needs no client-side queue.
29
+ */
30
+ export const usePlaylistNavigation = (
31
+ entry: ZappEntry,
32
+ playerState: QuickBrickPlayer.PlayerState
33
+ ): PlaylistNavigation => {
34
+ const navigator = useNavigation();
35
+ // Not playerManager.getActivePlayer(): that is always the local player, so
36
+ // seeking would miss the receiver while casting.
37
+ const player = usePlayer();
38
+
39
+ const [nextUrl, setNextUrl] = React.useState<string | null>(null);
40
+
41
+ React.useEffect(() => {
42
+ let cancelled = false;
43
+
44
+ retrieveFeedUrl(entry)
45
+ .then((url) => {
46
+ if (!cancelled) {
47
+ setNextUrl(url ?? null);
48
+ }
49
+ })
50
+ .catch((error) => {
51
+ log_error(
52
+ "usePlaylistNavigation: could not resolve the next feed url",
53
+ { error }
54
+ );
55
+
56
+ if (!cancelled) {
57
+ setNextUrl(null);
58
+ }
59
+ });
60
+
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [entry]);
65
+
66
+ const previousUrl = retrievePlayPreviousFeedUrl(entry);
67
+ const currentTime = playerState?.currentTime ?? 0;
68
+
69
+ const canRestart = (playerState?.seekableDuration ?? 0) > 0;
70
+
71
+ const isNavigatingRef = React.useRef(false);
72
+
73
+ const navigateTo = React.useCallback(
74
+ async (feedUrl: string) => {
75
+ // A second tap mid-flight resolves to the same target, since `entry` has
76
+ // not been replaced yet.
77
+ if (isNavigatingRef.current) {
78
+ return;
79
+ }
80
+
81
+ isNavigatingRef.current = true;
82
+
83
+ try {
84
+ const target = await loadFeedEntry(feedUrl, entry);
85
+
86
+ log_info(`navigateTo: playing "${target.title}", id: ${target.id}`, {
87
+ feedUrl,
88
+ targetId: target.id,
89
+ });
90
+
91
+ navigator.replaceTop(target);
92
+ } catch (error) {
93
+ log_error("navigateTo: could not play the neighbouring entry", {
94
+ error,
95
+ feedUrl,
96
+ });
97
+ } finally {
98
+ // Released on failure too, so one bad request cannot latch the control.
99
+ isNavigatingRef.current = false;
100
+ }
101
+ },
102
+ [entry, navigator]
103
+ );
104
+
105
+ const goNext = React.useCallback(async () => {
106
+ if (!nextUrl) return;
107
+
108
+ await navigateTo(nextUrl);
109
+ }, [nextUrl, navigateTo]);
110
+
111
+ const goPrevious = React.useCallback(async () => {
112
+ // Restarting belongs to the stream, not the chain — so it stays available
113
+ // at the head, where there is nothing to step back to.
114
+ const shouldRestart =
115
+ canRestart && (currentTime > RESTART_THRESHOLD_SECONDS || !previousUrl);
116
+
117
+ if (shouldRestart) {
118
+ player?.seekTo(0);
119
+
120
+ return;
121
+ }
122
+
123
+ if (!previousUrl) return;
124
+
125
+ await navigateTo(previousUrl);
126
+ }, [previousUrl, currentTime, canRestart, navigateTo, player]);
127
+
128
+ return {
129
+ hasNext: Boolean(nextUrl),
130
+ hasPrevious: Boolean(previousUrl) || canRestart,
131
+ goNext,
132
+ goPrevious,
133
+ };
134
+ };
@@ -1,30 +1,19 @@
1
1
  import { usePlayerState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState";
2
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
2
3
  import { useCallback, useEffect, useMemo, useState } from "react";
3
4
  import {
4
5
  SLEEP_TIMER_OFF,
5
6
  SleepTimerController,
6
7
  SleepTimerState,
7
8
  } from "./SleepTimerManager";
8
-
9
- import { openBottomSheetModal } from "@applicaster/zapp-react-native-utils/modalState";
10
-
11
- import { SleepTimerModalHeader } from "./components/SleepTimerModalHeader";
12
- import { getSleepTimerOptions } from "../Const/sleepTimerOptions";
13
9
  import {
14
- getBottomSheetModalLabelProps,
15
- getBottomSheetModalIconProps,
16
- getBottomSheetModalItemProps,
17
- getBottomSheetModalSelectedItemIcon,
18
- selectSleepTitle,
19
- selectSleepOffLabel,
20
- } from "../Utils";
10
+ PLAYER_ACTION_IDENTIFIERS,
11
+ registerPlayerActions,
12
+ } from "./playerActions";
21
13
 
22
- export const useSleepTimerModal = (player, configuration) => {
14
+ export const useSleepTimerModal = (player) => {
23
15
  const playerState = usePlayerState("audio-player-sleep-timer-modal");
24
16
 
25
- const title = configuration(selectSleepTitle);
26
- const offLabel = configuration(selectSleepOffLabel);
27
-
28
17
  const [sleepTimerManager, setSleepTimerManager] =
29
18
  useState<SleepTimerController | null>(null);
30
19
 
@@ -33,19 +22,22 @@ export const useSleepTimerModal = (player, configuration) => {
33
22
  );
34
23
 
35
24
  useEffect(() => {
36
- if (player) {
37
- setSleepTimerManager(new SleepTimerController(player));
38
- } else {
39
- setSleepTimerManager(null);
40
- }
25
+ setSleepTimerManager(player ? new SleepTimerController(player) : null);
41
26
  }, [player]);
42
27
 
43
28
  useEffect(() => {
44
29
  if (playerState?.isLive) {
45
- // Resetting a sleep timer when audio content is live (Timer not supported on live video)
46
- sleepTimerManager.setSleepTimer(SLEEP_TIMER_OFF).catch();
30
+ sleepTimerManager?.setSleepTimer(SLEEP_TIMER_OFF).catch(() => undefined);
31
+ }
32
+ }, [playerState?.isLive, sleepTimerManager]);
33
+
34
+ useEffect(() => {
35
+ if (!sleepTimerManager || playerState.isLive) {
36
+ return;
47
37
  }
48
- }, [playerState?.isLive]);
38
+
39
+ return registerPlayerActions({ sleepController: sleepTimerManager });
40
+ }, [sleepTimerManager, playerState.isLive]);
49
41
 
50
42
  useEffect(() => {
51
43
  if (!playerState.isReadyToPlay) {
@@ -64,40 +56,11 @@ export const useSleepTimerModal = (player, configuration) => {
64
56
  };
65
57
  }, [sleepTimerManager]);
66
58
 
67
- const onSleepSelect = useCallback(
68
- async (item) => {
69
- await sleepTimerManager?.setSleepTimer(item);
70
- },
71
- [sleepTimerManager]
72
- );
73
-
74
59
  const onSleepButtonPress = useCallback(() => {
75
- const timerDate = sleepTimerState.timerDate;
76
-
77
- const timerRemaining =
78
- timerDate !== 0 && +timerDate > Date.now() ? timerDate : null;
79
-
80
- openBottomSheetModal({
81
- modalBottomSheetContentProps: {
82
- headerComponent: SleepTimerModalHeader,
83
- items: getSleepTimerOptions(offLabel, timerRemaining),
84
- onPress: onSleepSelect,
85
- title,
86
- summary: timerRemaining,
87
- itemProps: getBottomSheetModalItemProps,
88
- iconProps: getBottomSheetModalIconProps,
89
- labelProps: getBottomSheetModalLabelProps,
90
- getSelectedItemIcon: getBottomSheetModalSelectedItemIcon,
91
- },
92
- options: {
93
- supportedOrientations: [
94
- "portrait",
95
- "landscape",
96
- "portrait-upside-down",
97
- ],
98
- },
99
- });
100
- }, [sleepTimerState.timerDate, sleepTimerState.timer, title, onSleepSelect]);
60
+ uiActionsRegistry
61
+ .getAction(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
62
+ ?.invokeAction(player?.entry);
63
+ }, [player]);
101
64
 
102
65
  return useMemo(
103
66
  () => ({
@@ -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,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
+ });