@ethora/chat-component-rn 26.10.3 → 26.10.4

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 (64) hide show
  1. package/lib/commonjs/components/Header/HeaderRoomList.js +72 -69
  2. package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
  3. package/lib/commonjs/components/MainComponents/ChatHeader.js +2 -1
  4. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  5. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js +203 -0
  6. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js.map +1 -0
  7. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +14 -32
  8. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  9. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +34 -97
  10. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  11. package/lib/commonjs/components/VideoCalls/callSessionController.js +43 -13
  12. package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -1
  13. package/lib/commonjs/components/VideoCalls/callUiStore.js +91 -0
  14. package/lib/commonjs/components/VideoCalls/callUiStore.js.map +1 -0
  15. package/lib/commonjs/components/VideoCalls/livekitRuntime.js +1 -0
  16. package/lib/commonjs/components/VideoCalls/livekitRuntime.js.map +1 -1
  17. package/lib/commonjs/i18n/strings.js +6 -0
  18. package/lib/commonjs/i18n/strings.js.map +1 -1
  19. package/lib/commonjs/version.js +1 -1
  20. package/lib/module/components/Header/HeaderRoomList.js +73 -70
  21. package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
  22. package/lib/module/components/MainComponents/ChatHeader.js +2 -1
  23. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  24. package/lib/module/components/VideoCalls/ActiveCallBar.js +195 -0
  25. package/lib/module/components/VideoCalls/ActiveCallBar.js.map +1 -0
  26. package/lib/module/components/VideoCalls/VideoCallOverlay.js +15 -33
  27. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  28. package/lib/module/components/VideoCalls/VideoCallSession.js +35 -99
  29. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  30. package/lib/module/components/VideoCalls/callSessionController.js +43 -13
  31. package/lib/module/components/VideoCalls/callSessionController.js.map +1 -1
  32. package/lib/module/components/VideoCalls/callUiStore.js +84 -0
  33. package/lib/module/components/VideoCalls/callUiStore.js.map +1 -0
  34. package/lib/module/components/VideoCalls/livekitRuntime.js +1 -0
  35. package/lib/module/components/VideoCalls/livekitRuntime.js.map +1 -1
  36. package/lib/module/i18n/strings.js +6 -0
  37. package/lib/module/i18n/strings.js.map +1 -1
  38. package/lib/module/version.js +1 -1
  39. package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
  40. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  41. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts +21 -0
  42. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts.map +1 -0
  43. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  44. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +7 -3
  45. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  46. package/lib/typescript/components/VideoCalls/callSessionController.d.ts +2 -0
  47. package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -1
  48. package/lib/typescript/components/VideoCalls/callUiStore.d.ts +24 -0
  49. package/lib/typescript/components/VideoCalls/callUiStore.d.ts.map +1 -0
  50. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts +2 -0
  51. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts.map +1 -1
  52. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  53. package/lib/typescript/version.d.ts +1 -1
  54. package/package.json +1 -1
  55. package/src/components/Header/HeaderRoomList.tsx +66 -61
  56. package/src/components/MainComponents/ChatHeader.tsx +3 -0
  57. package/src/components/VideoCalls/ActiveCallBar.tsx +176 -0
  58. package/src/components/VideoCalls/VideoCallOverlay.tsx +11 -34
  59. package/src/components/VideoCalls/VideoCallSession.tsx +42 -83
  60. package/src/components/VideoCalls/callSessionController.ts +37 -19
  61. package/src/components/VideoCalls/callUiStore.ts +76 -0
  62. package/src/components/VideoCalls/livekitRuntime.ts +3 -0
  63. package/src/i18n/strings.ts +6 -0
  64. package/src/version.ts +1 -1
