@applicaster/zapp-react-native-ui-components 14.0.20-rc.1 → 14.0.21-alpha.1214400134

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}
@@ -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,21 @@ 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;
62
+ tag: string;
50
63
  };
51
64
 
52
65
  // Disabled because we have only unmute button but no play/pause state anymore
@@ -85,7 +98,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
85
98
 
86
99
  public register = (item: LiveImage): (() => void) => {
87
100
  this.items.push(item);
88
- log_debug(`register: live image ${playerInfo(item.player)}`);
101
+ log_debug(`register: live image ${item.playerId} - ${item.tag}`);
89
102
 
90
103
  // TV only Start playing video once registered
91
104
  if (isTV()) {
@@ -96,15 +109,13 @@ export class LiveImageManager implements PlayerLifecycleListener {
96
109
  };
97
110
 
98
111
  public unregister = (item: LiveImage) => {
99
- log_debug(`unregister: live-image ${playerInfo(item.player)}`);
112
+ log_debug(`unregister: live-image ${item.playerId} - ${item.tag}`);
100
113
 
101
114
  if (this.currentlyPlaying === item) {
102
115
  this.currentlyPlaying = null;
103
116
 
104
117
  log_debug(
105
- `unregister: currently playing live-image was destroyed, ${playerInfo(
106
- item.player
107
- )}`
118
+ `unregister: currently playing live-image was destroyed, ${item.playerId} - ${item.tag}`
108
119
  );
109
120
 
110
121
  // TODO: Maybe start another one
@@ -118,7 +129,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
118
129
  item,
119
130
  playerId: this.currentlyPlaying?.playerId,
120
131
  primaryPlayerId: this.primaryPlayer?.playerId,
121
- entry: item.getPlayer().getEntry(),
132
+ entry: item.getPlayer()?.getEntry(),
122
133
  },
123
134
  });
124
135
  };
@@ -130,9 +141,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
130
141
 
