@applicaster/quick-brick-player 16.0.0-alpha.1212303401 → 16.0.0-alpha.1263110541
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 +5 -5
- package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +25 -4
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +14 -0
- 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/__tests__/playerActions.test.ts +27 -0
- package/src/Player/AudioLayer/playerActions.ts +22 -5
- package/src/Player/PlayerModal/VideoPlayerModal.tsx +18 -1
- package/src/Player/PlayerModal/__tests__/VideoPlayerModal.test.tsx +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/quick-brick-player",
|
|
3
|
-
"version": "16.0.0-alpha.
|
|
3
|
+
"version": "16.0.0-alpha.1263110541",
|
|
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.
|
|
38
|
+
"@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.73",
|
|
39
39
|
"@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.69",
|
|
40
|
-
"@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.
|
|
41
|
-
"@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.
|
|
42
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-alpha.
|
|
40
|
+
"@applicaster/zapp-react-native-tvos-app": "16.0.0-alpha.1263110541",
|
|
41
|
+
"@applicaster/zapp-react-native-ui-components": "16.0.0-alpha.1263110541",
|
|
42
|
+
"@applicaster/zapp-react-native-utils": "16.0.0-alpha.1263110541",
|
|
43
43
|
"query-string": "7.1.3",
|
|
44
44
|
"shaka-player": "4.3.5",
|
|
45
45
|
"typeface-montserrat": "^0.0.54",
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
+
controlsRowActionIdentifiers,
|
|
2
3
|
nextTrackButton,
|
|
3
4
|
previousTrackButton,
|
|
4
5
|
usesPlaylistControls,
|
|
5
6
|
} from "../utils";
|
|
7
|
+
import { PLAYER_ACTION_IDENTIFIERS } from "../../../playerActions";
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
key === "player_controls_layout" ? layout : null) as unknown as GetValue;
|
|
9
|
+
const configuredAs = (layout?: unknown): GetValue =>
|
|
10
|
+
((key: string) =>
|
|
11
|
+
key === "player_controls_layout" ? layout : null) as unknown as GetValue;
|
|
11
12
|
|
|
13
|
+
describe("usesPlaylistControls", () => {
|
|
12
14
|
it("opts in only for the playlist controls value", () => {
|
|
13
15
|
expect(usesPlaylistControls(configuredAs("playlist_controls"))).toBe(true);
|
|
14
16
|
});
|
|
@@ -29,6 +31,25 @@ describe("usesPlaylistControls", () => {
|
|
|
29
31
|
});
|
|
30
32
|
});
|
|
31
33
|
|
|
34
|
+
describe("controlsRowActionIdentifiers", () => {
|
|
35
|
+
it("claims playback speed and sleep timer on the default layout", () => {
|
|
36
|
+
expect(
|
|
37
|
+
controlsRowActionIdentifiers(
|
|
38
|
+
configuredAs("playback_speed_and_sleep_timer")
|
|
39
|
+
)
|
|
40
|
+
).toEqual([
|
|
41
|
+
PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
|
|
42
|
+
PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("claims nothing when the row shows playlist controls", () => {
|
|
47
|
+
expect(
|
|
48
|
+
controlsRowActionIdentifiers(configuredAs("playlist_controls"))
|
|
49
|
+
).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
32
53
|
describe("track control buttons", () => {
|
|
33
54
|
it("builds a previous button using the previous_track icon", () => {
|
|
34
55
|
const onPress = jest.fn();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PLAYER_ACTION_IDENTIFIERS } from "../../playerActions";
|
|
2
|
+
|
|
1
3
|
const playbackButtonMargins = (
|
|
2
4
|
isTablet: boolean,
|
|
3
5
|
isLiveShowFullControls: boolean
|
|
@@ -109,6 +111,18 @@ export const sleepButton = (props: ButtonsProps): ControlsButton =>
|
|
|
109
111
|
export const usesPlaylistControls = (value: GetValue): boolean =>
|
|
110
112
|
value("player_controls_layout") === "playlist_controls";
|
|
111
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Player actions this row renders as its own buttons. Consumers of the shared
|
|
116
|
+
* player action list have to filter these out to avoid showing them twice.
|
|
117
|
+
*/
|
|
118
|
+
export const controlsRowActionIdentifiers = (value: GetValue): string[] =>
|
|
119
|
+
usesPlaylistControls(value)
|
|
120
|
+
? []
|
|
121
|
+
: [
|
|
122
|
+
PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
|
|
123
|
+
PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
|
|
124
|
+
];
|
|
125
|
+
|
|
112
126
|
type TrackButtonProps = ButtonsProps & { disabled?: boolean };
|
|
113
127
|
|
|
114
128
|
const trackButtonByName = ({
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, waitFor } from "@testing-library/react-native";
|
|
3
|
+
|
|
4
|
+
import { TransportInfo } from "../index";
|
|
5
|
+
import { resolvePlayerActions } from "@applicaster/zapp-react-native-utils/playerUtils";
|
|
6
|
+
import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
|
|
7
|
+
import { TransportInfo as MobileTransportInfo } from "@applicaster/quick-brick-mobile-transport-controls";
|
|
8
|
+
import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
9
|
+
import { PLAYER_ACTION_IDENTIFIERS } from "../../../playerActions";
|
|
10
|
+
|
|
11
|
+
jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
|
|
12
|
+
useIsTablet: () => false,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock("react-native-safe-area-context", () => ({
|
|
16
|
+
useSafeAreaFrame: () => ({ height: 800, width: 400 }),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
jest.mock(
|
|
20
|
+
"@applicaster/zapp-react-native-ui-components/Hooks/useEntryActionsExpander",
|
|
21
|
+
() => ({ useEntryActionsExpander: () => () => [] })
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
jest.mock("@applicaster/zapp-react-native-redux/hooks", () => ({
|
|
25
|
+
usePlugins: jest.fn(() => []),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock("@applicaster/zapp-react-native-utils/playerUtils", () => {
|
|
29
|
+
const actual = jest.requireActual(
|
|
30
|
+
"@applicaster/zapp-react-native-utils/playerUtils"
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
...actual,
|
|
35
|
+
resolvePlayerActions: jest.fn(),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
jest.mock("@applicaster/quick-brick-mobile-transport-controls", () => ({
|
|
40
|
+
TransportInfo: jest.fn(() => null),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const MobileTransportInfoMock = MobileTransportInfo as unknown as jest.Mock;
|
|
44
|
+
const resolvePlayerActionsMock = resolvePlayerActions as jest.Mock;
|
|
45
|
+
const usePluginsMock = usePlugins as jest.Mock;
|
|
46
|
+
|
|
47
|
+
const action = (identifier: string) =>
|
|
48
|
+
({ identifier, action: { invokeAction: jest.fn() } }) as any;
|
|
49
|
+
|
|
50
|
+
const valueForLayout = (layout?: string): GetValue =>
|
|
51
|
+
((key: string) =>
|
|
52
|
+
key === "player_controls_layout" ? layout : null) as unknown as GetValue;
|
|
53
|
+
|
|
54
|
+
const entry: ZappEntry = {
|
|
55
|
+
id: "audio-1",
|
|
56
|
+
title: "Track",
|
|
57
|
+
type: { value: "audio" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const layoutState = { inline: false, docked: false, isModal: true };
|
|
61
|
+
|
|
62
|
+
describe("Audio TransportInfo overflow wiring", () => {
|
|
63
|
+
let getActionsSpy: jest.SpyInstance;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
jest.clearAllMocks();
|
|
67
|
+
|
|
68
|
+
resolvePlayerActionsMock.mockReturnValue([
|
|
69
|
+
action("favorites"),
|
|
70
|
+
action("offline-content-button"),
|
|
71
|
+
action("native_share_action"),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
getActionsSpy = jest
|
|
75
|
+
.spyOn(uiActionsRegistry, "getActions")
|
|
76
|
+
.mockImplementation((identifier: string) => {
|
|
77
|
+
if (
|
|
78
|
+
identifier === PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED ||
|
|
79
|
+
identifier === PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER
|
|
80
|
+
) {
|
|
81
|
+
return [action(identifier)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [];
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
getActionsSpy.mockRestore();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("passes resolved actions as descriptors and enables overflow when the plugin is present", () => {
|
|
93
|
+
usePluginsMock.mockReturnValue([
|
|
94
|
+
{ identifier: "open-modal-bottom-sheet-cell-action" },
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
render(
|
|
98
|
+
<TransportInfo
|
|
99
|
+
entry={entry}
|
|
100
|
+
layoutState={layoutState}
|
|
101
|
+
value={valueForLayout("playback_speed_and_sleep_timer")}
|
|
102
|
+
configuration={{ player_action_buttons: "a,b,c" }}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(resolvePlayerActionsMock).toHaveBeenCalledWith({
|
|
107
|
+
configuration: { player_action_buttons: "a,b,c" },
|
|
108
|
+
entry,
|
|
109
|
+
expandEntryActions: expect.any(Function),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const props = MobileTransportInfoMock.mock.calls[0][0];
|
|
113
|
+
|
|
114
|
+
expect(props.actionButtonDescriptors).toEqual([
|
|
115
|
+
{
|
|
116
|
+
kind: "action",
|
|
117
|
+
action: expect.objectContaining({ identifier: "favorites" }),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
kind: "action",
|
|
121
|
+
action: expect.objectContaining({
|
|
122
|
+
identifier: "offline-content-button",
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
kind: "action",
|
|
127
|
+
action: expect.objectContaining({ identifier: "native_share_action" }),
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
expect(props.actionButtons).toBeUndefined();
|
|
132
|
+
|
|
133
|
+
// Controls row renders speed and sleep timer itself in this layout.
|
|
134
|
+
expect(getActionsSpy).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("appends playback speed and sleep timer when playlist controls layout is active", async () => {
|
|
138
|
+
usePluginsMock.mockReturnValue([
|
|
139
|
+
{ identifier: "open-modal-bottom-sheet-cell-action" },
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
resolvePlayerActionsMock.mockReturnValue([
|
|
143
|
+
action("favorites"),
|
|
144
|
+
action("offline-content-button"),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
// The audio player registers these in a later effect than our first paint.
|
|
148
|
+
let registered = false;
|
|
149
|
+
|
|
150
|
+
getActionsSpy.mockImplementation((identifier: string) => {
|
|
151
|
+
if (
|
|
152
|
+
!registered ||
|
|
153
|
+
(identifier !== PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED &&
|
|
154
|
+
identifier !== PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER)
|
|
155
|
+
) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return [action(identifier)];
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
render(
|
|
163
|
+
<TransportInfo
|
|
164
|
+
entry={entry}
|
|
165
|
+
layoutState={layoutState}
|
|
166
|
+
value={valueForLayout("playlist_controls")}
|
|
167
|
+
configuration={{ player_action_buttons: "favorites,offline" }}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(
|
|
172
|
+
MobileTransportInfoMock.mock.calls[0][0].actionButtonDescriptors
|
|
173
|
+
).toEqual([
|
|
174
|
+
{
|
|
175
|
+
kind: "action",
|
|
176
|
+
action: expect.objectContaining({ identifier: "favorites" }),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
kind: "action",
|
|
180
|
+
action: expect.objectContaining({
|
|
181
|
+
identifier: "offline-content-button",
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
// Registering for real is what tells subscribers to re-read the registry.
|
|
187
|
+
registered = true;
|
|
188
|
+
|
|
189
|
+
const unregister = uiActionsRegistry.registerAction(
|
|
190
|
+
PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
|
|
191
|
+
{ invokeAction: jest.fn() }
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await waitFor(() => {
|
|
195
|
+
const latest =
|
|
196
|
+
MobileTransportInfoMock.mock.calls[
|
|
197
|
+
MobileTransportInfoMock.mock.calls.length - 1
|
|
198
|
+
][0];
|
|
199
|
+
|
|
200
|
+
expect(latest.actionButtonDescriptors).toEqual([
|
|
201
|
+
{
|
|
202
|
+
kind: "action",
|
|
203
|
+
action: expect.objectContaining({ identifier: "favorites" }),
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
kind: "action",
|
|
207
|
+
action: expect.objectContaining({
|
|
208
|
+
identifier: "offline-content-button",
|
|
209
|
+
}),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
kind: "action",
|
|
213
|
+
action: expect.objectContaining({
|
|
214
|
+
identifier: PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
kind: "action",
|
|
219
|
+
action: expect.objectContaining({
|
|
220
|
+
identifier: PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
|
|
221
|
+
}),
|
|
222
|
+
},
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
unregister();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("does not duplicate playback speed or sleep timer if already configured", () => {
|
|
230
|
+
resolvePlayerActionsMock.mockReturnValue([
|
|
231
|
+
action("favorites"),
|
|
232
|
+
action(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED),
|
|
233
|
+
action(PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
render(
|
|
237
|
+
<TransportInfo
|
|
238
|
+
entry={entry}
|
|
239
|
+
layoutState={layoutState}
|
|
240
|
+
value={valueForLayout("playlist_controls")}
|
|
241
|
+
configuration={{}}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const identifiers =
|
|
246
|
+
MobileTransportInfoMock.mock.calls[0][0].actionButtonDescriptors.map(
|
|
247
|
+
(descriptor: { action: { identifier: string } }) =>
|
|
248
|
+
descriptor.action.identifier
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(identifiers).toEqual([
|
|
252
|
+
"favorites",
|
|
253
|
+
PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
|
|
254
|
+
PLAYER_ACTION_IDENTIFIERS.SLEEP_TIMER,
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -3,7 +3,8 @@ import { LayoutChangeEvent, StyleSheet, View } from "react-native";
|
|
|
3
3
|
|
|
4
4
|
import { TransportInfo as MobileTransportInfo } from "@applicaster/quick-brick-mobile-transport-controls";
|
|
5
5
|
import { useIsTablet } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
6
|
-
import {
|
|
6
|
+
import { useRegistryRevision } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
|
|
7
|
+
import { useEntryActionsExpander } from "@applicaster/zapp-react-native-ui-components/Hooks/useEntryActionsExpander";
|
|
7
8
|
|
|
8
9
|
import { toPositiveNumberWithDefaultZero } from "@applicaster/zapp-react-native-utils/numberUtils";
|
|
9
10
|
import { noop } from "@applicaster/zapp-react-native-utils/functionUtils";
|
|
@@ -12,7 +13,22 @@ import { NAVBAR_HEIGHT, TABLET_LANDSCAPE } from "../const";
|
|
|
12
13
|
import { withDebug } from "../debug";
|
|
13
14
|
import { useSafeAreaFrame } from "react-native-safe-area-context";
|
|
14
15
|
import { isSmallTabletHeight } from "../utils";
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ActionButtonDescriptor,
|
|
18
|
+
resolvePlayerActions,
|
|
19
|
+
} from "@applicaster/zapp-react-native-utils/playerUtils";
|
|
20
|
+
import {
|
|
21
|
+
RegisteredAction,
|
|
22
|
+
uiActionsRegistry,
|
|
23
|
+
} from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
24
|
+
|
|
25
|
+
import { PLAYER_ACTION_IDENTIFIERS } from "../../playerActions";
|
|
26
|
+
import { controlsRowActionIdentifiers } from "../Controls/utils";
|
|
27
|
+
|
|
28
|
+
/** Player-owned actions that join the shared action list of the overlay. */
|
|
29
|
+
const AUDIO_PLAYER_ACTION_IDENTIFIERS: string[] = Object.values(
|
|
30
|
+
PLAYER_ACTION_IDENTIFIERS
|
|
31
|
+
);
|
|
16
32
|
|
|
17
33
|
const styles = StyleSheet.create({
|
|
18
34
|
container: {
|
|
@@ -52,7 +68,8 @@ export const TransportInfo = ({
|
|
|
52
68
|
}: Props) => {
|
|
53
69
|
const isTablet = useIsTablet();
|
|
54
70
|
const safeAreaFrame = useSafeAreaFrame();
|
|
55
|
-
const
|
|
71
|
+
const registryRevision = useRegistryRevision();
|
|
72
|
+
const expandEntryActions = useEntryActionsExpander();
|
|
56
73
|
|
|
57
74
|
const [marginBottom, setMarginBottom] = React.useState(0);
|
|
58
75
|
|
|
@@ -81,11 +98,56 @@ export const TransportInfo = ({
|
|
|
81
98
|
[onLayout]
|
|
82
99
|
);
|
|
83
100
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
101
|
+
// Joined so the memo below is not invalidated on every render.
|
|
102
|
+
const controlsRowIdentifiersKey =
|
|
103
|
+
controlsRowActionIdentifiers(value).join(",");
|
|
104
|
+
|
|
105
|
+
const overlayActionIdentifiers = React.useMemo(() => {
|
|
106
|
+
const renderedByControlsRow = controlsRowIdentifiersKey.split(",");
|
|
107
|
+
|
|
108
|
+
return AUDIO_PLAYER_ACTION_IDENTIFIERS.filter(
|
|
109
|
+
(identifier) => !renderedByControlsRow.includes(identifier)
|
|
110
|
+
);
|
|
111
|
+
}, [controlsRowIdentifiersKey]);
|
|
112
|
+
|
|
113
|
+
const readOwnPlayerActions = React.useCallback(
|
|
114
|
+
(): RegisteredAction[] =>
|
|
115
|
+
overlayActionIdentifiers.flatMap((identifier) =>
|
|
116
|
+
uiActionsRegistry.getActions(identifier, entry)
|
|
117
|
+
),
|
|
118
|
+
[entry, overlayActionIdentifiers]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Playback speed and the sleep timer register in a later effect than our
|
|
122
|
+
// first render - speed waits for SpeedController state - so re-read whenever
|
|
123
|
+
// the registry changes instead of guessing when they will be there.
|
|
124
|
+
const playerActions = React.useMemo(
|
|
125
|
+
() => readOwnPlayerActions(),
|
|
126
|
+
// eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
|
|
127
|
+
[readOwnPlayerActions, registryRevision]
|
|
87
128
|
);
|
|
88
129
|
|
|
130
|
+
const actionButtonDescriptors = React.useMemo<
|
|
131
|
+
ActionButtonDescriptor[]
|
|
132
|
+
>(() => {
|
|
133
|
+
const content = resolvePlayerActions({
|
|
134
|
+
configuration,
|
|
135
|
+
entry,
|
|
136
|
+
expandEntryActions,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const seen = new Set(content.map((action) => action.identifier));
|
|
140
|
+
|
|
141
|
+
const extras = playerActions.filter(
|
|
142
|
+
(action) => action?.identifier && !seen.has(action.identifier)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return [...content, ...extras].map((action) => ({
|
|
146
|
+
kind: "action" as const,
|
|
147
|
+
action,
|
|
148
|
+
}));
|
|
149
|
+
}, [configuration, entry, playerActions, expandEntryActions]);
|
|
150
|
+
|
|
89
151
|
return (
|
|
90
152
|
<View
|
|
91
153
|
style={[
|
|
@@ -106,9 +168,8 @@ export const TransportInfo = ({
|
|
|
106
168
|
content={entry}
|
|
107
169
|
value={value}
|
|
108
170
|
visible
|
|
109
|
-
languageData={languageData}
|
|
110
171
|
layoutState={layoutState}
|
|
111
|
-
|
|
172
|
+
actionButtonDescriptors={actionButtonDescriptors}
|
|
112
173
|
/>
|
|
113
174
|
</View>
|
|
114
175
|
</View>
|
|
@@ -78,6 +78,8 @@ describe("playerActions", () => {
|
|
|
78
78
|
playback_sleep_off_label: "Off",
|
|
79
79
|
sleep_timer: "sleep_timer",
|
|
80
80
|
sleep_timer_active: "sleep_timer_active",
|
|
81
|
+
speed_1: "speed_1_asset_uri",
|
|
82
|
+
speed_1_5: "speed_1_5_asset_uri",
|
|
81
83
|
}),
|
|
82
84
|
setPlaybackRate: jest.fn(),
|
|
83
85
|
startSleepTimer: jest.fn(),
|
|
@@ -325,6 +327,31 @@ describe("playerActions", () => {
|
|
|
325
327
|
});
|
|
326
328
|
});
|
|
327
329
|
|
|
330
|
+
it("initialEntryState for playback-speed uses the current speed icon asset", async () => {
|
|
331
|
+
const action = uiActionsRegistry.getAction(
|
|
332
|
+
PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
expect(action.initialEntryState({} as ZappEntry)).toMatchObject({
|
|
336
|
+
label: "Playback Speed",
|
|
337
|
+
asset: "speed_1_asset_uri",
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await speedController.setPlaybackSpeed({
|
|
341
|
+
asset: "speed_1_5",
|
|
342
|
+
value: 1.5,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const observed = await firstValueFrom(
|
|
346
|
+
action.observeEntryState({} as ZappEntry)
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
expect(observed).toMatchObject({
|
|
350
|
+
label: "Playback Speed",
|
|
351
|
+
asset: "speed_1_5_asset_uri",
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
328
355
|
it("registers UI action identifiers that open the matching sheets", async () => {
|
|
329
356
|
await uiActionsRegistry
|
|
330
357
|
.getAction(PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED)
|
|
@@ -75,6 +75,22 @@ function sleepTimerEntryState(
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function playbackSpeedEntryState(speed: {
|
|
79
|
+
asset?: string;
|
|
80
|
+
value?: number;
|
|
81
|
+
}): CellActionEntryState {
|
|
82
|
+
const config = getPlayerConfig();
|
|
83
|
+
const assetKey = speed?.asset || formatSpeed(speed?.value ?? 1);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
state: 0,
|
|
87
|
+
label: config.playback_speed_title || "Playback Speed",
|
|
88
|
+
// Manifest keys are speed_0_8 / speed_1 / … (same icons as the transport row).
|
|
89
|
+
// There is no playback_speed uploader.
|
|
90
|
+
asset: config[assetKey] || config.speed_1 || "",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
export async function openPlaybackSpeedAction(
|
|
79
95
|
speedController: SpeedController
|
|
80
96
|
): Promise<ActionResult> {
|
|
@@ -220,11 +236,12 @@ export function registerPlayerActions(options: {
|
|
|
220
236
|
uiActionsRegistry.registerAction(
|
|
221
237
|
PLAYER_ACTION_IDENTIFIERS.PLAYBACK_SPEED,
|
|
222
238
|
{
|
|
223
|
-
initialEntryState: () =>
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
239
|
+
initialEntryState: () =>
|
|
240
|
+
playbackSpeedEntryState(speedController.currentSpeed),
|
|
241
|
+
observeEntryState: () =>
|
|
242
|
+
speedController.playbackSpeed$.pipe(
|
|
243
|
+
map((speed) => playbackSpeedEntryState(speed))
|
|
244
|
+
),
|
|
228
245
|
invokeAction: async () => {
|
|
229
246
|
await actionExecutor.handleAction({
|
|
230
247
|
type: PLAYER_ACTION_TYPES.OPEN_PLAYBACK_SPEED,
|
|
@@ -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
|
+
});
|