@applicaster/zapp-react-native-utils 15.1.6 → 15.1.7

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.
@@ -0,0 +1,7 @@
1
+ import { Image } from "react-native";
2
+ import { isTV } from "../reactUtils";
3
+
4
+ export const resolveDefaultAssetUri = (source: string | number): string =>
5
+ isTV()
6
+ ? (source as string)
7
+ : Image.resolveAssetSource(source as number)?.uri || "";
@@ -11,7 +11,7 @@ describe("showToastAction", () => {
11
11
  jest.clearAllMocks();
12
12
  });
13
13
 
14
- it("calls postEvent with the provided options and returns Success", async () => {
14
+ it("calls postEvent with confirmation source, default timeout, and returns Success", async () => {
15
15
  const action = {
16
16
  type: "showToast",
17
17
  options: {
@@ -31,7 +31,8 @@ describe("showToastAction", () => {
31
31
  message: "Added to queue",
32
32
  extraMessage: "Extra info",
33
33
  style: undefined,
34
- timeout: undefined,
34
+ timeout: 1000,
35
+ source: "confirmation",
35
36
  },
36
37
  ]);
37
38
 
@@ -57,6 +58,7 @@ describe("showToastAction", () => {
57
58
  extraMessage: undefined,
58
59
  style: { backgroundColor: "#00FF00" },
59
60
  timeout: 3000,
61
+ source: "confirmation",
60
62
  },
61
63
  ]);
62
64
  });
@@ -76,7 +78,8 @@ describe("showToastAction", () => {
76
78
  message: undefined,
77
79
  extraMessage: undefined,
78
80
  style: undefined,
79
- timeout: undefined,
81
+ timeout: 1000,
82
+ source: "confirmation",
80
83
  },
81
84
  ]);
82
85
 
@@ -3,8 +3,61 @@ import { ActionResult } from "../ActionExecutor";
3
3
  import { ActionHandler } from "../types";
4
4
  import { postEvent } from "../../reactHooks/useSubscriberFor";
5
5
 
6
+ export const CONFIRMATION_TOAST_SOURCE = "confirmation";
7
+
8
+ export const CONFIRMATION_TOAST_DEFAULT_TIMEOUT = 1000;
9
+
6
10
  /**
7
- * Options for showToast action.
11
+ * Shows a mobile confirmation toast with Theme defaults from
12
+ * `Confirmation Toast / Message` in the base theme plugin.
13
+ *
14
+ * This action is confirmation-only by design: it always stamps
15
+ * `source: "confirmation"` (so Theme confirmation styles apply) and defaults
16
+ * `timeout` to 1000 ms. Non-confirmation toasts (e.g. Offline Experience)
17
+ * must post on the shared `"showToast"` event bus directly and omit `source`.
18
+ * Callers may override individual style fields; Theme supplies the rest.
19
+ *
20
+ * @example From React via ActionExecutorContext
21
+ * ```tsx
22
+ * import React, { useContext } from "react";
23
+ * import { ActionExecutorContext } from "@applicaster/zapp-react-native-utils/actionsExecutor/ActionExecutorContext";
24
+ *
25
+ * function QueueButton() {
26
+ * const { handleAction } = useContext(ActionExecutorContext);
27
+ *
28
+ * const onPress = () => {
29
+ * void handleAction({
30
+ * type: "showToast",
31
+ * options: { message: "Added to queue" },
32
+ * });
33
+ * };
34
+ *
35
+ * return <Button onPress={onPress} title="Add to queue" />;
36
+ * }
37
+ * ```
38
+ *
39
+ * @example From code via the actionExecutor singleton
40
+ * ```ts
41
+ * import { actionExecutor } from "@applicaster/zapp-react-native-utils/actionsExecutor/ActionExecutor";
42
+ *
43
+ * await actionExecutor.handleAction({
44
+ * type: "showToast",
45
+ * options: {
46
+ * message: "Playlist created",
47
+ * style: { backgroundColor: "#00AA55" }, // optional partial override
48
+ * },
49
+ * });
50
+ * ```
51
+ *
52
+ * @example From entry / pipes actions JSON
53
+ * ```json
54
+ * {
55
+ * "type": "showToast",
56
+ * "options": {
57
+ * "message": "Item removed from playlist"
58
+ * }
59
+ * }
60
+ * ```
8
61
  */
9
62
  export interface ShowToastActionOptions {
10
63
  id?: string;
@@ -25,7 +78,8 @@ export const showToastAction: ActionHandler<ShowToastActionOptions> = async (
25
78
  message: options?.message,
26
79
  extraMessage: options?.extraMessage,
27
80
  style: options?.style,
28
- timeout: options?.timeout,
81
+ timeout: options?.timeout ?? CONFIRMATION_TOAST_DEFAULT_TIMEOUT,
82
+ source: CONFIRMATION_TOAST_SOURCE,
29
83
  },
30
84
  ]);