131
142
  public onViewportEnter = (item: LiveImage) => {
132
143
  log_debug(
133
- `onViewportEnter: live-image ${playerInfo(
134
- item.player
135
- )}, primary ${playerInfo(this.primaryPlayer)}`
144
+ `onViewportEnter: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)} - position:${item.positionToString()}`
136
145
  );
137
146
 
138
147
  if (!isTV()) {
@@ -154,9 +163,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
154
163
 
155
164
  public onViewportLeave = (item: LiveImage) => {
156
165
  log_debug(
157
- `onViewportLeave: live-image playerId: ${playerInfo(
158
- item.player
159
- )}, primary ${playerInfo(this.primaryPlayer)}`
166
+ `onViewportLeave: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)} - position:${item.positionToString()}`
160
167
  );
161
168
 
162
169
  this.pauseItem(item);
@@ -190,20 +197,29 @@ export class LiveImageManager implements PlayerLifecycleListener {
190
197
  this.items.find((i) => i.playerId === playerId) || null;
191
198
 
192
199
  private pauseItem = (item: LiveImage) => {
193
- log_debug(`pauseItem: live-image ${playerInfo(item.player)}`);
200
+ log_debug(`pauseItem: live-image ${item.playerId} - ${item.tag}`);
201
+
202
+ if (!item.player) {
203
+ // Player not yet created (e.g. hooks still running) — just reset mode
204
+ item.setMode?.(LiveImageType.Image);
205
+
206
+ if (item === this.currentlyPlaying) {
207
+ this.currentlyPlaying = null;
208
+ }
209
+
210
+ return;
211
+ }
194
212
 
195
213
  if (!item.player.playerState.isReadyToPlay) {
196
214
  log_debug(
197
- `playItem: live-image not ready, will start playback after loading, ${playerInfo(
198
- item.player
199
- )}`
215
+ `pauseItem: live-image not ready, ${item.playerId} - ${item.tag}`
200
216
  );
201
217
  } else {
202
- item.player?.pause();
218
+ item.player.pause();
203
219
  }
204
220
 
205
221
  // Fake close event, because we unmount native view
206
- item.player?.onPlayerClose();
222
+ item.player.onPlayerClose();
207
223
  item.setMode?.(LiveImageType.Image);
208
224
 
209
225
  if (item === this.currentlyPlaying) {
@@ -213,9 +229,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
213
229
 
214
230
  public playLiveImage = (item: LiveImage) => {
215
231
  log_debug(
216
- `playLiveImage: live-image ${playerInfo(
217
- item.player
218
- )}, primary ${playerInfo(this.primaryPlayer)}`
232
+ `playLiveImage: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)}`
219
233
  );
220
234
 
221
235
  if (this.primaryPlayer) {
@@ -223,7 +237,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
223
237
  }
224
238
 
225
239
  if (this.currentlyPlaying) {
226
- if (this.currentlyPlaying?.player?.playerId === item.player.playerId) {
240
+ if (this.currentlyPlaying.playerId === item.playerId) {
227
241
  return;
228
242
  } else {
229
243
  this.pauseItem(this.currentlyPlaying);
@@ -231,18 +245,42 @@ export class LiveImageManager implements PlayerLifecycleListener {
231
245
  }
232
246
 
233
247
  this.currentlyPlaying = item;
234
- item.setMode?.(LiveImageType.Video);
235
248
 
236
- if (item.player.playerState.isReadyToPlay) {
237
- item.player.play();
238
- }
249
+ item
250
+ .prepareForPlayback()
251
+ .then((result) => {
252
+ if (!result) {
253
+ log_error(
254
+ `Failed to prepare live image ${item.playerId} - ${item.tag} for playback: prepareForPlayback returned false`
255
+ );
256
+
257
+ this.currentlyPlaying = null;
258
+ item.setMode?.(LiveImageType.Image);
259
+
260
+ return;
261
+ }
262
+
263
+ // Guard: item might have been replaced while hooks were running
264
+ if (this.currentlyPlaying !== item) return;
265
+
266
+ item.setMode?.(LiveImageType.Video);
267
+
268
+ if (item.player?.playerState.isReadyToPlay) {
269
+ item.player.play();
270
+ }
271
+ })
272
+ .catch((error) => {
273
+ log_error(
274
+ `Failed to prepare live image ${item.playerId} - ${item.tag} for playback: ${error?.message}`
275
+ );
276
+
277
+ this.onLiveImageError(item, error);
278
+ });
239
279
  };
240
280
 
241
281
  public pauseLiveImage = (item: LiveImage) => {
242
282
  log_debug(
243
- `pauseLiveImage: live-image playerId: ${playerInfo(
244
- item.player
245
- )}, primary ${playerInfo(this.primaryPlayer)}`
283
+ `pauseLiveImage: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)}`
246
284
  );
247
285
 
248
286
  this.pauseItem(item);
@@ -258,7 +296,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
258
296
 
259
297
  setUserCellPlayerMutedPreference(true);
260
298
 
261
- this.items.forEach((liveImage) => liveImage.player.mute());
299
+ this.items.forEach((liveImage) => liveImage.player?.mute());
262
300
  };
263
301
 
264
302
  public unmuteAll = () => {
@@ -266,16 +304,36 @@ export class LiveImageManager implements PlayerLifecycleListener {
266
304
 
267
305
  setUserCellPlayerMutedPreference(false);
268
306
 
269
- this.items.forEach((liveImage) => liveImage.player.unmute());
307
+ this.items.forEach((liveImage) => liveImage.player?.unmute());
270
308
  };
271
309
 
272
- public checkPlayerPosition = (item: LiveImage) => {
310
+ public onViewPositionChanged = (item: LiveImage) => {
311
+ log_debug(
312
+ `onViewPositionChanged: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)} - position:${item.positionToString()}`
313
+ );
314
+
315
+ if (!isTV()) {
316
+ // mobile only
317
+ // we have to delay running checkPlayerPosition, because sometimes on fast scrolling we get wrong order onEnter, then onLeave.
318
+ // which could cause select wrong item to play
319
+
320
+ this.cancelCheckPlayerPositionTimeout();
321
+
322
+ this.checkPlayerPositionTimeout = setTimeout(() => {
323
+ this.cancelCheckPlayerPositionTimeout();
324
+
325
+ this.checkPlayerPosition(item);
326
+ }, TIMEOUT_FOR_DELAY_CHECK_PLAYER_POSITION);
327
+ } else {
328
+ this.checkPlayerPosition(item);
329
+ }
330
+ };
331
+
332
+ private checkPlayerPosition = (item: LiveImage) => {
273
333
  this.cancelCheckPlayerPositionTimeout();
274
334
 
275
335
  log_debug(
276
- `checkPlayerPosition: live-image playerId: ${playerInfo(
277
- item.player
278
- )}, primary ${playerInfo(this.primaryPlayer)}`
336
+ `checkPlayerPosition: live-image ${item.playerId} - ${item.tag}, primary ${playerInfo(this.primaryPlayer)}`
279
337
  );
280
338
 
281
339
  const playerItem = this.findNextPlayableItem();
@@ -391,7 +449,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
391
449
  item,
392
450
  playerId: this.currentlyPlaying?.playerId,
393
451
  primaryPlayerId: this.primaryPlayer?.playerId,
394
- entry: item.getPlayer().getEntry(),
452
+ entry: item.getPlayer()?.getEntry(),
395
453
  },
396
454
  });
397
455
  };
@@ -426,7 +484,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
426
484
  item,
427
485
  playerId: this.currentlyPlaying?.playerId,
428
486
  primaryPlayerId: this.primaryPlayer?.playerId,
429
- entry: item.getPlayer().getEntry(),
487
+ entry: item.getPlayer()?.getEntry(),
430
488
  },
431
489
  });
432
490
  };
@@ -448,7 +506,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
448
506
  this.currentlyPlaying = null;
449
507
 
450
508
  log_debug(
451
- `onLiveImageError: currentitem: ${currentItem.playerId} was removed`
509
+ `onLiveImageError: currentItem: ${currentItem.playerId} - ${currentItem.tag} was removed`
452
510
  );
453
511
 
454
512
  // TODO: ...Maybe player some other item
@@ -460,8 +518,8 @@ export class LiveImageManager implements PlayerLifecycleListener {
460
518
  item,
461
519
  error,
462
520
  playerId: currentItem?.playerId,
463
- primaryPlayerId: currentItem?.playerId,
464
- entry: item.getPlayer().getEntry(),
521
+ primaryPlayerId: this.primaryPlayer?.playerId,
522
+ entry: item.getPlayer()?.getEntry(),
465
523
  },
466
524
  });
467
525
  };
@@ -503,10 +561,10 @@ export class LiveImageManager implements PlayerLifecycleListener {
503
561
  LiveImageManager.instance;
504
562
 
505
563
  export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
506
- public player: Player;
507
- public setMode: (type: LiveImageType) => void;
508
- // Will be replaced with rects
564
+ public player: Player | null = null;
565
+ public setMode?: (type: LiveImageType) => void;
509
566
  public isFullyVisible: boolean = false;
567
+ public tag: string;
510
568
  public position: Position = {
511
569
  centerX: 0,
512
570
  centerY: 0,
@@ -516,18 +574,105 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
516
574
  left: 0,
517
575
  };
518
576
 
577
+ positionToString() {
578
+ if (!this.position) {
579
+ return "position not set";
580
+ }
581
+
582
+ const { centerX, centerY, top, bottom, left, right } = this.position;
583
+
584
+ return `centerX: ${centerX.toFixed(2)}, centerY: ${centerY.toFixed(2)}, top: ${top.toFixed(2)}, bottom: ${bottom.toFixed(2)}, left: ${left.toFixed(2)}, right: ${right.toFixed(2)}`;
585
+ }
586
+
519
587
  readonly playerId: string;
520
- readonly component: Component;
588
+ public component: any = null;
589
+
590
+ private factoryConfig: PlayerFactoryConfig;
591
+ public preloadHooks?: PreloadHookConfig[];
592
+ public processedEntry: ZappEntry | null = null;
593
+ private _preparePromise: Promise<boolean> | null = null;
521
594
 
522
595
  constructor(props: LiveImageProps) {
523
- this.player = props.player;
524
596
  this.setMode = props.setMode;
525
- this.playerId = this.player.playerId;
526
- this.component = props.component;
527
- this.player.addListener({ id: "live-image", listener: this });
597
+ this.playerId = props.playerId;
598
+ this.preloadHooks = props.preloadHooks;
599
+ this.factoryConfig = props.factoryConfig;
600
+ this.tag = props.tag || "untagged";
601
+ }
602
+
603
+ async prepareForPlayback(): Promise<boolean> {
604
+ // Already prepared — player exists
605
+ if (this.player) {
606
+ return true;
607
+ }
608
+
609
+ // Deduplicate: if preparation is already in flight, await the same promise
610
+ if (this._preparePromise) {
611
+ return this._preparePromise;
612
+ }
613
+
614
+ this._preparePromise = (async (): Promise<boolean> => {
615
+ // 1. Run hooks if configured
616
+ let entry = this.factoryConfig.entry;
617
+
618
+ if (this.preloadHooks?.length) {
619
+ const result = await executePreloadHooks({
620
+ preloadHooks: this.preloadHooks,
621
+ entry,
622
+ });
623
+
624
+ if (result) {
625
+ this.processedEntry = result;
626
+ entry = result;
627
+ } else {
628
+ return false;
629
+ }
630
+ }
631
+
632
+ // 2. Create the player with the correct entry
633
+ const factoryItem = playerFactory({
634
+ player: this.factoryConfig.player,
635
+ playerId: this.factoryConfig.playerId,
636
+ autoplay: false,
637
+ entry,
638
+ muted: this.factoryConfig.muted,
639
+ playerPluginId: this.factoryConfig.playerPluginId,
640
+ screenConfig: this.factoryConfig.screenConfig,
641
+ playerRole: PlayerRole.Cell,
642
+ });
643
+
644
+ if (!factoryItem) {
645
+ throw new Error("Player factory returned null");
646
+ }
647
+
648
+ this.player = factoryItem.controller;
649
+ this.component = factoryItem.Component;
650
+
651
+ // 3. Register callbacks — player now exists
652
+ this.player.addListener({ id: "live-image", listener: this });
653
+
654
+ return true;
655
+ })()
656
+ .then((result) => {
657
+ this._preparePromise = null;
658
+
659
+ return result;
660
+ })
661
+ .catch((error) => {
662
+ this._preparePromise = null;
663
+
664
+ log_error(
665
+ `prepareForPlayback: live-image ${this.playerId}, error preparing for playback: ${error?.message}`,
666
+ { error }
667
+ );
668
+
669
+ throw error;
670
+ });
671
+
672
+ return this._preparePromise;
528
673
  }
529
674
 
530
- public getPlayer = (): Player => {
675
+ public getPlayer = (): Player | null => {
531
676
  return this.player;
532
677
  };
533
678
 
@@ -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,36 @@ 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
+ tag: item.title?.toString(),
135
+ });
136
+ }, [playerId, preloadHooks, muted, playerPluginId, screenConfig, item]);
145
137
 
