@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.
- package/lib/commonjs/components/Header/HeaderRoomList.js +72 -69
- package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatHeader.js +2 -1
- package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/ActiveCallBar.js +203 -0
- package/lib/commonjs/components/VideoCalls/ActiveCallBar.js.map +1 -0
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +14 -32
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/VideoCallSession.js +34 -97
- package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/callSessionController.js +43 -13
- package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/callUiStore.js +91 -0
- package/lib/commonjs/components/VideoCalls/callUiStore.js.map +1 -0
- package/lib/commonjs/components/VideoCalls/livekitRuntime.js +1 -0
- package/lib/commonjs/components/VideoCalls/livekitRuntime.js.map +1 -1
- package/lib/commonjs/i18n/strings.js +6 -0
- package/lib/commonjs/i18n/strings.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/components/Header/HeaderRoomList.js +73 -70
- package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
- package/lib/module/components/MainComponents/ChatHeader.js +2 -1
- package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
- package/lib/module/components/VideoCalls/ActiveCallBar.js +195 -0
- package/lib/module/components/VideoCalls/ActiveCallBar.js.map +1 -0
- package/lib/module/components/VideoCalls/VideoCallOverlay.js +15 -33
- package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/module/components/VideoCalls/VideoCallSession.js +35 -99
- package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
- package/lib/module/components/VideoCalls/callSessionController.js +43 -13
- package/lib/module/components/VideoCalls/callSessionController.js.map +1 -1
- package/lib/module/components/VideoCalls/callUiStore.js +84 -0
- package/lib/module/components/VideoCalls/callUiStore.js.map +1 -0
- package/lib/module/components/VideoCalls/livekitRuntime.js +1 -0
- package/lib/module/components/VideoCalls/livekitRuntime.js.map +1 -1
- package/lib/module/i18n/strings.js +6 -0
- package/lib/module/i18n/strings.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts +21 -0
- package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts.map +1 -0
- package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +7 -3
- package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/callSessionController.d.ts +2 -0
- package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/callUiStore.d.ts +24 -0
- package/lib/typescript/components/VideoCalls/callUiStore.d.ts.map +1 -0
- package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts +2 -0
- package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts.map +1 -1
- package/lib/typescript/i18n/strings.d.ts.map +1 -1
- package/lib/typescript/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Header/HeaderRoomList.tsx +66 -61
- package/src/components/MainComponents/ChatHeader.tsx +3 -0
- package/src/components/VideoCalls/ActiveCallBar.tsx +176 -0
- package/src/components/VideoCalls/VideoCallOverlay.tsx +11 -34
- package/src/components/VideoCalls/VideoCallSession.tsx +42 -83
- package/src/components/VideoCalls/callSessionController.ts +37 -19
- package/src/components/VideoCalls/callUiStore.ts +76 -0
- package/src/components/VideoCalls/livekitRuntime.ts +3 -0
- package/src/i18n/strings.ts +6 -0
- 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 {
|
|
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
|
|
291
|
-
* different trees
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
onToggleMinimize={() => setMinimized((m) => !m)}
|
|
419
|
+
onToggleMinimize={() => setCallMinimized(!minimized)}
|
|
427
420
|
onHangup={() => terminateActiveCall('ended')}
|
|
428
421
|
/>
|
|
429
422
|
);
|
|
430
423
|
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
{
|
|
192
|
-
<VideoTrack
|
|
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
|
-
{
|
|
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
|
-
{/*
|
|
235
|
-
|
|
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
|
-
<
|
|
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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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,
|