feat(mobile-next): 导入官方 openim-flutter-demo 固定基线 3.8.3-patch.3 并完成双构建
- 上游: github.com/OpenIMSDK/openim-flutter-demo tag 3.8.3-patch.3 commit b3dfdb1e8aaeaaf6f0793e10cadd20d5c184a31f - 并行目录 mobile-next/ 原样导入(含 LICENSE),不覆盖现网 mobile/ - 锁定 Flutter 3.24.5 / Dart 3.5.4 / JDK 17 / Gradle 7.6.3 / AGP 7.3.1 - 实测 Android debug 与 release 构建均成功(见 docs/mobile-next-baseline-import.md) - 本提交可单独回退:git revert 1db1229(重写前) Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
|
||||
channel: stable
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/developing-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,4 @@
|
||||
library openim_live;
|
||||
|
||||
export 'src/live_client.dart';
|
||||
export 'src/live_controller.dart';
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import 'pages/single/room.dart';
|
||||
|
||||
enum CallType { audio, video }
|
||||
|
||||
enum CallObj { single, group }
|
||||
|
||||
enum CallState {
|
||||
call,
|
||||
beCalled,
|
||||
reject,
|
||||
beRejected,
|
||||
calling,
|
||||
beAccepted,
|
||||
hangup,
|
||||
beHangup,
|
||||
connecting,
|
||||
otherAccepted,
|
||||
otherReject,
|
||||
cancel,
|
||||
beCanceled,
|
||||
timeout,
|
||||
join,
|
||||
networkError,
|
||||
}
|
||||
|
||||
class CallEvent {
|
||||
CallState state;
|
||||
SignalingInfo data;
|
||||
dynamic fields;
|
||||
|
||||
CallEvent(this.state, this.data, {this.fields});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CallEvent{state: $state, data: $data, fields: $fields}';
|
||||
}
|
||||
}
|
||||
|
||||
class OpenIMLiveClient implements RTCBridge {
|
||||
OpenIMLiveClient._();
|
||||
|
||||
static final OpenIMLiveClient singleton = OpenIMLiveClient._();
|
||||
|
||||
factory OpenIMLiveClient() {
|
||||
PackageBridge.rtcBridge = singleton;
|
||||
return singleton;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get hasConnection => isBusy;
|
||||
|
||||
@override
|
||||
void dismiss() {
|
||||
close();
|
||||
}
|
||||
|
||||
static OverlayEntry? _holder;
|
||||
|
||||
bool isBusy = false;
|
||||
|
||||
String? currentRoomID;
|
||||
Future Function(int duration, bool isPositive)? onTapHangup;
|
||||
|
||||
quitClose(String roomID) async {
|
||||
if (currentRoomID == roomID) {
|
||||
await onTapHangup?.call(0, true);
|
||||
closeByRoomID(roomID);
|
||||
}
|
||||
}
|
||||
|
||||
closeByRoomID(String roomID) {
|
||||
if (currentRoomID == roomID) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (_holder != null) {
|
||||
_holder?.remove();
|
||||
_holder = null;
|
||||
}
|
||||
isBusy = false;
|
||||
currentRoomID = null;
|
||||
// The next line disables the wakelock again.
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
start(
|
||||
BuildContext ctx, {
|
||||
required PublishSubject<CallEvent> callEventSubject,
|
||||
String? roomID,
|
||||
CallState initState = CallState.call,
|
||||
CallType callType = CallType.video,
|
||||
CallObj callObj = CallObj.single,
|
||||
required String inviterUserID,
|
||||
required List<String> inviteeUserIDList,
|
||||
String? groupID,
|
||||
Future<SignalingCertificate> Function()? onDialSingle,
|
||||
Future<SignalingCertificate> Function()? onDialGroup,
|
||||
Future<SignalingCertificate> Function()? onJoinGroup,
|
||||
Future<SignalingCertificate> Function()? onTapPickup,
|
||||
Future Function()? onTapCancel,
|
||||
Future Function(int duration, bool isPositive)? onTapHangup,
|
||||
Future Function()? onTapReject,
|
||||
Future<UserInfo?> Function(String userID)? onSyncUserInfo,
|
||||
Future<GroupInfo?> Function(String groupID)? onSyncGroupInfo,
|
||||
Future<List<GroupMembersInfo>> Function(String groupID, List<String> memberIDList)? onSyncGroupMemberInfo,
|
||||
bool autoPickup = false,
|
||||
Function()? onWaitingAccept,
|
||||
Function()? onBusyLine,
|
||||
Function()? onStartCalling,
|
||||
Function(dynamic error, dynamic stack)? onError,
|
||||
Function()? onClose,
|
||||
Function()? onRoomDisconnected,
|
||||
}) {
|
||||
if (isBusy) return;
|
||||
// close();
|
||||
isBusy = true;
|
||||
currentRoomID = roomID;
|
||||
this.onTapHangup = onTapHangup;
|
||||
|
||||
FocusScope.of(ctx).requestFocus(FocusNode());
|
||||
|
||||
if (callObj == CallObj.single) {
|
||||
_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();
|
||||
},
|
||||
));
|
||||
} else {}
|
||||
|
||||
Overlay.of(ctx).insert(_holder!);
|
||||
// The following line will enable the Android and iOS wakelock.
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
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 '../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(
|
||||
// Handle audio_session events ourselves for the purpose of this demo.
|
||||
handleInterruptions: false,
|
||||
// androidApplyAudioAttributes: false,
|
||||
// handleAudioSessionActivation: false,
|
||||
);
|
||||
|
||||
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();
|
||||
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),
|
||||
);
|
||||
} 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: 30,
|
||||
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();
|
||||
},
|
||||
onError: onError,
|
||||
onRoomDisconnected: () => onRoomDisconnected(signal),
|
||||
onClose: _stopSound,
|
||||
);
|
||||
}
|
||||
|
||||
onError(error, stack) {
|
||||
Logger.print('onError=====> $error $stack');
|
||||
OpenIMLiveClient().close();
|
||||
_stopSound();
|
||||
if (error is PlatformException) {
|
||||
if (int.parse(error.code) == SDKErrorCode.hasBeenBlocked) {
|
||||
IMViews.showToast(StrRes.callFail);
|
||||
return;
|
||||
}
|
||||
}
|
||||
IMViews.showToast(StrRes.networkError);
|
||||
}
|
||||
|
||||
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 Apis.getTokenForRTC(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
|
||||
|
||||
return certificate;
|
||||
}
|
||||
|
||||
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 Apis.getTokenForRTC(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,
|
||||
isOnlineOnly: true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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() async {
|
||||
if (!_audioPlayer.playerState.playing) {
|
||||
_audioPlayer.setAsset(_ring, package: 'openim_common');
|
||||
_audioPlayer.setLoopMode(LoopMode.one);
|
||||
_audioPlayer.setVolume(1.0);
|
||||
_audioPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopSound() async {
|
||||
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: '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
|
||||
import '../../live_client.dart';
|
||||
import 'widgets/call_state.dart';
|
||||
import 'widgets/participant.dart';
|
||||
|
||||
class SingleRoomView extends SignalView {
|
||||
const SingleRoomView({
|
||||
super.key,
|
||||
required super.callType,
|
||||
required super.initState,
|
||||
required super.userID,
|
||||
required super.callEventSubject,
|
||||
required super.autoPickup,
|
||||
super.roomID,
|
||||
super.onClose,
|
||||
super.onBindRoomID,
|
||||
super.onBusyLine,
|
||||
super.onDial,
|
||||
super.onStartCalling,
|
||||
super.onTapCancel,
|
||||
super.onTapHangup,
|
||||
super.onTapPickup,
|
||||
super.onTapReject,
|
||||
super.onWaitingAccept,
|
||||
super.onSyncUserInfo,
|
||||
super.onError,
|
||||
super.onRoomDisconnected,
|
||||
});
|
||||
|
||||
@override
|
||||
SignalState<SingleRoomView> createState() => _SingleRoomViewState();
|
||||
}
|
||||
|
||||
class _SingleRoomViewState extends SignalState<SingleRoomView> {
|
||||
EventsListener<RoomEvent>? _listener;
|
||||
Room? _room;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// always dispose listener
|
||||
(() async {
|
||||
_room?.removeListener(_onRoomDidUpdate);
|
||||
await _listener?.dispose();
|
||||
await _room?.remoteParticipants.values.firstOrNull?.dispose();
|
||||
await _room?.localParticipant?.dispose();
|
||||
await _room?.disconnect();
|
||||
await _room?.dispose();
|
||||
})();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
final url = certificate.liveURL!;
|
||||
final token = certificate.token!;
|
||||
final busyLineUsers = certificate.busyLineUserIDList ?? [];
|
||||
if (busyLineUsers.isNotEmpty) {
|
||||
widget.onBusyLine?.call();
|
||||
widget.onClose?.call();
|
||||
return;
|
||||
}
|
||||
// Try to connect to a room
|
||||
// This will throw an Exception if it fails for any reason.
|
||||
try {
|
||||
//create new room
|
||||
_room = Room();
|
||||
|
||||
// Create a Listener before connecting
|
||||
_listener = _room?.createListener();
|
||||
// Try to connect to the room
|
||||
// This will throw an Exception if it fails for any reason.
|
||||
await _room?.connect(url, token,
|
||||
roomOptions: RoomOptions(
|
||||
dynacast: true,
|
||||
adaptiveStream: true,
|
||||
defaultCameraCaptureOptions: const CameraCaptureOptions(params: VideoParametersPresets.h720_169),
|
||||
defaultVideoPublishOptions: VideoPublishOptions(
|
||||
simulcast: true,
|
||||
videoCodec: 'VP9',
|
||||
videoEncoding: const VideoEncoding(
|
||||
maxBitrate: 5 * 1000 * 1000,
|
||||
maxFramerate: 15,
|
||||
))));
|
||||
if (!mounted) return;
|
||||
_room?.addListener(_onRoomDidUpdate);
|
||||
if (null != _listener) _setUpListeners();
|
||||
if (null != _room) roomDidUpdateSubject.add(_room!);
|
||||
_sortParticipants();
|
||||
if (CallState.call == callState || CallState.connecting == callState) {
|
||||
widget.onWaitingAccept?.call();
|
||||
}
|
||||
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) {
|
||||
_publish();
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
widget.onError?.call(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _setUpListeners() => _listener!
|
||||
..on<RoomDisconnectedEvent>((event) async {
|
||||
Logger.print('Room disconnected: reason => ${event.reason}');
|
||||
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) {
|
||||
widget.onRoomDisconnected?.call();
|
||||
widget.onClose?.call();
|
||||
});
|
||||
})
|
||||
..on<RoomRecordingStatusChanged>((event) {})
|
||||
..on<LocalTrackPublishedEvent>((_) => _sortParticipants())
|
||||
..on<LocalTrackUnpublishedEvent>((_) => _sortParticipants())
|
||||
..on<ParticipantConnectedEvent>((_) => onParticipantConnected())
|
||||
..on<ParticipantDisconnectedEvent>((_) => onParticipantDisconnected())
|
||||
..on<DataReceivedEvent>((event) {
|
||||
String decoded = 'Failed to decode';
|
||||
try {
|
||||
decoded = utf8.decode(event.data);
|
||||
} catch (_) {
|
||||
Logger.print('Failed to decode: $_');
|
||||
}
|
||||
});
|
||||
|
||||
void _publish() async {
|
||||
// video will fail when running in ios simulator
|
||||
try {
|
||||
final enabled = widget.callType == CallType.video;
|
||||
await _room?.localParticipant?.setCameraEnabled(enabled);
|
||||
} catch (error, stackTrace) {
|
||||
Logger.print('could not publish video: $error $stackTrace');
|
||||
}
|
||||
try {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(enabledMicrophone);
|
||||
} catch (error, stackTrace) {
|
||||
Logger.print('could not publish audio: $error $stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
void _onRoomDidUpdate() {
|
||||
_sortParticipants();
|
||||
if (null != _room) roomDidUpdateSubject.add(_room!);
|
||||
}
|
||||
|
||||
void _sortParticipants() {
|
||||
if (null == _room) return;
|
||||
|
||||
final localParticipant = _room!.localParticipant;
|
||||
if (null != localParticipant) {
|
||||
VideoTrack? videoTrack;
|
||||
for (var t in localParticipant.videoTrackPublications) {
|
||||
if (!t.isScreenShare) {
|
||||
videoTrack = t.track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
localParticipantTrack = ParticipantTrack(
|
||||
participant: localParticipant,
|
||||
videoTrack: videoTrack,
|
||||
isScreenShare: false,
|
||||
);
|
||||
}
|
||||
|
||||
final participant = _room!.remoteParticipants.values.firstOrNull;
|
||||
if (null != participant) {
|
||||
VideoTrack? videoTrack;
|
||||
for (var t in participant.videoTrackPublications) {
|
||||
if (!t.isScreenShare) {
|
||||
videoTrack = t.track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
remoteParticipantTrack = ParticipantTrack(
|
||||
participant: participant,
|
||||
videoTrack: videoTrack,
|
||||
isScreenShare: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (null != remoteParticipantTrack) {
|
||||
onParticipantConnected();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
bool existParticipants() {
|
||||
return _room?.remoteParticipants.isNotEmpty == true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
import 'package:openim_live/src/utils/live_utils.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:sprintf/sprintf.dart';
|
||||
|
||||
import '../../../../openim_live.dart';
|
||||
import '../../../widgets/small_window.dart';
|
||||
import 'controls.dart';
|
||||
import 'participant.dart';
|
||||
|
||||
abstract class SignalView extends StatefulWidget {
|
||||
const SignalView({
|
||||
Key? key,
|
||||
required this.callType,
|
||||
required this.initState,
|
||||
this.roomID,
|
||||
required this.userID,
|
||||
required this.callEventSubject,
|
||||
this.onDial,
|
||||
this.onSyncUserInfo,
|
||||
this.onTapCancel,
|
||||
this.onTapHangup,
|
||||
this.onTapPickup,
|
||||
this.onTapReject,
|
||||
this.onClose,
|
||||
required this.autoPickup,
|
||||
this.onBindRoomID,
|
||||
this.onWaitingAccept,
|
||||
this.onBusyLine,
|
||||
this.onStartCalling,
|
||||
this.onError,
|
||||
this.onRoomDisconnected,
|
||||
}) : super(key: key);
|
||||
final CallType callType;
|
||||
final CallState initState;
|
||||
final String? roomID;
|
||||
final String userID;
|
||||
final PublishSubject<CallEvent> callEventSubject;
|
||||
final Future<SignalingCertificate> Function()? onDial;
|
||||
final Future<SignalingCertificate> Function()? onTapPickup;
|
||||
final Future Function()? onTapCancel;
|
||||
final Future Function(int duration, bool isPositive)? onTapHangup;
|
||||
final Future Function()? onTapReject;
|
||||
final Function()? onClose;
|
||||
final bool autoPickup;
|
||||
final Function(String roomID)? onBindRoomID;
|
||||
final Function()? onWaitingAccept;
|
||||
final Function()? onBusyLine;
|
||||
final Function()? onStartCalling;
|
||||
final Function()? onRoomDisconnected;
|
||||
final Function(dynamic error, dynamic stack)? onError;
|
||||
final Future<UserInfo?> Function(String userID)? onSyncUserInfo;
|
||||
}
|
||||
|
||||
abstract class SignalState<T extends SignalView> extends State<T> {
|
||||
final callStateSubject = BehaviorSubject<CallState>();
|
||||
final roomDidUpdateSubject = PublishSubject<Room>();
|
||||
late CallState callState;
|
||||
late SignalingCertificate certificate;
|
||||
String? roomID;
|
||||
UserInfo? userInfo;
|
||||
StreamSubscription? callEventSub;
|
||||
bool minimize = false;
|
||||
int duration = 0;
|
||||
bool enabledMicrophone = true;
|
||||
bool enabledSpeaker = true;
|
||||
|
||||
ParticipantTrack? remoteParticipantTrack;
|
||||
ParticipantTrack? localParticipantTrack;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
roomID ??= widget.roomID;
|
||||
callState = widget.initState;
|
||||
callEventSub = sameRoomSignalStream.listen(_onStateDidUpdate);
|
||||
widget.onSyncUserInfo?.call(widget.userID).then(_onUpdateUserInfo);
|
||||
onDail();
|
||||
autoPickup();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
callStateSubject.close();
|
||||
callEventSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 过滤其他房间的信令
|
||||
Stream<CallEvent> get sameRoomSignalStream => widget.callEventSubject.stream.where((event) => LiveUtils.isSameRoom(event, roomID));
|
||||
|
||||
_onUpdateUserInfo(UserInfo? info) {
|
||||
if (!mounted && null != info) return;
|
||||
setState(() {
|
||||
userInfo = info;
|
||||
});
|
||||
}
|
||||
|
||||
/// 某些信令通过liveKit的监听
|
||||
_onStateDidUpdate(CallEvent event) {
|
||||
Logger.print("CallEvent : 当前:$callState 收到:$event");
|
||||
if (!mounted) return;
|
||||
|
||||
// ui 状态只有 呼叫,被呼叫,通话中,连接中
|
||||
if (event.state == CallState.call ||
|
||||
event.state == CallState.beCalled ||
|
||||
event.state == CallState.connecting ||
|
||||
event.state == CallState.calling) {
|
||||
callStateSubject.add(event.state);
|
||||
}
|
||||
|
||||
if (event.state == CallState.beRejected || event.state == CallState.beCanceled) {
|
||||
widget.onClose?.call();
|
||||
} else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) {
|
||||
if (existParticipants()) {
|
||||
return;
|
||||
}
|
||||
widget.onClose?.call();
|
||||
IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept]));
|
||||
} else if (event.state == CallState.timeout) {
|
||||
widget.onClose?.call();
|
||||
} else if (event.state == CallState.beAccepted) {
|
||||
// 邀请对象比发起对象提前进入房间
|
||||
if (null != remoteParticipantTrack) {
|
||||
onParticipantConnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onParticipantConnected() {
|
||||
callStateSubject.add(CallState.calling);
|
||||
widget.onStartCalling?.call();
|
||||
}
|
||||
|
||||
onParticipantDisconnected() {
|
||||
onTapHangup(false);
|
||||
}
|
||||
|
||||
/// 发起者在对方为进入房间都是 等待状态
|
||||
onDail() async {
|
||||
if (widget.initState == CallState.call) {
|
||||
// callStateSubject.add(CallState.connecting);
|
||||
certificate = await widget.onDial!.call();
|
||||
widget.onBindRoomID?.call(roomID = certificate.roomID!);
|
||||
await connect();
|
||||
}
|
||||
}
|
||||
|
||||
autoPickup() {
|
||||
if (widget.autoPickup) {
|
||||
onTapPickup();
|
||||
}
|
||||
}
|
||||
|
||||
onTapPickup() async {
|
||||
Logger.print('------------onTapPickup---------连接中--------');
|
||||
callStateSubject.add(CallState.connecting);
|
||||
certificate = await widget.onTapPickup!.call();
|
||||
widget.onBindRoomID?.call(roomID = certificate.roomID!);
|
||||
await connect();
|
||||
callStateSubject.add(CallState.calling);
|
||||
widget.onStartCalling?.call();
|
||||
Logger.print('------------onTapPickup---------连接成功--------');
|
||||
}
|
||||
|
||||
/// [isPositive] 人为挂断行为
|
||||
onTapHangup(bool isPositive) async {
|
||||
await widget.onTapHangup?.call(duration, isPositive).whenComplete(() => /*isPositive ? {} : */ widget.onClose?.call());
|
||||
}
|
||||
|
||||
onTapCancel() async {
|
||||
await widget.onTapCancel?.call().whenComplete(() => widget.onClose?.call());
|
||||
}
|
||||
|
||||
onTapReject() async {
|
||||
await widget.onTapReject?.call().whenComplete(() => widget.onClose?.call());
|
||||
}
|
||||
|
||||
onTapMinimize() {
|
||||
setState(() {
|
||||
minimize = true;
|
||||
});
|
||||
}
|
||||
|
||||
onTapMaximize() {
|
||||
setState(() {
|
||||
minimize = false;
|
||||
});
|
||||
}
|
||||
|
||||
callingDuration(int duration) {
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
onChangedMicStatus(bool enabled) {
|
||||
enabledMicrophone = enabled;
|
||||
}
|
||||
|
||||
onChangedSpeakerStatus(bool enabled) {
|
||||
enabledSpeaker = enabled;
|
||||
}
|
||||
|
||||
//Alignment(0.9, -0.9),
|
||||
double alignX = 0.9;
|
||||
double alignY = -0.9;
|
||||
|
||||
Alignment get moveAlign => Alignment(alignX, alignY);
|
||||
|
||||
onMoveSmallWindow(DragUpdateDetails details) {
|
||||
final globalDy = details.globalPosition.dy;
|
||||
final globalDx = details.globalPosition.dx;
|
||||
setState(() {
|
||||
alignX = (globalDx - .5.sw) / .5.sw;
|
||||
alignY = (globalDy - .5.sh) / .5.sh;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> connect();
|
||||
|
||||
bool existParticipants();
|
||||
|
||||
bool smallScreenIsRemote = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Stack(
|
||||
children: [
|
||||
AnimatedScale(
|
||||
scale: minimize ? 0 : 1,
|
||||
alignment: moveAlign,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
onEnd: () {},
|
||||
child: Container(
|
||||
color: Styles.c_000000,
|
||||
child: Stack(
|
||||
children: [
|
||||
// ImageRes.liveBg.toImage
|
||||
// ..fit = BoxFit.cover
|
||||
// ..width = 1.sw
|
||||
// ..height = 1.sh,
|
||||
if (null != remoteParticipantTrack)
|
||||
ParticipantWidget.widgetFor(smallScreenIsRemote ? remoteParticipantTrack! : localParticipantTrack!),
|
||||
|
||||
if (null != localParticipantTrack)
|
||||
Positioned(
|
||||
top: 97.h,
|
||||
right: 12.w,
|
||||
child: GestureDetector(
|
||||
child: SizedBox(
|
||||
width: 120.w,
|
||||
height: 180.h,
|
||||
child: ParticipantWidget.widgetFor(smallScreenIsRemote ? localParticipantTrack! : remoteParticipantTrack!),
|
||||
),
|
||||
onTap: () {
|
||||
if (remoteParticipantTrack != null) {
|
||||
setState(() {
|
||||
smallScreenIsRemote = !smallScreenIsRemote;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
ControlsView(
|
||||
callStateStream: callStateSubject.stream,
|
||||
roomDidUpdateStream: roomDidUpdateSubject.stream,
|
||||
initState: widget.initState,
|
||||
callType: widget.callType,
|
||||
userInfo: userInfo,
|
||||
onMinimize: onTapMinimize,
|
||||
onCallingDuration: callingDuration,
|
||||
onEnabledMicrophone: onChangedMicStatus,
|
||||
onEnabledSpeaker: onChangedSpeakerStatus,
|
||||
onHangUp: onTapHangup,
|
||||
onPickUp: onTapPickup,
|
||||
onReject: onTapReject,
|
||||
onCancel: onTapCancel,
|
||||
onChangedCallState: (state) => callState = state,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (minimize)
|
||||
Align(
|
||||
alignment: moveAlign,
|
||||
child: AnimatedOpacity(
|
||||
opacity: minimize ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: SmallWindowView(
|
||||
opacity: minimize ? 1 : 0,
|
||||
userInfo: userInfo,
|
||||
callState: callState,
|
||||
onTapMaximize: onTapMaximize,
|
||||
onPanUpdate: onMoveSmallWindow,
|
||||
child: (state) {
|
||||
// if (null != remoteParticipantTrack &&
|
||||
// state == CallState.calling &&
|
||||
// widget.callType == CallType.video) {
|
||||
// return SizedBox(
|
||||
// width: 120.w,
|
||||
// height: 180.h,
|
||||
// child: ParticipantWidget.widgetFor(
|
||||
// remoteParticipantTrack!),
|
||||
// );
|
||||
// }
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
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:livekit_client/livekit_client.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
import 'package:openim_live/src/widgets/live_button.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
import '../../../live_client.dart';
|
||||
import '../../../widgets/loading_view.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.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';
|
||||
import 'package:openim_live/src/widgets/live_button.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
import '../../../live_client.dart';
|
||||
class ControlsView extends StatefulWidget {
|
||||
const ControlsView({
|
||||
Key? key,
|
||||
this.initState = CallState.call,
|
||||
this.callType = CallType.video,
|
||||
required this.callStateStream,
|
||||
required this.roomDidUpdateStream,
|
||||
this.userInfo,
|
||||
this.onMinimize,
|
||||
this.onCallingDuration,
|
||||
this.onEnabledMicrophone,
|
||||
this.onEnabledSpeaker,
|
||||
this.onCancel,
|
||||
this.onHangUp,
|
||||
this.onPickUp,
|
||||
this.onReject,
|
||||
this.onChangedCallState,
|
||||
}) : super(key: key);
|
||||
final Stream<Room> roomDidUpdateStream;
|
||||
final Stream<CallState> callStateStream;
|
||||
final CallState initState;
|
||||
final CallType callType;
|
||||
final UserInfo? userInfo;
|
||||
final Function()? onMinimize;
|
||||
final Function(int duration)? onCallingDuration;
|
||||
final Function(bool enabled)? onEnabledMicrophone;
|
||||
final Function(bool enabled)? onEnabledSpeaker;
|
||||
final Function()? onPickUp;
|
||||
final Function()? onCancel;
|
||||
final Function()? onReject;
|
||||
final Function(bool isPositive)? onHangUp;
|
||||
final Function(CallState state)? onChangedCallState;
|
||||
|
||||
@override
|
||||
State<ControlsView> createState() => _ControlsViewState();
|
||||
}
|
||||
|
||||
class _ControlsViewState extends State<ControlsView> {
|
||||
late CallState _callState;
|
||||
Timer? _callingTimer;
|
||||
int _callingDuration = 0;
|
||||
String _callingDurationStr = "00:00";
|
||||
|
||||
//
|
||||
CameraPosition position = CameraPosition.front;
|
||||
|
||||
List<MediaDevice>? _audioInputs;
|
||||
List<MediaDevice>? _audioOutputs;
|
||||
List<MediaDevice>? _videoInputs;
|
||||
|
||||
StreamSubscription<CallState>? _callStateChangedSub;
|
||||
StreamSubscription? _deviceChangeSub;
|
||||
StreamSubscription<Room>? _roomDidUpdateSub;
|
||||
|
||||
Room? _room;
|
||||
LocalParticipant? _participant;
|
||||
|
||||
/// 默认启用麦克风
|
||||
bool _enabledMicrophone = true;
|
||||
|
||||
/// 默认开启扬声器
|
||||
bool _enabledSpeaker = true;
|
||||
|
||||
final _lockAudio = Lock();
|
||||
final _lockSpeaker = Lock();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_callStateChangedSub?.cancel();
|
||||
_roomDidUpdateSub?.cancel();
|
||||
_callingTimer?.cancel();
|
||||
_deviceChangeSub?.cancel();
|
||||
_participant?.removeListener(_onChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_onChangedCallState(widget.initState);
|
||||
_callStateChangedSub = widget.callStateStream.listen(_onChangedCallState);
|
||||
_roomDidUpdateSub = widget.roomDidUpdateStream.listen(_roomDidUpdate);
|
||||
// _queryUserInfo();
|
||||
|
||||
_deviceChangeSub = Hardware.instance.onDeviceChange.stream.listen(_loadDevices);
|
||||
Hardware.instance.enumerateDevices().then(_loadDevices);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
_roomDidUpdate(Room room) {
|
||||
_room ??= room;
|
||||
if (room.localParticipant != null && _participant == null) {
|
||||
_participant = room.localParticipant;
|
||||
_participant?.addListener(_onChange);
|
||||
}
|
||||
}
|
||||
|
||||
_onChangedCallState(CallState state) {
|
||||
if (!mounted) return;
|
||||
widget.onChangedCallState?.call(state);
|
||||
setState(() {
|
||||
_callState = state;
|
||||
if (_callState == CallState.calling) {
|
||||
_startCallingTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startCallingTimer() {
|
||||
_callingTimer ??= Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_callingDurationStr = IMUtils.seconds2HMS(++_callingDuration);
|
||||
widget.onCallingDuration?.call(_callingDuration);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _loadDevices(List<MediaDevice> devices) async {
|
||||
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
|
||||
_audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList();
|
||||
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
|
||||
// setState(() {});
|
||||
}
|
||||
|
||||
void _onChange() {
|
||||
// trigger refresh
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _toggleAudio() async {
|
||||
await _lockAudio.synchronized(() async {
|
||||
_enabledMicrophone = !_enabledMicrophone;
|
||||
widget.onEnabledMicrophone?.call(_enabledMicrophone);
|
||||
if (_enabledMicrophone) {
|
||||
await _enableAudio();
|
||||
} else {
|
||||
await _disableAudio();
|
||||
}
|
||||
});
|
||||
|
||||
// if (null != _participant) {
|
||||
// if (_participant!.isMicrophoneEnabled()) {
|
||||
// _disableAudio();
|
||||
// } else {
|
||||
// _enableAudio();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
void _toggleSpeaker() async {
|
||||
await _lockSpeaker.synchronized(() async {
|
||||
_enabledSpeaker = !_enabledSpeaker;
|
||||
widget.onEnabledSpeaker?.call(_enabledSpeaker);
|
||||
if (_enabledSpeaker) {
|
||||
await _enableSpeaker();
|
||||
} else {
|
||||
await _disableSpeaker();
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _disableAudio() async {
|
||||
await _participant?.setMicrophoneEnabled(false);
|
||||
}
|
||||
|
||||
Future<void> _enableAudio() async {
|
||||
await _participant?.setMicrophoneEnabled(true);
|
||||
}
|
||||
|
||||
Future<void> _disableVideo() async {
|
||||
await _participant?.setCameraEnabled(false);
|
||||
}
|
||||
|
||||
Future<void> _enableVideo() async {
|
||||
await _participant?.setCameraEnabled(true, cameraCaptureOptions: CameraCaptureOptions(cameraPosition: position));
|
||||
}
|
||||
|
||||
Future<void> _disableSpeaker() async {
|
||||
await Hardware.instance.setSpeakerphoneOn(false);
|
||||
}
|
||||
|
||||
Future<void> _enableSpeaker() async {
|
||||
await Hardware.instance.setSpeakerphoneOn(true);
|
||||
}
|
||||
|
||||
void _selectAudioOutput(MediaDevice device) async {
|
||||
await _room?.setAudioOutputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _selectAudioInput(MediaDevice device) async {
|
||||
await _room?.setAudioInputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _selectVideoInput(MediaDevice device) async {
|
||||
await _room?.setVideoInputDevice(device);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _toggleCamera() async {
|
||||
//
|
||||
final track = _participant?.videoTrackPublications.firstOrNull?.track;
|
||||
if (track == null) return;
|
||||
Helper.switchCamera(track.mediaStreamTrack);
|
||||
// try {
|
||||
// final newPosition = position.switched();
|
||||
// await track.setCameraPosition(newPosition);
|
||||
// // setState(() {
|
||||
// // position = newPosition;
|
||||
// // });
|
||||
// } catch (error, stack) {
|
||||
// Logger.print('could not restart track: $error $stack');
|
||||
// return;
|
||||
// }
|
||||
}
|
||||
|
||||
@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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> 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),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
|
||||
class ParticipantTrack {
|
||||
ParticipantTrack({required this.participant, required this.videoTrack, required this.isScreenShare});
|
||||
|
||||
VideoTrack? videoTrack;
|
||||
Participant participant;
|
||||
final bool isScreenShare;
|
||||
}
|
||||
|
||||
abstract class ParticipantWidget extends StatefulWidget {
|
||||
// Convenience method to return relevant widget for participant
|
||||
static ParticipantWidget widgetFor(ParticipantTrack participantTrack) {
|
||||
if (participantTrack.participant is LocalParticipant) {
|
||||
return LocalParticipantWidget(participantTrack.participant as LocalParticipant, participantTrack.videoTrack, participantTrack.isScreenShare);
|
||||
} else if (participantTrack.participant is RemoteParticipant) {
|
||||
return RemoteParticipantWidget(participantTrack.participant as RemoteParticipant, participantTrack.videoTrack, participantTrack.isScreenShare);
|
||||
}
|
||||
throw UnimplementedError('Unknown participant type');
|
||||
}
|
||||
|
||||
// Must be implemented by child class
|
||||
abstract final Participant participant;
|
||||
abstract final VideoTrack? videoTrack;
|
||||
abstract final bool isScreenShare;
|
||||
final VideoQuality quality;
|
||||
|
||||
const ParticipantWidget({
|
||||
this.quality = VideoQuality.MEDIUM,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
}
|
||||
|
||||
class LocalParticipantWidget extends ParticipantWidget {
|
||||
@override
|
||||
final LocalParticipant participant;
|
||||
@override
|
||||
final VideoTrack? videoTrack;
|
||||
@override
|
||||
final bool isScreenShare;
|
||||
|
||||
const LocalParticipantWidget(
|
||||
this.participant,
|
||||
this.videoTrack,
|
||||
this.isScreenShare, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _LocalParticipantWidgetState();
|
||||
}
|
||||
|
||||
class RemoteParticipantWidget extends ParticipantWidget {
|
||||
@override
|
||||
final RemoteParticipant participant;
|
||||
@override
|
||||
final VideoTrack? videoTrack;
|
||||
@override
|
||||
final bool isScreenShare;
|
||||
|
||||
const RemoteParticipantWidget(
|
||||
this.participant,
|
||||
this.videoTrack,
|
||||
this.isScreenShare, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
|
||||
}
|
||||
|
||||
abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends State<T> {
|
||||
VideoTrack? get activeVideoTrack;
|
||||
|
||||
TrackPublication? get videoPublication;
|
||||
|
||||
TrackPublication? get firstAudioPublication;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.participant.addListener(_onParticipantChanged);
|
||||
_onParticipantChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.participant.removeListener(_onParticipantChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant T oldWidget) {
|
||||
oldWidget.participant.removeListener(_onParticipantChanged);
|
||||
widget.participant.addListener(_onParticipantChanged);
|
||||
_onParticipantChanged();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
// Notify Flutter that UI re-build is required, but we don't set anything here
|
||||
// since the updated values are computed properties.
|
||||
void _onParticipantChanged() => setState(() {});
|
||||
|
||||
// Widgets to show above the info bar
|
||||
List<Widget> extraWidgets(bool isScreenShare) => [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext ctx) => SizedBox(
|
||||
child: activeVideoTrack != null && !activeVideoTrack!.muted
|
||||
? VideoTrackRenderer(
|
||||
activeVideoTrack!,
|
||||
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> {
|
||||
@override
|
||||
LocalTrackPublication<LocalVideoTrack>? get videoPublication =>
|
||||
widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull;
|
||||
|
||||
@override
|
||||
LocalTrackPublication<LocalAudioTrack>? get firstAudioPublication => widget.participant.audioTrackPublications.firstOrNull;
|
||||
|
||||
@override
|
||||
VideoTrack? get activeVideoTrack => widget.videoTrack;
|
||||
}
|
||||
|
||||
class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemoteParticipantWidget> {
|
||||
@override
|
||||
RemoteTrackPublication<RemoteVideoTrack>? get videoPublication =>
|
||||
widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull;
|
||||
|
||||
@override
|
||||
RemoteTrackPublication<RemoteAudioTrack>? get firstAudioPublication => widget.participant.audioTrackPublications.firstOrNull;
|
||||
|
||||
@override
|
||||
VideoTrack? get activeVideoTrack => widget.videoTrack;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
|
||||
import '../../openim_live.dart';
|
||||
|
||||
class LiveUtils {
|
||||
/// Regex of url.
|
||||
static const String regexUrl = '[a-zA-Z]+://[^\\s]*';
|
||||
|
||||
/// Return whether input matches regex of url.
|
||||
static bool isURL(String input) {
|
||||
return matches(regexUrl, input);
|
||||
}
|
||||
|
||||
static bool matches(String regex, String input) {
|
||||
if (input.isEmpty) return false;
|
||||
return RegExp(regex).hasMatch(input);
|
||||
}
|
||||
|
||||
static String seconds2HMS(int seconds) {
|
||||
int h = 0;
|
||||
int m = 0;
|
||||
int s = 0;
|
||||
int temp = seconds % 3600;
|
||||
if (seconds > 3600) {
|
||||
h = seconds ~/ 3600;
|
||||
if (temp != 0) {
|
||||
if (temp > 60) {
|
||||
m = temp ~/ 60;
|
||||
if (temp % 60 != 0) {
|
||||
s = temp % 60;
|
||||
}
|
||||
} else {
|
||||
s = temp;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m = seconds ~/ 60;
|
||||
if (seconds % 60 != 0) {
|
||||
s = seconds % 60;
|
||||
}
|
||||
}
|
||||
if (h == 0) {
|
||||
return '${m < 10 ? '0$m' : m}:${s < 10 ? '0$s' : s}';
|
||||
}
|
||||
return "${h < 10 ? '0$h' : h}:${m < 10 ? '0$m' : m}:${s < 10 ? '0$s' : s}";
|
||||
}
|
||||
|
||||
static VideoTrack? activeVideoTrack(RemoteParticipant participant) {
|
||||
for (final trackPublication in participant.videoTrackPublications) {
|
||||
Logger.print(
|
||||
'video track ${trackPublication.sid} subscribed ${trackPublication.subscribed} muted ${trackPublication.muted}');
|
||||
if (trackPublication.subscribed && !trackPublication.muted) {
|
||||
return trackPublication.track;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 剔除房间观察者
|
||||
static List<RemoteParticipant> removeObserver(String roomID, Room room) {
|
||||
return room.remoteParticipants.values.where((element) {
|
||||
Logger.print(
|
||||
'removeObserver roomID:$roomID userID:${element.identity} ${roomID == element.identity}');
|
||||
return roomID != element.identity;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 单聊获取真正通话的对象
|
||||
static RemoteParticipant? getRemoteParticipant(String? roomID, Room? room) {
|
||||
return room?.remoteParticipants.values
|
||||
.where((element) => roomID != element.identity)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
/// 我主动发起通话,一开始roomID为null,拨号成功返回roomID
|
||||
/// 我被邀请通话,一开始就存在roomID
|
||||
static bool isSameRoom(CallEvent event, String? roomID) {
|
||||
var signalingInfo = event.data;
|
||||
var opUserID = signalingInfo.userID;
|
||||
var invitation = signalingInfo.invitation!;
|
||||
// var inviterUserID = invitation.inviterUserID;
|
||||
// var inviteeUserIDList = invitation.inviteeUserIDList;
|
||||
// var groupID = invitation.groupID;
|
||||
// var roomID = invitation.roomID;
|
||||
// var timeout = invitation.timeout;
|
||||
// var mediaType = invitation.mediaType;
|
||||
// var sessionType = invitation.sessionType;
|
||||
// var platformID = invitation.platformID;
|
||||
Logger.print('${event.state}--当前房间:$roomID--信令来自:${invitation.roomID}');
|
||||
return roomID == invitation.roomID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
|
||||
class LiveButton extends StatelessWidget {
|
||||
const LiveButton({
|
||||
Key? key,
|
||||
required this.text,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
}) : super(key: key);
|
||||
final String text;
|
||||
final String icon;
|
||||
final Function()? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
icon.toImage
|
||||
..width = 62.w
|
||||
..height = 62.h
|
||||
..onTap = onTap,
|
||||
10.verticalSpace,
|
||||
text.toText..style = Styles.ts_FFFFFF_opacity70_14sp,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
LiveButton.microphone({
|
||||
super.key,
|
||||
this.onTap,
|
||||
bool on = true,
|
||||
}) : text = StrRes.microphone,
|
||||
icon = on ? ImageRes.liveMicOn : ImageRes.liveMicOff;
|
||||
|
||||
LiveButton.speaker({
|
||||
super.key,
|
||||
this.onTap,
|
||||
bool on = true,
|
||||
}) : text = StrRes.speaker,
|
||||
icon = on ? ImageRes.liveSpeakerOn : ImageRes.liveSpeakerOff;
|
||||
|
||||
LiveButton.hungUp({
|
||||
super.key,
|
||||
this.onTap,
|
||||
}) : text = StrRes.hangUp,
|
||||
icon = ImageRes.liveHangUp;
|
||||
|
||||
LiveButton.reject({
|
||||
super.key,
|
||||
this.onTap,
|
||||
}) : text = StrRes.reject,
|
||||
icon = ImageRes.liveHangUp;
|
||||
|
||||
LiveButton.cancel({
|
||||
super.key,
|
||||
this.onTap,
|
||||
}) : text = StrRes.cancel,
|
||||
icon = ImageRes.liveHangUp;
|
||||
|
||||
LiveButton.pickUp({
|
||||
super.key,
|
||||
this.onTap,
|
||||
}) : text = StrRes.pickUp,
|
||||
icon = ImageRes.livePicUp;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
|
||||
class LiveLoadingView extends StatelessWidget {
|
||||
const LiveLoadingView({
|
||||
Key? key,
|
||||
this.assetsName = 'assets/anim/live_loading.json',
|
||||
this.package = 'openim_common',
|
||||
this.status = false,
|
||||
}) : super(key: key);
|
||||
final bool status;
|
||||
final String assetsName;
|
||||
final String? package;
|
||||
|
||||
Widget get _loadingAnimView => Center(
|
||||
child: Lottie.asset(assetsName, width: 50.w, package: package),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => status
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
StrRes.connecting.toText..style = Styles.ts_FFFFFF_opacity70_17sp,
|
||||
_loadingAnimView,
|
||||
],
|
||||
)
|
||||
: _loadingAnimView;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:eva_icons_flutter/eva_icons_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
import 'package:openim_live/src/utils/live_utils.dart';
|
||||
|
||||
class NoVideoWidget extends StatelessWidget {
|
||||
//
|
||||
const NoVideoWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
alignment: Alignment.center,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) => Icon(
|
||||
EvaIcons.videoOffOutline,
|
||||
color: Styles.c_0089FF,
|
||||
size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class NoVideoAvatarWidget extends StatelessWidget {
|
||||
//
|
||||
const NoVideoAvatarWidget({Key? key, this.faceURL, this.name}) : super(key: key);
|
||||
final String? faceURL;
|
||||
final String? name;
|
||||
//
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors.grey,
|
||||
child: null != faceURL && LiveUtils.isURL(faceURL!)
|
||||
? ImageUtil.networkImage(
|
||||
url: faceURL!, fit: BoxFit.cover, height: MediaQuery.of(context).size.width.h / 2, width: MediaQuery.of(context).size.width.h / 2)
|
||||
: AvatarView(
|
||||
url: faceURL,
|
||||
text: name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
|
||||
import '../../openim_live.dart';
|
||||
|
||||
class SmallWindowView extends StatelessWidget {
|
||||
const SmallWindowView({
|
||||
Key? key,
|
||||
this.userInfo,
|
||||
this.groupInfo,
|
||||
required this.callState,
|
||||
this.opacity = 1,
|
||||
this.onTapMaximize,
|
||||
this.child,
|
||||
this.onPanUpdate,
|
||||
}) : super(key: key);
|
||||
final CallState callState;
|
||||
final UserInfo? userInfo;
|
||||
final GroupInfo? groupInfo;
|
||||
final double opacity;
|
||||
final Function()? onTapMaximize;
|
||||
final Widget? Function(CallState state)? child;
|
||||
final GestureDragUpdateCallback? onPanUpdate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTapMaximize,
|
||||
onPanUpdate: onPanUpdate,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: child?.call(callState) ??
|
||||
Container(
|
||||
width: 84.w,
|
||||
height: 101.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Styles.c_0C1C33_opacity80,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (null != userInfo)
|
||||
AvatarView(
|
||||
text: IMUtils.emptyStrToNull(userInfo!.remark) ??
|
||||
userInfo!.nickname,
|
||||
url: userInfo!.faceURL,
|
||||
),
|
||||
if (null != groupInfo)
|
||||
AvatarView(
|
||||
text: groupInfo!.groupName,
|
||||
url: groupInfo!.faceURL,
|
||||
),
|
||||
10.verticalSpace,
|
||||
callStateStr.toText
|
||||
..style = Styles.ts_FFFFFF_12sp
|
||||
..maxLines = 1
|
||||
..overflow = TextOverflow.ellipsis,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String get callStateStr {
|
||||
if (callState == CallState.call) {
|
||||
return StrRes.waitingToAnswer;
|
||||
} else if (callState == CallState.beCalled) {
|
||||
return StrRes.invitedYouToCall;
|
||||
} else if (callState == CallState.calling) {
|
||||
return StrRes.calling;
|
||||
} else if (callState == CallState.connecting) {
|
||||
return StrRes.connecting;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// class SmallWindowView extends StatefulWidget {
|
||||
// const SmallWindowView({
|
||||
// Key? key,
|
||||
// required this.callStateStream,
|
||||
// this.userInfo,
|
||||
// this.groupInfo,
|
||||
// this.initState = CallState.call,
|
||||
// this.opacity = 1,
|
||||
// this.onTapMaximize,
|
||||
// this.child,
|
||||
// }) : super(key: key);
|
||||
// final Stream<CallState> callStateStream;
|
||||
// final CallState initState;
|
||||
// final UserInfo? userInfo;
|
||||
// final GroupInfo? groupInfo;
|
||||
// final double opacity;
|
||||
// final Function()? onTapMaximize;
|
||||
// final Widget? Function(CallState state)? child;
|
||||
//
|
||||
// @override
|
||||
// State<SmallWindowView> createState() => _SmallWindowViewState();
|
||||
// }
|
||||
//
|
||||
// class _SmallWindowViewState extends State<SmallWindowView> {
|
||||
// late CallState _callState;
|
||||
// StreamSubscription<CallState>? _sub;
|
||||
//
|
||||
// @override
|
||||
// void dispose() {
|
||||
// _sub?.cancel();
|
||||
// super.dispose();
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// void initState() {
|
||||
// _onChangedCallState(widget.initState);
|
||||
// _sub = widget.callStateStream.listen(_onChangedCallState);
|
||||
// super.initState();
|
||||
// }
|
||||
//
|
||||
// _onChangedCallState(CallState state) {
|
||||
// if (!mounted) return;
|
||||
// setState(() {
|
||||
// _callState = state;
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return GestureDetector(
|
||||
// onTap: widget.onTapMaximize,
|
||||
// child: ClipRRect(
|
||||
// borderRadius: BorderRadius.circular(6.r),
|
||||
// child: widget.child?.call(_callState) ??
|
||||
// Container(
|
||||
// width: 84.w,
|
||||
// height: 101.h,
|
||||
// decoration: BoxDecoration(
|
||||
// color: Styles.c_0C1C33_opacity80,
|
||||
// borderRadius: BorderRadius.circular(6.r),
|
||||
// ),
|
||||
// child: Material(
|
||||
// color: Colors.transparent,
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// if (null != widget.userInfo)
|
||||
// AvatarView(
|
||||
// text: IMUtils.emptyStrToNull(widget.userInfo!.remark) ??
|
||||
// widget.userInfo!.nickname,
|
||||
// url: widget.userInfo!.faceURL,
|
||||
// ),
|
||||
// if (null != widget.groupInfo)
|
||||
// AvatarView(
|
||||
// text: widget.groupInfo!.groupName,
|
||||
// url: widget.groupInfo!.faceURL,
|
||||
// ),
|
||||
// 10.verticalSpace,
|
||||
// callStateStr.toText..style = Styles.ts_FFFFFF_12sp,
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// String get callStateStr {
|
||||
// if (_callState == CallState.call) {
|
||||
// return StrRes.waitingToAnswer;
|
||||
// } else if (_callState == CallState.beCalled) {
|
||||
// return StrRes.invitedYouToCall;
|
||||
// } else if (_callState == CallState.calling) {
|
||||
// return StrRes.calling;
|
||||
// } else if (_callState == CallState.connecting) {
|
||||
// return StrRes.connecting;
|
||||
// }
|
||||
// return 'unknown';
|
||||
// }
|
||||
// }
|
||||
|
||||
// class SmallWindowView extends StatelessWidget {
|
||||
// const SmallWindowView({
|
||||
// Key? key,
|
||||
// required this.callState,
|
||||
// required this.opacity,
|
||||
// required this.userInfo,
|
||||
// this.onTapMaximize,
|
||||
// this.child,
|
||||
// }) : super(key: key);
|
||||
// final CallState callState;
|
||||
// final UserInfo userInfo;
|
||||
// final double opacity;
|
||||
// final Function()? onTapMaximize;
|
||||
// final Widget? child;
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return AnimatedOpacity(
|
||||
// opacity: opacity,
|
||||
// duration: const Duration(milliseconds: 200),
|
||||
// child: GestureDetector(
|
||||
// onTap: onTapMaximize,
|
||||
// child: ClipRRect(
|
||||
// borderRadius: BorderRadius.circular(6.r),
|
||||
// child: child ??
|
||||
// Container(
|
||||
// width: 84.w,
|
||||
// height: 101.h,
|
||||
// decoration: BoxDecoration(
|
||||
// color: Styles.c_0C1C33_opacity80,
|
||||
// borderRadius: BorderRadius.circular(6.r),
|
||||
// ),
|
||||
// child: Material(
|
||||
// color: Colors.transparent,
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// AvatarView(
|
||||
// text: IMUtils.emptyStrToNull(userInfo.remark) ??
|
||||
// userInfo.nickname,
|
||||
// url: userInfo.faceURL,
|
||||
// ),
|
||||
// 10.verticalSpace,
|
||||
// callStateStr.toText..style = Styles.ts_FFFFFF_12sp,
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// String get callStateStr {
|
||||
// if (callState == CallState.call) {
|
||||
// return StrRes.waitingToAnswer;
|
||||
// } else if (callState == CallState.beCalled) {
|
||||
// return StrRes.invitedYouToCall;
|
||||
// } else if (callState == CallState.calling) {
|
||||
// return StrRes.calling;
|
||||
// }
|
||||
// return 'unknown';
|
||||
// }
|
||||
// }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
name: openim_live
|
||||
description: A new Flutter project.
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_screenutil:
|
||||
get: 4.6.6
|
||||
livekit_client: 2.2.5
|
||||
flutter_background: 1.3.0+1
|
||||
eva_icons_flutter: 3.1.0
|
||||
flutter_svg: 2.0.9
|
||||
rxdart:
|
||||
lottie:
|
||||
synchronized:
|
||||
wakelock_plus: ^1.1.3
|
||||
sprintf: 7.0.0
|
||||
animate_do: 3.0.2
|
||||
common_utils: 2.1.0
|
||||
uuid:
|
||||
device_info_plus:
|
||||
openim_common:
|
||||
path: ../openim_common
|
||||
flutter_openim_sdk: 3.8.3+3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.1
|
||||
dependency_overrides:
|
||||
flutter_webrtc: 0.12.10
|
||||
# device_info_plus: ^8.1.0
|
||||
# collection: ^1.17.1
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/assets-and-images/#from-packages
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|
||||
# To add custom fonts to your package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/custom-fonts/#from-packages
|
||||
@@ -0,0 +1 @@
|
||||
void main() {}
|
||||
Reference in New Issue
Block a user