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

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,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 { useLanguageData } from "@applicaster/zapp-react-native-utils/reactHooks/player";
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 { getPlayerActionButtons } from "@applicaster/zapp-react-native-utils/playerUtils";
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 languageData = useLanguageData("audio-player");
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
- const actionButtons = React.useMemo(
85
- () => getPlayerActionButtons(configuration),
86
- [configuration]
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
- actionButtons={actionButtons}
172
+ actionButtonDescriptors={actionButtonDescriptors}
112
173
  />
113
174
  </View>
114
175
  </View>
@@ -41,6 +41,10 @@ export class SleepTimerController {
41
41
  return this.sleepTimerStateSubject.asObservable();
42
42
  }
43
43
 
44
+ get currentState(): SleepTimerState {
45
+ return this.sleepTimerStateSubject.getValue();
46
+ }
47
+
44
48
  private async scheduleSleep(sleepDate): Promise<boolean> {
45
49
  log_debug(`Setting sleep interval to ${sleepDate}`);
46
50
 
@@ -118,7 +122,7 @@ export class SleepTimerController {
118
122
 
119
123
  log_debug("Sleep state restored");
120
124
 
121
- this.setSleepTimer(item as SleepTimer, futureSleepDate);
125
+ await this.setSleepTimer(item as SleepTimer, futureSleepDate);
122
126
  }
123
127
 
124
128
  // Will be called if user closed the player explicitly.
@@ -33,6 +33,10 @@ export class SpeedController {
33
33
  return this.playbackSpeedSubject.asObservable();
34
34
  }
35
35
 
36
+ get currentSpeed(): PlaybackSpeed {
37
+ return this.playbackSpeedSubject.getValue();
38
+ }
39
+
36
40
  async loadSpeed() {
37
41
  const speed = await localStorage.getItem(SPEED_PREFERENCE_KEY, NAMESPACE);
38
42
 
@@ -0,0 +1,147 @@
1
+ import React from "react";
2
+ import { StyleSheet } from "react-native";
3
+ import { render } from "@testing-library/react-native";
4
+
5
+ import { AudioDescription } from "../AudioDescription";
6
+
7
+ // `title`/`summary` come from the player manager's content channel rather than
8
+ // from props or context, so the hook is mocked here. Read lazily inside the
9
+ // factory: `jest.mock` is hoisted above the `let`, so capturing eagerly would
10
+ // freeze it at `undefined`.
11
+ let mockContent: {
12
+ title: Option<string | number>;
13
+ summary: Option<string | number>;
14
+ } = { title: "A Title", summary: "A Summary" };
15
+
16
+ jest.mock(
17
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerContent",
18
+ () => ({ usePlayerContent: () => mockContent })
19
+ );
20
+
21
+ beforeEach(() => {
22
+ mockContent = { title: "A Title", summary: "A Summary" };
23
+ });
24
+
25
+ const layoutState = { inline: false, docked: false, isModal: true };
26
+
27
+ const valueFrom =
28
+ (config: Record<string, unknown>): GetValue =>
29
+ (key: string) =>
30
+ (config[key] ?? null) as never;
31
+
32
+ const renderDescription = (
33
+ config: Record<string, unknown>,
34
+ state = layoutState
35
+ ) =>
36
+ render(
37
+ <AudioDescription
38
+ layoutState={state}
39
+ value={valueFrom(config)}
40
+ maxWidth={400}
41
+ />
42
+ );
43
+
44
+ // The wrapper's style prop is an array, so flatten before reading height.
45
+ const wrapperHeight = (tree) =>
46
+ StyleSheet.flatten(tree.toJSON().props.style).height;
47
+
48
+ describe("AudioDescription", () => {
49
+ it("renders both texts when both toggles are on", () => {
50
+ const { queryByText } = renderDescription({ title: true, subtitle: true });
51
+
52
+ expect(queryByText("A Title")).toBeTruthy();
53
+ expect(queryByText("A Summary")).toBeTruthy();
54
+ });
55
+
56
+ it("hides the title when the title toggle is off", () => {
57
+ const { queryByText } = renderDescription({ title: false, subtitle: true });
58
+
59
+ expect(queryByText("A Title")).toBeNull();
60
+ expect(queryByText("A Summary")).toBeTruthy();
61
+ });
62
+
63
+ it("hides the summary when the subtitle toggle is off", () => {
64
+ const { queryByText } = renderDescription({ title: true, subtitle: false });
65
+
66
+ expect(queryByText("A Title")).toBeTruthy();
67
+ expect(queryByText("A Summary")).toBeNull();
68
+ });
69
+
70
+ it("renders neither text when both toggles are off", () => {
71
+ const { queryByText } = renderDescription({
72
+ title: false,
73
+ subtitle: false,
74
+ });
75
+
76
+ expect(queryByText("A Title")).toBeNull();
77
+ expect(queryByText("A Summary")).toBeNull();
78
+ });
79
+
80
+ it("reserves no height when both toggles are off", () => {
81
+ const tree = renderDescription({ title: false, subtitle: false });
82
+
83
+ expect(wrapperHeight(tree)).toBe(0);
84
+ });
85
+
86
+ it("reserves less height when the title is hidden than when it is shown", () => {
87
+ const withTitle = renderDescription({ title: true, subtitle: true });
88
+ const withoutTitle = renderDescription({ title: false, subtitle: true });
89
+
90
+ expect(wrapperHeight(withoutTitle)).toBeLessThan(wrapperHeight(withTitle));
91
+ });
92
+
93
+ it("reserves less height when the subtitle is hidden than when it is shown", () => {
94
+ const withSubtitle = renderDescription({ title: true, subtitle: true });
95
+ const withoutSubtitle = renderDescription({ title: true, subtitle: false });
96
+
97
+ expect(wrapperHeight(withoutSubtitle)).toBeLessThan(
98
+ wrapperHeight(withSubtitle)
99
+ );
100
+ });
101
+
102
+ it("reads the docked toggle keys when docked", () => {
103
+ const { queryByText } = renderDescription(
104
+ { docked_player_title: true, docked_player_subtitle: false },
105
+ { ...layoutState, docked: true }
106
+ );
107
+
108
+ expect(queryByText("A Title")).toBeTruthy();
109
+ expect(queryByText("A Summary")).toBeNull();
110
+ });
111
+
112
+ /**
113
+ * Live audio resolves its text asynchronously: the channel feed is polled
114
+ * after playback starts, so the first render has no programme title yet. The
115
+ * row must hold its place across that transition instead of collapsing and
116
+ * expanding a second later.
117
+ */
118
+ describe("layout stability while the text is still missing", () => {
119
+ it("reserves the same height with and without text", () => {
120
+ const withText = renderDescription({ title: true, subtitle: true });
121
+
122
+ mockContent = { title: null, summary: null };
123
+
124
+ const withoutText = renderDescription({ title: true, subtitle: true });
125
+
126
+ expect(wrapperHeight(withoutText)).toBe(wrapperHeight(withText));
127
+ });
128
+
129
+ it("still renders both rows when the text has not arrived", () => {
130
+ mockContent = { title: null, summary: null };
131
+
132
+ const tree = renderDescription({ title: true, subtitle: true });
133
+
134
+ expect(wrapperHeight(tree)).toBeGreaterThan(0);
135
+ });
136
+
137
+ it("reserves height for the summary even when only the summary is missing", () => {
138
+ const withSummary = renderDescription({ title: true, subtitle: true });
139
+
140
+ mockContent = { title: "A Title", summary: null };
141
+
142
+ const withoutSummary = renderDescription({ title: true, subtitle: true });
143
+
144
+ expect(wrapperHeight(withoutSummary)).toBe(wrapperHeight(withSummary));
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,130 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react-native";
3
+
4
+ /**
5
+ * Covers AudioPlayerWrapper's remaining Title/Subtitle responsibility, which is
6
+ * only lifecycle: keep the manager's live-content polling running while this
7
+ * player is mounted. It carries neither the text (consumers read it from the
8
+ * player manager via `usePlayerContent`) nor any decision about the polling -
9
+ * whether the content is live, where the channel's programme feed lives and
10
+ * how often to poll are all `OverlaysObserver`'s.
11
+ *
12
+ * The rest of the chain is covered where each link lives:
13
+ * - is-live / feed url / interval OverlayObserver/__tests__/liveContent.test.ts
14
+ * - polling -> `observer.liveEntry` OverlayObserver/__tests__/liveContent.test.ts
15
+ * - liveEntry -> published content playerManager/__tests__/playerContent.test.ts
16
+ * - published content -> rendered text AudioLayer/__tests__/AudioDescription.test.tsx
17
+ */
18
+
19
+ const mockStartLiveContentUpdates = jest.fn();
20
+ const mockStopLiveContentUpdates = jest.fn();
21
+
22
+ const mockPlayer = {
23
+ getOverlayObservable: () => ({
24
+ startLiveContentUpdates: mockStartLiveContentUpdates,
25
+ stopLiveContentUpdates: mockStopLiveContentUpdates,
26
+ }),
27
+ addListener: jest.fn(() => jest.fn()),
28
+ play: jest.fn(),
29
+ pause: jest.fn(),
30
+ rewind: jest.fn(),
31
+ forward: jest.fn(),
32
+ };
33
+
34
+ let mockIsLive = false;
35
+
36
+ jest.mock(
37
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer",
38
+ () => ({ usePlayer: () => mockPlayer })
39
+ );
40
+
41
+ jest.mock(
42
+ "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState",
43
+ () => ({
44
+ usePlayerState: () => ({
45
+ isLive: mockIsLive,
46
+ seekableDuration: 0,
47
+ isReadyToPlay: false,
48
+ isBuffering: false,
49
+ isPaused: false,
50
+ }),
51
+ })
52
+ );
53
+
54
+ jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
55
+ playerManager: {
56
+ close: jest.fn(),
57
+ isCasting: jest.fn(() => false),
58
+ },
59
+ }));
60
+
61
+ jest.mock("@applicaster/zapp-react-native-utils/reactHooks", () => ({
62
+ useNavigation: jest.fn(() => ({ maximiseVideoModal: jest.fn() })),
63
+ }));
64
+
65
+ jest.mock("@applicaster/zapp-react-native-utils/modalState", () => ({
66
+ useModalStoreState: jest.fn(() => ({ visible: false })),
67
+ dismissModal: jest.fn(),
68
+ openBottomSheetModal: jest.fn(),
69
+ }));
70
+
71
+ jest.mock("../Layout", () => ({
72
+ __esModule: true,
73
+ default: () => null,
74
+ }));
75
+
76
+ import { AudioPlayerWrapper } from "../AudioPlayerWrapper";
77
+
78
+ const LIVE_ENTRY = {
79
+ id: "entry-1",
80
+ title: "Channel Title",
81
+ summary: "Channel Summary",
82
+ extensions: {
83
+ live: { channel_src: "https://feed.test/subClass/audio" },
84
+ },
85
+ } as any;
86
+
87
+ const baseProps = {
88
+ configuration: { audio_live_player_update_interval: 30 },
89
+ style: { width: 100, height: 100 } as any,
90
+ entry: LIVE_ENTRY,
91
+ layoutState: { inline: false, docked: false, isModal: false, pip: false },
92
+ playNextData: null,
93
+ children: null,
94
+ };
95
+
96
+ describe("AudioPlayerWrapper live content polling", () => {
97
+ beforeEach(() => {
98
+ jest.clearAllMocks();
99
+ mockIsLive = true;
100
+ });
101
+
102
+ it("asks the observer to keep live content fresh while mounted", () => {
103
+ render(<AudioPlayerWrapper {...baseProps} />);
104
+
105
+ expect(mockStartLiveContentUpdates).toHaveBeenCalled();
106
+ });
107
+
108
+ it("stops polling on unmount", () => {
109
+ const { unmount } = render(<AudioPlayerWrapper {...baseProps} />);
110
+
111
+ unmount();
112
+
113
+ expect(mockStopLiveContentUpdates).toHaveBeenCalled();
114
+ });
115
+
116
+ it("re-evaluates when the player reports the content is live", () => {
117
+ // `isLive` is unknown until the player loads, so the effect has to run
118
+ // again once it settles - the observer decides what to do with it.
119
+ mockIsLive = false;
120
+
121
+ const { rerender } = render(<AudioPlayerWrapper {...baseProps} />);
122
+
123
+ mockStartLiveContentUpdates.mockClear();
124
+ mockIsLive = true;
125
+
126
+ rerender(<AudioPlayerWrapper {...baseProps} />);
127
+
128
+ expect(mockStartLiveContentUpdates).toHaveBeenCalled();
129
+ });
130
+ });