@applicaster/zapp-react-native-utils 15.1.7 → 15.1.9-alpha.3547421944

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,61 @@
1
+ import { decorateFeed } from "../feedDecorator";
2
+
3
+ const preferenceFeed = (selectMode: string) =>
4
+ ({
5
+ id: "agree-component",
6
+ title: "I agree to the terms",
7
+ type: { value: "feed" },
8
+ extensions: {
9
+ role: "preference_editor",
10
+ preference_editor_options: {
11
+ key: "agree",
12
+ scope: "screen",
13
+ select_mode: selectMode,
14
+ },
15
+ },
16
+ entry: [
17
+ {
18
+ id: "I agree to the terms-agree",
19
+ title: "I agree to the terms",
20
+ type: { value: "action" },
21
+ extensions: { tag: "true" },
22
+ },
23
+ ],
24
+ }) as unknown as ZappFeed;
25
+
26
+ describe("decorateFeed", () => {
27
+ it("decorates single select feeds with screenSetVariable", () => {
28
+ const decorated = decorateFeed(preferenceFeed("single"));
29
+
30
+ expect(decorated.extensions.behavior).toEqual(
31
+ expect.objectContaining({
32
+ select_mode: "single",
33
+ current_selection: "@{screen/agree}",
34
+ })
35
+ );
36
+
37
+ expect(decorated.entry[0].extensions.tap_actions.actions[0].type).toBe(
38
+ "screenSetVariable"
39
+ );
40
+ });
41
+
42
+ it("decorates multi select feeds with screenToggleFlag", () => {
43
+ const decorated = decorateFeed(preferenceFeed("multi"));
44
+
45
+ expect(decorated.extensions.behavior.select_mode).toBe("multi");
46
+
47
+ expect(decorated.entry[0].extensions.tap_actions.actions[0].type).toBe(
48
+ "screenToggleFlag"
49
+ );
50
+ });
51
+
52
+ it("leaves a feed without the preference_editor role untouched", () => {
53
+ const feed = preferenceFeed("single");
54
+ feed.extensions.role = undefined;
55
+
56
+ const decorated = decorateFeed(feed);
57
+
58
+ expect(decorated.extensions.behavior).toBeUndefined();
59
+ expect(decorated.entry[0].extensions.tap_actions).toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,133 @@
1
+ import { createNavigateToScreenAction } from "../navigateToScreen";
2
+ import { ActionResult } from "../../ActionExecutor";
3
+
4
+ const river = { id: "screen-id", type: "parent-lock" } as unknown as ZappRiver;
5
+
6
+ const rivers = { "screen-id": river } as Record<string, ZappRiver>;
7
+
8
+ const contentTypes = {
9
+ parentLock: { screen_id: "screen-id" },
10
+ } as unknown as ZappContentTypes;
11
+
12
+ function createNavigator() {
13
+ return {
14
+ push: jest.fn(),
15
+ replace: jest.fn(),
16
+ replaceTop: jest.fn(),
17
+ } as unknown as QuickBrickAppNavigator;
18
+ }
19
+
20
+ describe("navigateToScreen navigationOptions", () => {
21
+ it("forwards navigationOptions to push when navigating to a screen", async () => {
22
+ const navigator = createNavigator();
23
+
24
+ const action = {
25
+ type: "navigateToScreen",
26
+ options: {
27
+ typeMapping: "parentLock",
28
+ navigationAction: "push",
29
+ navigationOptions: { pinFlow: "set-pin" },
30
+ },
31
+ };
32
+
33
+ const result = await createNavigateToScreenAction(
34
+ navigator,
35
+ rivers,
36
+ contentTypes
37
+ )(action);
38
+
39
+ expect(navigator.push).toHaveBeenCalledWith(river, {
40
+ pinFlow: "set-pin",
41
+ });
42
+
43
+ expect(result).toBe(ActionResult.Success);
44
+ });
45
+
46
+ it("forwards navigationOptions to replace when navigating to a screen", async () => {
47
+ const navigator = createNavigator();
48
+
49
+ const action = {
50
+ type: "navigateToScreen",
51
+ options: {
52
+ typeMapping: "parentLock",
53
+ navigationOptions: { pinFlow: "change-pin" },
54
+ },
55
+ };
56
+
57
+ await createNavigateToScreenAction(navigator, rivers, contentTypes)(action);
58
+
59
+ expect(navigator.replace).toHaveBeenCalledWith(river, {
60
+ pinFlow: "change-pin",
61
+ });
62
+ });
63
+
64
+ it("forwards navigationOptions to push when navigating with an entry", async () => {
65
+ const navigator = createNavigator();
66
+ const entry = { id: "entry-id" } as unknown as ZappEntry;
67
+
68
+ const action = {
69
+ type: "navigateToScreen",
70
+ options: {
71
+ typeMapping: "parentLock",
72
+ navigationAction: "push",
73
+ entry,
74
+ navigationOptions: { pinFlow: "set-pin" },
75
+ },
76
+ };
77
+
78
+ await createNavigateToScreenAction(navigator, rivers, contentTypes)(action);
79
+
80
+ expect(navigator.push).toHaveBeenCalledWith(
81
+ { id: "entry-id", type: { value: "parentLock" } },
82
+ { pinFlow: "set-pin" }
83
+ );
84
+ });
85
+
86
+ // Entries cannot go through replace(), which only accepts a river, so the
87
+ // non-push path falls back to replaceTop().
88
+ it("forwards navigationOptions to replaceTop when navigating with an entry", async () => {
89
+ const navigator = createNavigator();
90
+ const entry = { id: "entry-id" } as unknown as ZappEntry;
91
+
92
+ const action = {
93
+ type: "navigateToScreen",
94
+ options: {
95
+ typeMapping: "parentLock",
96
+ entry,
97
+ navigationOptions: { pinFlow: "change-pin" },
98
+ },
99
+ };
100
+
101
+ await createNavigateToScreenAction(navigator, rivers, contentTypes)(action);
102
+
103
+ expect(navigator.replaceTop).toHaveBeenCalledWith(
104
+ { id: "entry-id", type: { value: "parentLock" } },
105
+ { pinFlow: "change-pin" }
106
+ );
107
+
108
+ expect(navigator.replace).not.toHaveBeenCalled();
109
+ });
110
+
111
+ // Regression guard: the no-options path must keep working unchanged.
112
+ it("still navigates when no navigationOptions are configured", async () => {
113
+ const navigator = createNavigator();
114
+
115
+ const action = {
116
+ type: "navigateToScreen",
117
+ options: {
118
+ typeMapping: "parentLock",
119
+ navigationAction: "push",
120
+ },
121
+ };
122
+
123
+ const result = await createNavigateToScreenAction(
124
+ navigator,
125
+ rivers,
126
+ contentTypes
127
+ )(action);
128
+
129
+ expect(navigator.push).toHaveBeenCalledTimes(1);
130
+ expect((navigator.push as jest.Mock).mock.calls[0][0]).toBe(river);
131
+ expect(result).toBe(ActionResult.Success);
132
+ });
133
+ });
@@ -25,6 +25,19 @@ export interface NavigateToScreenOptions {
25
25
  * - undefined to navigate to screen without entry
26
26
  */
27
27
  entry?: "@{entry/}" | ZappEntry;
28
+
29
+ /**
30
+ * Arbitrary values passed through to the destination screen.
31
+ *
32
+ * They are stored on the navigation route as `NavigationScreenData.options`
33
+ * and read on the target screen via `useNavigation().data?.options`. Use this
34
+ * to hand per-navigation parameters to a screen instead of routing them
35
+ * through module-level state.
36
+ *
37
+ * Note: `legacyScreenData()` does not carry these values, so they are not
38
+ * visible through `useCurrentScreenData()` / `useRoute()`.
39
+ */
40
+ navigationOptions?: Record<string, unknown>;
28
41
  }
29
42
 
30
43
  /**
@@ -55,6 +68,7 @@ export function createNavigateToScreenAction(
55
68
 
56
69
  const navigationAction = action.options?.navigationAction;
57
70
  const entrySource = action.options?.entry;
71
+ const navigationOptions = action.options?.navigationOptions;
58
72
 
59
73
  const entry = entrySource
60
74
  ? entrySource === "@{entry/}"
@@ -83,10 +97,10 @@ export function createNavigateToScreenAction(
83
97
  };
84
98
 
85
99
  if (navigationAction === "push") {
86
- navigator.push(overriddenEntry);
100
+ navigator.push(overriddenEntry, navigationOptions);
87
101
  } else {
88
102
  // Use replaceTop for entries since replace() only accepts ZappRiver
89
- navigator.replaceTop(overriddenEntry);
103
+ navigator.replaceTop(overriddenEntry, navigationOptions);
90
104
  }
91
105
 
92
106
  return ActionResult.Success;
@@ -113,9 +127,9 @@ export function createNavigateToScreenAction(
113
127
  context?.callback?.({ success: false, error: null, abort: true });
114
128
 
115
129
  if (navigationAction === "push") {
116
- navigator.push(river);
130
+ navigator.push(river, navigationOptions);
117
131
  } else {
118
- navigator.replace(river);
132
+ navigator.replace(river, navigationOptions);
119
133
  }
120
134
 
121
135
  return ActionResult.Success;
@@ -159,13 +159,13 @@ export const decorateFeed = (feed: ZappFeed) => {
159
159
 
160
160
  const decoratedFeed = R.clone(feed);
161
161
 
162
- const isSingleSelect =
163
- (feed.extensions?.["preference_editor_options"]?.select_mode ||
164
- feed.extensions?.["behavior"]?.select_mode) === "single";
162
+ const selectMode =
163
+ feed.extensions?.preference_editor_options?.select_mode ||
164
+ feed.extensions?.behavior?.select_mode;
165
165
 
166
- if (isSingleSelect) {
166
+ if (selectMode === "single") {
167
167
  return makeSingleSelect(feed, key, decoratedFeed);
168
- } else {
169
- return makeMultiSelect(feed, key, decoratedFeed);
170
168
  }
169
+
170
+ return makeMultiSelect(feed, key, decoratedFeed);
171
171
  };
@@ -504,6 +504,15 @@ export function HooksManager({
504
504
  presentUI
505
505
  );
506
506
  } catch (e) {
507
+ // Without this the failure is invisible: the hook stops right after the
508
+ // "Executing hook" line, with nothing in the log explaining why and no
509
+ // remaining hook ever running.
510
+ logHookEvent(
511
+ hooksManagerLogger.error,
512
+ `runInBackground: hook ${hookPlugin.identifier} threw: ${e?.message}`,
513
+ { hook: hookPlugin, payload, error: e, stack: e?.stack }
514
+ );
515
+
507
516
  hookPlugin.setStateAndNotify(HOOKS_EVENTS.ERROR, {
508
517
  error: e,
509
518
  hookPlugin,
@@ -19,6 +19,12 @@ export const { log_verbose, log_debug, log_info, log_error } = createLogger({
19
19
  parent: utilsLogger,
20
20
  });
21
21
 
22
+ /**
23
+ * Mirrors `initial_value` for `audio_live_player_update_interval` in
24
+ * `defaultManifestConfigurations/player.js`.
25
+ */
26
+ const DEFAULT_LIVE_CONTENT_UPDATE_INTERVAL_SECONDS = 60;
27
+
22
28
  type ChapterMarkerOriginal = {
23
29
  id: string;
24
30
  title: string;
@@ -40,11 +46,6 @@ export type LiveMetadataConfig = {
40
46
  updateInterval: number;
41
47
  };
42
48
 
43
- export type TitleSummaryEvent = {
44
- title: string | number;
45
- summary: string | number;
46
- };
47
-
48
49
  type ChapterMarkersObserverProps = {
49
50
  player: Player;
50
51
  };
@@ -66,11 +67,9 @@ export class OverlaysObserver {
66
67
  readonly chapterSubject: BehaviorSubject<ChapterMarkerEvent>;
67
68
  private playNextSubject: BehaviorSubject<PlayNextState>;
68
69
  private liveMetadataSubject: BehaviorSubject<LiveMetadataEvent>;
69
- private titleSummarySubject: BehaviorSubject<TitleSummaryEvent>;
70
- private feedUrl: string;
71
- private reloadData: () => void;
72
- private updateTitleAndDescription: (data: any) => void;
73
- private feedDataInterval: any;
70
+ private liveContentInterval: any;
71
+ private liveContentGeneration = 0;
72
+ private _liveEntry: ZappEntry | null = null;
74
73
  private liveMetadataUpdateInterval: any;
75
74
  private releasePlayerObserver?: () => void;
76
75
  readonly entry: ZappEntry;
@@ -86,41 +85,140 @@ export class OverlaysObserver {
86
85
  this.player = player;
87
86
  this.entry = player.getEntry();
88
87
 
89
- this.titleSummarySubject = new BehaviorSubject<TitleSummaryEvent>({
90
- title: this.entry?.title || "",
91
- summary: this.entry?.summary || "",
92
- });
93
-
94
88
  this.liveMetadataSubject = new BehaviorSubject<LiveMetadataEvent>(
95
89
  this.entry?.extensions?.liveMetadata || null
96
90
  );
97
91
 
98
92
  this.chapterMarkerEvents = this.prepareChapterMarkers();
99
93
  this.releasePlayerObserver = this.subscribeToPlayerEvents();
100
- this.feedUrl = "";
101
- this.reloadData = () => {};
102
- this.updateTitleAndDescription = () => {};
103
- this.feedDataInterval = null;
94
+ this.liveContentInterval = null;
104
95
  this.liveMetadataUpdateInterval = null;
105
96
  void this.preparePlayNext();
106
97
  void this.prepareLiveMetadata();
107
98
  }
108
99
 
109
- private setupFeedDataInterval(interval: number) {
110
- if (this.feedUrl && this.reloadData && this.updateTitleAndDescription) {
111
- this.feedDataInterval = setInterval(() => {
112
- this.reloadData();
113
- }, interval * 1000);
100
+ /**
101
+ * Polls the live feed and pushes each loaded entry into the player, which
102
+ * re-resolves its Title/Subtitle and publishes on its content channel.
103
+ *
104
+ * Runs entirely outside React: `loadFeedEntry` builds its request through
105
+ * `RequestBuilder.setUrl`, which inflates URL macros with the entry and
106
+ * screen contexts, so no `useInflatedUrl` is needed. `prepareLiveMetadata`
107
+ * below already polls this way.
108
+ */
109
+ public startLiveContentUpdates() {
110
+ this.stopLiveContentUpdates();
111
+
112
+ if (!this.player?.isLive?.()) {
113
+ log_debug("startLiveContentUpdates: Player is not live. Skipping...");
114
+
115
+ return;
114
116
  }
117
+
118
+ const feedUrl = this.getLiveContentFeedUrl();
119
+
120
+ if (!feedUrl) {
121
+ log_debug(
122
+ "startLiveContentUpdates: Entry carries no live channel feed. Skipping..."
123
+ );
124
+
125
+ return;
126
+ }
127
+
128
+ const intervalSeconds = this.getLiveContentUpdateInterval();
129
+
130
+ // Clearing the interval cannot cancel a request already in flight, so each
131
+ // run carries the generation it started in. A response that arrives after
132
+ // a stop or a restart belongs to a channel that is no longer playing and
133
+ // is dropped, rather than publishing the previous programme's title over
134
+ // the current one.
135
+ const generation = ++this.liveContentGeneration;
136
+
137
+ const load = async () => {
138
+ try {
139
+ const entry = await loadFeedEntry(feedUrl, this.entry);
140
+
141
+ if (generation !== this.liveContentGeneration) {
142
+ return;
143
+ }
144
+
145
+ this._liveEntry = entry;
146
+ this.player.publishContent();
147
+ } catch {
148
+ // `loadFeedEntry` already logs the failure. Keep whatever content is
149
+ // currently published rather than blanking the title on a transient
150
+ // network error.
151
+ }
152
+ };
153
+
154
+ void load();
155
+
156
+ this.liveContentInterval = setInterval(load, intervalSeconds * 1000);
115
157
  }
116
158
 
117
- public clearFeedDataInterval() {
118
- if (this.feedDataInterval) {
119
- clearInterval(this.feedDataInterval);
120
- this.feedDataInterval = null;
159
+ public stopLiveContentUpdates() {
160
+ // Invalidates any in-flight request as well as the interval.
161
+ this.liveContentGeneration += 1;
162
+
163
+ if (this.liveContentInterval) {
164
+ clearInterval(this.liveContentInterval);
165
+ this.liveContentInterval = null;
121
166
  }
122
167
  }
123
168
 
169
+ /**
170
+ * The programme feed for the channel currently playing. Derived here rather
171
+ * than by the caller so no UI component needs to know where a live channel
172
+ * keeps its feed, or that the URL has to be rewritten to reach the
173
+ * programme ("livePage") view of it.
174
+ */
175
+ private getLiveContentFeedUrl(): string {
176
+ const channelSrc =
177
+ this.player.getEntry()?.extensions?.live?.channel_src ?? "";
178
+
179
+ return channelSrc.replace(new RegExp("subClass", "g"), "livePage");
180
+ }
181
+
182
+ /**
183
+ * Falls back to the manifest's own `initial_value` when the app ships no
184
+ * value: `intervalSeconds * 1000` would otherwise be `NaN`, and
185
+ * `setInterval` treats `NaN` as `0` - a tight loop hammering the feed.
186
+ */
187
+ private getLiveContentUpdateInterval(): number {
188
+ const configured =
189
+ this.player.getPluginConfiguration()?.audio_live_player_update_interval;
190
+
191
+ const seconds = Number(configured);
192
+
193
+ return Number.isFinite(seconds) && seconds > 0
194
+ ? seconds
195
+ : DEFAULT_LIVE_CONTENT_UPDATE_INTERVAL_SECONDS;
196
+ }
197
+
198
+ /**
199
+ * The entry most recently loaded from the live channel feed - the programme
200
+ * currently on air, as opposed to `player.entry`, which is the channel and
201
+ * stays put for the whole listening session.
202
+ *
203
+ * Read by `Player.getContent()`, which prefers it over the player's own
204
+ * entry. It lives here rather than on `Player` because it is live-feed
205
+ * state, like `liveMetadata` and play-next: a VOD or cast player never has
206
+ * an observer at all, so it never carries this either.
207
+ */
208
+ public get liveEntry(): ZappEntry | null {
209
+ return this._liveEntry;
210
+ }
211
+
212
+ /**
213
+ * Called by `Player`'s `entry` setter. The live entry is an overlay on the
214
+ * current entry, so its lifetime is scoped to that entry: without this, a
215
+ * programme from a previous channel would shadow the new one forever - the
216
+ * content would change but the title would not.
217
+ */
218
+ public clearLiveEntry() {
219
+ this._liveEntry = null;
220
+ }
221
+
124
222
  public clearLiveMetadataUpdateInterval() {
125
223
  if (this.liveMetadataUpdateInterval) {
126
224
  clearInterval(this.liveMetadataUpdateInterval);
@@ -128,22 +226,6 @@ export class OverlaysObserver {
128
226
  }
129
227
  }
130
228
 
131
- public setFeedDataHandlers(
132
- feedUrl: string,
133
- reloadData: () => void,
134
- interval: number,
135
- updateTitleAndDescription: (data: any) => void
136
- ) {
137
- this.feedUrl = feedUrl;
138
- this.reloadData = reloadData;
139
- this.updateTitleAndDescription = updateTitleAndDescription;
140
- this.setupFeedDataInterval(interval);
141
- }
142
-
143
- public getTitleSummaryObservable(): Observable<TitleSummaryEvent> {
144
- return this.titleSummarySubject.asObservable().pipe(distinctUntilChanged());
145
- }
146
-
147
229
  hidePlayNext = () => {
148
230
  this.isPlayNextSuppressed = true;
149
231
  this.playNextSubject.next(null);
@@ -395,9 +477,8 @@ export class OverlaysObserver {
395
477
  this.chapterSubject.complete();
396
478
  this.playNextSubject.complete();
397
479
  this.liveMetadataSubject.complete();
398
- this.titleSummarySubject.complete();
399
480
  this.releasePlayerObserver?.();
400
- this.clearFeedDataInterval();
481
+ this.stopLiveContentUpdates();
401
482
  this.clearLiveMetadataUpdateInterval();
402
483
  this.releasePlayerObserver = null;
403
484
  };