import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:uuid/uuid.dart'; import '../models/signaling.dart'; import 'auth_api.dart'; import 'im_service.dart'; /// 通话阶段 enum CallPhase { idle, // 空闲 outgoing, // 呼出中(等待对方接听) incoming, // 来电中(等待本机接听) incall, // 通话中 } /// 一对一语音通话:OpenIM 自定义消息做信令 + LiveKit 传声音。 /// 信令协议与官方样板工程一致(customType 200-204 的仅在线自定义消息)。 class CallService extends ChangeNotifier { CallService._(); static final CallService instance = CallService._(); final AuthApi _authApi = AuthApi(); CallPhase phase = CallPhase.idle; /// 对方 userID 与显示名 String? peerUserID; String peerName = ''; /// 通话时长(秒) int callSeconds = 0; /// 麦克风是否开启 bool micOn = true; /// 是否免提 bool speakerOn = false; /// 有来电时的回调(由 main.dart 设置,用于弹出来电界面) void Function()? onIncomingCall; /// 通话结束时要提示给用户的一句话(可为空) String? endHint; InvitationInfo? _invitation; Room? _room; EventsListener? _roomListener; Timer? _timeoutTimer; Timer? _durationTimer; StreamSubscription? _signalingSub; bool _started = false; /// 来电铃声等待 / 呼叫超时时间(秒),与信令里的 timeout 一致 static const int _inviteTimeoutSec = 30; /// 启动信令监听(登录成功后调用一次) void start() { if (_started) return; _started = true; _signalingSub = IMService.instance.onSignaling.listen(_onSignaling); } bool get isBusy => phase != CallPhase.idle; /// 发起呼叫(单聊)。返回错误提示,null 表示成功进入呼叫流程。 Future startCall({required String peerUserID, required String peerName}) async { if (isBusy) return '正在通话中,请稍后再试'; final im = IMService.instance; final myUserID = im.currentUserID; final authToken = im.currentToken; if (myUserID == null || authToken == null) return '登录状态已失效,请重新登录'; _invitation = InvitationInfo( inviterUserID: myUserID, inviteeUserIDList: [peerUserID], roomID: const Uuid().v4(), timeout: _inviteTimeoutSec, initiateTime: DateTime.now().millisecondsSinceEpoch, mediaType: 'audio', sessionType: ConversationType.single, platformID: im.platformID, ); this.peerUserID = peerUserID; this.peerName = peerName; try { // 1. 发呼叫信令(仅在线,不落地) await _sendSignaling(SignalingType.callingInvite, peerUserID); // 2. 取 LiveKit token 并进房(先进房等对方,对方接听信令到达后开始计时) await _joinRoom(myUserID, authToken); _setPhase(CallPhase.outgoing); _startTimeoutTimer(() { endHint = '对方暂时无人接听'; _sendSignalingQuietly(SignalingType.callingCancel, peerUserID); _teardown(); }); return null; } on AuthException catch (e) { _teardown(); return e.message; } catch (_) { _teardown(); return '发起通话失败,请检查网络后重试'; } } /// 接听来电 Future accept() async { if (phase != CallPhase.incoming) return; final im = IMService.instance; final myUserID = im.currentUserID; final authToken = im.currentToken; final inviter = _invitation?.inviterUserID; if (myUserID == null || authToken == null || inviter == null) { _teardown(); return; } _timeoutTimer?.cancel(); try { await _sendSignaling(SignalingType.callingAccept, inviter); await _joinRoom(myUserID, authToken); _beginIncall(); } catch (_) { endHint = '接听失败,请检查网络'; _sendSignalingQuietly(SignalingType.callingReject, inviter); _teardown(); } } /// 拒接来电 Future reject() async { if (phase != CallPhase.incoming) return; final inviter = _invitation?.inviterUserID; if (inviter != null) await _sendSignalingQuietly(SignalingType.callingReject, inviter); _teardown(); } /// 取消呼叫(呼出方主动取消) Future cancel() async { if (phase != CallPhase.outgoing) return; final peer = peerUserID; if (peer != null) await _sendSignalingQuietly(SignalingType.callingCancel, peer); _teardown(); } /// 挂断(通话中) Future hangup() async { if (phase != CallPhase.incall) return; final peer = peerUserID; if (peer != null) await _sendSignalingQuietly(SignalingType.callingHungup, peer); _teardown(); } /// 通话界面上的统一返回键:按当前阶段取消/挂断 Future leave() async { switch (phase) { case CallPhase.outgoing: await cancel(); break; case CallPhase.incoming: await reject(); break; case CallPhase.incall: await hangup(); break; case CallPhase.idle: break; } } Future toggleMic() async { micOn = !micOn; notifyListeners(); try { await _room?.localParticipant?.setMicrophoneEnabled(micOn); } catch (_) { // 切换失败时回退状态 micOn = !micOn; notifyListeners(); } } Future toggleSpeaker() async { speakerOn = !speakerOn; notifyListeners(); try { await Hardware.instance.setSpeakerphoneOn(speakerOn); } catch (_) { speakerOn = !speakerOn; notifyListeners(); } } // ---------------- 信令处理 ---------------- void _onSignaling(Message msg) { final payload = IMService.parseSignaling(msg); if (payload == null) return; final myUserID = IMService.instance.currentUserID; switch (payload.type) { case SignalingType.callingInvite: // 只处理发给自己的单聊音频呼叫 final invitation = payload.invitation; final invitees = invitation.inviteeUserIDList ?? []; if (!invitees.contains(myUserID)) return; if (invitation.mediaType != 'audio') return; if (isBusy) { // 占线:直接回拒接(UI 从简,无排队等待) _sendSignalingQuietly(SignalingType.callingReject, invitation.inviterUserID, invitation: invitation); return; } _invitation = invitation; peerUserID = invitation.inviterUserID; peerName = invitation.inviterUserID ?? ''; _loadPeerName(); _setPhase(CallPhase.incoming); _startTimeoutTimer(() { // 来电超时未接 _teardown(); }); onIncomingCall?.call(); break; case SignalingType.callingAccept: if (phase == CallPhase.outgoing && _sameRoom(payload)) { _timeoutTimer?.cancel(); _beginIncall(); } break; case SignalingType.callingReject: if ((phase == CallPhase.outgoing || phase == CallPhase.incall) && _sameRoom(payload)) { endHint = '对方拒绝了通话'; _teardown(); } break; case SignalingType.callingCancel: if (phase == CallPhase.incoming && _sameRoom(payload)) { endHint = '对方已取消'; _teardown(); } break; case SignalingType.callingHungup: if (phase == CallPhase.incall && _sameRoom(payload)) { endHint = '通话已结束'; _teardown(); } break; } } bool _sameRoom(SignalingPayload payload) { return payload.roomID != null && payload.roomID == _invitation?.roomID; } Future _loadPeerName() async { final id = peerUserID; if (id == null) return; try { final list = await OpenIM.iMManager.userManager.getUsersInfo(userIDList: [id]); if (list.isNotEmpty) { final name = list.first.nickname ?? ''; if (name.isNotEmpty && peerUserID == id) { peerName = name; notifyListeners(); } } } catch (_) { // 拉不到名字就显示工号 } } // ---------------- LiveKit ---------------- Future _joinRoom(String myUserID, String authToken) async { final roomID = _invitation?.roomID; if (roomID == null) throw AuthException('通话数据不完整'); final credential = await _authApi.getRtcToken(room: roomID, identity: myUserID, authToken: authToken); _room = Room(); _roomListener = _room!.createListener(); _roomListener! ..on((event) { // 掉线或房间被销毁:直接结束 if (phase == CallPhase.incall) endHint = '通话已结束'; _teardown(); }) ..on((event) { // 对方离开房间 if (phase == CallPhase.incall) { endHint = '通话已结束'; _teardown(); } }); await _room!.connect(credential.liveURL, credential.token); await _room!.localParticipant?.setMicrophoneEnabled(micOn); } // ---------------- 内部工具 ---------------- void _beginIncall() { callSeconds = 0; _durationTimer?.cancel(); _durationTimer = Timer.periodic(const Duration(seconds: 1), (_) { callSeconds += 1; notifyListeners(); }); _setPhase(CallPhase.incall); } void _setPhase(CallPhase p) { phase = p; notifyListeners(); } void _startTimeoutTimer(void Function() onTimeout) { _timeoutTimer?.cancel(); _timeoutTimer = Timer(const Duration(seconds: _inviteTimeoutSec), onTimeout); } Future _sendSignaling(int type, String recvUserID, {InvitationInfo? invitation}) async { final inv = invitation ?? _invitation; if (inv == null) return; final data = jsonEncode({'customType': type, 'data': inv.toJson()}); final message = await OpenIM.iMManager.messageManager.createCustomMessage( data: data, extension: '', description: '', ); await OpenIM.iMManager.messageManager.sendMessage( message: message, offlinePushInfo: OfflinePushInfo(), userID: recvUserID, isOnlineOnly: true, ); } /// 发信令失败时静默处理(挂断/取消类消息失败不影响本地收尾) Future _sendSignalingQuietly(int type, String? recvUserID, {InvitationInfo? invitation}) async { if (recvUserID == null) return; try { await _sendSignaling(type, recvUserID, invitation: invitation); } catch (_) {} } /// 结束通话:释放房间与计时器,回到空闲态 void _teardown() { _timeoutTimer?.cancel(); _timeoutTimer = null; _durationTimer?.cancel(); _durationTimer = null; final room = _room; _room = null; _roomListener?.dispose(); _roomListener = null; if (room != null) { () async { try { await room.disconnect(); await room.dispose(); } catch (_) {} }(); } // 复位免提 if (speakerOn) { Hardware.instance.setSpeakerphoneOn(false).catchError((_) {}); speakerOn = false; } micOn = true; callSeconds = 0; _invitation = null; _setPhase(CallPhase.idle); } /// 消费结束提示(界面弹出提示后清空) String? takeEndHint() { final hint = endHint; endHint = null; return hint; } @override void dispose() { _signalingSub?.cancel(); _teardown(); super.dispose(); } }