@applicaster/zapp-react-native-ui-components 16.0.0-alpha.1334507601 → 16.0.0-alpha.1444784746

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 (26) hide show
  1. package/Components/LinkHandler/LinkHandler.tsx +2 -7
  2. package/Components/MasterCell/DefaultComponents/LiveImage/executePreloadHooks.ts +2 -4
  3. package/Components/MasterCell/DefaultComponents/LiveImage/index.tsx +2 -3
  4. package/Components/ModalComponent/AudioPlayer/Components/Header.tsx +64 -20
  5. package/Components/ModalComponent/AudioPlayer/Components/Input.tsx +139 -39
  6. package/Components/ModalComponent/AudioPlayer/Components/NowPlayingHeaderSection.tsx +147 -24
  7. package/Components/ModalComponent/AudioPlayer/Components/__tests__/NowPlayingHeaderSection.test.tsx +36 -0
  8. package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +108 -0
  9. package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +148 -1
  10. package/Components/ModalComponent/BottomSheetModalContent.tsx +95 -9
  11. package/Components/ModalComponent/SortableList.tsx +2 -3
  12. package/Components/ModalComponent/TextInputContent.tsx +27 -4
  13. package/Components/ModalComponent/__tests__/BottomSheetModalContent.test.tsx +74 -1
  14. package/Components/ModalComponent/__tests__/TextInputContent.test.tsx +14 -1
  15. package/Components/ModalComponent/showTextInput.ts +11 -2
  16. package/Components/OfflineHandler/__tests__/index.test.tsx +109 -0
  17. package/Components/OfflineHandler/index.tsx +42 -4
  18. package/Components/PlayerContainer/PlayerContainer.tsx +8 -1
  19. package/Components/Transitioner/AnimationManager.js +1 -1
  20. package/Components/Transitioner/Scene.tsx +4 -3
  21. package/Components/Transitioner/Transitioner.tsx +71 -33
  22. package/Components/Transitioner/__tests__/transitioner.test.js +372 -3
  23. package/Components/VideoLive/LiveImageManager.ts +8 -2
  24. package/Components/VideoLive/__tests__/LiveImageManager.test.ts +59 -0
  25. package/assets/empty-Info.png +0 -0
  26. package/package.json +5 -5
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { View } from "react-native";
3
- import { append, compose, identity, tail, take, update } from "ramda";
3
+ import { append, compose, identity, tail, update } from "ramda";
4
4
 
5
5
  import { AnimationManager } from "./AnimationManager";
6
6
  import { Scene } from "./Scene";
@@ -18,11 +18,23 @@ export const NAV_ACTION_REPLACE = "REPLACE";
18
18
 
19
19
  export const NAV_ACTION_BACK = "POP";
20
20
 
