feat(call): implement CallKeep integration and enhance group call support
Some checks failed
Frontend CI / ota-android (push) Successful in 1m27s
Frontend CI / build-and-push-web (push) Failing after 1m28s
Frontend CI / ota-ios (push) Successful in 2m58s
Frontend CI / build-android-apk (push) Successful in 51m46s

Integrate `expo-callkit-telecom` to support native system call handling (answering, ending, and muting via system UI). This includes a new `callKeepService` and a `CallKeepBootstrap` component to manage the lifecycle of system call events.

Additionally, improves the calling experience by:
- Adding support for group calls with participant tracking and UI indicators.
- Enhancing LiveKit integration by replacing polling with event-driven video track synchronization.
- Updating `WebSocketService` to prevent disconnection when the app enters the background during an active call.
- Adding new WebSocket message types for participant join/leave events and group invites.
- Refining call UI components (`CallScreen`, `FloatingCallWindow`, `IncomingCallModal`) for better visual feedback and safe area handling.

Refactor LiveKit service to use event-driven updates for local and remote tracks, improving performance and reliability.
This commit is contained in:
2026-06-03 01:56:02 +08:00
parent d6a94c7b4d
commit 2e6912dddf
18 changed files with 1635 additions and 507 deletions

View File

@@ -40,10 +40,27 @@ const CallScreen: React.FC = () => {
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
// Subscribe to LiveKit track events
// Event-driven video track subscription (no polling)
useEffect(() => {
const unsubs: (() => void)[] = [];
// Sync current tracks immediately
const syncTracks = () => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
setLocalVideoTrack((videoPub?.track as VideoTrack | undefined) ?? null);
}
liveKitService.remoteParticipants.forEach((participant) => {
const videoPub = participant.getTrackPublication(Track.Source.Camera);
if (videoPub?.track) {
setRemoteVideoTrack(videoPub.track as VideoTrack);
}
});
};
syncTracks();
// Remote video track events
unsubs.push(
liveKitService.on('trackSubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
@@ -69,28 +86,32 @@ const CallScreen: React.FC = () => {
);
unsubs.push(
liveKitService.on('trackUnmuted', ({ publication, participant }) => {
liveKitService.on('trackUnmuted', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setRemoteVideoTrack(publication.track as VideoTrack);
}
})
);
// Poll local video track from localParticipant
const localPollInterval = setInterval(() => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
const track = (videoPub?.track ?? null) as VideoTrack | null;
setLocalVideoTrack((prev) => (prev === track ? prev : track));
} else {
setLocalVideoTrack((prev) => (prev === null ? prev : null));
}
}, 500);
// Local video track events (replace 500ms polling)
unsubs.push(
liveKitService.on('localTrackPublished', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setLocalVideoTrack(publication.track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('localTrackUnpublished', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setLocalVideoTrack(null);
}
})
);
return () => {
unsubs.forEach((unsub) => unsub());
clearInterval(localPollInterval);
};
}, []);