@applicaster/zapp-react-native-ui-components 14.0.18 → 14.0.19-alpha.1129407856

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,352 @@
1
+ import { prepareEntry } from "../index";
2
+
3
+ const makeEntry = (overrides = {}): any => ({
4
+ content: { src: "https://example.com/video.mp4", type: "video/mp4" },
5
+ extensions: {},
6
+ ...overrides,
7
+ });
8
+
9
+ describe("prepareEntry", () => {
10
+ it("should return null when entry is null or undefined", () => {
11
+ expect(prepareEntry(null)).toBeNull();
12
+ expect(prepareEntry(undefined)).toBeNull();
13
+ });
14
+
15
+ describe("preview_playback_override", () => {
16
+ it("should deep merge override with entry, override taking precedence", () => {
17
+ const override = {
18
+ link: { href: "https://video-preload.com/id", rel: "self" },
19
+ extensions: {
20
+ brightcove: { video_id: 1234 },
21
+ preview: true,
22
+ free: true,
23
+ requires_authentication: false,
24
+ },
25
+ };
26
+
27
+ const entry = makeEntry({
28
+ extensions: {
29
+ preview_playback_override: override,
30
+ existing_field: "keep-me",
31
+ },
32
+ });
33
+
34
+ const result = prepareEntry(entry);
35
+
36
+ expect(result.extensions.brightcove.video_id).toBe(1234);
37
+ expect(result.extensions.preview).toBe(true);
38
+ expect(result.extensions.existing_field).toBe("keep-me");
39
+
40
+ expect(result.link).toEqual({
41
+ href: "https://video-preload.com/id",
42
+ rel: "self",
43
+ });
44
+
45
+ expect(result.content).toEqual(entry.content);
46
+ });
47
+
48
+ it("should override entry fields when both have the same key", () => {
49
+ const override = {
50
+ extensions: {
51
+ brightcove: { video_id: 9999 },
52
+ },
53
+ content: { src: "https://override.com/video.mp4" },
54
+ };
55
+
56
+ const entry = makeEntry({
57
+ extensions: {
58
+ preview_playback_override: override,
59
+ brightcove: { video_id: 1111, account_id: "abc" },
60
+ },
61
+ content: { src: "https://original.com/video.mp4", type: "video/mp4" },
62
+ });
63
+
64
+ const result = prepareEntry(entry);
65
+
66
+ expect(result.extensions.brightcove.video_id).toBe(9999);
67
+ expect(result.extensions.brightcove.account_id).toBe("abc");
68
+ expect(result.content.src).toBe("https://override.com/video.mp4");
69
+ expect(result.content.type).toBe("video/mp4");
70
+ });
71
+
72
+ it("should take priority over other playback sources", () => {
73
+ const override = {
74
+ link: { href: "https://override.com" },
75
+ extensions: { brightcove: { video_id: 5555 } },
76
+ };
77
+
78
+ const entry = makeEntry({
79
+ extensions: {
80
+ preview_playback_override: override,
81
+ brightcove: { preview_playback: "other-id" },
82
+ preview_playback: "https://other.com/video.mp4",
83
+ },
84
+ content: {
85
+ src: "https://example.com/video.mp4",
86
+ teaser: "https://teaser.com/video.mp4",
87
+ },
88
+ });
89
+
90
+ const result = prepareEntry(entry);
91
+
92
+ expect(result.link).toEqual({ href: "https://override.com" });
93
+ expect(result.extensions.brightcove.video_id).toBe(5555);
94
+ });
95
+ });
96
+
97
+ describe("brightcove preview_playback", () => {
98
+ it("should return entry with brightcove video_id replaced by preview_playback", () => {
99
+ const entry = makeEntry({
100
+ extensions: {
101
+ brightcove: {
102
+ video_id: "original-id",
103
+ preview_playback: "preview-id",
104
+ },
105
+ some_other: "value",
106
+ },
107
+ });
108
+
109
+ const result = prepareEntry(entry);
110
+
111
+ expect(result).toEqual({
112
+ ...entry,
113
+ extensions: {
114
+ ...entry.extensions,
115
+ brightcove: {
116
+ video_id: "preview-id",
117
+ preview_playback: "preview-id",
118
+ },
119
+ },
120
+ });
121
+ });
122
+
123
+ it("should preserve other extensions", () => {
124
+ const entry = makeEntry({
125
+ extensions: {
126
+ brightcove: { preview_playback: "preview-id" },
127
+ custom_field: "keep-me",
128
+ },
129
+ });
130
+
131
+ const result = prepareEntry(entry);
132
+
133
+ expect(result.extensions.custom_field).toBe("keep-me");
134
+ });
135
+ });
136
+
137
+ describe("extensions.preview_playback (non-brightcove)", () => {
138
+ it("should return entry with content.src set to preview_playback URL", () => {
139
+ const entry = makeEntry({
140
+ extensions: { preview_playback: "https://preview.com/video.m3u8" },
141
+ content: { src: "https://original.com/video.mp4" },
142
+ });
143
+
144
+ const result = prepareEntry(entry);
145
+
146
+ expect(result.content.src).toBe("https://preview.com/video.m3u8");
147
+ });
148
+
149
+ it("should preserve other content fields", () => {
150
+ const entry = makeEntry({
151
+ extensions: { preview_playback: "https://preview.com/video.m3u8" },
152
+ content: { src: "https://original.com/video.mp4", type: "video/mp4" },
153
+ });
154
+
155
+ const result = prepareEntry(entry);
156
+
157
+ expect(result.content.type).toBe("video/mp4");
158
+ });
159
+ });
160
+
161
+ describe("content.teaser", () => {
162
+ it("should return entry with content.src set to teaser URL", () => {
163
+ const entry = makeEntry({
164
+ extensions: {},
165
+ content: {
166
+ src: "https://original.com/video.mp4",
167
+ teaser: "https://teaser.com/teaser.mp4",
168
+ },
169
+ });
170
+
171
+ const result = prepareEntry(entry);
172
+
173
+ expect(result.content.src).toBe("https://teaser.com/teaser.mp4");
174
+ });
175
+ });
176
+
177
+ describe("brightcove video_id (without preview_playback)", () => {
178
+ it("should return the entry as-is when brightcove video_id exists", () => {
179
+ const entry = makeEntry({
180
+ extensions: { brightcove: { video_id: "some-id" } },
181
+ content: { src: "" },
182
+ });
183
+
184
+ const result = prepareEntry(entry);
185
+
186
+ expect(result).toBe(entry);
187
+ });
188
+ });
189
+
190
+ describe("content.src fallback", () => {
191
+ it("should return null when content.src is missing", () => {
192
+ const entry = makeEntry({
193
+ extensions: {},
194
+ content: {},
195
+ });
196
+
197
+ expect(prepareEntry(entry)).toBeNull();
198
+ });
199
+
200
+ it("should return null when content.src is empty", () => {
201
+ const entry = makeEntry({
202
+ extensions: {},
203
+ content: { src: "" },
204
+ });
205
+
206
+ expect(prepareEntry(entry)).toBeNull();
207
+ });
208
+
209
+ it("should return entry when content.type starts with video", () => {
210
+ const entry = makeEntry({
211
+ extensions: {},
212
+ content: { src: "https://example.com/stream", type: "video/mp4" },
213
+ });
214
+
215
+ expect(prepareEntry(entry)).toBe(entry);
216
+ });
217
+
218
+ it("should return entry when src has a known video extension", () => {
219
+ const extensions = ["m3u8", "mp4", "m4v", "mkv", "mov", "mpd", "ogv"];
220
+
221
+ for (const ext of extensions) {
222
+ const entry = makeEntry({
223
+ extensions: {},
224
+ content: { src: `https://example.com/video.${ext}` },
225
+ });
226
+
227
+ expect(prepareEntry(entry)).toBe(entry);
228
+ }
229
+ });
230
+
231
+ it("should return entry for audio extensions", () => {
232
+ const extensions = ["mp3", "oga", "opus"];
233
+
234
+ for (const ext of extensions) {
235
+ const entry = makeEntry({
236
+ extensions: {},
237
+ content: { src: `https://example.com/audio.${ext}` },
238
+ });
239
+
240
+ expect(prepareEntry(entry)).toBe(entry);
241
+ }
242
+ });
243
+
244
+ it("should return null for non-video/non-audio URLs", () => {
245
+ const entry = makeEntry({
246
+ extensions: {},
247
+ content: { src: "https://example.com/page.html" },
248
+ });
249
+
250
+ expect(prepareEntry(entry)).toBeNull();
251
+ });
252
+
253
+ it("should return null for URLs without extensions", () => {
254
+ const entry = makeEntry({
255
+ extensions: {},
256
+ content: { src: "https://example.com/stream" },
257
+ });
258
+
259
+ expect(prepareEntry(entry)).toBeNull();
260
+ });
261
+ });
262
+
263
+ describe("priority order", () => {
264
+ it("should prefer brightcove.preview_playback over extensions.preview_playback", () => {
265
+ const entry = makeEntry({
266
+ extensions: {
267
+ brightcove: { preview_playback: "bc-preview" },
268
+ preview_playback: "ext-preview",
269
+ },
270
+ });
271
+
272
+ const result = prepareEntry(entry);
273
+
274
+ expect(result.extensions.brightcove.video_id).toBe("bc-preview");
275
+ });
276
+
277
+ it("should prefer extensions.preview_playback over content.teaser", () => {
278
+ const entry = makeEntry({
279
+ extensions: { preview_playback: "https://preview.com/video.mp4" },
280
+ content: {
281
+ src: "https://original.com/video.mp4",
282
+ teaser: "https://teaser.com/video.mp4",
283
+ },
284
+ });
285
+
286
+ const result = prepareEntry(entry);
287
+
288
+ expect(result.content.src).toBe("https://preview.com/video.mp4");
289
+ });
290
+
291
+ it("should prefer preview_playback_override over all other sources", () => {
292
+ const override = {
293
+ extensions: { brightcove: { video_id: "override-id" } },
294
+ };
295
+
296
+ const entry = makeEntry({
297
+ extensions: {
298
+ preview_playback_override: override,
299
+ brightcove: { preview_playback: "bc-preview", video_id: "original" },
300
+ preview_playback: "https://ext-preview.com/video.mp4",
301
+ },
302
+ content: {
303
+ src: "https://original.com/video.mp4",
304
+ teaser: "https://teaser.com/video.mp4",
305
+ },
306
+ });
307
+
308
+ const result = prepareEntry(entry);
309
+
310
+ expect(result.extensions.brightcove.video_id).toBe("override-id");
311
+ });
312
+
313
+ it("should prefer content.teaser over brightcove.video_id", () => {
314
+ const entry = makeEntry({
315
+ extensions: { brightcove: { video_id: "some-id" } },
316
+ content: {
317
+ src: "https://original.com/video.mp4",
318
+ teaser: "https://teaser.com/teaser.mp4",
319
+ },
320
+ });
321
+
322
+ const result = prepareEntry(entry);
323
+
324
+ expect(result.content.src).toBe("https://teaser.com/teaser.mp4");
325
+ });
326
+
327
+ it("should prefer content.teaser over content.src URL check", () => {
328
+ const entry = makeEntry({
329
+ extensions: {},
330
+ content: {
331
+ src: "https://original.com/video.mp4",
332
+ teaser: "https://teaser.com/teaser.m3u8",
333
+ },
334
+ });
335
+
336
+ const result = prepareEntry(entry);
337
+
338
+ expect(result.content.src).toBe("https://teaser.com/teaser.m3u8");
339
+ });
340
+
341
+ it("should prefer brightcove.video_id over content.src URL check", () => {
342
+ const entry = makeEntry({
343
+ extensions: { brightcove: { video_id: "bc-id" } },
344
+ content: { src: "https://example.com/page.html" },
345
+ });
346
+
347
+ const result = prepareEntry(entry);
348
+
349
+ expect(result).toBe(entry);
350
+ });
351
+ });
352
+ });
@@ -0,0 +1,136 @@
1
+ import { findPluginByIdentifier } from "@applicaster/zapp-react-native-utils/pluginUtils";
2
+ import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
3
+ import { loggerLiveImageManager } from "../../../VideoLive/loggerHelper";
4
+
5
+ const { log_debug, log_error } = loggerLiveImageManager;
6
+
7
+ export type PreloadHookConfig = ZappPreloadPlugins & {
8
+ configuration: any;
9
+ };
10
+
11
+ function runHookWithCallback(
12
+ invoke: (
13
+ payload: any,
14
+ callback: (result: hookCallbackArgs) => void,
15
+ abortCallback: () => void
16
+ ) => void,
17
+ payload: any
18
+ ): Promise<{ payload: any; aborted: boolean }> {
19
+ return new Promise((resolve, reject) => {
20
+ const abortCallback = () => {
21
+ resolve({ payload, aborted: true });
22
+ };
23
+
24
+ try {
25
+ invoke(
26
+ payload,
27
+ ({
28
+ success,
29
+ error,
30
+ payload: resultPayload,
31
+ abort,
32
+ }: hookCallbackArgs) => {
33
+ if (error) {
34
+ reject(error);
35
+ } else if (!success || abort) {
36
+ resolve({ payload: resultPayload ?? payload, aborted: true });
37
+ } else {
38
+ resolve({ payload: resultPayload ?? payload, aborted: false });
39
+ }
40
+ },
41
+ abortCallback
42
+ );
43
+ } catch (error) {
44
+ log_error(`Error running preload hook: ${error.message}`, { error });
45
+ reject(error);
46
+ }
47
+ });
48
+ }
49
+
50
+ // TODO: Move this function to a more generic place,
51
+ // as it can be used by other components that want to run preload hooks
52
+ // and add test coverage for it
53
+ export async function executePreloadHooks({
54
+ preloadHooks,
55
+ entry,
56
+ }: {
57
+ preloadHooks: PreloadHookConfig[];
58
+ entry: ZappEntry;
59
+ }): Promise<ZappEntry | null> {
60
+ if (!preloadHooks?.length) {
61
+ return entry;
62
+ }
63
+
64
+ const sortedHooks = [...preloadHooks].sort(
65
+ (a, b) => (a.weight || 0) - (b.weight || 0)
66
+ );
67
+
68
+ const hookNames = sortedHooks.map((hook) => hook.identifier).join(", ");
69
+
70
+ log_debug(
71
+ `Preload hook sequence: ${hookNames}, entry: ${entry.id} - ${entry.title}`
72
+ );
73
+
74
+ let payload: any = entry;
75
+
76
+ let needsAbort = false;
77
+ const plugins = appStore.get("plugins");
78
+
79
+ for (const hookConfig of sortedHooks) {
80
+ const plugin = findPluginByIdentifier(hookConfig.identifier, plugins, true);
81
+
82
+ if (!plugin?.module) {
83
+ log_debug(
84
+ `Preload hook plugin not found: ${hookConfig.identifier}, skipping`
85
+ );
86
+
87
+ continue;
88
+ }
89
+
90
+ const { module: hookModule } = plugin;
91
+
92
+ if (hookModule.skipHook?.(payload)) {
93
+ log_debug(`Skipping hook: ${hookConfig.identifier}`);
94
+
95
+ continue;
96
+ }
97
+
98
+ if (hookModule.runInBackground) {
99
+ log_debug(
100
+ `Running background hook: ${hookConfig.identifier}, entry: ${payload?.id} - ${payload?.title}`
101
+ );
102
+
103
+ const result = await runHookWithCallback(
104
+ (item, callback, abortCallback) =>
105
+ hookModule
106
+ .runInBackground(item, callback, hookConfig, abortCallback)
107
+ ?.catch?.((error: Error) => {
108
+ log_error(
109
+ `Error running background preload hook: ${hookConfig.identifier}, error: ${error.message}`,
110
+ { error }
111
+ );
112
+
113
+ callback({ success: false, payload: item, error });
114
+ }),
115
+ payload
116
+ );
117
+
118
+ payload = result.payload;
119
+ needsAbort = result.aborted;
120
+ }
121
+
122
+ if (needsAbort) {
123
+ log_debug(
124
+ `Preload hook requested abort: ${hookConfig.identifier}, stopping chain`
125
+ );
126
+
127
+ break;
128
+ }
129
+ }
130
+
131
+ if (needsAbort) {
132
+ return null;
133
+ }
134
+
135
+ return payload;
136
+ }
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
- import * as R from "ramda";
2
+ import merge from "lodash/merge";
3
+ import { clone } from "@applicaster/zapp-react-native-utils/utils";
3
4
  import { Platform, View, ViewStyle } from "react-native";