31
85
 
@@ -0,0 +1,41 @@
1
+ import { retrieveFeedUrl, retrievePlayPreviousFeedUrl } from "../utils";
2
+
3
+ const entryWith = (extensions: Record<string, unknown>): ZappEntry =>
4
+ ({ id: "1", type: { value: "video" }, extensions }) as ZappEntry;
5
+
6
+ describe("playlist feed url accessors", () => {
7
+ it("returns the next feed url when present", () => {
8
+ const entry = entryWith({ play_next_feed_url: "https://feeds/next.json" });
9
+
10
+ expect(retrieveFeedUrl(entry)).toBe("https://feeds/next.json");
11
+ });
12
+
13
+ it("returns the previous feed url when present", () => {
14
+ const entry = entryWith({
15
+ play_previous_feed_url: "https://feeds/prev.json",
16
+ });
17
+
18
+ expect(retrievePlayPreviousFeedUrl(entry)).toBe("https://feeds/prev.json");
19
+ });
20
+
21
+ it("returns undefined when only the next url is present", () => {
22
+ const entry = entryWith({ play_next_feed_url: "https://feeds/next.json" });
23
+
24
+ expect(retrievePlayPreviousFeedUrl(entry)).toBeUndefined();
25
+ });
26
+
27
+ it("returns undefined for an entry with no extensions", () => {
28
+ expect(
29
+ retrievePlayPreviousFeedUrl({
30
+ id: "1",
31
+ type: { value: "video" },
32
+ } as ZappEntry)
33
+ ).toBeUndefined();
34
+ });
35
+
36
+ it("returns undefined for a missing entry", () => {
37
+ expect(
38
+ retrievePlayPreviousFeedUrl(undefined as unknown as ZappEntry)
39
+ ).toBeUndefined();
40
+ });
41
+ });
@@ -43,6 +43,9 @@ export const parseTimeToSeconds = (timeStr: string): number => {
43
43
  export const retrieveFeedUrl = (entry: ZappEntry) =>
44
44
  entry?.extensions?.play_next_feed_url;
45
45
 
46
+ export const retrievePlayPreviousFeedUrl = (entry: ZappEntry) =>
47
+ entry?.extensions?.play_previous_feed_url;
48
+
46
49
  export const retrieveOverlayDuration = (plugin) =>
47
50
  toNumberWithDefault(plugin?.configuration?.overlay_duration, 15);
48
51
 
@@ -0,0 +1,158 @@
1
+ const { player } = require("../defaultManifestConfigurations/player");
2
+
3
+ const manifestFor = (platform) => player({ platform, version: "0.0.0" });
4
+
5
+ const findGroup = (manifest, label) =>
6
+ manifest.styles.fields.find((group) => group.group && group.label === label);
7
+
8
+ const findFieldInGroup = (manifest, label, key) =>
9
+ findGroup(manifest, label)?.fields?.find((field) => field.key === key);
10
+
11
+ const findLocalization = (manifest, key) =>
12
+ manifest.localizations.fields.find((field) => field.key === key);
13
+
14
+ describe("player manifest — audio playlist controls", () => {
15
+ const manifest = manifestFor("ios_for_quickbrick");
16
+
17
+ describe("Player Controls Layout", () => {
18
+ const field = () =>
19
+ findFieldInGroup(manifest, "Audio Player", "player_controls_layout");
20
+
21
+ it("is declared in the Audio Player group", () => {
22
+ expect(field()).toBeDefined();
23
+ });
24
+
25
+ it("is a select defaulting to the existing controls", () => {
26
+ expect(field().type).toBe("select");
27
+ expect(field().initial_value).toBe("playback_speed_and_sleep_timer");
28
+ });
29
+
30
+ it("only shows when the audio player layout is in use", () => {
31
+ expect(field()).toMatchObject({
32
+ rules: "conditional",
33
+ conditional_fields: [
34
+ { key: "styles/full_screen_audio_player", condition_value: true },
35
+ ],
36
+ });
37
+ });
38
+
39
+ it("offers exactly the two documented options", () => {
40
+ expect(field().options).toEqual([
41
+ {
42
+ text: "Playback Speed & Sleep Timer (Default)",
43
+ value: "playback_speed_and_sleep_timer",
44
+ },
45
+ {
46
+ text: "Playlist Controls (Previous & Next)",
47
+ value: "playlist_controls",
48
+ },
49
+ ]);
50
+ });
51
+ });
52
+
53
+ describe("control icons", () => {
54
+ it.each(["previous_track", "next_track"])(
55
+ "declares %s as an uploader",
56
+ (key) => {
57
+ expect(findFieldInGroup(manifest, "Assets", key)).toMatchObject({
58
+ key,
59
+ type: "uploader",
60
+ });
61
+ }
62
+ );
63
+
64
+ it.each(["previous_track", "next_track"])(
65
+ "only shows %s when the playlist controls layout is selected",
66
+ (key) => {
67
+ expect(findFieldInGroup(manifest, "Assets", key)).toMatchObject({
68
+ rules: "conditional",
69
+ conditional_fields: [
70
+ {
71
+ key: "styles/player_controls_layout",
72
+ condition_value: "playlist_controls",
73
+ },
74
+ ],
75
+ });
76
+ }
77
+ );
78
+
79
+ it.each([
80
+ "speed_0_8",
81
+ "speed_1",
82
+ "speed_1_2",
83
+ "speed_1_5",
84
+ "speed_2",
85
+ "sleep_timer",
86
+ "sleep_timer_active",
87
+ ])(
88
+ "only shows %s when the playback speed and sleep timer layout is selected",
89
+ (key) => {
90
+ expect(findFieldInGroup(manifest, "Assets", key)).toMatchObject({
91
+ rules: "conditional",
92
+ conditional_fields: [
93
+ {
94
+ key: "styles/player_controls_layout",
95
+ condition_value: "playback_speed_and_sleep_timer",
96
+ },
97
+ ],
98
+ });
99
+ }
100
+ );
101
+ });
102
+
103
+ describe("accessibility strings", () => {
104
+ it.each([
105
+ "accessibility_previous_track_label",
106
+ "accessibility_previous_track_hint",
107
+ "accessibility_next_track_label",
108
+ "accessibility_next_track_hint",
109
+ ])("declares %s with a default", (key) => {
110
+ expect(findLocalization(manifest, key)).toMatchObject({
111
+ type: "text_input",
112
+ });
113
+
114
+ expect(findLocalization(manifest, key).initial_value).toBeTruthy();
115
+ });
116
+ });
117
+
118
+ describe("tv platforms", () => {
119
+ // The audio player layout is mobile only, so nothing this feature adds —
120
+ // including its screen reader strings — belongs in a tv manifest.
121
+ it.each(["tvos_for_quickbrick", "android_tv_for_quickbrick", "lg_tv"])(
122
+ "adds no audio controls layout field on %s",
123
+ (platform) => {
124
+ expect(
125
+ findFieldInGroup(
126
+ manifestFor(platform),
127
+ "Audio Player",
128
+ "player_controls_layout"
129
+ )
130
+ ).toBeUndefined();
131
+ }
132
+ );
133
+
134
+ it.each([
135
+ "accessibility_previous_track_label",
136
+ "accessibility_previous_track_hint",
137
+ "accessibility_next_track_label",
138
+ "accessibility_next_track_hint",
139
+ ])("does not declare %s on tv", (key) => {
140
+ expect(
141
+ findLocalization(manifestFor("tvos_for_quickbrick"), key)
142
+ ).toBeUndefined();
143
+ });
144
+
145
+ it("still declares the tv-only localizations", () => {
146
+ expect(
147
+ findLocalization(
148
+ manifestFor("tvos_for_quickbrick"),
149
+ "back_to_live_label"
150
+ )
151
+ ).toBeDefined();
152
+
153
+ expect(
154
+ findLocalization(manifestFor("tvos_for_quickbrick"), "start_over_label")
155
+ ).toBeDefined();
156
+ });
157
+ });
158
+ });
@@ -369,6 +369,58 @@ function getPlayerConfiguration({ platform, version }) {
369
369
  ],
370
370
  };
