✨ Call
This commit is contained in:
parent
66f41179ba
commit
7221af75eb
@ -202,5 +202,31 @@
|
|||||||
"one": "{} result",
|
"one": "{} result",
|
||||||
"other": "{} results"
|
"other": "{} results"
|
||||||
},
|
},
|
||||||
"postSearchTook": "Took {}"
|
"postSearchTook": "Took {}",
|
||||||
|
"call" : "Call",
|
||||||
|
"callOngoingNotice": "A call is ongoing",
|
||||||
|
"callJoin": "Join",
|
||||||
|
"callResume": "Resume",
|
||||||
|
"callMicrophone": "Microphone",
|
||||||
|
"callCamera": "Camera",
|
||||||
|
"callMicrophoneDisabled": "Microphone is disabled",
|
||||||
|
"callMicrophoneSelect": "Select a microphone",
|
||||||
|
"callCameraDisabled": "Camera is disabled",
|
||||||
|
"callCameraSelect": "Select a camera",
|
||||||
|
"callDisconnected": "Call has been disconnected",
|
||||||
|
"callEnded": "Call has been ended",
|
||||||
|
"callStatusConnected": "Connected",
|
||||||
|
"callStatusDisconnected": "Disconnected",
|
||||||
|
"callStatusConnecting": "Connecting",
|
||||||
|
"callStatusReconnecting": "Reconnecting",
|
||||||
|
"callDisconnect": "Disconnect",
|
||||||
|
"callDisconnectDescription": "Are you sure you want to disconnect from the call?",
|
||||||
|
"callMicrophoneOff": "Turn off microphone",
|
||||||
|
"callMicrophoneOn": "Turn on microphone",
|
||||||
|
"callCameraOff": "Turn off camera",
|
||||||
|
"callCameraOn": "Turn on camera",
|
||||||
|
"callVideoFlip": "Mirror video",
|
||||||
|
"callSpeakerphoneToggle": "Toggle speakerphone",
|
||||||
|
"callScreenOff": "Turn off screen share",
|
||||||
|
"callScreenOn": "Turn on screen share"
|
||||||
}
|
}
|
||||||
|
@ -202,5 +202,31 @@
|
|||||||
"one": "搜索到 {} 个结果",
|
"one": "搜索到 {} 个结果",
|
||||||
"other": "搜索到 {} 个结果"
|
"other": "搜索到 {} 个结果"
|
||||||
},
|
},
|
||||||
"postSearchTook": "耗时 {}"
|
"postSearchTook": "耗时 {}",
|
||||||
|
"call": "通话",
|
||||||
|
"callOngoingNotice": "一则通话进行中",
|
||||||
|
"callJoin": "加入",
|
||||||
|
"callResume": "恢复",
|
||||||
|
"callMicrophone": "麦克风",
|
||||||
|
"callCamera": "摄像头",
|
||||||
|
"callMicrophoneDisabled": "麦克风已禁用",
|
||||||
|
"callMicrophoneSelect": "选择麦克风",
|
||||||
|
"callCameraDisabled": "摄像头已禁用",
|
||||||
|
"callCameraSelect": "选择摄像头",
|
||||||
|
"callDisconnected": "通话已断开",
|
||||||
|
"callEnded": "通话已结束",
|
||||||
|
"callStatusConnected": "已连接",
|
||||||
|
"callStatusDisconnected": "未连接",
|
||||||
|
"callStatusConnecting": "正在连接",
|
||||||
|
"callStatusReconnecting": "正在重连",
|
||||||
|
"callDisconnect": "断开连接",
|
||||||
|
"callDisconnectDescription": "您确定要与通话断开连接吗?",
|
||||||
|
"callMicrophoneOff": "关闭麦克风",
|
||||||
|
"callMicrophoneOn": "打开麦克风",
|
||||||
|
"callCameraOff": "关闭摄像头",
|
||||||
|
"callCameraOn": "打开摄像头",
|
||||||
|
"callVideoFlip": "镜像画面",
|
||||||
|
"callSpeakerphoneToggle": "切换扬声器",
|
||||||
|
"callScreenOff": "关闭屏幕共享",
|
||||||
|
"callScreenOn": "开启屏幕共享"
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,8 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- cupertino_http (0.0.1):
|
- cupertino_http (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- device_info_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
- DKImagePickerController/Core (4.3.9):
|
- DKImagePickerController/Core (4.3.9):
|
||||||
- DKImagePickerController/ImageDataManager
|
- DKImagePickerController/ImageDataManager
|
||||||
- DKImagePickerController/Resource
|
- DKImagePickerController/Resource
|
||||||
@ -160,8 +162,9 @@ PODS:
|
|||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- image_picker_ios (0.0.1):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- isar_flutter_libs (1.0.0):
|
- livekit_client (2.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- WebRTC-SDK (= 125.6422.05)
|
||||||
- media_kit_libs_ios_video (1.0.4):
|
- media_kit_libs_ios_video (1.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- media_kit_native_event_loop (1.0.0):
|
- media_kit_native_event_loop (1.0.0):
|
||||||
@ -180,6 +183,8 @@ PODS:
|
|||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- permission_handler_apple (9.3.0):
|
||||||
|
- Flutter
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
- SAMKeychain (1.5.3)
|
- SAMKeychain (1.5.3)
|
||||||
- screen_brightness_ios (0.1.0):
|
- screen_brightness_ios (0.1.0):
|
||||||
@ -211,6 +216,7 @@ DEPENDENCIES:
|
|||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
|
||||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
|
- cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
|
||||||
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
||||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||||
@ -220,13 +226,14 @@ DEPENDENCIES:
|
|||||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||||
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
||||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||||
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
||||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
|
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
|
||||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
@ -263,6 +270,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/croppy/ios"
|
:path: ".symlinks/plugins/croppy/ios"
|
||||||
cupertino_http:
|
cupertino_http:
|
||||||
:path: ".symlinks/plugins/cupertino_http/ios"
|
:path: ".symlinks/plugins/cupertino_http/ios"
|
||||||
|
device_info_plus:
|
||||||
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
firebase_analytics:
|
firebase_analytics:
|
||||||
@ -281,8 +290,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
isar_flutter_libs:
|
livekit_client:
|
||||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
:path: ".symlinks/plugins/livekit_client/ios"
|
||||||
media_kit_libs_ios_video:
|
media_kit_libs_ios_video:
|
||||||
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
||||||
media_kit_native_event_loop:
|
media_kit_native_event_loop:
|
||||||
@ -295,6 +304,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/pasteboard/ios"
|
:path: ".symlinks/plugins/pasteboard/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
permission_handler_apple:
|
||||||
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
screen_brightness_ios:
|
screen_brightness_ios:
|
||||||
:path: ".symlinks/plugins/screen_brightness_ios/ios"
|
:path: ".symlinks/plugins/screen_brightness_ios/ios"
|
||||||
sentry_flutter:
|
sentry_flutter:
|
||||||
@ -314,6 +325,7 @@ SPEC CHECKSUMS:
|
|||||||
connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
|
connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
|
||||||
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
|
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
|
||||||
cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
|
cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec
|
||||||
|
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||||
@ -334,7 +346,7 @@ SPEC CHECKSUMS:
|
|||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
livekit_client: 5c31e13cd17dd0d545a074290c937dbdff1d809d
|
||||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||||
@ -342,6 +354,7 @@ SPEC CHECKSUMS:
|
|||||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||||
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
|
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
|
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
||||||
|
@ -74,18 +74,6 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
final payload = SnChatMessage.fromJson(event.payload!);
|
final payload = SnChatMessage.fromJson(event.payload!);
|
||||||
_addMessage(payload);
|
_addMessage(payload);
|
||||||
break;
|
break;
|
||||||
case 'calls.new':
|
|
||||||
final payload = SnChatMessage.fromJson(event.payload!);
|
|
||||||
if (payload.channel.id == channel?.id) {
|
|
||||||
// TODO impl call
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'calls.end':
|
|
||||||
final payload = SnChatMessage.fromJson(event.payload!);
|
|
||||||
if (payload.channel.id == channel?.id) {
|
|
||||||
// TODO impl call
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'status.typing':
|
case 'status.typing':
|
||||||
if (event.payload?['channel_id'] != channel?.id) break;
|
if (event.payload?['channel_id'] != channel?.id) break;
|
||||||
final member = SnChannelMember.fromJson(event.payload!['member']);
|
final member = SnChannelMember.fromJson(event.payload!['member']);
|
||||||
|
@ -12,6 +12,7 @@ import 'package:responsive_framework/responsive_framework.dart';
|
|||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
import 'package:surface/firebase_options.dart';
|
import 'package:surface/firebase_options.dart';
|
||||||
import 'package:surface/providers/channel.dart';
|
import 'package:surface/providers/channel.dart';
|
||||||
|
import 'package:surface/providers/chat_call.dart';
|
||||||
import 'package:surface/providers/navigation.dart';
|
import 'package:surface/providers/navigation.dart';
|
||||||
import 'package:surface/providers/notification.dart';
|
import 'package:surface/providers/notification.dart';
|
||||||
import 'package:surface/providers/sn_attachment.dart';
|
import 'package:surface/providers/sn_attachment.dart';
|
||||||
@ -86,6 +87,7 @@ class SolianApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
|
||||||
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
||||||
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
||||||
|
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
|
||||||
],
|
],
|
||||||
child: AppMainContent(),
|
child: AppMainContent(),
|
||||||
),
|
),
|
||||||
|
459
lib/providers/chat_call.dart
Normal file
459
lib/providers/chat_call.dart
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/types/chat.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
class ChatCallProvider extends ChangeNotifier {
|
||||||
|
late final SnNetworkProvider _sn;
|
||||||
|
|
||||||
|
ChatCallProvider(BuildContext context) {
|
||||||
|
_sn = context.read<SnNetworkProvider>();
|
||||||
|
}
|
||||||
|
|
||||||
|
SnChatCall? _current;
|
||||||
|
SnChannel? _channel;
|
||||||
|
|
||||||
|
bool _isReady = false;
|
||||||
|
bool _isMounted = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool _isBusy = false;
|
||||||
|
|
||||||
|
String _lastDuration = '00:00:00';
|
||||||
|
Timer? _lastDurationUpdateTimer;
|
||||||
|
|
||||||
|
String? token;
|
||||||
|
String? endpoint;
|
||||||
|
|
||||||
|
StreamSubscription? hwSubscription;
|
||||||
|
List<MediaDevice> _audioInputs = [];
|
||||||
|
List<MediaDevice> _videoInputs = [];
|
||||||
|
|
||||||
|
bool _enableAudio = true;
|
||||||
|
bool _enableVideo = false;
|
||||||
|
LocalAudioTrack? _audioTrack;
|
||||||
|
LocalVideoTrack? _videoTrack;
|
||||||
|
MediaDevice? _videoDevice;
|
||||||
|
MediaDevice? _audioDevice;
|
||||||
|
|
||||||
|
late Room _room;
|
||||||
|
late EventsListener<RoomEvent> _listener;
|
||||||
|
|
||||||
|
List<ParticipantTrack> _participantTracks = [];
|
||||||
|
ParticipantTrack? _focusTrack;
|
||||||
|
|
||||||
|
// Getters for private fields
|
||||||
|
SnChatCall? get current => _current;
|
||||||
|
SnChannel? get channel => _channel;
|
||||||
|
bool get isReady => _isReady;
|
||||||
|
bool get isMounted => _isMounted;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
String get lastDuration => _lastDuration;
|
||||||
|
List<MediaDevice> get audioInputs => _audioInputs;
|
||||||
|
List<MediaDevice> get videoInputs => _videoInputs;
|
||||||
|
bool get enableAudio => _enableAudio;
|
||||||
|
bool get enableVideo => _enableVideo;
|
||||||
|
LocalAudioTrack? get audioTrack => _audioTrack;
|
||||||
|
LocalVideoTrack? get videoTrack => _videoTrack;
|
||||||
|
MediaDevice? get videoDevice => _videoDevice;
|
||||||
|
MediaDevice? get audioDevice => _audioDevice;
|
||||||
|
List<ParticipantTrack> get participantTracks => _participantTracks;
|
||||||
|
ParticipantTrack? get focusTrack => _focusTrack;
|
||||||
|
Room get room => _room;
|
||||||
|
|
||||||
|
void _updateDuration() {
|
||||||
|
if (_current == null) {
|
||||||
|
_lastDuration = '00:00:00';
|
||||||
|
} else {
|
||||||
|
Duration duration = DateTime.now().difference(_current!.createdAt);
|
||||||
|
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||||
|
_lastDuration = '${twoDigits(duration.inHours)}:'
|
||||||
|
'${twoDigits(duration.inMinutes.remainder(60))}:'
|
||||||
|
'${twoDigits(duration.inSeconds.remainder(60))}';
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void enableDurationUpdater() {
|
||||||
|
_updateDuration();
|
||||||
|
_lastDurationUpdateTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 1),
|
||||||
|
(_) => _updateDuration(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void disableDurationUpdater() {
|
||||||
|
_lastDurationUpdateTimer?.cancel();
|
||||||
|
_lastDurationUpdateTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> checkPermissions() async {
|
||||||
|
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Permission.camera.request();
|
||||||
|
await Permission.microphone.request();
|
||||||
|
await Permission.bluetooth.request();
|
||||||
|
await Permission.bluetoothConnect.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCall(SnChatCall call, SnChannel related) {
|
||||||
|
_current = call;
|
||||||
|
_channel = related;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(String, String)> getRoomToken() async {
|
||||||
|
final resp = await _sn.client.post(
|
||||||
|
'/cgi/im/channels/${_channel!.keyPath}/calls/ongoing/token',
|
||||||
|
);
|
||||||
|
token = resp.data['token'];
|
||||||
|
endpoint = 'wss://${resp.data['endpoint']}';
|
||||||
|
return (token!, endpoint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initHardware() {
|
||||||
|
if (_isReady) return;
|
||||||
|
|
||||||
|
_isReady = true;
|
||||||
|
hwSubscription = Hardware.instance.onDeviceChange.stream.listen(
|
||||||
|
_revertDevices,
|
||||||
|
);
|
||||||
|
Hardware.instance.enumerateDevices().then(_revertDevices);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void initRoom() {
|
||||||
|
initHardware();
|
||||||
|
_room = Room(
|
||||||
|
roomOptions: const RoomOptions(
|
||||||
|
dynacast: true,
|
||||||
|
adaptiveStream: true,
|
||||||
|
defaultAudioPublishOptions: AudioPublishOptions(
|
||||||
|
name: 'call_voice',
|
||||||
|
stream: 'call_stream',
|
||||||
|
),
|
||||||
|
defaultVideoPublishOptions: VideoPublishOptions(
|
||||||
|
name: 'call_video',
|
||||||
|
stream: 'call_stream',
|
||||||
|
simulcast: true,
|
||||||
|
backupVideoCodec: BackupVideoCodec(enabled: true),
|
||||||
|
),
|
||||||
|
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
|
||||||
|
useiOSBroadcastExtension: true,
|
||||||
|
params: VideoParametersPresets.screenShareH1080FPS30,
|
||||||
|
),
|
||||||
|
defaultCameraCaptureOptions: CameraCaptureOptions(
|
||||||
|
maxFrameRate: 30,
|
||||||
|
params: VideoParametersPresets.h1080_169,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_listener = _room.createListener();
|
||||||
|
WakelockPlus.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> joinRoom(String url, String token) async {
|
||||||
|
if (_isMounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _room.connect(
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
fastConnectOptions: FastConnectOptions(
|
||||||
|
microphone: TrackOption(track: _audioTrack),
|
||||||
|
camera: TrackOption(track: _videoTrack),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_isMounted = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setupRoom() {
|
||||||
|
if (isInitialized) return;
|
||||||
|
|
||||||
|
sortParticipants();
|
||||||
|
_room.addListener(_onRoomDidUpdate);
|
||||||
|
WidgetsBindingCompatible.instance?.addPostFrameCallback(
|
||||||
|
(_) => autoPublish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lkPlatformIsMobile()) {
|
||||||
|
Hardware.instance.setSpeakerphoneOn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isBusy = false;
|
||||||
|
_isInitialized = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void autoPublish() async {
|
||||||
|
try {
|
||||||
|
if (enableVideo) {
|
||||||
|
await _room.localParticipant?.setCameraEnabled(true);
|
||||||
|
}
|
||||||
|
if (enableAudio) {
|
||||||
|
await _room.localParticipant?.setMicrophoneEnabled(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setEnableAudio(bool value) async {
|
||||||
|
_enableAudio = value;
|
||||||
|
if (!_enableAudio) {
|
||||||
|
await _audioTrack?.stop();
|
||||||
|
_audioTrack = null;
|
||||||
|
} else {
|
||||||
|
await _changeLocalAudioTrack();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setEnableVideo(bool value) async {
|
||||||
|
_enableVideo = value;
|
||||||
|
if (!_enableVideo) {
|
||||||
|
await _videoTrack?.stop();
|
||||||
|
_videoTrack = null;
|
||||||
|
} else {
|
||||||
|
await _changeLocalVideoTrack();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setupRoomListeners({
|
||||||
|
required Function(DisconnectReason?) onDisconnected,
|
||||||
|
}) {
|
||||||
|
_listener
|
||||||
|
..on<RoomDisconnectedEvent>((event) async {
|
||||||
|
onDisconnected(event.reason);
|
||||||
|
})
|
||||||
|
..on<ParticipantEvent>((event) => sortParticipants())
|
||||||
|
..on<LocalTrackPublishedEvent>((_) => sortParticipants())
|
||||||
|
..on<LocalTrackUnpublishedEvent>((_) => sortParticipants())
|
||||||
|
..on<TrackSubscribedEvent>((_) => sortParticipants())
|
||||||
|
..on<TrackUnsubscribedEvent>((_) => sortParticipants())
|
||||||
|
..on<ParticipantNameUpdatedEvent>((event) {
|
||||||
|
sortParticipants();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void sortParticipants() {
|
||||||
|
Map<String, ParticipantTrack> mediaTracks = {};
|
||||||
|
for (var participant in _room.remoteParticipants.values) {
|
||||||
|
mediaTracks[participant.sid] = ParticipantTrack(
|
||||||
|
participant: participant,
|
||||||
|
videoTrack: null,
|
||||||
|
isScreenShare: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var t in participant.videoTrackPublications) {
|
||||||
|
mediaTracks[participant.sid]?.videoTrack = t.track;
|
||||||
|
mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newTracks = List<ParticipantTrack>.empty(growable: true);
|
||||||
|
|
||||||
|
final mediaTrackList = mediaTracks.values.toList();
|
||||||
|
mediaTrackList.sort((a, b) {
|
||||||
|
// Loudest people first
|
||||||
|
if (a.participant.isSpeaking && b.participant.isSpeaking) {
|
||||||
|
if (a.participant.audioLevel > b.participant.audioLevel) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last spoke first
|
||||||
|
final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
|
||||||
|
final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0;
|
||||||
|
|
||||||
|
if (aSpokeAt != bSpokeAt) {
|
||||||
|
return aSpokeAt > bSpokeAt ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has video first
|
||||||
|
if (a.participant.hasVideo != b.participant.hasVideo) {
|
||||||
|
return a.participant.hasVideo ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First joined people first
|
||||||
|
return a.participant.joinedAt.millisecondsSinceEpoch -
|
||||||
|
b.participant.joinedAt.millisecondsSinceEpoch;
|
||||||
|
});
|
||||||
|
|
||||||
|
newTracks.addAll(mediaTrackList);
|
||||||
|
|
||||||
|
if (_room.localParticipant != null) {
|
||||||
|
ParticipantTrack localTrack = ParticipantTrack(
|
||||||
|
participant: _room.localParticipant!,
|
||||||
|
videoTrack: null,
|
||||||
|
isScreenShare: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final localParticipantTracks =
|
||||||
|
_room.localParticipant?.videoTrackPublications;
|
||||||
|
if (localParticipantTracks != null) {
|
||||||
|
for (var t in localParticipantTracks) {
|
||||||
|
localTrack.videoTrack = t.track;
|
||||||
|
localTrack.isScreenShare = t.isScreenShare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newTracks.add(localTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
_participantTracks = newTracks;
|
||||||
|
|
||||||
|
if (focusTrack != null) {
|
||||||
|
final idx = participantTracks
|
||||||
|
.indexWhere((x) => x.participant.sid == _focusTrack!.participant.sid);
|
||||||
|
if (idx == -1) {
|
||||||
|
_focusTrack = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusTrack == null) {
|
||||||
|
_focusTrack = participantTracks.firstOrNull;
|
||||||
|
} else {
|
||||||
|
final idx = participantTracks.indexWhere(
|
||||||
|
(x) => _focusTrack!.participant.sid == x.participant.sid,
|
||||||
|
);
|
||||||
|
if (idx > -1) {
|
||||||
|
_focusTrack = participantTracks[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeLocalAudioTrack() async {
|
||||||
|
if (_audioTrack != null) {
|
||||||
|
await _audioTrack!.stop();
|
||||||
|
_audioTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_audioDevice != null) {
|
||||||
|
_audioTrack = await LocalAudioTrack.create(
|
||||||
|
AudioCaptureOptions(deviceId: _audioDevice!.deviceId),
|
||||||
|
);
|
||||||
|
await _audioTrack!.start();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeLocalVideoTrack() async {
|
||||||
|
if (_videoTrack != null) {
|
||||||
|
await _videoTrack!.stop();
|
||||||
|
_videoTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_videoDevice != null) {
|
||||||
|
_videoTrack = await LocalVideoTrack.createCameraTrack(
|
||||||
|
CameraCaptureOptions(
|
||||||
|
deviceId: _videoDevice!.deviceId,
|
||||||
|
params: VideoParametersPresets.h1080_169,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _videoTrack!.start();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _revertDevices(List<MediaDevice> devices) {
|
||||||
|
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
|
||||||
|
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRoomDidUpdate() => sortParticipants();
|
||||||
|
|
||||||
|
Future<void> changeLocalAudioTrack() async {
|
||||||
|
if (audioTrack != null) {
|
||||||
|
await audioTrack!.stop();
|
||||||
|
_audioTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioDevice != null) {
|
||||||
|
_audioTrack = await LocalAudioTrack.create(
|
||||||
|
AudioCaptureOptions(
|
||||||
|
deviceId: audioDevice!.deviceId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await audioTrack!.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changeLocalVideoTrack() async {
|
||||||
|
if (videoTrack != null) {
|
||||||
|
await _videoTrack!.stop();
|
||||||
|
_videoTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoDevice != null) {
|
||||||
|
_videoTrack = await LocalVideoTrack.createCameraTrack(
|
||||||
|
CameraCaptureOptions(
|
||||||
|
deviceId: videoDevice!.deviceId,
|
||||||
|
params: VideoParametersPresets.h1080_169,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await videoTrack!.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void deactivateHardware() {
|
||||||
|
hwSubscription?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void disposeRoom() {
|
||||||
|
_isBusy = false;
|
||||||
|
_isMounted = false;
|
||||||
|
_isInitialized = false;
|
||||||
|
_current = null;
|
||||||
|
_channel = null;
|
||||||
|
_room.removeListener(_onRoomDidUpdate);
|
||||||
|
_room.disconnect();
|
||||||
|
_room.dispose();
|
||||||
|
_listener.dispose();
|
||||||
|
WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
void disposeHardware() {
|
||||||
|
_isReady = false;
|
||||||
|
_audioTrack?.stop();
|
||||||
|
_audioTrack = null;
|
||||||
|
_videoTrack?.stop();
|
||||||
|
_videoTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setVideoDevice(MediaDevice? value) {
|
||||||
|
_videoDevice = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAudioDevice(MediaDevice? value) {
|
||||||
|
_audioDevice = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFocusTrack(ParticipantTrack? value) {
|
||||||
|
_focusTrack = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setIsBusy(bool value) {
|
||||||
|
_isBusy = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
@ -114,7 +114,6 @@ final _appRoutes = [
|
|||||||
path: '/:scope/:alias/call',
|
path: '/:scope/:alias/call',
|
||||||
name: 'chatCallRoom',
|
name: 'chatCallRoom',
|
||||||
builder: (context, state) => AppBackground(
|
builder: (context, state) => AppBackground(
|
||||||
isLessOptimization: true,
|
|
||||||
child: CallRoomScreen(
|
child: CallRoomScreen(
|
||||||
scope: state.pathParameters['scope']!,
|
scope: state.pathParameters['scope']!,
|
||||||
alias: state.pathParameters['alias']!,
|
alias: state.pathParameters['alias']!,
|
||||||
|
@ -26,6 +26,7 @@ class AccountScreen extends StatelessWidget {
|
|||||||
GoRouter.of(context).pushNamed('settings');
|
GoRouter.of(context).pushNamed('settings');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import 'dart:convert';
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:livekit_client/livekit_client.dart' as livekit;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/chat_call.dart';
|
||||||
|
import 'package:surface/widgets/chat/call/call_controls.dart';
|
||||||
|
import 'package:surface/widgets/chat/call/call_participant.dart';
|
||||||
|
|
||||||
class CallRoomScreen extends StatefulWidget {
|
class CallRoomScreen extends StatefulWidget {
|
||||||
final String scope;
|
final String scope;
|
||||||
@ -15,16 +20,301 @@ class CallRoomScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CallRoomScreenState extends State<CallRoomScreen> {
|
class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||||
@override
|
int _layoutMode = 0;
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
void _switchLayout() {
|
||||||
appBar: AppBar(title: Text('Voice Chat')),
|
if (_layoutMode < 1) {
|
||||||
body: Center(
|
setState(() => _layoutMode++);
|
||||||
child: ElevatedButton(
|
} else {
|
||||||
onPressed: () {},
|
setState(() => _layoutMode = 0);
|
||||||
child: Text('Start Call'),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildListLayout() {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
child: call.focusTrack != null
|
||||||
|
? InteractiveParticipantWidget(
|
||||||
|
isFixedAvatar: false,
|
||||||
|
participant: call.focusTrack!,
|
||||||
|
onTap: () {},
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 128,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: math.max(0, call.participantTracks.length),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final track = call.participantTracks[index];
|
||||||
|
if (track.participant.sid == call.focusTrack?.participant.sid) {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, left: 8),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: InteractiveParticipantWidget(
|
||||||
|
isFixedAvatar: true,
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
participant: track,
|
||||||
|
onTap: () {
|
||||||
|
if (track.participant.sid !=
|
||||||
|
call.focusTrack?.participant.sid) {
|
||||||
|
call.setFocusTrack(track);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGridLayout() {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
|
||||||
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
|
double screenWidth = constraints.maxWidth;
|
||||||
|
double screenHeight = constraints.maxHeight;
|
||||||
|
|
||||||
|
int columns = (math.sqrt(call.participantTracks.length)).ceil();
|
||||||
|
int rows = (call.participantTracks.length / columns).ceil();
|
||||||
|
|
||||||
|
double tileWidth = screenWidth / columns;
|
||||||
|
double tileHeight = screenHeight / rows;
|
||||||
|
|
||||||
|
return StyledWidget(GridView.builder(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: columns,
|
||||||
|
childAspectRatio: tileWidth / tileHeight,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
|
itemCount: math.max(0, call.participantTracks.length),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final track = call.participantTracks[index];
|
||||||
|
return Card(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: InteractiveParticipantWidget(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
participant: track,
|
||||||
|
onTap: () {
|
||||||
|
if (track.participant.sid !=
|
||||||
|
call.focusTrack?.participant.sid) {
|
||||||
|
call.setFocusTrack(track);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)).padding(all: 8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
call
|
||||||
|
..setupRoom()
|
||||||
|
..enableDurationUpdater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: call,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: RichText(
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
text: TextSpan(children: [
|
||||||
|
TextSpan(
|
||||||
|
text: 'call'.tr(),
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleLarge!
|
||||||
|
.copyWith(color: Colors.white),
|
||||||
|
),
|
||||||
|
const TextSpan(text: '\n'),
|
||||||
|
TextSpan(
|
||||||
|
text: call.lastDuration.toString(),
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodySmall!
|
||||||
|
.copyWith(color: Colors.white),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
height: 64,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Builder(builder: (context) {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
final connectionQuality =
|
||||||
|
call.room.localParticipant?.connectionQuality ??
|
||||||
|
livekit.ConnectionQuality.unknown;
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
call.channel?.name ?? 'unknown'.tr(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
Text(call.lastDuration.toString())
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
{
|
||||||
|
livekit.ConnectionState.disconnected:
|
||||||
|
'callStatusDisconnected'.tr(),
|
||||||
|
livekit.ConnectionState.connected:
|
||||||
|
'callStatusConnected'.tr(),
|
||||||
|
livekit.ConnectionState.connecting:
|
||||||
|
'callStatusConnecting'.tr(),
|
||||||
|
livekit.ConnectionState.reconnecting:
|
||||||
|
'callStatusReconnecting'.tr(),
|
||||||
|
}[call.room.connectionState]!,
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
if (connectionQuality !=
|
||||||
|
livekit.ConnectionQuality.unknown)
|
||||||
|
Icon(
|
||||||
|
{
|
||||||
|
livekit.ConnectionQuality.excellent:
|
||||||
|
Icons.signal_cellular_alt,
|
||||||
|
livekit.ConnectionQuality.good:
|
||||||
|
Icons.signal_cellular_alt_2_bar,
|
||||||
|
livekit.ConnectionQuality.poor:
|
||||||
|
Icons.signal_cellular_alt_1_bar,
|
||||||
|
}[connectionQuality],
|
||||||
|
color: {
|
||||||
|
livekit.ConnectionQuality.excellent:
|
||||||
|
Colors.green,
|
||||||
|
livekit.ConnectionQuality.good:
|
||||||
|
Colors.orange,
|
||||||
|
livekit.ConnectionQuality.poor:
|
||||||
|
Colors.red,
|
||||||
|
}[connectionQuality],
|
||||||
|
size: 16,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
).padding(all: 3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: _layoutMode == 0
|
||||||
|
? const Icon(Icons.view_list)
|
||||||
|
: const Icon(Icons.grid_view),
|
||||||
|
onPressed: () {
|
||||||
|
_switchLayout();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(left: 20, right: 16),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Material(
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
switch (_layoutMode) {
|
||||||
|
case 1:
|
||||||
|
return _buildGridLayout();
|
||||||
|
default:
|
||||||
|
return _buildListLayout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (call.room.localParticipant != null)
|
||||||
|
SizedBox(
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
child: ControlsWidget(
|
||||||
|
call.room,
|
||||||
|
call.room.localParticipant!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void deactivate() {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
call.disableDurationUpdater();
|
||||||
|
super.deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void activate() {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
call.enableDurationUpdater();
|
||||||
|
super.activate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:surface/controllers/chat_message_controller.dart';
|
import 'package:surface/controllers/chat_message_controller.dart';
|
||||||
import 'package:surface/providers/channel.dart';
|
import 'package:surface/providers/channel.dart';
|
||||||
|
import 'package:surface/providers/chat_call.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/providers/websocket.dart';
|
||||||
import 'package:surface/types/chat.dart';
|
import 'package:surface/types/chat.dart';
|
||||||
|
import 'package:surface/widgets/chat/call/call_prejoin.dart';
|
||||||
import 'package:surface/widgets/chat/chat_message.dart';
|
import 'package:surface/widgets/chat/chat_message.dart';
|
||||||
import 'package:surface/widgets/chat/chat_message_input.dart';
|
import 'package:surface/widgets/chat/chat_message_input.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
@ -24,12 +34,16 @@ class ChatRoomScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
|
bool _isCalling = false;
|
||||||
|
|
||||||
SnChannel? _channel;
|
SnChannel? _channel;
|
||||||
|
SnChatCall? _ongoingCall;
|
||||||
|
|
||||||
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
|
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
|
||||||
late final ChatMessageController _messageController;
|
late final ChatMessageController _messageController;
|
||||||
|
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
|
||||||
Future<void> _fetchChannel() async {
|
Future<void> _fetchChannel() async {
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
@ -44,6 +58,87 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchOngoingCall() async {
|
||||||
|
setState(() => _isCalling = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get(
|
||||||
|
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
|
||||||
|
options: Options(
|
||||||
|
validateStatus: (status) => status != null && status < 500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (resp.statusCode == 200) {
|
||||||
|
_ongoingCall = SnChatCall.fromJson(resp.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isCalling = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _makeCall() async {
|
||||||
|
setState(() => _isCalling = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.post(
|
||||||
|
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls',
|
||||||
|
options: Options(
|
||||||
|
sendTimeout: const Duration(seconds: 30),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
log(jsonDecode(resp.data));
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isCalling = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _endCall() async {
|
||||||
|
setState(() => _isCalling = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.delete(
|
||||||
|
'/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing',
|
||||||
|
);
|
||||||
|
log(jsonDecode(resp.data));
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isCalling = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCallJoin() async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ChatCallPrejoinPopup(
|
||||||
|
ongoingCall: _ongoingCall!,
|
||||||
|
channel: _channel!,
|
||||||
|
onJoin: _onCallResume,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCallResume() {
|
||||||
|
GoRouter.of(context).pushNamed(
|
||||||
|
'chatCallRoom',
|
||||||
|
pathParameters: {
|
||||||
|
'scope': _channel!.realm!.alias,
|
||||||
|
'alias': _channel!.alias,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -51,30 +146,58 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
_fetchChannel().then((_) async {
|
_fetchChannel().then((_) async {
|
||||||
await _messageController.initialize(_channel!);
|
await _messageController.initialize(_channel!);
|
||||||
await _messageController.checkUpdate();
|
await _messageController.checkUpdate();
|
||||||
|
await _fetchOngoingCall();
|
||||||
|
});
|
||||||
|
|
||||||
|
final ws = context.read<WebSocketProvider>();
|
||||||
|
_wsSubscription = ws.stream.stream.listen((event) {
|
||||||
|
switch (event.method) {
|
||||||
|
case 'calls.new':
|
||||||
|
final payload = SnChatCall.fromJson(event.payload!);
|
||||||
|
if (payload.channelId == _channel?.id) {
|
||||||
|
setState(() => _ongoingCall = payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'calls.end':
|
||||||
|
final payload = SnChatCall.fromJson(event.payload!);
|
||||||
|
if (payload.channelId == _channel?.id) {
|
||||||
|
setState(() => _ongoingCall = null);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_wsSubscription?.cancel();
|
||||||
|
_messageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final call = context.watch<ChatCallProvider>();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_channel?.name ?? 'loading'.tr()),
|
title: Text(_channel?.name ?? 'loading'.tr()),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
icon: _ongoingCall == null
|
||||||
GoRouter.of(context).pushNamed('chatCallRoom', pathParameters: {
|
? const Icon(Symbols.call)
|
||||||
'scope': widget.scope,
|
: const Icon(Symbols.call_end),
|
||||||
'alias': widget.alias,
|
onPressed: _isCalling
|
||||||
});
|
? null
|
||||||
},
|
: _ongoingCall == null
|
||||||
icon: const Icon(Symbols.voice_chat),
|
? _makeCall
|
||||||
|
: _endCall,
|
||||||
),
|
),
|
||||||
IconButton(onPressed: () {}, icon: const Icon(Symbols.more_vert)),
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.more_vert),
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListenableBuilder(
|
body: ListenableBuilder(
|
||||||
@ -83,6 +206,28 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
LoadingIndicator(isActive: _isBusy),
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: MaterialBanner(
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
leading: const Icon(Symbols.call_received),
|
||||||
|
content: Text('callOngoingNotice').tr().padding(top: 2),
|
||||||
|
actions: [
|
||||||
|
if (call.current == null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _onCallJoin,
|
||||||
|
child: Text('callJoin').tr(),
|
||||||
|
)
|
||||||
|
else if (call.current?.channelId == _channel?.id)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _onCallResume,
|
||||||
|
child: Text('callResume').tr(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
Curves.fastLinearToSlowEaseIn),
|
||||||
if (_messageController.isPending)
|
if (_messageController.isPending)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: const CircularProgressIndicator().center(),
|
child: const CircularProgressIndicator().center(),
|
||||||
@ -112,7 +257,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
idx > 0 ? _messageController.messages[idx - 1] : null;
|
idx > 0 ? _messageController.messages[idx - 1] : null;
|
||||||
|
|
||||||
final canMerge = nextMessage != null &&
|
final canMerge = nextMessage != null &&
|
||||||
nextMessage.updatedAt == nextMessage.createdAt &&
|
|
||||||
nextMessage.senderId == message.senderId &&
|
nextMessage.senderId == message.senderId &&
|
||||||
message.createdAt
|
message.createdAt
|
||||||
.difference(nextMessage.createdAt)
|
.difference(nextMessage.createdAt)
|
||||||
@ -120,7 +264,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
.abs() <=
|
.abs() <=
|
||||||
3;
|
3;
|
||||||
final canMergePrevious = previousMessage != null &&
|
final canMergePrevious = previousMessage != null &&
|
||||||
message.updatedAt == message.createdAt &&
|
|
||||||
previousMessage.senderId == message.senderId &&
|
previousMessage.senderId == message.senderId &&
|
||||||
message.createdAt
|
message.createdAt
|
||||||
.difference(previousMessage.createdAt)
|
.difference(previousMessage.createdAt)
|
||||||
|
@ -171,6 +171,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
|||||||
GoRouter.of(context).pushNamed('postSearch');
|
GoRouter.of(context).pushNamed('postSearch');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SliverInfiniteList(
|
SliverInfiniteList(
|
||||||
|
@ -138,6 +138,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
icon: const Icon(Symbols.checklist),
|
icon: const Icon(Symbols.checklist),
|
||||||
onPressed: _isSubmitting ? null : _markAllAsRead,
|
onPressed: _isSubmitting ? null : _markAllAsRead,
|
||||||
),
|
),
|
||||||
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
|
@ -146,6 +146,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
icon: const Icon(Symbols.tune),
|
icon: const Icon(Symbols.tune),
|
||||||
onPressed: _writeController.isBusy ? null : _updateMeta,
|
onPressed: _writeController.isBusy ? null : _updateMeta,
|
||||||
),
|
),
|
||||||
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
|
@ -101,6 +101,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
icon: const Icon(Symbols.tune),
|
icon: const Icon(Symbols.tune),
|
||||||
onPressed: _showAdvancedSearchTune,
|
onPressed: _showAdvancedSearchTune,
|
||||||
),
|
),
|
||||||
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
|
@ -87,6 +87,7 @@ class _RealmScreenState extends State<RealmScreen> {
|
|||||||
setState(() => _isCompactView = !_isCompactView);
|
setState(() => _isCompactView = !_isCompactView);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
@ -39,6 +39,9 @@ Future<ThemeData> createAppTheme(
|
|||||||
opticalSize: 20,
|
opticalSize: 20,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
scaffoldBackgroundColor: Colors.transparent,
|
scaffoldBackgroundColor: Colors.transparent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
import 'package:surface/types/account.dart';
|
import 'package:surface/types/account.dart';
|
||||||
import 'package:surface/types/attachment.dart';
|
import 'package:surface/types/attachment.dart';
|
||||||
import 'package:surface/types/realm.dart';
|
import 'package:surface/types/realm.dart';
|
||||||
@ -101,3 +102,43 @@ class SnChatMessagePreload with _$SnChatMessagePreload {
|
|||||||
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
|
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SnChatMessagePreloadFromJson(json);
|
_$SnChatMessagePreloadFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SnChatCall with _$SnChatCall {
|
||||||
|
const factory SnChatCall({
|
||||||
|
required int id,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
required DateTime? deletedAt,
|
||||||
|
required DateTime? endedAt,
|
||||||
|
required String externalId,
|
||||||
|
required int founderId,
|
||||||
|
required int channelId,
|
||||||
|
required SnChannelMember founder,
|
||||||
|
@Default([]) List<dynamic> participants,
|
||||||
|
}) = _SnChatCall;
|
||||||
|
|
||||||
|
factory SnChatCall.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnChatCallFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call stuff
|
||||||
|
|
||||||
|
enum ParticipantStatsType {
|
||||||
|
unknown,
|
||||||
|
localAudioSender,
|
||||||
|
localVideoSender,
|
||||||
|
remoteAudioReceiver,
|
||||||
|
remoteVideoReceiver,
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParticipantTrack {
|
||||||
|
ParticipantTrack(
|
||||||
|
{required this.participant,
|
||||||
|
required this.videoTrack,
|
||||||
|
required this.isScreenShare});
|
||||||
|
|
||||||
|
VideoTrack? videoTrack;
|
||||||
|
Participant participant;
|
||||||
|
bool isScreenShare;
|
||||||
|
}
|
||||||
|
@ -1788,3 +1788,376 @@ abstract class _SnChatMessagePreload extends SnChatMessagePreload {
|
|||||||
_$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl>
|
_$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl>
|
||||||
get copyWith => throw _privateConstructorUsedError;
|
get copyWith => throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SnChatCall _$SnChatCallFromJson(Map<String, dynamic> json) {
|
||||||
|
return _SnChatCall.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$SnChatCall {
|
||||||
|
int get id => throw _privateConstructorUsedError;
|
||||||
|
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||||
|
DateTime get updatedAt => throw _privateConstructorUsedError;
|
||||||
|
DateTime? get deletedAt => throw _privateConstructorUsedError;
|
||||||
|
DateTime? get endedAt => throw _privateConstructorUsedError;
|
||||||
|
String get externalId => throw _privateConstructorUsedError;
|
||||||
|
int get founderId => throw _privateConstructorUsedError;
|
||||||
|
int get channelId => throw _privateConstructorUsedError;
|
||||||
|
SnChannelMember get founder => throw _privateConstructorUsedError;
|
||||||
|
List<dynamic> get participants => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this SnChatCall to a JSON map.
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of SnChatCall
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$SnChatCallCopyWith<SnChatCall> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $SnChatCallCopyWith<$Res> {
|
||||||
|
factory $SnChatCallCopyWith(
|
||||||
|
SnChatCall value, $Res Function(SnChatCall) then) =
|
||||||
|
_$SnChatCallCopyWithImpl<$Res, SnChatCall>;
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{int id,
|
||||||
|
DateTime createdAt,
|
||||||
|
DateTime updatedAt,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
DateTime? endedAt,
|
||||||
|
String externalId,
|
||||||
|
int founderId,
|
||||||
|
int channelId,
|
||||||
|
SnChannelMember founder,
|
||||||
|
List<dynamic> participants});
|
||||||
|
|
||||||
|
$SnChannelMemberCopyWith<$Res> get founder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$SnChatCallCopyWithImpl<$Res, $Val extends SnChatCall>
|
||||||
|
implements $SnChatCallCopyWith<$Res> {
|
||||||
|
_$SnChatCallCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnChatCall
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? createdAt = null,
|
||||||
|
Object? updatedAt = null,
|
||||||
|
Object? deletedAt = freezed,
|
||||||
|
Object? endedAt = freezed,
|
||||||
|
Object? externalId = null,
|
||||||
|
Object? founderId = null,
|
||||||
|
Object? channelId = null,
|
||||||
|
Object? founder = null,
|
||||||
|
Object? participants = null,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
createdAt: null == createdAt
|
||||||
|
? _value.createdAt
|
||||||
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
updatedAt: null == updatedAt
|
||||||
|
? _value.updatedAt
|
||||||
|
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
deletedAt: freezed == deletedAt
|
||||||
|
? _value.deletedAt
|
||||||
|
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
endedAt: freezed == endedAt
|
||||||
|
? _value.endedAt
|
||||||
|
: endedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
externalId: null == externalId
|
||||||
|
? _value.externalId
|
||||||
|
: externalId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
founderId: null == founderId
|
||||||
|
? _value.founderId
|
||||||
|
: founderId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
channelId: null == channelId
|
||||||
|
? _value.channelId
|
||||||
|
: channelId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
founder: null == founder
|
||||||
|
? _value.founder
|
||||||
|
: founder // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnChannelMember,
|
||||||
|
participants: null == participants
|
||||||
|
? _value.participants
|
||||||
|
: participants // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<dynamic>,
|
||||||
|
) as $Val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of SnChatCall
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnChannelMemberCopyWith<$Res> get founder {
|
||||||
|
return $SnChannelMemberCopyWith<$Res>(_value.founder, (value) {
|
||||||
|
return _then(_value.copyWith(founder: value) as $Val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$SnChatCallImplCopyWith<$Res>
|
||||||
|
implements $SnChatCallCopyWith<$Res> {
|
||||||
|
factory _$$SnChatCallImplCopyWith(
|
||||||
|
_$SnChatCallImpl value, $Res Function(_$SnChatCallImpl) then) =
|
||||||
|
__$$SnChatCallImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{int id,
|
||||||
|
DateTime createdAt,
|
||||||
|
DateTime updatedAt,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
DateTime? endedAt,
|
||||||
|
String externalId,
|
||||||
|
int founderId,
|
||||||
|
int channelId,
|
||||||
|
SnChannelMember founder,
|
||||||
|
List<dynamic> participants});
|
||||||
|
|
||||||
|
@override
|
||||||
|
$SnChannelMemberCopyWith<$Res> get founder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$SnChatCallImplCopyWithImpl<$Res>
|
||||||
|
extends _$SnChatCallCopyWithImpl<$Res, _$SnChatCallImpl>
|
||||||
|
implements _$$SnChatCallImplCopyWith<$Res> {
|
||||||
|
__$$SnChatCallImplCopyWithImpl(
|
||||||
|
_$SnChatCallImpl _value, $Res Function(_$SnChatCallImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of SnChatCall
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? createdAt = null,
|
||||||
|
Object? updatedAt = null,
|
||||||
|
Object? deletedAt = freezed,
|
||||||
|
Object? endedAt = freezed,
|
||||||
|
Object? externalId = null,
|
||||||
|
Object? founderId = null,
|
||||||
|
Object? channelId = null,
|
||||||
|
Object? founder = null,
|
||||||
|
Object? participants = null,
|
||||||
|
}) {
|
||||||
|
return _then(_$SnChatCallImpl(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
createdAt: null == createdAt
|
||||||
|
? _value.createdAt
|
||||||
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
updatedAt: null == updatedAt
|
||||||
|
? _value.updatedAt
|
||||||
|
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,
|
||||||
|
deletedAt: freezed == deletedAt
|
||||||
|
? _value.deletedAt
|
||||||
|
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
endedAt: freezed == endedAt
|
||||||
|
? _value.endedAt
|
||||||
|
: endedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
externalId: null == externalId
|
||||||
|
? _value.externalId
|
||||||
|
: externalId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
founderId: null == founderId
|
||||||
|
? _value.founderId
|
||||||
|
: founderId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
channelId: null == channelId
|
||||||
|
? _value.channelId
|
||||||
|
: channelId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
founder: null == founder
|
||||||
|
? _value.founder
|
||||||
|
: founder // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnChannelMember,
|
||||||
|
participants: null == participants
|
||||||
|
? _value._participants
|
||||||
|
: participants // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<dynamic>,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$SnChatCallImpl implements _SnChatCall {
|
||||||
|
const _$SnChatCallImpl(
|
||||||
|
{required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.deletedAt,
|
||||||
|
required this.endedAt,
|
||||||
|
required this.externalId,
|
||||||
|
required this.founderId,
|
||||||
|
required this.channelId,
|
||||||
|
required this.founder,
|
||||||
|
final List<dynamic> participants = const []})
|
||||||
|
: _participants = participants;
|
||||||
|
|
||||||
|
factory _$SnChatCallImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$SnChatCallImplFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final int id;
|
||||||
|
@override
|
||||||
|
final DateTime createdAt;
|
||||||
|
@override
|
||||||
|
final DateTime updatedAt;
|
||||||
|
@override
|
||||||
|
final DateTime? deletedAt;
|
||||||
|
@override
|
||||||
|
final DateTime? endedAt;
|
||||||
|
@override
|
||||||
|
final String externalId;
|
||||||
|
@override
|
||||||
|
final int founderId;
|
||||||
|
@override
|
||||||
|
final int channelId;
|
||||||
|
@override
|
||||||
|
final SnChannelMember founder;
|
||||||
|
final List<dynamic> _participants;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
List<dynamic> get participants {
|
||||||
|
if (_participants is EqualUnmodifiableListView) return _participants;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnChatCall(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, endedAt: $endedAt, externalId: $externalId, founderId: $founderId, channelId: $channelId, founder: $founder, participants: $participants)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$SnChatCallImpl &&
|
||||||
|
(identical(other.id, id) || other.id == id) &&
|
||||||
|
(identical(other.createdAt, createdAt) ||
|
||||||
|
other.createdAt == createdAt) &&
|
||||||
|
(identical(other.updatedAt, updatedAt) ||
|
||||||
|
other.updatedAt == updatedAt) &&
|
||||||
|
(identical(other.deletedAt, deletedAt) ||
|
||||||
|
other.deletedAt == deletedAt) &&
|
||||||
|
(identical(other.endedAt, endedAt) || other.endedAt == endedAt) &&
|
||||||
|
(identical(other.externalId, externalId) ||
|
||||||
|
other.externalId == externalId) &&
|
||||||
|
(identical(other.founderId, founderId) ||
|
||||||
|
other.founderId == founderId) &&
|
||||||
|
(identical(other.channelId, channelId) ||
|
||||||
|
other.channelId == channelId) &&
|
||||||
|
(identical(other.founder, founder) || other.founder == founder) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other._participants, _participants));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
id,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
deletedAt,
|
||||||
|
endedAt,
|
||||||
|
externalId,
|
||||||
|
founderId,
|
||||||
|
channelId,
|
||||||
|
founder,
|
||||||
|
const DeepCollectionEquality().hash(_participants));
|
||||||
|
|
||||||
|
/// Create a copy of SnChatCall
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$SnChatCallImplCopyWith<_$SnChatCallImpl> get copyWith =>
|
||||||
|
__$$SnChatCallImplCopyWithImpl<_$SnChatCallImpl>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$SnChatCallImplToJson(
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _SnChatCall implements SnChatCall {
|
||||||
|
const factory _SnChatCall(
|
||||||
|
{required final int id,
|
||||||
|
required final DateTime createdAt,
|
||||||
|
required final DateTime updatedAt,
|
||||||
|
required final DateTime? deletedAt,
|
||||||
|
required final DateTime? endedAt,
|
||||||
|
required final String externalId,
|
||||||
|
required final int founderId,
|
||||||
|
required final int channelId,
|
||||||
|
required final SnChannelMember founder,
|
||||||
|
final List<dynamic> participants}) = _$SnChatCallImpl;
|
||||||
|
|
||||||
|
factory _SnChatCall.fromJson(Map<String, dynamic> json) =
|
||||||
|
_$SnChatCallImpl.fromJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get id;
|
||||||
|
@override
|
||||||
|
DateTime get createdAt;
|
||||||
|
@override
|
||||||
|
DateTime get updatedAt;
|
||||||
|
@override
|
||||||
|
DateTime? get deletedAt;
|
||||||
|
@override
|
||||||
|
DateTime? get endedAt;
|
||||||
|
@override
|
||||||
|
String get externalId;
|
||||||
|
@override
|
||||||
|
int get founderId;
|
||||||
|
@override
|
||||||
|
int get channelId;
|
||||||
|
@override
|
||||||
|
SnChannelMember get founder;
|
||||||
|
@override
|
||||||
|
List<dynamic> get participants;
|
||||||
|
|
||||||
|
/// Create a copy of SnChatCall
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$SnChatCallImplCopyWith<_$SnChatCallImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
@ -360,3 +360,36 @@ Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
|
|||||||
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
|
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
|
||||||
'quote_event': instance.quoteEvent?.toJson(),
|
'quote_event': instance.quoteEvent?.toJson(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnChatCallImpl(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
deletedAt: json['deleted_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deleted_at'] as String),
|
||||||
|
endedAt: json['ended_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['ended_at'] as String),
|
||||||
|
externalId: json['external_id'] as String,
|
||||||
|
founderId: (json['founder_id'] as num).toInt(),
|
||||||
|
channelId: (json['channel_id'] as num).toInt(),
|
||||||
|
founder:
|
||||||
|
SnChannelMember.fromJson(json['founder'] as Map<String, dynamic>),
|
||||||
|
participants: json['participants'] as List<dynamic>? ?? const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$SnChatCallImplToJson(_$SnChatCallImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
'ended_at': instance.endedAt?.toIso8601String(),
|
||||||
|
'external_id': instance.externalId,
|
||||||
|
'founder_id': instance.founderId,
|
||||||
|
'channel_id': instance.channelId,
|
||||||
|
'founder': instance.founder.toJson(),
|
||||||
|
'participants': instance.participants,
|
||||||
|
};
|
||||||
|
@ -290,7 +290,7 @@ class _AttachmentItemContentVideoState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.play_arrow,
|
Symbols.play_arrow,
|
||||||
shadows: labelShadows,
|
shadows: labelShadows,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
).padding(bottom: 4, right: 8),
|
).padding(bottom: 4, right: 8),
|
||||||
@ -450,7 +450,7 @@ class _AttachmentItemContentAudioState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.play_arrow,
|
Symbols.play_arrow,
|
||||||
shadows: labelShadows,
|
shadows: labelShadows,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
).padding(bottom: 4, right: 8),
|
).padding(bottom: 4, right: 8),
|
||||||
|
369
lib/widgets/chat/call/call_controls.dart
Normal file
369
lib/widgets/chat/call/call_controls.dart
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/providers/chat_call.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
|
||||||
|
class ControlsWidget extends StatefulWidget {
|
||||||
|
final Room room;
|
||||||
|
final LocalParticipant participant;
|
||||||
|
|
||||||
|
const ControlsWidget(
|
||||||
|
this.room,
|
||||||
|
this.participant, {
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _ControlsWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ControlsWidgetState extends State<ControlsWidget> {
|
||||||
|
CameraPosition _position = CameraPosition.front;
|
||||||
|
|
||||||
|
List<MediaDevice>? _audioInputs;
|
||||||
|
List<MediaDevice>? _audioOutputs;
|
||||||
|
List<MediaDevice>? _videoInputs;
|
||||||
|
|
||||||
|
StreamSubscription? _subscription;
|
||||||
|
|
||||||
|
bool _speakerphoneOn = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_participant.addListener(onChange);
|
||||||
|
_subscription = Hardware.instance.onDeviceChange.stream
|
||||||
|
.listen((List<MediaDevice> devices) {
|
||||||
|
_revertDevices(devices);
|
||||||
|
});
|
||||||
|
Hardware.instance.enumerateDevices().then(_revertDevices);
|
||||||
|
_speakerphoneOn = Hardware.instance.speakerOn ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subscription?.cancel();
|
||||||
|
_participant.removeListener(onChange);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalParticipant get _participant => widget.participant;
|
||||||
|
|
||||||
|
void _revertDevices(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() => setState(() {});
|
||||||
|
|
||||||
|
bool get isMuted => _participant.isMuted;
|
||||||
|
|
||||||
|
Future<bool?> showDisconnectDialog() {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text('callDisconnect').tr(),
|
||||||
|
content: Text('callDisconnectDescription').tr(),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text('cancel').tr(),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text('dialogConfirm').tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disconnect() async {
|
||||||
|
if (await showDisconnectDialog() != true) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
if (call.current != null) {
|
||||||
|
call.disposeRoom();
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disableAudio() async {
|
||||||
|
await _participant.setMicrophoneEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enableAudio() async {
|
||||||
|
await _participant.setMicrophoneEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disableVideo() async {
|
||||||
|
await _participant.setCameraEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enableVideo() async {
|
||||||
|
await _participant.setCameraEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAudioOutput(MediaDevice device) async {
|
||||||
|
await widget.room.setAudioOutputDevice(device);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAudioInput(MediaDevice device) async {
|
||||||
|
await widget.room.setAudioInputDevice(device);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectVideoInput(MediaDevice device) async {
|
||||||
|
await widget.room.setVideoInputDevice(device);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSpeakerphoneOn() {
|
||||||
|
_speakerphoneOn = !_speakerphoneOn;
|
||||||
|
Hardware.instance.setSpeakerphoneOn(_speakerphoneOn);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleCamera() async {
|
||||||
|
final track = _participant.videoTrackPublications.firstOrNull?.track;
|
||||||
|
if (track == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newPosition = _position.switched();
|
||||||
|
await track.setCameraPosition(newPosition);
|
||||||
|
setState(() {
|
||||||
|
_position = newPosition;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enableScreenShare() async {
|
||||||
|
if (lkPlatformIsDesktop()) {
|
||||||
|
try {
|
||||||
|
final source = await showDialog<DesktopCapturerSource>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ScreenSelectDialog(),
|
||||||
|
);
|
||||||
|
if (source == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var track = await LocalVideoTrack.createScreenShareTrack(
|
||||||
|
ScreenShareCaptureOptions(
|
||||||
|
captureScreenAudio: true,
|
||||||
|
sourceId: source.id,
|
||||||
|
maxFrameRate: 30.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _participant.publishVideoTrack(track);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lkPlatformIs(PlatformType.iOS)) {
|
||||||
|
var track = await LocalVideoTrack.createScreenShareTrack(
|
||||||
|
const ScreenShareCaptureOptions(
|
||||||
|
useiOSBroadcastExtension: true,
|
||||||
|
captureScreenAudio: true,
|
||||||
|
maxFrameRate: 30.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _participant.publishVideoTrack(track);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lkPlatformIsWebMobile()) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Screen share is not supported mobile platform.'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _participant.setScreenShareEnabled(true, captureScreenAudio: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disableScreenShare() async {
|
||||||
|
await _participant.setScreenShareEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 5,
|
||||||
|
runSpacing: 5,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.exit_to_app),
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
onPressed: _disconnect,
|
||||||
|
),
|
||||||
|
if (_participant.isMicrophoneEnabled())
|
||||||
|
if (lkPlatformIs(PlatformType.android))
|
||||||
|
IconButton(
|
||||||
|
onPressed: _disableAudio,
|
||||||
|
icon: const Icon(Symbols.mic),
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
tooltip: 'callMicrophoneOff'.tr(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
PopupMenuButton<MediaDevice>(
|
||||||
|
icon: const Icon(Symbols.settings_voice),
|
||||||
|
itemBuilder: (BuildContext context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem<MediaDevice>(
|
||||||
|
value: null,
|
||||||
|
onTap: isMuted ? _enableAudio : _disableAudio,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Symbols.mic_off),
|
||||||
|
title: Text(isMuted
|
||||||
|
? 'callMicrophoneOn'.tr()
|
||||||
|
: 'callMicrophoneOff'.tr()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_audioInputs != null)
|
||||||
|
..._audioInputs!.map((device) {
|
||||||
|
return PopupMenuItem<MediaDevice>(
|
||||||
|
value: device,
|
||||||
|
child: ListTile(
|
||||||
|
leading: (device.deviceId ==
|
||||||
|
widget.room.selectedAudioInputDeviceId)
|
||||||
|
? const Icon(Symbols.check_box)
|
||||||
|
: const Icon(Symbols.check_box_outline_blank),
|
||||||
|
title: Text(device.label),
|
||||||
|
),
|
||||||
|
onTap: () => _selectAudioInput(device),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
onPressed: _enableAudio,
|
||||||
|
icon: const Icon(Symbols.mic_off),
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
tooltip: 'callMicrophoneOn'.tr(),
|
||||||
|
),
|
||||||
|
if (_participant.isCameraEnabled())
|
||||||
|
PopupMenuButton<MediaDevice>(
|
||||||
|
icon: const Icon(Symbols.videocam_sharp),
|
||||||
|
itemBuilder: (BuildContext context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem<MediaDevice>(
|
||||||
|
value: null,
|
||||||
|
onTap: _disableVideo,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Symbols.videocam_off),
|
||||||
|
title: Text('callCameraOff'.tr()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_videoInputs != null)
|
||||||
|
..._videoInputs!.map((device) {
|
||||||
|
return PopupMenuItem<MediaDevice>(
|
||||||
|
value: device,
|
||||||
|
child: ListTile(
|
||||||
|
leading: (device.deviceId ==
|
||||||
|
widget.room.selectedVideoInputDeviceId)
|
||||||
|
? const Icon(Symbols.check_box)
|
||||||
|
: const Icon(Symbols.check_box_outline_blank),
|
||||||
|
title: Text(device.label),
|
||||||
|
),
|
||||||
|
onTap: () => _selectVideoInput(device),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
onPressed: _enableVideo,
|
||||||
|
icon: const Icon(Symbols.videocam_off),
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
tooltip: 'callCameraOn'.tr(),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_position == CameraPosition.back
|
||||||
|
? Symbols.video_camera_back
|
||||||
|
: Symbols.video_camera_front),
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
onPressed: () => _toggleCamera(),
|
||||||
|
tooltip: 'callVideoFlip'.tr(),
|
||||||
|
),
|
||||||
|
if (!lkPlatformIs(PlatformType.iOS))
|
||||||
|
PopupMenuButton<MediaDevice>(
|
||||||
|
icon: const Icon(Symbols.volume_up),
|
||||||
|
itemBuilder: (BuildContext context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem<MediaDevice>(
|
||||||
|
value: null,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Symbols.speaker),
|
||||||
|
title: Text('callSpeakerSelect').tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_audioOutputs != null)
|
||||||
|
..._audioOutputs!.map((device) {
|
||||||
|
return PopupMenuItem<MediaDevice>(
|
||||||
|
value: device,
|
||||||
|
child: ListTile(
|
||||||
|
leading: (device.deviceId ==
|
||||||
|
widget.room.selectedAudioOutputDeviceId)
|
||||||
|
? const Icon(Symbols.check_box)
|
||||||
|
: const Icon(Symbols.check_box_outline_blank),
|
||||||
|
title: Text(device.label),
|
||||||
|
),
|
||||||
|
onTap: () => _selectAudioOutput(device),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!kIsWeb && Hardware.instance.canSwitchSpeakerphone)
|
||||||
|
IconButton(
|
||||||
|
onPressed: _toggleSpeakerphoneOn,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
icon: _speakerphoneOn
|
||||||
|
? Icon(Symbols.volume_up)
|
||||||
|
: Icon(Symbols.volume_down),
|
||||||
|
tooltip: 'callSpeakerphoneToggle'.tr(),
|
||||||
|
),
|
||||||
|
if (_participant.isScreenShareEnabled())
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.stop_screen_share),
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
onPressed: () => _disableScreenShare(),
|
||||||
|
tooltip: 'callScreenOff'.tr(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.screen_share),
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
onPressed: () => _enableScreenShare(),
|
||||||
|
tooltip: 'callScreenOn'.tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
92
lib/widgets/chat/call/call_no_content.dart
Normal file
92
lib/widgets/chat/call/call_no_content.dart
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/widgets/account/account_image.dart';
|
||||||
|
|
||||||
|
class NoContentWidget extends StatefulWidget {
|
||||||
|
final SnAccount? userinfo;
|
||||||
|
final bool isSpeaking;
|
||||||
|
final bool isFixed;
|
||||||
|
|
||||||
|
const NoContentWidget({
|
||||||
|
super.key,
|
||||||
|
this.userinfo,
|
||||||
|
this.isFixed = false,
|
||||||
|
required this.isSpeaking,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NoContentWidget> createState() => _NoContentWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoContentWidgetState extends State<NoContentWidget>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _animationController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(NoContentWidget old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.isSpeaking) {
|
||||||
|
_animationController.repeat(reverse: true);
|
||||||
|
} else {
|
||||||
|
_animationController
|
||||||
|
.animateTo(0, duration: 300.ms)
|
||||||
|
.then((_) => _animationController.reset());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final double radius = widget.isFixed
|
||||||
|
? 32
|
||||||
|
: math.min(
|
||||||
|
MediaQuery.of(context).size.width * 0.1,
|
||||||
|
MediaQuery.of(context).size.height * 0.1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Center(
|
||||||
|
child: Animate(
|
||||||
|
autoPlay: false,
|
||||||
|
controller: _animationController,
|
||||||
|
effects: [
|
||||||
|
CustomEffect(
|
||||||
|
begin: widget.isSpeaking ? 2 : 0,
|
||||||
|
end: 8,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
duration: 1250.ms,
|
||||||
|
builder: (context, value, child) => Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(radius + 8)),
|
||||||
|
border: value > 0
|
||||||
|
? Border.all(color: Colors.green, width: value)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
child: AccountImage(
|
||||||
|
content: widget.userinfo?.avatar,
|
||||||
|
radius: radius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
242
lib/widgets/chat/call/call_participant.dart
Normal file
242
lib/widgets/chat/call/call_participant.dart
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/types/chat.dart';
|
||||||
|
import 'package:surface/widgets/chat/call/call_no_content.dart';
|
||||||
|
import 'package:surface/widgets/chat/call/call_participant_info.dart';
|
||||||
|
import 'package:surface/widgets/chat/call/call_participant_menu.dart';
|
||||||
|
import 'package:surface/widgets/chat/call/call_participant_stats.dart';
|
||||||
|
|
||||||
|
abstract class ParticipantWidget extends StatefulWidget {
|
||||||
|
static ParticipantWidget widgetFor(ParticipantTrack participantTrack,
|
||||||
|
{bool isFixed = false, bool showStatsLayer = false}) {
|
||||||
|
if (participantTrack.participant is LocalParticipant) {
|
||||||
|
return LocalParticipantWidget(
|
||||||
|
participantTrack.participant as LocalParticipant,
|
||||||
|
participantTrack.videoTrack,
|
||||||
|
isFixed,
|
||||||
|
participantTrack.isScreenShare,
|
||||||
|
showStatsLayer,
|
||||||
|
);
|
||||||
|
} else if (participantTrack.participant is RemoteParticipant) {
|
||||||
|
return RemoteParticipantWidget(
|
||||||
|
participantTrack.participant as RemoteParticipant,
|
||||||
|
participantTrack.videoTrack,
|
||||||
|
isFixed,
|
||||||
|
participantTrack.isScreenShare,
|
||||||
|
showStatsLayer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw UnimplementedError('Unknown participant type');
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final Participant participant;
|
||||||
|
abstract final VideoTrack? videoTrack;
|
||||||
|
abstract final bool isScreenShare;
|
||||||
|
abstract final bool isFixed;
|
||||||
|
abstract final bool showStatsLayer;
|
||||||
|
final VideoQuality quality;
|
||||||
|
|
||||||
|
const ParticipantWidget({
|
||||||
|
super.key,
|
||||||
|
this.quality = VideoQuality.MEDIUM,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalParticipantWidget extends ParticipantWidget {
|
||||||
|
@override
|
||||||
|
final LocalParticipant participant;
|
||||||
|
@override
|
||||||
|
final VideoTrack? videoTrack;
|
||||||
|
@override
|
||||||
|
final bool isFixed;
|
||||||
|
@override
|
||||||
|
final bool isScreenShare;
|
||||||
|
@override
|
||||||
|
final bool showStatsLayer;
|
||||||
|
|
||||||
|
const LocalParticipantWidget(
|
||||||
|
this.participant,
|
||||||
|
this.videoTrack,
|
||||||
|
this.isFixed,
|
||||||
|
this.isScreenShare,
|
||||||
|
this.showStatsLayer, {
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _LocalParticipantWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteParticipantWidget extends ParticipantWidget {
|
||||||
|
@override
|
||||||
|
final RemoteParticipant participant;
|
||||||
|
@override
|
||||||
|
final VideoTrack? videoTrack;
|
||||||
|
@override
|
||||||
|
final bool isFixed;
|
||||||
|
@override
|
||||||
|
final bool isScreenShare;
|
||||||
|
@override
|
||||||
|
final bool showStatsLayer;
|
||||||
|
|
||||||
|
const RemoteParticipantWidget(
|
||||||
|
this.participant,
|
||||||
|
this.videoTrack,
|
||||||
|
this.isFixed,
|
||||||
|
this.isScreenShare,
|
||||||
|
this.showStatsLayer, {
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _RemoteParticipantWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _ParticipantWidgetState<T extends ParticipantWidget>
|
||||||
|
extends State<T> {
|
||||||
|
VideoTrack? get _activeVideoTrack;
|
||||||
|
|
||||||
|
TrackPublication? get _firstAudioPublication;
|
||||||
|
|
||||||
|
SnAccount? _userinfoMetadata;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onParticipantChanged() {
|
||||||
|
setState(() {
|
||||||
|
if (widget.participant.metadata != null) {
|
||||||
|
_userinfoMetadata = SnAccount.fromJson(
|
||||||
|
jsonDecode(widget.participant.metadata!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext ctx) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
_activeVideoTrack != null && !_activeVideoTrack!.muted
|
||||||
|
? VideoTrackRenderer(
|
||||||
|
_activeVideoTrack!,
|
||||||
|
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
||||||
|
)
|
||||||
|
: NoContentWidget(
|
||||||
|
userinfo: _userinfoMetadata,
|
||||||
|
isFixed: widget.isFixed,
|
||||||
|
isSpeaking: widget.participant.isSpeaking,
|
||||||
|
),
|
||||||
|
if (widget.showStatsLayer)
|
||||||
|
Positioned(
|
||||||
|
top: 30,
|
||||||
|
right: 30,
|
||||||
|
child: ParticipantStatsWidget(participant: widget.participant),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ParticipantInfoWidget(
|
||||||
|
title: widget.participant.name.isNotEmpty
|
||||||
|
? widget.participant.name
|
||||||
|
: widget.participant.identity,
|
||||||
|
audioAvailable: _firstAudioPublication?.muted == false &&
|
||||||
|
_firstAudioPublication?.subscribed == true,
|
||||||
|
connectionQuality: widget.participant.connectionQuality,
|
||||||
|
isScreenShare: widget.isScreenShare,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocalParticipantWidgetState
|
||||||
|
extends _ParticipantWidgetState<LocalParticipantWidget> {
|
||||||
|
@override
|
||||||
|
LocalTrackPublication<LocalAudioTrack>? get _firstAudioPublication =>
|
||||||
|
widget.participant.audioTrackPublications.firstOrNull;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VideoTrack? get _activeVideoTrack => widget.videoTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RemoteParticipantWidgetState
|
||||||
|
extends _ParticipantWidgetState<RemoteParticipantWidget> {
|
||||||
|
@override
|
||||||
|
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
|
||||||
|
widget.participant.audioTrackPublications.firstOrNull;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VideoTrack? get _activeVideoTrack => widget.videoTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InteractiveParticipantWidget extends StatelessWidget {
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final Color? color;
|
||||||
|
final bool isFixedAvatar;
|
||||||
|
final ParticipantTrack participant;
|
||||||
|
final Function() onTap;
|
||||||
|
|
||||||
|
const InteractiveParticipantWidget({
|
||||||
|
super.key,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.color,
|
||||||
|
this.isFixedAvatar = false,
|
||||||
|
required this.participant,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
child: Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
color: color,
|
||||||
|
child: ParticipantWidget.widgetFor(participant, isFixed: isFixedAvatar),
|
||||||
|
),
|
||||||
|
onTap: () => onTap(),
|
||||||
|
onLongPress: () {
|
||||||
|
if (participant.participant is LocalParticipant) return;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ParticipantMenu(
|
||||||
|
participant: participant.participant as RemoteParticipant,
|
||||||
|
videoTrack: participant.videoTrack,
|
||||||
|
isScreenShare: participant.isScreenShare,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
79
lib/widgets/chat/call/call_participant_info.dart
Normal file
79
lib/widgets/chat/call/call_participant_info.dart
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
|
class ParticipantInfoWidget extends StatelessWidget {
|
||||||
|
final String? title;
|
||||||
|
final bool audioAvailable;
|
||||||
|
final ConnectionQuality connectionQuality;
|
||||||
|
final bool isScreenShare;
|
||||||
|
|
||||||
|
const ParticipantInfoWidget({
|
||||||
|
super.key,
|
||||||
|
this.title,
|
||||||
|
this.audioAvailable = true,
|
||||||
|
this.connectionQuality = ConnectionQuality.unknown,
|
||||||
|
this.isScreenShare = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 7,
|
||||||
|
horizontal: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (title != null)
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
title!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(5),
|
||||||
|
isScreenShare
|
||||||
|
? const Icon(
|
||||||
|
Symbols.monitor,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 16,
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
audioAvailable ? Symbols.mic : Symbols.mic_off,
|
||||||
|
color: audioAvailable ? Colors.white : Colors.red,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const Gap(3),
|
||||||
|
if (connectionQuality != ConnectionQuality.unknown)
|
||||||
|
Icon(
|
||||||
|
{
|
||||||
|
ConnectionQuality.excellent: Symbols.signal_cellular_alt,
|
||||||
|
ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar,
|
||||||
|
ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar,
|
||||||
|
}[connectionQuality],
|
||||||
|
color: {
|
||||||
|
ConnectionQuality.excellent: Colors.green,
|
||||||
|
ConnectionQuality.good: Colors.orange,
|
||||||
|
ConnectionQuality.poor: Colors.red,
|
||||||
|
}[connectionQuality],
|
||||||
|
size: 16,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
).padding(all: 3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
161
lib/widgets/chat/call/call_participant_menu.dart
Normal file
161
lib/widgets/chat/call/call_participant_menu.dart
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
class ParticipantMenu extends StatefulWidget {
|
||||||
|
final RemoteParticipant participant;
|
||||||
|
final VideoTrack? videoTrack;
|
||||||
|
final bool isScreenShare;
|
||||||
|
final bool showStatsLayer;
|
||||||
|
|
||||||
|
const ParticipantMenu({
|
||||||
|
super.key,
|
||||||
|
required this.participant,
|
||||||
|
this.videoTrack,
|
||||||
|
this.isScreenShare = false,
|
||||||
|
this.showStatsLayer = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParticipantMenu> createState() => _ParticipantMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParticipantMenuState extends State<ParticipantMenu> {
|
||||||
|
RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication =>
|
||||||
|
widget.participant.videoTrackPublications
|
||||||
|
.where((element) => element.sid == widget.videoTrack?.sid)
|
||||||
|
.firstOrNull;
|
||||||
|
|
||||||
|
RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication =>
|
||||||
|
widget.participant.audioTrackPublications.firstOrNull;
|
||||||
|
|
||||||
|
void tookAction() {
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'callParticipantAction',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
if (_firstAudioPublication != null && !widget.isScreenShare)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Symbols.volume_up,
|
||||||
|
color: {
|
||||||
|
TrackSubscriptionState.notAllowed:
|
||||||
|
Theme.of(context).colorScheme.error,
|
||||||
|
TrackSubscriptionState.unsubscribed: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withOpacity(0.6),
|
||||||
|
TrackSubscriptionState.subscribed:
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
}[_firstAudioPublication!.subscriptionState],
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_firstAudioPublication!.subscribed
|
||||||
|
? 'callParticipantMicrophoneOff'.tr()
|
||||||
|
: 'callParticipantMicrophoneOn'.tr(),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (_firstAudioPublication!.subscribed) {
|
||||||
|
_firstAudioPublication!.unsubscribe();
|
||||||
|
} else {
|
||||||
|
_firstAudioPublication!.subscribe();
|
||||||
|
}
|
||||||
|
tookAction();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_videoPublication != null)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
widget.isScreenShare ? Symbols.monitor : Symbols.videocam,
|
||||||
|
color: {
|
||||||
|
TrackSubscriptionState.notAllowed:
|
||||||
|
Theme.of(context).colorScheme.error,
|
||||||
|
TrackSubscriptionState.unsubscribed: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withOpacity(0.6),
|
||||||
|
TrackSubscriptionState.subscribed:
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
}[_videoPublication!.subscriptionState],
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_videoPublication!.subscribed
|
||||||
|
? 'callParticipantVideoOff'.tr()
|
||||||
|
: 'callParticipantVideoOn'.tr(),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (_videoPublication!.subscribed) {
|
||||||
|
_videoPublication!.unsubscribe();
|
||||||
|
} else {
|
||||||
|
_videoPublication!.subscribe();
|
||||||
|
}
|
||||||
|
tookAction();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_videoPublication != null) const Divider(thickness: 0.3),
|
||||||
|
if (_videoPublication != null)
|
||||||
|
...[30, 15, 8].map(
|
||||||
|
(x) => ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
_videoPublication?.fps == x
|
||||||
|
? Symbols.check_box
|
||||||
|
: Symbols.check_box_outline_blank,
|
||||||
|
),
|
||||||
|
title: Text('Set preferred frame-per-second to $x'),
|
||||||
|
onTap: () {
|
||||||
|
_videoPublication!.setVideoFPS(x);
|
||||||
|
tookAction();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_videoPublication != null) const Divider(thickness: 0.3),
|
||||||
|
if (_videoPublication != null)
|
||||||
|
...[
|
||||||
|
('High', VideoQuality.HIGH),
|
||||||
|
('Medium', VideoQuality.MEDIUM),
|
||||||
|
('Low', VideoQuality.LOW),
|
||||||
|
].map(
|
||||||
|
(x) => ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
_videoPublication?.videoQuality == x.$2
|
||||||
|
? Symbols.check_box
|
||||||
|
: Symbols.check_box_outline_blank,
|
||||||
|
),
|
||||||
|
title: Text('Set preferred quality to ${x.$1}'),
|
||||||
|
onTap: () {
|
||||||
|
_videoPublication!.setVideoQuality(x.$2);
|
||||||
|
tookAction();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
133
lib/widgets/chat/call/call_participant_stats.dart
Normal file
133
lib/widgets/chat/call/call_participant_stats.dart
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:surface/types/chat.dart';
|
||||||
|
|
||||||
|
class ParticipantStatsWidget extends StatefulWidget {
|
||||||
|
const ParticipantStatsWidget({super.key, required this.participant});
|
||||||
|
|
||||||
|
final Participant participant;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _ParticipantStatsWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> {
|
||||||
|
List<EventsListener<TrackEvent>> listeners = [];
|
||||||
|
ParticipantStatsType statsType = ParticipantStatsType.unknown;
|
||||||
|
Map<String, String> stats = {};
|
||||||
|
|
||||||
|
void _setUpListener(Track track) {
|
||||||
|
var listener = track.createListener();
|
||||||
|
listeners.add(listener);
|
||||||
|
if (track is LocalVideoTrack) {
|
||||||
|
statsType = ParticipantStatsType.localVideoSender;
|
||||||
|
listener.on<VideoSenderStatsEvent>((event) {
|
||||||
|
setState(() {
|
||||||
|
stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs';
|
||||||
|
event.stats.forEach((key, value) {
|
||||||
|
stats['layer-$key'] =
|
||||||
|
'${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps';
|
||||||
|
});
|
||||||
|
var firstStats =
|
||||||
|
event.stats['f'] ?? event.stats['h'] ?? event.stats['q'];
|
||||||
|
if (firstStats != null) {
|
||||||
|
stats['encoder'] = firstStats.encoderImplementation ?? '';
|
||||||
|
stats['video codec'] =
|
||||||
|
'${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}';
|
||||||
|
stats['qualityLimitationReason'] =
|
||||||
|
firstStats.qualityLimitationReason ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (track is RemoteVideoTrack) {
|
||||||
|
statsType = ParticipantStatsType.remoteVideoReceiver;
|
||||||
|
listener.on<VideoReceiverStatsEvent>((event) {
|
||||||
|
setState(() {
|
||||||
|
stats['video rx'] = '${event.currentBitrate.toInt()} kpbs';
|
||||||
|
stats['video codec'] =
|
||||||
|
'${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}';
|
||||||
|
stats['video size'] =
|
||||||
|
'${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps';
|
||||||
|
stats['video jitter'] = '${event.stats.jitter} s';
|
||||||
|
stats['video decoder'] = '${event.stats.decoderImplementation}';
|
||||||
|
stats['video packets lost'] = '${event.stats.packetsLost}';
|
||||||
|
stats['video packets received'] = '${event.stats.packetsReceived}';
|
||||||
|
stats['video frames received'] = '${event.stats.framesReceived}';
|
||||||
|
stats['video frames decoded'] = '${event.stats.framesDecoded}';
|
||||||
|
stats['video frames dropped'] = '${event.stats.framesDropped}';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (track is LocalAudioTrack) {
|
||||||
|
statsType = ParticipantStatsType.localAudioSender;
|
||||||
|
listener.on<AudioSenderStatsEvent>((event) {
|
||||||
|
setState(() {
|
||||||
|
stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs';
|
||||||
|
stats['audio codec'] =
|
||||||
|
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (track is RemoteAudioTrack) {
|
||||||
|
statsType = ParticipantStatsType.remoteAudioReceiver;
|
||||||
|
listener.on<AudioReceiverStatsEvent>((event) {
|
||||||
|
setState(() {
|
||||||
|
stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs';
|
||||||
|
stats['audio codec'] =
|
||||||
|
'${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}';
|
||||||
|
stats['audio jitter'] = '${event.stats.jitter} s';
|
||||||
|
stats['audio concealed samples'] =
|
||||||
|
'${event.stats.concealedSamples} / ${event.stats.concealmentEvents}';
|
||||||
|
stats['audio packets lost'] = '${event.stats.packetsLost}';
|
||||||
|
stats['audio packets received'] = '${event.stats.packetsReceived}';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onParticipantChanged() {
|
||||||
|
for (var element in listeners) {
|
||||||
|
element.dispose();
|
||||||
|
}
|
||||||
|
listeners.clear();
|
||||||
|
for (var track in [
|
||||||
|
...widget.participant.videoTrackPublications,
|
||||||
|
...widget.participant.audioTrackPublications
|
||||||
|
]) {
|
||||||
|
if (track.track != null) {
|
||||||
|
_setUpListener(track.track!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.participant.addListener(onParticipantChanged);
|
||||||
|
onParticipantChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void deactivate() {
|
||||||
|
for (var element in listeners) {
|
||||||
|
element.dispose();
|
||||||
|
}
|
||||||
|
widget.participant.removeListener(onParticipantChanged);
|
||||||
|
super.deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
num sendBitrate = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children:
|
||||||
|
stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
191
lib/widgets/chat/call/call_prejoin.dart
Normal file
191
lib/widgets/chat/call/call_prejoin.dart
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/chat_call.dart';
|
||||||
|
import 'package:surface/types/chat.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
|
||||||
|
class ChatCallPrejoinPopup extends StatefulWidget {
|
||||||
|
final SnChatCall ongoingCall;
|
||||||
|
final SnChannel channel;
|
||||||
|
final void Function() onJoin;
|
||||||
|
|
||||||
|
const ChatCallPrejoinPopup({
|
||||||
|
super.key,
|
||||||
|
required this.ongoingCall,
|
||||||
|
required this.channel,
|
||||||
|
required this.onJoin,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatCallPrejoinPopup> createState() => _ChatCallPrejoinPopupState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
|
||||||
|
late final ChatCallProvider _call = context.read<ChatCallProvider>();
|
||||||
|
|
||||||
|
void _performJoin() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
_call.setCall(widget.ongoingCall, widget.channel);
|
||||||
|
_call.setIsBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resp = await _call.getRoomToken();
|
||||||
|
final token = resp.$1;
|
||||||
|
final endpoint = resp.$2;
|
||||||
|
|
||||||
|
_call.initRoom();
|
||||||
|
_call.setupRoomListeners(
|
||||||
|
onDisconnected: (reason) {
|
||||||
|
context.showSnackbar(
|
||||||
|
'callDisconnected'.tr(args: [reason.toString()]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await _call.joinRoom(endpoint, token);
|
||||||
|
widget.onJoin();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(e);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
call.checkPermissions().then((_) {
|
||||||
|
call.initHardware();
|
||||||
|
});
|
||||||
|
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final call = context.read<ChatCallProvider>();
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: call,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 320),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('callMicrophone').tr(),
|
||||||
|
Switch(
|
||||||
|
value: call.enableAudio,
|
||||||
|
onChanged: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(bottom: 5),
|
||||||
|
DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton2<MediaDevice>(
|
||||||
|
isExpanded: true,
|
||||||
|
disabledHint: Text('callMicrophoneDisabled').tr(),
|
||||||
|
hint: Text('callMicrophoneSelect').tr(),
|
||||||
|
items: call.enableAudio
|
||||||
|
? call.audioInputs
|
||||||
|
.map(
|
||||||
|
(item) => DropdownMenuItem<MediaDevice>(
|
||||||
|
value: item,
|
||||||
|
child: Text(item.label),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
.cast<DropdownMenuItem<MediaDevice>>()
|
||||||
|
: [],
|
||||||
|
value: call.audioDevice,
|
||||||
|
onChanged: (MediaDevice? value) async {
|
||||||
|
if (value != null) {
|
||||||
|
call.setAudioDevice(value);
|
||||||
|
await call.changeLocalAudioTrack();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonStyleData: const ButtonStyleData(
|
||||||
|
height: 40,
|
||||||
|
width: 320,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(bottom: 25),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('callCamera').tr(),
|
||||||
|
Switch(
|
||||||
|
value: call.enableVideo,
|
||||||
|
onChanged: (value) => call.setEnableAudio(value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(bottom: 5),
|
||||||
|
DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton2<MediaDevice>(
|
||||||
|
isExpanded: true,
|
||||||
|
disabledHint: Text('callCameraDisabled').tr(),
|
||||||
|
hint: Text('callCameraSelect').tr(),
|
||||||
|
items: call.enableVideo
|
||||||
|
? call.videoInputs
|
||||||
|
.map(
|
||||||
|
(item) => DropdownMenuItem<MediaDevice>(
|
||||||
|
value: item,
|
||||||
|
child: Text(item.label),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
.cast<DropdownMenuItem<MediaDevice>>()
|
||||||
|
: [],
|
||||||
|
value: call.videoDevice,
|
||||||
|
onChanged: (MediaDevice? value) async {
|
||||||
|
if (value != null) {
|
||||||
|
call.setVideoDevice(value);
|
||||||
|
await call.changeLocalVideoTrack();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonStyleData: const ButtonStyleData(
|
||||||
|
height: 40,
|
||||||
|
width: 320,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(bottom: 25),
|
||||||
|
if (_isBusy)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size(320, 56),
|
||||||
|
),
|
||||||
|
onPressed: _isBusy ? null : _performJoin,
|
||||||
|
child: Text('callJoin').tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_call
|
||||||
|
..deactivateHardware()
|
||||||
|
..disposeHardware();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
50
pubspec.lock
50
pubspec.lock
@ -1226,6 +1226,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.3.1"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.13"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.5"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+5"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.3"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1792,7 +1840,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.8"
|
version: "2.0.8"
|
||||||
wakelock_plus:
|
wakelock_plus:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus
|
name: wakelock_plus
|
||||||
sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484
|
sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484
|
||||||
|
@ -89,6 +89,8 @@ dependencies:
|
|||||||
sentry_flutter: ^8.10.1
|
sentry_flutter: ^8.10.1
|
||||||
synchronized: ^3.3.0+3
|
synchronized: ^3.3.0+3
|
||||||
livekit_client: ^2.3.0
|
livekit_client: ^2.3.0
|
||||||
|
wakelock_plus: ^1.2.8
|
||||||
|
permission_handler: ^11.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||||
#include <pasteboard/pasteboard_plugin.h>
|
#include <pasteboard/pasteboard_plugin.h>
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <screen_brightness_windows/screen_brightness_windows_plugin.h>
|
#include <screen_brightness_windows/screen_brightness_windows_plugin.h>
|
||||||
#include <sentry_flutter/sentry_flutter_plugin.h>
|
#include <sentry_flutter/sentry_flutter_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
@ -38,6 +39,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
||||||
PasteboardPluginRegisterWithRegistrar(
|
PasteboardPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PasteboardPlugin"));
|
registry->GetRegistrarForPlugin("PasteboardPlugin"));
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
ScreenBrightnessWindowsPluginRegisterWithRegistrar(
|
ScreenBrightnessWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin"));
|
||||||
SentryFlutterPluginRegisterWithRegistrar(
|
SentryFlutterPluginRegisterWithRegistrar(
|
||||||
|
@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
media_kit_libs_windows_video
|
media_kit_libs_windows_video
|
||||||
media_kit_video
|
media_kit_video
|
||||||
pasteboard
|
pasteboard
|
||||||
|
permission_handler_windows
|
||||||
screen_brightness_windows
|
screen_brightness_windows
|
||||||
sentry_flutter
|
sentry_flutter
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
|
Loading…
Reference in New Issue
Block a user