呼出/通话中按钮、计时器、底色、60s 未接与进出场动效对齐视觉规范。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
520 lines
17 KiB
Dart
520 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:openim_common/openim_common.dart';
|
|
import 'package:rxdart/rxdart.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:vibration/vibration.dart';
|
|
|
|
import '../openim_live.dart';
|
|
|
|
/// 信令
|
|
mixin OpenIMLive {
|
|
final signalingSubject = PublishSubject<CallEvent>();
|
|
|
|
void invitationCancelled(SignalingInfo info) {
|
|
signalingSubject.add(CallEvent(CallState.beCanceled, info));
|
|
}
|
|
|
|
void inviteeAccepted(SignalingInfo info) {
|
|
signalingSubject.add(CallEvent(CallState.beAccepted, info));
|
|
}
|
|
|
|
void inviteeRejected(SignalingInfo info) {
|
|
signalingSubject.add(CallEvent(CallState.beRejected, info));
|
|
}
|
|
|
|
void receiveNewInvitation(SignalingInfo info) {
|
|
signalingSubject.add(CallEvent(CallState.beCalled, info));
|
|
}
|
|
|
|
void beHangup(SignalingInfo info) {
|
|
signalingSubject.add(CallEvent(CallState.beHangup, info));
|
|
}
|
|
|
|
final backgroundSubject = PublishSubject<bool>();
|
|
|
|
final insertSignalingMessageSubject = PublishSubject<CallEvent>();
|
|
|
|
Function(SignalingMessageEvent)? onSignalingMessage;
|
|
final roomParticipantDisconnectedSubject = PublishSubject<RoomCallingInfo>();
|
|
final roomParticipantConnectedSubject = PublishSubject<RoomCallingInfo>();
|
|
|
|
bool _isRunningBackground = false;
|
|
|
|
CallEvent? _beCalledEvent;
|
|
|
|
bool _autoPickup = false;
|
|
|
|
final _ring = 'assets/audio/live_ring.wav';
|
|
final _audioPlayer = AudioPlayer(
|
|
handleInterruptions: false,
|
|
);
|
|
|
|
Timer? _incomingVibrateTimer;
|
|
|
|
bool get isBusy => OpenIMLiveClient().isBusy;
|
|
|
|
onCloseLive() {
|
|
signalingSubject.close();
|
|
backgroundSubject.close();
|
|
roomParticipantDisconnectedSubject.close();
|
|
roomParticipantConnectedSubject.close();
|
|
_stopSound();
|
|
}
|
|
|
|
onInitLive() async {
|
|
_signalingListener();
|
|
_insertSignalingMessageListener();
|
|
backgroundSubject.listen((background) {
|
|
_isRunningBackground = background;
|
|
if (!_isRunningBackground) {
|
|
if (_beCalledEvent != null) {
|
|
signalingSubject.add(_beCalledEvent!);
|
|
}
|
|
}
|
|
});
|
|
|
|
roomParticipantDisconnectedSubject.listen((info) {
|
|
if (null == info.participant || info.participant!.length == 1) {
|
|
OpenIMLiveClient().closeByRoomID(info.invitation!.roomID!);
|
|
}
|
|
});
|
|
}
|
|
|
|
Stream<CallEvent> get _stream => signalingSubject.stream /*.where((event) => LiveClient.dispatchSignaling(event))*/;
|
|
|
|
_signalingListener() => _stream.listen(
|
|
(event) async {
|
|
_beCalledEvent = null;
|
|
if (event.state == CallState.beCalled) {
|
|
_playSound(vibrate: true);
|
|
final mediaType = event.data.invitation!.mediaType;
|
|
final sessionType = event.data.invitation!.sessionType;
|
|
final callType = mediaType == 'audio' ? CallType.audio : CallType.video;
|
|
final callObj = sessionType == ConversationType.single ? CallObj.single : CallObj.group;
|
|
|
|
if (Platform.isAndroid && _isRunningBackground) {
|
|
_beCalledEvent = event;
|
|
if (await Permissions.checkSystemAlertWindow()) {
|
|
return;
|
|
}
|
|
}
|
|
_beCalledEvent = null;
|
|
OpenIMLiveClient().start(
|
|
Get.overlayContext!,
|
|
callEventSubject: signalingSubject,
|
|
roomID: event.data.invitation!.roomID!,
|
|
inviteeUserIDList: event.data.invitation!.inviteeUserIDList!,
|
|
inviterUserID: event.data.invitation!.inviterUserID!,
|
|
groupID: event.data.invitation!.groupID,
|
|
callType: callType,
|
|
callObj: callObj,
|
|
initState: CallState.beCalled,
|
|
onSyncUserInfo: onSyncUserInfo,
|
|
onSyncGroupInfo: onSyncGroupInfo,
|
|
onSyncGroupMemberInfo: onSyncGroupMemberInfo,
|
|
autoPickup: _autoPickup,
|
|
onTapPickup: () => onTapPickup(
|
|
event.data..userID = OpenIM.iMManager.userID,
|
|
),
|
|
onTapReject: () => onTapReject(
|
|
event.data..userID = OpenIM.iMManager.userID,
|
|
),
|
|
onTapHangup: (duration, isPositive) => onTapHangup(
|
|
event.data..userID = OpenIM.iMManager.userID,
|
|
duration,
|
|
isPositive,
|
|
),
|
|
onError: onError,
|
|
onRoomDisconnected: () => onRoomDisconnected(event.data),
|
|
onStartCalling: onLiveConnected,
|
|
onInviteTimeout: () => _onInviteTimeout(event.data),
|
|
onClose: () {
|
|
_stopSound();
|
|
onLiveSessionClosed();
|
|
},
|
|
);
|
|
} else if (event.state == CallState.beRejected) {
|
|
insertSignalingMessageSubject.add(event);
|
|
_stopSound();
|
|
} else if (event.state == CallState.beHangup) {
|
|
_stopSound();
|
|
} else if (event.state == CallState.beCanceled) {
|
|
insertSignalingMessageSubject.add(event);
|
|
_stopSound();
|
|
} else if (event.state == CallState.beAccepted) {
|
|
_stopSound();
|
|
} else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) {
|
|
_stopSound();
|
|
} else if (event.state == CallState.timeout) {
|
|
insertSignalingMessageSubject.add(event);
|
|
|
|
_stopSound();
|
|
final sessionType = event.data.invitation!.sessionType;
|
|
|
|
if (sessionType == 1) {
|
|
onTimeoutCancelled(event.data);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
_insertSignalingMessageListener() {
|
|
insertSignalingMessageSubject.listen((value) {
|
|
_insertMessage(
|
|
state: value.state,
|
|
signalingInfo: value.data,
|
|
duration: value.fields ?? 0,
|
|
);
|
|
});
|
|
}
|
|
|
|
call({
|
|
required CallObj callObj,
|
|
required CallType callType,
|
|
CallState callState = CallState.call,
|
|
String? roomID,
|
|
String? inviterUserID,
|
|
required List<String> inviteeUserIDList,
|
|
String? groupID,
|
|
SignalingCertificate? credentials,
|
|
}) async {
|
|
final mediaType = callType == CallType.audio ? 'audio' : 'video';
|
|
final sessionType = callObj == CallObj.single ? 1 : 3;
|
|
inviterUserID ??= OpenIM.iMManager.userID;
|
|
|
|
final signal = SignalingInfo(
|
|
userID: inviterUserID,
|
|
invitation: InvitationInfo(
|
|
inviterUserID: inviterUserID,
|
|
inviteeUserIDList: inviteeUserIDList,
|
|
roomID: roomID ?? groupID ?? const Uuid().v4(),
|
|
timeout: Styles.callInviteTimeout.inSeconds,
|
|
mediaType: mediaType,
|
|
sessionType: sessionType,
|
|
platformID: IMUtils.getPlatform(),
|
|
groupID: groupID,
|
|
),
|
|
);
|
|
|
|
OpenIMLiveClient().start(
|
|
Get.overlayContext!,
|
|
callEventSubject: signalingSubject,
|
|
inviterUserID: inviterUserID,
|
|
groupID: groupID,
|
|
inviteeUserIDList: inviteeUserIDList,
|
|
callObj: callObj,
|
|
callType: callType,
|
|
initState: callState,
|
|
onDialSingle: () => onDialSingle(signal),
|
|
onJoinGroup: () => Future.value(credentials!),
|
|
onTapCancel: () => onTapCancel(signal),
|
|
onTapHangup: (duration, isPositive) => onTapHangup(
|
|
signal,
|
|
duration,
|
|
isPositive,
|
|
),
|
|
onSyncUserInfo: onSyncUserInfo,
|
|
onSyncGroupInfo: onSyncGroupInfo,
|
|
onSyncGroupMemberInfo: onSyncGroupMemberInfo,
|
|
onWaitingAccept: () {
|
|
if (callObj == CallObj.single) _playSound();
|
|
},
|
|
onBusyLine: onBusyLine,
|
|
onStartCalling: () {
|
|
_stopSound();
|
|
onLiveConnected();
|
|
},
|
|
onError: onError,
|
|
onRoomDisconnected: () => onRoomDisconnected(signal),
|
|
onInviteTimeout: () => _onInviteTimeout(signal),
|
|
onClose: () {
|
|
_stopSound();
|
|
onLiveSessionClosed();
|
|
},
|
|
);
|
|
}
|
|
|
|
onError(error, stack) {
|
|
Logger.print('onError=====> $error $stack');
|
|
_stopSound();
|
|
if (error is PlatformException) {
|
|
if (int.parse(error.code) == SDKErrorCode.hasBeenBlocked) {
|
|
IMViews.showToast(StrRes.callFail);
|
|
return;
|
|
}
|
|
}
|
|
IMViews.showToast(StrRes.callNetworkInterrupted);
|
|
}
|
|
|
|
onRoomDisconnected(SignalingInfo signalingInfo) {}
|
|
|
|
Future<SignalingCertificate> onDialSingle(SignalingInfo signaling) async {
|
|
final data = {'customType': CustomMessageType.callingInvite, 'data': signaling.invitation!.toJson()};
|
|
final message = await OpenIM.iMManager.messageManager
|
|
.createCustomMessage(data: jsonEncode(data), extension: '', description: '');
|
|
OpenIM.iMManager.messageManager.sendMessage(
|
|
message: message,
|
|
offlinePushInfo: OfflinePushInfo(),
|
|
userID: signaling.invitation!.inviteeUserIDList!.first,
|
|
isOnlineOnly: true);
|
|
final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
|
|
|
|
return certificate;
|
|
}
|
|
|
|
/// 公司层可覆盖:走账号服务 `/api/rtc_token`,默认仍走官方 chat 换票。
|
|
Future<SignalingCertificate> getRtcCertificate(String roomID, String userID) {
|
|
return Apis.getTokenForRTC(roomID, userID);
|
|
}
|
|
|
|
void onLiveSessionClosed() {}
|
|
|
|
void onLiveConnected() {}
|
|
|
|
Future<SignalingCertificate> onTapPickup(SignalingInfo signaling) async {
|
|
_beCalledEvent = null; // ios bug
|
|
_autoPickup = false;
|
|
_stopSound();
|
|
final data = {'customType': CustomMessageType.callingAccept, 'data': signaling.invitation!.toJson()};
|
|
final message = await OpenIM.iMManager.messageManager
|
|
.createCustomMessage(data: jsonEncode(data), extension: '', description: '');
|
|
OpenIM.iMManager.messageManager.sendMessage(
|
|
message: message,
|
|
offlinePushInfo: OfflinePushInfo(),
|
|
userID: signaling.invitation!.inviterUserID,
|
|
isOnlineOnly: true);
|
|
final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
|
|
|
|
return certificate;
|
|
}
|
|
|
|
onTapReject(SignalingInfo signaling) async {
|
|
_stopSound();
|
|
insertSignalingMessageSubject.add(CallEvent(CallState.reject, signaling));
|
|
|
|
final data = {'customType': CustomMessageType.callingReject, 'data': signaling.invitation!.toJson()};
|
|
final message = await OpenIM.iMManager.messageManager
|
|
.createCustomMessage(data: jsonEncode(data), extension: '', description: '');
|
|
final recvUserID = signaling.invitation!.inviterUserID == OpenIM.iMManager.userID
|
|
? signaling.invitation!.inviteeUserIDList!.first
|
|
: signaling.invitation!.inviterUserID;
|
|
return OpenIM.iMManager.messageManager
|
|
.sendMessage(message: message, offlinePushInfo: OfflinePushInfo(), userID: recvUserID, isOnlineOnly: true);
|
|
}
|
|
|
|
onTapCancel(SignalingInfo signaling) async {
|
|
_stopSound();
|
|
insertSignalingMessageSubject.add(CallEvent(CallState.cancel, signaling));
|
|
|
|
final data = {'customType': CustomMessageType.callingCancel, 'data': signaling.invitation!.toJson()};
|
|
final message = await OpenIM.iMManager.messageManager
|
|
.createCustomMessage(data: jsonEncode(data), extension: '', description: '');
|
|
final recvUserID = signaling.invitation!.inviterUserID == OpenIM.iMManager.userID
|
|
? signaling.invitation!.inviteeUserIDList!.first
|
|
: signaling.invitation!.inviterUserID;
|
|
OpenIM.iMManager.messageManager
|
|
.sendMessage(message: message, offlinePushInfo: OfflinePushInfo(), userID: recvUserID, isOnlineOnly: true);
|
|
return true;
|
|
}
|
|
|
|
onTimeoutCancelled(SignalingInfo signaling) async {
|
|
final data = {'customType': CustomMessageType.callingCancel, 'data': signaling.invitation!.toJson()};
|
|
final message = await OpenIM.iMManager.messageManager
|
|
.createCustomMessage(data: jsonEncode(data), extension: '', description: '');
|
|
|
|
OpenIM.iMManager.messageManager.sendMessage(
|
|
message: message,
|
|
offlinePushInfo: OfflinePushInfo(),
|
|
userID: signaling.invitation!.inviterUserID == OpenIM.iMManager.userID
|
|
? signaling.invitation!.inviteeUserIDList!.first
|
|
: signaling.invitation!.inviterUserID,
|
|
isOnlineOnly: true);
|
|
|
|
return true;
|
|
}
|
|
|
|
Future<void> _onInviteTimeout(SignalingInfo signaling) async {
|
|
_stopSound();
|
|
insertSignalingMessageSubject.add(CallEvent(CallState.timeout, signaling));
|
|
await onTimeoutCancelled(signaling);
|
|
}
|
|
|
|
onTapHangup(SignalingInfo signaling, int duration, bool isPositive) async {
|
|
if (isPositive) {
|
|
final data = {'customType': CustomMessageType.callingHungup, 'data': signaling.invitation!.toJson()};
|
|
final message = await OpenIM.iMManager.messageManager
|
|
.createCustomMessage(data: jsonEncode(data), extension: '', description: '');
|
|
final recvUserID = signaling.invitation!.inviterUserID == OpenIM.iMManager.userID
|
|
? signaling.invitation!.inviteeUserIDList!.first
|
|
: signaling.invitation!.inviterUserID;
|
|
OpenIM.iMManager.messageManager
|
|
.sendMessage(message: message, offlinePushInfo: OfflinePushInfo(), userID: recvUserID, isOnlineOnly: true);
|
|
}
|
|
_stopSound();
|
|
|
|
insertSignalingMessageSubject.add(CallEvent(
|
|
CallState.hangup,
|
|
signaling,
|
|
fields: duration,
|
|
));
|
|
}
|
|
|
|
onBusyLine() {
|
|
_stopSound();
|
|
IMViews.showToast(StrRes.busyVideoCallHint);
|
|
}
|
|
|
|
onJoin() {}
|
|
|
|
Future<UserInfo?> onSyncUserInfo(userID) async {
|
|
var list = await OpenIM.iMManager.userManager.getUsersInfo(
|
|
userIDList: [userID],
|
|
);
|
|
|
|
return list.firstOrNull?.simpleUserInfo;
|
|
}
|
|
|
|
Future<GroupInfo?> onSyncGroupInfo(groupID) async {
|
|
var list = await OpenIM.iMManager.groupManager.getGroupsInfo(
|
|
groupIDList: [groupID],
|
|
);
|
|
return list.firstOrNull;
|
|
}
|
|
|
|
Future<List<GroupMembersInfo>> onSyncGroupMemberInfo(groupID, userIDList) async {
|
|
var list = await OpenIM.iMManager.groupManager.getGroupMembersInfo(
|
|
groupID: groupID,
|
|
userIDList: userIDList,
|
|
);
|
|
return list;
|
|
}
|
|
|
|
void _playSound({bool vibrate = false}) async {
|
|
if (!_audioPlayer.playerState.playing) {
|
|
_audioPlayer.setAsset(_ring, package: 'openim_common');
|
|
_audioPlayer.setLoopMode(LoopMode.one);
|
|
_audioPlayer.setVolume(1.0);
|
|
_audioPlayer.play();
|
|
}
|
|
if (vibrate) {
|
|
_startIncomingVibrate();
|
|
}
|
|
}
|
|
|
|
void _startIncomingVibrate() {
|
|
_stopIncomingVibrate();
|
|
_pulseVibrate();
|
|
_incomingVibrateTimer = Timer.periodic(Styles.callVibrate, (_) => _pulseVibrate());
|
|
}
|
|
|
|
void _stopIncomingVibrate() {
|
|
_incomingVibrateTimer?.cancel();
|
|
_incomingVibrateTimer = null;
|
|
try {
|
|
Vibration.cancel();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _pulseVibrate() async {
|
|
try {
|
|
if (await Vibration.hasVibrator() == true) {
|
|
await Vibration.vibrate(duration: 200);
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
HapticFeedback.vibrate();
|
|
}
|
|
|
|
void _stopSound() async {
|
|
_stopIncomingVibrate();
|
|
if (_audioPlayer.playerState.playing) {
|
|
_audioPlayer.stop();
|
|
}
|
|
}
|
|
|
|
void _insertMessage({
|
|
required CallState state,
|
|
required SignalingInfo signalingInfo,
|
|
int duration = 0,
|
|
}) async {
|
|
(() async {
|
|
var invitation = signalingInfo.invitation;
|
|
var mediaType = invitation!.mediaType;
|
|
var inviterUserID = invitation.inviterUserID;
|
|
var inviteeUserID = invitation.inviteeUserIDList!.first;
|
|
var groupID = invitation.groupID;
|
|
Logger.print(
|
|
'end calling and insert message state:${state.name}, mediaType:$mediaType, inviterUserID:$inviterUserID, inviteeUserID:$inviteeUserID, groupID:$groupID, duration:$duration',
|
|
functionName: '_insertMessage');
|
|
var message = await OpenIM.iMManager.messageManager.createCallMessage(
|
|
state: state.name,
|
|
type: mediaType!,
|
|
duration: duration,
|
|
);
|
|
|
|
String? receiverID;
|
|
if (inviterUserID != OpenIM.iMManager.userID) {
|
|
receiverID = inviterUserID;
|
|
} else {
|
|
receiverID = inviteeUserID;
|
|
}
|
|
|
|
var msg = await OpenIM.iMManager.messageManager.insertSingleMessageToLocalStorage(
|
|
receiverID: inviteeUserID,
|
|
senderID: inviterUserID,
|
|
message: message
|
|
..status = 2
|
|
..isRead = true,
|
|
);
|
|
|
|
onSignalingMessage?.call(SignalingMessageEvent(msg, 1, receiverID, null));
|
|
})();
|
|
}
|
|
}
|
|
|
|
class SignalingMessageEvent {
|
|
Message message;
|
|
String? userID;
|
|
String? groupID;
|
|
int sessionType;
|
|
|
|
SignalingMessageEvent(
|
|
this.message,
|
|
this.sessionType,
|
|
this.userID,
|
|
this.groupID,
|
|
);
|
|
|
|
bool get isSingleChat => sessionType == ConversationType.single;
|
|
|
|
bool get isGroupChat => sessionType == ConversationType.group || sessionType == ConversationType.superGroup;
|
|
}
|
|
|
|
extension MessageMangerExt on MessageManager {
|
|
Future<Message> createCallMessage({
|
|
required String type,
|
|
required String state,
|
|
int? duration,
|
|
}) =>
|
|
createCustomMessage(
|
|
data: json.encode({
|
|
"customType": CustomMessageType.call,
|
|
"data": {
|
|
'duration': duration,
|
|
'state': state,
|
|
'type': type,
|
|
},
|
|
}),
|
|
extension: '',
|
|
description: '',
|
|
);
|
|
}
|