@applicaster/zapp-react-native-ui-components 16.0.0-alpha.8101951023 → 16.0.0-alpha.8114431136

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 (33) hide show
  1. package/Components/MasterCell/SharedUI/CollapsibleTextContainer/__tests__/factory.test.ts +2 -2
  2. package/Components/MasterCell/SharedUI/CollapsibleTextContainer/index.ts +2 -1
  3. package/Components/ModalComponent/AudioPlayer/Components/Input.tsx +139 -39
  4. package/Components/ModalComponent/AudioPlayer/Components/Item.tsx +22 -1
  5. package/Components/ModalComponent/AudioPlayer/Components/NowPlayingHeaderSection.tsx +144 -23
  6. package/Components/ModalComponent/AudioPlayer/Components/__tests__/Item.test.tsx +52 -1
  7. package/Components/ModalComponent/AudioPlayer/Components/__tests__/NowPlayingHeaderSection.test.tsx +36 -0
  8. package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +77 -0
  9. package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +58 -16
  10. package/Components/ModalComponent/BottomSheetModalContent.tsx +19 -74
  11. package/Components/ModalComponent/SortableList.tsx +2 -3
  12. package/Components/ModalComponent/TextInputContent.tsx +27 -4
  13. package/Components/ModalComponent/__tests__/BottomSheetModalContent.test.tsx +74 -1
  14. package/Components/ModalComponent/__tests__/TextInputContent.test.tsx +14 -1
  15. package/Components/ModalComponent/__tests__/showTextInput.test.ts +25 -0
  16. package/Components/ModalComponent/showTextInput.ts +13 -2
  17. package/Components/OfflineHandler/__tests__/index.test.tsx +109 -0
  18. package/Components/OfflineHandler/index.tsx +42 -4
  19. package/Components/PlayerContainer/PlayerContainer.tsx +8 -1
  20. package/Components/River/ComponentsMap/ComponentsMap.tsx +4 -1
  21. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
  22. package/Components/Transitioner/AnimationManager.js +1 -1
  23. package/Components/Transitioner/Scene.tsx +4 -3
  24. package/Components/Transitioner/Transitioner.tsx +71 -33
  25. package/Components/Transitioner/__tests__/transitioner.test.js +372 -3
  26. package/Components/VideoLive/LiveImageManager.ts +8 -2
  27. package/Components/VideoLive/__tests__/LiveImageManager.test.ts +59 -0
  28. package/Decorators/ZappPipesDataConnector/__tests__/screenToolbox.test.ts +347 -0
  29. package/Decorators/ZappPipesDataConnector/__tests__/screenToolboxUnavailable.test.ts +53 -0
  30. package/Decorators/ZappPipesDataConnector/index.tsx +19 -4
  31. package/Decorators/ZappPipesDataConnector/screenToolbox.ts +384 -0
  32. package/assets/empty-Info.png +0 -0
  33. package/package.json +5 -5
@@ -20,13 +20,13 @@ const getValue = (key: string) => {
20
20
  };
21
21
 
