@applicaster/zapp-react-native-ui-components 16.0.0-alpha.3081324238 → 16.0.0-alpha.3202041045

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 (36) hide show
  1. package/Components/MasterCell/DefaultComponents/ActionButton.tsx +13 -0
  2. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +12 -3
  3. package/Components/MasterCell/DefaultComponents/PressableView.tsx +3 -0
  4. package/Components/MasterCell/DefaultComponents/__tests__/ActionButton.test.tsx +288 -0
  5. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/ActionButton.tsx +2 -1
  6. package/Components/MasterCell/DefaultComponents/mobile/MobileActionButtons/__tests__/PressableView.test.tsx +4 -2
  7. package/Components/MasterCell/__tests__/masterCell.test.js +60 -0
  8. package/Components/MasterCell/index.tsx +28 -4
  9. package/Components/MasterCell/utils/__tests__/withoutFocusedState.test.js +20 -0
  10. package/Components/MasterCell/utils/index.ts +18 -0
  11. package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +40 -0
  12. package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +55 -15
  13. package/Components/ModalComponent/BottomSheetModalContent.tsx +5 -1
  14. package/Components/ModalComponent/__tests__/showTextInput.test.ts +25 -0
  15. package/Components/ModalComponent/showTextInput.ts +2 -0
  16. package/Components/PlayerContainer/PlayerContainer.tsx +22 -5
  17. package/Components/PlayerContainer/__tests__/PlayerContainer.test.tsx +51 -0
  18. package/Components/River/ComponentsMap/ComponentsMap.tsx +4 -1
  19. package/Components/River/__tests__/__snapshots__/componentsMap.test.js.snap +2 -0
  20. package/Components/VideoLive/LiveImageManager.ts +126 -12
  21. package/Components/VideoLive/MuteUnmuteController.ts +135 -0
  22. package/Components/VideoLive/PlayerLiveImageComponent.tsx +15 -19
  23. package/Components/VideoLive/__tests__/LiveImageMuteState.test.ts +311 -0
  24. package/Components/VideoLive/__tests__/MuteUnmuteController.test.ts +135 -0
  25. package/Components/VideoLive/__tests__/PlayerLiveImageComponent.test.tsx +63 -1
  26. package/Components/VideoModal/PlayerWrapper.tsx +4 -1
  27. package/Components/VideoModal/__tests__/PlayerWrapper.test.tsx +1 -0
  28. package/Components/VideoModal/__tests__/playerWrapperStyle.test.ts +58 -0
  29. package/Components/VideoModal/__tests__/playerWrapperUtils.test.ts +16 -0
  30. package/Components/VideoModal/playerWrapperStyle.ts +21 -3
  31. package/Components/VideoModal/playerWrapperUtils.ts +7 -1
  32. package/Decorators/ZappPipesDataConnector/__tests__/screenToolbox.test.ts +347 -0
  33. package/Decorators/ZappPipesDataConnector/__tests__/screenToolboxUnavailable.test.ts +53 -0
  34. package/Decorators/ZappPipesDataConnector/index.tsx +19 -4
  35. package/Decorators/ZappPipesDataConnector/screenToolbox.ts +384 -0
  36. package/package.json +5 -5
