@applicaster/zapp-react-dom-app 16.0.0-rc.1 → 16.0.0-rc.100

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.
@@ -8,12 +8,12 @@ import {
8
8
  useCurrentScreenIsHome,
9
9
  useCurrentScreenIsRoot,
10
10
  useCurrentScreenIsTabs,
11
- useIsStandaloneFullscreen,
12
11
  } from "../hooks";
13
12
 
14
13
  import {
15
14
  useCurrentScreenIsHook,
16
15
  useCurrentScreenIsStartupHook,
16
+ useIsStandaloneFullscreen,
17
17
  } from "@applicaster/zapp-react-native-utils/reactHooks/screen";
18
18
 
19
19
  jest.mock("@applicaster/zapp-react-native-utils/appUtils", () => ({
@@ -64,14 +64,14 @@ describe("withBackToTopActionHOC - backToTopAction", () => {
64
64
  });
65
65
  });
66
66
 
67
- it("returns PLATFORM_BACK for startup hook", () => {
67
+ it("returns PLATFORM_TO_BACKGROUND for startup hook", () => {
68
68
  expect.assertions(1);
69
69
 
70
70
  (useCurrentScreenIsStartupHook as jest.Mock).mockReturnValue(true);
71
71
 
72
72
  render(<Wrapped />);
73
73
 
74
- expect(receivedProps.backToTopAction()).toBe("PLATFORM_BACK");
74
+ expect(receivedProps.backToTopAction()).toBe("PLATFORM_TO_BACKGROUND");
75
75
  });
76
76
 
77
77
  it("returns GO_BACK_FROM_HOOK when isHook", () => {
@@ -94,7 +94,7 @@ describe("withBackToTopActionHOC - backToTopAction", () => {
94
94
  expect(receivedProps.backToTopAction()).toBe("GO_HOME");
95
95
  });
96
96
 
97
- it("returns PLATFORM_BACK when root + menu focus + home", () => {
97
+ it("returns PLATFORM_TO_BACKGROUND when root + menu focus + home", () => {
98
98
  expect.assertions(1);
99
99
 
100
100
  (useCurrentScreenIsRoot as jest.Mock).mockReturnValue(true);
@@ -106,7 +106,7 @@ describe("withBackToTopActionHOC - backToTopAction", () => {
106
106
 
107
107
  render(<Wrapped />);
108
108
 
109
- expect(receivedProps.backToTopAction()).toBe("PLATFORM_BACK");
109
+ expect(receivedProps.backToTopAction()).toBe("PLATFORM_TO_BACKGROUND");
110
110
  });
111
111
 
112
112
  it("returns GO_HOME when root + menu focus + NOT home", () => {
@@ -4,7 +4,6 @@ import {
4
4
  useRivers,
5
5
  } from "@applicaster/zapp-react-native-utils/reactHooks/state";
6
6
  import { last } from "@applicaster/zapp-react-native-utils/utils";
7
- import { toBooleanWithDefaultFalse } from "@applicaster/zapp-react-native-utils/booleanUtils";
8
7
 
9
8
  export const useCurrentScreenIsHome = (): boolean => {
10
9
  const navigator = useNavigation();
@@ -33,12 +32,3 @@ export const useCurrentScreenIsTabs = (): boolean => {
33
32
 
34
33
  return river?.type === "tabs_screen";
35
34
  };
36
-
37
- export const useIsStandaloneFullscreen = (): boolean => {
38
- const navigator = useNavigation();
39
-
40
- return toBooleanWithDefaultFalse(
41
- !navigator?.canGoBack() &&
42
- navigator?.screenData?.general?.allow_screen_plugin_presentation
43
- );
44
- };
@@ -8,12 +8,12 @@ import {
8
8
  useCurrentScreenIsHome,
9
9
  useCurrentScreenIsRoot,
10
10
  useCurrentScreenIsTabs,
11
- useIsStandaloneFullscreen,
12
11
  } from "./hooks";
13
12
 
14
13
  import {
15
14
  useCurrentScreenIsHook,
16
15
  useCurrentScreenIsStartupHook,
16
+ useIsStandaloneFullscreen,
17
17
  } from "@applicaster/zapp-react-native-utils/reactHooks/screen";
18
18
 
19
19
  export function withBackToTopActionHOC(Component) {
@@ -38,20 +38,20 @@ export function withBackToTopActionHOC(Component) {
38
38
 
39
39
  const backToTopAction = React.useCallback((): BackToTopAction => {
40
40
  if (isStartUpHook) {
41
- return "PLATFORM_BACK";
41
+ return "PLATFORM_TO_BACKGROUND";
42
42
  }
43
43
 
44
44
  if (isHook) {
45
45
  return "GO_BACK_FROM_HOOK";
46
46
  }
47
47
 
48
- // Handling case where is not top menu visible
48
+ // Handle the case where the top menu is not visible
49
49
  if (isStandaloneFullscreen) {
50
50
  return "GO_HOME";
51
51
  }
52
52
 
53
53
  if (isRoot && focusManager.isFocusOnMenu() && isHome) {
54
- return "PLATFORM_BACK";
54
+ return "PLATFORM_TO_BACKGROUND";
55
55
  }
56
56
 
57
57
  if (isRoot && focusManager.isFocusOnMenu() && !isHome) {
@@ -3,11 +3,6 @@ import * as R from "ramda";
3
3
 
4
4
  import { connectToStore } from "@applicaster/zapp-react-native-redux/utils/connectToStore";
5
5
 
6
- import {
7
- QUICK_BRICK_EVENTS,
8
- sendQuickBrickEvent,
9
- } from "@applicaster/zapp-react-native-bridge/QuickBrick";
10
-
11
6
  import { toBooleanWithDefaultFalse } from "@applicaster/zapp-react-native-utils/booleanUtils";
12
7
 
13
8
  import {
@@ -50,6 +45,8 @@ import {
50
45
  isVizioPlatform,
51
46
  } from "@applicaster/zapp-react-native-utils/reactUtils";
52
47
 
48
+ import { platformToBackground as sendAppToBackground } from "@applicaster/zapp-react-native-utils/appUtils/platform";
49
+
53
50
  import { OnScreenKeyboard } from "@applicaster/zapp-react-dom-ui-components/Components/OnScreenKeyboard";
54
51
  import { KeyboardLongPressManager } from "@applicaster/zapp-react-dom-ui-components/Utils/KeyboardLongPressManager";
55
52
  import { KeyInputHandler } from "@applicaster/zapp-react-native-utils/appUtils/keyInputHandler/KeyInputHandler";
@@ -207,7 +204,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
207
204
  }
208
205
 
209
206
  componentWillUnmount() {
210
- window.removeEventListener("keydown", this.onKeyDownListener);
207
+ window.removeEventListener("keydown", this.onKeyDownListener, true);
211
208
  window.removeEventListener("keyup", this.onKeyUp);
212
209
  document.removeEventListener("wheel", this.onScrollListener);
213
210
 
@@ -242,7 +239,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
242
239
  performExit() {
243
240
  if (isSamsungPlatform()) {
244
241
  this.onConfirmDialogClose();
245
- sendQuickBrickEvent(QUICK_BRICK_EVENTS.MOVE_APP_TO_BACKGROUND);
242
+ sendAppToBackground();
246
243
  }
247
244
 
248
245
  if (isLgPlatform()) {
@@ -256,7 +253,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
256
253
  }
257
254
 
258
255
  handleWebOsBack() {
259
- const systemBack = globalAny?.webOS?.platformBack;
256
+ const systemBack = globalAny?.webOS?.platformToBackground;
260
257
 
261
258
  if (systemBack) {
262
259
  systemBack();
@@ -277,7 +274,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
277
274
  * WebOS behavior depends on the SDK version, WebOS 4 would pull up the launcher menu
278
275
  * following versions would show a native exit prompt
279
276
  */
280
- platformBack() {
277
+ platformToBackground() {
281
278
  try {
282
279
  // TODO: temporary hack until this code is refactored
283
280
  if (isSamsungPlatform() || isVizioPlatform()) {
@@ -294,9 +291,11 @@ class InteractionManagerClass extends React.Component<Props, State> {
294
291
  }
295
292
  } else if (isLgPlatform()) {
296
293
  this.handleWebOsBack();
294
+ } else {
295
+ sendAppToBackground();
297
296
  }
298
297
  } catch (e) {
299
- log_warning("Failed to execute platformBack", e);
298
+ log_warning("Failed to execute platformToBackground", e);
300
299
  }
301
300
  }
302
301
 
@@ -423,12 +422,22 @@ class InteractionManagerClass extends React.Component<Props, State> {
423
422
 
424
423
  const action = backToTopAction();
425
424
 
426
- switch (displayState) {
425
+ const effectiveDisplayState = this.getEffectiveDisplayState();
426
+
427
+ // TEMP(native-controls): remove after device verification
428
+ if (effectiveDisplayState !== displayState) {
429
+ log_debug("[NativeControls] Back handled as PLAYER", {
430
+ displayState,
431
+ action,
432
+ });
433
+ }
434
+
435
+ switch (effectiveDisplayState) {
427
436
  case DISPLAY_STATES.DEFAULT:
428
- if (action === "PLATFORM_BACK") {
437
+ if (action === "PLATFORM_TO_BACKGROUND") {
429
438
  log_info(action);
430
439
 
431
- this.platformBack();
440
+ this.platformToBackground();
432
441
  }
433
442
 
434
443
  if (action === "GO_HOME") {
@@ -477,11 +486,11 @@ class InteractionManagerClass extends React.Component<Props, State> {
477
486
  } else if (navigator.canGoBack()) {
478
487
  navigator.goBack();
479
488
  } else if (this.isHomeScreen()) {
480
- this.platformBack();
489
+ this.platformToBackground();
481
490
  } else if (navigator.currentRoute.includes("hook")) {
482
491
  // If code reaches this block navigator cant go back, it is not home,
483
492
  // and this is a login plugin that was pushed on top of the home
484
- // Treat it like a home screen and run platformBack()
493
+ // Treat it like a home screen and run platformToBackground()
485
494
  const entryData = navigator?.data?.entry;
486
495
  const isHookInHomescreen = entryData?.payload?.home;
487
496
 
@@ -491,7 +500,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
491
500
  : entryData?.hookPlugin?.module?.isFlowBlocker;
492
501
 
493
502
  if (isHookInHomescreen && isHookFlowBlocker) {
494
- this.platformBack();
503
+ this.platformToBackground();
495
504
  } else {
496
505
  navigator.goBack(false, true);
497
506
  }
@@ -523,8 +532,42 @@ class InteractionManagerClass extends React.Component<Props, State> {
523
532
  this.onBackPress(event);
524
533
  }
525
534
 
535
+ // True only while the active (primary) player renders its own controls.
536
+ isNativePlayerControlsEnabled = () =>
537
+ !!playerManager.getInstanceController?.()?.supportsNativeControls?.();
538
+
539
+ // PLAYER/HUD are set by QuickBrick's TV transport controls, which are not
540
+ // mounted when the player renders native controls — the state would stay
541
+ // DEFAULT and keys would reach the screen behind the player (Enter re-opens it).
542
+ // The play-next overlay is the exception: it is QuickBrick UI, so the real
543
+ // state applies and Enter presses the focused overlay button.
544
+ getEffectiveDisplayState = () =>
545
+ this.isNativePlayerControlsEnabled() && !this.isPlayNextOverlayVisible()
546
+ ? DISPLAY_STATES.PLAYER
547
+ : this.props.displayState;
548
+
549
+ // True when the player closed something of its own instead.
550
+ playerConsumesBack = () =>
551
+ !!playerManager.getInstanceController?.()?.handlesBackKey?.();
552
+
553
+ // While the play-next overlay is up it owns the remote: its buttons are
554
+ // QuickBrick focusables, so the keys must not go to the player's own controls.
555
+ isPlayNextOverlayVisible = () =>
556
+ !!this.props.navigator?.getPlayNextOverlay?.();
557
+
558
+ shouldDeferPlaybackNavigationToNativeControls = (event: KeyboardEvent) => {
559
+ return (
560
+ this.isNativePlayerControlsEnabled() &&
561
+ !this.isPlayNextOverlayVisible() &&
562
+ (keyCode(event).matchesAny(...ARROW_KEYS) ||
563
+ keyCode(event).matches(KEYS.Enter) ||
564
+ (__DEV__ && keyCode(event).matches(KEYS.X)))
565
+ );
566
+ };
567
+
526
568
  onKeyDown(event) {
527
- const { displayState, setDisplayState, navigator, xray } = this.props;
569
+ const { setDisplayState, navigator, xray } = this.props;
570
+ const displayState = this.getEffectiveDisplayState();
528
571
  const { isDialogVisible } = confirmationDialogStore.getState();
529
572
  const { isKeyboardVisible } = this.state;
530
573
 
@@ -542,6 +585,12 @@ class InteractionManagerClass extends React.Component<Props, State> {
542
585
  }
543
586
 
544
587
  if (keyCode(event).matchesAny(KEYS.Back, KEYS.Escape, KEYS.EscapeDevice)) {
588
+ // A player with native controls can have something of its own open that
589
+ // Back should close first (e.g. an ad click-through overlay).
590
+ if (this.isNativePlayerControlsEnabled() && this.playerConsumesBack()) {
591
+ return;
592
+ }
593
+
545
594
  this.onBackPress(event);
546
595
 
547
596
  return;
@@ -554,6 +603,21 @@ class InteractionManagerClass extends React.Component<Props, State> {
554
603
  // Only handle repeat events for arrow keys
555
604
  if (event.repeat && !keyCode(event).matchesAny(...ARROW_KEYS)) return;
556
605
 
606
+ if (
607
+ !isDialogVisible &&
608
+ this.shouldDeferPlaybackNavigationToNativeControls(event)
609
+ ) {
610
+ // The player's own spatial navigation handles these keys.
611
+ // TEMP(native-controls): remove after device verification
612
+ log_debug("[NativeControls] key deferred to player", {
613
+ key: event.key,
614
+ displayState,
615
+ activeElement: document.activeElement?.className,
616
+ });
617
+
618
+ return;
619
+ }
620
+
557
621
  /**
558
622
  When the player is displayed, we listen to Native Play / Pause / Stop
559
623
  buttons. Any other key press will display the transport controls
@@ -570,7 +634,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
570
634
  const { playing } = playerManager.getState() || {};
571
635
 
572
636
  if (!playing) {
573
- return playerManager.togglePlayPause();
637
+ return playerManager.getInstanceController()?.togglePlayPause();
574
638
  }
575
639
  }
576
640
 
@@ -640,7 +704,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
640
704
  return;
641
705
  }
642
706
 
643
- this.platformBack();
707
+ this.platformToBackground();
644
708
  }
645
709
 
646
710
  if (keyCode(event).matchesAny(...ARROW_KEYS)) {
@@ -1,7 +1,8 @@
1
1
  import * as React from "react";
2
2
  import { createPortal } from "react-dom";
3
3
 
4
- import { Background } from "@applicaster/zapp-react-dom-ui-components/Components/Background";
4
+ import { BackgroundImage } from "@applicaster/zapp-react-native-ui-components/Components/BackgroundImage";
5
+
5
6
  import { ConfirmDialog } from "@applicaster/zapp-react-dom-ui-components/Components/ConfirmDialog";
6
7
  import { NavWrapper } from "@applicaster/zapp-react-dom-ui-components/Components/NavWrapper";
7
8
  import { useNavigation } from "@applicaster/zapp-react-native-utils/reactHooks/navigation";
@@ -25,7 +26,7 @@ export function Layout({ children }: Props) {
25
26
  return (
26
27
  <>
27
28
  <LayoutComponent
28
- Components={{ Background, NavBar: NavWrapper }}
29
+ Components={{ Background: BackgroundImage, NavBar: NavWrapper }}
29
30
  ComponentsExtraProps={ComponentsExtraProps}
30
31
  >
31
32
  {children}
@@ -64,6 +64,10 @@ export const PLATFORMS = {
64
64
  android: "Android",
65
65
  mobile: "Mobile",
66
66
  vizio: "VIZIO",
67
+ vidaa: "VIDAA",
68
+ // VIDAA U3 and earlier (2019 and earlier) carry no "VIDAA" token at all —
69
+ // "Hisense" is the only signal on those models. See isVidaaPlatform().
70
+ hisense: "Hisense",
67
71
  smartcast: "SmartCast",
68
72
  conjure: "Conjure",
69
73
  };
@@ -74,14 +78,42 @@ export const PLATFORMS = {
74
78
 
75
79
  /**
76
80
  * Simple way of identifying if we have access to the tizen APIs
81
+ *
82
+ * NOTE: evaluated once, when this module is first imported. The native API
83
+ * scripts are injected asynchronously by loader.js, so this can be `false`
84
+ * simply because the bundle executed first. Prefer `hasTizenAPIs()` for
85
+ * runtime checks; this constant is retained for the DEFAULT values below,
86
+ * which are themselves import-time fallbacks.
77
87
  */
78
88
  export const hasTizen = typeof window?.tizen !== "undefined";
79
89
 
80
90
  /**
81
91
  * Simple way of identifying if we have access to the webOS
92
+ *
93
+ * NOTE: same import-time caveat as `hasTizen` — prefer `hasWebOSAPIs()`.
82
94
  */
83
95
  export const hasWebOS = typeof window?.webOS !== "undefined";
84
96
 
97
+ /**
98
+ * Checks, at call time, whether the Tizen APIs are available.
99
+ *
100
+ * loader.js injects `$WEBAPIS/webapis/webapis.js` via a dynamically-created
101
+ * <script>, which is async by default, so `window.tizen` may not exist yet when
102
+ * this module is imported. Checking on each call avoids latching a stale
103
+ * `false` for the lifetime of the app.
104
+ *
105
+ * @returns {boolean} True if the Tizen APIs are available.
106
+ */
107
+ export const hasTizenAPIs = () => typeof window?.tizen !== "undefined";
108
+
109
+ /**
110
+ * Checks, at call time, whether the webOS APIs are available.
111
+ * See `hasTizenAPIs()` for why this is a function rather than a constant.
112
+ *
113
+ * @returns {boolean} True if the webOS APIs are available.
114
+ */
115
+ export const hasWebOSAPIs = () => typeof window?.webOS !== "undefined";
116
+
85
117
  /**
86
118
  * Simple way of identifying if we are on a web based platform
87
119
  */
@@ -3,17 +3,11 @@
3
3
  /* eslint-disable no-console */
4
4
 
5
5
  import { Platform } from "react-native";
6
- import {
7
- DEFAULT,
8
- desiredKeysMap,
9
- hasTizen,
10
- hasWebOS,
11
- PLATFORMS,
12
- } from "./const";
6
+ import { desiredKeysMap, hasTizenAPIs, hasWebOSAPIs, PLATFORMS } from "./const";
13
7
 
14
8
  /**
15
9
  * @typedef {"tv" | "web" | "other"} DeviceType
16
- * @typedef {"samsung_tv" | "lg_tv" | unknown} CustomPlatformTypes
10
+ * @typedef {"samsung_tv" | "lg_tv" | "vidaa" | unknown} CustomPlatformTypes
17
11
  */
18
12
 
19
13
  /**
@@ -27,7 +21,7 @@ export const getUserAgent = () =>
27
21
  * Checks if the device is HD (1280 width).
28
22
  * @returns {boolean} True if the device is HD, false otherwise.
29
23
  */
30
- export const isHD = () => window.innerWidth === 1280;
24
+ export const isHD = () => window?.innerWidth === 1280;
31
25
 
32
26
  /**
33
27
  * Checks if the platform is LG.
@@ -40,7 +34,7 @@ export const isLgPlatform = () => {
40
34
  return false;
41
35
  }
42
36
 
43
- return hasWebOS || userAgent.includes(PLATFORMS.webos);
37
+ return hasWebOSAPIs() || userAgent.includes(PLATFORMS.webos);
44
38
  };
45
39
 
46
40
  /**
@@ -55,7 +49,7 @@ export const isSamsungPlatform = () => {
55
49
  }
56
50
 
57
51
  return (
58
- hasTizen ||
52
+ hasTizenAPIs() ||
59
53
  userAgent.includes(PLATFORMS.samsung) ||
60
54
  userAgent.includes(PLATFORMS.tizen)
61
55
  );
@@ -68,6 +62,10 @@ export const isSamsungPlatform = () => {
68
62
  export const isVizioPlatform = () => {
69
63
  const userAgent = getUserAgent();
70
64
 
65
+ if (!userAgent) {
66
+ return false;
67
+ }
68
+
71
69
  return (
72
70
  userAgent.includes(PLATFORMS.vizio) ||
73
71
  userAgent.includes(PLATFORMS.smartcast) ||
@@ -75,17 +73,241 @@ export const isVizioPlatform = () => {
75
73
  );
76
74
  };
77
75
 
76
+ /**
77
+ * Checks if the platform is VIDAA.
78
+ *
79
+ * Two tokens are needed because the userAgent format changed with VIDAA U4:
80
+ * - 2020 and later (U4/U5/U6/U7): always carries a "VIDAA" token, e.g.
81
+ * `Model/Hisense-MT9602 VIDAA/5.0(Hisense;SmartTV;50A53FSV;...)`
82
+ * - 2019 and earlier (U3 and prior): carries NO "VIDAA" token at all — the only
83
+ * signal is "Hisense", e.g. `Model/Hisense-MSD6586 (Hisense;HE50N3050UWTS;...)`
84
+ *
85
+ * Matching "VIDAA" alone silently misses every U3-and-earlier device, which the
86
+ * VIDAA JS SDK still supports (getDeviceID/getBrand/config are all U3U4U5+).
87
+ *
88
+ * Caveat: "Hisense" also appears in the userAgent of Hisense-branded Android TV
89
+ * models, which are not VIDAA. That is tolerable here because this is only
90
+ * consulted on a VIDAA-targeted build (see getDeviceType), but it is why the
91
+ * vendor docs warn against using "Hisense" as a general-purpose signal. A
92
+ * stricter check would test for the native `Hisense_GetDeviceID` global, which
93
+ * VIDAA exposes from U3 onwards and Android TV does not.
94
+ *
95
+ * @see https://partner-doc.vidaa.com/vdocs/development/open-capacity.html
96
+ * @returns {boolean} True if the platform is VIDAA, false otherwise.
97
+ */
98
+ export const isVidaaPlatform = () => {
99
+ const userAgent = getUserAgent();
100
+
101
+ if (!userAgent) {
102
+ return false;
103
+ }
104
+
105
+ return (
106
+ userAgent.includes(PLATFORMS.vidaa) || userAgent.includes(PLATFORMS.hisense)
107
+ );
108
+ };
109
+
78
110
  /**
79
111
  * Checks if the Vizio APIs are available.
80
112
  * @returns {boolean} True if the Vizio APIs are available, false otherwise.
81
113
  */
82
114
  export const hasVizioAPIs = () => {
83
115
  return (
84
- typeof window.VIZIO !== "undefined" &&
116
+ typeof window?.VIZIO !== "undefined" &&
85
117
  window?.applicaster?.vizioLibraryDidLoad
86
118
  );
87
119
  };
88
120
 
121
+ /**
122
+ * Checks if the VIDAA JS SDK has finished loading.
123
+ * @returns {boolean} True if the `vidaatv` object is available.
124
+ */
125
+ export const hasVidaaAPIs = () => typeof window?.vidaatv !== "undefined";
126
+
127
+ /** How long to wait for a native device API callback before giving up, in ms. */
128
+ const NATIVE_API_TIMEOUT_MS = 3000;
129
+
130
+ /**
131
+ * Wraps a promise so that it *always* settles, and always resolves.
132
+ *
133
+ * The native device APIs are callback-based and wrapped in promises that can
134
+ * fail in two ways this guards against:
135
+ *
136
+ * 1. **Rejection.** Device info and connection info are requested together with
137
+ * `Promise.all`, which rejects as a whole on the first failure — so a routine
138
+ * "no network" error from the connection lookup would discard the model and
139
+ * OS version that were retrieved successfully.
140
+ * 2. **Never settling.** `webOS.deviceInfo(cb)`, `tizen.systeminfo(cb)` and
141
+ * `VIZIO.getFirmwareVersion(cb)` resolve only from their callbacks. If a
142
+ * callback never fires the promise hangs forever, and because `getDeviceData`
143
+ * is awaited during startup, app boot hangs with it.
144
+ *
145
+ * @param {Promise} promise The promise to guard.
146
+ * @param {object} options
147
+ * @param {string} options.label Name used in warnings.
148
+ * @param {number} [options.timeout=NATIVE_API_TIMEOUT_MS] Milliseconds to wait.
149
+ * @param {*} [options.fallback=null] Value to resolve with on failure/timeout.
150
+ * @returns {Promise<*>} Resolves with the value, or `fallback`.
151
+ */
152
+ const settleWithin = (
153
+ promise,
154
+ { label, timeout = NATIVE_API_TIMEOUT_MS, fallback = null } = {}
155
+ ) => {
156
+ return new Promise((resolve) => {
157
+ let settled = false;
158
+
159
+ const finish = (value) => {
160
+ if (settled) {
161
+ return;
162
+ }
163
+
164
+ settled = true;
165
+ clearTimeout(timeoutId);
166
+ resolve(value);
167
+ };
168
+
169
+ const timeoutId = setTimeout(() => {
170
+ console.warn(`${label}: timed out after ${timeout}ms`);
171
+ finish(fallback);
172
+ }, timeout);
173
+
174
+ Promise.resolve(promise)
175
+ .then(finish)
176
+ .catch((error) => {
177
+ console.warn(`${label}: failed`, error);
178
+ finish(fallback);
179
+ });
180
+ });
181
+ };
182
+
183
+ /** How long to wait for the VIDAA SDK before giving up, in ms. */
184
+ const VIDAA_SDK_TIMEOUT_MS = 3000;
185
+
186
+ /** How often to re-check for the VIDAA SDK while waiting, in ms. */
187
+ const VIDAA_SDK_POLL_MS = 50;
188
+
189
+ /**
190
+ * The value the VIDAA SDK returns for APIs the current platform version does
191
+ * not implement, per the vendor docs ("For features Not Supported by the
192
+ * platform, the interface returns `Not Supported`"). Treated as absent.
193
+ */
194
+ const VIDAA_NOT_SUPPORTED = "Not Supported";
195
+
196
+ /**
197
+ * Waits for the VIDAA JS SDK to become available on `window`.
198
+ *
199
+ * The SDK is injected by loader.js as a dynamically-created <script>, which is
200
+ * async by default and fetched from the VIDAA CDN. The app bundle is served
201
+ * locally and can therefore boot before that request completes, so reading
202
+ * `window.vidaatv` directly at startup is a race. This resolves as soon as the
203
+ * SDK appears, or with `null` once the timeout elapses.
204
+ *
205
+ * Resolves rather than rejects on timeout so a missing SDK degrades to partial
206
+ * device data instead of failing the whole `getDeviceData` call.
207
+ *
208
+ * @param {number} [timeout=VIDAA_SDK_TIMEOUT_MS] Milliseconds to wait.
209
+ * @returns {Promise<object|null>} The `vidaatv` object, or null if it never arrived.
210
+ */
211
+ export const waitForVidaaAPIs = (timeout = VIDAA_SDK_TIMEOUT_MS) => {
212
+ return new Promise((resolve) => {
213
+ if (hasVidaaAPIs()) {
214
+ resolve(window.vidaatv);
215
+
216
+ return;
217
+ }
218
+
219
+ const startedAt = Date.now();
220
+
221
+ const intervalId = setInterval(() => {
222
+ if (hasVidaaAPIs()) {
223
+ clearInterval(intervalId);
224
+ resolve(window.vidaatv);
225
+
226
+ return;
227
+ }
228
+
229
+ if (Date.now() - startedAt >= timeout) {
230
+ clearInterval(intervalId);
231
+
232
+ console.warn(
233
+ `waitForVidaaAPIs: VIDAA SDK did not load within ${timeout}ms`
234
+ );
235
+
236
+ resolve(null);
237
+ }
238
+ }, VIDAA_SDK_POLL_MS);
239
+ });
240
+ };
241
+
242
+ /**
243
+ * Reads a single value from the VIDAA SDK, tolerating APIs that the current
244
+ * platform version does not implement.
245
+ *
246
+ * Every accessor is optional: older platforms omit the method entirely, and
247
+ * supported-but-unavailable values come back as the string "Not Supported".
248
+ *
249
+ * @param {object} sdk The `vidaatv` object.
250
+ * @param {string} method The accessor name, e.g. "getModelName".
251
+ * @returns {string|null} The value, or null when unavailable.
252
+ */
253
+ const readVidaaValue = (sdk, method) => {
254
+ try {
255
+ if (typeof sdk?.[method] !== "function") {
256
+ return null;
257
+ }
258
+
259
+ const value = sdk[method]();
260
+
261
+ return value && value !== VIDAA_NOT_SUPPORTED ? value : null;
262
+ } catch (error) {
263
+ console.warn(`readVidaaValue: ${method} threw`, error);
264
+
265
+ return null;
266
+ }
267
+ };
268
+
269
+ /**
270
+ * Gets device info from the VIDAA JS SDK.
271
+ *
272
+ * Field availability varies by VIDAA version, so each value is read
273
+ * defensively and omitted when unavailable:
274
+ * - getBrand / getModelName / getFirmWareVersion / getCountryCode are U3+
275
+ * - getOSVersion / getNetType are U4+, hence the firmware fallback for
276
+ * osVersion so pre-2020 models still report something
277
+ *
278
+ * @returns {Promise<object|null>} Device info, or null if the SDK never loaded.
279
+ */
280
+ export const getVidaaInfo = async () => {
281
+ const sdk = await waitForVidaaAPIs();
282
+
283
+ if (!sdk) {
284
+ return null;
285
+ }
286
+
287
+ const modelName = readVidaaValue(sdk, "getModelName");
288
+ const brand = readVidaaValue(sdk, "getBrand");
289
+ const networkType = readVidaaValue(sdk, "getNetType");
290
+ const countryCode = readVidaaValue(sdk, "getCountryCode");
291
+
292
+ // getOSVersion is U4+; fall back to the firmware version, which is U3+.
293
+ const osVersion =
294
+ readVidaaValue(sdk, "getOSVersion") ||
295
+ readVidaaValue(sdk, "getFirmWareVersion");
296
+
297
+ // Human-readable name for the `deviceName` context key, e.g.
298
+ // "Hisense 50A53FSV". Falls back to whichever half is available.
299
+ const deviceName = [brand, modelName].filter(Boolean).join(" ");
300
+
301
+ return {
302
+ ...(modelName ? { modelName } : {}),
303
+ ...(brand ? { deviceMake: brand } : {}),
304
+ ...(deviceName ? { deviceName } : {}),
305
+ ...(osVersion ? { osVersion } : {}),
306
+ ...(networkType ? { networkType } : {}),
307
+ ...(countryCode ? { countryCode } : {}),
308
+ };
309
+ };
310
+
89
311
  /**
90
312
  * Determines the device type based on the platform.
91
313
  * @returns {DeviceType} The device type ("tv", "web", or "other").
@@ -99,6 +321,10 @@ export const getDeviceType = () => {
99
321
  return isSamsungPlatform() ? "tv" : "web";
100
322
  case "lg_tv":
101
323
  return isLgPlatform() ? "tv" : "web";
324
+ case "vizio":
325
+ return isVizioPlatform() ? "tv" : "web";
326
+ case "vidaa":
327
+ return isVidaaPlatform() ? "tv" : "web";
102
328
  default:
103
329
  return "other";
104
330
  }
@@ -151,7 +377,7 @@ export const getWebOSConnectionInfo = () => {
151
377
  */
152
378
  export const getTizenConnectionInfo = () => {
153
379
  return new Promise((resolve, reject) => {
154
- if (!isSamsungPlatform) {
380
+ if (!isSamsungPlatform()) {
155
381
  resolve(null);
156
382
 
157
383
  return;
@@ -175,6 +401,36 @@ export const getTizenConnectionInfo = () => {
175
401
  });
176
402
  };
177
403
 
404
+ /**
405
+ * Retrieves the firmware version from the Vizio companion library.
406
+ *
407
+ * `VIZIO.getFirmwareVersion` is callback-based. It was previously called
408
+ * without being awaited, assigning onto `deviceData` after `getDeviceData` had
409
+ * already returned — by which point the caller had spread the object into
410
+ * session storage, so the value was silently dropped whenever the callback did
411
+ * not fire synchronously. Wrapping it in a promise lets it be awaited like
412
+ * every other platform's device info.
413
+ *
414
+ * @returns {Promise<string|null>} The firmware version, or null if unavailable.
415
+ */
416
+ export const getVizioFirmwareVersion = () => {
417
+ return new Promise((resolve, reject) => {
418
+ if (!hasVizioAPIs()) {
419
+ resolve(null);
420
+
421
+ return;
422
+ }
423
+
424
+ try {
425
+ window.VIZIO.getFirmwareVersion((firmwareVersion) => {
426
+ resolve(firmwareVersion);
427
+ });
428
+ } catch (error) {
429
+ reject(error);
430
+ }
431
+ });
432
+ };
433
+
178
434
  /**
179
435
  * Gets all of the webOS device info available to us via webOS.deviceInfo
180
436
  */
@@ -190,7 +446,12 @@ export const getWebOSInfo = () => {
190
446
  if (info) {
191
447
  resolve({
192
448
  ...info,
193
- name: `${info.modelName} - ${info.version}`,
449
+ // `deviceName` is a persisted context key; the previous `name` was
450
+ // absent from desiredKeysMap, so it was computed and dropped. Format
451
+ // is "<make> <model>", matching Samsung/Vizio/VIDAA. The old string
452
+ // was `${info.modelName} - ${info.version}`, which omitted the make
453
+ // and embedded a version already reported as osVersion.
454
+ deviceName: `LG ${info.modelName}`,
194
455
  osVersion: info.sdkVersion, // info.sdkVersion is the OS version, info.version is software version
195
456
  });
196
457
  } else {
@@ -217,16 +478,22 @@ export const getTizenInfo = () => {
217
478
  "http://tizen.org/system/model_name"
218
479
  );
219
480
 
220
- const name = `${DEFAULT.make} ${modelName}`;
481
+ // Previously `${DEFAULT.make} ${modelName}` — DEFAULT has no `make` key
482
+ // (it is `deviceMake`), so this interpolated the literal string
483
+ // "undefined". It went unnoticed because the value was named `name`,
484
+ // which is absent from desiredKeysMap and therefore never persisted.
485
+ const deviceName = `Samsung ${modelName}`;
221
486
 
222
487
  const osVersion = tizen.systeminfo.getCapability(
223
488
  "http://tizen.org/feature/platform.version"
224
489
  );
225
490
 
226
- if (modelName && name && osVersion) {
491
+ // `deviceName` is derived from modelName, so checking it here would be
492
+ // redundant — it is truthy whenever modelName is.
493
+ if (modelName && osVersion) {
227
494
  resolve({
228
495
  modelName,
229
- name,
496
+ deviceName,
230
497
  osVersion,
231
498
  });
232
499
  } else {
@@ -265,9 +532,13 @@ export const getDeviceData = async () => {
265
532
  };
266
533
 
267
534
  if (isLgPlatform()) {
535
+ // Guarded individually so a failure in one lookup does not discard the
536
+ // other — see settleWithin.
268
537
  const [webOSInfo, webOSConnectionInfo] = await Promise.all([
269
- getWebOSInfo(),
270
- getWebOSConnectionInfo(),
538
+ settleWithin(getWebOSInfo(), { label: "getWebOSInfo" }),
539
+ settleWithin(getWebOSConnectionInfo(), {
540
+ label: "getWebOSConnectionInfo",
541
+ }),
271
542
  ]);
272
543
 
273
544
  deviceData = {
@@ -280,8 +551,10 @@ export const getDeviceData = async () => {
280
551
  };
281
552
  } else if (isSamsungPlatform()) {
282
553
  const [tizenInfo, tizenConnectionInfo] = await Promise.all([
283
- getTizenInfo(),
284
- getTizenConnectionInfo(),
554
+ settleWithin(getTizenInfo(), { label: "getTizenInfo" }),
555
+ settleWithin(getTizenConnectionInfo(), {
556
+ label: "getTizenConnectionInfo",
557
+ }),
285
558
  ]);
286
559
 
287
560
  deviceData = {
@@ -297,17 +570,39 @@ export const getDeviceData = async () => {
297
570
  ...deviceData,
298
571
  platform: "vizio",
299
572
  deviceMake: "Vizio",
300
- deviceType: "tv",
573
+ deviceType: getDeviceType(),
301
574
  ...deviceDimensions,
302
575
  };
303
576
 
304
577
  if (hasVizioAPIs()) {
305
- window.VIZIO.getFirmwareVersion(function (firmwareVersion) {
306
- deviceData.osVersion = firmwareVersion;
578
+ const firmwareVersion = await settleWithin(getVizioFirmwareVersion(), {
579
+ label: "getVizioFirmwareVersion",
307
580
  });
308
581
 
582
+ if (firmwareVersion) {
583
+ deviceData.osVersion = firmwareVersion;
584
+ }
585
+
309
586
  deviceData.deviceModel = window.VIZIO.deviceModel;
587
+
588
+ if (deviceData.deviceModel) {
589
+ deviceData.deviceName = `Vizio ${deviceData.deviceModel}`;
590
+ }
310
591
  }
592
+ } else if (isVidaaPlatform()) {
593
+ // Waits for the VIDAA SDK, which loader.js loads asynchronously from the
594
+ // VIDAA CDN. Resolves to null on timeout, leaving the static fields below.
595
+ const vidaaInfo = await getVidaaInfo();
596
+
597
+ deviceData = {
598
+ ...vidaaInfo,
599
+ platform: "vidaa",
600
+ // Brand is read from the SDK rather than hardcoded: VIDAA ships on
601
+ // OEM sets beyond Hisense, so getBrand() is the accurate source.
602
+ deviceMake: vidaaInfo?.deviceMake || "Hisense",
603
+ deviceType: getDeviceType(),
604
+ ...deviceDimensions,
605
+ };
311
606
  }
312
607
 
313
608
  return deviceData;
@@ -318,6 +613,10 @@ export const getDeviceData = async () => {
318
613
  error
319
614
  );
320
615
 
321
- return null;
616
+ // Returns an empty object rather than null: the caller
617
+ // (loadSessionStorageData) does `delete deviceData.sdkVersion` on the
618
+ // result, which throws a TypeError on null and turns a handled error into
619
+ // an unhandled one during startup.
620
+ return {};
322
621
  }
323
622
  };
@@ -9,4 +9,5 @@ export enum STORE_PLATFORM {
9
9
  lg = "lg_content_store",
10
10
  samsung = "samsung_app_store",
11
11
  vizio = "vizio_app_store",
12
+ vidaa = "vidaa_app_store",
12
13
  }
@@ -26,7 +26,7 @@ export function StorageMock() {
26
26
 
27
27
  delete this[key];
28
28
 
29
- return true;
29
+ // Return undefined like the real API; a truthy return would re-mask the bug.
30
30
  };
31
31
 
32
32
  storage.getItem = (key) => {
@@ -0,0 +1,104 @@
1
+ import { getContextResolverBridge } from "../";
2
+
3
+ function createStorageMock(initial = {}) {
4
+ const data = { ...initial };
5
+
6
+ return {
7
+ getItem: (key) => (key in data ? data[key] : null),
8
+ setItem: (key, value) => {
9
+ data[key] = value;
10
+ },
11
+ };
12
+ }
13
+
14
+ describe("ContextResolverBridge polyfill", () => {
15
+ let bridge;
16
+
17
+ beforeEach(() => {
18
+ global.window = {
19
+ sessionStorage: createStorageMock(),
20
+ localStorage: createStorageMock(),
21
+ };
22
+
23
+ bridge = getContextResolverBridge();
24
+ });
25
+
26
+ it("resolves a key from session storage using the web namespace separator", () => {
27
+ // the namespace itself contains dots, so the key must be split on the last dot
28
+ window.sessionStorage.setItem("applicaster.v2_::_appVersion", "1.0.0");
29
+
30
+ const result = bridge.resolveContextKeys({
31
+ "applicaster.v2.appVersion": false,
32
+ });
33
+
34
+ expect(result).toEqual({ "applicaster.v2.appVersion": "1.0.0" });
35
+ });
36
+
37
+ it("falls back to local storage when the key is missing in session storage", () => {
38
+ window.localStorage.setItem("applicaster.v2_::_appVersion", "1.0.0");
39
+
40
+ const result = bridge.resolveContextKeys({
41
+ "applicaster.v2.appVersion": false,
42
+ });
43
+
44
+ expect(result).toEqual({ "applicaster.v2.appVersion": "1.0.0" });
45
+ });
46
+
47
+ it("prefers the session storage value when the key exists in both", () => {
48
+ window.sessionStorage.setItem("applicaster.v2_::_appVersion", "session");
49
+ window.localStorage.setItem("applicaster.v2_::_appVersion", "local");
50
+
51
+ const result = bridge.resolveContextKeys({
52
+ "applicaster.v2.appVersion": false,
53
+ });
54
+
55
+ expect(result).toEqual({ "applicaster.v2.appVersion": "session" });
56
+ });
57
+
58
+ it("returns null for keys that are missing from both storages", () => {
59
+ const result = bridge.resolveContextKeys({
60
+ "applicaster.v2.missing": false,
61
+ });
62
+
63
+ expect(result).toEqual({ "applicaster.v2.missing": null });
64
+ });
65
+
66
+ it("JSON-parses stored values just like the async storage path", () => {
67
+ window.sessionStorage.setItem(
68
+ "applicaster.v2_::_profile",
69
+ JSON.stringify({ name: "Ada" })
70
+ );
71
+
72
+ window.localStorage.setItem("applicaster.v2_::_count", "42");
73
+
74
+ const result = bridge.resolveContextKeys({
75
+ "applicaster.v2.profile": false,
76
+ "applicaster.v2.count": true,
77
+ });
78
+
79
+ expect(result).toEqual({
80
+ "applicaster.v2.profile": { name: "Ada" },
81
+ "applicaster.v2.count": 42,
82
+ });
83
+ });
84
+
85
+ it("resolves multiple keys synchronously in a single call", () => {
86
+ window.sessionStorage.setItem("applicaster.v2_::_a", "valueA");
87
+ window.localStorage.setItem("applicaster.v2_::_b", "valueB");
88
+
89
+ const result = bridge.resolveContextKeys({
90
+ "applicaster.v2.a": false,
91
+ "applicaster.v2.b": false,
92
+ "applicaster.v2.c": false,
93
+ });
94
+
95
+ // returned object is not a promise - reads happen synchronously
96
+ expect(result).not.toBeInstanceOf(Promise);
97
+
98
+ expect(result).toEqual({
99
+ "applicaster.v2.a": "valueA",
100
+ "applicaster.v2.b": "valueB",
101
+ "applicaster.v2.c": null,
102
+ });
103
+ });
104
+ });
@@ -155,7 +155,11 @@ export function getStorageModule(type) {
155
155
  function removeItem(key, namespace = DEFAULT_NAMESPACE) {
156
156
  const keyName = applyNamespaceToKeyName(key, namespace);
157
157
 
158
- return tryAndResolve(() => Storage.callMethod("removeItem", keyName));
158
+ return tryAndResolve(() => {
159
+ Storage.callMethod("removeItem", keyName);
160
+
161
+ return true;
162
+ });
159
163
  }
160
164
 
161
165
  /**
@@ -200,3 +204,74 @@ export function getStorageModule(type) {
200
204
  removeItem,
201
205
  };
202
206
  }
207
+
208
+ const STORAGE_READ_ORDER = ["sessionStorage", "localStorage"];
209
+
210
+ /**
211
+ * Splits a normalized context key ("<namespace>.<key>") back into its
212
+ * namespace and key parts. Mirrors getNamespaceAndKey in the context keys
213
+ * manager: the key is everything after the last dot, since the namespace
214
+ * itself may contain dots (e.g. "applicaster.v2").
215
+ * @param {String} namespacedKey
216
+ * @returns {{ namespace: String, key: String }}
217
+ */
218
+ function splitNamespacedKey(namespacedKey) {
219
+ const lastDotIndex = namespacedKey.lastIndexOf(".");
220
+
221
+ if (lastDotIndex === -1) {
222
+ return { namespace: DEFAULT_NAMESPACE, key: namespacedKey };
223
+ }
224
+
225
+ return {
226
+ namespace: namespacedKey.slice(0, lastDotIndex),
227
+ key: namespacedKey.slice(lastDotIndex + 1),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Synchronously reads a single context key from session storage, then local
233
+ * storage (matching the native ContextResolverBridge resolution order), and
234
+ * returns the parsed value, or null when the key is absent from both.
235
+ * @param {String} namespacedKey
236
+ * @returns {Any|null}
237
+ */
238
+ function resolveContextKey(namespacedKey) {
239
+ const { namespace, key } = splitNamespacedKey(namespacedKey);
240
+ const storageKey = applyNamespaceToKeyName(key, namespace);
241
+
242
+ for (const type of STORAGE_READ_ORDER) {
243
+ const storage = window[type];
244
+ const value = storage ? storage.getItem(storageKey) : null;
245
+
246
+ if (value != null) {
247
+ return parseJsonIfNeeded(value);
248
+ }
249
+ }
250
+
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * Web polyfill for the native NativeModules.ContextResolverBridge module.
256
+ * Unlike the per-key async storage path, it resolves every requested key in a
257
+ * single synchronous pass over window.sessionStorage / window.localStorage.
258
+ * @returns {Object} bridge
259
+ * @returns {Function} bridge.resolveContextKeys: resolves a batch of keys
260
+ */
261
+ export function getContextResolverBridge() {
262
+ return {
263
+ /**
264
+ * @param {Object} keys map of normalized key -> required flag
265
+ * @returns {Object} map of normalized key -> resolved value (or null)
266
+ */
267
+ resolveContextKeys(keys) {
268
+ const result = {};
269
+
270
+ for (const namespacedKey of Object.keys(keys || {})) {
271
+ result[namespacedKey] = resolveContextKey(namespacedKey);
272
+ }
273
+
274
+ return result;
275
+ },
276
+ };
277
+ }
@@ -1,4 +1,4 @@
1
- import { getStorageModule } from "./Storage";
1
+ import { getStorageModule, getContextResolverBridge } from "./Storage";
2
2
  import { DeviceEventEmitter } from "./DeviceEventEmitter";
3
3
  import { isSamsungPlatform, isLgPlatform } from "../App/Loader/utils/platform";
4
4
 
@@ -15,6 +15,7 @@ import "@webcomponents/webcomponentsjs";
15
15
  const PLATFORM_KEYS = {
16
16
  samsung: "samsung_tv",
17
17
  lg: "lg_tv",
18
+ vidaa: "vidaa",
18
19
  web: "web",
19
20
  };
20
21
 
@@ -23,6 +24,7 @@ const PLATFORM_KEYS = {
23
24
  export function registerNativeModulesPolyfills(NativeModules) {
24
25
  NativeModules.LocalStorage = getStorageModule("localStorage");
25
26
  NativeModules.SessionStorage = getStorageModule("sessionStorage");
27
+ NativeModules.ContextResolverBridge = getContextResolverBridge();
26
28
  NativeModules.AnalyticsBridge = require("./AnalyticsBridge").AnalyticsBridge;
27
29
  NativeModules.AppLoaderBridge = require("./AppLoaderBridge").AppLoaderBridge;
28
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-dom-app",
3
- "version": "16.0.0-rc.1",
3
+ "version": "16.0.0-rc.100",
4
4
  "description": "Zapp App Component for Applicaster's Quick Brick React Native App",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -22,11 +22,11 @@
22
22
  },
23
23
  "homepage": "https://github.com/applicaster/zapp-react-dom-app#readme",
24
24
  "dependencies": {
25
- "@applicaster/zapp-react-dom-ui-components": "16.0.0-rc.1",
26
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.1",
27
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.1",
28
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.1",
29
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.1",
25
+ "@applicaster/zapp-react-dom-ui-components": "16.0.0-rc.100",
26
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.100",
27
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.100",
28
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.100",
29
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.100",
30
30
  "abortcontroller-polyfill": "^1.7.5",
31
31
  "typeface-montserrat": "^0.0.54",
32
32
  "video.js": "7.14.3",