22
22
  describe("CollapsibleTextContainer view tree", () => {
23
- it("stretches to the parent width so labels wrap using the cell width", () => {
23
+ it("aligns the label to the text alignment instead of stretching the cell", () => {
24
24
  const result = CollapsibleTextContainer({
25
25
  getValue,
26
26
  elements: [],
27
27
  additionalStyles: { backgroundColor: "transparent" },
28
28
  });
29
29
 
30
- expect(result.style.alignSelf).toBe("stretch");
30
+ expect(result.style.alignSelf).toBe("flex-start");
31
31
  });
32
32
  });
@@ -3,6 +3,7 @@ import { isNilOrEmpty } from "@applicaster/zapp-react-native-utils/reactUtils/he
3
3
  import {
4
4
  getColorFromData,
5
5
  getKeyForLabel,
6
+ mapSelfAlignment,
6
7
  } from "@applicaster/zapp-react-native-utils/cellUtils";
7
8
  import { toNumberWithDefaultZero } from "@applicaster/zapp-react-native-utils/numberUtils";
8
9
 
@@ -22,7 +23,7 @@ export const CollapsibleTextContainer = ({
22
23
  return {
23
24
  type: "CollapsibleTextContainer", // Collapsible Text Container,
24
25
  style: {
25
- alignSelf: "stretch",
26
+ alignSelf: mapSelfAlignment(getValue("text_alignment")),
26
27
  marginTop: toNumberWithDefaultZero(getValue("margin_top")),
27
28
  marginRight: toNumberWithDefaultZero(getValue("margin_right")),
28
29
  marginBottom: toNumberWithDefaultZero(getValue("margin_bottom")),
@@ -18,11 +18,42 @@ import {
18
18
  View,
19
19
  } from "react-native";
20
20
 
21
+ import { resolveDefaultAssetUri } from "@applicaster/zapp-react-native-utils/actionUtils";
22
+
21
23
  const styles = StyleSheet.create({
22
24
  column: {
23
25
  alignItems: "center",
24
26
  width: "100%",
25
27
  },
28
+ validationMessage: {
29
+ flexDirection: "row",
30
+ alignItems: "center",
31
+ alignSelf: "center",
32
+ overflow: "hidden",
33
+ backgroundColor: "rgba(229,30,37,1.00)",
34
+ borderRadius: 100,
35
+ marginTop: 16,
36
+ paddingLeft: 8,
37
+ paddingRight: 12,
38
+ paddingTop: 8,
39
+ paddingBottom: 8,
40
+ gap: 8,
41
+ },
42
+ validationMessageIcon: {
43
+ width: 20,
44
+ height: 20,
45
+ alignSelf: "flex-start",
46
+ },
47
+ validationMessageText: {
48
+ color: "rgba(255,255,255,1.00)",
49
+ fontFamily: Platform.select({
50
+ ios: "GreycliffCF-Bold",
51
+ android: "Greycliff-Bold",
52
+ default: "Greycliff-Bold",
53
+ }),
54
+ fontSize: 13,
55
+ lineHeight: 20,
56
+ },
26
57
  textInput: {
27
58
  padding: 0,
28
59
  margin: 0,
@@ -51,14 +82,22 @@ export type TextTransform =
51
82
 
52
83
  export type TextAlignment = "left" | "center";
53
84
 
85
+ export type UnderlineConfiguration = {
86
+ focusedBorderColor: string;
87
+ borderColor: string;
88
+ /** Bottom underline height; 0 = none (pill search uses borderWidth instead) */
89
+ underlineHeight: number;
90
+ underlineMarginTop: number;
91
+ /** Radius of the underline bar itself (bottom border style). */
92
+ underlineCornerRadius: number;
93
+ };
94
+
54
95
  export type BackgroundConfiguration = {
55
96
  defaultColor: string;
56
97
  focusedColor: string;
57
98
  borderColor: string;
58
99
  focusedBorderColor: string;
59
100
  borderWidth: number;
60
- /** Bottom underline height; 0 = none (pill search uses borderWidth instead) */
61
- underlineHeight: number;
62
101
  cornerRadius: number;
63
102
  paddingTop: number;
64
103
  paddingRight: number;
@@ -95,6 +134,9 @@ export type TextConfiguration = {
95
134
  androidLetterSpacing: number;
96
135
  textTransform: TextTransform;
97
136
  alignment: TextAlignment;
137
+ /** Vertical spacing around the input text; drives the gap above the underline. */
138
+ marginTop: number;
139
+ marginBottom: number;
98
140
  };
99
141
 
100
142
  export type LeadingIconConfiguration = {
@@ -117,6 +159,7 @@ export type IconsConfiguration = {
117
159
  };
118
160
 
119
161
  export type InputConfiguration = {
162
+ underline: UnderlineConfiguration;
120
163
  background: BackgroundConfiguration;
121
164
  label: LabelConfiguration;
122
165
  text: TextConfiguration;
@@ -129,7 +172,6 @@ export const DEFAULT_BACKGROUND_CONFIGURATION: BackgroundConfiguration = {
129
172
  borderColor: "rgba(255, 255, 255, 0.55)",
130
173
  focusedBorderColor: "rgba(255, 255, 255, 0.85)",
131
174
  borderWidth: 1,
132
- underlineHeight: 0,
133
175
  cornerRadius: 24,
134
176
  paddingTop: 10,
135
177
  paddingRight: 10,
@@ -165,6 +207,8 @@ export const DEFAULT_TEXT_CONFIGURATION: TextConfiguration = {
165
207
  androidLetterSpacing: 0,
166
208
  textTransform: "default",
167
209
  alignment: "left",
210
+ marginTop: 0,
211
+ marginBottom: 0,
168
212
  };
169
213
 
170
214
  export const DEFAULT_ICONS_CONFIGURATION: IconsConfiguration = {
@@ -180,8 +224,17 @@ export const DEFAULT_ICONS_CONFIGURATION: IconsConfiguration = {
180
224
  },
181
225
  };
182
226
 
227
+ export const DEFAULT_UNDERLINE_CONFIGURATION: UnderlineConfiguration = {
228
+ borderColor: "rgba(255, 255, 255, 0.55)",
229
+ focusedBorderColor: "rgba(255, 255, 255, 0.85)",
230
+ underlineHeight: 0,
231
+ underlineMarginTop: 0,
232
+ underlineCornerRadius: 0,
233
+ };
234
+
183
235
  export const DEFAULT_INPUT_CONFIGURATION: InputConfiguration = {
184
236
  background: DEFAULT_BACKGROUND_CONFIGURATION,
237
+ underline: DEFAULT_UNDERLINE_CONFIGURATION,
185
238
  label: DEFAULT_LABEL_CONFIGURATION,
186
239
  text: DEFAULT_TEXT_CONFIGURATION,
187
240
  icons: DEFAULT_ICONS_CONFIGURATION,
@@ -203,6 +256,7 @@ export type ItemProps = {
203
256
  value?: string;
204
257
  focused?: boolean;
205
258
  focusedClearButton?: boolean;
259
+ validationMessage?: string;
206
260
  onChangeText?: (text: string) => void;
207
261
  onClearPress?: () => void;
208
262
  onFocus?: () => void;
@@ -243,7 +297,7 @@ function shouldShowLabel(label: LabelConfiguration, labelText?: string) {
243
297
  }
244
298
 
245
299
  function isUnderlineLayout(configuration: InputConfiguration) {
246
- return configuration.background.underlineHeight > 0;
300
+ return configuration.underline.underlineHeight > 0;
247
301
  }
248
302
 
249
303
  // ---------------------------------------------------------------------------
@@ -323,7 +377,7 @@ function InputContainer({
323
377
  }
324
378
 
325
379
  type InputUnderlineProps = {
326
- configuration: BackgroundConfiguration;
380
+ configuration: UnderlineConfiguration;
327
381
  focused: boolean;
328
382
  };
329
383
 
@@ -337,7 +391,9 @@ function InputUnderline({ configuration, focused }: InputUnderlineProps) {
337
391
  style={[
338
392
  styles.underline,
339
393
  {
394
+ marginTop: configuration.underlineMarginTop,
340
395
  height: configuration.underlineHeight,
396
+ borderRadius: configuration.underlineCornerRadius,
341
397
  backgroundColor: focused
342
398
  ? configuration.focusedBorderColor
343
399
  : configuration.borderColor,
@@ -427,6 +483,8 @@ function InputField({
427
483
  }),
428
484
  textTransform: getTextTransformStyle(configuration.textTransform),
429
485
  textAlign: configuration.alignment,
486
+ marginTop: configuration.marginTop,
487
+ marginBottom: configuration.marginBottom,
430
488
  },
431
489
  ]}
432
490
  />
@@ -479,12 +537,13 @@ export function AudioPlayerInput({
479
537
  value = "",
480
538
  focused = false,
481
539
  focusedClearButton = false,
540
+ validationMessage,
482
541
  onChangeText,
483
542
  onClearPress,
484
543
  onFocus,
485
544
  onBlur,
486
545
  }: ItemProps) {
487
- const { background, label, text, icons } = configuration;
546
+ const { background, label, text, icons, underline } = configuration;
488
547
  const inputRef = useRef<TextInputRef>(null);
489
548
  const underlineLayout = isUnderlineLayout(configuration);
490
549
  const showLabel = shouldShowLabel(label, data.label);
@@ -545,21 +604,40 @@ export function AudioPlayerInput({
545
604
  </>
546
605
  ) : null}
547
606
  {underlineLayout ? (
548
- <InputUnderline configuration={background} focused={focused} />
607
+ <InputUnderline configuration={underline} focused={focused} />
549
608
  ) : null}
550
609
  </InputContainer>
551
610
  </Pressable>
552
611
  );
553
612
 
554
- if (!showLabel || !data.label) {
555
- return inputRow;
556
- }
557
-
558
613
  return (
559
614
  <View style={styles.column}>
560
- <InputLabel text={data.label} configuration={label} />
561
- <View style={{ height: background.verticalGutter }} />
615
+ {showLabel && data.label ? (
616
+ <>
617
+ <InputLabel text={data.label} configuration={label} />
618
+ <View style={{ height: background.verticalGutter }} />
619
+ </>
620
+ ) : null}
562
621
  {inputRow}
622
+ {validationMessage ? (
623
+ <View style={styles.validationMessage} accessibilityRole="alert">
624
+ <Image
625
+ source={{
626
+ uri: resolveDefaultAssetUri(
627
+ require("../../../../assets/empty-Info.png")
628
+ ),
629
+ }}
630
+ style={styles.validationMessageIcon}
631
+ />
632
+ <Text
633
+ style={styles.validationMessageText}
634
+ numberOfLines={1}
635
+ ellipsizeMode="tail"
636
+ >
637
+ {validationMessage}
638
+ </Text>
639
+ </View>
640
+ ) : null}
563
641
  </View>
564
642
  );
565
643
  }
@@ -572,55 +650,77 @@ export function AudioPlayerInput({
572
650
  export const VARIANT_SEARCH_INPUT: InputConfiguration =
573
651
  DEFAULT_INPUT_CONFIGURATION;
574
652
 
575
- /** Name playlist — centered title + label + underline (screenshot) */
653
+ /**
654
+ * Name playlist — centered title + 32pt text over a rounded bottom underline.
655
+ *
656
+ * Values mirror the manifest configuration the
657
+ * `quick-brick-component-text-field` plugin renders from; the comment on each
658
+ * line names the reference key it comes from.
659
+ */
576
660
  export const VARIANT_NAME_PLAYLIST_INPUT: InputConfiguration = {
577
661
  background: {
578
- defaultColor: "transparent",
579
- focusedColor: "transparent",
580
- borderColor: "rgba(255, 255, 255, 0.35)",
581
- focusedBorderColor: "rgba(255, 255, 255, 0.55)",
662
+ // text_input_background_*_background_color
663
+ defaultColor: "rgba(30,30,30,0.00)",
664
+ focusedColor: "rgba(46,46,46,0.00)",
665
+ // text_input_style_border_*_border_color
666
+ borderColor: "rgba(255,255,255,0.25)",
667
+ focusedBorderColor: "rgba(255,255,255,1.00)",
668
+ // border_border_style is "bottom", so the box border is off and the
669
+ // underline below draws the border instead.
582
670
  borderWidth: 0,
583
- underlineHeight: 1,
584
671
  cornerRadius: 0,
672
+ // text_input_style_input_padding_*
585
673
  paddingTop: 0,
586
674
  paddingRight: 0,
587
- paddingBottom: 10,
675
+ paddingBottom: 0,
588
676
  paddingLeft: 0,
589
- horizontalGutter: 0,
590
- verticalGutter: 16,
591
- height: 0,
677
+ horizontalGutter: 0, // text_input_style_input_horizontal_gutter
678
+ verticalGutter: 0, // component_style_vertical_gutter
679
+ height: 0, // hug content
680
+ },
681
+ underline: {
682
+ borderColor: "rgba(255,255,255,0.25)",
683
+ focusedBorderColor: "rgba(255,255,255,1.00)",
684
+ underlineHeight: 2, // text_input_style_border_border_size
685
+ // The gap above the bar comes from the text's marginBottom, matching how
686
+ // the plugin applies `text_label_margin_bottom` to the input itself.
687
+ underlineMarginTop: 0,
688
+ underlineCornerRadius: 4, // ..._border_bottom_border_corner_radius
592
689
  },
593
690
  label: {
594
- enabled: true,
595
- fontColor: "rgba(142, 142, 147, 1)",
596
- iosFontFamily: "SFProText-Regular",
597
- androidFontFamily: "Roboto",
598
- fontSize: 15,
599
- lineHeight: 20,
600
- iosLetterSpacing: -0.2,
691
+ enabled: true, // title_enable
692
+ fontColor: "rgba(255,255,255,0.50)", // title_style_color
693
+ iosFontFamily: "GreycliffCF-Bold", // title_style_ios_font_family
694
+ androidFontFamily: "Greycliff-Bold", // title_style_android_font_family
695
+ fontSize: 14, // title_style_font_size
696
+ lineHeight: 32, // title_style_line_height
697
+ iosLetterSpacing: 0,
601
698
  androidLetterSpacing: 0,
602
- textTransform: "default",
699
+ textTransform: "default", // title_style_text_transform: "none"
603
700
  },
604
701
  text: {
605
- fontColor: "#FFFFFF",
606
- focusedFontColor: "#FFFFFF",
607
- placeholderColor: "rgba(142, 142, 147, 1)",
608
- cursorColor: "#FFFFFF",
609
- iosFontFamily: "SFProText-Bold",
610
- androidFontFamily: "Roboto-Bold",
702
+ // text_input_style_text_label_*
703
+ fontColor: "rgba(255,255,255,1.00)", // active_label_color
704
+ focusedFontColor: "rgba(255,255,255,1.00)",
705
+ placeholderColor: "rgba(255,255,255,0.50)", // placeholder_label_color
706
+ cursorColor: "rgba(249,183,72,1.00)", // text_input_style_cursor_cursor_color
707
+ iosFontFamily: "GreycliffCF-Bold",
708
+ androidFontFamily: "Greycliff-Bold",
611
709
  fontSize: 32,
612
710
  lineHeight: 40,
613
- iosLetterSpacing: -0.6,
711
+ iosLetterSpacing: 0,
614
712
  androidLetterSpacing: 0,
615
713
  textTransform: "default",
616
714
  alignment: "center",
715
+ marginTop: 20, // text_label_margin_top
716
+ marginBottom: 20, // text_label_margin_bottom
617
717
  },
618
718
  icons: {
619
719
  width: 20,
620
720
  height: 20,
621
721
  leadingIcon: { enabled: false },
622
722
  trailingIcon: {
623
- enabled: false,
723
+ enabled: false, // clear_button_enable
624
724
  backgroundColor: "rgba(58, 58, 60, 1)",
625
725
  focusedBackgroundColor: "rgba(72, 72, 74, 1)",
626
726
  },
@@ -6,7 +6,7 @@
6
6
  * Green (configurable): background, image, nowPlayingAsset, nowPlayingLabel,
7
7
  * textLabel1, textLabel2
8
8
  * White (hardcoded): container padding/gutters, nowPlaying container layout,
9
- * button sizes/padding/assets
9
+ * button sizes/padding. Play/pause icons use plugin assets when set.
10
10
  *
11
11
  * Variants (VARIANT_*) are preset configuration bundles — not passed as a field.
12
12
  */
@@ -94,6 +94,10 @@ export type ActionButtonAssetsConfiguration = {
94
94
  removeAsset?: string;
95
95
  /** Trailing drag handle on Edit Order / queue rows. */
96
96
  dragAsset?: string;
97
+ /** Shown on the Now Playing row while the track is paused. */
98
+ playAsset?: string;
99
+ /** Shown on the Now Playing row while the track is playing. */
100
+ pauseAsset?: string;
97
101
  };
98
102
 
99
103
  export type ItemConfiguration = {
@@ -504,6 +508,23 @@ function ItemButton({
504
508
  const renderIcon = () => {
505
509
  switch (action) {
506
510
  case "playPause": {
511
+ const playbackAsset = isSelected
512
+ ? actionButtonAssets?.pauseAsset
513
+ : actionButtonAssets?.playAsset;
514
+
515
+ if (playbackAsset) {
516
+ return (
517
+ <Image
518
+ source={{ uri: playbackAsset }}
519
+ style={{
520
+ width: layout.assetWidth,
521
+ height: layout.assetHeight,
522
+ }}
523
+ resizeMode="contain"
524
+ />
525
+ );
526
+ }
527
+
507
528
  const iconSize = layout.assetWidth;
508
529
  const iconRadius = iconSize / 2;
509
530
 
@@ -1,5 +1,5 @@
1
- import React from "react";
2
- import { View } from "react-native";
1
+ import React, { useEffect, useState } from "react";
2
+ import { StyleSheet, Text, View } from "react-native";
3
3
  import {
4
4
  AudioPlayerTrackItem,
5
5
  VARIANT_NOW_PLAYING,
@@ -7,13 +7,57 @@ import {
7
7
  } from "./Item";
8
8
  import { AudioPlayerHeader, VARIANT_SECTION_HEADER } from "./Header";
9
9
  import type { MenuItem } from "@applicaster/zapp-react-native-utils/modalState/types";
10
- import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
11
- import { usePlayerState } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayerState";
10
+ import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
11
+ import { PlayerRole } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
12
+ import type { Player } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/player";
13
+
14
+ const DEFAULT_EMPTY_MESSAGE = "Queued items will be added here";
15
+
16
+ const styles = StyleSheet.create({
17
+ divider: {
18
+ height: StyleSheet.hairlineWidth,
19
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
20
+ marginHorizontal: 20,
21
+ marginVertical: 8,
22
+ },
23
+ emptyMessage: {
24
+ paddingTop: 32,
25
+ paddingBottom: 32,
26
+ paddingHorizontal: 40,
27
+ textAlign: "center",
28
+ color: "#999999",
29
+ fontSize: 13,
30
+ lineHeight: 16,
31
+ },
32
+ });
33
+
34
+ const isPrimaryOrCastingPlayer = (player?: Player | null): boolean => {
35
+ if (!player) {
36
+ return false;
37
+ }
38
+
39
+ return (
40
+ player.playerRole === PlayerRole.Primary ||
41
+ player.playerRole === PlayerRole.Chromecast
42
+ );
43
+ };
44
+
45
+ const getTargetPlayer = (): Player | null => {
46
+ if (playerManager.isCasting()) {
47
+ return playerManager.getCastingReceiver() || null;
48
+ }
49
+
50
+ const active = playerManager.getActivePlayer();
51
+
52
+ return isPrimaryOrCastingPlayer(active) ? active : null;
53
+ };
12
54
 
13
55
  type NowPlayingHeaderSectionProps = {
14
56
  onClear?: () => void;
15
57
  nextUpTitle?: string;
16
58
  nowPlayingTitle?: string;
59
+ isEmpty?: boolean;
60
+ emptyMessage?: string;
17
61
  clearLabel?: string;
18
62
  };
19
63
 
@@ -21,20 +65,104 @@ export function NowPlayingHeaderSection({
21
65
  onClear,
22
66
  nextUpTitle = "Next Up",
23
67
  nowPlayingTitle = "Now Playing",
68
+ isEmpty = false,
69
+ emptyMessage = DEFAULT_EMPTY_MESSAGE,
24
70
  clearLabel,
25
71
  }: NowPlayingHeaderSectionProps) {
26
- const player = usePlayer();
27
- const playerState = usePlayerState("queue-now-playing-section");
72
+ const [activePlayer, setActivePlayer] = useState<Player | null>(
73
+ getTargetPlayer
74
+ );
75
+
76
+ const [currentEntry, setCurrentEntry] = useState<ZappEntry | null>(
77
+ () => getTargetPlayer()?.entry || null
78
+ );
79
+
80
+ const [isPaused, setIsPaused] = useState<boolean>(() =>
81
+ Boolean(getTargetPlayer()?.isPaused())
82
+ );
83
+
84
+ useEffect(() => {
85
+ const handleActivePlayerUpdate = () => {
86
+ const player = getTargetPlayer();
87
+
88
+ if (player) {
89
+ setActivePlayer(player);
90
+
91
+ if (player.entry) {
92
+ setCurrentEntry(player.entry);
93
+ }
94
+
95
+ setIsPaused(Boolean(player.isPaused()));
96
+ }
97
+ };
98
+
99
+ handleActivePlayerUpdate();
100
+
101
+ const removeLifecycle = playerManager.addLifecycleListener({
102
+ onRegistered: (registeredPlayer) => {
103
+ if (isPrimaryOrCastingPlayer(registeredPlayer)) {
104
+ handleActivePlayerUpdate();
105
+ }
106
+ },
107
+ onCastingChanged: handleActivePlayerUpdate,
108
+ });
109
+
110
+ return () => {
111
+ removeLifecycle?.();
112
+ };
113
+ }, []);
114
+
115
+ useEffect(() => {
116
+ if (!activePlayer) {
117
+ return;
118
+ }
119
+
120
+ const syncPlayerState = () => {
121
+ if (activePlayer.entry) {
122
+ setCurrentEntry(activePlayer.entry);
123
+ }
124
+
125
+ setIsPaused(Boolean(activePlayer.isPaused()));
126
+ };
127
+
128
+ syncPlayerState();
129
+
130
+ const removeListener = activePlayer.addListener({
131
+ id: "queue-now-playing-header",
132
+ listener: {
133
+ onPlayerPause: () => setIsPaused(true),
134
+ onPlayerResume: () => setIsPaused(false),
135
+ onVideoLoad: syncPlayerState,
136
+ onLoad: syncPlayerState,
137
+ },
138
+ });
139
+
140
+ return () => {
141
+ removeListener?.();
142
+ };
143
+ }, [activePlayer]);
144
+
145
+ const handlePlayPause = () => {
146
+ if (!activePlayer) {
147
+ return;
148
+ }
149
+
150
+ if (isPaused) {
151
+ activePlayer.play();
152
+ setIsPaused(false);
153
+ } else {
154
+ activePlayer.pause();
155
+ setIsPaused(true);
156
+ }
157
+ };
28
158
 
29
159
  const renderNowPlayingTrack = () => {
30
- if (!player?.entry) {
160
+ if (!currentEntry) {
31
161
  return null;
32
162
  }
33
163
 
34
- const mediaGroup = player.entry.media_group;
35
-
36
- const imageGroup = Array.isArray(mediaGroup)
37
- ? mediaGroup.find((g: any) => g.type === "image")
164
+ const imageGroup = Array.isArray(currentEntry.media_group)
165
+ ? currentEntry.media_group.find((g: any) => g.type === "image")
38
166
  : null;
39
167
 
40
168
  const firstImage = Array.isArray(imageGroup?.media_item)
@@ -42,26 +170,18 @@ export function NowPlayingHeaderSection({
42
170
  : imageGroup?.media_item?.src;
43
171
 
44
172
  const nowPlayingItem: MenuItem = {
45
- id: String(player.entry.id ?? "now_playing"),
46
- title: (player.entry.title as string) || String(player.entry.id),
47
- summary: (player.entry.summary as string) ?? "",
173
+ id: String(currentEntry.id ?? "now_playing"),
174
+ title: (currentEntry.title as string) || String(currentEntry.id),
175
+ summary: (currentEntry.summary as string) ?? "",
48
176
  icon: firstImage,
49
177
  };
50
178
 
51
179
  const data = mapMenuItemToNowPlayingData(
52
180
  nowPlayingItem,
53
- playerState?.isPaused,
181
+ isPaused,
54
182
  nowPlayingTitle
55
183
  );
56
184
 
57
- const handlePlayPause = () => {
58
- if (playerState?.isPaused) {
59
- player?.play();
60
- } else {
61
- player?.pause();
62
- }
63
- };
64
-
65
185
  return (
66
186
  <AudioPlayerTrackItem
67
187
  configuration={VARIANT_NOW_PLAYING}
@@ -79,6 +199,7 @@ export function NowPlayingHeaderSection({
79
199
  data={{ title: nextUpTitle, clearLabel }}
80
200
  onClear={onClear}
81
201
  />
202
+ {isEmpty ? <Text style={styles.emptyMessage}>{emptyMessage}</Text> : null}
82
203
  </View>
83
204
  );
84
205
  }
@@ -1,8 +1,10 @@
1
1
  import React from "react";
2
- import { Pressable } from "react-native";
2
+ import { Image, Pressable } from "react-native";
3
3
  import { fireEvent, render, screen } from "@testing-library/react-native";
4
+ import { ModalBlocksStyleContext } from "../../../ModalBlocksStyleContext";
4
5
  import {
5
6
  AudioPlayerTrackItem,
7
+ VARIANT_NOW_PLAYING,
6
8
  VARIANT_PLAYLIST_ITEM,
7
9
  mapMenuItemToNowPlayingData,
8
10
  } from "../Item";
@@ -101,6 +103,55 @@ describe("mapMenuItemToNowPlayingData", () => {
101
103
  expect(data.textLabel2).toBe("Artist Name");
102
104
  });
103
105
 
106
+ it("uses the plugin play asset while paused and the pause asset while playing", () => {
107
+ const menuItem = {
108
+ id: "item_1",
109
+ title: "Track Title",
110
+ summary: "Artist Name",
111
+ };
112
+
113
+ const style = {
114
+ nowPlayingItem: {
115
+ actionButtons: {
116
+ default: {
117
+ playAsset: "https://example.com/play.png",
118
+ pauseAsset: "https://example.com/pause.png",
119
+ },
120
+ },
121
+ },
122
+ };
123
+
124
+ const { UNSAFE_getAllByType, rerender } = render(
125
+ <ModalBlocksStyleContext.Provider value={style}>
126
+ <AudioPlayerTrackItem
127
+ configuration={VARIANT_NOW_PLAYING}
128
+ data={mapMenuItemToNowPlayingData(menuItem as any, true)}
129
+ />
130
+ </ModalBlocksStyleContext.Provider>
131
+ );
132
+
133
+ expect(
134
+ UNSAFE_getAllByType(Image).some(
135
+ (node) => node.props.source?.uri === "https://example.com/play.png"
136
+ )
137
+ ).toBe(true);
138
+
139
+ rerender(
140
+ <ModalBlocksStyleContext.Provider value={style}>
141
+ <AudioPlayerTrackItem
142
+ configuration={VARIANT_NOW_PLAYING}
143
+ data={mapMenuItemToNowPlayingData(menuItem as any, false)}
144
+ />
145
+ </ModalBlocksStyleContext.Provider>
146
+ );
147
+
148
+ expect(
149
+ UNSAFE_getAllByType(Image).some(
150
+ (node) => node.props.source?.uri === "https://example.com/pause.png"
151
+ )
152
+ ).toBe(true);
153
+ });
154
+
104
155
  it("uses custom nowPlayingLabel when provided", () => {
105
156
  const data = mapMenuItemToNowPlayingData(
106
157
  item as any,