@@ -381,6 +381,22 @@ export function mergeHeaderConfiguration(
381
381
  // Button Merging Helpers
382
382
  // ---------------------------------------------------------------------------
383
383
 
384
+ function fontFamilyName(value: unknown): string | undefined {
385
+ if (typeof value === "string" && value.trim()) {
386
+ return value;
387
+ }
388
+
389
+ if (value && typeof value === "object") {
390
+ const family = (value as { fontFamily?: unknown }).fontFamily;
391
+
392
+ if (typeof family === "string" && family.trim()) {
393
+ return family;
394
+ }
395
+ }
396
+
397
+ return undefined;
398
+ }
399
+
384
400
  function mergeButtonBackground(
385
401
  base: ButtonBackgroundConfiguration,
386
402
  defaultOverride?: Record<string, any>,
@@ -399,6 +415,9 @@ function mergeButtonBackground(
399
415
  ...(defaultOverride?.backgroundColor
400
416
  ? { defaultColor: defaultOverride.backgroundColor }
401
417
  : {}),
418
+ ...(defaultOverride?.inactiveColor
419
+ ? { inactiveColor: defaultOverride.inactiveColor }
420
+ : {}),
402
421
  ...(focusedColor != null && focusedColor !== "" ? { focusedColor } : {}),
403
422
  ...(defaultOverride?.borderRadius !== undefined
404
423
  ? { cornerRadius: defaultOverride.borderRadius }
@@ -415,47 +434,62 @@ function mergeButtonBackground(
415
434
  ...(defaultOverride?.paddingLeft !== undefined
416
435
  ? { paddingLeft: defaultOverride.paddingLeft }
417
436
  : {}),
437
+ ...(defaultOverride?.horizontalGutter !== undefined
438
+ ? { horizontalGutter: defaultOverride.horizontalGutter }
439
+ : {}),
418
440
  };
419
441
  }
420
442
 
421
443
  function mergeButtonTitle(
422
444
  base: ButtonTitleConfiguration,
423
- override?: Record<string, any>
445
+ override?: Record<string, any>,
446
+ focusedOverride?: Record<string, any>
424
447
  ): ButtonTitleConfiguration {
425
- if (!override) return base;
448
+ if (!override && !focusedOverride) return base;
449
+
450
+ const fontFamily = fontFamilyName(override?.fontFamily);
451
+
452
+ const focusedFontColor =
453
+ focusedOverride?.fontColor || override?.focusedFontColor;
454
+
455
+ const inactiveFontColor = override?.inactiveFontColor;
426
456
 
427
457
  return {
428
458
  ...base,
429
- ...(override.fontColor ? { fontColor: override.fontColor } : {}),
430
- ...(override.fontSize !== undefined ? { fontSize: override.fontSize } : {}),
431
- ...(override.lineHeight !== undefined
459
+ ...(override?.fontColor ? { fontColor: override.fontColor } : {}),
460
+ ...(focusedFontColor ? { focusedFontColor } : {}),
461
+ ...(inactiveFontColor ? { inactiveFontColor } : {}),
462
+ ...(override?.fontSize !== undefined
463
+ ? { fontSize: override.fontSize }
464
+ : {}),
465
+ ...(override?.lineHeight !== undefined
432
466
  ? { lineHeight: override.lineHeight }
433
467
  : {}),
434
- ...(override.fontFamily
468
+ ...(fontFamily
435
469
  ? {
436
- iosFontFamily: override.fontFamily,
437
- androidFontFamily: override.fontFamily,
470
+ iosFontFamily: fontFamily,
471
+ androidFontFamily: fontFamily,
438
472
  }
439
473
  : {}),
440
- ...(override.iosFontFamily
474
+ ...(override?.iosFontFamily
441
475
  ? { iosFontFamily: override.iosFontFamily }
442
476
  : {}),
443
- ...(override.androidFontFamily
477
+ ...(override?.androidFontFamily
444
478
  ? { androidFontFamily: override.androidFontFamily }
445
479
  : {}),
446
- ...(override.letterSpacing !== undefined
480
+ ...(override?.letterSpacing !== undefined
447
481
  ? {
448
482
  iosLetterSpacing: override.letterSpacing,
449
483
  androidLetterSpacing: override.letterSpacing,
450
484
  }
451
485
  : {}),
452
- ...(override.iosLetterSpacing !== undefined
486
+ ...(override?.iosLetterSpacing !== undefined
453
487
  ? { iosLetterSpacing: override.iosLetterSpacing }
454
488
  : {}),
455
- ...(override.androidLetterSpacing !== undefined
489
+ ...(override?.androidLetterSpacing !== undefined
456
490
  ? { androidLetterSpacing: override.androidLetterSpacing }
457
491
  : {}),
458
- ...(override.textTransform
492
+ ...(override?.textTransform
459
493
  ? { textTransform: override.textTransform }
460
494
  : {}),
461
495
  };
@@ -511,13 +545,19 @@ export function mergeButtonConfiguration(
511
545
  const titleOverride =
512
546
  buttonBlock?.title?.default || actionBlock?.title?.default;
513
547
 
548
+ const titleFocused =
549
+ buttonBlock?.title?.focused ||
550
+ buttonBlock?.title?.focus ||
551
+ actionBlock?.title?.focused ||
552
+ actionBlock?.title?.focus;
553
+
514
554
  const iconsOverride = buttonBlock?.icons || actionBlock?.icons;
515
555
  const leadingOverride = buttonBlock?.leadingIcon || actionBlock?.leadingIcon;
516
556
 
517
557
  return {
518
558
  ...base,
519
559
  background: mergeButtonBackground(base.background, bgDefault, bgFocused),
520
- title: mergeButtonTitle(base.title, titleOverride),
560
+ title: mergeButtonTitle(base.title, titleOverride, titleFocused),
521
561
  icons: mergeButtonIcons(base.icons, iconsOverride, leadingOverride),
522
562
  };
523
563
  }
@@ -90,6 +90,7 @@ type ModalComponentProps = {
90
90
  /** Optional body that replaces the items list (header stays DefaultModalHeader). */
91
91
  contentComponent?: React.ComponentType<any>;
92
92
  contentComponentProps?: Record<string, any>;
93
+ themePluginId?: string;
93
94
  styleOverrides?: ModalBlocksConfig | Record<string, any>;
94
95
  measurementPhase?: boolean;
95
96
  /** Copy under Next Up when the queue has no upcoming items. */
@@ -115,6 +116,7 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
115
116
  iconPlacement,
116
117
  contentComponent: ContentComponent,
117
118
  contentComponentProps = {},
119
+ themePluginId: themePluginIdProp,
118
120
  measurementPhase = false,
119
121
  emptyMessage,
120
122
  } = props;
@@ -139,12 +141,14 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
139
141
  role,
140
142
  behavior,
141
143
  editableCollection,
142
- themePluginId,
144
+ themePluginId: themePluginIdFromSheet,
143
145
  } = useBottomSheetContent({
144
146
  onPress,
145
147
  viewModel,
146
148
  });
147
149
 
150
+ const themePluginId = themePluginIdFromSheet || themePluginIdProp;
151
+
148
152
  const plugins = usePlugins();
149
153
 
150
154
  const plugin = useMemo(
@@ -92,4 +92,29 @@ describe("showTextInputAction", () => {
92
92
  expect(result).toBe(ActionResult.Error);
93
93
  expect(actionExecutor.handleAction).not.toHaveBeenCalled();
94
94
  });
95
+
96
+ it("forwards themePluginId so the sheet can read plugin button styles", async () => {
97
+ const action = {
98
+ type: "showTextInput",
99
+ options: {
100
+ headerTitle: "Create New Playlist",
101
+ inputLabel: "Name your playlist",
102
+ buttonLabel: "Create",
103
+ themePluginId: "playlist-experience",
104
+ actions: [{ type: "sendCloudEvent", options: {} }],
105
+ },
106
+ };
107
+
108
+ await showTextInputAction(action as any);
109
+
110
+ expect(actionExecutor.handleAction).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ type: "openBottomSheet",
113
+ options: expect.objectContaining({
114
+ themePluginId: "playlist-experience",
115
+ }),
116
+ }),
117
+ undefined
118
+ );
119
+ });
95
120
  });
@@ -21,6 +21,7 @@ export async function showTextInputAction(
21
21
  validationLabel,
22
22
  actions,
23
23
  maxLength,
24
+ themePluginId,
24
25
  } = action.options || {};
25
26
 
26
27
  if (!headerTitle || !buttonLabel || !actions || !actions.length) {
@@ -34,6 +35,7 @@ export async function showTextInputAction(
34
35
  header: {
35
36
  title: headerTitle,
36
37
  },
38
+ ...(themePluginId ? { themePluginId } : {}),
37
39
  content: {
38
40
  component: TextInputContent,
39
41
  props: {
@@ -273,7 +273,10 @@ const PlayerContainerComponent = (props: Props) => {
273
273
  const playEntry = (entry: ZappEntry) => navigator.replaceTop(entry, { mode });
274
274
 
275
275
  const onPlayNextPerformNextVideoPlay = React.useCallback(() => {
276
- if (!playNextOverlayState.entry) {
276
+ // The overlay's entry, or the player's when the overlay is not showing yet (X-Ray player.next)
277
+ const nextEntry = playNextOverlayState?.entry ?? player?.getPlayNextEntry();
278
+
279
+ if (!nextEntry) {
277
280
  log_warning(
278
281
  "onPlayNextPerformNextVideoPlay: playNextOverlayState.entry is not defined"
279
282
  );
@@ -282,16 +285,30 @@ const PlayerContainerComponent = (props: Props) => {
282
285
  }
283
286
 
284
287
  log_debug(
285
- `onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${playNextOverlayState.entry.title}, id: ${playNextOverlayState.entry.id}, outgoing item id: ${item?.id}`
288
+ `onPlayNextPerformNextVideoPlay: delay completed, replaceTop to item: ${nextEntry.title}, id: ${nextEntry.id}, outgoing item id: ${item?.id}`
286
289
  );
287
290
 
288
- playNextOverlayState.hidePlayNext();
291
+ (
292
+ playNextOverlayState?.hidePlayNext ??
293
+ player?.getOverlayObservable()?.hidePlayNext
294
+ )?.();
289
295
 
290
296
  playerEvent(PLAYER_EVENTS.PlayNextTriggered, { id: item?.id });
291
297
 
292
298
  setIsLoadingNextVideo(true);
293
- playEntry(playNextOverlayState.entry);
294
- }, [playNextOverlayState, item?.id]);
299
+ playEntry(nextEntry);
300
+ }, [playNextOverlayState, item?.id, player]);
301
+
302
+ // Lets the X-Ray player.next tool run the same Play Next path as the overlay.
303
+ useEffect(() => {
304
+ if (!player) return;
305
+
306
+ player.playNextHandler = onPlayNextPerformNextVideoPlay;
307
+
308
+ return () => {
309
+ player.playNextHandler = null;
310
+ };
311
+ }, [player, onPlayNextPerformNextVideoPlay]);
295
312
 
296
313
  const playNextData: Nullable<PlayNextData> = React.useMemo(() => {
297
314
  if (!playNextOverlayState) {
@@ -127,6 +127,8 @@ jest.mock(
127
127
  seekTo: jest.fn(),
128
128
  getPluginConfiguration: jest.fn(() => ({})),
129
129
  getOverlayObservable: jest.fn(() => ({ getPlayNextEntry: () => null })),
130
+ getPlayNextEntry: jest.fn(() => null),
131
+ playNextHandler: null,
130
132
  };
131
133
 
132
134
  return {
@@ -335,3 +337,52 @@ describe("PlayerContainer hardware back handling", () => {
335
337
  expect(mockNavigator.closeVideoModal).not.toHaveBeenCalled();
336
338
  });
337
339
  });
340
+
341
+ describe("PlayerContainer Play Next action for X-Ray player.next", () => {
342
+ beforeEach(() => {
343
+ jest.clearAllMocks();
344
+ mockIsTV.mockReturnValue(false);
345
+ mockPlayer.getPluginConfiguration.mockReturnValue({});
346
+ mockPlayerManager.getPluginConfiguration.mockReturnValue({});
347
+ mockPlayerManager.on.mockReturnThis();
348
+ mockPlayerManager.removeHandler.mockReturnThis();
349
+ });
350
+
351
+ it("sets its Play Next action on the player and clears it on unmount", () => {
352
+ const { unmount } = renderPlayerContainer();
353
+
354
+ expect(mockPlayer.playNextHandler).toEqual(expect.any(Function));
355
+
356
+ unmount();
357
+
358
+ expect(mockPlayer.playNextHandler).toBeNull();
359
+ });
360
+
361
+ it("plays the player's next entry once, with analytics and the overlay hidden, when the overlay is not showing", () => {
362
+ const next = { id: "entry-2", title: "Next" };
363
+ const hidePlayNext = jest.fn();
364
+ mockPlayer.getPlayNextEntry.mockReturnValue(next);
365
+
366
+ mockPlayer.getOverlayObservable.mockReturnValue({
367
+ getPlayNextEntry: () => next,
368
+ hidePlayNext,
369
+ });
370
+
371
+ renderPlayerContainer();
372
+
373
+ mockPlayer.playNextHandler();
374
+
375
+ expect(hidePlayNext).toHaveBeenCalledTimes(1);
376
+
377
+ expect(mockPlayerManager.invokeHandler).toHaveBeenCalledWith(
378
+ "play_next_triggered",
379
+ { id: "entry-1" }
380
+ );
381
+
382
+ expect(mockNavigator.replaceTop).toHaveBeenCalledTimes(1);
383
+
384
+ expect(mockNavigator.replaceTop).toHaveBeenCalledWith(next, {
385
+ mode: VideoModalMode.MAXIMIZED,
386
+ });
387
+ });
388
+ });
@@ -118,7 +118,10 @@ function ComponentsMapComponent(props: Props) {
118
118
  };
119
119
 
120
120
  return (
121
- <ScreenLoadingMeasurementsListItemWrapper index={index}>
121
+ <ScreenLoadingMeasurementsListItemWrapper
122
+ index={index}
123
+ testID={`component-${item.id}`}
124
+ >
122
125
  <RiverItem {...riverItemProps} />
123
126
  </ScreenLoadingMeasurementsListItemWrapper>
124
127
  );
@@ -213,6 +213,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
213
213
  <View
214
214
  onLayout={[Function]}
215
215
  style={{}}
216
+ testID="component-A1"
216
217
  >
217
218
  <View />
218
219
  </View>
@@ -225,6 +226,7 @@ exports[`componentsMap renders renders components map correctly 1`] = `
225
226
  <View
226
227
  onLayout={[Function]}
227
228
  style={{}}
229
+ testID="component-A2"
228
230
  />
229
231
  </View>
230
232
  <View
@@ -3,7 +3,10 @@ import {
3
3
  PlayerLifecycleListener,
4
4
  playerManager,
5
5
  } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
6
- import { setUserCellPlayerMutedPreference } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference";
6
+ import {
7
+ getUserCellPlayerMutedPreference,
8
+ setUserCellPlayerMutedPreference,
9
+ } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/userCellPlayerMutedPreference";
7
10
  import { playerFactory } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/playerFactory";
8
11
  import { PlayerRole } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/const";
9
12
  import { loggerLiveImageManager } from "./loggerHelper";
@@ -21,13 +24,15 @@ export type LiveImageManagerEvent =
21
24
  | "onEnd"
22
25
  | "onStart"
23
26
  | "onError"
24
- | "onDestroy";
27
+ | "onDestroy"
28
+ | "onRegister"
29
+ | "onPlaybackChange"
30
+ | "onMuteChange";
25
31
 
26
32
  export type LiveImageManagerListenerData = {
27
33
  id: string;
28
- listener: Record<
29
- LiveImageManagerEvent,
30
- (event?: Record<string, any>) => void
34
+ listener: Partial<
35
+ Record<LiveImageManagerEvent, (event?: Record<string, any>) => void>
31
36
  >;
32
37
  };
33
38
 
@@ -54,6 +59,7 @@ type PlayerFactoryConfig = {
54
59
  };
55
60
 
56
61
  type LiveImageProps = {
62
+ audioMutedByDefault?: boolean;
57
63
  playerId: string;
58
64
  setMode?: (type: LiveImageType) => void;
59
65
  preloadHooks?: PreloadHookConfig[];
@@ -81,9 +87,11 @@ export class LiveImageManager implements PlayerLifecycleListener {
81
87
 
82
88
  protected listeners: Record<
83
89
  string,
84
- Record<LiveImageManagerEvent, (event?: Record<string, any>) => void>
90
+ LiveImageManagerListenerData["listener"]
85
91
  > = {};
86
92
 
93
+ private mutedSubscriptions = new Map<LiveImage, () => void>();
94
+
87
95
  constructor() {
88
96
  playerManager.addLifecycleListener(this);
89
97
  this.listeners = {};
@@ -96,8 +104,25 @@ export class LiveImageManager implements PlayerLifecycleListener {
96
104
  }
97
105
 
98
106
  public register = (item: LiveImage): (() => void) => {
107
+ if (!isTV()) {
108
+ const muted = !!getUserCellPlayerMutedPreference(item.getMuted());
109
+
110
+ if (muted !== item.getMuted()) {
111
+ muted ? item.mute() : item.unmute();
112
+ }
113
+ }
114
+
99
115
  this.items.push(item);
116
+
117
+ this.mutedSubscriptions.set(
118
+ item,
119
+ item.subscribeMuted(() =>
120
+ this.invokeListenersUpdate({ callbackName: "onMuteChange" })
121
+ )
122
+ );
123
+
100
124
  log_debug(`register: live image ${item.playerId} - ${item.tag}`);
125
+ this.invokeListenersUpdate({ callbackName: "onRegister" });
101
126
 
102
127
  // TV only Start playing video once registered
103
128
  if (isTV()) {
@@ -109,6 +134,8 @@ export class LiveImageManager implements PlayerLifecycleListener {
109
134
 
110
135
  public unregister = (item: LiveImage) => {
111
136
  log_debug(`unregister: live-image ${item.playerId} - ${item.tag}`);
137
+ this.mutedSubscriptions.get(item)?.();
138
+ this.mutedSubscriptions.delete(item);
112
139
 
113
140
  if (this.currentlyPlaying === item) {
114
141
  this.currentlyPlaying = null;
@@ -172,6 +199,25 @@ export class LiveImageManager implements PlayerLifecycleListener {
172
199
  return this.currentlyPlaying;
173
200
  };
174
201
 
202
+ public getEntryMuted = (entryId: string): boolean | undefined => {
203
+ const matches = (item: LiveImage) => String(item.getEntryId()) === entryId;
204
+
205
+ const item =
206
+ this.currentlyPlaying && matches(this.currentlyPlaying)
207
+ ? this.currentlyPlaying
208
+ : this.items.find(matches);
209
+
210
+ return item?.getMuted();
211
+ };
212
+
213
+ public getPlayingCellId = (entryId: string): string | undefined => {
214
+ const item = this.currentlyPlaying;
215
+
216
+ return item && String(item.getEntryId()) === entryId && item.isPlaying()
217
+ ? item.playerId
218
+ : undefined;
219
+ };
220
+
175
221
  private findNextPlayableItem = () => {
176
222
  if (isTV()) {
177
223
  return this.items[this.items.length - 1];
@@ -302,6 +348,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
302
348
  setUserCellPlayerMutedPreference(true);
303
349
 
304
350
  this.items.forEach((liveImage) => liveImage.mute());
351
+ this.invokeListenersUpdate({ callbackName: "onMuteChange" });
305
352
  };
306
353
 
307
354
  public unmuteAll = () => {
@@ -310,6 +357,7 @@ export class LiveImageManager implements PlayerLifecycleListener {
310
357
  setUserCellPlayerMutedPreference(false);
311
358
 
312
359
  this.items.forEach((liveImage) => liveImage.unmute());
360
+ this.invokeListenersUpdate({ callbackName: "onMuteChange" });
313
361
  };
314
362
 
315
363
  public onViewPositionChanged = (item: LiveImage) => {
@@ -579,20 +627,67 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
579
627
  left: 0,
580
628
  };
581
629
 
582
- // Keeps muted state in sync: updates the initial value for deferred
583
- // player creation and forwards to the player if it already exists.
584
- public initiallyMuted: boolean = true;
630
+ private muted: boolean;
631
+ private mutedListeners = new Set<() => void>();
632
+ private playbackEnded = false;
633
+
634
+ getMuted = (): boolean => this.muted;
635
+
636
+ getEntryId = () => this.factoryConfig.entry.id;
637
+
638
+ isPlaying = (): boolean =>
639
+ !this.playbackEnded &&
640
+ !!this.player?.isReadyToPlay() &&
641
+ this.player.isPlaying();
642
+
643
+ subscribeMuted = (listener: () => void): (() => void) => {
644
+ this.mutedListeners.add(listener);
645
+
646
+ return () => this.mutedListeners.delete(listener);
647
+ };
648
+
649
+ private setMuted = (muted: boolean) => {
650
+ if (this.muted === muted) return;
651
+
652
+ this.muted = muted;
653
+
654
+ this.mutedListeners.forEach((listener) => {
655
+ try {
656
+ listener();
657
+ } catch (error) {
658
+ log_error(`Mute listener failed for ${this.playerId}`, { error });
659
+ }
660
+ });
661
+ };
585
662
 
586
663
  mute = () => {
587
- this.initiallyMuted = true;
664
+ this.setMuted(true);
588
665
  this.player?.mute();
589
666
  };
590
667
 
591
668
  unmute = () => {
592
- this.initiallyMuted = false;
669
+ this.setMuted(false);
593
670
  this.player?.unmute();
594
671
  };
595
672
 
673
+ onPlayerMute = () => this.setMuted(true);
674
+
675
+ onPlayerUnmute = () => this.setMuted(false);
676
+
677
+ onPlayerResume = () => {
678
+ this.playbackEnded = false;
679
+
680
+ LiveImageManager.instance.invokeListenersUpdate({
681
+ callbackName: "onPlaybackChange",
682
+ });
683
+ };
684
+
685
+ onPlayerPause = () => {
686
+ LiveImageManager.instance.invokeListenersUpdate({
687
+ callbackName: "onPlaybackChange",
688
+ });
689
+ };
690
+
596
691
  positionToString() {
597
692
  if (!this.position) {
598
693
  return "position not set";
@@ -617,6 +712,10 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
617
712
  this.preloadHooks = props.preloadHooks;
618
713
  this.factoryConfig = props.factoryConfig;
619
714
  this.tag = props.tag || "untagged";
715
+
716
+ this.muted = isTV()
717
+ ? false
718
+ : !!getUserCellPlayerMutedPreference(props.audioMutedByDefault);
620
719
  }
621
720
 
622
721
  async prepareForPlayback(): Promise<boolean> {
@@ -670,7 +769,7 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
670
769
  playerId: this.factoryConfig.playerId,
671
770
  autoplay: false,
672
771
  entry,
673
- muted: this.initiallyMuted,
772
+ muted: this.muted,
674
773
  playerPluginId: this.factoryConfig.playerPluginId,
675
774
  screenConfig: this.factoryConfig.screenConfig,
676
775
  playerRole: PlayerRole.Cell,
@@ -686,6 +785,11 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
686
785
  this.component = factoryItem.Component;
687
786
  this.player.addListener({ id: "live-image", listener: this });
688
787
 
788
+ // A user toggle may have arrived while the async factory was pending.
789
+ if (this.player.getIsMuted() !== this.muted) {
790
+ this.muted ? this.player.mute() : this.player.unmute();
791
+ }
792
+
689
793
  return true;
690
794
  }
691
795
 
@@ -694,6 +798,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
694
798
  };
695
799
 
696
800
  onLoad = (_event) => {
801
+ this.playbackEnded = false;
802
+
697
803
  log_info(
698
804
  `onLoad: live-image, video loaded, switching to video, ${playerInfo(
699
805
  this.player
@@ -704,6 +810,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
704
810
  };
705
811
 
706
812
  onError = (error: Error) => {
813
+ this.playbackEnded = true;
814
+
707
815
  log_error(
708
816
  `onError: live-image, fail to play, switching back to image. error: ${
709
817
  error?.message
@@ -714,6 +822,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
714
822
  };
715
823
 
716
824
  onVideoError = (error: Error) => {
825
+ this.playbackEnded = true;
826
+
717
827
  log_error(
718
828
  `onVideoError: live-image, fail to play, switching back to image. error: ${
719
829
  error?.message
@@ -724,6 +834,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
724
834
  };
725
835
 
726
836
  onVideoEnd = (_event) => {
837
+ this.playbackEnded = true;
838
+
727
839
  log_info(
728
840
  `onVideoEnd: live-image, video completed, switching back to image, ${playerInfo(
729
841
  this.player
@@ -734,6 +846,8 @@ export class LiveImage implements QuickBrickPlayer.SharedPlayerCallBacks {
734
846
  };
735
847
 
736
848
  onPlayerClose = () => {
849
+ this.playbackEnded = true;
850
+
737
851
  log_info(
738
852
  `onPlayerClose: live-image, player closed, switching back to image, ${playerInfo(
739
853
  this.player