- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
395 lines
11 KiB
Dart
395 lines
11 KiB
Dart
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<RoomEvent>? _roomListener;
|
|
Timer? _timeoutTimer;
|
|
Timer? _durationTimer;
|
|
StreamSubscription<Message>? _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<String?> 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<void> 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<void> reject() async {
|
|
if (phase != CallPhase.incoming) return;
|
|
final inviter = _invitation?.inviterUserID;
|
|
if (inviter != null) await _sendSignalingQuietly(SignalingType.callingReject, inviter);
|
|
_teardown();
|
|
}
|
|
|
|
/// 取消呼叫(呼出方主动取消)
|
|
Future<void> cancel() async {
|
|
if (phase != CallPhase.outgoing) return;
|
|
final peer = peerUserID;
|
|
if (peer != null) await _sendSignalingQuietly(SignalingType.callingCancel, peer);
|
|
_teardown();
|
|
}
|
|
|
|
/// 挂断(通话中)
|
|
Future<void> hangup() async {
|
|
if (phase != CallPhase.incall) return;
|
|
final peer = peerUserID;
|
|
if (peer != null) await _sendSignalingQuietly(SignalingType.callingHungup, peer);
|
|
_teardown();
|
|
}
|
|
|
|
/// 通话界面上的统一返回键:按当前阶段取消/挂断
|
|
Future<void> 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<void> toggleMic() async {
|
|
micOn = !micOn;
|
|
notifyListeners();
|
|
try {
|
|
await _room?.localParticipant?.setMicrophoneEnabled(micOn);
|
|
} catch (_) {
|
|
// 切换失败时回退状态
|
|
micOn = !micOn;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> 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<void> _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<void> _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<RoomDisconnectedEvent>((event) {
|
|
// 掉线或房间被销毁:直接结束
|
|
if (phase == CallPhase.incall) endHint = '通话已结束';
|
|
_teardown();
|
|
})
|
|
..on<ParticipantDisconnectedEvent>((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<void> _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<void> _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();
|
|
}
|
|
}
|