diff --git a/assets/translations/en.json b/assets/translations/en.json index 0ca73c5..132be6d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -202,5 +202,31 @@ "one": "{} result", "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" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 8a557bd..cc3beae 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -202,5 +202,31 @@ "one": "搜索到 {} 个结果", "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": "开启屏幕共享" } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0378e06..6337af3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,6 +6,8 @@ PODS: - Flutter - cupertino_http (0.0.1): - Flutter + - device_info_plus (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -160,8 +162,9 @@ PODS: - GoogleUtilities/Privacy - image_picker_ios (0.0.1): - Flutter - - isar_flutter_libs (1.0.0): + - livekit_client (2.3.0): - Flutter + - WebRTC-SDK (= 125.6422.05) - media_kit_libs_ios_video (1.0.4): - Flutter - media_kit_native_event_loop (1.0.0): @@ -180,6 +183,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter - PromisesObjC (2.4.0) - SAMKeychain (1.5.3) - screen_brightness_ios (0.1.0): @@ -211,6 +216,7 @@ DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - croppy (from `.symlinks/plugins/croppy/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`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -220,13 +226,14 @@ DEPENDENCIES: - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/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_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - 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`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -263,6 +270,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/croppy/ios" cupertino_http: :path: ".symlinks/plugins/cupertino_http/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" firebase_analytics: @@ -281,8 +290,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_webrtc/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" - isar_flutter_libs: - :path: ".symlinks/plugins/isar_flutter_libs/ios" + livekit_client: + :path: ".symlinks/plugins/livekit_client/ios" media_kit_libs_ios_video: :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" media_kit_native_event_loop: @@ -295,6 +304,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/pasteboard/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" screen_brightness_ios: :path: ".symlinks/plugins/screen_brightness_ios/ios" sentry_flutter: @@ -314,6 +325,7 @@ SPEC CHECKSUMS: connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563 croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 @@ -334,7 +346,7 @@ SPEC CHECKSUMS: GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 + livekit_client: 5c31e13cd17dd0d545a074290c937dbdff1d809d media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e @@ -342,6 +354,7 @@ SPEC CHECKSUMS: package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index 65a2359..650bf93 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -74,18 +74,6 @@ class ChatMessageController extends ChangeNotifier { final payload = SnChatMessage.fromJson(event.payload!); _addMessage(payload); 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': if (event.payload?['channel_id'] != channel?.id) break; final member = SnChannelMember.fromJson(event.payload!['member']); diff --git a/lib/main.dart b/lib/main.dart index 0926143..70238f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:responsive_framework/responsive_framework.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:surface/firebase_options.dart'; import 'package:surface/providers/channel.dart'; +import 'package:surface/providers/chat_call.dart'; import 'package:surface/providers/navigation.dart'; import 'package:surface/providers/notification.dart'; import 'package:surface/providers/sn_attachment.dart'; @@ -86,6 +87,7 @@ class SolianApp extends StatelessWidget { ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), + ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), ], child: AppMainContent(), ), diff --git a/lib/providers/chat_call.dart b/lib/providers/chat_call.dart new file mode 100644 index 0000000..93971fc --- /dev/null +++ b/lib/providers/chat_call.dart @@ -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(); + } + + 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 _audioInputs = []; + List _videoInputs = []; + + bool _enableAudio = true; + bool _enableVideo = false; + LocalAudioTrack? _audioTrack; + LocalVideoTrack? _videoTrack; + MediaDevice? _videoDevice; + MediaDevice? _audioDevice; + + late Room _room; + late EventsListener _listener; + + List _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 get audioInputs => _audioInputs; + List 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 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 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 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 setEnableAudio(bool value) async { + _enableAudio = value; + if (!_enableAudio) { + await _audioTrack?.stop(); + _audioTrack = null; + } else { + await _changeLocalAudioTrack(); + } + notifyListeners(); + } + + Future 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((event) async { + onDisconnected(event.reason); + }) + ..on((event) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((event) { + sortParticipants(); + }); + } + + void sortParticipants() { + Map 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.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 _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 _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 devices) { + _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); + _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); + notifyListeners(); + } + + void _onRoomDidUpdate() => sortParticipants(); + + Future 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 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(); + } +} diff --git a/lib/router.dart b/lib/router.dart index 9ff4f60..3556061 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -114,7 +114,6 @@ final _appRoutes = [ path: '/:scope/:alias/call', name: 'chatCallRoom', builder: (context, state) => AppBackground( - isLessOptimization: true, child: CallRoomScreen( scope: state.pathParameters['scope']!, alias: state.pathParameters['alias']!, diff --git a/lib/screens/account.dart b/lib/screens/account.dart index d0f60ad..f9225bc 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -26,6 +26,7 @@ class AccountScreen extends StatelessWidget { GoRouter.of(context).pushNamed('settings'); }, ), + const Gap(8), ], ), body: SingleChildScrollView( diff --git a/lib/screens/chat/call_room.dart b/lib/screens/chat/call_room.dart index 09080d7..2bb7843 100644 --- a/lib/screens/chat/call_room.dart +++ b/lib/screens/chat/call_room.dart @@ -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_webrtc/flutter_webrtc.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:gap/gap.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 { final String scope; @@ -15,16 +20,301 @@ class CallRoomScreen extends StatefulWidget { } class _CallRoomScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('Voice Chat')), - body: Center( - child: ElevatedButton( - onPressed: () {}, - child: Text('Start Call'), + int _layoutMode = 0; + + void _switchLayout() { + if (_layoutMode < 1) { + setState(() => _layoutMode++); + } else { + setState(() => _layoutMode = 0); + } + } + + Widget _buildListLayout() { + final call = context.read(); + 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(); + + 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(); + + Future.delayed(Duration.zero, () { + call + ..setupRoom() + ..enableDurationUpdater(); + }); + } + + @override + Widget build(BuildContext context) { + final call = context.read(); + + 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(); + 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(); + call.disableDurationUpdater(); + super.deactivate(); + } + + @override + void activate() { + final call = context.read(); + call.enableDurationUpdater(); + super.activate(); + } } diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 5c570d6..a4c161b 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -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:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/chat_message_controller.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/widgets/chat/call/call_prejoin.dart'; import 'package:surface/widgets/chat/chat_message.dart'; import 'package:surface/widgets/chat/chat_message_input.dart'; import 'package:surface/widgets/dialog.dart'; @@ -24,12 +34,16 @@ class ChatRoomScreen extends StatefulWidget { class _ChatRoomScreenState extends State { bool _isBusy = false; + bool _isCalling = false; SnChannel? _channel; + SnChatCall? _ongoingCall; final GlobalKey _inputGlobalKey = GlobalKey(); late final ChatMessageController _messageController; + StreamSubscription? _wsSubscription; + Future _fetchChannel() async { setState(() => _isBusy = true); @@ -44,6 +58,87 @@ class _ChatRoomScreenState extends State { } } + Future _fetchOngoingCall() async { + setState(() => _isCalling = true); + + try { + final sn = context.read(); + 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 _makeCall() async { + setState(() => _isCalling = true); + + try { + final sn = context.read(); + 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 _endCall() async { + setState(() => _isCalling = true); + + try { + final sn = context.read(); + 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 _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 void initState() { super.initState(); @@ -51,30 +146,58 @@ class _ChatRoomScreenState extends State { _fetchChannel().then((_) async { await _messageController.initialize(_channel!); await _messageController.checkUpdate(); + await _fetchOngoingCall(); + }); + + final ws = context.read(); + _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 void dispose() { + _wsSubscription?.cancel(); + _messageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final call = context.watch(); + return Scaffold( appBar: AppBar( title: Text(_channel?.name ?? 'loading'.tr()), actions: [ IconButton( - onPressed: () { - GoRouter.of(context).pushNamed('chatCallRoom', pathParameters: { - 'scope': widget.scope, - 'alias': widget.alias, - }); - }, - icon: const Icon(Symbols.voice_chat), + icon: _ongoingCall == null + ? const Icon(Symbols.call) + : const Icon(Symbols.call_end), + onPressed: _isCalling + ? null + : _ongoingCall == null + ? _makeCall + : _endCall, ), - IconButton(onPressed: () {}, icon: const Icon(Symbols.more_vert)), + IconButton( + icon: const Icon(Symbols.more_vert), + onPressed: () {}, + ), + const Gap(8), ], ), body: ListenableBuilder( @@ -83,6 +206,28 @@ class _ChatRoomScreenState extends State { return Column( children: [ 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) Expanded( child: const CircularProgressIndicator().center(), @@ -112,7 +257,6 @@ class _ChatRoomScreenState extends State { idx > 0 ? _messageController.messages[idx - 1] : null; final canMerge = nextMessage != null && - nextMessage.updatedAt == nextMessage.createdAt && nextMessage.senderId == message.senderId && message.createdAt .difference(nextMessage.createdAt) @@ -120,7 +264,6 @@ class _ChatRoomScreenState extends State { .abs() <= 3; final canMergePrevious = previousMessage != null && - message.updatedAt == message.createdAt && previousMessage.senderId == message.senderId && message.createdAt .difference(previousMessage.createdAt) diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 2e7dcfb..e278b20 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -171,6 +171,7 @@ class _ExploreScreenState extends State { GoRouter.of(context).pushNamed('postSearch'); }, ), + const Gap(8), ], ), SliverInfiniteList( diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index 8f83ecb..28cf993 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -138,6 +138,7 @@ class _NotificationScreenState extends State { icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead, ), + const Gap(8), ], ), body: Column( diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 5e13725..c9ebbc8 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -146,6 +146,7 @@ class _PostEditorScreenState extends State { icon: const Icon(Symbols.tune), onPressed: _writeController.isBusy ? null : _updateMeta, ), + const Gap(8), ], ), body: Column( diff --git a/lib/screens/post/post_search.dart b/lib/screens/post/post_search.dart index a9b1c2a..088c906 100644 --- a/lib/screens/post/post_search.dart +++ b/lib/screens/post/post_search.dart @@ -101,6 +101,7 @@ class _PostSearchScreenState extends State { icon: const Icon(Symbols.tune), onPressed: _showAdvancedSearchTune, ), + const Gap(8), ], ), body: Stack( diff --git a/lib/screens/realm.dart b/lib/screens/realm.dart index e67de33..f3fb17b 100644 --- a/lib/screens/realm.dart +++ b/lib/screens/realm.dart @@ -87,6 +87,7 @@ class _RealmScreenState extends State { setState(() => _isCompactView = !_isCompactView); }, ), + const Gap(8), ], ), floatingActionButton: FloatingActionButton( diff --git a/lib/theme.dart b/lib/theme.dart index 10fdec7..9575efd 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -39,6 +39,9 @@ Future createAppTheme( opticalSize: 20, color: colorScheme.onSurface, ), + appBarTheme: AppBarTheme( + centerTitle: true, + ), scaffoldBackgroundColor: Colors.transparent, ); } diff --git a/lib/types/chat.dart b/lib/types/chat.dart index 6e214f5..9fa3581 100644 --- a/lib/types/chat.dart +++ b/lib/types/chat.dart @@ -1,5 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.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/attachment.dart'; import 'package:surface/types/realm.dart'; @@ -101,3 +102,43 @@ class SnChatMessagePreload with _$SnChatMessagePreload { factory SnChatMessagePreload.fromJson(Map 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 participants, + }) = _SnChatCall; + + factory SnChatCall.fromJson(Map 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; +} diff --git a/lib/types/chat.freezed.dart b/lib/types/chat.freezed.dart index ee9ddc4..f7fdda6 100644 --- a/lib/types/chat.freezed.dart +++ b/lib/types/chat.freezed.dart @@ -1788,3 +1788,376 @@ abstract class _SnChatMessagePreload extends SnChatMessagePreload { _$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl> get copyWith => throw _privateConstructorUsedError; } + +SnChatCall _$SnChatCallFromJson(Map 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 get participants => throw _privateConstructorUsedError; + + /// Serializes this SnChatCall to a JSON map. + Map 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 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 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, + ) 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 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, + )); + } +} + +/// @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 participants = const []}) + : _participants = participants; + + factory _$SnChatCallImpl.fromJson(Map 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 _participants; + @override + @JsonKey() + List 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 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 participants}) = _$SnChatCallImpl; + + factory _SnChatCall.fromJson(Map 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 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; +} diff --git a/lib/types/chat.g.dart b/lib/types/chat.g.dart index 405a07a..a4b875b 100644 --- a/lib/types/chat.g.dart +++ b/lib/types/chat.g.dart @@ -360,3 +360,36 @@ Map _$$SnChatMessagePreloadImplToJson( 'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), 'quote_event': instance.quoteEvent?.toJson(), }; + +_$SnChatCallImpl _$$SnChatCallImplFromJson(Map 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), + participants: json['participants'] as List? ?? const [], + ); + +Map _$$SnChatCallImplToJson(_$SnChatCallImpl instance) => + { + '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, + }; diff --git a/lib/widgets/attachment/attachment_item.dart b/lib/widgets/attachment/attachment_item.dart index 367018c..fb3037d 100644 --- a/lib/widgets/attachment/attachment_item.dart +++ b/lib/widgets/attachment/attachment_item.dart @@ -290,7 +290,7 @@ class _AttachmentItemContentVideoState ), ), const Icon( - Icons.play_arrow, + Symbols.play_arrow, shadows: labelShadows, color: Colors.white, ).padding(bottom: 4, right: 8), @@ -450,7 +450,7 @@ class _AttachmentItemContentAudioState ), ), const Icon( - Icons.play_arrow, + Symbols.play_arrow, shadows: labelShadows, color: Colors.white, ).padding(bottom: 4, right: 8), diff --git a/lib/widgets/chat/call/call_controls.dart b/lib/widgets/chat/call/call_controls.dart new file mode 100644 index 0000000..88b34e1 --- /dev/null +++ b/lib/widgets/chat/call/call_controls.dart @@ -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 createState() => _ControlsWidgetState(); +} + +class _ControlsWidgetState extends State { + CameraPosition _position = CameraPosition.front; + + List? _audioInputs; + List? _audioOutputs; + List? _videoInputs; + + StreamSubscription? _subscription; + + bool _speakerphoneOn = false; + + @override + void initState() { + super.initState(); + _participant.addListener(onChange); + _subscription = Hardware.instance.onDeviceChange.stream + .listen((List 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 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 showDisconnectDialog() { + return showDialog( + 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(); + 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( + 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( + icon: const Icon(Symbols.settings_voice), + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + 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( + 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( + icon: const Icon(Symbols.videocam_sharp), + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: null, + onTap: _disableVideo, + child: ListTile( + leading: const Icon(Symbols.videocam_off), + title: Text('callCameraOff'.tr()), + ), + ), + if (_videoInputs != null) + ..._videoInputs!.map((device) { + return PopupMenuItem( + 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( + icon: const Icon(Symbols.volume_up), + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: null, + child: ListTile( + leading: const Icon(Symbols.speaker), + title: Text('callSpeakerSelect').tr(), + ), + ), + if (_audioOutputs != null) + ..._audioOutputs!.map((device) { + return PopupMenuItem( + 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(), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat/call/call_no_content.dart b/lib/widgets/chat/call/call_no_content.dart new file mode 100644 index 0000000..1dd4e03 --- /dev/null +++ b/lib/widgets/chat/call/call_no_content.dart @@ -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 createState() => _NoContentWidgetState(); +} + +class _NoContentWidgetState extends State + 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(); + } +} diff --git a/lib/widgets/chat/call/call_participant.dart b/lib/widgets/chat/call/call_participant.dart new file mode 100644 index 0000000..56d851d --- /dev/null +++ b/lib/widgets/chat/call/call_participant.dart @@ -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 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 createState() => _RemoteParticipantWidgetState(); +} + +abstract class _ParticipantWidgetState + extends State { + 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 { + @override + LocalTrackPublication? get _firstAudioPublication => + widget.participant.audioTrackPublications.firstOrNull; + + @override + VideoTrack? get _activeVideoTrack => widget.videoTrack; +} + +class _RemoteParticipantWidgetState + extends _ParticipantWidgetState { + @override + RemoteTrackPublication? 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, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/chat/call/call_participant_info.dart b/lib/widgets/chat/call/call_participant_info.dart new file mode 100644 index 0000000..d81a75e --- /dev/null +++ b/lib/widgets/chat/call/call_participant_info.dart @@ -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), + ], + ), + ); +} diff --git a/lib/widgets/chat/call/call_participant_menu.dart b/lib/widgets/chat/call/call_participant_menu.dart new file mode 100644 index 0000000..bfe62d5 --- /dev/null +++ b/lib/widgets/chat/call/call_participant_menu.dart @@ -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 createState() => _ParticipantMenuState(); +} + +class _ParticipantMenuState extends State { + RemoteTrackPublication? get _videoPublication => + widget.participant.videoTrackPublications + .where((element) => element.sid == widget.videoTrack?.sid) + .firstOrNull; + + RemoteTrackPublication? 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(); + }, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/chat/call/call_participant_stats.dart b/lib/widgets/chat/call/call_participant_stats.dart new file mode 100644 index 0000000..7c9629d --- /dev/null +++ b/lib/widgets/chat/call/call_participant_stats.dart @@ -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 createState() => _ParticipantStatsWidgetState(); +} + +class _ParticipantStatsWidgetState extends State { + List> listeners = []; + ParticipantStatsType statsType = ParticipantStatsType.unknown; + Map stats = {}; + + void _setUpListener(Track track) { + var listener = track.createListener(); + listeners.add(listener); + if (track is LocalVideoTrack) { + statsType = ParticipantStatsType.localVideoSender; + listener.on((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((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((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((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(), + ), + ); + } +} diff --git a/lib/widgets/chat/call/call_prejoin.dart b/lib/widgets/chat/call/call_prejoin.dart new file mode 100644 index 0000000..bf1a13b --- /dev/null +++ b/lib/widgets/chat/call/call_prejoin.dart @@ -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 createState() => _ChatCallPrejoinPopupState(); +} + +class _ChatCallPrejoinPopupState extends State { + bool _isBusy = false; + + late final ChatCallProvider _call = context.read(); + + 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(); + call.checkPermissions().then((_) { + call.initHardware(); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final call = context.read(); + 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( + isExpanded: true, + disabledHint: Text('callMicrophoneDisabled').tr(), + hint: Text('callMicrophoneSelect').tr(), + items: call.enableAudio + ? call.audioInputs + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item.label), + ), + ) + .toList() + .cast>() + : [], + 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( + isExpanded: true, + disabledHint: Text('callCameraDisabled').tr(), + hint: Text('callCameraSelect').tr(), + items: call.enableVideo + ? call.videoInputs + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item.label), + ), + ) + .toList() + .cast>() + : [], + 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(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 76831bc..247c253 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1226,6 +1226,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -1792,7 +1840,7 @@ packages: source: hosted version: "2.0.8" wakelock_plus: - dependency: transitive + dependency: "direct main" description: name: wakelock_plus sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484 diff --git a/pubspec.yaml b/pubspec.yaml index 6f6ea11..5a9beb8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,6 +89,8 @@ dependencies: sentry_flutter: ^8.10.1 synchronized: ^3.3.0+3 livekit_client: ^2.3.0 + wakelock_plus: ^1.2.8 + permission_handler: ^11.3.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a326ed7..602792d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenBrightnessWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); SentryFlutterPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2e80234..a5ce971 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_windows_video media_kit_video pasteboard + permission_handler_windows screen_brightness_windows sentry_flutter url_launcher_windows