diff --git a/mobile-next/lib/company/brand/app_tokens.dart b/mobile-next/lib/company/brand/app_tokens.dart index 585ec89..4d25172 100644 --- a/mobile-next/lib/company/brand/app_tokens.dart +++ b/mobile-next/lib/company/brand/app_tokens.dart @@ -24,6 +24,9 @@ class AppColors { static const Color maskBg = Color(0xFF000000); static const Color success = Color(0xFF00B42A); static const Color warning = Color(0xFFFF7D00); + + /// 通话页纯色深底。与 [textPrimary] 同值(`#1D2129`),不得改用 [maskBg]。 + static const Color callBg = textPrimary; } class AppFont { @@ -33,6 +36,9 @@ class AppFont { static const double sub = 14; static const double body = 16; static const double title = 18; + + /// 语音通话计时器,B-91 §2 唯一字号例外。 + static const double callTimer = 32; } class AppGap { @@ -45,6 +51,9 @@ class AppGap { static const double x6 = 24; static const double x8 = 32; static const double x12 = 48; + + /// 来电拒绝/接听间距,B-91 §4.10。 + static const double x24 = 96; } class AppRadius { @@ -66,6 +75,18 @@ class AppIconSize { static const double lg = 24; } +/// 安卓语音通话页尺寸,取值只来自 B-91 §4.10。 +class AppCallSize { + AppCallSize._(); + + static const double nameTop = 96; + static const double avatar = 96; + static const double primaryBtn = 64; + static const double secondaryBtn = 48; + static const double incomingGap = AppGap.x24; + static const double minimizeHit = 48; +} + class AppDuration { AppDuration._(); @@ -77,6 +98,10 @@ class AppDuration { static const toastStay = Duration(seconds: 2); static const bannerCollapse = Duration(milliseconds: 300); static const picture = Duration(milliseconds: 200); + static const callPage = Duration(milliseconds: 250); + static const callEndHold = Duration(milliseconds: 1500); + static const callInviteTimeout = Duration(seconds: 60); + static const callVibrate = Duration(seconds: 1); } class AppMotion { diff --git a/mobile-next/lib/company/brand/apply_official_styles.dart b/mobile-next/lib/company/brand/apply_official_styles.dart index 7c5a8a4..bbfc19a 100644 --- a/mobile-next/lib/company/brand/apply_official_styles.dart +++ b/mobile-next/lib/company/brand/apply_official_styles.dart @@ -21,6 +21,10 @@ void applyOfficialStylesFromTokens() { Styles.c_F2F8FF = AppColors.primaryActive; Styles.durationOverlay = AppDuration.overlay; Styles.durationOverlaySwitch = AppDuration.overlaySwitch; + Styles.callPageMotion = AppDuration.callPage; + Styles.callEndHold = AppDuration.callEndHold; + Styles.callInviteTimeout = AppDuration.callInviteTimeout; + Styles.callVibrate = AppDuration.callVibrate; Styles.reduceMotion = AppMotion.reduce; Styles.motionOf = AppMotion.of; Styles.c_18E875 = AppColors.success; @@ -28,6 +32,15 @@ void applyOfficialStylesFromTokens() { Styles.c_6085B1 = AppColors.avatarGray; Styles.c_000000 = AppColors.maskBg; Styles.c_000000_opacity70 = AppColors.maskBg.withOpacity(.7); + Styles.c_callBg = AppColors.callBg; + Styles.c_callSecondaryBg = AppColors.pageBg.withOpacity(.12); + Styles.callNameTop = AppCallSize.nameTop; + Styles.callAvatar = AppCallSize.avatar; + Styles.callPrimaryBtn = AppCallSize.primaryBtn; + Styles.callSecondaryBtn = AppCallSize.secondaryBtn; + Styles.callIncomingGap = AppCallSize.incomingGap; + Styles.callMinimizeHit = AppCallSize.minimizeHit; + Styles.callIconSize = AppIconSize.lg; Styles.ts_FFFFFF_20sp_medium = TextStyle( color: AppColors.pageBg, @@ -42,6 +55,20 @@ void applyOfficialStylesFromTokens() { color: AppColors.pageBg.withOpacity(.7), fontSize: AppFont.sub, ); + Styles.ts_callName = TextStyle( + color: AppColors.pageBg, + fontSize: AppFont.title, + fontWeight: FontWeight.w500, + ); + Styles.ts_callStatus = TextStyle( + color: AppColors.pageBg.withOpacity(.6), + fontSize: AppFont.sub, + ); + Styles.ts_callTimer = TextStyle( + color: AppColors.pageBg, + fontSize: AppFont.callTimer, + fontFeatures: const [FontFeature.tabularFigures()], + ); Styles.c_0089FF_opacity10 = AppColors.primary.withOpacity(.1); Styles.c_0089FF_opacity20 = AppColors.primary.withOpacity(.2); diff --git a/mobile-next/lib/company/rtc/call_session_guard.dart b/mobile-next/lib/company/rtc/call_session_guard.dart index da4e176..8afe066 100644 --- a/mobile-next/lib/company/rtc/call_session_guard.dart +++ b/mobile-next/lib/company/rtc/call_session_guard.dart @@ -1,11 +1,13 @@ import 'dart:async'; +import '../brand/app_tokens.dart'; + /// 一对一语音会话防护:占线、超时、重复信令、销毁时释放计时器。 /// 不自建第二套信令,只约束官方 OpenIMLive 的进出场。 enum VoiceCallPhase { idle, outgoing, incoming, inCall } class CallSessionGuard { - CallSessionGuard({this.inviteTimeout = const Duration(seconds: 30)}); + CallSessionGuard({this.inviteTimeout = AppDuration.callInviteTimeout}); final Duration inviteTimeout; diff --git a/mobile-next/lib/company/rtc/livekit_call_config.dart b/mobile-next/lib/company/rtc/livekit_call_config.dart index 6ad900e..937ca6d 100644 --- a/mobile-next/lib/company/rtc/livekit_call_config.dart +++ b/mobile-next/lib/company/rtc/livekit_call_config.dart @@ -1,3 +1,4 @@ +import '../brand/app_tokens.dart'; import '../config/app_endpoints.dart'; /// Android 一对一语音通话配置门。入口只在配置齐全且本卡已落地时打开。 @@ -7,7 +8,8 @@ class LiveKitCallConfig { /// 公司账号服务换票路径,对应 account-service `POST /api/rtc_token`。 static const String rtcTokenPath = '/api/rtc_token'; - static const Duration inviteTimeout = Duration(seconds: 30); + /// 未接超时,唯一业务来源;界面层经 `Styles.callInviteTimeout` 对齐同一 Token。 + static const Duration inviteTimeout = AppDuration.callInviteTimeout; /// 薄适配与构建验证通过后为 true;配置不齐时 [enabled] 仍为 false。 static const bool shipped = true; diff --git a/mobile-next/lib/core/controller/im_controller.dart b/mobile-next/lib/core/controller/im_controller.dart index 1a62329..0dd7f52 100644 --- a/mobile-next/lib/core/controller/im_controller.dart +++ b/mobile-next/lib/core/controller/im_controller.dart @@ -12,6 +12,7 @@ import 'package:openim_live/openim_live.dart'; import '../../company/feature_flags.dart'; import '../../company/rtc/call_permissions.dart'; import '../../company/rtc/call_session_guard.dart'; +import '../../company/rtc/livekit_call_config.dart'; import '../../company/rtc/rtc_token_api.dart'; import '../im_callback.dart'; @@ -19,7 +20,7 @@ class IMController extends GetxController with IMCallback, OpenIMLive { late Rx userInfo; late String atAllTag; final _rtcTokenApi = RtcTokenApi(); - final callGuard = CallSessionGuard(); + final callGuard = CallSessionGuard(inviteTimeout: LiveKitCallConfig.inviteTimeout); @override void onClose() { diff --git a/mobile-next/openim_common/lib/src/res/lang/en_US.dart b/mobile-next/openim_common/lib/src/res/lang/en_US.dart index 999a4e5..b7989a0 100644 --- a/mobile-next/openim_common/lib/src/res/lang/en_US.dart +++ b/mobile-next/openim_common/lib/src/res/lang/en_US.dart @@ -425,18 +425,23 @@ const Map en_US = { "outgoingCall": "Outgoing", "microphone": "Microphone", "speaker": "Speaker", + "callMute": "Mute", + "callHandsFree": "Speaker", + "callEnded": "Call ended", + "callNetworkInterrupted": "Network error, call interrupted", + "unansweredCall": "Missed call", "hangUp": "Hang Up", "pickUp": "Pick Up", "waitingCallHint": "Calling...", - "waitingVoiceCallHint": "Waiting for the other party to pick up...", - "invitedVoiceCallHint": "Inviting you for a voice call...", + "waitingVoiceCallHint": "Waiting for the other party to accept the invitation...", + "invitedVoiceCallHint": "Inviting you to a voice call", "waitingVideoCallHint": "Waiting for the other party to accept the invitation", "invitedVideoCallHint": "Inviting you for a video call...", "waitingToAnswer": "Waiting to answer", "invitedYouToCall": "Invited you to a call", "calling": "Calling...", "nPeopleCalling": "%s people on the call", - 'busyVideoCallHint': 'A user is busy and cannot receive your invitation', + 'busyVideoCallHint': 'The other party is busy and cannot receive the invitation', 'inviterBusyVideoCallHint': 'You are in another call and cannot send an invitation', "whoInvitedVoiceCallHint": "%s invited you for a voice call", "whoInvitedVideoCallHint": "%s invited you for a video call", diff --git a/mobile-next/openim_common/lib/src/res/lang/zh_CN.dart b/mobile-next/openim_common/lib/src/res/lang/zh_CN.dart index 3124979..011e3aa 100644 --- a/mobile-next/openim_common/lib/src/res/lang/zh_CN.dart +++ b/mobile-next/openim_common/lib/src/res/lang/zh_CN.dart @@ -427,18 +427,23 @@ const Map zh_CN = { "outgoingCall": "呼出", "microphone": "麦克风", "speaker": "扬声器", + "callMute": "静音", + "callHandsFree": "免提", + "callEnded": "通话已结束", + "callNetworkInterrupted": "网络异常,通话中断", + "unansweredCall": "未接通话", "hangUp": "挂断", "pickUp": "接听", "waitingCallHint": "正在呼叫中…", - "waitingVoiceCallHint": "正在等待对方接听…", - "invitedVoiceCallHint": "邀请你进行语音通话…", + "waitingVoiceCallHint": "正在等待对方接受邀请…", + "invitedVoiceCallHint": "邀请你语音通话", "waitingVideoCallHint": "正在等待对方接受邀请", "invitedVideoCallHint": "邀请你进行视频通话…", "waitingToAnswer": "等待接听", "invitedYouToCall": "邀请你通话", "calling": "通话中", "nPeopleCalling": "%s人正在通话中", - 'busyVideoCallHint': '有用户正忙,无法接收到你的邀请', + 'busyVideoCallHint': '对方正忙,无法接收邀请', 'inviterBusyVideoCallHint': '你在其它通话中,无法发起邀请', "whoInvitedVoiceCallHint": "%s邀请你进行语音通话", "whoInvitedVideoCallHint": "%s邀请你进行视频通话", diff --git a/mobile-next/openim_common/lib/src/res/strings.dart b/mobile-next/openim_common/lib/src/res/strings.dart index 2279cc3..3fd375d 100644 --- a/mobile-next/openim_common/lib/src/res/strings.dart +++ b/mobile-next/openim_common/lib/src/res/strings.dart @@ -874,6 +874,16 @@ class StrRes { static String get speaker => 'speaker'.tr; + static String get callMute => 'callMute'.tr; + + static String get callHandsFree => 'callHandsFree'.tr; + + static String get callEnded => 'callEnded'.tr; + + static String get callNetworkInterrupted => 'callNetworkInterrupted'.tr; + + static String get unansweredCall => 'unansweredCall'.tr; + static String get hangUp => 'hangUp'.tr; static String get pickUp => 'pickUp'.tr; diff --git a/mobile-next/openim_common/lib/src/res/styles.dart b/mobile-next/openim_common/lib/src/res/styles.dart index fec8907..07badc6 100644 --- a/mobile-next/openim_common/lib/src/res/styles.dart +++ b/mobile-next/openim_common/lib/src/res/styles.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show FontFeature; + import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -17,6 +19,35 @@ class Styles { static Duration durationOverlay = const Duration(milliseconds: 150); static Duration durationOverlaySwitch = const Duration(milliseconds: 100); + static Duration callPageMotion = const Duration(milliseconds: 250); + static Duration callEndHold = const Duration(milliseconds: 1500); + static Duration callInviteTimeout = const Duration(seconds: 60); + static Duration callVibrate = const Duration(seconds: 1); + + static Color c_callBg = const Color(0xFF1D2129); + static Color c_callSecondaryBg = c_FFFFFF.withOpacity(.12); + static double callNameTop = 96; + static double callAvatar = 96; + static double callPrimaryBtn = 64; + static double callSecondaryBtn = 48; + static double callIncomingGap = 96; + static double callMinimizeHit = 48; + static double callIconSize = 24; + + static TextStyle ts_callName = TextStyle( + color: c_FFFFFF, + fontSize: 18.sp, + fontWeight: FontWeight.w500, + ); + static TextStyle ts_callStatus = TextStyle( + color: c_FFFFFF.withOpacity(.6), + fontSize: 14.sp, + ); + static TextStyle ts_callTimer = TextStyle( + color: c_FFFFFF, + fontSize: 32.sp, + fontFeatures: const [FontFeature.tabularFigures()], + ); static bool Function(BuildContext) reduceMotion = (BuildContext context) => MediaQuery.maybeOf(context)?.disableAnimations ?? false; diff --git a/mobile-next/openim_common/lib/src/utils/utils.dart b/mobile-next/openim_common/lib/src/utils/utils.dart index ed937bf..ff87306 100644 --- a/mobile-next/openim_common/lib/src/utils/utils.dart +++ b/mobile-next/openim_common/lib/src/utils/utils.dart @@ -939,7 +939,7 @@ class IMUtils { content = StrRes.rejectedByCaller; break; case 'timeout': - content = StrRes.callTimeout; + content = StrRes.unansweredCall; break; case 'networkError': content = StrRes.networkAnomaly; diff --git a/mobile-next/openim_live/lib/src/live_client.dart b/mobile-next/openim_live/lib/src/live_client.dart index a9ebd38..d0caad0 100644 --- a/mobile-next/openim_live/lib/src/live_client.dart +++ b/mobile-next/openim_live/lib/src/live_client.dart @@ -5,6 +5,7 @@ import 'package:rxdart/rxdart.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'pages/single/room.dart'; +import 'widgets/call_page_host.dart'; enum CallType { audio, video } @@ -61,6 +62,8 @@ class OpenIMLiveClient implements RTCBridge { } static OverlayEntry? _holder; + static GlobalKey? _hostKey; + bool _closing = false; bool isBusy = false; @@ -81,13 +84,25 @@ class OpenIMLiveClient implements RTCBridge { } close() { + if (_closing) return; + _closing = true; + final host = _hostKey?.currentState; + if (host == null) { + _removeHolder(); + return; + } + host.dismiss().whenComplete(_removeHolder); + } + + void _removeHolder() { if (_holder != null) { _holder?.remove(); _holder = null; } + _hostKey = null; isBusy = false; currentRoomID = null; - // The next line disables the wakelock again. + _closing = false; WakelockPlus.disable(); } @@ -118,9 +133,10 @@ class OpenIMLiveClient implements RTCBridge { Function(dynamic error, dynamic stack)? onError, Function()? onClose, Function()? onRoomDisconnected, + Future Function()? onInviteTimeout, }) { if (isBusy) return; - // close(); + _closing = false; isBusy = true; currentRoomID = roomID; this.onTapHangup = onTapHangup; @@ -128,30 +144,35 @@ class OpenIMLiveClient implements RTCBridge { FocusScope.of(ctx).requestFocus(FocusNode()); if (callObj == CallObj.single) { + _hostKey = GlobalKey(); _holder = OverlayEntry( - builder: (context) => SingleRoomView( - callType: callType, - initState: initState, - callEventSubject: callEventSubject, - roomID: roomID, - userID: initState == CallState.call ? inviteeUserIDList.first : inviterUserID, - onDial: onDialSingle, - onTapCancel: onTapCancel, - onTapHangup: onTapHangup, - onTapReject: onTapReject, - onTapPickup: onTapPickup, - onSyncUserInfo: onSyncUserInfo, - autoPickup: autoPickup, - onBindRoomID: (roomID) => currentRoomID = roomID, - onWaitingAccept: onWaitingAccept, - onBusyLine: onBusyLine, - onStartCalling: onStartCalling, - onError: onError, - onRoomDisconnected: onRoomDisconnected, - onClose: () { - onClose?.call(); - close(); - }, + builder: (context) => CallPageHost( + key: _hostKey, + child: SingleRoomView( + callType: callType, + initState: initState, + callEventSubject: callEventSubject, + roomID: roomID, + userID: initState == CallState.call ? inviteeUserIDList.first : inviterUserID, + onDial: onDialSingle, + onTapCancel: onTapCancel, + onTapHangup: onTapHangup, + onTapReject: onTapReject, + onTapPickup: onTapPickup, + onSyncUserInfo: onSyncUserInfo, + autoPickup: autoPickup, + onBindRoomID: (roomID) => currentRoomID = roomID, + onWaitingAccept: onWaitingAccept, + onBusyLine: onBusyLine, + onStartCalling: onStartCalling, + onError: onError, + onRoomDisconnected: onRoomDisconnected, + onInviteTimeout: onInviteTimeout, + onClose: () { + onClose?.call(); + close(); + }, + ), )); } else {} diff --git a/mobile-next/openim_live/lib/src/live_controller.dart b/mobile-next/openim_live/lib/src/live_controller.dart index 1a689aa..98dfc85 100644 --- a/mobile-next/openim_live/lib/src/live_controller.dart +++ b/mobile-next/openim_live/lib/src/live_controller.dart @@ -10,6 +10,7 @@ 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'; @@ -53,12 +54,11 @@ mixin OpenIMLive { final _ring = 'assets/audio/live_ring.wav'; final _audioPlayer = AudioPlayer( - // Handle audio_session events ourselves for the purpose of this demo. handleInterruptions: false, - // androidApplyAudioAttributes: false, - // handleAudioSessionActivation: false, ); + Timer? _incomingVibrateTimer; + bool get isBusy => OpenIMLiveClient().isBusy; onCloseLive() { @@ -94,7 +94,7 @@ mixin OpenIMLive { (event) async { _beCalledEvent = null; if (event.state == CallState.beCalled) { - _playSound(); + _playSound(vibrate: true); final mediaType = event.data.invitation!.mediaType; final sessionType = event.data.invitation!.sessionType; final callType = mediaType == 'audio' ? CallType.audio : CallType.video; @@ -135,6 +135,7 @@ mixin OpenIMLive { onError: onError, onRoomDisconnected: () => onRoomDisconnected(event.data), onStartCalling: onLiveConnected, + onInviteTimeout: () => _onInviteTimeout(event.data), onClose: () { _stopSound(); onLiveSessionClosed(); @@ -195,7 +196,7 @@ mixin OpenIMLive { inviterUserID: inviterUserID, inviteeUserIDList: inviteeUserIDList, roomID: roomID ?? groupID ?? const Uuid().v4(), - timeout: 30, + timeout: Styles.callInviteTimeout.inSeconds, mediaType: mediaType, sessionType: sessionType, platformID: IMUtils.getPlatform(), @@ -233,6 +234,7 @@ mixin OpenIMLive { }, onError: onError, onRoomDisconnected: () => onRoomDisconnected(signal), + onInviteTimeout: () => _onInviteTimeout(signal), onClose: () { _stopSound(); onLiveSessionClosed(); @@ -242,7 +244,6 @@ mixin OpenIMLive { onError(error, stack) { Logger.print('onError=====> $error $stack'); - OpenIMLiveClient().close(); _stopSound(); if (error is PlatformException) { if (int.parse(error.code) == SDKErrorCode.hasBeenBlocked) { @@ -250,7 +251,7 @@ mixin OpenIMLive { return; } } - IMViews.showToast(StrRes.networkError); + IMViews.showToast(StrRes.callNetworkInterrupted); } onRoomDisconnected(SignalingInfo signalingInfo) {} @@ -332,12 +333,20 @@ mixin OpenIMLive { OpenIM.iMManager.messageManager.sendMessage( message: message, offlinePushInfo: OfflinePushInfo(), - userID: signaling.invitation!.inviterUserID, + userID: signaling.invitation!.inviterUserID == OpenIM.iMManager.userID + ? signaling.invitation!.inviteeUserIDList!.first + : signaling.invitation!.inviterUserID, isOnlineOnly: true); return true; } + Future _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()}; @@ -388,16 +397,44 @@ mixin OpenIMLive { return list; } - void _playSound() async { + 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 _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(); } diff --git a/mobile-next/openim_live/lib/src/pages/single/room.dart b/mobile-next/openim_live/lib/src/pages/single/room.dart index 7ac6a34..2a4e447 100644 --- a/mobile-next/openim_live/lib/src/pages/single/room.dart +++ b/mobile-next/openim_live/lib/src/pages/single/room.dart @@ -31,6 +31,7 @@ class SingleRoomView extends SignalView { super.onSyncUserInfo, super.onError, super.onRoomDisconnected, + super.onInviteTimeout, }); @override @@ -64,13 +65,13 @@ class _SingleRoomViewState extends SignalState { final token = certificate.token; if (url == null || url.isEmpty || token == null || token.isEmpty) { widget.onError?.call(StateError('missing livekit credential'), StackTrace.current); - widget.onClose?.call(); + onNetworkInterrupted(); return; } final busyLineUsers = certificate.busyLineUserIDList ?? []; if (busyLineUsers.isNotEmpty) { widget.onBusyLine?.call(); - widget.onClose?.call(); + await beginEnding(CallState.cancel); return; } try { @@ -109,6 +110,7 @@ class _SingleRoomViewState extends SignalState { }); } catch (error, stackTrace) { widget.onError?.call(error, stackTrace); + onNetworkInterrupted(); } } @@ -117,7 +119,7 @@ class _SingleRoomViewState extends SignalState { Logger.print('Room disconnected: reason => ${event.reason}'); WidgetsBindingCompatible.instance?.addPostFrameCallback((_) { widget.onRoomDisconnected?.call(); - widget.onClose?.call(); + onNetworkInterrupted(); }); }) ..on((event) { diff --git a/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart b/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart index ccee282..c873350 100644 --- a/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart +++ b/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart @@ -36,6 +36,7 @@ abstract class SignalView extends StatefulWidget { this.onStartCalling, this.onError, this.onRoomDisconnected, + this.onInviteTimeout, }) : super(key: key); final CallType callType; final CallState initState; @@ -56,6 +57,7 @@ abstract class SignalView extends StatefulWidget { final Function()? onRoomDisconnected; final Function(dynamic error, dynamic stack)? onError; final Future Function(String userID)? onSyncUserInfo; + final Future Function()? onInviteTimeout; } abstract class SignalState extends State { @@ -71,6 +73,8 @@ abstract class SignalState extends State { bool enabledMicrophone = true; bool enabledSpeaker = true; Timer? _inviteTimeout; + bool _ending = false; + bool _closed = false; ParticipantTrack? remoteParticipantTrack; ParticipantTrack? localParticipantTrack; @@ -109,9 +113,8 @@ abstract class SignalState extends State { /// 某些信令通过liveKit的监听 _onStateDidUpdate(CallEvent event) { Logger.print("CallEvent : 当前:$callState 收到:$event"); - if (!mounted) return; + if (!mounted || _ending) return; - // ui 状态只有 呼叫,被呼叫,通话中,连接中 if (event.state == CallState.call || event.state == CallState.beCalled || event.state == CallState.connecting || @@ -119,21 +122,23 @@ abstract class SignalState extends State { callStateSubject.add(event.state); } - if (event.state == CallState.beRejected || event.state == CallState.beCanceled) { - _cancelInviteTimeout(); - widget.onClose?.call(); + if (event.state == CallState.beRejected) { + beginEnding(CallState.beRejected, toast: StrRes.rejectedByCaller); + } else if (event.state == CallState.beCanceled) { + beginEnding(CallState.beCanceled); } else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) { if (existParticipants()) { return; } - _cancelInviteTimeout(); - widget.onClose?.call(); IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept])); + beginEnding(event.state); } else if (event.state == CallState.timeout) { - _cancelInviteTimeout(); - widget.onClose?.call(); + beginEnding(CallState.timeout); + } else if (event.state == CallState.beHangup) { + beginEnding(CallState.beHangup); + } else if (event.state == CallState.networkError) { + beginEnding(CallState.networkError, toast: StrRes.callNetworkInterrupted); } else if (event.state == CallState.beAccepted) { - // 邀请对象比发起对象提前进入房间 if (null != remoteParticipantTrack) { onParticipantConnected(); } @@ -147,6 +152,7 @@ abstract class SignalState extends State { } onParticipantDisconnected() { + if (_ending) return; onTapHangup(false); } @@ -187,18 +193,25 @@ abstract class SignalState extends State { /// [isPositive] 人为挂断行为 onTapHangup(bool isPositive) async { - _cancelInviteTimeout(); - await widget.onTapHangup?.call(duration, isPositive).whenComplete(() => /*isPositive ? {} : */ widget.onClose?.call()); + if (_ending) return; + await widget.onTapHangup?.call(duration, isPositive); + await beginEnding(CallState.hangup); } onTapCancel() async { - _cancelInviteTimeout(); - await widget.onTapCancel?.call().whenComplete(() => widget.onClose?.call()); + if (_ending) return; + await widget.onTapCancel?.call(); + await beginEnding(CallState.cancel); } onTapReject() async { - _cancelInviteTimeout(); - await widget.onTapReject?.call().whenComplete(() => widget.onClose?.call()); + if (_ending) return; + await widget.onTapReject?.call(); + await beginEnding(CallState.reject); + } + + onNetworkInterrupted() { + beginEnding(CallState.networkError, toast: StrRes.callNetworkInterrupted); } onTapMinimize() { @@ -251,14 +264,17 @@ abstract class SignalState extends State { return; } _inviteTimeout?.cancel(); - _inviteTimeout = Timer(const Duration(seconds: 30), () async { - if (!mounted) return; + _inviteTimeout = Timer(Styles.callInviteTimeout, () async { + if (!mounted || _ending) return; if (callState == CallState.calling) return; - if (widget.initState == CallState.call) { - await onTapCancel(); + if (widget.onInviteTimeout != null) { + await widget.onInviteTimeout!.call(); + } else if (widget.initState == CallState.call) { + await widget.onTapCancel?.call(); } else { - await onTapReject(); + await widget.onTapReject?.call(); } + await beginEnding(CallState.timeout); }); } @@ -267,6 +283,31 @@ abstract class SignalState extends State { _inviteTimeout = null; } + Future beginEnding(CallState endState, {String? toast}) async { + if (_ending || _closed) return; + _ending = true; + _cancelInviteTimeout(); + if (!mounted) { + _finishClose(); + return; + } + callState = endState; + if (!callStateSubject.isClosed) { + callStateSubject.add(endState); + } + if (toast != null && toast.isNotEmpty) { + IMViews.showToast(toast); + } + await Future.delayed(Styles.callEndHold); + _finishClose(); + } + + void _finishClose() { + if (_closed) return; + _closed = true; + widget.onClose?.call(); + } + @override Widget build(BuildContext context) => Stack( children: [ @@ -276,7 +317,7 @@ abstract class SignalState extends State { duration: const Duration(milliseconds: 200), onEnd: () {}, child: Container( - color: Styles.c_000000, + color: Styles.c_callBg, child: Stack( children: [ // ImageRes.liveBg.toImage diff --git a/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart b/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart index dbda274..ea9e358 100644 --- a/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart +++ b/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:openim_common/openim_common.dart'; @@ -153,6 +152,7 @@ class _ControlsViewState extends State { } else { await _disableAudio(); } + if (mounted) setState(() {}); }); // if (null != _participant) { @@ -233,127 +233,168 @@ class _ControlsViewState extends State { // } } - @override - Widget build(BuildContext context) => SafeArea( - child: Stack( - children: [ - Positioned( - left: 16.w, - top: 7.h, - child: ImageRes.liveClose.toImage - ..width = 30.w - ..height = 30.h - ..onTap = widget.onMinimize, - ), - if (null != _participant) - Positioned( - right: 16.w, - top: 7.h, - child: Visibility( - visible: isVideo, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - (_participant!.isCameraEnabled() ? ImageRes.liveCameraOff : ImageRes.liveCameraOn).toImage - ..width = 30.w - ..height = 30.h - ..onTap = (_participant!.isCameraEnabled() ? _disableVideo : _enableVideo), - 16.horizontalSpace, - ImageRes.liveSwitchCamera.toImage - ..width = 30.w - ..height = 30.h - ..onTap = _toggleCamera, - ], - ), - ), - ), - if (null != widget.userInfo) - Positioned( - top: 166.h, - width: 1.sw, - child: _userInfoView, - ), - Positioned( - bottom: 32.h, - width: 1.sw, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: _buttonGroup, - ), - ), - Positioned( - bottom: 156.h, - width: 1.sw, - child: Center(child: _videoCallingDurationView), - ), - if (_callState == CallState.connecting) const LiveLoadingView(), - ], - ), - ); + bool get _isEnding => + _callState == CallState.hangup || + _callState == CallState.beHangup || + _callState == CallState.cancel || + _callState == CallState.beCanceled || + _callState == CallState.timeout || + _callState == CallState.beRejected || + _callState == CallState.reject || + _callState == CallState.networkError; - List get _buttonGroup { - if (_callState == CallState.call || _callState == CallState.connecting && widget.initState == CallState.call) { - return [ - LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio), - LiveButton.cancel(onTap: widget.onCancel), - LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker), - ]; - } else if (_callState == CallState.beCalled || _callState == CallState.connecting && widget.initState == CallState.beCalled) { - return [ - LiveButton.reject(onTap: widget.onReject), - LiveButton.pickUp(onTap: widget.onPickUp), - ]; - } else if (_callState == CallState.calling) { - return [ - LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio), - LiveButton.hungUp(onTap: () => widget.onHangUp?.call(true)), - LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker), - ]; + @override + Widget build(BuildContext context) { + final topInset = MediaQuery.of(context).padding.top; + return Stack( + children: [ + Positioned( + left: 16, + top: topInset + 8, + child: SizedBox( + width: Styles.callMinimizeHit, + height: Styles.callMinimizeHit, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.keyboard_arrow_down, color: Styles.c_FFFFFF, size: Styles.callIconSize), + onPressed: widget.onMinimize, + ), + ), + ), + if (null != _participant && isVideo) + Positioned( + right: 16, + top: topInset + 8, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + _participant!.isCameraEnabled() ? Icons.videocam_off_outlined : Icons.videocam_outlined, + color: Styles.c_FFFFFF, + size: Styles.callIconSize, + ), + onPressed: _participant!.isCameraEnabled() ? _disableVideo : _enableVideo, + ), + IconButton( + icon: Icon(Icons.cameraswitch_outlined, color: Styles.c_FFFFFF, size: Styles.callIconSize), + onPressed: _toggleCamera, + ), + ], + ), + ), + Positioned.fill(child: _infoColumn), + Positioned( + left: 0, + right: 0, + bottom: 32 + MediaQuery.of(context).padding.bottom, + child: _buttonArea, + ), + if (_callState == CallState.connecting) const LiveLoadingView(), + ], + ); + } + + Widget get _infoColumn { + final nickname = widget.userInfo == null + ? '' + : (IMUtils.emptyStrToNull(widget.userInfo!.remark) ?? widget.userInfo!.nickname ?? ''); + final faceURL = widget.userInfo?.faceURL; + return Column( + children: [ + SizedBox(height: Styles.callNameTop), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Styles.ts_callName, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _statusText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: isCalling && !isVideo ? Styles.ts_callTimer : Styles.ts_callStatus, + ), + ), + const SizedBox(height: 32), + AvatarView( + width: Styles.callAvatar, + height: Styles.callAvatar, + text: nickname, + url: faceURL, + borderRadius: BorderRadius.circular(Styles.callAvatar * 0.18), + ), + ], + ); + } + + String get _statusText { + if (_callState == CallState.call) { + return isVideo ? StrRes.waitingVideoCallHint : StrRes.waitingVoiceCallHint; } - return []; + if (_callState == CallState.beCalled) { + return isVideo ? StrRes.invitedVideoCallHint : StrRes.invitedVoiceCallHint; + } + if (_callState == CallState.connecting) { + return StrRes.connecting; + } + if (_callState == CallState.networkError) { + return StrRes.callNetworkInterrupted; + } + if (_callState == CallState.beRejected || _callState == CallState.reject) { + return StrRes.rejectedByCaller; + } + if (_isEnding) { + return StrRes.callEnded; + } + if (isCalling) { + return _callingDurationStr; + } + return ''; + } + + Widget get _buttonArea { + if (_callState == CallState.call || + (_callState == CallState.connecting && widget.initState == CallState.call)) { + return Center(child: LiveButton.cancel(onTap: widget.onCancel)); + } + if (_callState == CallState.beCalled) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LiveButton.reject(onTap: widget.onReject), + SizedBox(width: Styles.callIncomingGap), + LiveButton.pickUp(onTap: widget.onPickUp), + ], + ); + } + if (_callState == CallState.calling) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LiveButton.mute(muted: !_enabledMicrophone, onTap: _toggleAudio), + const SizedBox(width: 48), + LiveButton.handsFree(on: _enabledSpeaker, onTap: _toggleSpeaker), + ], + ), + const SizedBox(height: 32), + LiveButton.hungUp(onTap: () => widget.onHangUp?.call(true)), + ], + ); + } + return const SizedBox.shrink(); } bool get isVideo => widget.callType == CallType.video; bool get isCalling => _callState == CallState.calling; - - Widget get _videoCallingDurationView => Visibility( - visible: isVideo && isCalling, - child: _callingDurationStr.toText..style = Styles.ts_FFFFFF_opacity70_17sp, - ); - - Widget get _userInfoView { - String text; - if (_callState == CallState.call) { - text = isVideo ? StrRes.waitingVideoCallHint : StrRes.waitingVoiceCallHint; - } else if (_callState == CallState.beCalled) { - text = isVideo ? StrRes.invitedVideoCallHint : StrRes.invitedVoiceCallHint; - } else if (_callState == CallState.connecting) { - text = StrRes.connecting; - } else { - text = isVideo ? '' : _callingDurationStr; - } - - String? nickname = IMUtils.emptyStrToNull(widget.userInfo!.remark) ?? widget.userInfo!.nickname; - String? faceURL = widget.userInfo!.faceURL; - - return Visibility( - visible: !(isVideo && isCalling), - child: Column( - children: [ - AvatarView(width: 70.w, height: 70.h, text: nickname, url: faceURL), - 10.verticalSpace, - (nickname ?? '').toText..style = Styles.ts_FFFFFF_20sp_medium, - 10.verticalSpace, - Padding( - padding: EdgeInsets.symmetric(horizontal: 12.w), - child: text.toText - ..style = Styles.ts_FFFFFF_opacity70_17sp - ..maxLines = 1 - ..overflow = TextOverflow.ellipsis, - ), - ], - ), - ); - } } diff --git a/mobile-next/openim_live/lib/src/widgets/call_page_host.dart b/mobile-next/openim_live/lib/src/widgets/call_page_host.dart new file mode 100644 index 0000000..5ef6dbf --- /dev/null +++ b/mobile-next/openim_live/lib/src/widgets/call_page_host.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:openim_common/openim_common.dart'; + +/// 通话页进出:250ms 自下而上滑入 ease-out,不可打断。 +class CallPageHost extends StatefulWidget { + const CallPageHost({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + CallPageHostState createState() => CallPageHostState(); +} + +class CallPageHostState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + Completer? _dismissing; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: Styles.callPageMotion, + ); + _controller.forward(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controller.duration = Styles.motionOf(context, Styles.callPageMotion); + } + + Future dismiss() { + if (_dismissing != null) return _dismissing!.future; + _dismissing = Completer(); + final future = _controller.status == AnimationStatus.dismissed + ? Future.value() + : _controller.reverse(); + future.whenComplete(() { + if (_dismissing?.isCompleted == false) { + _dismissing!.complete(); + } + }); + return _dismissing!.future; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)), + child: Material( + color: Styles.c_callBg, + child: widget.child, + ), + ); + } +} diff --git a/mobile-next/openim_live/lib/src/widgets/live_button.dart b/mobile-next/openim_live/lib/src/widgets/live_button.dart index 8a9aece..4e04b97 100644 --- a/mobile-next/openim_live/lib/src/widgets/live_button.dart +++ b/mobile-next/openim_live/lib/src/widgets/live_button.dart @@ -1,67 +1,119 @@ import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:openim_common/openim_common.dart'; +/// 通话页圆形按钮:Token 色圆底 + 线性图标,禁止位图 PNG。 class LiveButton extends StatelessWidget { const LiveButton({ Key? key, - required this.text, required this.icon, + required this.size, + required this.background, + required this.iconColor, + this.text, this.onTap, }) : super(key: key); - final String text; - final String icon; + + final IconData icon; + final double size; + final Color background; + final Color iconColor; + final String? text; final Function()? onTap; @override Widget build(BuildContext context) { + final button = Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Ink( + width: size, + height: size, + decoration: BoxDecoration( + color: background, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: Styles.callIconSize, + color: iconColor, + ), + ), + ), + ); + if (text == null || text!.isEmpty) return button; return Column( + mainAxisSize: MainAxisSize.min, children: [ - icon.toImage - ..width = 62.w - ..height = 62.h - ..onTap = onTap, - 10.verticalSpace, - text.toText..style = Styles.ts_FFFFFF_opacity70_14sp, + button, + const SizedBox(height: 8), + Text(text!, style: Styles.ts_callStatus), ], ); } - LiveButton.microphone({ - super.key, - this.onTap, - bool on = true, - }) : text = StrRes.microphone, - icon = on ? ImageRes.liveMicOn : ImageRes.liveMicOff; + static Color get _danger => Styles.c_FF381F; - LiveButton.speaker({ + static Color get _success => Styles.c_18E875; + + static Color get _white => Styles.c_FFFFFF; + + static Color get _dark => Styles.c_0C1C33; + + LiveButton.mute({ super.key, this.onTap, - bool on = true, - }) : text = StrRes.speaker, - icon = on ? ImageRes.liveSpeakerOn : ImageRes.liveSpeakerOff; + bool muted = false, + }) : text = StrRes.callMute, + icon = muted ? Icons.mic_off_outlined : Icons.mic_none_outlined, + size = Styles.callSecondaryBtn, + background = muted ? _white : Styles.c_callSecondaryBg, + iconColor = muted ? _dark : _white; + + LiveButton.handsFree({ + super.key, + this.onTap, + bool on = false, + }) : text = StrRes.callHandsFree, + icon = Icons.volume_up_outlined, + size = Styles.callSecondaryBtn, + background = on ? _white : Styles.c_callSecondaryBg, + iconColor = on ? _dark : _white; LiveButton.hungUp({ super.key, this.onTap, }) : text = StrRes.hangUp, - icon = ImageRes.liveHangUp; + icon = Icons.call_end, + size = Styles.callPrimaryBtn, + background = _danger, + iconColor = _white; LiveButton.reject({ super.key, this.onTap, }) : text = StrRes.reject, - icon = ImageRes.liveHangUp; + icon = Icons.call_end, + size = Styles.callPrimaryBtn, + background = _danger, + iconColor = _white; LiveButton.cancel({ super.key, this.onTap, }) : text = StrRes.cancel, - icon = ImageRes.liveHangUp; + icon = Icons.call_end, + size = Styles.callPrimaryBtn, + background = _danger, + iconColor = _white; LiveButton.pickUp({ super.key, this.onTap, }) : text = StrRes.pickUp, - icon = ImageRes.livePicUp; + icon = Icons.phone_outlined, + size = Styles.callPrimaryBtn, + background = _success, + iconColor = _white; } diff --git a/mobile-next/openim_live/pubspec.yaml b/mobile-next/openim_live/pubspec.yaml index 9e6d110..62e188e 100644 --- a/mobile-next/openim_live/pubspec.yaml +++ b/mobile-next/openim_live/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: openim_common: path: ../openim_common flutter_openim_sdk: 3.8.3+hotfix.12 + vibration: ^1.7.6 dev_dependencies: flutter_test: diff --git a/mobile-next/test/company/call_session_guard_test.dart b/mobile-next/test/company/call_session_guard_test.dart index dfcd0aa..ae02acb 100644 --- a/mobile-next/test/company/call_session_guard_test.dart +++ b/mobile-next/test/company/call_session_guard_test.dart @@ -9,6 +9,11 @@ void main() { expect(LiveKitCallConfig.enabled, LiveKitCallConfig.shipped); }); + test('默认未接超时是 60 秒', () { + expect(LiveKitCallConfig.inviteTimeout, const Duration(seconds: 60)); + expect(CallSessionGuard().inviteTimeout, LiveKitCallConfig.inviteTimeout); + }); + test('占线时拒绝第二路呼出/呼入', () { final guard = CallSessionGuard(); expect(guard.startOutgoing('room-a'), isNull); diff --git a/mobile-next/test/company/call_visual_rework_test.dart b/mobile-next/test/company/call_visual_rework_test.dart new file mode 100644 index 0000000..0fd13b3 --- /dev/null +++ b/mobile-next/test/company/call_visual_rework_test.dart @@ -0,0 +1,50 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openim/company/brand/app_tokens.dart'; +import 'package:openim/company/brand/apply_official_styles.dart'; +import 'package:openim/company/rtc/call_session_guard.dart'; +import 'package:openim/company/rtc/livekit_call_config.dart'; +import 'package:openim_common/openim_common.dart'; + +void main() { + setUpAll(applyOfficialStylesFromTokens); + + test('未接超时只有 60s 一个来源,不再出现 30s', () { + expect(AppDuration.callInviteTimeout, const Duration(seconds: 60)); + expect(LiveKitCallConfig.inviteTimeout, AppDuration.callInviteTimeout); + expect(Styles.callInviteTimeout, AppDuration.callInviteTimeout); + expect(CallSessionGuard().inviteTimeout, AppDuration.callInviteTimeout); + }); + + test('通话页底色走 callBg #1D2129,不复用大图遮罩黑', () { + expect(AppColors.callBg, const Color(0xFF1D2129)); + expect(AppColors.callBg, AppColors.textPrimary); + expect(Styles.c_callBg, AppColors.callBg); + expect(Styles.c_callBg, isNot(AppColors.maskBg)); + expect(Styles.c_000000, AppColors.maskBg); + }); + + test('计时器 32px 纯白 tabular figures,状态字 14px 白 60%,姓名 18px', () { + expect(AppFont.callTimer, 32); + expect(Styles.ts_callTimer.fontSize, 32); + expect(Styles.ts_callTimer.color, AppColors.pageBg); + expect(Styles.ts_callTimer.fontFeatures, contains(FontFeature.tabularFigures())); + expect(Styles.ts_callStatus.fontSize, AppFont.sub); + expect(Styles.ts_callStatus.color, AppColors.pageBg.withOpacity(.6)); + expect(Styles.ts_callName.fontSize, AppFont.title); + }); + + test('通话页尺寸与进出场动效来自 Token', () { + expect(Styles.callAvatar, 96); + expect(Styles.callPrimaryBtn, 64); + expect(Styles.callSecondaryBtn, 48); + expect(Styles.callIncomingGap, 96); + expect(Styles.callNameTop, 96); + expect(Styles.callMinimizeHit, 48); + expect(Styles.callPageMotion, const Duration(milliseconds: 250)); + expect(Styles.callEndHold, const Duration(milliseconds: 1500)); + expect(Styles.callVibrate, const Duration(seconds: 1)); + expect(AppRadius.avatar(96), closeTo(17.28, 0.001)); + }); +}