@applicaster/zapp-react-native-ui-components 15.1.21 → 15.1.22

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.
@@ -17,6 +17,9 @@ import {
17
17
  Text,
18
18
  View,
19
19
  } from "react-native";
20
+ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
21
+
22
+ const logger = createLogger({ category: "ModalComponent:AudioPlayerButton" });
20
23
 
21
24
  const styles = StyleSheet.create({
22
25
  container: {
@@ -130,6 +133,28 @@ export const DEFAULT_ICONS_CONFIGURATION: IconsConfiguration = {
130
133
  },
131
134
  };
132
135
 
136
+ // ---------------------------------------------------------------------------
137
+ // Variant configurations
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export const VARIANT_TEXT_ONLY: ButtonConfiguration = {
141
+ background: DEFAULT_BACKGROUND_CONFIGURATION,
142
+ title: DEFAULT_TITLE_CONFIGURATION,
143
+ icons: DEFAULT_ICONS_CONFIGURATION,
144
+ };
145
+
146
+ export const VARIANT_WITH_ICON: ButtonConfiguration = {
147
+ background: DEFAULT_BACKGROUND_CONFIGURATION,
148
+ title: DEFAULT_TITLE_CONFIGURATION,
149
+ icons: {
150
+ ...DEFAULT_ICONS_CONFIGURATION,
151
+ leadingIcon: {
152
+ enabled: true,
153
+ actionType: "addToPlaylist",
154
+ },
155
+ },
156
+ };
157
+
133
158
  // ---------------------------------------------------------------------------
134
159
  // Data (runtime / localization — not styling config)
135
160
  // ---------------------------------------------------------------------------
@@ -140,30 +165,15 @@ export type ButtonData = {
140
165
  };
141
166
 
142
167
  export type AudioPlayerButtonProps = {
143
- configuration: ButtonConfiguration;
144
- data: ButtonData;
168
+ configuration?: ButtonConfiguration;
169
+ data?: ButtonData;
145
170
  disabled?: boolean;
146
171
  focused?: boolean;
147
172
  onPress?: () => void;
148
173
  };
149
174
 
150
- // ---------------------------------------------------------------------------
151
- // Hardcoded spec values (black fields)
152
- // ---------------------------------------------------------------------------
153
-
154
- // Hardcoded: action type → icon asset mapping (base64 PNG)
155
- const ACTION_ICON_ASSETS: Record<ButtonActionType, string> = {
156
- addToQueue:
157
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAKUlEQVR42mNgGAVDCfxHw6MWjFpAvEGk4lELKLdgNJmOWkA7C0YBbQAAwiZvkR9eFgQAAAAASUVORK5CYII=",
158
- addToPlaylist:
159
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAJklEQVR42mNgGAUjFvzHgmluATF4cFswGsmjkTwayaORPAqGLwAA6Jpxj9Ms9QMAAAAASUVORK5CYII=",
160
- goToPlaylist:
161
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAALklEQVR42mNgGAWjYNiD/7Q2/D8tLflPiSX/ycQ0t+D/yLGAEkeMWjAKRgG9AQB4zlSshwSzWwAAAABJRU5ErkJggg==",
162
- confirm:
163
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAJElEQVR42mNgGAWjYBSQCv4TwKMWDHELho/hNE/3o2AUDDcAAPv0QMAyy1CtAAAAAElFTkSuQmCC",
164
- cancel:
165
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAASElEQVR42mNgGAWjGvynkVoUTf+pqA6nxv8UqiHbEooNx2cQ1QzHZiDVDcdlCcOQsoCmQUTTSKZpMqV5RqNLUUHTwm4UDBMAALBLQ73LTvoyAAAAAElFTkSuQmCC",
166
- };
175
+ const PLACEHOLDER_RED_BOX =
176
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAJ0lEQVR4nO3NMQEAAAjDsIF/z2ACvlRAU5NMHuvPOQAAAAAAAMBdC/07Ai5sNw+CAAAAAElFTkSuQmCC";
167
177
 
168
178
  function getTextTransformStyle(
169
179
  textTransform: TextTransform
@@ -276,13 +286,19 @@ type ButtonLeadingIconProps = {
276
286
  configuration: IconsConfiguration;
277
287
  };
278
288
 
279
- function ButtonLeadingIcon({
280
- actionType,
281
- configuration,
282
- }: ButtonLeadingIconProps) {
283
- const asset =
284
- configuration.leadingIcon.asset ||
285
- (actionType ? ACTION_ICON_ASSETS[actionType] : undefined);
289
+ function ButtonLeadingIcon({ configuration }: ButtonLeadingIconProps) {
290
+ const asset = configuration.leadingIcon.asset || PLACEHOLDER_RED_BOX;
291
+
292
+ React.useEffect(() => {
293
+ if (!configuration.leadingIcon.asset) {
294
+ logger.log_warning(
295
+ "AudioPlayerButton has leadingIcon enabled but no asset was provided. Falling back to placeholder red box. Ensure the relevant plugin (e.g. playlist-experience) provides leadingIcon.asset in styleOverrides.",
296
+ {
297
+ leadingIcon: configuration.leadingIcon,
298
+ }
299
+ );
300
+ }
301
+ }, [configuration.leadingIcon.asset]);
286
302
 
287
303
  if (!asset) {
288
304
  return null;
@@ -308,13 +324,14 @@ import { useModalBlocksStyle } from "../../ModalBlocksStyleContext";
308
324
  import { mergeButtonConfiguration } from "../utils/mergeStyles";
309
325
 
310
326
  export function AudioPlayerButton({
311
- configuration,
312
- data,
327
+ configuration = VARIANT_WITH_ICON,
328
+ data = { title: undefined },
313
329
  disabled,
314
330
  focused = false,
315
331
  onPress,
316
332
  }: AudioPlayerButtonProps) {
317
333
  const modalBlocksStyle = useModalBlocksStyle();
334
+
318
335
  const buttonBlock = modalBlocksStyle?.button;
319
336
  const actionBlock = modalBlocksStyle?.action;
320
337
 
@@ -328,9 +345,22 @@ export function AudioPlayerButton({
328
345
  icons: iconsConfiguration,
329
346
  } = effectiveConfig;
330
347
 
331
- const { title } = data;
348
+ const title = data.title;
349
+
332
350
  const isDisabled = disabled ?? data.disabled ?? false;
333
351
 
352
+ React.useEffect(() => {
353
+ if (!title) {
354
+ logger.log_warning(
355
+ "AudioPlayerButton rendering with empty title. Ensure data.title is set.",
356
+ {
357
+ leadingIcon: iconsConfiguration.leadingIcon,
358
+ modalBlocksStyle: modalBlocksStyle,
359
+ }
360
+ );
361
+ }
362
+ }, [title]);
363
+
334
364
  const { leadingIcon } = iconsConfiguration;
335
365
  const showIcon = shouldShowLeadingIcon(leadingIcon);
336
366
 
@@ -361,25 +391,3 @@ export function AudioPlayerButton({
361
391
  </ButtonContainer>
362
392
  );
363
393
  }
364
-
365
- // ---------------------------------------------------------------------------
366
- // Variant configurations
367
- // ---------------------------------------------------------------------------
368
-
369
- export const VARIANT_TEXT_ONLY: ButtonConfiguration = {
370
- background: DEFAULT_BACKGROUND_CONFIGURATION,
371
- title: DEFAULT_TITLE_CONFIGURATION,
372
- icons: DEFAULT_ICONS_CONFIGURATION,
373
- };
374
-
375
- export const VARIANT_WITH_ICON: ButtonConfiguration = {
376
- background: DEFAULT_BACKGROUND_CONFIGURATION,
377
- title: DEFAULT_TITLE_CONFIGURATION,
378
- icons: {
379
- ...DEFAULT_ICONS_CONFIGURATION,
380
- leadingIcon: {
381
- enabled: true,
382
- actionType: "addToPlaylist",
383
- },
384
- },
385
- };
@@ -22,6 +22,7 @@ import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
22
22
  import {
23
23
  ContentViewModel,
24
24
  OPERATIONS,
25
+ type OperationButtons,
25
26
  hasOperation,
26
27
  useBottomSheetContent,
27
28
  } from "@applicaster/zapp-react-native-utils/modalState";
@@ -33,7 +34,6 @@ import { SortableList } from "./SortableList";
33
34
  import {
34
35
  AudioPlayerButton,
35
36
  NowPlayingHeaderSection,
36
- VARIANT_WITH_ICON,
37
37
  } from "./AudioPlayer/Components";
38
38
  import { BottomSheetDragArea } from "./BottomSheetDragArea";
39
39
  import { ModalBlocksStyleContext } from "./ModalBlocksStyleContext";
@@ -43,6 +43,7 @@ import {
43
43
  parseModalBlocksConfiguration,
44
44
  } from "@applicaster/zapp-react-native-utils/configurationUtils/modalBlocksParser";
45
45
  import { useLocalizedStrings } from "@applicaster/zapp-react-native-utils/localizationUtils";
46
+ import { VARIANT_TEXT_ONLY } from "./AudioPlayer/Components/Button";
46
47
 
47
48
  const styles = StyleSheet.create({
48
49
  centered: {
@@ -96,6 +97,8 @@ type ModalComponentProps = {
96
97
  measurementPhase?: boolean;
97
98
  /** Copy under Next Up when the queue has no upcoming items. */
98
99
  emptyMessage?: string;
100
+ /** Producer-owned title/iconURL for sheet-synthesized operation controls. */
101
+ operationButtons?: OperationButtons;
99
102
  };
100
103
 
101
104
  function _BottomSheetModalContent(props: ModalComponentProps) {
@@ -120,6 +123,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
120
123
  themePluginId: themePluginIdProp,
121
124
  measurementPhase = false,
122
125
  emptyMessage,
126
+ operationButtons,
123
127
  } = props;
124
128
 
125
129
  const route = useRef(currentRoute);
@@ -174,13 +178,32 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
174
178
 
175
179
  const normalizedPluginId = themePluginId?.replace(/-/g, "_");
176
180
 
181
+ const addLabel = operationButtons?.add?.title || "Create New";
182
+ const addIcon = operationButtons?.add?.iconURL;
183
+
184
+ const addButtonConfiguration = useMemo(() => {
185
+ const leadingIcon =
186
+ styleOverrides?.button?.leadingIcon ||
187
+ styleOverrides?.action?.leadingIcon;
188
+
189
+ const configuredIcon = leadingIcon?.default?.asset;
190
+
191
+ return {
192
+ ...VARIANT_TEXT_ONLY,
193
+ icons: {
194
+ ...VARIANT_TEXT_ONLY.icons,
195
+ leadingIcon: {
196
+ enabled: !!(addIcon || configuredIcon),
197
+ asset: addIcon,
198
+ },
199
+ },
200
+ };
201
+ }, [addIcon, styleOverrides]);
202
+
177
203
  const localizedHeaderTitle = normalizedPluginId
178
204
  ? pluginLocalizations?.[`${normalizedPluginId}_header_title`]
179
205
  : undefined;
180
206
 
181
- const createPlaylistTitle =
182
- pluginLocalizations?.create_new_playlist_title || "Create New Playlist";
183
-
184
207
  const nextUpTitle =
185
208
  pluginLocalizations?.next_up_title ||
186
209
  (normalizedPluginId
@@ -333,12 +356,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
333
356
  editableCollection?.deleteCollection?.();
334
357
  }, [editableCollection]);
335
358
 
336
- const handleCreatePlaylist = useCallback(() => {
337
- editableCollection?.add?.({
338
- id: "create_new_playlist",
339
- title: createPlaylistTitle,
340
- });
341
- }, [editableCollection, createPlaylistTitle]);
359
+ const handleAdd = useCallback(() => {
360
+ editableCollection?.add?.();
361
+ }, [editableCollection]);
342
362
 
343
363
  const headers = useMemo(() => {
344
364
  const list: Array<{ index: number; component: () => React.ReactElement }> =
@@ -368,9 +388,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
368
388
  style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 12 }}
369
389
  >
370
390
  <AudioPlayerButton
371
- configuration={VARIANT_WITH_ICON}
372
- data={{ title: createPlaylistTitle }}
373
- onPress={handleCreatePlaylist}
391
+ configuration={addButtonConfiguration}
392
+ data={{ title: addLabel }}
393
+ onPress={handleAdd}
374
394
  />
375
395
  </View>
376
396
  ),
@@ -388,8 +408,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
388
408
  emptyMessage,
389
409
  clearLabel,
390
410
  hasAdd,
391
- handleCreatePlaylist,
392
- createPlaylistTitle,
411
+ addLabel,
412
+ addButtonConfiguration,
413
+ handleAdd,
393
414
  ]);
394
415
 
395
416
  const _items = useMemo(() => {
@@ -505,9 +526,9 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
505
526
  {hasAdd ? (
506
527
  <View style={styles.someStyles}>
507
528
  <AudioPlayerButton
508
- configuration={VARIANT_WITH_ICON}
509
- data={{ title: createPlaylistTitle }}
510
- onPress={handleCreatePlaylist}
529
+ configuration={addButtonConfiguration}
530
+ data={{ title: addLabel }}
531
+ onPress={handleAdd}
511
532
  />
512
533
  </View>
513
534
  ) : null}
@@ -1,7 +1,17 @@
1
1
  import React from "react";
2
2
  import { render, screen } from "@testing-library/react-native";
3
3
  import { BottomSheetModalContent } from "../BottomSheetModalContent";
4
- import { ContentViewModel } from "@applicaster/zapp-react-native-utils/modalState";
4
+ import {
5
+ ContentViewModel,
6
+ OPERATIONS,
7
+ } from "@applicaster/zapp-react-native-utils/modalState";
8
+ import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
9
+ import { Image, StyleSheet } from "react-native";
10
+ import {
11
+ AudioPlayerButton,
12
+ VARIANT_TEXT_ONLY,
13
+ } from "../AudioPlayer/Components/Button";
14
+ import { ModalBlocksStyleContext } from "../ModalBlocksStyleContext";
5
15
 
6
16
  jest.mock("react-native-sortables", () => {
7
17
  const { View } = require("react-native");
@@ -45,11 +55,11 @@ jest.mock("@applicaster/zapp-react-native-utils/theme", () => ({
45
55
  }));
46
56
 
47
57
  jest.mock("@applicaster/zapp-react-native-redux/hooks", () => ({
48
- usePlugins: () => [],
58
+ usePlugins: jest.fn(() => []),
49
59
  }));
50
60
 
51
61
  jest.mock("@applicaster/zapp-react-native-utils/localizationUtils", () => ({
52
- useLocalizedStrings: () => ({}),
62
+ useLocalizedStrings: ({ localizations }: any) => localizations || {},
53
63
  }));
54
64
 
55
65
  jest.mock("react-native-safe-area-context", () => ({
@@ -92,17 +102,14 @@ jest.mock("../AudioPlayer/Components", () => ({
92
102
  return null;
93
103
  },
94
104
  AudioPlayerButton: (props: any) => {
95
- const { Text } = require("react-native");
105
+ const { AudioPlayerButton } = jest.requireActual(
106
+ "../AudioPlayer/Components/Button"
107
+ );
96
108
 
97
109
  mockAudioPlayerButtonProps.push(props);
98
110
 
99
- return (
100
- <Text testID="create-playlist-button" onPress={props.onPress}>
101
- {props.data?.title}
102
- </Text>
103
- );
111
+ return <AudioPlayerButton {...props} />;
104
112
  },
105
- VARIANT_WITH_ICON: {},
106
113
  }));
107
114
 
108
115
  const baseProps = {
@@ -116,6 +123,10 @@ const baseProps = {
116
123
  };
117
124
 
118
125
  describe("BottomSheetModalContent role/behavior cells", () => {
126
+ beforeEach(() => {
127
+ jest.mocked(usePlugins).mockReturnValue([]);
128
+ });
129
+
119
130
  it("renders standard Button rows and default header when no viewModel/role", () => {
120
131
  render(<BottomSheetModalContent {...baseProps} />);
121
132
 
@@ -180,6 +191,8 @@ describe("BottomSheetModalContent role/behavior cells", () => {
180
191
  });
181
192
 
182
193
  it("renders create playlist button when operations include add and calls editableCollection.add on press", () => {
194
+ mockAudioPlayerButtonProps.length = 0;
195
+
183
196
  const addMock = jest.fn();
184
197
  const { BehaviorSubject } = require("rxjs");
185
198
 
@@ -198,16 +211,234 @@ describe("BottomSheetModalContent role/behavior cells", () => {
198
211
 
199
212
  render(<BottomSheetModalContent {...baseProps} viewModel={viewModel} />);
200
213
 
201
- const createBtn = screen.getByTestId("create-playlist-button");
214
+ const createBtn = screen.getByText("Create New");
202
215
  expect(createBtn).toBeTruthy();
203
216
 
217
+ expect(mockAudioPlayerButtonProps.length).toBeGreaterThan(0);
218
+
219
+ for (const props of mockAudioPlayerButtonProps) {
220
+ expect(props.configuration.icons.leadingIcon.enabled).toBe(false);
221
+ expect(props.data).toEqual({ title: "Create New" });
222
+ expect(typeof props.onPress).toBe("function");
223
+ }
224
+
204
225
  const { fireEvent } = require("@testing-library/react-native");
205
226
  fireEvent.press(createBtn);
206
227
 
207
- expect(addMock).toHaveBeenCalledWith({
208
- id: "create_new_playlist",
209
- title: "Create New Playlist",
228
+ expect(addMock).toHaveBeenCalledWith();
229
+ });
230
+
231
+ it.each([["add"], ["add", "reorder"]])(
232
+ "renders operationButtons.add.title with plugin typography and icon for %j",
233
+ (...operations: string[]) => {
234
+ const { BehaviorSubject } = require("rxjs");
235
+
236
+ const configuration = {
237
+ "button.title.font_color": "#123456",
238
+ "button.title.font_size": 19,
239
+ "button.title.line_height": 27,
240
+ "button.background.background_color": "#ff9900",
241
+ "button.leading_icon.asset": "https://example.com/create.png",
242
+ localizations: {
243
+ playlist_experience_button_create_new: "Plugin key label",
244
+ },
245
+ };
246
+
247
+ jest
248
+ .mocked(usePlugins)
249
+ .mockReturnValue([
250
+ { identifier: "playlist-experience", configuration },
251
+ ]);
252
+
253
+ const viewModel = new ContentViewModel({
254
+ title: "Playlists",
255
+ items: [],
256
+ role: "collection_selector",
257
+ themePluginId: "playlist-experience",
258
+ dynamicCollection: { operations },
259
+ });
260
+
261
+ viewModel.editableCollection = {
262
+ items$: new BehaviorSubject([]),
263
+ operations,
264
+ add: jest.fn(),
265
+ move: jest.fn(),
266
+ };
267
+
268
+ const styleOverrides = {
269
+ button: {
270
+ title: { default: "Wrong decorator label" },
271
+ leadingIcon: { default: { asset: "https://example.com/wrong.png" } },
272
+ },
273
+ };
274
+
275
+ render(
276
+ <BottomSheetModalContent
277
+ {...baseProps}
278
+ viewModel={viewModel}
279
+ styleOverrides={styleOverrides}
280
+ operationButtons={{ add: { title: "Neue Playlist" } }}
281
+ />
282
+ );
283
+
284
+ expect(
285
+ StyleSheet.flatten(screen.getByText("Neue Playlist").props.style)
286
+ ).toMatchObject({ color: "#123456", fontSize: 19, lineHeight: 27 });
287
+
288
+ expect(screen.UNSAFE_getByType(Image).props.source).toEqual({
289
+ uri: "https://example.com/create.png",
290
+ });
291
+
292
+ expect(screen.queryByText("Wrong decorator label")).toBeNull();
293
+ expect(screen.queryByText("Plugin key label")).toBeNull();
294
+ expect(styleOverrides.button.title.default).toBe("Wrong decorator label");
295
+ expect(configuration["button.title.font_size"]).toBe(19);
296
+ }
297
+ );
298
+
299
+ it("does not read the playlist-specific Add icon key", () => {
300
+ const { BehaviorSubject } = require("rxjs");
301
+
302
+ jest.mocked(usePlugins).mockReturnValue([
303
+ {
304
+ identifier: "playlist-experience",
305
+ configuration: {
306
+ "button.create_new_playlist_asset": "https://example.com/legacy.png",
307
+ },
308
+ },
309
+ ]);
310
+
311
+ const viewModel = new ContentViewModel({
312
+ title: "Playlists",
313
+ items: [],
314
+ role: "collection_selector",
315
+ themePluginId: "playlist-experience",
316
+ dynamicCollection: { operations: ["add"] },
210
317
  });
318
+
319
+ viewModel.editableCollection = {
320
+ items$: new BehaviorSubject([]),
321
+ operations: ["add"],
322
+ add: jest.fn(),
323
+ };
324
+
325
+ render(<BottomSheetModalContent {...baseProps} viewModel={viewModel} />);
326
+
327
+ expect(screen.UNSAFE_queryAllByType(Image)).toHaveLength(0);
328
+ });
329
+
330
+ it.each(
331
+ [false, true].flatMap((sortable) =>
332
+ [
333
+ { iconURL: undefined, themeIcon: undefined, expected: undefined },
334
+ {
335
+ iconURL: "file:///bundled-add.png",
336
+ themeIcon: undefined,
337
+ expected: "file:///bundled-add.png",
338
+ },
339
+ {
340
+ iconURL: "https://example.com/upload.png",
341
+ themeIcon: undefined,
342
+ expected: "https://example.com/upload.png",
343
+ },
344
+ {
345
+ iconURL: undefined,
346
+ themeIcon: "https://example.com/theme.png",
347
+ expected: "https://example.com/theme.png",
348
+ },
349
+ {
350
+ iconURL: "file:///bundled-add.png",
351
+ themeIcon: "https://example.com/theme.png",
352
+ expected: "https://example.com/theme.png",
353
+ },
354
+ {
355
+ iconURL: "file:///bundled-add.png",
356
+ themeIcon: "https://example.com/theme.png",
357
+ themeEnabled: false,
358
+ expected: undefined,
359
+ },
360
+ ].map((scenario) => ({ ...scenario, sortable }))
361
+ )
362
+ )(
363
+ "resolves Add icon presentation for %j",
364
+ ({ sortable, iconURL, themeIcon, themeEnabled, expected }) => {
365
+ const { BehaviorSubject } = require("rxjs");
366
+
367
+ const operations = sortable
368
+ ? [OPERATIONS.ADD, OPERATIONS.REORDER]
369
+ : [OPERATIONS.ADD];
370
+
371
+ jest.mocked(usePlugins).mockReturnValue([
372
+ {
373
+ identifier: "generic-theme",
374
+ configuration: themeIcon
375
+ ? {
376
+ "button.leading_icon.asset": themeIcon,
377
+ "button.leading_icon.enabled": themeEnabled,
378
+ }
379
+ : {},
380
+ },
381
+ ]);
382
+
383
+ const viewModel = new ContentViewModel({
384
+ title: "Collections",
385
+ items: [],
386
+ themePluginId: "generic-theme",
387
+ dynamicCollection: { operations },
388
+ });
389
+
390
+ viewModel.editableCollection = {
391
+ items$: new BehaviorSubject([]),
392
+ operations,
393
+ add: jest.fn(),
394
+ move: jest.fn(),
395
+ };
396
+
397
+ render(
398
+ <BottomSheetModalContent
399
+ {...baseProps}
400
+ viewModel={viewModel}
401
+ operationButtons={{ add: { iconURL } }}
402
+ />
403
+ );
404
+
405
+ if (expected) {
406
+ expect(screen.UNSAFE_getByType(Image).props.source).toEqual({
407
+ uri: expected,
408
+ });
409
+ } else {
410
+ expect(screen.UNSAFE_queryAllByType(Image)).toHaveLength(0);
411
+ }
412
+ }
413
+ );
414
+
415
+ it("does not render a typography object as a button label", () => {
416
+ render(
417
+ <ModalBlocksStyleContext.Provider
418
+ value={{ button: { title: { default: { fontSize: 19 } } } }}
419
+ >
420
+ <AudioPlayerButton configuration={VARIANT_TEXT_ONLY} />
421
+ </ModalBlocksStyleContext.Provider>
422
+ );
423
+
424
+ expect(screen.toJSON()).toBeTruthy();
425
+ });
426
+
427
+ it("renders explicit button data separately from typography", () => {
428
+ render(
429
+ <ModalBlocksStyleContext.Provider
430
+ value={{ button: { title: { default: { fontSize: 19 } } } }}
431
+ >
432
+ <AudioPlayerButton
433
+ configuration={VARIANT_TEXT_ONLY}
434
+ data={{ title: "Create Playlist" }}
435
+ />
436
+ </ModalBlocksStyleContext.Provider>
437
+ );
438
+
439
+ expect(
440
+ StyleSheet.flatten(screen.getByText("Create Playlist").props.style)
441
+ ).toMatchObject({ fontSize: 19 });
211
442
  });
212
443
 
213
444
  it("shows the empty Next Up message only when now playing is on and the list is empty", () => {
@@ -272,7 +272,10 @@ const PlayerContainerComponent = (props: Props) => {
272
272
  const playEntry = (entry: ZappEntry) => navigator.replaceTop(entry, { mode });
273
273
 
274
274
  const onPlayNextPerformNextVideoPlay = React.useCallback(() => {
275
- if (!playNextOverlayState.entry) {
275
+ // The overlay's entry, or the player's when the overlay is not showing yet (X-Ray player.next)
276
+ const nextEntry = playNextOverlayState?.entry ?? player?.getPlayNextEntry();
277
+
278
+ if (!nextEntry) {
276
279
  log_warning(
277
280
  "onPlayNextPerformNextVideoPlay: playNextOverlayState.entry is not defined"
278
281
  );
@@ -281,16 +284,30 @@ const PlayerContainerComponent = (props: Props) => {
281
284
  }
282
285
 
283
286
  log_debug(
284
- `onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${playNextOverlayState.entry.title}, id: ${playNextOverlayState.entry.id}, outgoing item id: ${item?.id}`
287
+ `onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${nextEntry.title}, id: ${nextEntry.id}, outgoing item id: ${item?.id}`
285
288
  );
286
289
 
287
- playNextOverlayState.hidePlayNext();
290
+ (
291
+ playNextOverlayState?.hidePlayNext ??
292
+ player?.getOverlayObservable()?.hidePlayNext
293
+ )?.();
288
294
 
289
295
  playerEvent(PLAYER_EVENTS.PlayNextTriggered, { id: item?.id });
290
296
 
291
297
  setIsLoadingNextVideo(true);
292
- playEntry(playNextOverlayState.entry);
293
- }, [playNextOverlayState, item?.id]);
298
+ playEntry(nextEntry);
299
+ }, [playNextOverlayState, item?.id, player]);
300
+
301
+ // Lets the X-Ray player.next tool run the same Play Next path as the overlay.
302
+ useEffect(() => {
303
+ if (!player) return;
304
+
305
+ player.playNextHandler = onPlayNextPerformNextVideoPlay;
306
+
307
+ return () => {
308
+ player.playNextHandler = null;
309
+ };
310
+ }, [player, onPlayNextPerformNextVideoPlay]);
294
311
 
295
312
  const playNextData: Nullable<PlayNextData> = React.useMemo(() => {
296
313
  if (!playNextOverlayState) {
@@ -127,6 +127,8 @@ jest.mock(
127
127
  seekTo: jest.fn(),
128
128
  getPluginConfiguration: jest.fn(() => ({})),
129
129
  getOverlayObservable: jest.fn(() => ({ getPlayNextEntry: () => null })),
130
+ getPlayNextEntry: jest.fn(() => null),
131
+ playNextHandler: null,
130
132
  };
131
133
 
132
134
  return {
@@ -337,3 +339,52 @@ describe("PlayerContainer hardware back handling", () => {
337
339
  expect(mockNavigator.closeVideoModal).not.toHaveBeenCalled();
338
340
  });
339
341
  });
342
+
343
+ describe("PlayerContainer Play Next action for X-Ray player.next", () => {
344
+ beforeEach(() => {
345
+ jest.clearAllMocks();
346
+ mockIsTV.mockReturnValue(false);
347
+ mockPlayer.getPluginConfiguration.mockReturnValue({});
348
+ mockPlayerManager.getPluginConfiguration.mockReturnValue({});
349
+ mockPlayerManager.on.mockReturnThis();
350
+ mockPlayerManager.removeHandler.mockReturnThis();
351
+ });
352
+
353
+ it("sets its Play Next action on the player and clears it on unmount", () => {
354
+ const { unmount } = renderPlayerContainer();
355
+
356
+ expect(mockPlayer.playNextHandler).toEqual(expect.any(Function));
357
+
358
+ unmount();
359
+
360
+ expect(mockPlayer.playNextHandler).toBeNull();
361
+ });
362
+
363
+ it("plays the player's next entry once, with analytics and the overlay hidden, when the overlay is not showing", () => {
364
+ const next = { id: "entry-2", title: "Next" };
365
+ const hidePlayNext = jest.fn();
366
+ mockPlayer.getPlayNextEntry.mockReturnValue(next);
367
+
368
+ mockPlayer.getOverlayObservable.mockReturnValue({
369
+ getPlayNextEntry: () => next,
370
+ hidePlayNext,
371
+ });
372
+
373
+ renderPlayerContainer();
374
+
375
+ mockPlayer.playNextHandler();
376
+
377
+ expect(hidePlayNext).toHaveBeenCalledTimes(1);
378
+
379
+ expect(mockPlayerManager.invokeHandler).toHaveBeenCalledWith(
380
+ "play_next_triggered",
381
+ { id: "entry-1" }
382
+ );
383
+
384
+ expect(mockNavigator.replaceTop).toHaveBeenCalledTimes(1);
385
+
386
+ expect(mockNavigator.replaceTop).toHaveBeenCalledWith(next, {
387
+ mode: VideoModalMode.MAXIMIZED,
388
+ });
389
+ });
390
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "15.1.21",
3
+ "version": "15.1.22",
4
4
  "description": "Applicaster Zapp React Native ui components for the Quick Brick App",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -28,10 +28,10 @@
28
28
  },
29
29
  "homepage": "https://github.com/applicaster/quickbrick#readme",
30
30
  "dependencies": {
31
- "@applicaster/applicaster-types": "15.1.21",
32
- "@applicaster/zapp-react-native-bridge": "15.1.21",
33
- "@applicaster/zapp-react-native-redux": "15.1.21",
34
- "@applicaster/zapp-react-native-utils": "15.1.21",
31
+ "@applicaster/applicaster-types": "15.1.22",
32
+ "@applicaster/zapp-react-native-bridge": "15.1.22",
33
+ "@applicaster/zapp-react-native-redux": "15.1.22",
34
+ "@applicaster/zapp-react-native-utils": "15.1.22",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "react-native-sortables": "1.7.1",