feat(call): implement CallKeep integration and enhance group call support
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:
284
src/services/callkeep/callKeepService.ts
Normal file
284
src/services/callkeep/callKeepService.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
startOutgoingCall,
|
||||
reportIncomingCall,
|
||||
answerCall,
|
||||
endCall,
|
||||
reportCallEnded,
|
||||
reportOutgoingCallConnected,
|
||||
fulfillIncomingCallConnected,
|
||||
failIncomingCallConnected,
|
||||
setMuted,
|
||||
reportVideo,
|
||||
setAudioSessionPortOverride,
|
||||
setRTCAudioSessionConfiguration,
|
||||
addCallSessionAddedListener,
|
||||
addCallAnsweredListener,
|
||||
addCallEndedListener,
|
||||
addOutgoingCallStartedListener,
|
||||
addSetMutedActionListener,
|
||||
addVideoChangedListener,
|
||||
addAudioSessionActivatedListener,
|
||||
addAudioSessionDeactivatedListener,
|
||||
addAudioRouteChangedListener,
|
||||
type CallParticipant,
|
||||
type IncomingCallEvent,
|
||||
type CallEndedReason,
|
||||
type CallSession,
|
||||
type CallAnsweredEvent,
|
||||
type CallEndedEvent,
|
||||
type SetMutedActionEvent,
|
||||
} from 'expo-callkit-telecom';
|
||||
|
||||
const isWeb = Platform.OS === 'web';
|
||||
|
||||
export interface CallKeepCallbacks {
|
||||
onIncomingCallFromPush: (session: CallSession) => void;
|
||||
onSystemAnswered: () => void;
|
||||
onSystemEnded: () => void;
|
||||
onSystemMuted: (muted: boolean) => void;
|
||||
onOutgoingStarted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin state-machine adapter between callStore (Zustand) and expo-callkit-telecom.
|
||||
*
|
||||
* callStore remains the source of truth for call state.
|
||||
* This service delegates to the system call API and translates system UI events
|
||||
* back into callStore actions via registered callbacks.
|
||||
*/
|
||||
class CallKeepServiceImpl {
|
||||
private _activeSystemCallId: string | null = null;
|
||||
private _pendingRequestId: string | null = null;
|
||||
private _callbacks: CallKeepCallbacks | null = null;
|
||||
private _subscriptions: Array<{ remove: () => void }> = [];
|
||||
private _initialized = false;
|
||||
|
||||
// ---- Initialization ----
|
||||
|
||||
initialize(callbacks: CallKeepCallbacks): void {
|
||||
if (isWeb) return;
|
||||
if (this._initialized) return;
|
||||
|
||||
this._callbacks = callbacks;
|
||||
this._initialized = true;
|
||||
|
||||
this._setupListeners();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._subscriptions.forEach((s) => s.remove());
|
||||
this._subscriptions = [];
|
||||
this._callbacks = null;
|
||||
this._initialized = false;
|
||||
}
|
||||
|
||||
private _setupListeners(): void {
|
||||
// Outgoing call accepted by the OS — media should start connecting
|
||||
this._subscriptions.push(
|
||||
addOutgoingCallStartedListener(() => {
|
||||
this._callbacks?.onOutgoingStarted();
|
||||
}),
|
||||
);
|
||||
|
||||
// Incoming call — a new session was created (from push or reportIncomingCall)
|
||||
this._subscriptions.push(
|
||||
addCallSessionAddedListener((event) => {
|
||||
if (event.session.origin === 'incoming') {
|
||||
this._activeSystemCallId = event.session.id;
|
||||
if (!this._pendingRequestId) {
|
||||
this._callbacks?.onIncomingCallFromPush(event.session);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// User answered incoming call (from system UI or via answerCall)
|
||||
this._subscriptions.push(
|
||||
addCallAnsweredListener((event: CallAnsweredEvent) => {
|
||||
this._activeSystemCallId = event.id;
|
||||
this._pendingRequestId = event.requestId;
|
||||
this._callbacks?.onSystemAnswered();
|
||||
}),
|
||||
);
|
||||
|
||||
// Call ended from system UI (user pressed end in CallKit UI)
|
||||
this._subscriptions.push(
|
||||
addCallEndedListener((_event: CallEndedEvent) => {
|
||||
this._activeSystemCallId = null;
|
||||
this._pendingRequestId = null;
|
||||
this._callbacks?.onSystemEnded();
|
||||
}),
|
||||
);
|
||||
|
||||
// System mute toggle (CallKit mute button)
|
||||
this._subscriptions.push(
|
||||
addSetMutedActionListener((event: SetMutedActionEvent) => {
|
||||
this._callbacks?.onSystemMuted(event.isMuted);
|
||||
}),
|
||||
);
|
||||
|
||||
// Video changed from system
|
||||
this._subscriptions.push(
|
||||
addVideoChangedListener((event) => {
|
||||
if (this._callbacks && event.hasVideo !== undefined) {
|
||||
// could add onSystemVideoChanged callback if needed
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Audio session lifecycle — keep for debugging
|
||||
this._subscriptions.push(
|
||||
addAudioSessionActivatedListener(() => {
|
||||
console.log('[CallKeep] Audio session activated');
|
||||
}),
|
||||
);
|
||||
|
||||
this._subscriptions.push(
|
||||
addAudioSessionDeactivatedListener(() => {
|
||||
console.log('[CallKeep] Audio session deactivated');
|
||||
}),
|
||||
);
|
||||
|
||||
this._subscriptions.push(
|
||||
addAudioRouteChangedListener((event) => {
|
||||
console.log('[CallKeep] Audio route changed:', event.currentRoute);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Outgoing Call ----
|
||||
|
||||
async startOutgoingCall(
|
||||
calleeId: string,
|
||||
calleeName: string,
|
||||
hasVideo: boolean,
|
||||
): Promise<string | null> {
|
||||
if (isWeb) return null;
|
||||
|
||||
try {
|
||||
// End any stale system call before starting a new one
|
||||
if (this._activeSystemCallId) {
|
||||
console.warn('[CallKeep] Cleaning up stale system call before new outgoing call');
|
||||
this.endActiveCall();
|
||||
}
|
||||
|
||||
const recipient: CallParticipant = {
|
||||
id: calleeId,
|
||||
displayName: calleeName,
|
||||
};
|
||||
const id = await startOutgoingCall(recipient, { hasVideo });
|
||||
this._activeSystemCallId = id;
|
||||
console.log('[CallKeep] Outgoing call started:', id);
|
||||
return id;
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to start outgoing call:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
reportOutgoingCallConnected(): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
reportOutgoingCallConnected(this._activeSystemCallId);
|
||||
}
|
||||
|
||||
// ---- Incoming Call ----
|
||||
|
||||
reportIncomingCallEvent(event: IncomingCallEvent): void {
|
||||
if (isWeb) return;
|
||||
try {
|
||||
reportIncomingCall(event);
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to report incoming call:', err);
|
||||
}
|
||||
}
|
||||
|
||||
answerCallFromApp(): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
answerCall(this._activeSystemCallId);
|
||||
}
|
||||
|
||||
fulfillIncomingCallConnected(): void {
|
||||
if (isWeb || !this._pendingRequestId) return;
|
||||
try {
|
||||
fulfillIncomingCallConnected(this._pendingRequestId);
|
||||
this._pendingRequestId = null;
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to fulfill incoming call:', err);
|
||||
}
|
||||
}
|
||||
|
||||
failIncomingCallConnected(): void {
|
||||
if (isWeb || !this._activeSystemCallId || !this._pendingRequestId) return;
|
||||
try {
|
||||
failIncomingCallConnected(this._activeSystemCallId, this._pendingRequestId);
|
||||
this._pendingRequestId = null;
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to fail incoming call:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- End Call ----
|
||||
|
||||
endActiveCall(): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
try {
|
||||
endCall(this._activeSystemCallId);
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to end call:', err);
|
||||
}
|
||||
this._activeSystemCallId = null;
|
||||
this._pendingRequestId = null;
|
||||
}
|
||||
|
||||
reportCallEndedExternal(reason: CallEndedReason): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
try {
|
||||
reportCallEnded(this._activeSystemCallId, reason);
|
||||
} catch (err) {
|
||||
console.error('[CallKeep] Failed to report call ended:', err);
|
||||
}
|
||||
this._activeSystemCallId = null;
|
||||
this._pendingRequestId = null;
|
||||
}
|
||||
|
||||
// ---- Audio Session ----
|
||||
|
||||
configureAudioForCall(hasVideo: boolean): void {
|
||||
if (isWeb) return;
|
||||
setRTCAudioSessionConfiguration(hasVideo);
|
||||
}
|
||||
|
||||
// ---- Controls ----
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
setMuted(this._activeSystemCallId, muted);
|
||||
}
|
||||
|
||||
reportVideoEnabled(enabled: boolean): void {
|
||||
if (isWeb || !this._activeSystemCallId) return;
|
||||
reportVideo(this._activeSystemCallId, enabled);
|
||||
}
|
||||
|
||||
setAudioOutputToSpeaker(enabled: boolean): void {
|
||||
if (isWeb) return;
|
||||
setAudioSessionPortOverride(enabled);
|
||||
}
|
||||
|
||||
// ---- Getters ----
|
||||
|
||||
get activeSystemCallId(): string | null {
|
||||
return this._activeSystemCallId;
|
||||
}
|
||||
|
||||
get pendingRequestId(): string | null {
|
||||
return this._pendingRequestId;
|
||||
}
|
||||
|
||||
get isWebPlatform(): boolean {
|
||||
return isWeb;
|
||||
}
|
||||
}
|
||||
|
||||
export const callKeepService = new CallKeepServiceImpl();
|
||||
2
src/services/callkeep/index.ts
Normal file
2
src/services/callkeep/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { callKeepService } from './callKeepService';
|
||||
export type { CallKeepCallbacks } from './callKeepService';
|
||||
@@ -164,7 +164,7 @@ class ApiClient {
|
||||
// 清除 token
|
||||
async clearToken(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||
await AsyncStorage.removeMany([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||
} catch (error) {
|
||||
console.error('清除token失败:', error);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
|
||||
if (cacheKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(cacheKeys);
|
||||
await AsyncStorage.removeMany(cacheKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DataSourceError(
|
||||
|
||||
@@ -44,6 +44,8 @@ export type WSMessageType =
|
||||
| 'call_peer_muted'
|
||||
| 'call_invited'
|
||||
| 'call_answered_elsewhere'
|
||||
| 'call_participant_joined'
|
||||
| 'call_participant_left'
|
||||
| 'sync_required';
|
||||
|
||||
export interface WSCallIncomingMessage {
|
||||
@@ -103,6 +105,19 @@ export interface WSCallAnsweredElsewhereMessage {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSCallParticipantJoinedMessage {
|
||||
type: 'call_participant_joined';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface WSCallParticipantLeftMessage {
|
||||
type: 'call_participant_left';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSErrorMessage {
|
||||
type: 'error';
|
||||
code: string;
|
||||
@@ -257,6 +272,8 @@ export type WSMessage =
|
||||
| WSCallPeerMutedMessage
|
||||
| WSCallInvitedMessage
|
||||
| WSCallAnsweredElsewhereMessage
|
||||
| WSCallParticipantJoinedMessage
|
||||
| WSCallParticipantLeftMessage
|
||||
| WSErrorMessage
|
||||
| WSSyncRequiredMessage;
|
||||
|
||||
@@ -300,6 +317,9 @@ class WebSocketService {
|
||||
private isFallbackMode = false;
|
||||
private fallbackCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 通话中阻止后台断开(对方请求权限等场景会导致 AppState 切换,不应断开 WebSocket)
|
||||
preventDisconnectOnBackground = false;
|
||||
|
||||
private getWSUrl(token: string | null): string {
|
||||
return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1`;
|
||||
}
|
||||
@@ -747,6 +767,14 @@ class WebSocketService {
|
||||
this.sendFireAndForget('call_ready', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallGroupInvite(groupId: string, conversationId: string, callType: string): void {
|
||||
this.sendFireAndForget('call_group_invite', {
|
||||
group_id: groupId,
|
||||
conversation_id: conversationId,
|
||||
call_type: callType,
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessageSent(payload: any): void {
|
||||
// 找到对应的发送请求并resolve
|
||||
const pendingMsg = this.pendingMessages.find(p =>
|
||||
@@ -1059,9 +1087,14 @@ class WebSocketService {
|
||||
this.lastAppState = AppState.currentState;
|
||||
this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
|
||||
// 进入后台时断开 WebSocket,由 JPush 负责后台推送通知
|
||||
// 但如果正在通话中则不断开,避免对方请求权限等场景导致通话中断
|
||||
if (this.lastAppState === 'active' && nextState.match(/inactive|background/)) {
|
||||
console.log('[WebSocket] App entering background, disconnecting');
|
||||
this.disconnect();
|
||||
if (this.preventDisconnectOnBackground) {
|
||||
console.log('[WebSocket] App entering background but call is active, skipping disconnect');
|
||||
} else {
|
||||
console.log('[WebSocket] App entering background, disconnecting');
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
// 回到前台时重连 WebSocket
|
||||
if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import '../../polyfills';
|
||||
import { Platform } from 'react-native';
|
||||
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
ConnectionState,
|
||||
LocalParticipant,
|
||||
RemoteParticipant,
|
||||
RemoteTrackPublication,
|
||||
LocalTrackPublication,
|
||||
TrackPublication,
|
||||
Participant as LKParticipant,
|
||||
} from 'livekit-client';
|
||||
|
||||
export interface LiveKitServiceConfig {
|
||||
url: string;
|
||||
@@ -20,6 +30,11 @@ export interface LiveKitEventMap {
|
||||
trackUnmuted: { participant: LKParticipant; publication: TrackPublication };
|
||||
connectionStatusChanged: ConnectionStatus;
|
||||
error: Error;
|
||||
activeSpeakersChanged: { speakers: LKParticipant[] };
|
||||
participantConnected: { participant: RemoteParticipant };
|
||||
participantDisconnected: { participant: RemoteParticipant };
|
||||
localTrackPublished: { publication: LocalTrackPublication; participant: LocalParticipant };
|
||||
localTrackUnpublished: { publication: LocalTrackPublication; participant: LocalParticipant };
|
||||
}
|
||||
|
||||
type EventHandler<K extends keyof LiveKitEventMap> = (data: LiveKitEventMap[K]) => void;
|
||||
@@ -45,26 +60,11 @@ class LiveKitServiceImpl {
|
||||
return this.room?.remoteParticipants ?? new Map();
|
||||
}
|
||||
|
||||
async connect(url: string, token: string, isVideoCall: boolean = false): Promise<void> {
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
if (this.room) {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
// Configure native audio session before connecting (iOS only)
|
||||
if (Platform.OS === 'ios') {
|
||||
try {
|
||||
const { AudioSession } = require('@livekit/react-native');
|
||||
await AudioSession.configureAudio({
|
||||
ios: {
|
||||
defaultOutput: isVideoCall ? 'speaker' : 'default',
|
||||
},
|
||||
});
|
||||
await AudioSession.startAudioSession();
|
||||
} catch (err) {
|
||||
console.warn('[LiveKit] Audio session configuration failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
@@ -99,18 +99,16 @@ class LiveKitServiceImpl {
|
||||
this.room.off(RoomEvent.Reconnecting, this.onReconnecting);
|
||||
this.room.off(RoomEvent.Reconnected, this.onReconnected);
|
||||
this.room.off(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
|
||||
this.room.off(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged);
|
||||
this.room.off(RoomEvent.ParticipantConnected, this.onParticipantConnected);
|
||||
this.room.off(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected);
|
||||
this.room.off(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished);
|
||||
this.room.off(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished);
|
||||
|
||||
await this.room.disconnect();
|
||||
this.room = null;
|
||||
this._connectionStatus = 'disconnected';
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
try {
|
||||
const { AudioSession } = require('@livekit/react-native');
|
||||
await AudioSession.stopAudioSession();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.emit('connectionStatusChanged', 'disconnected');
|
||||
this.emit('disconnected', undefined);
|
||||
}
|
||||
@@ -122,20 +120,60 @@ class LiveKitServiceImpl {
|
||||
|
||||
async setVideoEnabled(enabled: boolean): Promise<void> {
|
||||
if (!this.room?.localParticipant) return;
|
||||
console.log('[LiveKit] setVideoEnabled called:', enabled);
|
||||
try {
|
||||
await this.room.localParticipant.setCameraEnabled(enabled);
|
||||
console.log('[LiveKit] setVideoEnabled succeeded:', enabled);
|
||||
} catch (err) {
|
||||
console.error('[LiveKit] setVideoEnabled failed:', err);
|
||||
throw err;
|
||||
await this.room.localParticipant.setCameraEnabled(enabled);
|
||||
}
|
||||
|
||||
async setSpeakerOn(_speakerOn: boolean): Promise<void> {
|
||||
// Audio routing is handled by expo-callkit-telecom
|
||||
}
|
||||
|
||||
async flipCamera(): Promise<void> {
|
||||
if (!this.room?.localParticipant) return;
|
||||
// Camera flip via LiveKit's native support (platform-specific)
|
||||
const videoTrack = this.room.localParticipant.getTrackPublication(Track.Source.Camera);
|
||||
if (videoTrack?.track && 'switchCamera' in videoTrack.track) {
|
||||
await (videoTrack.track as any).switchCamera();
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeakerOn(speakerOn: boolean): Promise<void> {
|
||||
if (Platform.OS === 'web' || Platform.OS === 'android') return;
|
||||
const AudioSession = require('@livekit/react-native').AudioSession;
|
||||
await AudioSession.selectAudioOutput(speakerOn ? 'force_speaker' : 'default');
|
||||
async switchMicrophone(deviceId: string): Promise<void> {
|
||||
if (!this.room) return;
|
||||
await this.room.switchActiveDevice('audioinput', deviceId);
|
||||
}
|
||||
|
||||
enumerateAudioInputDevices(): Promise<MediaDeviceInfo[]> {
|
||||
// Platform-specific: delegates to native livekit SDK where available
|
||||
if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) {
|
||||
return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'audioinput'));
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
enumerateVideoInputDevices(): Promise<MediaDeviceInfo[]> {
|
||||
if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) {
|
||||
return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'videoinput'));
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getActiveSpeakers(): LKParticipant[] {
|
||||
return this.room?.activeSpeakers ?? [];
|
||||
}
|
||||
|
||||
getParticipantTracks(identity: string): {
|
||||
audio: RemoteTrackPublication | null;
|
||||
video: RemoteTrackPublication | null;
|
||||
} {
|
||||
const participant = this.room?.remoteParticipants.get(identity);
|
||||
if (!participant) return { audio: null, video: null };
|
||||
return {
|
||||
audio: participant.getTrackPublication(Track.Source.Microphone) as RemoteTrackPublication | null,
|
||||
video: participant.getTrackPublication(Track.Source.Camera) as RemoteTrackPublication | null,
|
||||
};
|
||||
}
|
||||
|
||||
getParticipantCount(): number {
|
||||
return (this.room?.remoteParticipants.size ?? 0) + 1; // +1 for local
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
@@ -150,22 +188,6 @@ class LiveKitServiceImpl {
|
||||
return videoTrack?.isSubscribed ?? false;
|
||||
}
|
||||
|
||||
getRemoteAudioTrack(): Track | null {
|
||||
for (const [, participant] of this.remoteParticipants) {
|
||||
const audioPub = participant.getTrackPublication(Track.Source.Microphone);
|
||||
if (audioPub?.track) return audioPub.track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getRemoteVideoTrack(): Track | null {
|
||||
for (const [, participant] of this.remoteParticipants) {
|
||||
const videoPub = participant.getTrackPublication(Track.Source.Camera);
|
||||
if (videoPub?.track) return videoPub.track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
on<K extends keyof LiveKitEventMap>(event: K, handler: EventHandler<K>): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
@@ -192,6 +214,11 @@ class LiveKitServiceImpl {
|
||||
this.room.on(RoomEvent.Reconnecting, this.onReconnecting);
|
||||
this.room.on(RoomEvent.Reconnected, this.onReconnected);
|
||||
this.room.on(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
|
||||
this.room.on(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged);
|
||||
this.room.on(RoomEvent.ParticipantConnected, this.onParticipantConnected);
|
||||
this.room.on(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected);
|
||||
this.room.on(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished);
|
||||
this.room.on(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished);
|
||||
}
|
||||
|
||||
private onTrackSubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => {
|
||||
@@ -240,6 +267,26 @@ class LiveKitServiceImpl {
|
||||
this.emit('connectionStatusChanged', status);
|
||||
};
|
||||
|
||||
private onActiveSpeakersChanged = (speakers: LKParticipant[]): void => {
|
||||
this.emit('activeSpeakersChanged', { speakers });
|
||||
};
|
||||
|
||||
private onParticipantConnected = (participant: RemoteParticipant): void => {
|
||||
this.emit('participantConnected', { participant });
|
||||
};
|
||||
|
||||
private onParticipantDisconnected = (participant: RemoteParticipant): void => {
|
||||
this.emit('participantDisconnected', { participant });
|
||||
};
|
||||
|
||||
private onLocalTrackPublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => {
|
||||
this.emit('localTrackPublished', { publication, participant });
|
||||
};
|
||||
|
||||
private onLocalTrackUnpublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => {
|
||||
this.emit('localTrackUnpublished', { publication, participant });
|
||||
};
|
||||
|
||||
private emit<K extends keyof LiveKitEventMap>(event: K, data: LiveKitEventMap[K]): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
@@ -255,4 +302,4 @@ class LiveKitServiceImpl {
|
||||
}
|
||||
|
||||
export const liveKitService = new LiveKitServiceImpl();
|
||||
export default liveKitService;
|
||||
export default liveKitService;
|
||||
|
||||
Reference in New Issue
Block a user