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:
编码工程师
2026-08-19 15:24:33 +08:00
co-authored by multica-agent
parent fa8584a73f
commit 8046ec7483
767 changed files with 57658 additions and 0 deletions
+10
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+39
View File
@@ -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
+75
View File
@@ -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() {}