@@ -0,0 +1,176 @@
1
+ import React, { FC, useEffect, useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3
+ import { useSelector } from 'react-redux';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { RootState } from '../../roomStore';
6
+ import { useChatSettingState } from '../../hooks/useChatSettingState';
7
+ import { useTheme } from '../../hooks/useTheme';
8
+ import { useT } from '../../i18n/useT';
9
+ import { ExpandIcon, HangUpIcon, MicOffIcon, MicOnIcon } from './CallIcons';
10
+ import { toggleCallMic, useCallSession } from './callSessionController';
11
+ import { terminateActiveCall } from './callActions';
12
+ import { registerCallBarHost, setCallMinimized, useCallUi } from './callUiStore';
13
+ import { formatCallDuration, useSharedCallElapsed } from './VideoCallSession';
14
+
15
+ const DANGER = '#E53935';
16
+ const ON_BAR = '#FFFFFF';
17
+ const CONTROL = 'rgba(255, 255, 255, 0.2)';
18
+
19
+ /** True while a call is up and minimized, i.e. the strip should show. */
20
+ const useShowCallBar = (): boolean => {
21
+ const { minimized } = useCallUi();
22
+ const active = useSelector(
23
+ (state: RootState) =>
24
+ !!state.call.token &&
25
+ (state.call.phase === 'connecting' || state.call.phase === 'in-call')
26
+ );
27
+ const { config } = useChatSettingState();
28
+ return minimized && active && config?.videoCalls?.enabled === true;
29
+ };
30
+
31
+ /**
32
+ * The minimized call: a strip with who, how long, mic, expand and hang up.
33
+ * Tapping anywhere on it (outside the buttons) brings the call back
34
+ * full-screen, like Telegram's "return to call" bar.
35
+ */
36
+ export const ActiveCallBar: FC<{ topInset?: number }> = ({ topInset = 0 }) => {
37
+ const t = useT();
38
+ const theme = useTheme();
39
+ const { icons } = useChatSettingState().config?.videoCalls || {};
40
+ const peerName = useSelector((state: RootState) => state.call.roomName);
41
+ const session = useCallSession();
42
+ const elapsed = useSharedCallElapsed(session.anchorAt);
43
+
44
+ const name = peerName || session.remoteName || t('call.unknownCaller');
45
+ const status =
46
+ elapsed !== null
47
+ ? formatCallDuration(elapsed)
48
+ : !session.connected
49
+ ? t('call.status.connecting')
50
+ : !session.remoteJoined
51
+ ? t('call.status.waitingForPeer')
52
+ : t('call.status.inCall');
53
+ const statusLine = session.remoteMicMuted
54
+ ? `${status} · ${t('call.peerMuted', { name })}`
55
+ : status;
56
+ const expand = () => setCallMinimized(false);
57
+
58
+ return (
59
+ <Pressable
60
+ onPress={expand}
61
+ accessibilityRole="button"
62
+ accessibilityLabel={t('call.action.expand')}
63
+ style={[styles.bar, { backgroundColor: theme.success, paddingTop: 8 + topInset }]}
64
+ >
65
+ <View style={styles.info}>
66
+ <Text numberOfLines={1} style={styles.name}>
67
+ {name}
68
+ </Text>
69
+ <Text numberOfLines={1} style={styles.status}>
70
+ {statusLine}
71
+ </Text>
72
+ </View>
73
+ <TouchableOpacity
74
+ accessibilityRole="button"
75
+ accessibilityLabel={session.micOn ? t('call.action.muteMic') : t('call.action.unmuteMic')}
76
+ onPress={toggleCallMic}
77
+ style={[styles.button, { backgroundColor: session.micOn ? CONTROL : DANGER }]}
78
+ hitSlop={6}
79
+ >
80
+ {session.micOn
81
+ ? (icons?.micOn ?? <MicOnIcon color={ON_BAR} size={18} />)
82
+ : (icons?.micOff ?? <MicOffIcon color={ON_BAR} size={18} />)}
83
+ </TouchableOpacity>
84
+ <TouchableOpacity
85
+ accessibilityRole="button"
86
+ accessibilityLabel={t('call.action.expand')}
87
+ onPress={expand}
88
+ style={[styles.button, { backgroundColor: CONTROL }]}
89
+ hitSlop={6}
90
+ >
91
+ <ExpandIcon color={ON_BAR} size={18} />
92
+ </TouchableOpacity>
93
+ <TouchableOpacity
94
+ accessibilityRole="button"
95
+ accessibilityLabel={t('call.action.hangUp')}
96
+ onPress={() => terminateActiveCall('ended')}
97
+ style={[styles.button, { backgroundColor: DANGER }]}
98
+ hitSlop={6}
99
+ >
100
+ {icons?.hangup ?? <HangUpIcon color={ON_BAR} size={18} />}
101
+ </TouchableOpacity>
102
+ </Pressable>
103
+ );
104
+ };
105
+
106
+ /**
107
+ * Mounted under each SDK header (chat and room list). Registers the header
108
+ * as a place for the strip and renders it while this header is the current
109
+ * host and a call is minimized; renders nothing otherwise.
110
+ */
111
+ export const HeaderCallBar: FC = () => {
112
+ const [hostId, setHostId] = useState<number | null>(null);
113
+ const { barHost } = useCallUi();
114
+ const show = useShowCallBar();
115
+
116
+ // Returns its own unregister, which is the effect's cleanup.
117
+ useEffect(() => registerCallBarHost(setHostId), []);
118
+
119
+ if (!show || hostId === null || barHost !== hostId) {return null;}
120
+ return <ActiveCallBar />;
121
+ };
122
+
123
+ /**
124
+ * The overlay's fallback when no SDK header is on screen: the same strip,
125
+ * docked to the top under the status bar.
126
+ */
127
+ export const DockedCallBar: FC = () => {
128
+ const insets = useSafeAreaInsets();
129
+ const { barHost } = useCallUi();
130
+ const show = useShowCallBar();
131
+ if (!show || barHost !== null) {return null;}
132
+ return (
133
+ <View style={styles.docked} pointerEvents="box-none">
134
+ <ActiveCallBar topInset={insets.top} />
135
+ </View>
136
+ );
137
+ };
138
+
139
+ const styles = StyleSheet.create({
140
+ bar: {
141
+ flexDirection: 'row',
142
+ alignItems: 'center',
143
+ gap: 10,
144
+ paddingHorizontal: 16,
145
+ paddingBottom: 8,
146
+ },
147
+ info: {
148
+ flex: 1,
149
+ minWidth: 0,
150
+ },
151
+ name: {
152
+ color: ON_BAR,
153
+ fontSize: 15,
154
+ fontWeight: '600',
155
+ },
156
+ status: {
157
+ color: 'rgba(255, 255, 255, 0.85)',
158
+ fontSize: 12,
159
+ fontVariant: ['tabular-nums'],
160
+ },
161
+ button: {
162
+ width: 34,
163
+ height: 34,
164
+ borderRadius: 17,
165
+ alignItems: 'center',
166
+ justifyContent: 'center',
167
+ },
168
+ docked: {
169
+ position: 'absolute',
170
+ top: 0,
171
+ left: 0,
172
+ right: 0,
173
+ zIndex: 1001,
174
+ elevation: 12,
175
+ },
176
+ });
@@ -6,7 +6,6 @@ import React, {
6
6
  useEffect,
7
7
  useMemo,
8
8
  useRef,
9
- useState,
10
9
  } from 'react';
11
10
  import {
12
11
  Animated,
@@ -34,7 +33,8 @@ import type { ChatTheme } from '../../theme/theme';
34
33
  import { VideoCallSession } from './VideoCallSession';
35
34
  import { startCallSession } from './callSessionController';
36
35
  import { declineActiveCall, terminateActiveCall } from './callActions';
37
- import { useDraggable } from './useDraggable';
36
+ import { DockedCallBar } from './ActiveCallBar';
37
+ import { setCallMinimized, useCallUi } from './callUiStore';
38
38
  import { ProfileImagePlaceholder } from '../MainComponents/ProfileImagePlaceholder';
39
39
  import {
40
40
  AudioCallIcon,
@@ -287,8 +287,8 @@ class CallErrorBoundary extends Component<
287
287
  * 'idle'.
288
288
  *
289
289
  * The LiveKit connection itself lives in callSessionController.ts, so
290
- * switching between the full-screen Modal and the minimized panel (two
291
- * different trees here) no longer drops the call.
290
+ * switching between the full-screen Modal and the minimized header strip
291
+ * (two different trees) no longer drops the call.
292
292
  */
293
293
  const VideoCallOverlayInner: FC = () => {
294
294
  const dispatch = useDispatch();
@@ -296,7 +296,6 @@ const VideoCallOverlayInner: FC = () => {
296
296
  const { config } = useChatSettingState();
297
297
  const theme = useTheme();
298
298
  const t = useT();
299
- const panel = useDraggable();
300
299
 
301
300
  const videoCallsConfig = config?.videoCalls;
302
301
  const enabled = videoCallsConfig?.enabled === true;
@@ -310,7 +309,8 @@ const VideoCallOverlayInner: FC = () => {
310
309
  // fallback and always renders.
311
310
  useCallKeep();
312
311
 
313
- const [minimized, setMinimized] = useState(false);
312
+ // Shared with the header strip (ActiveCallBar), which expands it again.
313
+ const { minimized } = useCallUi();
314
314
 
315
315
  const canRenderSession =
316
316
  !!call.token &&
@@ -332,12 +332,6 @@ const VideoCallOverlayInner: FC = () => {
332
332
  // eslint-disable-next-line react-hooks/exhaustive-deps
333
333
  }, [enabled, canRenderSession, call.token, livekitUrl, call.kind]);
334
334
 
335
- // Reset the minimized flag whenever the call ends, so the next call
336
- // opens full-screen.
337
- useEffect(() => {
338
- if (call.phase === 'idle') {setMinimized(false);}
339
- }, [call.phase]);
340
-
341
335
  // Give up on an unanswered outgoing dial. Unlike web (which only shows
342
336
  // the error), also tell the callee, who would otherwise keep ringing:
343
337
  // there is no incoming ring timeout on either platform.
@@ -422,25 +416,16 @@ const VideoCallOverlayInner: FC = () => {
422
416
  peerName={call.roomName}
423
417
  primaryColor={primaryColor}
424
418
  icons={videoCallsConfig?.icons}
425
- minimized={isMinimized}
426
- onToggleMinimize={() => setMinimized((m) => !m)}
419
+ onToggleMinimize={() => setCallMinimized(!minimized)}
427
420
  onHangup={() => terminateActiveCall('ended')}
428
421
  />
429
422
  );
430
423
 
431
- // The minimized session is NOT a Modal: a modal is by definition on top
432
- // of everything and swallows touches, which is the opposite of what
433
- // minimize is for (keep talking, keep using the chat). It renders as a
434
- // floating bar the user can drag out of the way, like web's panel.
424
+ // Minimized, the call is a strip under the visible SDK header
425
+ // (HeaderCallBar), Telegram-style, so the chat stays fully usable. With
426
+ // no SDK header on screen the same strip docks to the top instead.
435
427
  if (isMinimized) {
436
- return (
437
- <Animated.View
438
- {...panel.panHandlers}
439
- style={[styles.floatingPanel, panel.style]}
440
- >
441
- {session}
442
- </Animated.View>
443
- );
428
+ return <DockedCallBar />;
444
429
  }
445
430
 
446
431
  return (
@@ -527,14 +512,6 @@ const styles = StyleSheet.create({
527
512
  flex: 1,
528
513
  backgroundColor: '#0B1220',
529
514
  },
530
- floatingPanel: {
531
- position: 'absolute',
532
- right: 16,
533
- bottom: 24,
534
- left: 16,
535
- zIndex: 1001,
536
- elevation: 12,
537
- },
538
515
  ringingCard: {
539
516
  width: '100%',
540
517
  maxWidth: 420,
@@ -2,6 +2,7 @@ import React, { FC, useEffect, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
4
  Animated,
5
+ Pressable,
5
6
  StyleSheet,
6
7
  Text,
7
8
  TouchableOpacity,
@@ -17,7 +18,6 @@ import {
17
18
  MicOffIcon,
18
19
  MicOnIcon,
19
20
  MinimizeIcon,
20
- ExpandIcon,
21
21
  SpeakerIcon,
22
22
  SpeakerOffIcon,
23
23
  SwitchCameraIcon,
@@ -47,9 +47,7 @@ export interface VideoCallSessionProps {
47
47
  peerName?: string | null;
48
48
  primaryColor?: string;
49
49
  icons?: VideoCallIcons;
50
- /** Render the compact floating-panel layout instead of the full screen. */
51
- minimized?: boolean;
52
- /** Toggle between full and minimized layouts. */
50
+ /** Minimize to the header strip (ActiveCallBar). */
53
51
  onToggleMinimize?: () => void;
54
52
  onHangup: () => void;
55
53
  }
@@ -59,7 +57,7 @@ export interface VideoCallSessionProps {
59
57
  * `joinedAt` anchor so both sides show the same time (web's
60
58
  * useSharedCallElapsed). Null until the peer is in.
61
59
  */
62
- const useSharedCallElapsed = (anchorAt: number | null): number | null => {
60
+ export const useSharedCallElapsed = (anchorAt: number | null): number | null => {
63
61
  const [, tick] = useState(0);
64
62
  useEffect(() => {
65
63
  if (anchorAt === null) {return;}
@@ -94,7 +92,6 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
94
92
  peerName,
95
93
  primaryColor = DEFAULT_PRIMARY,
96
94
  icons,
97
- minimized = false,
98
95
  onToggleMinimize,
99
96
  onHangup,
100
97
  }) => {
@@ -104,6 +101,8 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
104
101
  const session = useCallSession();
105
102
  const selfView = useDraggable();
106
103
  const elapsed = useSharedCallElapsed(session.anchorAt);
104
+ // Telegram-style: tap the small tile to swap it with the big one.
105
+ const [swapped, setSwapped] = useState(false);
107
106
 
108
107
  if (!runtime) {
109
108
  return (
@@ -124,12 +123,22 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
124
123
  speakerOn,
125
124
  deviceError,
126
125
  remoteMicMuted,
126
+ facingMode,
127
127
  } = session;
128
128
  // The call slice's name (the chat title) first, as web does: LiveKit's
129
129
  // participant name is often just the opaque xmpp identity.
130
130
  const displayName = peerName || session.remoteName || t('call.unknownCaller');
131
131
  const isVideo = kind === 'video';
132
132
  const showRemoteVideo = isVideo && !!remoteVideo;
133
+ const localLive = isVideo && cameraOn && !!localVideo;
134
+ // Swapping only means something while both sides have a picture; if
135
+ // either goes away, fall back to the default layout on its own.
136
+ const canSwap = showRemoteVideo && localLive;
137
+ const isSwapped = swapped && canSwap;
138
+ const mainVideo = isSwapped ? localVideo : showRemoteVideo ? remoteVideo : null;
139
+ const pipVideo = isSwapped ? remoteVideo : localLive ? localVideo : null;
140
+ // Only the front camera is mirrored, as in every camera app.
141
+ const selfMirror = facingMode === 'user';
133
142
 
134
143
  const statusText =
135
144
  elapsed !== null
@@ -142,54 +151,18 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
142
151
 
143
152
  const peerMutedLabel = t('call.peerMuted', { name: displayName });
144
153
 
145
- if (minimized) {
146
- return (
147
- <View style={styles.miniBar}>
148
- <ProfileImagePlaceholder name={displayName} size={36} />
149
- <View style={styles.miniInfo}>
150
- <Text numberOfLines={1} style={styles.miniName}>
151
- {displayName}
152
- </Text>
153
- <Text numberOfLines={1} style={styles.miniStatus}>
154
- {remoteMicMuted ? `${statusText} · ${peerMutedLabel}` : statusText}
155
- </Text>
156
- </View>
157
- <TouchableOpacity
158
- accessibilityRole="button"
159
- accessibilityLabel={micOn ? t('call.action.muteMic') : t('call.action.unmuteMic')}
160
- onPress={toggleCallMic}
161
- style={[styles.miniButton, { backgroundColor: micOn ? CONTROL_IDLE : DANGER }]}
162
- >
163
- {micOn
164
- ? (icons?.micOn ?? <MicOnIcon color={TEXT_ON_DARK} size={20} />)
165
- : (icons?.micOff ?? <MicOffIcon color={TEXT_ON_DARK} size={20} />)}
166
- </TouchableOpacity>
167
- <TouchableOpacity
168
- accessibilityRole="button"
169
- accessibilityLabel={t('call.action.expand')}
170
- onPress={onToggleMinimize}
171
- style={[styles.miniButton, { backgroundColor: CONTROL_IDLE }]}
172
- >
173
- <ExpandIcon color={TEXT_ON_DARK} size={20} />
174
- </TouchableOpacity>
175
- <TouchableOpacity
176
- accessibilityRole="button"
177
- accessibilityLabel={t('call.action.hangUp')}
178
- onPress={onHangup}
179
- style={[styles.miniButton, { backgroundColor: DANGER }]}
180
- >
181
- {icons?.hangup ?? <HangUpIcon color={TEXT_ON_DARK} size={20} />}
182
- </TouchableOpacity>
183
- </View>
184
- );
185
- }
186
-
187
154
  return (
188
155
  <View style={styles.canvas}>
189
156
  {/* Stage */}
190
157
  <View style={styles.stage}>
191
- {showRemoteVideo ? (
192
- <VideoTrack trackRef={remoteVideo} style={styles.remoteVideo} objectFit="cover" />
158
+ {mainVideo ? (
159
+ <VideoTrack
160
+ trackRef={mainVideo}
161
+ style={styles.remoteVideo}
162
+ objectFit="cover"
163
+ mirror={isSwapped && selfMirror}
164
+ zOrder={0}
165
+ />
193
166
  ) : (
194
167
  <View style={styles.centered}>
195
168
  <ProfileImagePlaceholder name={displayName} size={120} />
@@ -216,7 +189,7 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
216
189
  )}
217
190
 
218
191
  {/* Header strip: who, how long, and whether they can hear us. */}
219
- {showRemoteVideo && (
192
+ {mainVideo && (
220
193
  <View style={[styles.header, { paddingTop: 14 + insets.top }]}>
221
194
  <Text style={styles.headerName} numberOfLines={1}>
222
195
  {displayName}
@@ -231,13 +204,28 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
231
204
  </View>
232
205
  )}
233
206
 
234
- {/* Local preview, picture-in-picture, draggable like web's self-view. */}
235
- {isVideo && cameraOn && localVideo && (
207
+ {/* Picture-in-picture: draggable like web's self-view, tap to swap
208
+ it with the main video. */}
209
+ {pipVideo && (
236
210
  <Animated.View
237
211
  {...selfView.panHandlers}
238
212
  style={[styles.pip, { top: 16 + insets.top }, selfView.style]}
239
213
  >
240
- <VideoTrack trackRef={localVideo} style={styles.pipVideo} objectFit="cover" mirror />
214
+ <Pressable
215
+ style={styles.pipVideo}
216
+ disabled={!canSwap}
217
+ onPress={() => setSwapped((value) => !value)}
218
+ accessibilityRole="button"
219
+ accessibilityLabel={t('call.action.swapVideo')}
220
+ >
221
+ <VideoTrack
222
+ trackRef={pipVideo}
223
+ style={styles.pipVideo}
224
+ objectFit="cover"
225
+ mirror={!isSwapped && selfMirror}
226
+ zOrder={1}
227
+ />
228
+ </Pressable>
241
229
  </Animated.View>
242
230
  )}
243
231
 
@@ -475,35 +463,6 @@ const styles = StyleSheet.create({
475
463
  fontSize: 13,
476
464
  textAlign: 'center',
477
465
  },
478
- miniBar: {
479
- flexDirection: 'row',
480
- alignItems: 'center',
481
- gap: 10,
482
- paddingHorizontal: 12,
483
- paddingVertical: 10,
484
- borderRadius: 16,
485
- backgroundColor: SURFACE_DARK,
486
- },
487
- miniInfo: {
488
- flex: 1,
489
- minWidth: 0,
490
- },
491
- miniName: {
492
- color: TEXT_ON_DARK,
493
- fontSize: 15,
494
- },
495
- miniStatus: {
496
- color: TEXT_MUTED,
497
- fontSize: 12,
498
- fontVariant: ['tabular-nums'],
499
- },
500
- miniButton: {
501
- width: 40,
502
- height: 40,
503
- borderRadius: 20,
504
- alignItems: 'center',
505
- justifyContent: 'center',
506
- },
507
466
  });
508
467
 
509
468
  export default VideoCallSession;
@@ -31,6 +31,8 @@ export interface CallSessionSnapshot {
31
31
  micOn: boolean;
32
32
  cameraOn: boolean;
33
33
  speakerOn: boolean;
34
+ /** Which camera the local video comes from; only the front one is mirrored. */
35
+ facingMode: 'user' | 'environment';
34
36
  /** `{ participant, publication, source }` for `<VideoTrack />`, live and unmuted only. */
35
37
  remoteVideo: any;
36
38
  localVideo: any;
@@ -53,6 +55,7 @@ const IDLE: CallSessionSnapshot = {
53
55
  micOn: true,
54
56
  cameraOn: false,
55
57
  speakerOn: false,
58
+ facingMode: 'user',
56
59
  remoteVideo: null,
57
60
  localVideo: null,
58
61
  remoteName: null,
@@ -224,12 +227,24 @@ export const startCallSession = ({
224
227
  }
225
228
 
226
229
  ensureLiveKitGlobals(runtime);
227
- const { Room, RoomEvent, AudioSession } = runtime;
228
-
229
- // adaptiveStream + dynacast keep mobile data and battery sane: the SDK
230
- // drops resolution for a small tile and stops publishing layers nobody
231
- // subscribes to. Both are safe defaults for 1:1.
232
- const room = new Room({ adaptiveStream: true, dynacast: true });
230
+ const { Room, RoomEvent, AudioSession, VideoPresets } = runtime;
231
+
232
+ // The web session's options, verbatim. `pixelDensity: 'screen'` matters:
233
+ // with a plain `adaptiveStream: true` the subscriber asks for a layer
234
+ // sized to the view in POINTS, i.e. 2-3x below the phone's real pixels,
235
+ // and the peer's video arrived visibly blurry next to web.
236
+ const room = new Room({
237
+ adaptiveStream: { pixelDensity: 'screen' },
238
+ dynacast: true,
239
+ ...(VideoPresets
240
+ ? {
241
+ publishDefaults: {
242
+ videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216],
243
+ },
244
+ videoCaptureDefaults: { resolution: VideoPresets.h720.resolution },
245
+ }
246
+ : {}),
247
+ });
233
248
  const current: Session = { key, room, kind, disposed: false, everJoined: false };
234
249
  session = current;
235
250
  snapshot = {
@@ -368,22 +383,25 @@ export const switchCallCamera = async (): Promise<void> => {
368
383
  const current = session;
369
384
  const runtime = loadLiveKit();
370
385
  if (!current || !runtime) {return;}
386
+ const mediaTrack = current.room.localParticipant.getTrackPublication?.(
387
+ runtime.Track.Source.Camera
388
+ )?.track?.mediaStreamTrack;
389
+ if (!mediaTrack?.applyConstraints) {return;}
390
+ const next = snapshot.facingMode === 'user' ? 'environment' : 'user';
371
391
  try {
372
- const publication = current.room.localParticipant.getTrackPublication?.(
373
- runtime.Track.Source.Camera
374
- );
375
- const track = publication?.track;
376
- // `restartTrack` with the flipped facingMode is the portable way to
377
- // switch cameras; the RN SDK's `_switchCamera` helper is internal.
378
- await track?.restartTrack?.({
379
- facingMode:
380
- track?.mediaStreamTrack?.getSettings?.().facingMode === 'environment'
381
- ? 'user'
382
- : 'environment',
383
- });
392
+ // Swap the capture device inside the SAME track (react-native-webrtc's
393
+ // own camera switch). `restartTrack` stopped the published track and
394
+ // made a new one, and the native view kept showing the stopped stream:
395
+ // the picture froze on the switch.
396
+ // Keep the current capture settings (resolution, frame rate) and only
397
+ // flip the camera, exactly like the library's `_switchCamera`.
398
+ const constraints = { ...(mediaTrack.getSettings?.() || {}) };
399
+ delete constraints.deviceId;
400
+ await mediaTrack.applyConstraints({ ...constraints, facingMode: next });
401
+ if (!current.disposed) {emit({ facingMode: next });}
384
402
  resync();
385
403
  } catch {
386
- // Single camera, or the track is mid-restart.
404
+ // Single camera, or the switch is still in flight.
387
405
  }
388
406
  };
389
407
 
@@ -0,0 +1,76 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { store } from '../../roomStore';
3
+
4
+ /**
5
+ * UI state of the call shared between the overlay and the header bar:
6
+ * whether the call is minimized, and which header (if any) currently hosts
7
+ * the minimized bar.
8
+ *
9
+ * Minimized, the call is a strip under the visible header (ChatHeader or
10
+ * HeaderRoomList), Telegram-style, instead of a floating panel. Each header
11
+ * registers itself while mounted; the most recently mounted one shows the
12
+ * strip (on an iPad split view both are mounted, and only one strip should
13
+ * appear). With no header mounted (the user is on a host-app screen) the
14
+ * overlay docks the same strip to the top of the screen.
15
+ */
16
+
17
+ interface CallUiState {
18
+ minimized: boolean;
19
+ /** Id of the header that shows the strip, or null when none is mounted. */
20
+ barHost: number | null;
21
+ }
22
+
23
+ let state: CallUiState = { minimized: false, barHost: null };
24
+ const hosts: number[] = [];
25
+ let nextHostId = 1;
26
+ const listeners = new Set<() => void>();
27
+ let storeUnsubscribe: (() => void) | null = null;
28
+
29
+ const emit = (patch: Partial<CallUiState>) => {
30
+ state = { ...state, ...patch };
31
+ listeners.forEach((listener) => listener());
32
+ };
33
+
34
+ // The next call always opens full-screen.
35
+ const ensureStoreWatcher = () => {
36
+ if (storeUnsubscribe) {return;}
37
+ storeUnsubscribe = store.subscribe(() => {
38
+ if (state.minimized && store.getState().call.phase === 'idle') {
39
+ emit({ minimized: false });
40
+ }
41
+ });
42
+ };
43
+
44
+ const subscribe = (listener: () => void) => {
45
+ ensureStoreWatcher();
46
+ listeners.add(listener);
47
+ return () => {
48
+ listeners.delete(listener);
49
+ };
50
+ };
51
+
52
+ const getSnapshot = () => state;
53
+
54
+ /** Current state, for non-React code and tests. */
55
+ export const getCallUiState = (): CallUiState => state;
56
+
57
+ export const useCallUi = (): CallUiState =>
58
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
59
+
60
+ export const setCallMinimized = (minimized: boolean): void => {
61
+ if (state.minimized !== minimized) {emit({ minimized });}
62
+ };
63
+
64
+ export const registerCallBarHost = (
65
+ onId?: (id: number) => void
66
+ ): (() => void) => {
67
+ const id = nextHostId++;
68
+ hosts.push(id);
69
+ onId?.(id);
70
+ emit({ barHost: id });
71
+ return () => {
72
+ const index = hosts.indexOf(id);
73
+ if (index >= 0) {hosts.splice(index, 1);}
74
+ emit({ barHost: hosts.length ? hosts[hosts.length - 1] : null });
75
+ };
76
+ };
@@ -26,6 +26,8 @@ export interface LiveKitRuntime {
26
26
  Track: any;
27
27
  /** livekit-client `ConnectionState` enum. */
28
28
  ConnectionState: any;
29
+ /** livekit-client `VideoPresets` (h540, h216...). */
30
+ VideoPresets: any;
29
31
  /** RN `<VideoTrack trackRef={...} />` renderer. */
30
32
  VideoTrack: any;
31
33
  /** Native audio session control (routing, speaker, focus). */
@@ -63,6 +65,7 @@ export const loadLiveKit = (): LiveKitRuntime | null => {
63
65
  RoomEvent: core.RoomEvent,
64
66
  Track: core.Track,
65
67
  ConnectionState: core.ConnectionState,
68
+ VideoPresets: core.VideoPresets,
66
69
  VideoTrack: rn.VideoTrack,
67
70
  AudioSession: rn.AudioSession,
68
71
  registerGlobals: rn.registerGlobals,