@applicaster/zapp-react-native-utils 15.0.0-alpha.3514407021 → 15.0.0-alpha.3564377339

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/actionsExecutor/ActionExecutorContext.tsx +3 -6
  2. package/actionsExecutor/feedDecorator.ts +6 -6
  3. package/adsUtils/index.ts +2 -2
  4. package/analyticsUtils/README.md +1 -1
  5. package/appUtils/HooksManager/index.ts +10 -10
  6. package/appUtils/accessibilityManager/__tests__/utils.test.ts +360 -0
  7. package/appUtils/accessibilityManager/const.ts +4 -0
  8. package/appUtils/accessibilityManager/hooks.ts +20 -13
  9. package/appUtils/accessibilityManager/index.ts +28 -1
  10. package/appUtils/accessibilityManager/utils.ts +59 -8
  11. package/appUtils/focusManager/index.ios.ts +8 -2
  12. package/appUtils/focusManagerAux/utils/index.ts +1 -1
  13. package/appUtils/focusManagerAux/utils/utils.ios.ts +60 -3
  14. package/appUtils/keyCodes/keys/keys.web.ts +1 -4
  15. package/appUtils/orientationHelper.ts +2 -4
  16. package/appUtils/platform/platformUtils.ts +117 -18
  17. package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +94 -4
  18. package/appUtils/playerManager/OverlayObserver/utils.ts +32 -20
  19. package/appUtils/playerManager/player.ts +4 -0
  20. package/appUtils/playerManager/playerNative.ts +29 -16
  21. package/appUtils/playerManager/usePlayerState.tsx +14 -2
  22. package/cellUtils/index.ts +32 -0
  23. package/configurationUtils/__tests__/manifestKeyParser.test.ts +26 -26
  24. package/focusManager/aux/index.ts +1 -1
  25. package/manifestUtils/defaultManifestConfigurations/player.js +75 -1
  26. package/manifestUtils/keys.js +21 -0
  27. package/manifestUtils/sharedConfiguration/screenPicker/utils.js +1 -0
  28. package/manifestUtils/tvAction/container/index.js +1 -1
  29. package/package.json +2 -2
  30. package/playerUtils/usePlayerTTS.ts +8 -3
  31. package/pluginUtils/index.ts +4 -0
  32. package/reactHooks/advertising/index.ts +2 -2
  33. package/reactHooks/debugging/__tests__/index.test.js +4 -4
  34. package/reactHooks/device/useMemoizedIsTablet.ts +3 -3
  35. package/reactHooks/feed/__tests__/useEntryScreenId.test.tsx +3 -0
  36. package/reactHooks/feed/__tests__/{useInflatedUrl.test.ts → useInflatedUrl.test.tsx} +62 -7
  37. package/reactHooks/feed/useEntryScreenId.ts +2 -2
  38. package/reactHooks/feed/useInflatedUrl.ts +43 -17
  39. package/reactHooks/flatList/useLoadNextPageIfNeeded.ts +13 -16
  40. package/reactHooks/layout/index.ts +1 -1
  41. package/reactHooks/layout/useDimensions/__tests__/{useDimensions.test.ts → useDimensions.test.tsx} +105 -25
  42. package/reactHooks/layout/useDimensions/useDimensions.ts +2 -2
  43. package/reactHooks/navigation/index.ts +7 -6
  44. package/reactHooks/navigation/useRoute.ts +8 -6
  45. package/reactHooks/player/TVSeekControlller/TVSeekController.ts +27 -10
  46. package/reactHooks/resolvers/useCellResolver.ts +6 -2
  47. package/reactHooks/resolvers/useComponentResolver.ts +8 -2
  48. package/reactHooks/screen/__tests__/useTargetScreenData.test.tsx +10 -2
  49. package/reactHooks/screen/useTargetScreenData.ts +4 -2
  50. package/reactHooks/state/useRivers.ts +1 -1
  51. package/reactHooks/usePluginConfiguration.ts +2 -2
  52. package/testUtils/index.tsx +29 -20
  53. package/utils/__tests__/mapAccum.test.ts +73 -0
  54. package/utils/__tests__/selectors.test.ts +124 -0
  55. package/utils/index.ts +14 -0
  56. package/utils/mapAccum.ts +23 -0
  57. package/utils/path.ts +6 -3
  58. package/utils/pathOr.ts +5 -1
  59. package/utils/selectors.ts +46 -0
  60. package/zappFrameworkUtils/HookCallback/callbackNavigationAction.ts +34 -11
  61. package/zappFrameworkUtils/HookCallback/hookCallbackManifestExtensions.config.js +1 -1