371
371
 
372
+ if (isTV(platform)) {
373
+ localizations.fields.push({
374
+ key: "back_to_live_label",
375
+ label: "Back to live label",
376
+ initial_value: "Back To Live",
377
+ type: "text_input",
378
+ });
379
+
380
+ localizations.fields.push({
381
+ key: "start_over_label",
382
+ label: "Start over label",
383
+ initial_value: "Start Over",
384
+ type: "text_input",
385
+ });
386
+ }
387
+
388
+ if (isMobile(platform)) {
389
+ // Screen reader strings for the audio player's playlist controls. Mobile
390
+ // only, because the audio player layout itself is mobile only.
391
+ localizations.fields.push({
392
+ key: "accessibility_previous_track_label",
393
+ label: "Accessibility previous track label",
394
+ initial_value: "Previous track button",
395
+ label_tooltip: "Label for previous track button accessibility",
396
+ type: "text_input",
397
+ });
398
+
399
+ localizations.fields.push({
400
+ key: "accessibility_previous_track_hint",
401
+ label: "Accessibility previous track hint",
402
+ initial_value: "Press to play the previous track",
403
+ label_tooltip: "Hint for previous track button accessibility",
404
+ type: "text_input",
405
+ });
406
+
407
+ localizations.fields.push({
408
+ key: "accessibility_next_track_label",
409
+ label: "Accessibility next track label",
410
+ initial_value: "Next track button",
411
+ label_tooltip: "Label for next track button accessibility",
412
+ type: "text_input",
413
+ });
414
+
415
+ localizations.fields.push({
416
+ key: "accessibility_next_track_hint",
417
+ label: "Accessibility next track hint",
418
+ initial_value: "Press to play the next track",
419
+ label_tooltip: "Hint for next track button accessibility",
420
+ type: "text_input",
421
+ });
422
+ }
423
+
372
424
  if (isTV(platform)) {
373
425
  styles.fields.push(
374
426
  fieldsGroup("Always Show Scrub Bar & Timestamp", "", [
@@ -1163,6 +1215,13 @@ function getPlayerConfiguration({ platform, version }) {
1163
1215
  type: "uploader",
1164
1216
  label_tooltip: "Playback Speed 0.8x",
1165
1217
  initial_value: null,
1218
+ rules: "conditional",
1219
+ conditional_fields: [
1220
+ {
1221
+ key: "styles/player_controls_layout",
1222
+ condition_value: "playback_speed_and_sleep_timer",
1223
+ },
1224
+ ],
1166
1225
  },
1167
1226
  {
1168
1227
  key: "speed_1",
@@ -1170,6 +1229,13 @@ function getPlayerConfiguration({ platform, version }) {
1170
1229
  type: "uploader",
1171
1230
  label_tooltip: "Playback Speed 1x",
1172
1231
  initial_value: null,
1232
+ rules: "conditional",
1233
+ conditional_fields: [
1234
+ {
1235
+ key: "styles/player_controls_layout",
1236
+ condition_value: "playback_speed_and_sleep_timer",
1237
+ },
1238
+ ],
1173
1239
  },
1174
1240
  {
1175
1241
  key: "speed_1_2",
@@ -1177,6 +1243,13 @@ function getPlayerConfiguration({ platform, version }) {
1177
1243
  type: "uploader",
1178
1244
  label_tooltip: "Playback Speed 1.2x",
1179
1245
  initial_value: null,
1246
+ rules: "conditional",
1247
+ conditional_fields: [
1248
+ {
1249
+ key: "styles/player_controls_layout",
1250
+ condition_value: "playback_speed_and_sleep_timer",
1251
+ },
1252
+ ],
1180
1253
  },
1181
1254
  {
1182
1255
  key: "speed_1_5",
@@ -1184,6 +1257,13 @@ function getPlayerConfiguration({ platform, version }) {
1184
1257
  type: "uploader",
1185
1258
  label_tooltip: "Playback Speed 1.5x",
1186
1259
  initial_value: null,
1260
+ rules: "conditional",
1261
+ conditional_fields: [
1262
+ {
1263
+ key: "styles/player_controls_layout",
1264
+ condition_value: "playback_speed_and_sleep_timer",
1265
+ },
1266
+ ],
1187
1267
  },
1188
1268
  {
1189
1269
  key: "speed_2",
@@ -1191,6 +1271,13 @@ function getPlayerConfiguration({ platform, version }) {
1191
1271
  type: "uploader",
1192
1272
  label_tooltip: "Playback Speed 2x",
1193
1273
  initial_value: null,
1274
+ rules: "conditional",
1275
+ conditional_fields: [
1276
+ {
1277
+ key: "styles/player_controls_layout",
1278
+ condition_value: "playback_speed_and_sleep_timer",
1279
+ },
1280
+ ],
1194
1281
  },
1195
1282
  {
1196
1283
  key: "sleep_timer",
@@ -1198,6 +1285,13 @@ function getPlayerConfiguration({ platform, version }) {
1198
1285
  type: "uploader",
1199
1286
  label_tooltip: "Sleep Timer Default",
1200
1287
  initial_value: null,
1288
+ rules: "conditional",
1289
+ conditional_fields: [
1290
+ {
1291
+ key: "styles/player_controls_layout",
1292
+ condition_value: "playback_speed_and_sleep_timer",
1293
+ },
1294
+ ],
1201
1295
  },
1202
1296
  {
1203
1297
  key: "sleep_timer_active",
@@ -1205,6 +1299,43 @@ function getPlayerConfiguration({ platform, version }) {
1205
1299
  type: "uploader",
1206
1300
  label_tooltip: "Sleep Timer Active",
1207
1301
  initial_value: null,
1302
+ rules: "conditional",
1303
+ conditional_fields: [
1304
+ {
1305
+ key: "styles/player_controls_layout",
1306
+ condition_value: "playback_speed_and_sleep_timer",
1307
+ },
1308
+ ],
1309
+ },
1310
+ {
1311
+ key: "previous_track",
1312
+ label: "Previous Track Control Icon",
1313
+ type: "uploader",
1314
+ label_tooltip:
1315
+ "Override the default Previous Track icon. Recommended size 120x120px (@3x).",
1316
+ initial_value: null,
1317
+ rules: "conditional",
1318
+ conditional_fields: [
1319
+ {
1320
+ key: "styles/player_controls_layout",
1321
+ condition_value: "playlist_controls",
1322
+ },
1323
+ ],
1324
+ },
1325
+ {
1326
+ key: "next_track",
1327
+ label: "Next Track Control Icon",
1328
+ type: "uploader",
1329
+ label_tooltip:
1330
+ "Override the default Next Track icon. Recommended size 120x120px (@3x).",
1331
+ initial_value: null,
1332
+ rules: "conditional",
1333
+ conditional_fields: [
1334
+ {
1335
+ key: "styles/player_controls_layout",
1336
+ condition_value: "playlist_controls",
1337
+ },
1338
+ ],
1208
1339
  },
1209
1340
  ]
1210
1341
  ),
@@ -3393,6 +3524,31 @@ function getPlayerConfiguration({ platform, version }) {
3393
3524
  },
3394
3525
  ],
3395
3526
  },
3527
+ {
3528
+ key: "player_controls_layout",
3529
+ label: "Player Controls Layout",
3530
+ type: "select",
3531
+ initial_value: "playback_speed_and_sleep_timer",
3532
+ label_tooltip:
3533
+ "Choose which secondary controls appear in the audio player transport row.",
3534
+ rules: "conditional",
3535
+ conditional_fields: [
3536
+ {
3537
+ key: "styles/full_screen_audio_player",
3538
+ condition_value: true,
3539
+ },
3540
+ ],
3541
+ options: [
3542
+ {
3543
+ text: "Playback Speed & Sleep Timer (Default)",
3544
+ value: "playback_speed_and_sleep_timer",
3545
+ },
3546
+ {
3547
+ text: "Playlist Controls (Previous & Next)",
3548
+ value: "playlist_controls",
3549
+ },
3550
+ ],
3551
+ },
3396
3552
  {
3397
3553
  key: "audio_player_artwork_shadow_enabled",
3398
3554
  label: "Image Shadow Enabled",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "15.1.6",
3
+ "version": "15.1.7",
4
4
  "description": "Applicaster Zapp React Native utilities package",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "homepage": "https://github.com/applicaster/quickbrick#readme",
29
29
  "dependencies": {
30
- "@applicaster/applicaster-types": "15.1.6",
30
+ "@applicaster/applicaster-types": "15.1.7",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -17,3 +17,5 @@ export * from "./screen";
17
17
  export * from "./state";
18
18
 
19
19
  export * from "./events";
20
+
21
+ export * from "./utils";
@@ -1,5 +1,5 @@
1
- import { usePrevious, useIsInitialRender } from "../";
2
- import { renderHook, cleanup } from "@testing-library/react-hooks";
1
+ import { usePrevious, useIsInitialRender, useForceUpdate } from "../";
2
+ import { renderHook, cleanup, act } from "@testing-library/react-native";
3
3
 
4
4
  describe("usePrevious", () => {
5
5
  it("renders", () => {
@@ -49,3 +49,23 @@ describe("useIsInitialRender", () => {
49
49
  expect(result.current).toBe(false);
50
50
  });
51
51
  });
52
+
53
+ describe("useForceUpdate", () => {
54
+ it("forces component to re-render when called", () => {
55
+ let renderCount = 0;
56
+
57
+ const { result } = renderHook(() => {
58
+ renderCount++;
59
+
60
+ return useForceUpdate();
61
+ });
62
+
63
+ expect(renderCount).toBe(1);
64
+
65
+ act(() => {
66
+ result.current();
67
+ });
68
+
69
+ expect(renderCount).toBe(2);
70
+ });
71
+ });
@@ -1,4 +1,4 @@
1
- import { useRef, useEffect } from "react";
1
+ import { useEffect, useReducer, useRef } from "react";
2
2
 
3
3
  /**
4
4
  * This hook returns a previous value that was passed to it.
@@ -31,6 +31,16 @@ export const useIsInitialRender = (): boolean => {
31
31
  return isInitialRenderRef.current;
32
32
  };
33
33
 
34
+ /**
35
+ * Returns a function that forces a re-render of the component when invoked.
36
+ * @return {() => void}
37
+ */
38
+ export const useForceUpdate = (): (() => void) => {
39
+ const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
40
+
41
+ return forceUpdate;
42
+ };
43
+
34
44
  export const checkIsLocalFeed = (feedUrl) => feedUrl?.startsWith("pipesv2://");
35
45
 
36
46
  export const shouldDispatchData = (