@applicaster/quick-brick-player 16.0.0-rc.11 → 16.0.0-rc.111
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 +6 -6
- package/src/Player/AudioLayer/AudioDescription.tsx +46 -11
- package/src/Player/AudioLayer/AudioPlayerWrapper.tsx +38 -48
- package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +87 -0
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +34 -14
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +40 -0
- package/src/Player/AudioLayer/Layout/DockedControls/index.tsx +1 -13
- package/src/Player/AudioLayer/Layout/MobileLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletLandscapeLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TabletPortraitLayout.tsx +0 -4
- package/src/Player/AudioLayer/Layout/TransportInfo/__tests__/index.test.tsx +257 -0
- package/src/Player/AudioLayer/Layout/TransportInfo/index.tsx +69 -8
- package/src/Player/AudioLayer/PlaylistNavigationController.ts +354 -0
- package/src/Player/AudioLayer/SleepTimerManager.ts +5 -1
- package/src/Player/AudioLayer/SpeedManager.ts +4 -0
- package/src/Player/AudioLayer/__tests__/AudioDescription.test.tsx +147 -0
- package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
- package/src/Player/AudioLayer/__tests__/PlaylistNavigationController.test.ts +528 -0
- package/src/Player/AudioLayer/__tests__/SleepTimerManager.test.ts +78 -0
- package/src/Player/AudioLayer/__tests__/SpeedManager.test.ts +57 -0
- package/src/Player/AudioLayer/__tests__/playerActions.test.ts +369 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +539 -0
- package/src/Player/AudioLayer/playerActions.ts +294 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +148 -0
- package/src/Player/AudioLayer/useSleepTimerModal.ts +20 -57
- package/src/Player/PlayerModal/VideoPlayerModal.tsx +18 -1
- package/src/Player/PlayerModal/__tests__/VideoPlayerModal.test.tsx +160 -0
- package/src/Player/__tests__/getNativeProps.test.ts +115 -0
- package/src/Player/__tests__/onTracksChanged.test.ts +192 -0
- package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
- package/src/Player/index.tsx +168 -40
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/SleepTimerModalHeader.tsx +0 -66
- package/src/Player/AudioLayer/components/SleepTimerModalHeader/index.ts +0 -1
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|