21
- type SceneProps = {
22
- children: React.ReactNode;
23
- overlayStyle: any;
24
- style: any;
25
- animating: boolean;
21
+ const RIVER_SEGMENT_REGEX = /river\//g;
22
+
23
+ // Number of "river/" segments in the route, i.e. its depth in the navigation stack
24
+ function countRiverSegments(route?: string): number {
25
+ return (route?.match(RIVER_SEGMENT_REGEX) || []).length;
26
+ }
27
+
28
+ // The screen element rendered for the current route
29
+ type SceneChild = React.ReactElement<{
30
+ pathname: string;
31
+ screenData: NavigationScreenData;
32
+ }>;
33
+
34
+ type TransitionerScene = {
35
+ children: SceneChild;
36
+ key: string;
37
+ screenUniqueId: string;
26
38
  };
27
39
 
28
40
  /**
@@ -31,7 +43,7 @@ type SceneProps = {
31
43
  * @param {Object} children
32
44
  * @returns { children: Object, key: string }
33
45
  */
34
- function prepareScene(children) {
46
+ function prepareScene(children: SceneChild): TransitionerScene {
35
47
  return {
36
48
  children,
37
49
  key: children.props.pathname,
@@ -45,7 +57,10 @@ function prepareScene(children) {
45
57
  * @param {any} props Component's props
46
58
  * @param {any} state Component's state
47
59
  */
48
- function scenesForBackTransition(props: SceneProps, { scenes }) {
60
+ function scenesForBackTransition(
61
+ props: Pick<Props, "children">,
62
+ { scenes }: Pick<State, "scenes">
63
+ ): TransitionerScene[] {
49
64
  return scenes.length > 1
50
65
  ? identity(scenes)
51
66
  : [prepareScene(props.children), scenes[0]];
@@ -53,7 +68,8 @@ function scenesForBackTransition(props: SceneProps, { scenes }) {
53
68
 
54
69
  type Props = {
55
70
  transitionConfig: any;
56
- children: React.ReactNode;
71
+ isRTL?: boolean;
72
+ children: SceneChild;
57
73
  navigator: {
58
74
  previousAction: string;
59
75
  currentRoute: string;
@@ -78,13 +94,12 @@ type Props = {
78
94
  };
79
95
 
80
96
  type State = {
81
- animatedValue: any;
82
97
  animating: boolean;
83
98
  duration: number;
84
99
  easing: any;
85
100
  from: any;
86
101
  to: any;
87
- scenes: any;
102
+ scenes: TransitionerScene[];
88
103
  };
89
104
 
90
105
  const styles = {
@@ -92,11 +107,18 @@ const styles = {
92
107
  } as const;
93
108
 
94
109
  export class TransitionerComponent extends React.PureComponent<Props, State> {
110
+ private animationManager: AnimationManager;
111
+
112
+ private transitionId: number;
113
+
95
114
  constructor(props: Props) {
96
115
  super(props);
97
116
 
98
117
  this.animationManager = new AnimationManager(props);
99
118
 
119
+ // Bumped by every animated transition; end callbacks of older ones are ignored
120
+ this.transitionId = 0;
121
+
100
122
  this.state = {
101
123
  scenes: [prepareScene(props.children)], // starting with index: 0
102
124
  ...this.animationManager.initialState(),
@@ -113,29 +135,36 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
113
135
  * and trigger push animation with callbacks.
114
136
  */
115
137
  pushTransitionStart() {
138
+ const transitionId = ++this.transitionId;
139
+
116
140
  this.setState(
117
141
  {
118
- scenes: append(prepareScene(this.props.children), this.state.scenes),
142
+ // Append right after the visible scene: stateForAction(PUSH) animates to.index + 1
143
+ scenes: [
144
+ ...this.state.scenes.slice(0, this.state.to.index + 1),
145
+ prepareScene(this.props.children),
146
+ ],
119
147
  ...this.animationManager.stateForAction(this.state, NAV_ACTION_PUSH),
120
148
  },
121
149
  () => {
122
- this.animationManager.animate(this.state, this.pushTransitionEnd);
150
+ this.animationManager.animate(this.state, () =>
151
+ this.pushTransitionEnd(transitionId)
152
+ );
123
153
  }
124
154
  );
125
155
  }
126
156
 
127
157
  /**
128
158
  * Trigger back animation.
129
- * The last scene in the array is removed in the "backTransitionEnd" callback,
159
+ * The scenes after the target one are removed in the "backTransitionEnd" callback,
130
160
  * only after the animation is complete.
131
161
  */
132
- backTransitionStart() {
133
- const previousRoute = this.props.navigator?.currentRoute;
162
+ backTransitionStart(previousRoute: string) {
163
+ const transitionId = ++this.transitionId;
134
164
  const currentRoute = this.props.navigator?.currentRoute;
135
165
 
136
166
  const diffLengthRoutes =
137
- (previousRoute?.match(/river\//g) || []).length -
138
- (currentRoute?.match(/river\//g) || []).length;
167
+ countRiverSegments(previousRoute) - countRiverSegments(currentRoute);
139
168
 
140
169
  const stepsBack =
141
170
  diffLengthRoutes && diffLengthRoutes > 0 ? diffLengthRoutes : 1;
@@ -151,30 +180,32 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
151
180
  },
152
181
  () => {
153
182
  this.animationManager.animate(this.state, () =>
154
- this.backTransitionEnd(stepsBack)
183
+ this.backTransitionEnd(transitionId)
155
184
  );
156
185
  }
157
186
  );
158
187
  }
159
188
 
160
- shouldSkipAnimationForRoute(route) {
189
+ shouldSkipAnimationForRoute(route: string) {
161
190
  // We are skipping animations for some routes, likes hooks & the player mainly
162
191
 
163
192
  return route.includes("/playable") || route.includes("/hook");
164
193
  }
165
194
 
166
- shouldResetStateForRoute(route) {
195
+ shouldResetStateForRoute(route: string) {
167
196
  // We are resetting the transitioner state for root routes
168
197
 
169
- return (route?.match(/river\//g) || []).length === 1;
198
+ return countRiverSegments(route) === 1;
170
199
  }
171
200
 
172
- transitionWithoutAnimation(resetState) {
173
- const { scenes } = this.state;
201
+ transitionWithoutAnimation(resetState?: boolean) {
202
+ // Scenes after the visible one are only leaving (back animation in flight),
203
+ // so the new scene replaces the one being backed to
204
+ const scenes = this.state.scenes.slice(0, this.state.to.index + 1);
174
205
  const newScene = prepareScene(this.props.children);
175
206
  const isLastSceneHook = scenes[scenes.length - 1].key.startsWith("/hook");
176
207
 
177
- let newState;
208
+ let newState: Pick<State, "scenes"> | Pick<State, "scenes" | "to">;
178
209
 
179
210
  if (resetState) {
180
211
  newState = {
@@ -242,7 +273,7 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
242
273
  break;
243
274
 
244
275
  case NAV_ACTION_BACK: {
245
- this.backTransitionStart();
276
+ this.backTransitionStart(previousRoute);
246
277
  break;
247
278
  }
248
279
 
@@ -264,20 +295,27 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
264
295
  }
265
296
  }
266
297
 
267
- // Sets state.animating to false
268
- pushTransitionEnd() {
298
+ // Sets state.animating to false,
299
+ // unless a newer transition has started since this one
300
+ pushTransitionEnd(transitionId: number) {
301
+ if (transitionId !== this.transitionId) return;
302
+
269
303
  this.setState({
270
304
  animating: false,
271
305
  });
272
306
  }
273
307
 
274
308
  // Sets state.animating to false
275
- // and remove last scene from scenes stack
276
- backTransitionEnd(stepsBack) {
277
- this.setState({
309
+ // and drops every scene after the target one. Reads the latest state,
310
+ // so it's correct however many backs were merged into this animation.
311
+ // Ignored if a newer transition has started since this one
312
+ backTransitionEnd(transitionId: number) {
313
+ if (transitionId !== this.transitionId) return;
314
+
315
+ this.setState(({ scenes, to }) => ({
278
316
  animating: false,
279
- scenes: take(this.state.scenes.length - stepsBack, this.state.scenes),
280
- });
317
+ scenes: scenes.slice(0, to.index + 1),
318
+ }));
281
319
  }
282
320
 
283
321
  // On initial state, both "to" and "from" point to a single scene
@@ -1,8 +1,15 @@
1
1
  import React from "react";
2
- import { Text, View } from "react-native";
2
+ import { Animated, Text, View } from "react-native";
3
+ import { v4 as uuidV4 } from "uuid";
3
4
 
4
- import { TransitionerComponent } from "../Transitioner";
5
- import { render } from "@testing-library/react-native";
5
+ import { AnimationManager } from "../AnimationManager";
6
+ import {
7
+ NAV_ACTION_BACK,
8
+ NAV_ACTION_PUSH,
9
+ NAV_ACTION_REPLACE,
10
+ TransitionerComponent,
11
+ } from "../Transitioner";
12
+ import { act, render } from "@testing-library/react-native";
6
13
 
7
14
  jest.mock("../Scene", () => {
8
15
  const View = jest.requireActual("react-native").View;
@@ -40,6 +47,47 @@ const transitionerComponentProps = {
40
47
 
41
48
  jest.mock("uuid");
42
49
 
50
+ const sceneTransitionConfig = () => ({
51
+ push: {
52
+ duration: 0,
53
+ easing: jest.fn(),
54
+ from: { style: {} },
55
+ to: { style: {} },
56
+ },
57
+ back: {
58
+ duration: 0,
59
+ easing: jest.fn(),
60
+ from: { style: {} },
61
+ to: { style: {} },
62
+ },
63
+ });
64
+
65
+ const sceneFor = (route) => (
66
+ <View pathname={route}>
67
+ <Text>{route}</Text>
68
+ </View>
69
+ );
70
+
71
+ const root = "/river/root";
72
+ const child = `${root}/river/child`;
73
+ const grandchild = `${child}/river/grandchild`;
74
+
75
+ const propsFor = (route, previousAction, children = sceneFor(route)) => ({
76
+ navigator: {
77
+ previousAction,
78
+ currentRoute: route,
79
+ screenData: {},
80
+ },
81
+ transitionConfig: sceneTransitionConfig,
82
+ layoutData: {
83
+ isTablet: false,
84
+ isTabletPortrait: false,
85
+ },
86
+ children,
87
+ });
88
+
89
+ const sceneKeys = (ref) => ref.current.state.scenes.map((scene) => scene.key);
90
+
43
91
  describe("<TransitionerComponent />", () => {
44
92
  it("renders correctly", () => {
45
93
  const wrapper = render(
@@ -50,4 +98,325 @@ describe("<TransitionerComponent />", () => {
50
98
 
51
99
  expect(wrapper.toJSON()).toMatchSnapshot();
52
100
  });
101
+
102
+ it("removes both intermediate scenes when backing out to the tab root", () => {
103
+ jest
104
+ .spyOn(AnimationManager.prototype, "animate")
105
+ .mockImplementation((_, done) => done());
106
+
107
+ const ref = React.createRef();
108
+
109
+ const { rerender } = render(
110
+ <TransitionerComponent
111
+ ref={ref}
112
+ {...propsFor(root, NAV_ACTION_REPLACE)}
113
+ />
114
+ );
115
+
116
+ act(() => {
117
+ rerender(
118
+ <TransitionerComponent
119
+ ref={ref}
120
+ {...propsFor(child, NAV_ACTION_PUSH)}
121
+ />
122
+ );
123
+ });
124
+
125
+ act(() => {
126
+ rerender(
127
+ <TransitionerComponent
128
+ ref={ref}
129
+ {...propsFor(grandchild, NAV_ACTION_PUSH)}
130
+ />
131
+ );
132
+ });
133
+
134
+ act(() => {
135
+ rerender(
136
+ <TransitionerComponent ref={ref} {...propsFor(root, NAV_ACTION_BACK)} />
137
+ );
138
+ });
139
+
140
+ expect(sceneKeys(ref)).toEqual([root]);
141
+ });
142
+
143
+ describe("with interruptible animations", () => {
144
+ // End callback of the animation that is currently "running"
145
+ let runningAnimationEnd = null;
146
+
147
+ const finishAnimation = () => {
148
+ act(() => {
149
+ const done = runningAnimationEnd;
150
+ runningAnimationEnd = null;
151
+ done?.();
152
+ });
153
+ };
154
+
155
+ const mountTransitioner = ({
156
+ initialRoute = root,
157
+ renderScene = sceneFor,
158
+ } = {}) => {
159
+ const ref = React.createRef();
160
+
161
+ const transitioner = (route, action) => (
162
+ <TransitionerComponent
163
+ ref={ref}
164
+ {...propsFor(route, action, renderScene(route))}
165
+ />
166
+ );
167
+
168
+ const { rerender } = render(
169
+ transitioner(initialRoute, NAV_ACTION_REPLACE)
170
+ );
171
+
172
+ const navigate = (route, action) => {
173
+ act(() => {
174
+ rerender(transitioner(route, action));
175
+ });
176
+ };
177
+
178
+ return { ref, navigate };
179
+ };
180
+
181
+ beforeEach(() => {
182
+ let sceneCount = 0;
183
+ uuidV4.mockImplementation(() => `scene-${++sceneCount}`);
184
+
185
+ jest
186
+ .spyOn(AnimationManager.prototype, "animate")
187
+ .mockImplementation((_, done) => {
188
+ runningAnimationEnd = done;
189
+ });
190
+
191
+ const setValue = Animated.Value.prototype.setValue;
192
+
193
+ // RN 0.77: setValue() stops the running animation, which calls
194
+ // its end callback synchronously with { finished: false }
195
+ jest
196
+ .spyOn(Animated.Value.prototype, "setValue")
197
+ .mockImplementation(function (value) {
198
+ const interrupted = runningAnimationEnd;
199
+ runningAnimationEnd = null;
200
+ interrupted?.();
201
+
202
+ return setValue.call(this, value);
203
+ });
204
+ });
205
+
206
+ afterEach(() => {
207
+ runningAnimationEnd = null;
208
+ jest.restoreAllMocks();
209
+ uuidV4.mockReset();
210
+ });
211
+
212
+ it("drops interrupted scenes when going back twice during a back animation", () => {
213
+ const { ref, navigate } = mountTransitioner();
214
+
215
+ navigate(child, NAV_ACTION_PUSH);
216
+ finishAnimation();
217
+ navigate(grandchild, NAV_ACTION_PUSH);
218
+ finishAnimation();
219
+
220
+ navigate(child, NAV_ACTION_BACK);
221
+ navigate(root, NAV_ACTION_BACK);
222
+
223
+ expect(ref.current.state.animating).toBe(true);
224
+
225
+ finishAnimation();
226
+
227
+ expect(sceneKeys(ref)).toEqual([root]);
228
+ expect(ref.current.state.animating).toBe(false);
229
+ });
230
+
231
+ it("remounts the screen when revisiting it after a quick double back", () => {
232
+ const mounts = {};
233
+
234
+ const MountProbe = ({ pathname }) => {
235
+ React.useEffect(() => {
236
+ mounts[pathname] = (mounts[pathname] || 0) + 1;
237
+ }, [pathname]);
238
+
239
+ return <Text>{pathname}</Text>;
240
+ };
241
+
242
+ const { ref, navigate } = mountTransitioner({
243
+ renderScene: (route) => <MountProbe pathname={route} />,
244
+ });
245
+
246
+ navigate(child, NAV_ACTION_PUSH);
247
+ finishAnimation();
248
+
249
+ const originalChildId = ref.current.state.scenes[1].screenUniqueId;
250
+
251
+ navigate(grandchild, NAV_ACTION_PUSH);
252
+ finishAnimation();
253
+ navigate(child, NAV_ACTION_BACK);
254
+ navigate(root, NAV_ACTION_BACK);
255
+ finishAnimation();
256
+
257
+ navigate(child, NAV_ACTION_PUSH);
258
+ finishAnimation();
259
+
260
+ const { scenes, to } = ref.current.state;
261
+
262
+ expect(scenes).toHaveLength(2);
263
+ expect(to.index).toBe(1);
264
+ expect(scenes[1].screenUniqueId).not.toBe(originalChildId);
265
+ expect(mounts[child]).toBe(2);
266
+ });
267
+
268
+ it("keeps a single scene when going back from the first scene", () => {
269
+ const { ref, navigate } = mountTransitioner({ initialRoute: child });
270
+
271
+ navigate(root, NAV_ACTION_BACK);
272
+ finishAnimation();
273
+
274
+ expect(sceneKeys(ref)).toEqual([root]);
275
+ expect(ref.current.state.to.index).toBe(0);
276
+ expect(ref.current.state.animating).toBe(false);
277
+ });
278
+
279
+ it("pushes a fresh scene after a completed single back", () => {
280
+ const { ref, navigate } = mountTransitioner();
281
+
282
+ navigate(child, NAV_ACTION_PUSH);
283
+ finishAnimation();
284
+
285
+ const originalChildId = ref.current.state.scenes[1].screenUniqueId;
286
+
287
+ navigate(root, NAV_ACTION_BACK);
288
+ finishAnimation();
289
+ navigate(child, NAV_ACTION_PUSH);
290
+ finishAnimation();
291
+
292
+ const { scenes, to } = ref.current.state;
293
+
294
+ expect(sceneKeys(ref)).toEqual([root, child]);
295
+ expect(to.index).toBe(1);
296
+ expect(scenes[1].screenUniqueId).not.toBe(originalChildId);
297
+ });
298
+
299
+ it("ignores the end of a back animation that a newer back superseded", () => {
300
+ const { ref, navigate } = mountTransitioner();
301
+
302
+ navigate(child, NAV_ACTION_PUSH);
303
+ finishAnimation();
304
+ navigate(grandchild, NAV_ACTION_PUSH);
305
+ finishAnimation();
306
+
307
+ navigate(child, NAV_ACTION_BACK);
308
+
309
+ const supersededEnd = runningAnimationEnd;
310
+
311
+ // Deliver the interrupted animation's end late, after the newer one started
312
+ Animated.Value.prototype.setValue.mockImplementation(() => {});
313
+
314
+ navigate(root, NAV_ACTION_BACK);
315
+
316
+ act(() => {
317
+ supersededEnd();
318
+ });
319
+
320
+ expect(ref.current.state.animating).toBe(true);
321
+ expect(sceneKeys(ref)).toEqual([root, child, grandchild]);
322
+
323
+ finishAnimation();
324
+
325
+ expect(sceneKeys(ref)).toEqual([root]);
326
+ expect(ref.current.state.animating).toBe(false);
327
+ });
328
+
329
+ it("drops the pushed scene when going back before its push animation ends", () => {
330
+ const { ref, navigate } = mountTransitioner();
331
+
332
+ navigate(child, NAV_ACTION_PUSH);
333
+ navigate(root, NAV_ACTION_BACK);
334
+
335
+ expect(ref.current.state.animating).toBe(true);
336
+
337
+ finishAnimation();
338
+
339
+ expect(sceneKeys(ref)).toEqual([root]);
340
+ expect(ref.current.state.animating).toBe(false);
341
+ });
342
+
343
+ it("pushes right after the visible scene even when stale scenes are left behind", () => {
344
+ const { ref, navigate } = mountTransitioner();
345
+
346
+ navigate(child, NAV_ACTION_PUSH);
347
+ finishAnimation();
348
+
349
+ // Leftover past to.index, as some other path could leave behind
350
+ act(() => {
351
+ ref.current.setState(({ scenes }) => ({
352
+ scenes: [
353
+ ...scenes,
354
+ {
355
+ ...scenes[1],
356
+ key: `${root}/river/stale`,
357
+ screenUniqueId: "stale",
358
+ },
359
+ ],
360
+ }));
361
+ });
362
+
363
+ navigate(grandchild, NAV_ACTION_PUSH);
364
+ finishAnimation();
365
+
366
+ const { scenes, to } = ref.current.state;
367
+
368
+ expect(sceneKeys(ref)).toEqual([root, child, grandchild]);
369
+ expect(scenes[to.index].key).toBe(grandchild);
370
+ });
371
+
372
+ it("shows the replaced screen when going home during a back animation", () => {
373
+ const home = "/river/home";
374
+ const { ref, navigate } = mountTransitioner();
375
+
376
+ navigate(child, NAV_ACTION_PUSH);
377
+ finishAnimation();
378
+
379
+ navigate(root, NAV_ACTION_BACK);
380
+ navigate(home, NAV_ACTION_REPLACE);
381
+
382
+ const { scenes, to } = ref.current.state;
383
+
384
+ expect(scenes[to.index].key).toBe(home);
385
+
386
+ finishAnimation();
387
+
388
+ expect(sceneKeys(ref)).toEqual([home]);
389
+ expect(ref.current.state.animating).toBe(false);
390
+ });
391
+
392
+ it("shows the pushed screen when pushing during a back animation", () => {
393
+ const sibling = `${root}/river/sibling`;
394
+ const { ref, navigate } = mountTransitioner();
395
+
396
+ navigate(child, NAV_ACTION_PUSH);
397
+ finishAnimation();
398
+
399
+ navigate(root, NAV_ACTION_BACK);
400
+ navigate(sibling, NAV_ACTION_PUSH);
401
+
402
+ const { scenes, to } = ref.current.state;
403
+
404
+ expect(scenes[to.index].key).toBe(sibling);
405
+
406
+ finishAnimation();
407
+
408
+ expect(sceneKeys(ref)).not.toContain(child);
409
+ expect(ref.current.state.animating).toBe(false);
410
+ });
411
+
412
+ it("keeps both scenes when pushing during a push animation", () => {
413
+ const { ref, navigate } = mountTransitioner();
414
+
415
+ navigate(child, NAV_ACTION_PUSH);
416
+ navigate(grandchild, NAV_ACTION_PUSH);
417
+
418
+ expect(sceneKeys(ref)).toEqual([child, grandchild]);
419
+ expect(ref.current.state.to.index).toBe(1);
420
+ });
421
+ });
53
422
  });
@@ -213,10 +213,16 @@ export class LiveImageManager implements PlayerLifecycleListener {
213
213
  log_debug(
214
214
  `pauseItem: live-image not ready, ${item.playerId} - ${item.tag}`
215
215
  );
216
- } else {
217
- item.player.pause();
218
216
  }
219
217
 
218
+ // Pause even when the player is not ready yet: the underlying player may
219
+ // still be loading and would otherwise start playing on its own once it
220
+ // has buffered, leaving the live-image audible underneath the primary
221
+ // player. Note this is best effort - the web controller's pause() only
222
+ // logs while the player component is unmounted - so the player itself also
223
+ // refuses buffer-driven starts when it was never asked to play.
224
+ item.player.pause();
225
+
220
226
  // Fake close event, because we unmount native view
221
227
  item.player.onPlayerClose();
222
228
  item.setMode?.(LiveImageType.Image);
@@ -1,6 +1,8 @@
1
1
  const { LiveImageManager } = require("../LiveImageManager");
2
2
 
3
3
  describe("LiveImageManager", () => {
4
+ const flushPromises = () => new Promise(setImmediate);
5
+
4
6
  it("should subscribe to LiveImageManager events", () => {
5
7
  const sampleListener = {
6
8
  id: "1",
@@ -78,4 +80,61 @@ describe("LiveImageManager", () => {
78
80
  expect(mockOnEnd1).toHaveBeenCalledWith(eventBody);
79
81
  expect(mockOnEnd2).toHaveBeenCalledWith(eventBody);
80
82
  });
83
+
84
+ it("should start a prepared live image when the mounted player loads", async () => {
85
+ const manager = new LiveImageManager();
86
+
87
+ const item = {
88
+ playerId: "live-image",
89
+ tag: "Live image",
90
+ player: {
91
+ playerId: "live-image",
92
+ playerState: { isReadyToPlay: false },
93
+ getEntry: jest.fn(),
94
+ play: jest.fn(),
95
+ },
96
+ getPlayer: jest.fn(),
97
+ prepareForPlayback: jest.fn().mockResolvedValue(true),
98
+ setMode: jest.fn(),
99
+ };
100
+
101
+ manager.playLiveImage(item);
102
+ await flushPromises();
103
+
104
+ expect(item.setMode).toHaveBeenCalledWith("video");
105
+ expect(item.player.play).not.toHaveBeenCalled();
106
+
107
+ manager.onLiveImageVideoLoaded(item);
108
+
109
+ expect(item.player.play).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it("should not start a live image that was paused before the player loaded", async () => {
113
+ const manager = new LiveImageManager();
114
+
115
+ const item = {
116
+ playerId: "live-image",
117
+ tag: "Live image",
118
+ player: {
119
+ playerId: "live-image",
120
+ playerState: { isReadyToPlay: false },
121
+ getEntry: jest.fn(),
122
+ play: jest.fn(),
123
+ pause: jest.fn(),
124
+ onPlayerClose: jest.fn(),
125
+ },
126
+ getPlayer: jest.fn(),
127
+ prepareForPlayback: jest.fn().mockResolvedValue(true),
128
+ setMode: jest.fn(),
129
+ };
130
+
131
+ manager.playLiveImage(item);
132
+ await flushPromises();
133
+ manager.pauseLiveImage(item);
134
+ manager.onLiveImageVideoLoaded(item);
135
+
136
+ expect(item.player.pause).toHaveBeenCalledTimes(1);
137
+ expect(item.player.play).not.toHaveBeenCalled();
138
+ expect(item.setMode).toHaveBeenLastCalledWith("image");
139
+ });
81
140
  });