@applicaster/quick-brick-player 16.0.0-rc.59 → 16.0.0-rc.60
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 +21 -8
- package/src/Player/AudioLayer/Layout/Controls/__tests__/utils.test.ts +66 -0
- package/src/Player/AudioLayer/Layout/Controls/index.tsx +32 -6
- package/src/Player/AudioLayer/Layout/Controls/utils.ts +26 -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/__tests__/AudioDescription.test.tsx +147 -0
- package/src/Player/AudioLayer/__tests__/AudioPlayerWrapper.test.tsx +130 -0
- package/src/Player/AudioLayer/__tests__/usePlaylistNavigation.test.ts +387 -0
- package/src/Player/AudioLayer/usePlaylistNavigation.ts +134 -0
- package/src/Player/__tests__/getNativeProps.test.ts +115 -0
- package/src/Player/__tests__/toNativeMetadata.test.ts +77 -0
- package/src/Player/index.tsx +70 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/quick-brick-player",
|
|
3
|
-
"version": "16.0.0-rc.
|
|
3
|
+
"version": "16.0.0-rc.60",
|
|
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.
|
|
39
|
-
"@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.
|
|
40
|
-
"@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.
|
|
41
|
-
"@applicaster/zapp-react-native-ui-components": "16.0.0-rc.
|
|
42
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-rc.
|
|
38
|
+
"@applicaster/quick-brick-mobile-transport-controls": "16.0.0-rc.60",
|
|
39
|
+
"@applicaster/quick-brick-tv-transport-controls": "16.0.0-rc.60",
|
|
40
|
+
"@applicaster/zapp-react-native-tvos-app": "16.0.0-rc.60",
|
|
41
|
+
"@applicaster/zapp-react-native-ui-components": "16.0.0-rc.60",
|
|
42
|
+
"@applicaster/zapp-react-native-utils": "16.0.0-rc.60",
|
|
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 = (
|
|
100
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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>
|
|
@@ -12,7 +12,6 @@ import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
|
12
12
|
import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
13
13
|
import { PlayNextData } from "@applicaster/zapp-react-native-ui-components/Components/PlayerContainer/PlayerContainer";
|
|
14
14
|
import { formatSpeed } from "./utils";
|
|
15
|
-
import { useTitleSummaryOverlay } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerTitleSummaryOverlay";
|
|
16
15
|
import { toNumberWithDefault } from "@applicaster/zapp-react-native-utils/numberUtils";
|
|
17
16
|
import { DEFAULT_SPEED, PlaybackSpeed, SpeedController } from "./SpeedManager";
|
|
18
17
|
import {
|
|
@@ -83,11 +82,27 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
|
|
|
83
82
|
playNextData = null,
|
|
84
83
|
} = props;
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
// For live audio the programme changes while the entry stays the same, so
|
|
86
|
+
// the player polls the channel's programme feed and republishes its content.
|
|
87
|
+
// Everything about that - whether the content is live at all, where the feed
|
|
88
|
+
// lives, how often to poll - is decided by `OverlaysObserver`, which loads
|
|
89
|
+
// the feed without React. This effect owns only the lifecycle: keep the
|
|
90
|
+
// metadata fresh while this player is mounted. Consumers read the result
|
|
91
|
+
// through `usePlayerContent` like any other player content.
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
if (!player) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const observer = player.getOverlayObservable();
|
|
98
|
+
|
|
99
|
+
observer.startLiveContentUpdates();
|
|
100
|
+
|
|
101
|
+
return () => observer.stopLiveContentUpdates();
|
|
102
|
+
// Keyed on the entry's id rather than the object: starting the updates
|
|
103
|
+
// fires an immediate request, so a re-created `entry` prop with identical
|
|
104
|
+
// contents would re-fetch the feed on every render.
|
|
105
|
+
}, [player, playerState?.isLive, entry?.id]);
|
|
91
106
|
|
|
92
107
|
const [speedManager, setSpeedManager] = useState<SpeedController | null>(
|
|
93
108
|
null
|
|
@@ -259,8 +274,6 @@ export const AudioPlayerWrapper: React.FC<Props> = (props: Props) => {
|
|
|
259
274
|
configuration={configuration}
|
|
260
275
|
dimensions={dimensions}
|
|
261
276
|
playNextData={playNextData}
|
|
262
|
-
title={title}
|
|
263
|
-
summary={summary}
|
|
264
277
|
/>
|
|
265
278
|
</View>
|
|
266
279
|
</SafeAreaView>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
nextTrackButton,
|
|
3
|
+
previousTrackButton,
|
|
4
|
+
usesPlaylistControls,
|
|
5
|
+
} from "../utils";
|
|
6
|
+
|
|
7
|
+
describe("usesPlaylistControls", () => {
|
|
8
|
+
const configuredAs = (layout?: unknown): GetValue =>
|
|
9
|
+
((key: string) =>
|
|
10
|
+
key === "player_controls_layout" ? layout : null) as unknown as GetValue;
|
|
11
|
+
|
|
12
|
+
it("opts in only for the playlist controls value", () => {
|
|
13
|
+
expect(usesPlaylistControls(configuredAs("playlist_controls"))).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps the original controls for the default value", () => {
|
|
17
|
+
expect(
|
|
18
|
+
usesPlaylistControls(configuredAs("playback_speed_and_sleep_timer"))
|
|
19
|
+
).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it.each([
|
|
23
|
+
["an undeclared key", undefined],
|
|
24
|
+
["a null value", null],
|
|
25
|
+
["an empty string", ""],
|
|
26
|
+
["an unrecognised value", "something_else"],
|
|
27
|
+
])("keeps the original controls for %s", (_label, layout) => {
|
|
28
|
+
expect(usesPlaylistControls(configuredAs(layout))).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("track control buttons", () => {
|
|
33
|
+
it("builds a previous button using the previous_track icon", () => {
|
|
34
|
+
const onPress = jest.fn();
|
|
35
|
+
|
|
36
|
+
expect(previousTrackButton({ isTablet: false, onPress })).toMatchObject({
|
|
37
|
+
size: "small",
|
|
38
|
+
icon: "previous_track",
|
|
39
|
+
onPress,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("builds a next button using the next_track icon", () => {
|
|
44
|
+
const onPress = jest.fn();
|
|
45
|
+
|
|
46
|
+
expect(nextTrackButton({ isTablet: false, onPress })).toMatchObject({
|
|
47
|
+
size: "small",
|
|
48
|
+
icon: "next_track",
|
|
49
|
+
onPress,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("propagates the disabled flag", () => {
|
|
54
|
+
expect(
|
|
55
|
+
nextTrackButton({ isTablet: false, onPress: jest.fn(), disabled: true })
|
|
56
|
+
).toMatchObject({ disabled: true });
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
previousTrackButton({
|
|
60
|
+
isTablet: false,
|
|
61
|
+
onPress: jest.fn(),
|
|
62
|
+
disabled: true,
|
|
63
|
+
})
|
|
64
|
+
).toMatchObject({ disabled: true });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -15,14 +15,18 @@ import { VerticalGutter } from "./VerticalGutter";
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
forwardButton,
|
|
18
|
+
nextTrackButton,
|
|
18
19
|
playbackButton,
|
|
20
|
+
previousTrackButton,
|
|
19
21
|
rewindButton,
|
|
20
22
|
skipToLiveButton,
|
|
21
23
|
skipToStartButton,
|
|
22
24
|
sleepButton,
|
|
23
25
|
speedButton,
|
|
26
|
+
usesPlaylistControls,
|
|
24
27
|
} from "./utils";
|
|
25
28
|
import { useSleepTimerModal } from "../../useSleepTimerModal";
|
|
29
|
+
import { usePlaylistNavigation } from "../../usePlaylistNavigation";
|
|
26
30
|
|
|
27
31
|
const styles = StyleSheet.create({
|
|
28
32
|
flex: {
|
|
@@ -58,8 +62,6 @@ type Props = {
|
|
|
58
62
|
layoutState: QuickBrickPlayer.LayoutState;
|
|
59
63
|
playerState: QuickBrickPlayer.PlayerState;
|
|
60
64
|
value: GetValue;
|
|
61
|
-
title: string | number;
|
|
62
|
-
summary: string | number;
|
|
63
65
|
|
|
64
66
|
playbackSpeedName: string;
|
|
65
67
|
|
|
@@ -82,8 +84,6 @@ export const Controls = (props: Props) => {
|
|
|
82
84
|
layoutState,
|
|
83
85
|
playerState,
|
|
84
86
|
value,
|
|
85
|
-
title,
|
|
86
|
-
summary,
|
|
87
87
|
|
|
88
88
|
playbackSpeedName,
|
|
89
89
|
|
|
@@ -101,6 +101,13 @@ export const Controls = (props: Props) => {
|
|
|
101
101
|
playerManager.getActivePlayer()
|
|
102
102
|
);
|
|
103
103
|
|
|
104
|
+
const { hasNext, hasPrevious, goNext, goPrevious } = usePlaylistNavigation(
|
|
105
|
+
entry,
|
|
106
|
+
playerState
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const isPlaylistControlsLayout = usesPlaylistControls(value);
|
|
110
|
+
|
|
104
111
|
const timerRemaining =
|
|
105
112
|
sleepTimerState.timerDate !== 0 && +sleepTimerState.timerDate > Date.now()
|
|
106
113
|
? sleepTimerState.timerDate
|
|
@@ -197,6 +204,22 @@ export const Controls = (props: Props) => {
|
|
|
197
204
|
return [skipToStart, rewind, play, forward, skipToLive];
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
if (isPlaylistControlsLayout) {
|
|
208
|
+
const previous = previousTrackButton({
|
|
209
|
+
isTablet,
|
|
210
|
+
onPress: goPrevious,
|
|
211
|
+
disabled: !hasPrevious,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const next = nextTrackButton({
|
|
215
|
+
isTablet,
|
|
216
|
+
onPress: goNext,
|
|
217
|
+
disabled: !hasNext,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return [previous, rewind, play, forward, next];
|
|
221
|
+
}
|
|
222
|
+
|
|
200
223
|
return [speed, rewind, play, forward, sleep];
|
|
201
224
|
}, [
|
|
202
225
|
playerState,
|
|
@@ -214,6 +237,11 @@ export const Controls = (props: Props) => {
|
|
|
214
237
|
playbackSleepName,
|
|
215
238
|
controller,
|
|
216
239
|
isLiveShowFullControls,
|
|
240
|
+
isPlaylistControlsLayout,
|
|
241
|
+
hasNext,
|
|
242
|
+
hasPrevious,
|
|
243
|
+
goNext,
|
|
244
|
+
goPrevious,
|
|
217
245
|
]);
|
|
218
246
|
|
|
219
247
|
return (
|
|
@@ -221,8 +249,6 @@ export const Controls = (props: Props) => {
|
|
|
221
249
|
<VerticalGutter />
|
|
222
250
|
|
|
223
251
|
<AudioDescription
|
|
224
|
-
title={title}
|
|
225
|
-
summary={summary}
|
|
226
252
|
value={value}
|
|
227
253
|
layoutState={layoutState}
|
|
228
254
|
maxWidth={maxWidth}
|
|
@@ -103,3 +103,29 @@ export const speedButton = (props: ButtonsProps): ControlsButton =>
|
|
|
103
103
|
|
|
104
104
|
export const sleepButton = (props: ButtonsProps): ControlsButton =>
|
|
105
105
|
sleepButtonByName(props);
|
|
106
|
+
|
|
107
|
+
// Opt-in: a player plugin that does not declare this key returns undefined,
|
|
108
|
+
// which has to read as the default rather than as an opt-in.
|
|
109
|
+
export const usesPlaylistControls = (value: GetValue): boolean =>
|
|
110
|
+
value("player_controls_layout") === "playlist_controls";
|
|
111
|
+
|
|
112
|
+
type TrackButtonProps = ButtonsProps & { disabled?: boolean };
|
|
113
|
+
|
|
114
|
+
const trackButtonByName = ({
|
|
115
|
+
name,
|
|
116
|
+
onPress,
|
|
117
|
+
style,
|
|
118
|
+
disabled,
|
|
119
|
+
}: TrackButtonProps & { name: string }): ControlsButton => ({
|
|
120
|
+
size: "small",
|
|
121
|
+
icon: name,
|
|
122
|
+
onPress,
|
|
123
|
+
style,
|
|
124
|
+
disabled,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export const previousTrackButton = (props: TrackButtonProps): ControlsButton =>
|
|
128
|
+
trackButtonByName({ ...props, name: "previous_track" });
|
|
129
|
+
|
|
130
|
+
export const nextTrackButton = (props: TrackButtonProps): ControlsButton =>
|
|
131
|
+
trackButtonByName({ ...props, name: "next_track" });
|
|
@@ -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
|
|
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
|
+
});
|