diff --git a/android/app/build.gradle b/android/app/build.gradle index 92894d0..3a92e8f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,7 +23,7 @@ if (flutterVersionName == null) { } android { - namespace "com.example.solian" + namespace "dev.solsynth.solian" compileSdk flutter.compileSdkVersion ndkVersion flutter.ndkVersion @@ -41,11 +41,8 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.solian" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion flutter.minSdkVersion + applicationId "dev.solsynth.solian" + minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -53,8 +50,6 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 60c8f4a..c95f437 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,29 @@ + + + + + + + + + + + + + + + ... + + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIBackgroundModes + + audio + diff --git a/lib/models/call.dart b/lib/models/call.dart index 7288dd7..c95085a 100644 --- a/lib/models/call.dart +++ b/lib/models/call.dart @@ -1,3 +1,4 @@ +import 'package:livekit_client/livekit_client.dart'; import 'package:solian/models/channel.dart'; class Call { @@ -46,4 +47,22 @@ class Call { "channel_id": channelId, "channel": channel.toJson(), }; +} + +enum ParticipantStatsType { + unknown, + localAudioSender, + localVideoSender, + remoteAudioReceiver, + remoteVideoReceiver, +} + +class ParticipantTrack { + ParticipantTrack( + {required this.participant, + required this.videoTrack, + required this.isScreenShare}); + VideoTrack? videoTrack; + Participant participant; + final bool isScreenShare; } \ No newline at end of file diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index 5b44e75..13ba4e3 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -1,13 +1,21 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart'; import 'package:provider/provider.dart'; import 'package:solian/models/call.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/chat/call/exts.dart'; +import 'package:solian/widgets/chat/call/participant.dart'; import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'dart:math' as math; + +import '../../widgets/chat/call/controls.dart'; class ChatCall extends StatefulWidget { final Call call; @@ -20,8 +28,40 @@ class ChatCall extends StatefulWidget { class _ChatCallState extends State { String? _token; + String? _endpoint; + + bool _isMounted = false; + + StreamSubscription? _subscription; + List _audioInputs = []; + List _videoInputs = []; + + bool _enableAudio = true; + bool _enableVideo = false; + LocalAudioTrack? _audioTrack; + LocalVideoTrack? _videoTrack; + MediaDevice? _videoDevice; + MediaDevice? _audioDevice; + + final VideoParameters _videoParameters = VideoParametersPresets.h720_169; + + late Room _callRoom; + late EventsListener _callListener; + + List _participantTracks = []; + + bool get _fastConnection => _callRoom.engine.fastConnectOptions != null; + + Future checkPermissions() async { + await Permission.camera.request(); + await Permission.microphone.request(); + await Permission.bluetooth.request(); + await Permission.bluetoothConnect.request(); + } + + Future<(String, String)> exchangeToken() async { + await checkPermissions(); - Future exchangeToken() async { final auth = context.read(); if (!await auth.isAuthorized()) { router.pop(); @@ -34,7 +74,9 @@ class _ChatCallState extends State { if (res.statusCode == 200) { final result = jsonDecode(utf8.decode(res.bodyBytes)); _token = result['token']; - return _token!; + _endpoint = 'wss://${result['endpoint']}'; + joinRoom(_endpoint!, _token!); + return (_token!, _endpoint!); } else { var message = utf8.decode(res.bodyBytes); ScaffoldMessenger.of(context).showSnackBar( @@ -44,6 +86,284 @@ class _ChatCallState extends State { } } + void joinRoom(String url, String token) async { + if(_isMounted) { + return; + } else { + _isMounted = true; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + + final notify = ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.connectingServer), + duration: const Duration(minutes: 1), + ), + ); + + try { + await _callRoom.connect( + url, + token, + roomOptions: RoomOptions( + dynacast: true, + adaptiveStream: true, + defaultAudioPublishOptions: const AudioPublishOptions( + name: 'call_voice', + stream: 'call_stream', + ), + defaultVideoPublishOptions: const VideoPublishOptions( + name: 'call_video', + stream: 'call_stream', + simulcast: true, + backupVideoCodec: BackupVideoCodec(enabled: true), + ), + defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions( + useiOSBroadcastExtension: true, + params: VideoParameters( + dimensions: VideoDimensionsPresets.h1080_169, + encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30), + ), + ), + defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: _videoParameters), + ), + fastConnectOptions: FastConnectOptions( + microphone: TrackOption(track: _audioTrack), + camera: TrackOption(track: _videoTrack), + ), + ); + + setupRoom(); + } catch (e) { + final message = e.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } finally { + notify.close(); + } + } + + void askPublish() async { + final result = await context.showPublishDialog(); + if (result != true) return; + try { + await _callRoom.localParticipant?.setCameraEnabled(true); + } catch (error) { + await context.showErrorDialog(error); + } + try { + await _callRoom.localParticipant?.setMicrophoneEnabled(true); + } catch (error) { + await context.showErrorDialog(error); + } + } + + void setupRoom() { + _callRoom.addListener(onRoomDidUpdate); + setupRoomListeners(); + sortParticipants(); + WidgetsBindingCompatible.instance?.addPostFrameCallback((_) { + if (!_fastConnection) { + askPublish(); + } + }); + + if (lkPlatformIsMobile()) { + Hardware.instance.setSpeakerphoneOn(true); + } + } + + void setupRoomListeners() { + _callListener + ..on((event) async { + if (event.reason != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Call disconnected... ${event.reason}'), + )); + } + if (router.canPop()) router.pop(); + }) + ..on((event) => sortParticipants()) + ..on((event) { + context.showRecordingStatusChangedDialog(event.activeRecording); + }) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((_) => sortParticipants()) + ..on((event) { + sortParticipants(); + }) + ..on((event) async { + if (!_callRoom.canPlaybackAudio) { + bool? yesno = await context.showPlayAudioManuallyDialog(); + if (yesno == true) { + await _callRoom.startAudio(); + } + } + }); + } + + void sortParticipants() { + List userMediaTracks = []; + List screenTracks = []; + for (var participant in _callRoom.remoteParticipants.values) { + for (var t in participant.videoTrackPublications) { + if (t.isScreenShare) { + screenTracks.add(ParticipantTrack( + participant: participant, + videoTrack: t.track, + isScreenShare: true, + )); + } else { + userMediaTracks.add(ParticipantTrack( + participant: participant, + videoTrack: t.track, + isScreenShare: false, + )); + } + } + } + userMediaTracks.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; + }); + + final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications; + if (localParticipantTracks != null) { + for (var t in localParticipantTracks) { + if (t.isScreenShare) { + screenTracks.add(ParticipantTrack( + participant: _callRoom.localParticipant!, + videoTrack: t.track, + isScreenShare: true, + )); + } else { + userMediaTracks.add(ParticipantTrack( + participant: _callRoom.localParticipant!, + videoTrack: t.track, + isScreenShare: false, + )); + } + } + } + setState(() { + _participantTracks = [...screenTracks, ...userMediaTracks]; + }); + } + + void onRoomDidUpdate() => sortParticipants(); + + void revertDevices(List devices) async { + _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); + _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); + + if (_audioInputs.isNotEmpty) { + if (_audioDevice == null && _enableAudio) { + _audioDevice = _audioInputs.first; + Future.delayed(const Duration(milliseconds: 100), () async { + await changeLocalAudioTrack(); + setState(() {}); + }); + } + } + + if (_videoInputs.isNotEmpty) { + if (_videoDevice == null && _enableVideo) { + _videoDevice = _videoInputs.first; + Future.delayed(const Duration(milliseconds: 100), () async { + await changeLocalVideoTrack(); + setState(() {}); + }); + } + } + setState(() {}); + } + + Future setEnableVideo(value) async { + _enableVideo = value; + if (!_enableVideo) { + await _videoTrack?.stop(); + _videoTrack = null; + } else { + await changeLocalVideoTrack(); + } + setState(() {}); + } + + Future setEnableAudio(value) async { + _enableAudio = value; + if (!_enableAudio) { + await _audioTrack?.stop(); + _audioTrack = null; + } else { + await changeLocalAudioTrack(); + } + setState(() {}); + } + + 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: _videoParameters, + )); + await _videoTrack!.start(); + } + } + + @override + void initState() { + super.initState(); + _subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices); + _callRoom = Room(); + _callListener = _callRoom.createListener(); + Hardware.instance.enumerateDevices().then(revertDevices); + } + @override Widget build(BuildContext context) { return IndentWrapper( @@ -57,10 +377,60 @@ class _ChatCallState extends State { return const Center(child: CircularProgressIndicator()); } - print(snapshot.data!); - return Container(); + return Stack( + children: [ + Column( + children: [ + Expanded( + child: _participantTracks.isNotEmpty + ? ParticipantWidget.widgetFor(_participantTracks.first, showStatsLayer: true) + : Container(), + ), + if (_callRoom.localParticipant != null) + SafeArea( + top: false, + child: ControlsWidget(_callRoom, _callRoom.localParticipant!), + ) + ], + ), + Positioned( + left: 0, + right: 0, + top: 0, + child: SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: math.max(0, _participantTracks.length - 1), + itemBuilder: (BuildContext context, int index) => SizedBox( + width: 180, + height: 120, + child: ParticipantWidget.widgetFor(_participantTracks[index + 1]), + ), + ), + ), + ), + ], + ); }, ), ); } + + @override + void deactivate() { + _subscription?.cancel(); + super.deactivate(); + } + + @override + void dispose() { + (() async { + _callRoom.removeListener(onRoomDidUpdate); + await _callRoom.disconnect(); + await _callListener.dispose(); + await _callRoom.dispose(); + })(); + super.dispose(); + } } diff --git a/lib/screens/chat/manage.dart b/lib/screens/chat/manage.dart index 0187926..3abff55 100644 --- a/lib/screens/chat/manage.dart +++ b/lib/screens/chat/manage.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:solian/models/channel.dart'; diff --git a/lib/widgets/chat/call/controls.dart b/lib/widgets/chat/call/controls.dart new file mode 100644 index 0000000..15a581b --- /dev/null +++ b/lib/widgets/chat/call/controls.dart @@ -0,0 +1,379 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_background/flutter_background.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:solian/widgets/chat/call/exts.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(() {}); + + void unpublishAll() async { + final result = await context.showUnPublishDialog(); + if (result == true) await participant.unpublishAllTracks(); + } + + bool get isMuted => participant.isMuted; + + void disableAudio() async { + await participant.setMicrophoneEnabled(false); + } + + Future 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 setSpeakerphoneOn() { + _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( + sourceId: source.id, + maxFrameRate: 15.0, + ), + ); + await participant.publishVideoTrack(track); + } catch (e) { + final message = e.toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Something went wrong... $message'), + )); + } + return; + } + if (lkPlatformIs(PlatformType.android)) { + requestBackgroundPermission([bool isRetry = false]) async { + try { + bool hasPermissions = await FlutterBackground.hasPermissions; + if (!isRetry) { + const androidConfig = FlutterBackgroundAndroidConfig( + notificationTitle: 'Screen Sharing', + notificationText: 'A Solar Messager\'s Call is sharing your screen', + notificationImportance: AndroidNotificationImportance.Default, + notificationIcon: AndroidResource(name: 'launcher_icon', defType: 'mipmap'), + ); + hasPermissions = await FlutterBackground.initialize(androidConfig: androidConfig); + } + if (hasPermissions && !FlutterBackground.isBackgroundExecutionEnabled) { + await FlutterBackground.enableBackgroundExecution(); + } + } catch (e) { + if (!isRetry) { + return await Future.delayed(const Duration(seconds: 1), () => requestBackgroundPermission(true)); + } + } + } + + await requestBackgroundPermission(); + } + if (lkPlatformIs(PlatformType.iOS)) { + var track = await LocalVideoTrack.createScreenShareTrack( + const ScreenShareCaptureOptions( + useiOSBroadcastExtension: 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); + if (lkPlatformIs(PlatformType.android)) { + // Android specific + try { + await FlutterBackground.disableBackgroundExecution(); + } catch (_) {} + } + } + + void onTapUpdateSubscribePermission() async { + final result = await context.showSubscribePermissionDialog(); + if (result != null) { + try { + widget.room.localParticipant?.setTrackSubscriptionPermissions( + allParticipantsAllowed: result, + ); + } catch (e) { + final message = e.toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Something went wrong... $message'), + )); + } + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 15, + ), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 5, + runSpacing: 5, + children: [ + IconButton( + onPressed: unpublishAll, + icon: const Icon(Icons.cancel), + tooltip: 'Unpublish all', + ), + if (participant.isMicrophoneEnabled()) + if (lkPlatformIs(PlatformType.android)) + IconButton( + onPressed: disableAudio, + icon: const Icon(Icons.mic), + tooltip: 'mute audio', + ) + else + PopupMenuButton( + icon: const Icon(Icons.settings_voice), + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: null, + onTap: isMuted ? enableAudio : disableAudio, + child: const ListTile( + leading: Icon(Icons.mic_off), + title: Text('Mute Microphone'), + ), + ), + if (_audioInputs != null) + ..._audioInputs!.map((device) { + return PopupMenuItem( + value: device, + child: ListTile( + leading: (device.deviceId == widget.room.selectedAudioInputDeviceId) + ? const Icon(Icons.check_box_outlined) + : const Icon(Icons.check_box_outline_blank), + title: Text(device.label), + ), + onTap: () => selectAudioInput(device), + ); + }) + ]; + }, + ) + else + IconButton( + onPressed: enableAudio, + icon: const Icon(Icons.mic_off), + tooltip: 'un-mute audio', + ), + if (!lkPlatformIs(PlatformType.iOS)) + PopupMenuButton( + icon: const Icon(Icons.volume_up), + itemBuilder: (BuildContext context) { + return [ + const PopupMenuItem( + value: null, + child: ListTile( + leading: Icon(Icons.speaker), + title: Text('Select Audio Output'), + ), + ), + if (_audioOutputs != null) + ..._audioOutputs!.map((device) { + return PopupMenuItem( + value: device, + child: ListTile( + leading: (device.deviceId == widget.room.selectedAudioOutputDeviceId) + ? const Icon(Icons.check_box_outlined) + : const Icon(Icons.check_box_outline_blank), + title: Text(device.label), + ), + onTap: () => selectAudioOutput(device), + ); + }) + ]; + }, + ), + if (!kIsWeb && lkPlatformIs(PlatformType.iOS)) + IconButton( + disabledColor: Colors.grey, + onPressed: Hardware.instance.canSwitchSpeakerphone ? setSpeakerphoneOn : null, + icon: Icon(_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android), + tooltip: 'Switch SpeakerPhone', + ), + if (participant.isCameraEnabled()) + PopupMenuButton( + icon: const Icon(Icons.videocam_sharp), + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + value: null, + onTap: disableVideo, + child: const ListTile( + leading: Icon( + Icons.videocam_off, + color: Colors.white, + ), + title: Text('Disable Camera'), + ), + ), + if (_videoInputs != null) + ..._videoInputs!.map((device) { + return PopupMenuItem( + value: device, + child: ListTile( + leading: (device.deviceId == widget.room.selectedVideoInputDeviceId) + ? const Icon(Icons.check_box_outlined) + : const Icon(Icons.check_box_outline_blank), + title: Text(device.label), + ), + onTap: () => selectVideoInput(device), + ); + }) + ]; + }, + ) + else + IconButton( + onPressed: enableVideo, + icon: const Icon(Icons.videocam_off), + tooltip: 'un-mute video', + ), + IconButton( + icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front), + onPressed: () => toggleCamera(), + tooltip: 'toggle camera', + ), + if (participant.isScreenShareEnabled()) + IconButton( + icon: const Icon(Icons.monitor_outlined), + onPressed: () => disableScreenShare(), + tooltip: 'unshare screen (experimental)', + ) + else + IconButton( + icon: const Icon(Icons.monitor), + onPressed: () => enableScreenShare(), + tooltip: 'share screen (experimental)', + ), + IconButton( + onPressed: onTapUpdateSubscribePermission, + icon: const Icon(Icons.settings), + tooltip: 'Subscribe permission', + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat/call/exts.dart b/lib/widgets/chat/call/exts.dart new file mode 100644 index 0000000..41130d7 --- /dev/null +++ b/lib/widgets/chat/call/exts.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; + +extension SolianCallExt on BuildContext { + Future showPublishDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Publish'), + content: const Text('Would you like to publish your Camera & Mic ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('NO'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('YES'), + ), + ], + ), + ); + + Future showPlayAudioManuallyDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Play Audio'), + content: const Text( + 'You need to manually activate audio PlayBack for iOS Safari !'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Ignore'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Play Audio'), + ), + ], + ), + ); + + Future showUnPublishDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('UnPublish'), + content: + const Text('Would you like to un-publish your Camera & Mic ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('NO'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('YES'), + ), + ], + ), + ); + + Future showErrorDialog(dynamic exception) => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Error'), + content: Text(exception.toString()), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ) + ], + ), + ); + + Future showDisconnectDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Disconnect'), + content: const Text('Are you sure to disconnect?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Disconnect'), + ), + ], + ), + ); + + Future showReconnectDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Reconnect'), + content: const Text('This will force a reconnection'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Reconnect'), + ), + ], + ), + ); + + Future showReconnectSuccessDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Reconnect'), + content: const Text('Reconnection was successful.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), + ], + ), + ); + + Future showSendDataDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Send data'), + content: const Text( + 'This will send a sample data to all participants in the room'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Send'), + ), + ], + ), + ); + + Future showDataReceivedDialog(String data) => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Received data'), + content: Text(data), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('OK'), + ), + ], + ), + ); + + Future showRecordingStatusChangedDialog(bool isActiveRecording) => + showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Room recording reminder'), + content: Text(isActiveRecording + ? 'Room recording is active.' + : 'Room recording is stoped.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('OK'), + ), + ], + ), + ); + + Future showSubscribePermissionDialog() => showDialog( + context: this, + builder: (ctx) => AlertDialog( + title: const Text('Allow subscription'), + content: const Text( + 'Allow all participants to subscribe tracks published by local participant?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('NO'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('YES'), + ), + ], + ), + ); +} + +enum SimulateScenarioResult { + signalReconnect, + fullReconnect, + speakerUpdate, + nodeFailure, + migration, + serverLeave, + switchCandidate, + e2eeKeyRatchet, + participantName, + participantMetadata, + clear, +} \ No newline at end of file diff --git a/lib/widgets/chat/call/no_video.dart b/lib/widgets/chat/call/no_video.dart new file mode 100644 index 0000000..ee09f25 --- /dev/null +++ b/lib/widgets/chat/call/no_video.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'dart:math' as math; + +class NoVideoWidget extends StatelessWidget { + const NoVideoWidget({super.key}); + + @override + Widget build(BuildContext context) => Container( + alignment: Alignment.center, + child: LayoutBuilder( + builder: (ctx, constraints) => Icon( + Icons.videocam_off_outlined, + color: Theme.of(context).colorScheme.primary, + size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3, + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/chat/call/participant.dart b/lib/widgets/chat/call/participant.dart new file mode 100644 index 0000000..bc57304 --- /dev/null +++ b/lib/widgets/chat/call/participant.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:solian/models/call.dart'; +import 'package:solian/widgets/chat/call/no_video.dart'; +import 'package:solian/widgets/chat/call/participant_info.dart'; +import 'package:solian/widgets/chat/call/participant_stats.dart'; + +abstract class ParticipantWidget extends StatefulWidget { + static ParticipantWidget widgetFor(ParticipantTrack participantTrack, {bool showStatsLayer = false}) { + if (participantTrack.participant is LocalParticipant) { + return LocalParticipantWidget(participantTrack.participant as LocalParticipant, participantTrack.videoTrack, + participantTrack.isScreenShare, showStatsLayer); + } else if (participantTrack.participant is RemoteParticipant) { + return RemoteParticipantWidget(participantTrack.participant as RemoteParticipant, participantTrack.videoTrack, + participantTrack.isScreenShare, showStatsLayer); + } + throw UnimplementedError('Unknown participant type'); + } + + abstract final Participant participant; + abstract final VideoTrack? videoTrack; + abstract final bool isScreenShare; + 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 isScreenShare; + @override + final bool showStatsLayer; + + const LocalParticipantWidget( + this.participant, + this.videoTrack, + 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 isScreenShare; + @override + final bool showStatsLayer; + + const RemoteParticipantWidget( + this.participant, + this.videoTrack, + this.isScreenShare, + this.showStatsLayer, { + super.key, + }); + + @override + State createState() => _RemoteParticipantWidgetState(); +} + +abstract class _ParticipantWidgetState extends State { + bool _visible = true; + + VideoTrack? get _activeVideoTrack; + + TrackPublication? get _videoPublication; + + TrackPublication? get _firstAudioPublication; + + @override + void initState() { + super.initState(); + widget.participant.addListener(onParticipantChanged); + onParticipantChanged(); + } + + @override + void dispose() { + widget.participant.removeListener(onParticipantChanged); + super.dispose(); + } + + @override + void didUpdateWidget(covariant T oldWidget) { + oldWidget.participant.removeListener(onParticipantChanged); + widget.participant.addListener(onParticipantChanged); + onParticipantChanged(); + super.didUpdateWidget(oldWidget); + } + + void onParticipantChanged() => setState(() {}); + + List extraWidgets(bool isScreenShare) => []; + + @override + Widget build(BuildContext ctx) => Container( + foregroundDecoration: BoxDecoration( + border: widget.participant.isSpeaking && !widget.isScreenShare + ? Border.all( + width: 5, + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + decoration: BoxDecoration( + color: Theme.of(ctx).cardColor, + ), + child: Stack( + children: [ + // Video + InkWell( + onTap: () => setState(() => _visible = !_visible), + child: _activeVideoTrack != null && !_activeVideoTrack!.muted + ? VideoTrackRenderer( + _activeVideoTrack!, + fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + ) + : const NoVideoWidget(), + ), + if (widget.showStatsLayer) + Positioned( + top: 30, + right: 30, + child: ParticipantStatsWidget( + participant: widget.participant, + )), + // Bottom bar + Align( + alignment: Alignment.bottomCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ...extraWidgets(widget.isScreenShare), + ParticipantInfoWidget( + title: widget.participant.name.isNotEmpty + ? '${widget.participant.name} (${widget.participant.identity})' + : widget.participant.identity, + audioAvailable: + _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, + connectionQuality: widget.participant.connectionQuality, + isScreenShare: widget.isScreenShare, + enabledE2EE: widget.participant.isEncrypted, + ), + ], + ), + ), + ], + ), + ); +} + +class _LocalParticipantWidgetState extends _ParticipantWidgetState { + @override + LocalTrackPublication? get _videoPublication => + widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull; + + @override + LocalTrackPublication? get _firstAudioPublication => + widget.participant.audioTrackPublications.firstOrNull; + + @override + VideoTrack? get _activeVideoTrack => widget.videoTrack; +} + +class _RemoteParticipantWidgetState extends _ParticipantWidgetState { + @override + RemoteTrackPublication? get _videoPublication => + widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull; + + @override + RemoteTrackPublication? get _firstAudioPublication => + widget.participant.audioTrackPublications.firstOrNull; + + @override + VideoTrack? get _activeVideoTrack => widget.videoTrack; + + @override + List extraWidgets(bool isScreenShare) => [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Menu for RemoteTrackPublication + if (_firstAudioPublication != null && !isScreenShare) + RemoteTrackPublicationMenuWidget( + pub: _firstAudioPublication!, + icon: Icons.volume_up, + ), + // Menu for RemoteTrackPublication + if (_videoPublication != null) + RemoteTrackPublicationMenuWidget( + pub: _videoPublication!, + icon: isScreenShare ? Icons.monitor : Icons.videocam, + ), + if (_videoPublication != null) + RemoteTrackFPSMenuWidget( + pub: _videoPublication!, + icon: Icons.menu, + ), + if (_videoPublication != null) + RemoteTrackQualityMenuWidget( + pub: _videoPublication!, + icon: Icons.monitor_outlined, + ), + ], + ), + ]; +} + +class RemoteTrackPublicationMenuWidget extends StatelessWidget { + final IconData icon; + final RemoteTrackPublication pub; + + const RemoteTrackPublicationMenuWidget({ + required this.pub, + required this.icon, + super.key, + }); + + @override + Widget build(BuildContext context) => Material( + color: Colors.black.withOpacity(0.3), + child: PopupMenuButton( + tooltip: 'Subscribe menu', + icon: Icon(icon, + color: { + TrackSubscriptionState.notAllowed: Colors.red, + TrackSubscriptionState.unsubscribed: Colors.grey, + TrackSubscriptionState.subscribed: Colors.green, + }[pub.subscriptionState]), + onSelected: (value) => value(), + itemBuilder: (BuildContext context) => >[ + if (pub.subscribed == false) + PopupMenuItem( + child: const Text('Subscribe'), + value: () => pub.subscribe(), + ) + else if (pub.subscribed == true) + PopupMenuItem( + child: const Text('Un-subscribe'), + value: () => pub.unsubscribe(), + ), + ], + ), + ); +} + +class RemoteTrackFPSMenuWidget extends StatelessWidget { + final IconData icon; + final RemoteTrackPublication pub; + + const RemoteTrackFPSMenuWidget({ + required this.pub, + required this.icon, + super.key, + }); + + @override + Widget build(BuildContext context) => Material( + color: Colors.black.withOpacity(0.3), + child: PopupMenuButton( + tooltip: 'Preferred FPS', + icon: Icon(icon, color: Colors.white), + onSelected: (value) => value(), + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + child: const Text('30'), + value: () => pub.setVideoFPS(30), + ), + PopupMenuItem( + child: const Text('15'), + value: () => pub.setVideoFPS(15), + ), + PopupMenuItem( + child: const Text('8'), + value: () => pub.setVideoFPS(8), + ), + ], + ), + ); +} + +class RemoteTrackQualityMenuWidget extends StatelessWidget { + final IconData icon; + final RemoteTrackPublication pub; + + const RemoteTrackQualityMenuWidget({ + required this.pub, + required this.icon, + super.key, + }); + + @override + Widget build(BuildContext context) => Material( + color: Colors.black.withOpacity(0.3), + child: PopupMenuButton( + tooltip: 'Preferred Quality', + icon: Icon(icon, color: Colors.white), + onSelected: (value) => value(), + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + child: const Text('HIGH'), + value: () => pub.setVideoQuality(VideoQuality.HIGH), + ), + PopupMenuItem( + child: const Text('MEDIUM'), + value: () => pub.setVideoQuality(VideoQuality.MEDIUM), + ), + PopupMenuItem( + child: const Text('LOW'), + value: () => pub.setVideoQuality(VideoQuality.LOW), + ), + ], + ), + ); +} diff --git a/lib/widgets/chat/call/participant_info.dart b/lib/widgets/chat/call/participant_info.dart new file mode 100644 index 0000000..9a62fd2 --- /dev/null +++ b/lib/widgets/chat/call/participant_info.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart'; + +class ParticipantInfoWidget extends StatelessWidget { + final String? title; + final bool audioAvailable; + final ConnectionQuality connectionQuality; + final bool isScreenShare; + final bool enabledE2EE; + + const ParticipantInfoWidget({ + super.key, + this.title, + this.audioAvailable = true, + this.connectionQuality = ConnectionQuality.unknown, + this.isScreenShare = false, + this.enabledE2EE = false, + }); + + @override + Widget build(BuildContext context) => Container( + color: Colors.black.withOpacity(0.3), + 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, + ), + ), + isScreenShare + ? const Padding( + padding: EdgeInsets.only(left: 5), + child: Icon( + Icons.monitor, + color: Colors.white, + size: 16, + ), + ) + : Padding( + padding: const EdgeInsets.only(left: 5), + child: Icon( + audioAvailable ? Icons.mic : Icons.mic_off, + color: audioAvailable ? Colors.white : Colors.red, + size: 16, + ), + ), + if (connectionQuality != ConnectionQuality.unknown) + Padding( + padding: const EdgeInsets.only(left: 5), + child: Icon( + connectionQuality == ConnectionQuality.poor ? Icons.wifi_off_outlined : Icons.wifi, + color: { + ConnectionQuality.excellent: Colors.green, + ConnectionQuality.good: Colors.orange, + ConnectionQuality.poor: Colors.red, + }[connectionQuality], + size: 16, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 5), + child: Icon( + enabledE2EE ? Icons.lock : Icons.lock_open, + color: enabledE2EE ? Colors.green : Colors.red, + size: 16, + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/chat/call/participant_stats.dart b/lib/widgets/chat/call/participant_stats.dart new file mode 100644 index 0000000..274f258 --- /dev/null +++ b/lib/widgets/chat/call/participant_stats.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:solian/models/call.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: Colors.black.withOpacity(0.3), + 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/notification_notifier.dart b/lib/widgets/notification_notifier.dart index 5cacbaf..eac2c4d 100644 --- a/lib/widgets/notification_notifier.dart +++ b/lib/widgets/notification_notifier.dart @@ -19,7 +19,7 @@ class NotificationNotifier extends StatefulWidget { } class _NotificationNotifierState extends State { - void connect() { + void connect() async { final notify = ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.connectingServer), @@ -30,19 +30,21 @@ class _NotificationNotifierState extends State { final auth = context.read(); final nty = context.read(); - nty.fetch(auth); - nty.connect(auth).then((snapshot) { - snapshot!.stream.listen( - (event) { - final result = model.Notification.fromJson(jsonDecode(event)); - nty.onRemoteMessage(result); - }, - onError: (_, __) => connect(), - onDone: () => connect(), - ); + if (await auth.isAuthorized()) { + nty.fetch(auth); + nty.connect(auth).then((snapshot) { + snapshot!.stream.listen( + (event) { + final result = model.Notification.fromJson(jsonDecode(event)); + nty.onRemoteMessage(result); + }, + onError: (_, __) => connect(), + onDone: () => connect(), + ); + }); + } - notify.close(); - }); + notify.close(); } @override diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index f85e405..b9bbc5c 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "solian") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.solian") +set(APPLICATION_ID "dev.solsynth.solian") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 1e8b4d1..d41d77e 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -19,6 +20,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); + flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1977af0..6ad5e50 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux + flutter_webrtc media_kit_libs_linux media_kit_video url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 13d26ac..9647d86 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,12 @@ import FlutterMacOS import Foundation +import connectivity_plus +import device_info_plus import file_selector_macos import flutter_secure_storage_macos +import flutter_webrtc +import livekit_client import media_kit_libs_macos_video import media_kit_video import package_info_plus @@ -16,8 +20,12 @@ import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) + LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index f1b0e56..fdf8678 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/solian.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/solian"; @@ -492,7 +492,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/solian.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/solian"; @@ -507,7 +507,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.solian.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/solian.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/solian"; diff --git a/pubspec.lock b/pubspec.lock index 0517944..0d7eb6d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + connectivity_plus: + dependency: transitive + description: + name: connectivity_plus + sha256: db7a4e143dc72cc3cb2044ef9b052a7ebfe729513e6a82943bc3526f784365b8 + url: "https://pub.dev" + source: hosted + version: "6.0.3" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb + url: "https://pub.dev" + source: hosted + version: "2.0.0" convert: dependency: transitive description: @@ -105,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + url: "https://pub.dev" + source: hosted + version: "2.7.0" cupertino_icons: dependency: "direct main" description: @@ -113,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: b3a4f109c551a10170ece8fc79b5ca1b98223f24bcebc0f971d7fe35daad7a3b + url: "https://pub.dev" + source: hosted + version: "1.4.4" dbus: dependency: transitive description: @@ -121,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 + url: "https://pub.dev" + source: hosted + version: "10.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" + source: hosted + version: "7.0.0" fake_async: dependency: transitive description: @@ -137,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" file_selector_linux: dependency: transitive description: @@ -190,6 +246,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.0" + flutter_background: + dependency: "direct main" + description: + name: flutter_background + sha256: "035c31a738509d67ee70bbf174e5aa7db462c371e838ec8259700c5c4e7ca17f" + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter_carousel_widget: dependency: "direct main" description: @@ -309,6 +373,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_webrtc: + dependency: "direct main" + description: + name: flutter_webrtc + sha256: "20eac28848a2dffb26cc2b2870a5164613904511a0b7e8f4825e31a2768175d2" + url: "https://pub.dev" + source: hosted + version: "0.10.3" go_router: dependency: "direct main" description: @@ -485,6 +557,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + livekit_client: + dependency: "direct main" + description: + name: livekit_client + sha256: "9b7e471584b34d914dfea71ecbe4d1d5169690cc1055850509841827c489ddbb" + url: "https://pub.dev" + source: hosted + version: "2.1.3" logging: dependency: transitive description: @@ -613,6 +693,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" oauth2: dependency: "direct main" description: @@ -693,6 +781,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + 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: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" + url: "https://pub.dev" + source: hosted + version: "12.0.5" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 + url: "https://pub.dev" + source: hosted + version: "9.4.4" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + 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: @@ -709,6 +845,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + platform_detect: + dependency: transitive + description: + name: platform_detect + sha256: "08f4ee79c0e1c4858d37e06b22352a3ebdef5466b613749a3adb03e703d4f5b0" + url: "https://pub.dev" + source: hosted + version: "2.0.11" plugin_platform_interface: dependency: transitive description: @@ -725,6 +869,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.0" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" provider: dependency: "direct main" description: @@ -733,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" safe_local_storage: dependency: transitive description: @@ -789,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + sdp_transform: + dependency: transitive + description: + name: sdp_transform + sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" + url: "https://pub.dev" + source: hosted + version: "0.3.2" sky_engine: dependency: transitive description: flutter @@ -1026,6 +1194,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.5" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: abec3ab7956bd5ac539cf34a42fa0c82ea26675847c0966bb85160400eea9388 + url: "https://pub.dev" + source: hosted + version: "1.2.0" webview_flutter: dependency: "direct main" description: @@ -1066,6 +1242,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" + url: "https://pub.dev" + source: hosted + version: "1.1.3" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bc5f3ad..0d90f15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,10 @@ dependencies: web_socket_channel: ^2.4.5 badges: ^3.1.2 flutter_animate: ^4.5.0 + livekit_client: ^2.1.3 + permission_handler: ^11.3.1 + flutter_webrtc: ^0.10.3 + flutter_background: ^1.2.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f67a05b..53e4f6c 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,22 +6,34 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include +#include #include #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterWebRTCPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); + LiveKitPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LiveKitPlugin")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenBrightnessWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 485cc92..c88aaf1 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,10 +3,14 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus file_selector_windows flutter_secure_storage_windows + flutter_webrtc + livekit_client media_kit_libs_windows_video media_kit_video + permission_handler_windows screen_brightness_windows url_launcher_windows )