@@ -1,24 +1,75 @@
1
+ import { createLogger } from "../../logger";
2
+
3
+ const { log_error } = createLogger({
4
+ category: "AccessibilityManager",
5
+ subsystem: "AppUtils",
6
+ });
7
+
1
8
  /**
2
9
  * Calculates the reading time for a given text based on word count
3
- * @param text - The text to calculate the reading time for
4
- * @param wordsPerMinute - Words per minute reading speed (default: 160)
10
+ * @param text - The text to calculate the reading time for (string or number)
11
+ * @param wordsPerMinute - Words per minute reading speed (default: 140)
5
12
  * @param minimumPause - Minimum pause time in milliseconds (default: 500)
6
13
  * @param announcementDelay - Additional delay for announcement in milliseconds (default: 700)
7
14
  * @returns The reading time in milliseconds
8
15
  */
9
16
  export function calculateReadingTime(
10
- text: string,
17
+ text: string | number,
11
18
  wordsPerMinute: number = 140,
12
19
  minimumPause: number = 500,
13
20
  announcementDelay: number = 700
14
21
  ): number {
15
- const words = text
16
- .trim()
22
+ if (typeof text !== "string" && typeof text !== "number") {
23
+ log_error(
24
+ `Invalid text input for reading time calculation got: ${
25
+ typeof text === "symbol" ? String(text) : text
26
+ }`
27
+ );
28
+
29
+ return 0;
30
+ }
31
+
32
+ const trimmed = typeof text === "number" ? String(text) : text.trim();
33
+
34
+ if (!trimmed) {
35
+ return 0;
36
+ }
37
+
38
+ const words = trimmed
17
39
  .split(/(?<=\d)(?=[a-zA-Z])|(?<=[a-zA-Z])(?=\d)|[^\w\s]+|\s+/)
18
40
  .filter(Boolean).length;
19
41
 
20
- return (
21
- Math.max(minimumPause, (words / wordsPerMinute) * 60 * 1000) +
22
- announcementDelay
42
+ // Count spaces - multiple consecutive spaces add extra pause time
43
+ const spaceMatches: string[] = trimmed.match(/\s+/g) || [];
44
+
45
+ const totalSpaces = spaceMatches.reduce(
46
+ (sum: number, match: string) => sum + match.length,
47
+ 0
23
48
  );
49
+
50
+ const extraSpaces = Math.max(0, totalSpaces - (words - 1)); // words-1 is normal spacing
51
+
52
+ // Heuristic: punctuation increases TTS duration beyond word-based WPM.
53
+ // Commas typically introduce short pauses, sentence terminators longer ones.
54
+ const commaCount = (trimmed.match(/,/g) || []).length;
55
+ const semicolonCount = (trimmed.match(/;/g) || []).length;
56
+ const colonCount = (trimmed.match(/:/g) || []).length;
57
+ const dashCount = (trimmed.match(/\u2013|\u2014|-/g) || []).length; // – — -
58
+ const sentenceEndCount = (trimmed.match(/[.!?](?!\d)/g) || []).length;
59
+
60
+ const commaPauseMs = 220; // short prosody pause for ","
61
+ const midPauseMs = 260; // for ";", ":", dashes
62
+ const sentenceEndPauseMs = 420; // for ".", "!", "?"
63
+ const extraSpacePauseMs = 50; // per extra space beyond normal spacing
64
+
65
+ const punctuationPause =
66
+ commaCount * commaPauseMs +
67
+ (semicolonCount + colonCount + dashCount) * midPauseMs +
68
+ sentenceEndCount * sentenceEndPauseMs +
69
+ extraSpaces * extraSpacePauseMs;
70
+
71
+ const baseByWordsMs = (words / wordsPerMinute) * 60 * 1000;
72
+ const estimatedMs = Math.max(minimumPause, baseByWordsMs + punctuationPause);
73
+
74
+ return estimatedMs + announcementDelay;
24
75
  }
@@ -188,9 +188,15 @@ export const focusManager = (function () {
188
188
  function register({ id, component }) {
189
189
  const { isGroup = false } = component;
190
190
 
191
- emitRegistered(id);
191
+ if (isGroup) {
192
+ registerGroup(id, component);
193
+ } else {
194
+ registerItem(id, component);
195
+ }
196
+
197
+ const parentId = component?.props?.groupId;
192
198
 
193
- return isGroup ? registerGroup(id, component) : registerItem(id, component);
199
+ emitRegistered({ id, parentId, isGroup });
194
200
  }
195
201
 
196
202
  function unregister(id, { group = false } = {}) {
@@ -102,7 +102,7 @@ export const getNavbarNode = (focusableTree) => {
102
102
 
103
103
  export const waitForContent = (focusableTree) => {
104
104
  const contentHasAnyChildren = (): boolean => {
105
- const countOfChildren = pathOr(
105
+ const countOfChildren = pathOr<number>(
106
106
  0,
107
107
  ["children", "length"],
108
108
  getContentNode(focusableTree)
@@ -1,5 +1,5 @@
1
- import { ReplaySubject } from "rxjs";
2
- import { filter } from "rxjs/operators";
1
+ import { ReplaySubject, Subject } from "rxjs";
2
+ import { filter, switchMap, take } from "rxjs/operators";
3
3
  import { BUTTON_PREFIX } from "@applicaster/zapp-react-native-ui-components/Components/MasterCell/DefaultComponents/tv/TvActionButtons/const";
4
4
  import { focusManager } from "@applicaster/zapp-react-native-utils/appUtils/focusManager/index.ios";
5
5
 
@@ -9,6 +9,15 @@ type RegistrationEvent = {
9
9
  registered: boolean;
10
10
  };
11
11
 
12
+ let focusableViewRegistrationSubject$ = new Subject<{
13
+ id: FocusableID;
14
+ parentId: FocusableID;
15
+ }>();
16
+
17
+ let focusableGroupRegistrationSubject$ = new ReplaySubject<{
18
+ id: FocusableID;
19
+ }>();
20
+
12
21
  const isFocusableButton = (id: Option<FocusableID>): boolean =>
13
22
  id && id.includes?.(BUTTON_PREFIX);
14
23
 
@@ -22,10 +31,58 @@ export const focusableButtonsRegistration$ = (focusableGroupId: string) =>
22
31
  )
23
32
  );
24
33
 
25
- export const emitRegistered = (id: Option<FocusableID>): void => {
34
+ export const resetFocusableRegistration = () => {
35
+ // complete the old subject so subscribers are notified and resources are freed
36
+ if (!focusableViewRegistrationSubject$.closed) {
37
+ focusableViewRegistrationSubject$.complete();
38
+ }
39
+
40
+ if (!focusableGroupRegistrationSubject$.closed) {
41
+ focusableGroupRegistrationSubject$.complete();
42
+ }
43
+
44
+ focusableViewRegistrationSubject$ = new Subject<{
45
+ id: FocusableID;
46
+ parentId: FocusableID;
47
+ }>();
48
+
49
+ focusableGroupRegistrationSubject$ = new ReplaySubject<{
50
+ id: FocusableID;
51
+ }>();
52
+ };
53
+
54
+ export const firstFocusableViewRegistrationFactory = () =>
55
+ focusableViewRegistrationSubject$.pipe(
56
+ take(1), // we care about only first FocusableView registration
57
+ switchMap(({ parentId }) =>
58
+ // start waiting registration of its parent
59
+ focusableGroupRegistrationSubject$.pipe(
60
+ filter(({ id }) => id === parentId)
61
+ )
62
+ ),
63
+ take(1)
64
+ );
65
+
66
+ export const emitRegistered = ({
67
+ id,
68
+ parentId,
69
+ isGroup,
70
+ }: {
71
+ id: Option<FocusableID>;
72
+ parentId: Option<FocusableID>;
73
+ isGroup: boolean;
74
+ }): void => {
26
75
  if (isFocusableButton(id)) {
27
76
  registeredSubject$.next({ id, registered: true });
28
77
  }
78
+
79
+ if (isGroup && id) {
80
+ focusableGroupRegistrationSubject$.next({ id });
81
+ }
82
+
83
+ if (!isGroup && id && parentId) {
84
+ focusableViewRegistrationSubject$.next({ id, parentId });
85
+ }
29
86
  };
30
87
 
31
88
  export const emitUnregistered = (id: Option<FocusableID>): void => {
@@ -10,10 +10,7 @@ import { Platform } from "react-native";
10
10
  * platformKeys[Platform.OS] should only include keys
11
11
  * that are unique to that platform, i.e. Exit: { keyCode: 10182 }
12
12
  */
13
- export const KEYS = Object.assign(
14
- platformKeys["web"],
15
- platformKeys[Platform.OS]
16
- );
13
+ export const KEYS = Object.assign(platformKeys.web, platformKeys[Platform.OS]);
17
14
 
18
15
  export const ARROW_KEYS = [
19
16
  KEYS.ArrowUp,
@@ -1,5 +1,5 @@
1
1
  import * as ReactNative from "react-native";
2
- import { usePickFromState } from "@applicaster/zapp-react-native-redux/hooks";
2
+ import { useAppData } from "@applicaster/zapp-react-native-redux/hooks";
3
3
 
4
4
  import { isTV, platformSelect } from "../reactUtils";
5
5
  import { useIsTablet } from "../reactHooks";
@@ -184,9 +184,7 @@ export const getScreenOrientation = ({
184
184
 
185
185
  export const useGetScreenOrientation = (screenData) => {
186
186
  const isTablet = useIsTablet();
187
-
188
- const { appData } = usePickFromState(["appData"]);
189
- const isTabletPortrait = appData?.isTabletPortrait;
187
+ const { isTabletPortrait } = useAppData();
190
188
 
191
189
  return getScreenOrientation({
192
190
  screenData,
@@ -79,7 +79,7 @@ export class ClosedCaptioningManager {
79
79
  private constructor() {
80
80
  this.initialize();
81
81
 
82
- window["vizioDebug"] = {
82
+ window.vizioDebug = {
83
83
  setCCEnabled: (isCCEnabled: boolean) => {
84
84
  this.ccState$.next(isCCEnabled);
85
85
  },
@@ -170,7 +170,9 @@ export const getClosedCaptionState = () => {
170
170
  */
171
171
  export class TTSManager {
172
172
  private ttsState$ = new BehaviorSubject<boolean>(false);
173
+ private screenReaderEnabled$ = new BehaviorSubject<boolean>(false);
173
174
  private static ttsManagerInstance: TTSManager;
175
+ private samsungListenerId: number | null = null;
174
176
 
175
177
  private constructor() {
176
178
  this.initialize();
@@ -185,23 +187,116 @@ export class TTSManager {
185
187
  }
186
188
 
187
189
  async initialize() {
188
- if (!isVizioPlatform()) return;
190
+ if (isVizioPlatform()) {
191
+ document.addEventListener(
192
+ "VIZIO_TTS_ENABLED",
193
+ () => {
194
+ log_debug("Vizio screen reader enabled");
195
+ this.screenReaderEnabled$.next(true);
196
+ },
197
+ false
198
+ );
189
199
 
190
- document.addEventListener(
191
- "VIZIO_TTS_ENABLED",
192
- () => {
193
- this.ttsState$.next(true);
194
- },
195
- false
196
- );
200
+ document.addEventListener(
201
+ "VIZIO_TTS_DISABLED",
202
+ () => {
203
+ log_debug("Vizio screen reader disabled");
204
+ this.screenReaderEnabled$.next(false);
205
+ },
206
+ false
207
+ );
208
+ }
197
209
 
198
- document.addEventListener(
199
- "VIZIO_TTS_DISABLED",
200
- () => {
201
- this.ttsState$.next(false);
202
- },
203
- false
204
- );
210
+ if (isLgPlatform() && window.webOS?.service) {
211
+ try {
212
+ // https://webostv.developer.lge.com/develop/references/settings-service
213
+ window.webOS.service.request("luna://com.webos.settingsservice", {
214
+ method: "getSystemSettings",
215
+ parameters: {
216
+ category: "option",
217
+ keys: ["audioGuidance"],
218
+ subscribe: true, // Request a subscription to changes
219
+ },
220
+ onSuccess: (response: any) => {
221
+ const isEnabled = response?.settings?.audioGuidance === "on";
222
+
223
+ log_debug("LG Audio Guidance status changed", {
224
+ isEnabled,
225
+ response,
226
+ });
227
+
228
+ this.screenReaderEnabled$.next(isEnabled);
229
+ },
230
+ onFailure: (error: any) => {
231
+ log_debug("webOS settings subscription failed", { error });
232
+ this.screenReaderEnabled$.next(false);
233
+ },
234
+ });
235
+ } catch (error) {
236
+ log_debug("webOS settings service request error", { error });
237
+ // Fallback to false if the service is not available
238
+ this.screenReaderEnabled$.next(false);
239
+ }
240
+ }
241
+
242
+ if (isSamsungPlatform() && typeof window.webapis !== "undefined") {
243
+ try {
244
+ if (
245
+ window.webapis?.tvinfo &&
246
+ typeof window.webapis.tvinfo.getMenuValue === "function" &&
247
+ typeof window.webapis.tvinfo.addCaptionChangeListener === "function"
248
+ ) {
249
+ // Get initial Voice Guide status
250
+ const initialStatus = window.webapis.tvinfo.getMenuValue(
251
+ window.webapis.tvinfo.TvInfoMenuKey.VOICE_GUIDE_KEY
252
+ );
253
+
254
+ const isEnabled =
255
+ initialStatus === window.webapis.tvinfo.TvInfoMenuValue.ON;
256
+
257
+ log_debug("Samsung Voice Guide initial status", {
258
+ isEnabled,
259
+ initialStatus,
260
+ });
261
+
262
+ this.screenReaderEnabled$.next(isEnabled);
263
+
264
+ // Listen for Voice Guide status changes
265
+ const onChange = () => {
266
+ const currentStatus = window.webapis.tvinfo.getMenuValue(
267
+ window.webapis.tvinfo.TvInfoMenuKey.VOICE_GUIDE_KEY
268
+ );
269
+
270
+ const enabled =
271
+ currentStatus === window.webapis.tvinfo.TvInfoMenuValue.ON;
272
+
273
+ log_debug("Samsung Voice Guide status changed", {
274
+ enabled,
275
+ currentStatus,
276
+ });
277
+
278
+ this.screenReaderEnabled$.next(enabled);
279
+ };
280
+
281
+ this.samsungListenerId =
282
+ window.webapis.tvinfo.addCaptionChangeListener(
283
+ window.webapis.tvinfo.TvInfoMenuKey.VOICE_GUIDE_KEY,
284
+ onChange
285
+ );
286
+
287
+ log_debug("Samsung Voice Guide listener registered", {
288
+ listenerId: this.samsungListenerId,
289
+ });
290
+ } else {
291
+ log_debug("Samsung TvInfo API not available");
292
+ this.screenReaderEnabled$.next(false);
293
+ }
294
+ } catch (error) {
295
+ log_debug("Samsung Voice Guide listener error", { error });
296
+ // Fallback to false if the service is not available
297
+ this.screenReaderEnabled$.next(false);
298
+ }
299
+ }
205
300
  }
206
301
 
207
302
  getCurrentState(): boolean {
@@ -212,6 +307,10 @@ export class TTSManager {
212
307
  return this.ttsState$.asObservable();
213
308
  }
214
309
 
310
+ getScreenReaderEnabledAsObservable() {
311
+ return this.screenReaderEnabled$.asObservable();
312
+ }
313
+
215
314
  readText(text: string) {
216
315
  this.ttsState$.next(true);
217
316
 
@@ -229,14 +328,14 @@ export class TTSManager {
229
328
  try {
230
329
  window.webOS.service.request("luna://com.webos.service.tts", {
231
330
  method: "speak",
232
- onFailure(error: any) {
331
+ onFailure: (error: any) => {
233
332
  log_debug("There was a failure setting up webOS TTS service", {
234
333
  error,
235
334
  });
236
335
 
237
336
  this.ttsState$.next(false);
238
337
  },
239
- onSuccess(response: any) {
338
+ onSuccess: (response: any) => {
240
339
  log_debug("webOS TTS service is configured successfully", {
241
340
  response,
242
341
  });
@@ -5,6 +5,7 @@ import { createLogger, utilsLogger } from "../../../logger";
5
5
  import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
6
6
  import {
7
7
  findPluginByIdentifier,
8
+ loadFeedEntry,
8
9
  loadFeedAndPrefetchThumbnailImage,
9
10
  parseTimeToSeconds,
10
11
  retrieveFeedUrl,
@@ -26,6 +27,19 @@ type ChapterMarkerOriginal = {
26
27
  actions: ActionChapter[];
27
28
  };
28
29
 
30
+ export type LiveMetadataEvent = {
31
+ programId: string;
32
+ assetId: string;
33
+ title: string;
34
+ programStartTime: string;
35
+ programEndTime: string;
36
+ };
37
+
38
+ export type LiveMetadataConfig = {
39
+ updateUrl: string;
40
+ updateInterval: number;
41
+ };
42
+
29
43
  export type TitleSummaryEvent = {
30
44
  title: string | number;
31
45
  summary: string | number;
@@ -50,11 +64,13 @@ export type PlayNextState = PlayNextConfig & {
50
64
  export class OverlaysObserver {
51
65
  readonly chapterSubject: BehaviorSubject<ChapterMarkerEvent>;
52
66
  private playNextSubject: BehaviorSubject<PlayNextState>;
67
+ private liveMetadataSubject: BehaviorSubject<LiveMetadataEvent>;
53
68
  private titleSummarySubject: BehaviorSubject<TitleSummaryEvent>;
54
69
  private feedUrl: string;
55
70
  private reloadData: () => void;
56
71
  private updateTitleAndDescription: (data: any) => void;
57
72
  private feedDataInterval: any;
73
+ private liveMetadataUpdateInterval: any;
58
74
  private releasePlayerObserver?: () => void;
59
75
  readonly entry: ZappEntry;
60
76
  private chapterMarkerEvents: ChapterMarkerEvent[];
@@ -66,13 +82,17 @@ export class OverlaysObserver {
66
82
  this.chapterSubject = new BehaviorSubject(null);
67
83
  this.playNextSubject = new BehaviorSubject(null);
68
84
 
85
+ this.player = player;
86
+ this.entry = player.getEntry();
87
+
69
88
  this.titleSummarySubject = new BehaviorSubject<TitleSummaryEvent>({
70
- title: player.getEntry()?.title || "",
71
- summary: player.getEntry()?.summary || "",
89
+ title: this.entry?.title || "",
90
+ summary: this.entry?.summary || "",
72
91
  });
73
92
 
74
- this.entry = player.getEntry();
75
- this.player = player;
93
+ this.liveMetadataSubject = new BehaviorSubject<LiveMetadataEvent>(
94
+ this.entry?.extensions?.liveMetadata || null
95
+ );
76
96
 
77
97
  this.chapterMarkerEvents = this.prepareChapterMarkers();
78
98
  this.releasePlayerObserver = this.subscribeToPlayerEvents();
@@ -80,7 +100,9 @@ export class OverlaysObserver {
80
100
  this.reloadData = () => {};
81
101
  this.updateTitleAndDescription = () => {};
82
102
  this.feedDataInterval = null;
103
+ this.liveMetadataUpdateInterval = null;
83
104
  void this.preparePlayNext();
105
+ void this.prepareLiveMetadata();
84
106
  }
85
107
 
86
108
  private setupFeedDataInterval(interval: number) {
@@ -98,6 +120,13 @@ export class OverlaysObserver {
98
120
  }
99
121
  }
100
122
 
123
+ public clearLiveMetadataUpdateInterval() {
124
+ if (this.liveMetadataUpdateInterval) {
125
+ clearInterval(this.liveMetadataUpdateInterval);
126
+ this.liveMetadataUpdateInterval = null;
127
+ }
128
+ }
129
+
101
130
  public setFeedDataHandlers(
102
131
  feedUrl: string,
103
132
  reloadData: () => void,
@@ -197,6 +226,48 @@ export class OverlaysObserver {
197
226
  }
198
227
  };
199
228
 
229
+ prepareLiveMetadata = async () => {
230
+ if (!this.player?.isLive?.()) {
231
+ log_debug("prepareLiveMetadata: Player is not live. Skipping...");
232
+
233
+ return;
234
+ }
235
+
236
+ const config: LiveMetadataConfig =
237
+ this.entry?.extensions?.liveMetadataConfig;
238
+
239
+ if (!config?.updateUrl || !config?.updateInterval) {
240
+ log_debug(
241
+ "prepareLiveMetadata: Live metadata configuration is not available. Skipping...",
242
+ { config }
243
+ );
244
+
245
+ return;
246
+ }
247
+
248
+ const reloadData = async () => {
249
+ try {
250
+ const entry = await loadFeedEntry(config.updateUrl, this.entry);
251
+
252
+ this.onLiveMetadataUpdated(entry?.extensions?.liveMetadata || null);
253
+ } catch (error) {
254
+ log_error("prepareLiveMetadata: Metadata fetching failed", {
255
+ error,
256
+ config,
257
+ });
258
+ }
259
+ };
260
+
261
+ log_debug("prepareLiveMetadata: Setting up live metadata observer update", {
262
+ config,
263
+ });
264
+
265
+ const interval = Number(config?.updateInterval) || 60;
266
+
267
+ this.clearLiveMetadataUpdateInterval();
268
+ this.liveMetadataUpdateInterval = setInterval(reloadData, interval * 1000);
269
+ };
270
+
200
271
  // TODO: Hack for video end, will be replaced with playlist prev/next in the future
201
272
  getPlayNextEntry = () =>
202
273
  !this.isCanceledByUser ? this.playNextConfig?.entry : null;
@@ -317,9 +388,11 @@ export class OverlaysObserver {
317
388
  onPlayerClose = () => {
318
389
  this.chapterSubject.complete();
319
390
  this.playNextSubject.complete();
391
+ this.liveMetadataSubject.complete();
320
392
  this.titleSummarySubject.complete();
321
393
  this.releasePlayerObserver?.();
322
394
  this.clearFeedDataInterval();
395
+ this.clearLiveMetadataUpdateInterval();
323
396
  this.releasePlayerObserver = null;
324
397
  };
325
398
 
@@ -352,4 +425,21 @@ export class OverlaysObserver {
352
425
  (prev, curr) => prev?.triggerTime === curr?.triggerTime
353
426
  )
354
427
  );
428
+
429
+ private onLiveMetadataUpdated = (liveMetadataEvent: LiveMetadataEvent) => {
430
+ this.liveMetadataSubject.next(liveMetadataEvent);
431
+ };
432
+
433
+ public getLiveMetadataObservable = (): Observable<LiveMetadataEvent> => {
434
+ return this.liveMetadataSubject.pipe(
435
+ distinctUntilChanged(
436
+ (prev, curr) =>
437
+ prev?.programId === curr?.programId && prev?.assetId === curr?.assetId
438
+ )
439
+ );
440
+ };
441
+
442
+ public getLiveMetadataValue = (): LiveMetadataEvent => {
443
+ return this.liveMetadataSubject.value;
444
+ };
355
445
  }
@@ -92,22 +92,26 @@ export const prefetchImage = (playableItem: ZappEntry, config: any = {}) => {
92
92
  }
93
93
  };
94
94
 
95
- export const loadFeedAndPrefetchThumbnailImage = async (
96
- playNextFeedUrl: string,
97
- entry: ZappEntry,
98
- playNextPlugin
99
- ) => {
95
+ /**
96
+ * Loads a feed entry from the given feed URL using the provided Zapp entry as context.
97
+ *
98
+ * @param {string} feedUrl - The URL of the feed to load the entry from.
99
+ * @param {ZappEntry} entry - The Zapp entry to use as context for the request.
100
+ * @returns {Promise<ZappEntry>} A promise that resolves to the loaded Zapp entry.
101
+ * @throws {Error} If the feed loading fails or no entry is found in the response.
102
+ */
103
+ export const loadFeedEntry = async (feedUrl: string, entry: ZappEntry) => {
100
104
  const requestBuilder = new RequestBuilder()
101
105
  .setEntryContext(entry)
102
106
  .setScreenContext({} as ZappRiver)
103
- .setUrl(playNextFeedUrl);
107
+ .setUrl(feedUrl);
104
108
 
105
109
  const responseObject = await requestBuilder.call<ZappEntry>();
106
110
  const responseHelper = new PipesClientResponseHelper(responseObject);
107
111
 
108
112
  if (responseHelper.error) {
109
113
  log_error(
110
- `loadFeedAndPrefetchThumbnailImage: loading failed with error: ${responseHelper.error.message}. Play next observer, will not be executed`,
114
+ `loadFeedEntry: loading failed with error: ${responseHelper.error.message}. Observer will not be executed`,
111
115
  {
112
116
  response: responseHelper.getLogsData(),
113
117
  }
@@ -116,29 +120,37 @@ export const loadFeedAndPrefetchThumbnailImage = async (
116
120
  throw responseHelper.error;
117
121
  } else {
118
122
  log_info(
119
- `loadFeedAndPrefetchThumbnailImage: Play next url was successfully loaded for url: ${playNextFeedUrl}. Prefetching image`,
123
+ `loadFeedEntry: Feed was successfully loaded for url: ${feedUrl}`,
120
124
  responseHelper.getLogsData()
121
125
  );
122
126
 
123
- const playNextEntry = responseHelper.responseData?.entry[0];
127
+ const entry = responseHelper.responseData?.entry[0];
124
128
 
125
- if (!playNextEntry) {
126
- log_error(
127
- "loadFeedAndPrefetchThumbnailImage: Can not retrieve play next entry, feed was loaded but no entry was found",
128
- responseHelper.getLogsData()
129
- );
129
+ if (!entry) {
130
+ const error =
131
+ "loadFeedEntry: Can not retrieve entry, feed was loaded but no entry was found";
130
132
 
131
- throw new Error(
132
- "Can not retrieve play next entry, feed was loaded but no entry was found"
133
- );
134
- }
133
+ log_error(error, responseHelper.getLogsData());
135
134
 
136
- prefetchImage(playNextEntry, playNextPlugin?.configuration);
135
+ throw new Error(error);
136
+ }
137
137
 
138
- return playNextEntry;
138
+ return entry;
139
139
  }
140
140
  };
141
141
 
142
+ export const loadFeedAndPrefetchThumbnailImage = async (
143
+ playNextFeedUrl: string,
144
+ entry: ZappEntry,
145
+ playNextPlugin
146
+ ) => {
147
+ const playNextEntry = await loadFeedEntry(playNextFeedUrl, entry);
148
+
149
+ prefetchImage(playNextEntry, playNextPlugin?.configuration);
150
+
151
+ return playNextEntry;
152
+ };
153
+
142
154
  export const findPluginByIdentifier = (
143
155
  identifier: string,
144
156
  plugins: ZappPlugin[]
@@ -250,6 +250,10 @@ export class Player {
250
250
  return false;
251
251
  }
252
252
 
253
+ if (!Number.isFinite(duration)) {
254
+ return this.getSeekableDuration() > 0;
255
+ }
256
+
253
257
  return duration > 0;
254
258
  };
255
259