146
138
  React.useEffect(() => {
147
139
  liveImageItem.setMode = setModeDebounced;
148
140
  }, [setModeDebounced, liveImageItem]);
149
141
 
142
+ // todo: no need for it to be react, can be moved into `player.addListener`
150
143
  const { start, end } = React.useMemo(
151
144
  () => getAutoplaySettings(item),
152
145
  [item.id]
153
146
  );
154
147
 
155
- const controller = liveImageItem.getPlayer();
156
148
  const player = usePlayer(playerId);
157
149
 
158
150
  const _assignRoot = (component) => {
@@ -169,7 +161,7 @@ const PlayerLiveImageComponent = (props: Props) => {
169
161
 
170
162
  React.useEffect(() => {
171
163
  // FIXME - find a more elegant way to disable live-image on cell for measurement
172
- if (isMeasurement(item)) {
164
+ if (isMeasurement(item.id)) {
173
165
  return;
174
166
  }
175
167
 
@@ -253,7 +245,13 @@ const PlayerLiveImageComponent = (props: Props) => {
253
245
  }, [item.id]);
254
246
 
255
247
  React.useEffect(() => {
256
- if (isMeasurement(item) || !playerManager) {
248
+ if (isMeasurement(item.id) || !playerManager) {
249
+ return;
250
+ }
251
+
252
+ const controller = liveImageItem.getPlayer();
253
+
254
+ if (!controller) {
257
255
  return;
258
256
  }
259
257
 
@@ -273,7 +271,7 @@ const PlayerLiveImageComponent = (props: Props) => {
273
271
  playerManager.unregisterPlayer(playerId);
274
272
  };
275
273
  }
276
- }, [liveImageItem, playerId, controller, item.id]);
274
+ }, [liveImageItem, playerId, mode, item.id]);
277
275
 
278
276
  const onPositionUpdated = React.useCallback(
279
277
  (data) => {
@@ -310,7 +308,7 @@ const PlayerLiveImageComponent = (props: Props) => {
310
308
  ? LiveImageManager.instance.onViewportEnter(liveImageItem)
311
309
  : LiveImageManager.instance.onViewportLeave(liveImageItem);
312
310
  } else {
313
- LiveImageManager.instance.checkPlayerPosition(liveImageItem);
311
+ LiveImageManager.instance.onViewPositionChanged(liveImageItem);
314
312
  }
315
313
  },
316
314
  [liveImageItem, dimensions.height, dimensions.width]
@@ -338,11 +336,11 @@ const PlayerLiveImageComponent = (props: Props) => {
338
336
  <Player
339
337
  autoplay={false}
340
338
  ref={_assignRoot}
341
- entry={item} // Must be passed first in list
339
+ entry={liveImageItem.processedEntry || item} // Must be passed first in list
342
340
  style={videoStyles}
343
341
  playerId={playerId}
344
342
  muted={muted}
345
- listener={controller?.getListener()}
343
+ listener={liveImageItem.getPlayer()?.getListener()}
346
344
  resizeMode={"cover"}
347
345
  {...platformSpecificProps}
348
346
  />
@@ -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.20-rc.1",
3
+ "version": "14.0.21-alpha.1214400134",
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.20-rc.1",
32
- "@applicaster/zapp-react-native-bridge": "14.0.20-rc.1",
33
- "@applicaster/zapp-react-native-redux": "14.0.20-rc.1",
34
- "@applicaster/zapp-react-native-utils": "14.0.20-rc.1",
31
+ "@applicaster/applicaster-types": "14.0.21-alpha.1214400134",
32
+ "@applicaster/zapp-react-native-bridge": "14.0.21-alpha.1214400134",
33
+ "@applicaster/zapp-react-native-redux": "14.0.21-alpha.1214400134",
34
+ "@applicaster/zapp-react-native-utils": "14.0.21-alpha.1214400134",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "url": "^0.11.0",