4
5
  import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
5
6
  import { isTV, isWeb } from "@applicaster/zapp-react-native-utils/reactUtils";
@@ -83,30 +84,22 @@ const removeAdsFromEntry = (entry) => {
83
84
  return entry;
84
85
  }
85
86
 
86
- const newEntry = R.clone(entry);
87
+ const newEntry = clone(entry);
87
88
 
88
89
  delete newEntry.extensions.video_ads;
89
90
 
90
91
  return newEntry;
91
92
  };
92
93
 
93
- const prepareEntry = (entry) => {
94
+ export const prepareEntry = (entry: ZappEntry) => {
94
95
  if (!entry) {
95
96
  return null;
96
97
  }
97
98
 
98
- if (entry.extensions?.preview_playback) {
99
- return {
100
- ...entry,
101
- content: { ...entry?.content, src: entry?.extensions?.preview_playback },
102
- };
103
- }
99
+ const previewPlaybackOverride = entry.extensions?.preview_playback_override;
104
100
 
105
- if (entry.content?.teaser) {
106
- return {
107
- ...entry,
108
- content: { ...entry?.content, src: entry?.content?.teaser },
109
- };
101
+ if (previewPlaybackOverride) {
102
+ return merge({}, entry, previewPlaybackOverride);
110
103
  }
111
104
 
112
105
  const previewPlayback = entry.extensions?.brightcove?.preview_playback;
@@ -124,6 +117,20 @@ const prepareEntry = (entry) => {
124
117
  };
125
118
  }
126
119
 
120
+ if (entry.extensions?.preview_playback) {
121
+ return {
122
+ ...entry,
123
+ content: { ...entry?.content, src: entry?.extensions?.preview_playback },
124
+ };
125
+ }
126
+
127
+ if (entry.content?.teaser) {
128
+ return {
129
+ ...entry,
130
+ content: { ...entry?.content, src: entry?.content?.teaser },
131
+ };
132
+ }
133
+
127
134
  if (entry.extensions?.brightcove?.video_id) {
128
135
  return entry;
129
136
  }
@@ -167,14 +174,24 @@ function getFocusedState(
167
174
  const getPlayerConfig = (player_screen_id, actionIdentifier) => {
168
175
  if (player_screen_id) {
169
176
  const rivers = appStore.get("rivers");
177
+ const plugins = appStore.get("plugins");
170
178
  const playerScreen = rivers?.[player_screen_id] ?? null;
171
179
 
180
+ const preloadHooks = playerScreen?.hooks?.preload_plugins?.map((hook) => {
181
+ const configuration =
182
+ plugins.find((plugin) => plugin.identifier === hook.identifier)
183
+ ?.configuration || {};
184
+
185
+ return { ...hook, configuration, ...rivers[hook.screen_id] };
186
+ });
187
+
172
188
  // TODO: Check is it a player later
173
189
 
174
190
  // TODO: Add more dict if needed from the screen component, styles, data etc
175
191
  return {
176
192
  playerPluginId: playerScreen?.type ?? DEFAULT_PLAYER_IDENTIFIER,
177
193
  screenConfig: playerScreen?.general,
194
+ preloadHooks,
178
195
  };
179
196
  }
180
197
 
@@ -229,7 +246,7 @@ const LiveImageComponent = (props: LiveImageProps) => {
229
246
 
230
247
  const isCurrentlyFocused = useTrackCurrentAutoScrollingElement(componentId);
231
248
 
232
- const { playerPluginId, screenConfig } =
249
+ const { playerPluginId, screenConfig, preloadHooks } =
233
250
  getPlayerConfig(player_screen_id, actionIdentifier) ?? {};
234
251
 
235
252
  const playableEntry = playerPluginId ? getPlayableEntry(item) : null;
@@ -239,8 +256,8 @@ const LiveImageComponent = (props: LiveImageProps) => {
239
256
 
240
257
  const isShouldRender =
241
258
  playerPlugin &&
242
- getFocusedState(state, componentType, isCurrentlyFocused) &&
243
259
  playableEntry &&
260
+ getFocusedState(state, componentType, isCurrentlyFocused) &&
244
261
  cellUUID &&
245
262
  isSupportedTVForLiveImage() &&
246
263
  !isScreenReaderEnabled;
@@ -259,6 +276,7 @@ const LiveImageComponent = (props: LiveImageProps) => {
259
276
  imageKey={imageKey}
260
277
  item={playableEntry}
261
278
  audioMutedByDefault={audio_muted_by_default}
279
+ preloadHooks={preloadHooks}
262
280
  />
263
281
  </View>
264
282
  ) : null}
@@ -46,7 +46,6 @@ import {
46
46
  PlayerContainerContextProvider,
47
47
  } from "./PlayerContainerContext";
48
48
  import { FocusableGroup } from "@applicaster/zapp-react-native-ui-components/Components/FocusableGroup";
49
- import { ErrorDisplay } from "./ErrorDisplay";
50
49
  import { PlayerFocusableWrapperView } from "./WappersView/PlayerFocusableWrapperView";
51
50
  import { FocusableGroupMainContainerId } from "./index";
52
51
  import { isPlayable } from "@applicaster/zapp-react-native-utils/navigationUtils/itemTypeMatchers";
@@ -331,12 +330,6 @@ const PlayerContainerComponent = (props: Props) => {
331
330
  playerContainerLogger.error(errorObj);
332
331
 
333
332
  setState({ error: errorObj });
334
-
335
- if (!isTvOS) {
336
- setTimeout(() => {
337
- close();
338
- }, 800);
339
- }
340
333
  },
341
334
  [close]
342
335
  );
@@ -694,8 +687,6 @@ const PlayerContainerComponent = (props: Props) => {
694
687
  </Player>
695
688
  ) : null}
696
689
  </PlayerFocusableWrapperView>
697
-
698
- {state.error ? <ErrorDisplay error={state.error} /> : null}
699
690
  </View>
700
691
  {/* Components container */}
701
692
  {isInlineTV && context.showComponentsContainer ? (
@@ -4,9 +4,14 @@ import {
4
4
  playerManager,
5
5
  } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
6
6
  import { setUserCellPlayerMutedPreference } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference";
7
+ import { playerFactory } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory";
8
+ import { PlayerRole } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/conts";
7
9
  import { loggerLiveImageManager } from "./loggerHelper";
8
10
  import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils";
9
- import { Component } from "react";
11
+ import {
12
+ executePreloadHooks,
13
+ PreloadHookConfig,
14
+ } from "../MasterCell/DefaultComponents/LiveImage/executePreloadHooks";
10
15
 
11
16
  const TIMEOUT_FOR_DELAY_CHECK_PLAYER_POSITION = 500; // ms
12
17
 
@@ -40,13 +45,20 @@ type Position = {
40
45
  right: number;
41
46
  };
42
47
 
48
+ type PlayerFactoryConfig = {
49
+ player: any; // React ref for native view
50
+ playerId: string;
51
+ muted: boolean;
52
+ playerPluginId: string;
53
+ screenConfig: Record<string, any>;
54
+ entry: ZappEntry; // original entry, used as fallback if no hooks
55
+ };
56
+
43
57
  type LiveImageProps = {
44
- player: Player;
45
58
  playerId: string;
46
59
  setMode?: (type: LiveImageType) => void;
47
- component: Component;
48
- // TODO: ...primary, powerCell, tvGallery, etc.
49
- // type: string;
60
+ preloadHooks?: PreloadHookConfig[];
61
+ factoryConfig: PlayerFactoryConfig;
50
62
  };
51
63
 
52
64
  // Disabled because we have only unmute button but no play/pause state anymore
@@ -85,7 +97,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
85
97
 
86
98
  public register = (item: LiveImage): (() => void) => {
87
99
  this.items.push(item);
88
- log_debug(`register: live image ${playerInfo(item.player)}`);
100
+ log_debug(`register: live image ${item.playerId}`);
89
101
 
90
102
  // TV only Start playing video once registered
91
103
  if (isTV()) {
@@ -96,15 +108,13 @@ export class LiveImageManager implements PlayerLifecycleListener {
96
108
  };
97
109
 
98
110
  public unregister = (item: LiveImage) => {
99
- log_debug(`unregister: live-image ${playerInfo(item.player)}`);
111
+ log_debug(`unregister: live-image ${item.playerId}`);
100
112
 
101
113
  if (this.currentlyPlaying === item) {
102
114
  this.currentlyPlaying = null;
103
115
 
104
116
  log_debug(
105
- `unregister: currently playing live-image was destroyed, ${playerInfo(
106
- item.player
107
- )}`
117
+ `unregister: currently playing live-image was destroyed, ${item.playerId}`
108
118
  );
109
119
 
110
120
  // TODO: Maybe start another one
@@ -118,7 +128,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
118
128
  item,
119
129
  playerId: this.currentlyPlaying?.playerId,
120
130
  primaryPlayerId: this.primaryPlayer?.playerId,
121
- entry: item.getPlayer().getEntry(),
131
+ entry: item.getPlayer()?.getEntry(),
122
132
  },
123
133
  });
124
134
  };
@@ -130,9 +140,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
130
140
 
131
141
  public onViewportEnter = (item: LiveImage) => {
132
142
  log_debug(
133
- `onViewportEnter: live-image ${playerInfo(
134
- item.player
135
- )}, primary ${playerInfo(this.primaryPlayer)}`
143
+ `onViewportEnter: live-image ${item.playerId}, primary ${playerInfo(this.primaryPlayer)}`
136
144
  );
137
145
 
138
146
  if (!isTV()) {
@@ -154,9 +162,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
154
162
 
155
163
  public onViewportLeave = (item: LiveImage) => {
156
164
  log_debug(
157
- `onViewportLeave: live-image playerId: ${playerInfo(
158
- item.player
159
- )}, primary ${playerInfo(this.primaryPlayer)}`
165
+ `onViewportLeave: live-image ${item.playerId}, primary ${playerInfo(this.primaryPlayer)}`
160
166
  );
161
167
 
162
168
  this.pauseItem(item);
@@ -190,20 +196,27 @@ export class LiveImageManager implements PlayerLifecycleListener {
190
196
  this.items.find((i) => i.playerId === playerId) || null;
191
197
 
192
198
  private pauseItem = (item: LiveImage) => {
193
- log_debug(`pauseItem: live-image ${playerInfo(item.player)}`);
199
+ log_debug(`pauseItem: live-image ${item.playerId}`);
200
+
201
+ if (!item.player) {
202
+ // Player not yet created (e.g. hooks still running) — just reset mode
203
+ item.setMode?.(LiveImageType.Image);
204
+
205
+ if (item === this.currentlyPlaying) {
206
+ this.currentlyPlaying = null;
207
+ }
208
+
209
+ return;
210
+ }
194
211
 
195
212
  if (!item.player.playerState.isReadyToPlay) {
196
- log_debug(
197
- `playItem: live-image not ready, will start playback after loading, ${playerInfo(
198
- item.player
199
- )}`
200
- );
213
+ log_debug(`pauseItem: live-image not ready, ${item.playerId}`);
201
214
  } else {
202
- item.player?.pause();
215
+ item.player.pause();
203
216
  }
204
217
 
205
218
  // Fake close event, because we unmount native view
206
- item.player?.onPlayerClose();
219
+ item.player.onPlayerClose();
207
220
  item.setMode?.(LiveImageType.Image);
208
221
 
209
222
  if (item === this.currentlyPlaying) {
@@ -213,9 +226,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
213
226
 
214
227
  public playLiveImage = (item: LiveImage) => {
215
228
  log_debug(
216
- `playLiveImage: live-image ${playerInfo(
217
- item.player
218
- )}, primary ${playerInfo(this.primaryPlayer)}`
229
+ `playLiveImage: live-image ${item.playerId}, primary ${playerInfo(this.primaryPlayer)}`
219
230
  );
220
231
 
221
232
  if (this.primaryPlayer) {
@@ -223,7 +234,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
223
234
  }
224
235
 
225
236
  if (this.currentlyPlaying) {
226
- if (this.currentlyPlaying?.player?.playerId === item.player.playerId) {
237
+ if (this.currentlyPlaying.playerId === item.playerId) {
227
238
  return;
228
239
  } else {
229
240
  this.pauseItem(this.currentlyPlaying);
@@ -231,18 +242,42 @@ export class LiveImageManager implements PlayerLifecycleListener {
231
242
  }
232
243
 
233
244
  this.currentlyPlaying = item;
234
- item.setMode?.(LiveImageType.Video);
235
245
 
236
- if (item.player.playerState.isReadyToPlay) {
237
- item.player.play();
238
- }
246
+ item
247
+ .prepareForPlayback()
248
+ .then((result) => {
249
+ if (!result) {
250
+ log_error(
251
+ `Failed to prepare live image ${item.playerId} for playback: prepareForPlayback returned false`
252
+ );
253
+
254
+ this.currentlyPlaying = null;
255
+ item.setMode?.(LiveImageType.Image);
256
+
257
+ return;
258
+ }
259
+
260
+ // Guard: item might have been replaced while hooks were running
261
+ if (this.currentlyPlaying !== item) return;
262
+
263
+ item.setMode?.(LiveImageType.Video);
264
+
265
+ if (item.player?.playerState.isReadyToPlay) {
266
+ item.player.play();
267
+ }
268
+ })
269
+ .catch((error) => {
270
+ log_error(
271
+ `Failed to prepare live image ${item.playerId} for playback: ${error?.message}`
272
+ );
273
+
274
+ this.onLiveImageError(item, error);
275
+ });
239
276
  };
240
277
 
241
278
  public pauseLiveImage = (item: LiveImage) => {
242
279
  log_debug(
243
- `pauseLiveImage: live-image playerId: ${playerInfo(
244
- item.player
245
- )}, primary ${playerInfo(this.primaryPlayer)}`
280
+ `pauseLiveImage: live-image ${item.playerId}, primary ${playerInfo(this.primaryPlayer)}`
246
281
  );
247
282
 
248
283
  this.pauseItem(item);
@@ -258,7 +293,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
258
293
 
259
294
  setUserCellPlayerMutedPreference(true);
260
295
 
261
- this.items.forEach((liveImage) => liveImage.player.mute());
296
+ this.items.forEach((liveImage) => liveImage.player?.mute());
262
297
  };
263
298
 
264
299
  public unmuteAll = () => {
@@ -266,16 +301,14 @@ export class LiveImageManager implements PlayerLifecycleListener {
266
301
 
267
302
  setUserCellPlayerMutedPreference(false);
268
303
 
269
- this.items.forEach((liveImage) => liveImage.player.unmute());
304
+ this.items.forEach((liveImage) => liveImage.player?.unmute());
270
305
  };
271
306
 
272
307
  public checkPlayerPosition = (item: LiveImage) => {
273
308
  this.cancelCheckPlayerPositionTimeout();
274
309
 
275
310
  log_debug(
276
- `checkPlayerPosition: live-image playerId: ${playerInfo(
277
- item.player
278
- )}, primary ${playerInfo(this.primaryPlayer)}`
311
+ `checkPlayerPosition: live-image ${item.playerId}, primary ${playerInfo(this.primaryPlayer)}`
279
312
  );
280
313
 
281
314
  const playerItem = this.findNextPlayableItem();
@@ -391,7 +424,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
391
424
  item,
392
425
  playerId: this.currentlyPlaying?.playerId,
393
426
  primaryPlayerId: this.primaryPlayer?.playerId,
394
- entry: item.getPlayer().getEntry(),
427
+ entry: item.getPlayer()?.getEntry(),
395
428
  },
396
429
  });
397
430
  };
@@ -426,7 +459,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
426
459
  item,
427
460
  playerId: this.currentlyPlaying?.playerId,
428
461
  primaryPlayerId: this.primaryPlayer?.playerId,
429
- entry: item.getPlayer().getEntry(),
462
+ entry: item.getPlayer()?.getEntry(),
430
463
  },
431
464
  });
432
465
  };
@@ -448,7 +481,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
448
481
  this.currentlyPlaying = null;
449
482
 
450
483
  log_debug(
451
- `onLiveImageError: currentitem: ${currentItem.playerId} was removed`
484
+ `onLiveImageError: currentItem: ${currentItem.playerId} was removed`
452
485
  );
453
486
 
454
487
  // TODO: ...Maybe player some other item
@@ -460,8 +493,8 @@ export class LiveImageManager implements PlayerLifecycleListener {
460
493
  item,
461
494
  error,
462
495
  playerId: currentItem?.playerId,
463
- primaryPlayerId: currentItem?.playerId,
464
- entry: item.getPlayer().getEntry(),
496
+ primaryPlayerId: this.primaryPlayer?.playerId,
497
+ entry: item.getPlayer()?.getEntry(),
465
498
  },
466
499
  });
467
500
  };
@@ -503,9 +536,8 @@ export class LiveImageManager implements PlayerLifecycleListener {
503
536
  LiveImageManager.instance;
504
537
 
505
538
  export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
506
- public player: Player;
507
- public setMode: (type: LiveImageType) => void;
508
- // Will be replaced with rects
539
+ public player: Player | null = null;
540
+ public setMode?: (type: LiveImageType) => void;
509
541
  public isFullyVisible: boolean = false;
510
542
  public position: Position = {
511
543
  centerX: 0,
@@ -517,17 +549,93 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
517
549
  };
518
550
 
519
551
  readonly playerId: string;
520
- readonly component: Component;
552
+ public component: any = null;
553
+
554
+ private factoryConfig: PlayerFactoryConfig;
555
+ public preloadHooks?: PreloadHookConfig[];
556
+ public processedEntry: ZappEntry | null = null;
557
+ private _preparePromise: Promise<boolean> | null = null;
521
558
 
522
559
  constructor(props: LiveImageProps) {
523
- this.player = props.player;
524
560
  this.setMode = props.setMode;
525
- this.playerId = this.player.playerId;
526
- this.component = props.component;
527
- this.player.addListener({ id: "live-image", listener: this });
561
+ this.playerId = props.playerId;
562
+ this.preloadHooks = props.preloadHooks;
563
+ this.factoryConfig = props.factoryConfig;
564
+ }
565
+
566
+ async prepareForPlayback(): Promise<boolean> {
567
+ // Already prepared — player exists
568
+ if (this.player) {
569
+ return true;
570
+ }
571
+
572
+ // Deduplicate: if preparation is already in flight, await the same promise
573
+ if (this._preparePromise) {
574
+ return this._preparePromise;
575
+ }
576
+
577
+ this._preparePromise = (async (): Promise<boolean> => {
578
+ // 1. Run hooks if configured
579
+ let entry = this.factoryConfig.entry;
580
+
581
+ if (this.preloadHooks?.length) {
582
+ const result = await executePreloadHooks({
583
+ preloadHooks: this.preloadHooks,
584
+ entry,
585
+ });
586
+
587
+ if (result) {
588
+ this.processedEntry = result;
589
+ entry = result;
590
+ } else {
591
+ return false;
592
+ }
593
+ }
594
+
595
+ // 2. Create the player with the correct entry
596
+ const factoryItem = playerFactory({
597
+ player: this.factoryConfig.player,
598
+ playerId: this.factoryConfig.playerId,
599
+ autoplay: false,
600
+ entry,
601
+ muted: this.factoryConfig.muted,
602
+ playerPluginId: this.factoryConfig.playerPluginId,
603
+ screenConfig: this.factoryConfig.screenConfig,
604
+ playerRole: PlayerRole.Cell,
605
+ });
606
+
607
+ if (!factoryItem) {
608
+ throw new Error("Player factory returned null");
609
+ }
610
+
611
+ this.player = factoryItem.controller;
612
+ this.component = factoryItem.Component;
613
+
614
+ // 3. Register callbacks — player now exists
615
+ this.player.addListener({ id: "live-image", listener: this });
616
+
617
+ return true;
618
+ })()
619
+ .then((result) => {
620
+ this._preparePromise = null;
621
+
622
+ return result;
623
+ })
624
+ .catch((error) => {
625
+ this._preparePromise = null;
626
+
627
+ log_error(
628
+ `prepareForPlayback: live-image ${this.playerId}, error preparing for playback: ${error?.message}`,
629
+ { error }
630
+ );
631
+
632
+ throw error;
633
+ });
634
+
635
+ return this._preparePromise;
528
636
  }
529
637
 
530
- public getPlayer = (): Player => {
638
+ public getPlayer = (): Player | null => {
531
639
  return this.player;
532
640
  };
533
641
 
@@ -7,8 +7,6 @@ import { AppState, AppStateStatus, View } from "react-native";
7
7
  import { TrackedView } from "../TrackedView";
8
8
  import { useDimensions } from "@applicaster/zapp-react-native-utils/reactHooks/layout";
9
9
 
10
- import { playerFactory } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory";
11
-
12
10
  import {
13
11
  isApplePlatform,
14
12
  isTV,
@@ -22,15 +20,15 @@ import { AnimatedInOut } from "@applicaster/zapp-react-native-ui-components/Comp
22
20
  import { overlayFadeIn } from "./animationUtils";
23
21
  import { loggerLiveImageComponent } from "./loggerHelper";
24
22
  import { usePlayer } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/usePlayer";
25
- import { PlayerRole } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/conts";
26
23
  import { getAutoplaySettings } from "./utils";
27
24
  import { isString } from "@applicaster/zapp-react-native-utils/stringUtils";
28
25
  import { BufferAnimation } from "../PlayerContainer/BufferAnimation";
26
+ import { PreloadHookConfig } from "../MasterCell/DefaultComponents/LiveImage/executePreloadHooks";
29
27
 
30
28
  const { log_error, log_debug } = loggerLiveImageComponent;
31
29
 
32
- const isMeasurement = (item: ZappEntry) =>
33
- isString(item.id) && item.id.startsWith("pre-measurement-");
30
+ const isMeasurement = (itemId: string | number | null): boolean =>
31
+ isString(itemId) && itemId.startsWith("pre-measurement-");
34
32
 
35
33
  // Pixels by which the view can slightly extend outside the viewport and still be considered fully visible.
36
34
  const CLIP_THRESHOLD = 10;
@@ -44,6 +42,7 @@ type Props = {
44
42
  screenConfig: Record<string, any>;
45
43
  audioMutedByDefault?: boolean;
46
44
  uri: string; // cover image url
45
+ preloadHooks?: PreloadHookConfig[];
47
46
  };
48
47
 
49
48
  const PlayerLiveImageComponent = (props: Props) => {
@@ -116,43 +115,35 @@ const PlayerLiveImageComponent = (props: Props) => {
116
115
  }
117
116
 
118
117
  entryToViewIdMapping.current[nativeTag] = { itemId: item.id };
119
- }, [trackViewRef.current, item.id, mode]);
118
+ }, [item.id, mode]);
120
119
 
121
- const { screenConfig, playerPluginId } = props;
120
+ const { screenConfig, playerPluginId, preloadHooks } = props;
122
121
 
123
122
  const liveImageItem: LiveImage = React.useMemo(() => {
124
- const playerFactoryItem = playerFactory({
125
- player: ref,
123
+ return new LiveImage({
126
124
  playerId,
127
- autoplay: false,
128
- entry: item,
129
- muted, // Initial muted state, not needed in dependencies
130
- playerPluginId: playerPluginId,
131
- screenConfig: screenConfig,
132
- playerRole: PlayerRole.Cell,
133
- });
134
-
135
- if (playerFactoryItem) {
136
- return new LiveImage({
125
+ preloadHooks,
126
+ factoryConfig: {
127
+ player: ref,
137
128
  playerId,
138
- player: playerFactoryItem.controller,
139
- component: playerFactoryItem?.Component,
140
- });
141
- } else {
142
- throw new Error("Player factory item is null");
143
- }
144
- }, [playerId, item.id, playerPluginId, screenConfig]);
129
+ muted,
130
+ playerPluginId: playerPluginId,
131
+ screenConfig: screenConfig,
132
+ entry: item,
133
+ },
134
+ });
135
+ }, [playerId, preloadHooks, muted, playerPluginId, screenConfig, item]);
145
136
 
146
137
  React.useEffect(() => {
147
138
  liveImageItem.setMode = setModeDebounced;
148
139
  }, [setModeDebounced, liveImageItem]);
149
140
 
141
+ // todo: no need for it to be react, can be moved into `player.addListener`
150
142
  const { start, end } = React.useMemo(
151
143
  () => getAutoplaySettings(item),
152
144
  [item.id]
153
145
  );
154
146
 
155
- const controller = liveImageItem.getPlayer();
156
147
  const player = usePlayer(playerId);
157
148
 
158
149
  const _assignRoot = (component) => {
@@ -169,7 +160,7 @@ const PlayerLiveImageComponent = (props: Props) => {
169
160
 
170
161
  React.useEffect(() => {
171
162
  // FIXME - find a more elegant way to disable live-image on cell for measurement
172
- if (isMeasurement(item)) {
163
+ if (isMeasurement(item.id)) {
173
164
  return;
174
165
  }
175
166
 
@@ -253,7 +244,13 @@ const PlayerLiveImageComponent = (props: Props) => {
253
244
  }, [item.id]);
254
245
 
255
246
  React.useEffect(() => {
256
- if (isMeasurement(item) || !playerManager) {
247
+ if (isMeasurement(item.id) || !playerManager) {
248
+ return;
249
+ }
250
+
251
+ const controller = liveImageItem.getPlayer();
252
+
253
+ if (!controller) {
257
254
  return;
258
255
  }
259
256
 
@@ -273,7 +270,7 @@ const PlayerLiveImageComponent = (props: Props) => {
273
270
  playerManager.unregisterPlayer(playerId);
274
271
  };
275
272
  }
276
- }, [liveImageItem, playerId, controller, item.id]);
273
+ }, [liveImageItem, playerId, mode, item.id]);
277
274
 
278
275
  const onPositionUpdated = React.useCallback(
279
276
  (data) => {
@@ -338,11 +335,11 @@ const PlayerLiveImageComponent = (props: Props) => {
338
335
  <Player
339
336
  autoplay={false}
340
337
  ref={_assignRoot}
341
- entry={item} // Must be passed first in list
338
+ entry={liveImageItem.processedEntry || item} // Must be passed first in list
342
339
  style={videoStyles}
343
340
  playerId={playerId}
344
341
  muted={muted}
345
- listener={controller?.getListener()}
342
+ listener={liveImageItem.getPlayer()?.getListener()}
346
343
  resizeMode={"cover"}
347
344
  {...platformSpecificProps}
348
345
  />
@@ -120,29 +120,14 @@ describe("PlayerLiveImageComponent", () => {
120
120
  // TODO: implement this test
121
121
  });
122
122
 
123
- it("should register the player for normal item", () => {
123
+ it("should not register the player at mount (lazy creation)", () => {
124
124
  render(
125
125
  <Wrapper>
126
126
  <PlayerLiveImage {...defaultProps} />
127
127
  </Wrapper>
128
128
  );
129
129
 
130
- const isPlayerRegistered = playerManager.isPlayerRegistered(
131
- defaultProps.playerId
132
- );
133
-
134
- expect(isPlayerRegistered).toBe(true);
135
- });
136
-
137
- it("should unregister the player for normal item", () => {
138
- const component = render(
139
- <Wrapper>
140
- <PlayerLiveImage {...defaultProps} />
141
- </Wrapper>
142
- );
143
-
144
- component.unmount();
145
-
130
+ // Player is not registered until playback starts (lazy creation)
146
131
  const isPlayerRegistered = playerManager.isPlayerRegistered(
147
132
  defaultProps.playerId
148
133
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "14.0.18",
3
+ "version": "14.0.19-alpha.1129407856",
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": "14.0.18",
32
- "@applicaster/zapp-react-native-bridge": "14.0.18",
33
- "@applicaster/zapp-react-native-redux": "14.0.18",
34
- "@applicaster/zapp-react-native-utils": "14.0.18",
31
+ "@applicaster/applicaster-types": "14.0.19-alpha.1129407856",
32
+ "@applicaster/zapp-react-native-bridge": "14.0.19-alpha.1129407856",
33
+ "@applicaster/zapp-react-native-redux": "14.0.19-alpha.1129407856",
34
+ "@applicaster/zapp-react-native-utils": "14.0.19-alpha.1129407856",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "url": "^0.11.0",
@@ -1,57 +0,0 @@
1
- import * as React from "react";
2
- import * as R from "ramda";
3
- import { Text, TextStyle, View, ViewStyle } from "react-native";
4
-
5
- import { getLocalizations } from "@applicaster/zapp-react-native-utils/localizationUtils";
6
- import { getAppStylesColor } from "@applicaster/zapp-react-native-utils/stylesUtils";
7
- import { useTheme } from "@applicaster/zapp-react-native-utils/theme";
8
- import { styleKeys } from "@applicaster/zapp-react-native-utils/styleKeysUtils";
9
-
10
- type Props = {
11
- styles: {};
12
- error: {};
13
- remoteConfigurations: { localizations: {} };
14
- };
15
-
16
- const defaultAppStyles = {
17
- loading_error_label: {
18
- color: "#aaa",
19
- },
20
- };
21
-
22
- const textStyles = (appStyles = defaultAppStyles): TextStyle => ({
23
- color: getAppStylesColor("loading_error_label", appStyles),
24
- fontSize: 36,
25
- textAlign: "center",
26
- });
27
-
28
- const errorStyles = ({ backgroundColor }): ViewStyle => ({
29
- flex: 1,
30
- width: "100%",
31
- height: "100%",
32
- justifyContent: "center",
33
- alignItems: "center",
34
- position: "absolute",
35
- zIndex: 100,
36
- backgroundColor,
37
- });
38
-
39
- export function ErrorDisplayComponent({
40
- styles,
41
- remoteConfigurations: { localizations },
42
- }: Props) {
43
- const theme = useTheme();
44
- const backgroundColor = theme?.app_background_color;
45
-
46
- const { stream_error_message = "Cannot play stream" } = getLocalizations({
47
- localizations,
48
- });
49
-
50
- const appStyles = R.prop(styleKeys.style_namespace, styles);
51
-
52
- return (
53
- <View style={errorStyles({ backgroundColor })}>
54
- <Text style={textStyles(appStyles)}>{stream_error_message}</Text>
55
- </View>
56
- );
57
- }
@@ -1,9 +0,0 @@
1
- import * as R from "ramda";
2
-
3
- import { connectToStore } from "@applicaster/zapp-react-native-redux/utils/connectToStore";
4
-
5
- import { ErrorDisplayComponent } from "./ErrorDisplay";
6
-
7
- export const ErrorDisplay = R.compose(
8
- connectToStore(R.pick(["remoteConfigurations"]))
9
- )(ErrorDisplayComponent);