Files
tongxunruanjian/mobile-next/openim_live/lib/src/live_controller.dart
T
9bde926c81 fix(mobile-next): 按 B-91 收口 Android 通话视觉打回项
呼出/通话中按钮、计时器、底色、60s 未接与进出场动效对齐视觉规范。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 09:58:28 +08:00

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: '',
);
}