Compare commits
	
		
			10 Commits
		
	
	
		
			001549b190
			...
			refactor/w
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 0622498f4e | |||
| 844efcda1a | |||
| 98e39cce6a | |||
| 0c459bf7e3 | |||
| a2576abee0 | |||
| f4b28c3fa2 | |||
| 43d767bc03 | |||
| 0910be88ef | |||
| e96b1fd9d4 | |||
| 3f83bbc1d8 | 
| @@ -2,8 +2,6 @@ PODS: | ||||
|   - Alamofire (5.10.2) | ||||
|   - app_links (6.4.1): | ||||
|     - Flutter | ||||
|   - connectivity_plus (0.0.1): | ||||
|     - Flutter | ||||
|   - croppy (0.0.1): | ||||
|     - Flutter | ||||
|   - device_info_plus (0.0.1): | ||||
| @@ -219,10 +217,6 @@ PODS: | ||||
|   - irondash_engine_context (0.0.1): | ||||
|     - Flutter | ||||
|   - Kingfisher (8.6.0) | ||||
|   - livekit_client (2.5.0): | ||||
|     - Flutter | ||||
|     - flutter_webrtc | ||||
|     - WebRTC-SDK (= 137.7151.04) | ||||
|   - local_auth_darwin (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
| @@ -309,7 +303,6 @@ PODS: | ||||
| DEPENDENCIES: | ||||
|   - Alamofire | ||||
|   - app_links (from `.symlinks/plugins/app_links/ios`) | ||||
|   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) | ||||
|   - croppy (from `.symlinks/plugins/croppy/ios`) | ||||
|   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) | ||||
|   - file_picker (from `.symlinks/plugins/file_picker/ios`) | ||||
| @@ -333,7 +326,6 @@ DEPENDENCIES: | ||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||
|   - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) | ||||
|   - Kingfisher (~> 8.0) | ||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||
|   - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) | ||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) | ||||
|   - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) | ||||
| @@ -388,8 +380,6 @@ SPEC REPOS: | ||||
| EXTERNAL SOURCES: | ||||
|   app_links: | ||||
|     :path: ".symlinks/plugins/app_links/ios" | ||||
|   connectivity_plus: | ||||
|     :path: ".symlinks/plugins/connectivity_plus/ios" | ||||
|   croppy: | ||||
|     :path: ".symlinks/plugins/croppy/ios" | ||||
|   device_info_plus: | ||||
| @@ -434,8 +424,6 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/image_picker_ios/ios" | ||||
|   irondash_engine_context: | ||||
|     :path: ".symlinks/plugins/irondash_engine_context/ios" | ||||
|   livekit_client: | ||||
|     :path: ".symlinks/plugins/livekit_client/ios" | ||||
|   local_auth_darwin: | ||||
|     :path: ".symlinks/plugins/local_auth_darwin/darwin" | ||||
|   media_kit_libs_ios_video: | ||||
| @@ -480,7 +468,6 @@ EXTERNAL SOURCES: | ||||
| SPEC CHECKSUMS: | ||||
|   Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 | ||||
|   app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a | ||||
|   connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd | ||||
|   croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 | ||||
|   device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe | ||||
|   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c | ||||
| @@ -520,7 +507,6 @@ SPEC CHECKSUMS: | ||||
|   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a | ||||
|   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 | ||||
|   Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0 | ||||
|   livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4 | ||||
|   local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb | ||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||
|   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||
|   | ||||
| @@ -149,6 +149,8 @@ sealed class CallParticipant with _$CallParticipant { | ||||
|   const factory CallParticipant({ | ||||
|     required String identity, | ||||
|     required String name, | ||||
|     required String accountId, | ||||
|     @Default(null) SnAccount? account, | ||||
|     required DateTime joinedAt, | ||||
|   }) = _CallParticipant; | ||||
|  | ||||
|   | ||||
| @@ -2241,7 +2241,7 @@ as List<CallParticipant>, | ||||
| /// @nodoc | ||||
| mixin _$CallParticipant { | ||||
|  | ||||
|  String get identity; String get name; DateTime get joinedAt; | ||||
|  String get identity; String get name; String get accountId; SnAccount? get account; DateTime get joinedAt; | ||||
| /// Create a copy of CallParticipant | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -2254,16 +2254,16 @@ $CallParticipantCopyWith<CallParticipant> get copyWith => _$CallParticipantCopyW | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); | ||||
| int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)'; | ||||
|   return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -2274,11 +2274,11 @@ abstract mixin class $CallParticipantCopyWith<$Res>  { | ||||
|   factory $CallParticipantCopyWith(CallParticipant value, $Res Function(CallParticipant) _then) = _$CallParticipantCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String identity, String name, DateTime joinedAt | ||||
|  String identity, String name, String accountId, SnAccount? account, DateTime joinedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
| $SnAccountCopyWith<$Res>? get account; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -2291,15 +2291,29 @@ class _$CallParticipantCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of CallParticipant | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? accountId = null,Object? account = freezed,Object? joinedAt = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||
| as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccount?,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of CallParticipant | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAccountCopyWith<$Res>? get account { | ||||
|     if (_self.account == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnAccountCopyWith<$Res>(_self.account!, (value) { | ||||
|     return _then(_self.copyWith(account: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -2378,10 +2392,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String identity,  String name,  DateTime joinedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String identity,  String name,  String accountId,  SnAccount? account,  DateTime joinedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallParticipant() when $default != null: | ||||
| return $default(_that.identity,_that.name,_that.joinedAt);case _: | ||||
| return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -2399,10 +2413,10 @@ return $default(_that.identity,_that.name,_that.joinedAt);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String identity,  String name,  DateTime joinedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String identity,  String name,  String accountId,  SnAccount? account,  DateTime joinedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallParticipant(): | ||||
| return $default(_that.identity,_that.name,_that.joinedAt);} | ||||
| return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -2416,10 +2430,10 @@ return $default(_that.identity,_that.name,_that.joinedAt);} | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String identity,  String name,  DateTime joinedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String identity,  String name,  String accountId,  SnAccount? account,  DateTime joinedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallParticipant() when $default != null: | ||||
| return $default(_that.identity,_that.name,_that.joinedAt);case _: | ||||
| return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -2431,11 +2445,13 @@ return $default(_that.identity,_that.name,_that.joinedAt);case _: | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _CallParticipant implements CallParticipant { | ||||
|   const _CallParticipant({required this.identity, required this.name, required this.joinedAt}); | ||||
|   const _CallParticipant({required this.identity, required this.name, required this.accountId, this.account = null, required this.joinedAt}); | ||||
|   factory _CallParticipant.fromJson(Map<String, dynamic> json) => _$CallParticipantFromJson(json); | ||||
|  | ||||
| @override final  String identity; | ||||
| @override final  String name; | ||||
| @override final  String accountId; | ||||
| @override@JsonKey() final  SnAccount? account; | ||||
| @override final  DateTime joinedAt; | ||||
|  | ||||
| /// Create a copy of CallParticipant | ||||
| @@ -2451,16 +2467,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); | ||||
| int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)'; | ||||
|   return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -2471,11 +2487,11 @@ abstract mixin class _$CallParticipantCopyWith<$Res> implements $CallParticipant | ||||
|   factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String identity, String name, DateTime joinedAt | ||||
|  String identity, String name, String accountId, SnAccount? account, DateTime joinedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
| @override $SnAccountCopyWith<$Res>? get account; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -2488,16 +2504,30 @@ class __$CallParticipantCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of CallParticipant | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? accountId = null,Object? account = freezed,Object? joinedAt = null,}) { | ||||
|   return _then(_CallParticipant( | ||||
| identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||
| as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccount?,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of CallParticipant | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAccountCopyWith<$Res>? get account { | ||||
|     if (_self.account == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnAccountCopyWith<$Res>(_self.account!, (value) { | ||||
|     return _then(_self.copyWith(account: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -275,6 +275,11 @@ _CallParticipant _$CallParticipantFromJson(Map<String, dynamic> json) => | ||||
|     _CallParticipant( | ||||
|       identity: json['identity'] as String, | ||||
|       name: json['name'] as String, | ||||
|       accountId: json['account_id'] as String, | ||||
|       account: | ||||
|           json['account'] == null | ||||
|               ? null | ||||
|               : SnAccount.fromJson(json['account'] as Map<String, dynamic>), | ||||
|       joinedAt: DateTime.parse(json['joined_at'] as String), | ||||
|     ); | ||||
|  | ||||
| @@ -282,6 +287,8 @@ Map<String, dynamic> _$CallParticipantToJson(_CallParticipant instance) => | ||||
|     <String, dynamic>{ | ||||
|       'identity': instance.identity, | ||||
|       'name': instance.name, | ||||
|       'account_id': instance.accountId, | ||||
|       'account': instance.account?.toJson(), | ||||
|       'joined_at': instance.joinedAt.toIso8601String(), | ||||
|     }; | ||||
|  | ||||
|   | ||||
| @@ -3,13 +3,15 @@ import 'dart:io'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/widgets/chat/call_button.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart' as lk; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/chat/webrtc_manager.dart'; | ||||
| import 'package:wakelock_plus/wakelock_plus.dart'; | ||||
| import 'package:island/talker.dart'; | ||||
|  | ||||
| @@ -43,193 +45,212 @@ sealed class CallParticipantLive with _$CallParticipantLive { | ||||
|  | ||||
|   const factory CallParticipantLive({ | ||||
|     required CallParticipant participant, | ||||
|     required lk.Participant remoteParticipant, | ||||
|     required WebRTCParticipant remoteParticipant, | ||||
|   }) = _CallParticipantLive; | ||||
|  | ||||
|   bool get isSpeaking => remoteParticipant.isSpeaking; | ||||
|   bool get isMuted => | ||||
|       remoteParticipant.isMuted || !remoteParticipant.isMicrophoneEnabled(); | ||||
|   bool get isScreenSharing => remoteParticipant.isScreenShareEnabled(); | ||||
|   bool get isScreenSharingWithAudio => | ||||
|       remoteParticipant.isScreenShareAudioEnabled(); | ||||
|   bool get isSpeaking { | ||||
|     // Use the actual audio level from WebRTC monitoring | ||||
|     return remoteParticipant.audioLevel > 0.1; // Threshold for speaking | ||||
|   } | ||||
|  | ||||
|   bool get hasVideo => remoteParticipant.hasVideo; | ||||
|   bool get hasAudio => remoteParticipant.hasAudio; | ||||
|   double get audioLevel => remoteParticipant.audioLevel; | ||||
|  | ||||
|   bool get isMuted => !remoteParticipant.isAudioEnabled; | ||||
|   bool get isScreenSharing => remoteParticipant.isVideoEnabled; // Simplified | ||||
|   bool get isScreenSharingWithAudio => false; // TODO: Implement screen sharing | ||||
|  | ||||
|   bool get hasVideo => remoteParticipant.isVideoEnabled; | ||||
|   bool get hasAudio => remoteParticipant.isAudioEnabled; | ||||
| } | ||||
|  | ||||
| @Riverpod(keepAlive: true) | ||||
| class CallNotifier extends _$CallNotifier { | ||||
|   lk.Room? _room; | ||||
|   lk.LocalParticipant? _localParticipant; | ||||
|   WebRTCManager? _webrtcManager; | ||||
|   List<CallParticipantLive> _participants = []; | ||||
|   final Map<String, CallParticipant> _participantInfoByIdentity = {}; | ||||
|   lk.EventsListener? _roomListener; | ||||
|   StreamSubscription<WebRTCParticipant>? _participantJoinedSubscription; | ||||
|   StreamSubscription<String>? _participantLeftSubscription; | ||||
|  | ||||
|   List<CallParticipantLive> get participants => | ||||
|       List.unmodifiable(_participants); | ||||
|   lk.LocalParticipant? get localParticipant => _localParticipant; | ||||
|  | ||||
|   Map<String, double> participantsVolumes = {}; | ||||
|  | ||||
|   Timer? _durationTimer; | ||||
|  | ||||
|   lk.Room? get room => _room; | ||||
|   String? _roomId; | ||||
|   String? get roomId => _roomId; | ||||
|   WebRTCManager? get webrtcManager => _webrtcManager; | ||||
|  | ||||
|   @override | ||||
|   CallState build() { | ||||
|     // Subscribe to websocket updates | ||||
|     return const CallState( | ||||
|       isConnected: false, | ||||
|       isMicrophoneEnabled: true, | ||||
|       isCameraEnabled: false, | ||||
|       isMicrophoneEnabled: | ||||
|           true, // Audio enabled by default (matches WebRTC init) | ||||
|       isCameraEnabled: true, // Video enabled by default (matches WebRTC init) | ||||
|       isScreenSharing: false, | ||||
|       isSpeakerphone: true, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _initRoomListeners() { | ||||
|     if (_room == null) return; | ||||
|     _roomListener?.dispose(); | ||||
|     _roomListener = _room!.createListener(); | ||||
|     _room!.addListener(_onRoomChange); | ||||
|     _roomListener! | ||||
|       ..on<lk.ParticipantConnectedEvent>((e) { | ||||
|         _refreshLiveParticipants(); | ||||
|       }) | ||||
|       ..on<lk.RoomDisconnectedEvent>((e) { | ||||
|         _participants = []; | ||||
|         state = state.copyWith(); | ||||
|       }); | ||||
|   } | ||||
|   void _initWebRTCListeners() { | ||||
|     _participantJoinedSubscription?.cancel(); | ||||
|     _participantLeftSubscription?.cancel(); | ||||
|  | ||||
|   void _onRoomChange() { | ||||
|     _refreshLiveParticipants(); | ||||
|   } | ||||
|  | ||||
|   void _refreshLiveParticipants() { | ||||
|     if (_room == null) return; | ||||
|     final remoteParticipants = _room!.remoteParticipants; | ||||
|     _participants = []; | ||||
|     // Add local participant first if available | ||||
|     if (_localParticipant != null) { | ||||
|       final localInfo = _buildParticipant(); | ||||
|       _participants.add( | ||||
|         CallParticipantLive( | ||||
|           participant: localInfo, | ||||
|           remoteParticipant: _localParticipant!, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|     // Add remote participants | ||||
|     _participants.addAll( | ||||
|       remoteParticipants.values.map((remote) { | ||||
|         final match = | ||||
|             _participantInfoByIdentity[remote.identity] ?? | ||||
|             CallParticipant( | ||||
|               identity: remote.identity, | ||||
|               name: remote.identity, | ||||
|               joinedAt: DateTime.now(), | ||||
|             ); | ||||
|         return CallParticipantLive( | ||||
|           participant: match, | ||||
|           remoteParticipant: remote, | ||||
|         ); | ||||
|       }), | ||||
|     _participantJoinedSubscription = _webrtcManager?.onParticipantJoined.listen( | ||||
|       (participant) { | ||||
|         _updateLiveParticipantsFromWebRTC(); | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     _participantLeftSubscription = _webrtcManager?.onParticipantLeft.listen(( | ||||
|       participantId, | ||||
|     ) { | ||||
|       _participants.removeWhere((p) => p.remoteParticipant.id == participantId); | ||||
|       state = state.copyWith(); | ||||
|     }); | ||||
|  | ||||
|     // Add local participant immediately when WebRTC is initialized | ||||
|     final userinfo = ref.watch(userInfoProvider); | ||||
|     if (userinfo.value != null) { | ||||
|       _addLocalParticipant(userinfo.value!); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _addLocalParticipant(SnAccount userinfo) { | ||||
|     if (_webrtcManager == null) return; | ||||
|  | ||||
|     // Remove any existing local participant first | ||||
|     _participants.removeWhere((p) => p.participant.identity == userinfo.id); | ||||
|  | ||||
|     // Add local participant (current user) | ||||
|     final localParticipant = CallParticipantLive( | ||||
|       participant: CallParticipant( | ||||
|         identity: userinfo.id, // Use roomId as local identity | ||||
|         name: userinfo.name, | ||||
|         accountId: userinfo.id, | ||||
|         account: userinfo, | ||||
|         joinedAt: DateTime.now(), | ||||
|       ), | ||||
|       remoteParticipant: WebRTCParticipant( | ||||
|         id: _webrtcManager!.roomId, | ||||
|         name: userinfo.nick, | ||||
|         userinfo: userinfo, | ||||
|         isLocal: true, | ||||
|       )..remoteStream = _webrtcManager!.localStream, // Access local stream | ||||
|     ); | ||||
|  | ||||
|     _participants.insert(0, localParticipant); // Add at the beginning | ||||
|     state = state.copyWith(); | ||||
|   } | ||||
|  | ||||
|   /// Builds the CallParticipant object for the local participant. | ||||
|   /// Optionally, pass [participants] if you want to prioritize info from the latest list. | ||||
|   CallParticipant _buildParticipant({List<CallParticipant>? participants}) { | ||||
|     if (_localParticipant == null) { | ||||
|       throw StateError('No local participant available'); | ||||
|     } | ||||
|     // Prefer info from the latest participants list if available | ||||
|     if (participants != null) { | ||||
|       final idx = participants.indexWhere( | ||||
|         (p) => p.identity == _localParticipant!.identity, | ||||
|       ); | ||||
|       if (idx != -1) return participants[idx]; | ||||
|     } | ||||
|   void _updateLiveParticipantsFromWebRTC() { | ||||
|     if (_webrtcManager == null) return; | ||||
|  | ||||
|     // Otherwise, use info from the identity map or fallback to minimal | ||||
|     return _participantInfoByIdentity[_localParticipant!.identity] ?? | ||||
|         CallParticipant( | ||||
|           identity: _localParticipant!.identity, | ||||
|           name: _localParticipant!.identity, | ||||
|           joinedAt: DateTime.now(), | ||||
|         ); | ||||
|   } | ||||
|     final webrtcParticipants = _webrtcManager!.participants; | ||||
|  | ||||
|   void _updateLiveParticipants(List<CallParticipant> participants) { | ||||
|     // Update the info map for lookup | ||||
|     for (final p in participants) { | ||||
|       _participantInfoByIdentity[p.identity] = p; | ||||
|     } | ||||
|     if (_room == null) { | ||||
|       // Can't build live objects, just store empty | ||||
|       _participants = []; | ||||
|       state = state.copyWith(); | ||||
|       return; | ||||
|     } | ||||
|     final remoteParticipants = _room!.remoteParticipants; | ||||
|     final remotes = remoteParticipants.values.toList(); | ||||
|     _participants = []; | ||||
|     // Add local participant if present in the list | ||||
|     if (_localParticipant != null) { | ||||
|       final localInfo = _buildParticipant(participants: participants); | ||||
|       _participants.add( | ||||
|         CallParticipantLive( | ||||
|           participant: localInfo, | ||||
|           remoteParticipant: _localParticipant!, | ||||
|         ), | ||||
|       ); | ||||
|       state = state.copyWith(); | ||||
|     } | ||||
|     // Add remote participants | ||||
|     _participants.addAll( | ||||
|       participants.map((p) { | ||||
|         lk.RemoteParticipant? remote; | ||||
|         for (final r in remotes) { | ||||
|           if (r.identity == p.identity) { | ||||
|             remote = r; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (_localParticipant != null && | ||||
|             p.identity == _localParticipant!.identity) { | ||||
|           return null; // Already added local | ||||
|         } | ||||
|         return remote != null | ||||
|             ? CallParticipantLive(participant: p, remoteParticipant: remote) | ||||
|     // Always ensure local participant exists | ||||
|     final existingLocalParticipant = | ||||
|         _participants.isNotEmpty && | ||||
|                 _participants[0].remoteParticipant.id == _webrtcManager!.roomId | ||||
|             ? _participants[0] | ||||
|             : null; | ||||
|       }).whereType<CallParticipantLive>(), | ||||
|     ); | ||||
|  | ||||
|     final localParticipant = | ||||
|         existingLocalParticipant ?? _createLocalParticipant(); | ||||
|  | ||||
|     // Add remote participants | ||||
|     final remoteParticipants = | ||||
|         webrtcParticipants.map((p) { | ||||
|           final participantInfo = | ||||
|               _participantInfoByIdentity[p.id] ?? | ||||
|               CallParticipant( | ||||
|                 identity: p.id, | ||||
|                 name: p.name, | ||||
|                 accountId: p.userinfo.id, | ||||
|                 account: p.userinfo, | ||||
|                 joinedAt: DateTime.now(), | ||||
|               ); | ||||
|           return CallParticipantLive( | ||||
|             participant: participantInfo, | ||||
|             remoteParticipant: p, | ||||
|           ); | ||||
|         }).toList(); | ||||
|  | ||||
|     // Combine local participant with remote participants | ||||
|     _participants = [localParticipant, ...remoteParticipants]; | ||||
|  | ||||
|     state = state.copyWith(); | ||||
|   } | ||||
|  | ||||
|   String? _roomId; | ||||
|   String? get roomId => _roomId; | ||||
|   CallParticipantLive _createLocalParticipant() { | ||||
|     return CallParticipantLive( | ||||
|       participant: CallParticipant( | ||||
|         identity: _webrtcManager!.roomId, // Use roomId as local identity | ||||
|         name: 'You', | ||||
|         accountId: '', | ||||
|         account: null, | ||||
|         joinedAt: DateTime.now(), | ||||
|       ), | ||||
|       remoteParticipant: WebRTCParticipant( | ||||
|         id: _webrtcManager!.roomId, | ||||
|         name: 'You', | ||||
|         userinfo: SnAccount( | ||||
|           id: '', | ||||
|           name: '', | ||||
|           nick: '', | ||||
|           language: '', | ||||
|           isSuperuser: false, | ||||
|           automatedId: null, | ||||
|           profile: SnAccountProfile( | ||||
|             id: '', | ||||
|             firstName: '', | ||||
|             middleName: '', | ||||
|             lastName: '', | ||||
|             bio: '', | ||||
|             gender: '', | ||||
|             pronouns: '', | ||||
|             location: '', | ||||
|             timeZone: '', | ||||
|             links: [], | ||||
|             experience: 0, | ||||
|             level: 0, | ||||
|             socialCredits: 0, | ||||
|             socialCreditsLevel: 0, | ||||
|             levelingProgress: 0, | ||||
|             picture: null, | ||||
|             background: null, | ||||
|             verification: null, | ||||
|             usernameColor: null, | ||||
|             createdAt: DateTime.now(), | ||||
|             updatedAt: DateTime.now(), | ||||
|             deletedAt: null, | ||||
|           ), | ||||
|           perkSubscription: null, | ||||
|           createdAt: DateTime.now(), | ||||
|           updatedAt: DateTime.now(), | ||||
|           deletedAt: null, | ||||
|         ), | ||||
|       )..remoteStream = _webrtcManager!.localStream, // Access local stream | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<void> joinRoom(String roomId) async { | ||||
|     if (_roomId == roomId && _room != null) { | ||||
|       talker.info('[Call] Call skipped. Already has data'); | ||||
|       return; | ||||
|     } else if (_room != null) { | ||||
|       if (!_room!.isDisposed && | ||||
|           _room!.connectionState != lk.ConnectionState.disconnected) { | ||||
|         throw Exception('Call already connected'); | ||||
|     if (_roomId == roomId && _webrtcManager != null) { | ||||
|       talker.info('[Call] Call skipped. Already connected to this room'); | ||||
|       // Ensure state is connected even if we skip the join process | ||||
|       if (!state.isConnected) { | ||||
|         state = state.copyWith(isConnected: true); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|     _roomId = roomId; | ||||
|     if (_room != null) { | ||||
|       await _room!.disconnect(); | ||||
|       await _room!.dispose(); | ||||
|       _room = null; | ||||
|       _localParticipant = null; | ||||
|       _participants = []; | ||||
|     } | ||||
|  | ||||
|     // Clean up existing connection | ||||
|     await disconnect(); | ||||
|  | ||||
|     try { | ||||
|       final apiClient = ref.read(apiClientProvider); | ||||
|       final ongoingCall = await ref.read(ongoingCallProvider(roomId).future); | ||||
| @@ -241,8 +262,11 @@ class CallNotifier extends _$CallNotifier { | ||||
|         // Parse join response | ||||
|         final joinResponse = ChatRealtimeJoinResponse.fromJson(data); | ||||
|         final participants = joinResponse.participants; | ||||
|         final String endpoint = joinResponse.endpoint; | ||||
|         final String token = joinResponse.token; | ||||
|  | ||||
|         // Update participant info map | ||||
|         for (final p in participants) { | ||||
|           _participantInfoByIdentity[p.identity] = p; | ||||
|         } | ||||
|  | ||||
|         // Setup duration timer | ||||
|         _durationTimer?.cancel(); | ||||
| @@ -257,47 +281,18 @@ class CallNotifier extends _$CallNotifier { | ||||
|           ); | ||||
|         }); | ||||
|  | ||||
|         // Connect to LiveKit | ||||
|         _room = lk.Room(); | ||||
|         // Initialize WebRTC manager | ||||
|         final serverUrl = ref.watch(serverUrlProvider); | ||||
|  | ||||
|         await _room!.connect( | ||||
|           endpoint, | ||||
|           token, | ||||
|           connectOptions: lk.ConnectOptions(autoSubscribe: true), | ||||
|           roomOptions: lk.RoomOptions(adaptiveStream: true, dynacast: true), | ||||
|           fastConnectOptions: lk.FastConnectOptions( | ||||
|             microphone: lk.TrackOption(enabled: true), | ||||
|           ), | ||||
|         ); | ||||
|         _localParticipant = _room!.localParticipant; | ||||
|         _webrtcManager = WebRTCManager(roomId: roomId, serverUrl: serverUrl); | ||||
|  | ||||
|         _initRoomListeners(); | ||||
|         _updateLiveParticipants(participants); | ||||
|         await _webrtcManager!.initialize(ref); | ||||
|         _initWebRTCListeners(); | ||||
|  | ||||
|         if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) { | ||||
|           lk.Hardware.instance.setSpeakerphoneOn(true); | ||||
|           // TODO: Implement speakerphone control for WebRTC | ||||
|         } | ||||
|  | ||||
|         // Listen for connection updates | ||||
|         _room!.addListener(() { | ||||
|           final wasConnected = state.isConnected; | ||||
|           final isNowConnected = | ||||
|               _room!.connectionState == lk.ConnectionState.connected; | ||||
|           state = state.copyWith( | ||||
|             isConnected: isNowConnected, | ||||
|             isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(), | ||||
|             isCameraEnabled: _localParticipant!.isCameraEnabled(), | ||||
|             isScreenSharing: _localParticipant!.isScreenShareEnabled(), | ||||
|           ); | ||||
|           // Enable wakelock when call connects | ||||
|           if (!wasConnected && isNowConnected) { | ||||
|             WakelockPlus.enable(); | ||||
|           } | ||||
|           // Disable wakelock when call disconnects | ||||
|           else if (wasConnected && !isNowConnected) { | ||||
|             WakelockPlus.disable(); | ||||
|           } | ||||
|         }); | ||||
|         state = state.copyWith(isConnected: true); | ||||
|         // Enable wakelock when call connects | ||||
|         WakelockPlus.enable(); | ||||
| @@ -310,104 +305,114 @@ class CallNotifier extends _$CallNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<void> toggleMicrophone() async { | ||||
|     if (_localParticipant != null) { | ||||
|       const autostop = true; | ||||
|       final target = !_localParticipant!.isMicrophoneEnabled(); | ||||
|       state = state.copyWith(isMicrophoneEnabled: target); | ||||
|       if (target) { | ||||
|         await _localParticipant!.audioTrackPublications.firstOrNull?.unmute( | ||||
|           stopOnMute: autostop, | ||||
|         ); | ||||
|       } else { | ||||
|         await _localParticipant!.audioTrackPublications.firstOrNull?.mute( | ||||
|           stopOnMute: autostop, | ||||
|         ); | ||||
|       } | ||||
|       state = state.copyWith(); | ||||
|     final target = !state.isMicrophoneEnabled; | ||||
|     state = state.copyWith(isMicrophoneEnabled: target); | ||||
|     await _webrtcManager?.toggleMicrophone(target); | ||||
|  | ||||
|     // Update local participant's audio state | ||||
|     if (_participants.isNotEmpty) { | ||||
|       _participants[0].remoteParticipant.isAudioEnabled = target; | ||||
|       state = state.copyWith(); // Trigger UI update | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> toggleCamera() async { | ||||
|     if (_localParticipant != null) { | ||||
|       final target = !_localParticipant!.isCameraEnabled(); | ||||
|       state = state.copyWith(isCameraEnabled: target); | ||||
|       await _localParticipant!.setCameraEnabled(target); | ||||
|       state = state.copyWith(); | ||||
|     final target = !state.isCameraEnabled; | ||||
|     state = state.copyWith(isCameraEnabled: target); | ||||
|     await _webrtcManager?.toggleCamera(target); | ||||
|  | ||||
|     // Update local participant's video state | ||||
|     if (_participants.isNotEmpty) { | ||||
|       _participants[0].remoteParticipant.isVideoEnabled = target; | ||||
|       state = state.copyWith(); // Trigger UI update | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> toggleScreenShare(BuildContext context) async { | ||||
|     if (_localParticipant != null) { | ||||
|       final target = !_localParticipant!.isScreenShareEnabled(); | ||||
|       state = state.copyWith(isScreenSharing: target); | ||||
|     if (_webrtcManager == null) return; | ||||
|  | ||||
|       if (target && lk.lkPlatformIsDesktop()) { | ||||
|         try { | ||||
|           final source = await showDialog<DesktopCapturerSource>( | ||||
|             context: context, | ||||
|             builder: (context) => lk.ScreenSelectDialog(), | ||||
|           ); | ||||
|           if (source == null) { | ||||
|             return; | ||||
|           } | ||||
|           var track = await lk.LocalVideoTrack.createScreenShareTrack( | ||||
|             lk.ScreenShareCaptureOptions( | ||||
|               sourceId: source.id, | ||||
|               maxFrameRate: 30.0, | ||||
|               captureScreenAudio: true, | ||||
|             ), | ||||
|           ); | ||||
|           await _localParticipant!.publishVideoTrack(track); | ||||
|         } catch (err) { | ||||
|           showErrorAlert(err); | ||||
|         } | ||||
|         return; | ||||
|     try { | ||||
|       if (state.isScreenSharing) { | ||||
|         // Stop screen sharing - switch back to camera | ||||
|         await _webrtcManager!.toggleCamera(state.isCameraEnabled); | ||||
|         state = state.copyWith(isScreenSharing: false); | ||||
|       } else { | ||||
|         await _localParticipant!.setScreenShareEnabled(target); | ||||
|       } | ||||
|         // Start screen sharing | ||||
|         if (WebRTC.platformIsDesktop) { | ||||
|           // For desktop, we need to get screen capture source | ||||
|           // This would require implementing a screen selection dialog | ||||
|           // For now, just toggle the state | ||||
|           state = state.copyWith(isScreenSharing: true); | ||||
|         } else if (WebRTC.platformIsWeb) { | ||||
|           // For web, get display media directly | ||||
|           await navigator.mediaDevices.getDisplayMedia({ | ||||
|             'video': true, | ||||
|             'audio': | ||||
|                 false, // Screen sharing typically doesn't include system audio | ||||
|           }); | ||||
|  | ||||
|       state = state.copyWith(); | ||||
|           // Replace video track with screen sharing track | ||||
|           // This is a simplified implementation | ||||
|           state = state.copyWith(isScreenSharing: true); | ||||
|         } | ||||
|       } | ||||
|     } catch (e) { | ||||
|       talker.error('[Call] Screen sharing error: $e'); | ||||
|       state = state.copyWith(error: 'Failed to toggle screen sharing: $e'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> toggleSpeakerphone() async { | ||||
|     state = state.copyWith(isSpeakerphone: !state.isSpeakerphone); | ||||
|     await lk.Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone); | ||||
|     state = state.copyWith(); | ||||
|     if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) { | ||||
|       try { | ||||
|         // For mobile platforms, we can control audio routing | ||||
|         // This is a simplified implementation | ||||
|         final newSpeakerphoneState = !state.isSpeakerphone; | ||||
|         state = state.copyWith(isSpeakerphone: newSpeakerphoneState); | ||||
|  | ||||
|         // Note: Actual speakerphone control would require platform-specific code | ||||
|         // For a full implementation, you'd need to use platform channels | ||||
|         // to control audio routing on iOS/Android | ||||
|         talker.info('[Call] Speakerphone toggled to: $newSpeakerphoneState'); | ||||
|       } catch (e) { | ||||
|         talker.error('[Call] Speakerphone control error: $e'); | ||||
|         state = state.copyWith(error: 'Failed to toggle speakerphone: $e'); | ||||
|       } | ||||
|     } else { | ||||
|       // For web/desktop, speakerphone control is handled by the browser/OS | ||||
|       state = state.copyWith(isSpeakerphone: !state.isSpeakerphone); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> disconnect() async { | ||||
|     if (_room != null) { | ||||
|       await _room!.disconnect(); | ||||
|       state = state.copyWith( | ||||
|         isConnected: false, | ||||
|         isMicrophoneEnabled: false, | ||||
|         isCameraEnabled: false, | ||||
|         isScreenSharing: false, | ||||
|       ); | ||||
|       // Disable wakelock when call disconnects | ||||
|       WakelockPlus.disable(); | ||||
|     } | ||||
|     _webrtcManager?.dispose(); | ||||
|     _webrtcManager = null; | ||||
|     _participantJoinedSubscription?.cancel(); | ||||
|     _participantLeftSubscription?.cancel(); | ||||
|     _participants.clear(); | ||||
|     state = state.copyWith( | ||||
|       isConnected: false, | ||||
|       isMicrophoneEnabled: false, | ||||
|       isCameraEnabled: false, | ||||
|       isScreenSharing: false, | ||||
|     ); | ||||
|     // Disable wakelock when call disconnects | ||||
|     WakelockPlus.disable(); | ||||
|   } | ||||
|  | ||||
|   void setParticipantVolume(CallParticipantLive live, double volume) { | ||||
|     if (participantsVolumes[live.remoteParticipant.sid] == null) { | ||||
|       participantsVolumes[live.remoteParticipant.sid] = 1; | ||||
|     } | ||||
|     Helper.setVolume( | ||||
|       volume, | ||||
|       live | ||||
|           .remoteParticipant | ||||
|           .audioTrackPublications | ||||
|           .first | ||||
|           .track! | ||||
|           .mediaStreamTrack, | ||||
|     // Store volume setting for this participant | ||||
|     // Note: WebRTC doesn't have built-in per-participant volume control | ||||
|     // This is just storing the preference for UI purposes | ||||
|     // Actual volume control would need to be implemented at the audio rendering level | ||||
|     participantsVolumes[live.remoteParticipant.id] = volume.clamp(0.0, 1.0); | ||||
|     talker.info( | ||||
|       '[Call] Volume set to $volume for participant ${live.remoteParticipant.id}', | ||||
|     ); | ||||
|     participantsVolumes[live.remoteParticipant.sid] = volume; | ||||
|   } | ||||
|  | ||||
|   double getParticipantVolume(CallParticipantLive live) { | ||||
|     return participantsVolumes[live.remoteParticipant.sid] ?? 1; | ||||
|     return participantsVolumes[live.remoteParticipant.id] ?? 1.0; | ||||
|   } | ||||
|  | ||||
|   void dispose() { | ||||
| @@ -418,9 +423,10 @@ class CallNotifier extends _$CallNotifier { | ||||
|       isCameraEnabled: false, | ||||
|       isScreenSharing: false, | ||||
|     ); | ||||
|     _roomListener?.dispose(); | ||||
|     _room?.removeListener(_onRoomChange); | ||||
|     _room?.dispose(); | ||||
|     _participantJoinedSubscription?.cancel(); | ||||
|     _participantLeftSubscription?.cancel(); | ||||
|     _webrtcManager?.dispose(); | ||||
|     _webrtcManager = null; | ||||
|     _durationTimer?.cancel(); | ||||
|     _roomId = null; | ||||
|     participantsVolumes = {}; | ||||
|   | ||||
| @@ -295,7 +295,7 @@ as String?, | ||||
| /// @nodoc | ||||
| mixin _$CallParticipantLive implements DiagnosticableTreeMixin { | ||||
|  | ||||
|  CallParticipant get participant; lk.Participant get remoteParticipant; | ||||
|  CallParticipant get participant; WebRTCParticipant get remoteParticipant; | ||||
| /// Create a copy of CallParticipantLive | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -332,7 +332,7 @@ abstract mixin class $CallParticipantLiveCopyWith<$Res>  { | ||||
|   factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  CallParticipant participant, lk.Participant remoteParticipant | ||||
|  CallParticipant participant, WebRTCParticipant remoteParticipant | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -353,7 +353,7 @@ class _$CallParticipantLiveCopyWithImpl<$Res> | ||||
|   return _then(_self.copyWith( | ||||
| participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable | ||||
| as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable | ||||
| as lk.Participant, | ||||
| as WebRTCParticipant, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of CallParticipantLive | ||||
| @@ -444,7 +444,7 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( CallParticipant participant,  lk.Participant remoteParticipant)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( CallParticipant participant,  WebRTCParticipant remoteParticipant)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallParticipantLive() when $default != null: | ||||
| return $default(_that.participant,_that.remoteParticipant);case _: | ||||
| @@ -465,7 +465,7 @@ return $default(_that.participant,_that.remoteParticipant);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( CallParticipant participant,  lk.Participant remoteParticipant)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( CallParticipant participant,  WebRTCParticipant remoteParticipant)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallParticipantLive(): | ||||
| return $default(_that.participant,_that.remoteParticipant);} | ||||
| @@ -482,7 +482,7 @@ return $default(_that.participant,_that.remoteParticipant);} | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( CallParticipant participant,  lk.Participant remoteParticipant)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( CallParticipant participant,  WebRTCParticipant remoteParticipant)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallParticipantLive() when $default != null: | ||||
| return $default(_that.participant,_that.remoteParticipant);case _: | ||||
| @@ -501,7 +501,7 @@ class _CallParticipantLive extends CallParticipantLive with DiagnosticableTreeMi | ||||
|    | ||||
|  | ||||
| @override final  CallParticipant participant; | ||||
| @override final  lk.Participant remoteParticipant; | ||||
| @override final  WebRTCParticipant remoteParticipant; | ||||
|  | ||||
| /// Create a copy of CallParticipantLive | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -539,7 +539,7 @@ abstract mixin class _$CallParticipantLiveCopyWith<$Res> implements $CallPartici | ||||
|   factory _$CallParticipantLiveCopyWith(_CallParticipantLive value, $Res Function(_CallParticipantLive) _then) = __$CallParticipantLiveCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  CallParticipant participant, lk.Participant remoteParticipant | ||||
|  CallParticipant participant, WebRTCParticipant remoteParticipant | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -560,7 +560,7 @@ class __$CallParticipantLiveCopyWithImpl<$Res> | ||||
|   return _then(_CallParticipantLive( | ||||
| participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable | ||||
| as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable | ||||
| as lk.Participant, | ||||
| as WebRTCParticipant, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -6,7 +6,7 @@ part of 'call.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281'; | ||||
| String _$callNotifierHash() => r'4015d326388553c46859fe537e84d2c9da4236c9'; | ||||
|  | ||||
| /// See also [CallNotifier]. | ||||
| @ProviderFor(CallNotifier) | ||||
|   | ||||
							
								
								
									
										476
									
								
								lib/pods/chat/webrtc_manager.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										476
									
								
								lib/pods/chat/webrtc_manager.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,476 @@ | ||||
| import 'dart:async'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/chat/webrtc_signaling.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/talker.dart'; | ||||
|  | ||||
| class WebRTCParticipant { | ||||
|   final String id; | ||||
|   final String name; | ||||
|   final SnAccount userinfo; | ||||
|   RTCPeerConnection? peerConnection; | ||||
|   MediaStream? remoteStream; | ||||
|   List<RTCIceCandidate> remoteCandidates = []; | ||||
|   bool isAudioEnabled = true; | ||||
|   bool isVideoEnabled = false; | ||||
|   bool isConnected = false; | ||||
|   bool isLocal = false; | ||||
|   double audioLevel = 0.0; | ||||
|  | ||||
|   WebRTCParticipant({ | ||||
|     required this.id, | ||||
|     required this.name, | ||||
|     required this.userinfo, | ||||
|     this.isAudioEnabled = true, | ||||
|     this.isVideoEnabled = false, | ||||
|     this.isLocal = false, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class WebRTCManager { | ||||
|   final String roomId; | ||||
|   final String serverUrl; | ||||
|   late WebRTCSignaling _signaling; | ||||
|   final Map<String, WebRTCParticipant> _participants = {}; | ||||
|   final Map<String, RTCPeerConnection> _peerConnections = {}; | ||||
|  | ||||
|   MediaStream? _localStream; | ||||
|   Timer? _audioLevelTimer; | ||||
|  | ||||
|   MediaStream? get localStream => _localStream; | ||||
|   final StreamController<WebRTCParticipant> _participantController = | ||||
|       StreamController<WebRTCParticipant>.broadcast(); | ||||
|   final StreamController<String> _participantLeftController = | ||||
|       StreamController<String>.broadcast(); | ||||
|  | ||||
|   Stream<WebRTCParticipant> get onParticipantJoined => | ||||
|       _participantController.stream; | ||||
|   Stream<String> get onParticipantLeft => _participantLeftController.stream; | ||||
|  | ||||
|   WebRTCManager({required this.roomId, required this.serverUrl}) { | ||||
|     _signaling = WebRTCSignaling(roomId: roomId); | ||||
|   } | ||||
|  | ||||
|   Future<void> initialize(Ref ref) async { | ||||
|     final user = ref.watch(userInfoProvider).value!; | ||||
|     _signaling.userId = user.id; | ||||
|     _signaling.userName = user.name; | ||||
|     _signaling.user = user; | ||||
|     await _initializeLocalStream(); | ||||
|     _setupSignalingListeners(); | ||||
|     await _signaling.connect(ref); | ||||
|     _startAudioLevelMonitoring(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _initializeLocalStream() async { | ||||
|     try { | ||||
|       _localStream = await navigator.mediaDevices.getUserMedia({ | ||||
|         'audio': true, | ||||
|         'video': true, | ||||
|       }); | ||||
|       talker.info('[WebRTC] Local stream initialized'); | ||||
|     } catch (e) { | ||||
|       talker.error('[WebRTC] Failed to initialize local stream: $e'); | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _setupSignalingListeners() { | ||||
|     _signaling.messages.listen((message) async { | ||||
|       switch (message.type) { | ||||
|         case 'offer': | ||||
|           await _handleOffer(message.accountId, message.account, message.data); | ||||
|           break; | ||||
|         case 'answer': | ||||
|           await _handleAnswer(message.accountId, message.data); | ||||
|           break; | ||||
|         case 'ice-candidate': | ||||
|           await _handleIceCandidate(message.accountId, message.data); | ||||
|           break; | ||||
|         // CHANGED: Listen for new users joining the room. | ||||
|         case 'user-joined': | ||||
|           await _handleUserJoined(message.accountId, message.account); | ||||
|           break; | ||||
|         default: | ||||
|           talker.warning( | ||||
|             '[WebRTC Manager] Receieved an unknown type singaling message: ${message.type} with ${message.data}', | ||||
|           ); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // CHANGED: The welcome message now drives connection initiation. | ||||
|     _signaling.welcomeMessages.listen((welcome) { | ||||
|       talker.info('[WebRTC Manager] Connected to room: ${welcome.roomId}'); | ||||
|       final existingParticipants = | ||||
|           welcome.participants; // Assuming the server sends this. | ||||
|       talker.info( | ||||
|         '[WebRTC Manager] Existing participants: $existingParticipants', | ||||
|       ); | ||||
|  | ||||
|       // The newcomer is responsible for initiating the connection to everyone else. | ||||
|       for (final participant in existingParticipants) { | ||||
|         if (participant.identity != _signaling.userId) { | ||||
|           if (!_participants.containsKey(participant.identity)) { | ||||
|             final webrtcParticipant = WebRTCParticipant( | ||||
|               id: participant.identity, | ||||
|               name: participant.name, | ||||
|               userinfo: participant.account!, | ||||
|             ); | ||||
|             _participants[participant.identity] = webrtcParticipant; | ||||
|             _participantController.add(webrtcParticipant); | ||||
|           } | ||||
|           _createPeerConnection(participant.identity, isInitiator: true); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // CHANGED: New handler for when an existing user is notified of a new peer. | ||||
|   Future<void> _handleUserJoined( | ||||
|     String participantId, | ||||
|     SnAccount account, | ||||
|   ) async { | ||||
|     talker.info( | ||||
|       '[WebRTC Manager] User joined: $participantId. Waiting for their offer.', | ||||
|     ); | ||||
|     // We don't need to be the initiator here. The newcomer will send us an offer. | ||||
|     // We just create the peer connection to be ready for it. | ||||
|     if (!_peerConnections.containsKey(participantId)) { | ||||
|       // Create a participant object to represent the new user | ||||
|       if (!_participants.containsKey(participantId)) { | ||||
|         final participant = WebRTCParticipant( | ||||
|           id: participantId, | ||||
|           name: participantId, | ||||
|           userinfo: account, | ||||
|         ); // Placeholder name | ||||
|         _participants[participantId] = participant; | ||||
|         _participantController.add(participant); | ||||
|       } | ||||
|       await _createPeerConnection(participantId, isInitiator: false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _createPeerConnection( | ||||
|     String participantId, { | ||||
|     bool isInitiator = false, | ||||
|   }) async { | ||||
|     talker.info( | ||||
|       '[WebRTC] Creating peer connection to $participantId (initiator: $isInitiator)', | ||||
|     ); | ||||
|     final configuration = { | ||||
|       'iceServers': [ | ||||
|         {'urls': 'stun:stun.l.google.com:19302'}, | ||||
|       ], | ||||
|     }; | ||||
|  | ||||
|     final peerConnection = await createPeerConnection(configuration); | ||||
|     _peerConnections[participantId] = peerConnection; | ||||
|     _participants[participantId]!.peerConnection = peerConnection; | ||||
|  | ||||
|     if (_localStream != null) { | ||||
|       for (final track in _localStream!.getTracks()) { | ||||
|         await peerConnection.addTrack(track, _localStream!); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     peerConnection.onTrack = (event) { | ||||
|       if (event.streams.isNotEmpty) { | ||||
|         final participant = _participants[participantId]; | ||||
|         if (participant != null) { | ||||
|           participant.remoteStream = event.streams[0]; | ||||
|           participant.isConnected = true; | ||||
|  | ||||
|           // Detect video tracks and update video enabled state | ||||
|           final videoTracks = event.streams[0].getVideoTracks(); | ||||
|           if (videoTracks.isNotEmpty) { | ||||
|             participant.isVideoEnabled = true; | ||||
|           } | ||||
|  | ||||
|           _participantController.add(participant); | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     peerConnection.onIceCandidate = (candidate) { | ||||
|       // CHANGED: Send candidate to the specific participant | ||||
|       _signaling.sendIceCandidate(participantId, candidate); | ||||
|     }; | ||||
|  | ||||
|     peerConnection.onConnectionState = (state) { | ||||
|       talker.info('[WebRTC] Connection state for $participantId: $state'); | ||||
|       final participant = _participants[participantId]; | ||||
|       if (participant != null) { | ||||
|         participant.isConnected = | ||||
|             state == RTCPeerConnectionState.RTCPeerConnectionStateConnected; | ||||
|         _participantController.add(participant); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     if (isInitiator) { | ||||
|       final offer = await peerConnection.createOffer(); | ||||
|       await peerConnection.setLocalDescription(offer); | ||||
|       // CHANGED: Send offer to the specific participant | ||||
|       _signaling.sendOffer(participantId, offer); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _handleOffer( | ||||
|     String from, | ||||
|     SnAccount account, | ||||
|     Map<String, dynamic> data, | ||||
|   ) async { | ||||
|     final participantId = from; | ||||
|     talker.info('[WebRTC Manager] Handling offer from $participantId'); | ||||
|     final offer = RTCSessionDescription(data['sdp'], data['type']); | ||||
|  | ||||
|     if (!_peerConnections.containsKey(participantId)) { | ||||
|       if (!_participants.containsKey(participantId)) { | ||||
|         final participant = WebRTCParticipant( | ||||
|           id: participantId, | ||||
|           name: participantId, | ||||
|           userinfo: account, | ||||
|         ); | ||||
|         _participants[participantId] = participant; | ||||
|         _participantController.add(participant); | ||||
|       } | ||||
|       await _createPeerConnection(participantId, isInitiator: false); | ||||
|     } | ||||
|  | ||||
|     final peerConnection = _peerConnections[participantId]!; | ||||
|     await peerConnection.setRemoteDescription(offer); | ||||
|  | ||||
|     final answer = await peerConnection.createAnswer(); | ||||
|     await peerConnection.setLocalDescription(answer); | ||||
|     // CHANGED: Send answer to the specific participant | ||||
|     _signaling.sendAnswer(participantId, answer); | ||||
|  | ||||
|     // Process any queued ICE candidates | ||||
|     final participant = _participants[participantId]; | ||||
|     if (participant != null) { | ||||
|       for (final candidate in participant.remoteCandidates) { | ||||
|         await peerConnection.addCandidate(candidate); | ||||
|       } | ||||
|       participant.remoteCandidates.clear(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _handleAnswer(String from, Map<String, dynamic> data) async { | ||||
|     final participantId = from; | ||||
|     talker.info('[WebRTC Manager] Handling answer from $participantId'); | ||||
|     final answer = RTCSessionDescription(data['sdp'], data['type']); | ||||
|  | ||||
|     final peerConnection = _peerConnections[participantId]; | ||||
|     if (peerConnection != null) { | ||||
|       await peerConnection.setRemoteDescription(answer); | ||||
|  | ||||
|       // Process any queued ICE candidates | ||||
|       final participant = _participants[participantId]; | ||||
|       if (participant != null) { | ||||
|         for (final candidate in participant.remoteCandidates) { | ||||
|           await peerConnection.addCandidate(candidate); | ||||
|         } | ||||
|         participant.remoteCandidates.clear(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _handleIceCandidate( | ||||
|     String from, | ||||
|     Map<String, dynamic> data, | ||||
|   ) async { | ||||
|     final participantId = from; | ||||
|     final candidate = RTCIceCandidate( | ||||
|       data['candidate'], | ||||
|       data['sdpMid'], | ||||
|       data['sdpMLineIndex'], | ||||
|     ); | ||||
|  | ||||
|     final participant = _participants[participantId]; | ||||
|     if (participant != null) { | ||||
|       final pc = participant.peerConnection; | ||||
|       if (pc != null) { | ||||
|         await pc.addCandidate(candidate); | ||||
|       } else { | ||||
|         participant.remoteCandidates.add(candidate); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> replaceMediaStream(Map<String, dynamic> constraints) async { | ||||
|     try { | ||||
|       final newStream = await navigator.mediaDevices.getUserMedia(constraints); | ||||
|       final newVideoTrack = newStream.getVideoTracks().firstOrNull; | ||||
|       final newAudioTrack = newStream.getAudioTracks().firstOrNull; | ||||
|  | ||||
|       if (_localStream != null) { | ||||
|         final oldVideoTrack = _localStream!.getVideoTracks().firstOrNull; | ||||
|         final oldAudioTrack = _localStream!.getAudioTracks().firstOrNull; | ||||
|  | ||||
|         // Replace tracks in all existing peer connections | ||||
|         for (final pc in _peerConnections.values) { | ||||
|           final senders = await pc.getSenders(); | ||||
|           for (final sender in senders) { | ||||
|             if (newVideoTrack != null && sender.track == oldVideoTrack) { | ||||
|               await sender.replaceTrack(newVideoTrack); | ||||
|             } else if (newAudioTrack != null && sender.track == oldAudioTrack) { | ||||
|               await sender.replaceTrack(newAudioTrack); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // Stop old tracks and update local stream | ||||
|         for (final track in _localStream!.getTracks()) { | ||||
|           track.stop(); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       _localStream = newStream; | ||||
|       talker.info('[WebRTC] Media stream replaced with new constraints'); | ||||
|     } catch (e) { | ||||
|       talker.error('[WebRTC] Failed to replace media stream: $e'); | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> toggleMicrophone(bool enabled) async { | ||||
|     if (_localStream != null) { | ||||
|       final audioTracks = _localStream!.getAudioTracks(); | ||||
|       for (final track in audioTracks) { | ||||
|         track.enabled = enabled; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> toggleCamera(bool enabled) async { | ||||
|     if (_localStream != null) { | ||||
|       _localStream!.getVideoTracks().forEach((track) { | ||||
|         track.enabled = enabled; | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> switchCamera(String deviceId) async { | ||||
|     await replaceMediaStream({ | ||||
|       'audio': _localStream?.getAudioTracks().isNotEmpty ?? true, | ||||
|       'video': {'deviceId': deviceId}, | ||||
|     }); | ||||
|     talker.info('[WebRTC] Switched to camera device: $deviceId'); | ||||
|   } | ||||
|  | ||||
|   Future<void> switchMicrophone(String deviceId) async { | ||||
|     await replaceMediaStream({ | ||||
|       'audio': {'deviceId': deviceId}, | ||||
|       'video': _localStream?.getVideoTracks().isNotEmpty ?? true, | ||||
|     }); | ||||
|     talker.info('[WebRTC] Switched to microphone device: $deviceId'); | ||||
|   } | ||||
|  | ||||
|   Future<List<MediaDeviceInfo>> getVideoDevices() async { | ||||
|     try { | ||||
|       final devices = await navigator.mediaDevices.enumerateDevices(); | ||||
|       return devices.where((device) => device.kind == 'videoinput').toList(); | ||||
|     } catch (e) { | ||||
|       talker.error('[WebRTC] Failed to enumerate video devices: $e'); | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<List<MediaDeviceInfo>> getAudioDevices() async { | ||||
|     try { | ||||
|       final devices = await navigator.mediaDevices.enumerateDevices(); | ||||
|       return devices.where((device) => device.kind == 'audioinput').toList(); | ||||
|     } catch (e) { | ||||
|       talker.error('[WebRTC] Failed to enumerate audio devices: $e'); | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _startAudioLevelMonitoring() { | ||||
|     _audioLevelTimer?.cancel(); | ||||
|     _audioLevelTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { | ||||
|       _updateAudioLevels(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   void _stopAudioLevelMonitoring() { | ||||
|     _audioLevelTimer?.cancel(); | ||||
|     _audioLevelTimer = null; | ||||
|   } | ||||
|  | ||||
|   Future<void> _updateAudioLevels() async { | ||||
|     bool hasUpdates = false; | ||||
|  | ||||
|     for (final participant in _participants.values) { | ||||
|       if (participant.remoteStream != null && participant.isAudioEnabled) { | ||||
|         final audioTracks = participant.remoteStream!.getAudioTracks(); | ||||
|         if (audioTracks.isNotEmpty) { | ||||
|           try { | ||||
|             // Try to get stats for more accurate audio level detection | ||||
|             final pc = participant.peerConnection; | ||||
|             if (pc != null) { | ||||
|               final stats = await pc.getStats(); | ||||
|               double maxAudioLevel = 0.0; | ||||
|  | ||||
|               // Look for audio receiver stats | ||||
|               for (var report in stats) { | ||||
|                 if (report.type == 'inbound-rtp' && | ||||
|                     report.values['mediaType'] == 'audio') { | ||||
|                   final audioLevel = report.values['audioLevel'] as double?; | ||||
|                   if (audioLevel != null && audioLevel > maxAudioLevel) { | ||||
|                     maxAudioLevel = audioLevel; | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|  | ||||
|               // If we got stats, use them; otherwise use a simple heuristic | ||||
|               if (maxAudioLevel > 0) { | ||||
|                 participant.audioLevel = maxAudioLevel.clamp(0.0, 1.0); | ||||
|               } else { | ||||
|                 // Simple heuristic: if audio track is enabled, assume some level | ||||
|                 // In a real app, you'd analyze the actual audio data | ||||
|                 participant.audioLevel = audioTracks[0].enabled ? 0.5 : 0.0; | ||||
|               } | ||||
|             } else { | ||||
|               // Fallback for local participant or when no PC available | ||||
|               participant.audioLevel = participant.isLocal ? 0.0 : 0.3; | ||||
|             } | ||||
|  | ||||
|             hasUpdates = true; | ||||
|           } catch (e) { | ||||
|             talker.warning('[WebRTC] Failed to update audio level for ${participant.id}: $e'); | ||||
|             participant.audioLevel = 0.0; | ||||
|           } | ||||
|         } else { | ||||
|           participant.audioLevel = 0.0; | ||||
|         } | ||||
|       } else { | ||||
|         participant.audioLevel = 0.0; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Notify listeners if there were updates (throttled to avoid excessive updates) | ||||
|     if (hasUpdates) { | ||||
|       // This will trigger UI updates for speaking indicators | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   List<WebRTCParticipant> get participants => _participants.values.toList(); | ||||
|  | ||||
|   void dispose() { | ||||
|     _stopAudioLevelMonitoring(); | ||||
|     _signaling.disconnect(); | ||||
|     for (final pc in _peerConnections.values) { | ||||
|       pc.close(); | ||||
|     } | ||||
|     _peerConnections.clear(); | ||||
|     for (var p in _participants.values) { | ||||
|       p.remoteCandidates.clear(); | ||||
|     } | ||||
|     _participants.clear(); | ||||
|     _localStream?.dispose(); | ||||
|     _participantController.close(); | ||||
|     _participantLeftController.close(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										211
									
								
								lib/pods/chat/webrtc_signaling.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										211
									
								
								lib/pods/chat/webrtc_signaling.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,211 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:web_socket_channel/io.dart'; | ||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | ||||
| import 'package:island/talker.dart'; | ||||
|  | ||||
| part 'webrtc_signaling.freezed.dart'; | ||||
| part 'webrtc_signaling.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class SignalingMessage with _$SignalingMessage { | ||||
|   const factory SignalingMessage({ | ||||
|     required String type, | ||||
|     // CHANGED: Added 'to' field for directed messaging | ||||
|     String? to, | ||||
|     required String accountId, | ||||
|     required SnAccount account, | ||||
|     required Map<String, dynamic> data, | ||||
|   }) = _SignalingMessage; | ||||
|  | ||||
|   factory SignalingMessage.fromJson(Map<String, dynamic> json) => | ||||
|       _$SignalingMessageFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class WebRTCWelcomeMessage with _$WebRTCWelcomeMessage { | ||||
|   const factory WebRTCWelcomeMessage({ | ||||
|     required String userId, | ||||
|     required String roomId, | ||||
|     required String message, | ||||
|     required String timestamp, | ||||
|     // CHANGED: Added participants list | ||||
|     @Default([]) List<CallParticipant> participants, | ||||
|   }) = _WebRTCWelcomeMessage; | ||||
|  | ||||
|   factory WebRTCWelcomeMessage.fromJson(Map<String, dynamic> json) => | ||||
|       _$WebRTCWelcomeMessageFromJson(json); | ||||
| } | ||||
|  | ||||
| class WebRTCSignaling { | ||||
|   final String roomId; | ||||
|   late final String userId; | ||||
|   late final String userName; | ||||
|   late SnAccount user; | ||||
|   final StreamController<SignalingMessage> _messageController = | ||||
|       StreamController<SignalingMessage>.broadcast(); | ||||
|   final StreamController<WebRTCWelcomeMessage> _welcomeController = | ||||
|       StreamController<WebRTCWelcomeMessage>.broadcast(); | ||||
|   WebSocketChannel? _channel; | ||||
|   Timer? _heartbeatTimer; | ||||
|  | ||||
|   Stream<SignalingMessage> get messages => _messageController.stream; | ||||
|   Stream<WebRTCWelcomeMessage> get welcomeMessages => _welcomeController.stream; | ||||
|  | ||||
|   WebRTCSignaling({required this.roomId}); | ||||
|  | ||||
|   Future<void> connect(Ref ref) async { | ||||
|     final baseUrl = ref.watch(serverUrlProvider); | ||||
|     final token = await getToken(ref.watch(tokenProvider)); | ||||
|  | ||||
|     final url = '$baseUrl/sphere/chat/realtime/$roomId'.replaceFirst( | ||||
|       'http', | ||||
|       'ws', | ||||
|     ); | ||||
|  | ||||
|     talker.info('[WebRTC Signaling] Trying connecting to $url'); | ||||
|     try { | ||||
|       if (kIsWeb) { | ||||
|         _channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token')); | ||||
|       } else { | ||||
|         _channel = IOWebSocketChannel.connect( | ||||
|           Uri.parse(url), | ||||
|           headers: {'Authorization': 'AtField $token'}, | ||||
|         ); | ||||
|       } | ||||
|       await _channel!.ready; | ||||
|  | ||||
|       // Start heartbeat timer | ||||
|       _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) => _sendHeartbeat()); | ||||
|  | ||||
|       _channel!.stream.listen( | ||||
|         (data) { | ||||
|           final dataStr = | ||||
|               data is Uint8List ? utf8.decode(data) : data.toString(); | ||||
|           final packet = WebSocketPacket.fromJson(jsonDecode(dataStr)); | ||||
|           talker.info( | ||||
|             '[WebRTC Signaling] Recieved a singal message with packet type: ${packet.type}', | ||||
|           ); | ||||
|           if (packet.type == 'webrtc') { | ||||
|             try { | ||||
|               final welcomeMessage = WebRTCWelcomeMessage.fromJson( | ||||
|                 packet.data!, | ||||
|               ); | ||||
|               _welcomeController.add(welcomeMessage); | ||||
|               talker.info( | ||||
|                 '[WebRTC Signaling] Welcome message received: ${welcomeMessage.message}', | ||||
|               ); | ||||
|             } catch (e) { | ||||
|               talker.error( | ||||
|                 '[WebRTC Signaling] Failed to parse welcome message: $e', | ||||
|               ); | ||||
|             } | ||||
|           } else if (packet.type == 'webrtc.signal') { | ||||
|             try { | ||||
|               final signalingMessage = SignalingMessage.fromJson(packet.data!); | ||||
|               // CHANGED: Ensure we only process messages intended for us if the 'to' field is present | ||||
|               if (signalingMessage.to == null || | ||||
|                   signalingMessage.to == userId) { | ||||
|                 _messageController.add(signalingMessage); | ||||
|               } | ||||
|             } catch (e) { | ||||
|               talker.error( | ||||
|                 '[WebRTC Signaling] Failed to parse signaling message: $e', | ||||
|               ); | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         onError: (error) { | ||||
|           talker.error('[WebRTC Signaling] WebSocket error: $error'); | ||||
|           _messageController.addError(error); | ||||
|           _welcomeController.addError(error); | ||||
|         }, | ||||
|         onDone: () { | ||||
|           talker.info('[WebRTC Signaling] WebSocket connection closed'); | ||||
|           _messageController.close(); | ||||
|           _welcomeController.close(); | ||||
|         }, | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       talker.error('[WebRTC Signaling] Failed to connect: $err'); | ||||
|       _messageController.addError(err); | ||||
|       _welcomeController.addError(err); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void sendMessage(SignalingMessage message) { | ||||
|     if (_channel == null) return; | ||||
|     talker.info( | ||||
|       '[WebRTC Signaling] Sending a message with message type: ${message.type} to ${message.to}', | ||||
|     ); | ||||
|     final packet = WebSocketPacket( | ||||
|       type: 'webrtc.signal', | ||||
|       data: message.toJson(), | ||||
|     ); | ||||
|     _channel!.sink.add(jsonEncode(packet.toJson())); | ||||
|   } | ||||
|  | ||||
|   // CHANGED: All send methods now correctly use the `to` parameter | ||||
|   void sendOffer(String to, RTCSessionDescription offer) { | ||||
|     sendMessage( | ||||
|       SignalingMessage( | ||||
|         type: 'offer', | ||||
|         to: to, | ||||
|         accountId: userId, | ||||
|         account: user, | ||||
|         data: {'sdp': offer.sdp, 'type': offer.type}, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void sendAnswer(String to, RTCSessionDescription answer) { | ||||
|     sendMessage( | ||||
|       SignalingMessage( | ||||
|         type: 'answer', | ||||
|         to: to, | ||||
|         accountId: userId, | ||||
|         account: user, | ||||
|         data: {'sdp': answer.sdp, 'type': answer.type}, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void sendIceCandidate(String to, RTCIceCandidate candidate) { | ||||
|     sendMessage( | ||||
|       SignalingMessage( | ||||
|         type: 'ice-candidate', | ||||
|         to: to, | ||||
|         accountId: userId, | ||||
|         account: user, | ||||
|         data: { | ||||
|           'candidate': candidate.candidate, | ||||
|           'sdpMid': candidate.sdpMid, | ||||
|           'sdpMLineIndex': candidate.sdpMLineIndex, | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _sendHeartbeat() { | ||||
|     if (_channel == null) return; | ||||
|     talker.info('[WebRTC Signaling] Sending heartbeat'); | ||||
|     final packet = WebSocketPacket(type: 'heartbeat', data: null); | ||||
|     _channel!.sink.add(jsonEncode(packet.toJson())); | ||||
|   } | ||||
|  | ||||
|   void disconnect() { | ||||
|     _heartbeatTimer?.cancel(); | ||||
|     _channel?.sink.close(); | ||||
|     _messageController.close(); | ||||
|     _welcomeController.close(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										611
									
								
								lib/pods/chat/webrtc_signaling.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										611
									
								
								lib/pods/chat/webrtc_signaling.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,611 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // coverage:ignore-file | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'webrtc_signaling.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SignalingMessage implements DiagnosticableTreeMixin { | ||||
|  | ||||
|  String get type;// CHANGED: Added 'to' field for directed messaging | ||||
|  String? get to; String get accountId; SnAccount get account; Map<String, dynamic> get data; | ||||
| /// Create a copy of SignalingMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SignalingMessageCopyWith<SignalingMessage> get copyWith => _$SignalingMessageCopyWithImpl<SignalingMessage>(this as SignalingMessage, _$identity); | ||||
|  | ||||
|   /// Serializes this SignalingMessage to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'SignalingMessage')) | ||||
|     ..add(DiagnosticsProperty('type', type))..add(DiagnosticsProperty('to', to))..add(DiagnosticsProperty('accountId', accountId))..add(DiagnosticsProperty('account', account))..add(DiagnosticsProperty('data', data)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SignalingMessage&&(identical(other.type, type) || other.type == type)&&(identical(other.to, to) || other.to == to)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&const DeepCollectionEquality().equals(other.data, data)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,type,to,accountId,account,const DeepCollectionEquality().hash(data)); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'SignalingMessage(type: $type, to: $to, accountId: $accountId, account: $account, data: $data)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $SignalingMessageCopyWith<$Res>  { | ||||
|   factory $SignalingMessageCopyWith(SignalingMessage value, $Res Function(SignalingMessage) _then) = _$SignalingMessageCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String type, String? to, String accountId, SnAccount account, Map<String, dynamic> data | ||||
| }); | ||||
|  | ||||
|  | ||||
| $SnAccountCopyWith<$Res> get account; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SignalingMessageCopyWithImpl<$Res> | ||||
|     implements $SignalingMessageCopyWith<$Res> { | ||||
|   _$SignalingMessageCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final SignalingMessage _self; | ||||
|   final $Res Function(SignalingMessage) _then; | ||||
|  | ||||
| /// Create a copy of SignalingMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? to = freezed,Object? accountId = null,Object? account = null,Object? data = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as String,to: freezed == to ? _self.to : to // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccount,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of SignalingMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAccountCopyWith<$Res> get account { | ||||
|    | ||||
|   return $SnAccountCopyWith<$Res>(_self.account, (value) { | ||||
|     return _then(_self.copyWith(account: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Adds pattern-matching-related methods to [SignalingMessage]. | ||||
| extension SignalingMessagePatterns on SignalingMessage { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SignalingMessage value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SignalingMessage() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SignalingMessage value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SignalingMessage(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SignalingMessage value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SignalingMessage() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type,  String? to,  String accountId,  SnAccount account,  Map<String, dynamic> data)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SignalingMessage() when $default != null: | ||||
| return $default(_that.type,_that.to,_that.accountId,_that.account,_that.data);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type,  String? to,  String accountId,  SnAccount account,  Map<String, dynamic> data)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SignalingMessage(): | ||||
| return $default(_that.type,_that.to,_that.accountId,_that.account,_that.data);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type,  String? to,  String accountId,  SnAccount account,  Map<String, dynamic> data)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SignalingMessage() when $default != null: | ||||
| return $default(_that.type,_that.to,_that.accountId,_that.account,_that.data);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SignalingMessage with DiagnosticableTreeMixin implements SignalingMessage { | ||||
|   const _SignalingMessage({required this.type, this.to, required this.accountId, required this.account, required final  Map<String, dynamic> data}): _data = data; | ||||
|   factory _SignalingMessage.fromJson(Map<String, dynamic> json) => _$SignalingMessageFromJson(json); | ||||
|  | ||||
| @override final  String type; | ||||
| // CHANGED: Added 'to' field for directed messaging | ||||
| @override final  String? to; | ||||
| @override final  String accountId; | ||||
| @override final  SnAccount account; | ||||
|  final  Map<String, dynamic> _data; | ||||
| @override Map<String, dynamic> get data { | ||||
|   if (_data is EqualUnmodifiableMapView) return _data; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(_data); | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Create a copy of SignalingMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SignalingMessageCopyWith<_SignalingMessage> get copyWith => __$SignalingMessageCopyWithImpl<_SignalingMessage>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SignalingMessageToJson(this, ); | ||||
| } | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'SignalingMessage')) | ||||
|     ..add(DiagnosticsProperty('type', type))..add(DiagnosticsProperty('to', to))..add(DiagnosticsProperty('accountId', accountId))..add(DiagnosticsProperty('account', account))..add(DiagnosticsProperty('data', data)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SignalingMessage&&(identical(other.type, type) || other.type == type)&&(identical(other.to, to) || other.to == to)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&const DeepCollectionEquality().equals(other._data, _data)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,type,to,accountId,account,const DeepCollectionEquality().hash(_data)); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'SignalingMessage(type: $type, to: $to, accountId: $accountId, account: $account, data: $data)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$SignalingMessageCopyWith<$Res> implements $SignalingMessageCopyWith<$Res> { | ||||
|   factory _$SignalingMessageCopyWith(_SignalingMessage value, $Res Function(_SignalingMessage) _then) = __$SignalingMessageCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String type, String? to, String accountId, SnAccount account, Map<String, dynamic> data | ||||
| }); | ||||
|  | ||||
|  | ||||
| @override $SnAccountCopyWith<$Res> get account; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SignalingMessageCopyWithImpl<$Res> | ||||
|     implements _$SignalingMessageCopyWith<$Res> { | ||||
|   __$SignalingMessageCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _SignalingMessage _self; | ||||
|   final $Res Function(_SignalingMessage) _then; | ||||
|  | ||||
| /// Create a copy of SignalingMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? to = freezed,Object? accountId = null,Object? account = null,Object? data = null,}) { | ||||
|   return _then(_SignalingMessage( | ||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as String,to: freezed == to ? _self.to : to // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccount,data: null == data ? _self._data : data // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of SignalingMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAccountCopyWith<$Res> get account { | ||||
|    | ||||
|   return $SnAccountCopyWith<$Res>(_self.account, (value) { | ||||
|     return _then(_self.copyWith(account: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$WebRTCWelcomeMessage implements DiagnosticableTreeMixin { | ||||
|  | ||||
|  String get userId; String get roomId; String get message; String get timestamp;// CHANGED: Added participants list | ||||
|  List<CallParticipant> get participants; | ||||
| /// Create a copy of WebRTCWelcomeMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $WebRTCWelcomeMessageCopyWith<WebRTCWelcomeMessage> get copyWith => _$WebRTCWelcomeMessageCopyWithImpl<WebRTCWelcomeMessage>(this as WebRTCWelcomeMessage, _$identity); | ||||
|  | ||||
|   /// Serializes this WebRTCWelcomeMessage to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'WebRTCWelcomeMessage')) | ||||
|     ..add(DiagnosticsProperty('userId', userId))..add(DiagnosticsProperty('roomId', roomId))..add(DiagnosticsProperty('message', message))..add(DiagnosticsProperty('timestamp', timestamp))..add(DiagnosticsProperty('participants', participants)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is WebRTCWelcomeMessage&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.roomId, roomId) || other.roomId == roomId)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&const DeepCollectionEquality().equals(other.participants, participants)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,userId,roomId,message,timestamp,const DeepCollectionEquality().hash(participants)); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'WebRTCWelcomeMessage(userId: $userId, roomId: $roomId, message: $message, timestamp: $timestamp, participants: $participants)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $WebRTCWelcomeMessageCopyWith<$Res>  { | ||||
|   factory $WebRTCWelcomeMessageCopyWith(WebRTCWelcomeMessage value, $Res Function(WebRTCWelcomeMessage) _then) = _$WebRTCWelcomeMessageCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String userId, String roomId, String message, String timestamp, List<CallParticipant> participants | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$WebRTCWelcomeMessageCopyWithImpl<$Res> | ||||
|     implements $WebRTCWelcomeMessageCopyWith<$Res> { | ||||
|   _$WebRTCWelcomeMessageCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final WebRTCWelcomeMessage _self; | ||||
|   final $Res Function(WebRTCWelcomeMessage) _then; | ||||
|  | ||||
| /// Create a copy of WebRTCWelcomeMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? userId = null,Object? roomId = null,Object? message = null,Object? timestamp = null,Object? participants = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable | ||||
| as String,roomId: null == roomId ? _self.roomId : roomId // ignore: cast_nullable_to_non_nullable | ||||
| as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable | ||||
| as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable | ||||
| as String,participants: null == participants ? _self.participants : participants // ignore: cast_nullable_to_non_nullable | ||||
| as List<CallParticipant>, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Adds pattern-matching-related methods to [WebRTCWelcomeMessage]. | ||||
| extension WebRTCWelcomeMessagePatterns on WebRTCWelcomeMessage { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _WebRTCWelcomeMessage value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _WebRTCWelcomeMessage() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _WebRTCWelcomeMessage value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _WebRTCWelcomeMessage(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _WebRTCWelcomeMessage value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _WebRTCWelcomeMessage() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String userId,  String roomId,  String message,  String timestamp,  List<CallParticipant> participants)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _WebRTCWelcomeMessage() when $default != null: | ||||
| return $default(_that.userId,_that.roomId,_that.message,_that.timestamp,_that.participants);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String userId,  String roomId,  String message,  String timestamp,  List<CallParticipant> participants)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _WebRTCWelcomeMessage(): | ||||
| return $default(_that.userId,_that.roomId,_that.message,_that.timestamp,_that.participants);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String userId,  String roomId,  String message,  String timestamp,  List<CallParticipant> participants)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _WebRTCWelcomeMessage() when $default != null: | ||||
| return $default(_that.userId,_that.roomId,_that.message,_that.timestamp,_that.participants);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _WebRTCWelcomeMessage with DiagnosticableTreeMixin implements WebRTCWelcomeMessage { | ||||
|   const _WebRTCWelcomeMessage({required this.userId, required this.roomId, required this.message, required this.timestamp, final  List<CallParticipant> participants = const []}): _participants = participants; | ||||
|   factory _WebRTCWelcomeMessage.fromJson(Map<String, dynamic> json) => _$WebRTCWelcomeMessageFromJson(json); | ||||
|  | ||||
| @override final  String userId; | ||||
| @override final  String roomId; | ||||
| @override final  String message; | ||||
| @override final  String timestamp; | ||||
| // CHANGED: Added participants list | ||||
|  final  List<CallParticipant> _participants; | ||||
| // CHANGED: Added participants list | ||||
| @override@JsonKey() List<CallParticipant> get participants { | ||||
|   if (_participants is EqualUnmodifiableListView) return _participants; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_participants); | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Create a copy of WebRTCWelcomeMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$WebRTCWelcomeMessageCopyWith<_WebRTCWelcomeMessage> get copyWith => __$WebRTCWelcomeMessageCopyWithImpl<_WebRTCWelcomeMessage>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$WebRTCWelcomeMessageToJson(this, ); | ||||
| } | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'WebRTCWelcomeMessage')) | ||||
|     ..add(DiagnosticsProperty('userId', userId))..add(DiagnosticsProperty('roomId', roomId))..add(DiagnosticsProperty('message', message))..add(DiagnosticsProperty('timestamp', timestamp))..add(DiagnosticsProperty('participants', participants)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _WebRTCWelcomeMessage&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.roomId, roomId) || other.roomId == roomId)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&const DeepCollectionEquality().equals(other._participants, _participants)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,userId,roomId,message,timestamp,const DeepCollectionEquality().hash(_participants)); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'WebRTCWelcomeMessage(userId: $userId, roomId: $roomId, message: $message, timestamp: $timestamp, participants: $participants)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$WebRTCWelcomeMessageCopyWith<$Res> implements $WebRTCWelcomeMessageCopyWith<$Res> { | ||||
|   factory _$WebRTCWelcomeMessageCopyWith(_WebRTCWelcomeMessage value, $Res Function(_WebRTCWelcomeMessage) _then) = __$WebRTCWelcomeMessageCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String userId, String roomId, String message, String timestamp, List<CallParticipant> participants | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$WebRTCWelcomeMessageCopyWithImpl<$Res> | ||||
|     implements _$WebRTCWelcomeMessageCopyWith<$Res> { | ||||
|   __$WebRTCWelcomeMessageCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _WebRTCWelcomeMessage _self; | ||||
|   final $Res Function(_WebRTCWelcomeMessage) _then; | ||||
|  | ||||
| /// Create a copy of WebRTCWelcomeMessage | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? userId = null,Object? roomId = null,Object? message = null,Object? timestamp = null,Object? participants = null,}) { | ||||
|   return _then(_WebRTCWelcomeMessage( | ||||
| userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable | ||||
| as String,roomId: null == roomId ? _self.roomId : roomId // ignore: cast_nullable_to_non_nullable | ||||
| as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable | ||||
| as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable | ||||
| as String,participants: null == participants ? _self._participants : participants // ignore: cast_nullable_to_non_nullable | ||||
| as List<CallParticipant>, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
							
								
								
									
										49
									
								
								lib/pods/chat/webrtc_signaling.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								lib/pods/chat/webrtc_signaling.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'webrtc_signaling.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _SignalingMessage _$SignalingMessageFromJson(Map<String, dynamic> json) => | ||||
|     _SignalingMessage( | ||||
|       type: json['type'] as String, | ||||
|       to: json['to'] as String?, | ||||
|       accountId: json['account_id'] as String, | ||||
|       account: SnAccount.fromJson(json['account'] as Map<String, dynamic>), | ||||
|       data: json['data'] as Map<String, dynamic>, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$SignalingMessageToJson(_SignalingMessage instance) => | ||||
|     <String, dynamic>{ | ||||
|       'type': instance.type, | ||||
|       'to': instance.to, | ||||
|       'account_id': instance.accountId, | ||||
|       'account': instance.account.toJson(), | ||||
|       'data': instance.data, | ||||
|     }; | ||||
|  | ||||
| _WebRTCWelcomeMessage _$WebRTCWelcomeMessageFromJson( | ||||
|   Map<String, dynamic> json, | ||||
| ) => _WebRTCWelcomeMessage( | ||||
|   userId: json['user_id'] as String, | ||||
|   roomId: json['room_id'] as String, | ||||
|   message: json['message'] as String, | ||||
|   timestamp: json['timestamp'] as String, | ||||
|   participants: | ||||
|       (json['participants'] as List<dynamic>?) | ||||
|           ?.map((e) => CallParticipant.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList() ?? | ||||
|       const [], | ||||
| ); | ||||
|  | ||||
| Map<String, dynamic> _$WebRTCWelcomeMessageToJson( | ||||
|   _WebRTCWelcomeMessage instance, | ||||
| ) => <String, dynamic>{ | ||||
|   'user_id': instance.userId, | ||||
|   'room_id': instance.roomId, | ||||
|   'message': instance.message, | ||||
|   'timestamp': instance.timestamp, | ||||
|   'participants': instance.participants.map((e) => e.toJson()).toList(), | ||||
| }; | ||||
| @@ -100,12 +100,16 @@ class WebSocketService { | ||||
|           } | ||||
|         }, | ||||
|         onDone: () { | ||||
|           talker.info('[WebSocket] Connection closed, attempting to reconnect...'); | ||||
|           talker.info( | ||||
|             '[WebSocket] Connection closed, attempting to reconnect...', | ||||
|           ); | ||||
|           _scheduleReconnect(); | ||||
|           _statusStreamController.sink.add(WebSocketState.disconnected()); | ||||
|         }, | ||||
|         onError: (error) { | ||||
|           talker.error('[WebSocket] Error occurred: $error, attempting to reconnect...'); | ||||
|           talker.error( | ||||
|             '[WebSocket] Error occurred: $error, attempting to reconnect...', | ||||
|           ); | ||||
|           _scheduleReconnect(); | ||||
|           _statusStreamController.sink.add( | ||||
|             WebSocketState.error(error.toString()), | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart' hide ConnectionState; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -9,8 +9,6 @@ import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/chat/call_button.dart'; | ||||
| import 'package:island/widgets/chat/call_overlay.dart'; | ||||
| import 'package:island/widgets/chat/call_participant_tile.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -26,32 +24,13 @@ class CallScreen extends HookConsumerWidget { | ||||
|  | ||||
|     useEffect(() { | ||||
|       talker.info('[Call] Joining the call...'); | ||||
|       callNotifier.joinRoom(roomId).catchError((_) { | ||||
|         showConfirmAlert( | ||||
|           'Seems there already has a call connected, do you want override it?', | ||||
|           'Call already connected', | ||||
|         ).then((value) { | ||||
|           if (value != true) return; | ||||
|           talker.info('[Call] Joining the call... with overrides'); | ||||
|           callNotifier.disconnect(); | ||||
|           callNotifier.dispose(); | ||||
|           callNotifier.joinRoom(roomId); | ||||
|         }); | ||||
|       Future(() { | ||||
|         callNotifier.joinRoom(roomId); | ||||
|       }); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     final allAudioOnly = callNotifier.participants.every( | ||||
|       (p) => | ||||
|           !(p.hasVideo && | ||||
|               p.remoteParticipant.trackPublications.values.any( | ||||
|                 (pub) => | ||||
|                     pub.track != null && | ||||
|                     pub.kind == TrackType.VIDEO && | ||||
|                     !pub.muted && | ||||
|                     !pub.isDisposed, | ||||
|               )), | ||||
|     ); | ||||
|     final allAudioOnly = callNotifier.participants.every((p) => !p.hasVideo); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       isNoBackground: false, | ||||
| @@ -67,12 +46,7 @@ class CallScreen extends HookConsumerWidget { | ||||
|             Text( | ||||
|               callState.isConnected | ||||
|                   ? formatDuration(callState.duration) | ||||
|                   : (switch (callNotifier.room?.connectionState) { | ||||
|                     ConnectionState.connected => 'connected', | ||||
|                     ConnectionState.connecting => 'connecting', | ||||
|                     ConnectionState.reconnecting => 'reconnecting', | ||||
|                     _ => 'disconnected', | ||||
|                   }).tr(), | ||||
|                   : 'connecting'.tr(), | ||||
|               style: const TextStyle(fontSize: 14), | ||||
|             ), | ||||
|           ], | ||||
| @@ -159,19 +133,7 @@ class CallScreen extends HookConsumerWidget { | ||||
|  | ||||
|                         // Stage view: show main speaker(s) large, others in row | ||||
|                         final mainSpeakers = | ||||
|                             participants | ||||
|                                 .where( | ||||
|                                   (p) => p | ||||
|                                       .remoteParticipant | ||||
|                                       .trackPublications | ||||
|                                       .values | ||||
|                                       .any( | ||||
|                                         (pub) => | ||||
|                                             pub.track != null && | ||||
|                                             pub.kind == TrackType.VIDEO, | ||||
|                                       ), | ||||
|                                 ) | ||||
|                                 .toList(); | ||||
|                             participants.where((p) => p.hasVideo).toList(); | ||||
|                         if (mainSpeakers.isEmpty && participants.isNotEmpty) { | ||||
|                           mainSpeakers.add(participants.first); | ||||
|                         } | ||||
|   | ||||
| @@ -16,7 +16,7 @@ Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async { | ||||
|   if (roomId.isEmpty) return null; | ||||
|   try { | ||||
|     final apiClient = ref.watch(apiClientProvider); | ||||
|     final resp = await apiClient.get('/sphere/chat/realtime/$roomId'); | ||||
|     final resp = await apiClient.get('/sphere/chat/realtime/$roomId/status'); | ||||
|     return SnRealtimeCall.fromJson(resp.data); | ||||
|   } catch (e) { | ||||
|     if (e is DioException && e.response?.statusCode == 404) { | ||||
|   | ||||
| @@ -6,7 +6,7 @@ part of 'call_button.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$ongoingCallHash() => r'48031badb79efa07aefb3a4fc51635be457bd3f9'; | ||||
| String _$ongoingCallHash() => r'0f14b36393276720a06190cab3dc8d5e4c88cd57'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import 'package:island/widgets/chat/call_participant_tile.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
|  | ||||
| class CallControlsBar extends HookConsumerWidget { | ||||
|   const CallControlsBar({super.key}); | ||||
| @@ -194,9 +194,16 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|     String deviceType, | ||||
|   ) async { | ||||
|     try { | ||||
|       final devices = await Hardware.instance.enumerateDevices( | ||||
|         type: deviceType, | ||||
|       ); | ||||
|       final devices = await navigator.mediaDevices.enumerateDevices(); | ||||
|       final filteredDevices = | ||||
|           devices.where((device) { | ||||
|             if (deviceType == 'videoinput') { | ||||
|               return device.kind == 'videoinput'; | ||||
|             } else if (deviceType == 'audioinput') { | ||||
|               return device.kind == 'audioinput'; | ||||
|             } | ||||
|             return false; | ||||
|           }).toList(); | ||||
|  | ||||
|       if (!context.mounted) return; | ||||
|  | ||||
| @@ -209,9 +216,9 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|                     ? 'selectCamera'.tr() | ||||
|                     : 'selectMicrophone'.tr(), | ||||
|             child: ListView.builder( | ||||
|               itemCount: devices.length, | ||||
|               itemCount: filteredDevices.length, | ||||
|               itemBuilder: (context, index) { | ||||
|                 final device = devices[index]; | ||||
|                 final device = filteredDevices[index]; | ||||
|                 return ListTile( | ||||
|                   title: Text( | ||||
|                     device.label.isNotEmpty | ||||
| @@ -236,33 +243,17 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|   Future<void> _switchDevice( | ||||
|     BuildContext context, | ||||
|     WidgetRef ref, | ||||
|     MediaDevice device, | ||||
|     MediaDeviceInfo device, | ||||
|     String deviceType, | ||||
|   ) async { | ||||
|     try { | ||||
|       final callNotifier = ref.read(callNotifierProvider.notifier); | ||||
|       if (callNotifier.webrtcManager == null) return; | ||||
|  | ||||
|       if (deviceType == 'videoinput') { | ||||
|         // Switch camera device | ||||
|         final localParticipant = callNotifier.room?.localParticipant; | ||||
|         final videoTrack = | ||||
|             localParticipant?.videoTrackPublications.firstOrNull?.track; | ||||
|  | ||||
|         if (videoTrack is LocalVideoTrack) { | ||||
|           await videoTrack.switchCamera(device.deviceId); | ||||
|         } | ||||
|         await callNotifier.webrtcManager!.switchCamera(device.deviceId); | ||||
|       } else if (deviceType == 'audioinput') { | ||||
|         // Switch microphone device | ||||
|         final localParticipant = callNotifier.room?.localParticipant; | ||||
|         final audioTrack = | ||||
|             localParticipant?.audioTrackPublications.firstOrNull?.track; | ||||
|  | ||||
|         if (audioTrack is LocalAudioTrack) { | ||||
|           // For audio devices, we need to restart the track with new device | ||||
|           await audioTrack.restartTrack( | ||||
|             AudioCaptureOptions(deviceId: device.deviceId), | ||||
|           ); | ||||
|         } | ||||
|         await callNotifier.webrtcManager!.switchMicrophone(device.deviceId); | ||||
|       } | ||||
|  | ||||
|       if (context.mounted) { | ||||
| @@ -289,31 +280,9 @@ class CallOverlayBar extends HookConsumerWidget { | ||||
|     if (!callState.isConnected) return const SizedBox.shrink(); | ||||
|  | ||||
|     final lastSpeaker = | ||||
|         callNotifier.participants | ||||
|                 .where( | ||||
|                   (element) => element.remoteParticipant.lastSpokeAt != null, | ||||
|                 ) | ||||
|                 .isEmpty | ||||
|         callNotifier.participants.isNotEmpty | ||||
|             ? callNotifier.participants.first | ||||
|             : callNotifier.participants | ||||
|                 .where( | ||||
|                   (element) => element.remoteParticipant.lastSpokeAt != null, | ||||
|                 ) | ||||
|                 .fold( | ||||
|                   callNotifier.participants.first, | ||||
|                   (value, element) => | ||||
|                       element.remoteParticipant.lastSpokeAt != null && | ||||
|                               (value.remoteParticipant.lastSpokeAt == null || | ||||
|                                   element.remoteParticipant.lastSpokeAt! | ||||
|                                           .compareTo( | ||||
|                                             value | ||||
|                                                 .remoteParticipant | ||||
|                                                 .lastSpokeAt!, | ||||
|                                           ) > | ||||
|                                       0) | ||||
|                           ? element | ||||
|                           : value, | ||||
|                 ); | ||||
|             : null; | ||||
|  | ||||
|     final actionButtonStyle = ButtonStyle( | ||||
|       minimumSize: const MaterialStatePropertyAll(Size(24, 24)), | ||||
| @@ -330,17 +299,16 @@ class CallOverlayBar extends HookConsumerWidget { | ||||
|                 children: [ | ||||
|                   Builder( | ||||
|                     builder: (context) { | ||||
|                       if (callNotifier.localParticipant == null) { | ||||
|                         return CircularProgressIndicator().center(); | ||||
|                       if (lastSpeaker == null) { | ||||
|                         return const CircularProgressIndicator(); | ||||
|                       } | ||||
|                       return SizedBox( | ||||
|                         width: 40, | ||||
|                         height: 40, | ||||
|                         child: | ||||
|                             SpeakingRippleAvatar( | ||||
|                               live: lastSpeaker, | ||||
|                               size: 36, | ||||
|                             ).center(), | ||||
|                         child: SpeakingRippleAvatar( | ||||
|                           live: lastSpeaker, | ||||
|                           size: 36, | ||||
|                         ), | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
| @@ -348,7 +316,9 @@ class CallOverlayBar extends HookConsumerWidget { | ||||
|                   Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Text('@${lastSpeaker.participant.identity}').bold(), | ||||
|                       Text( | ||||
|                         '@${lastSpeaker?.participant.identity ?? 'Unknown'}', | ||||
|                       ), | ||||
|                       Text( | ||||
|                         formatDuration(callState.duration), | ||||
|                         style: Theme.of(context).textTheme.bodySmall, | ||||
|   | ||||
| @@ -7,7 +7,6 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/chat/call.dart'; | ||||
| import 'package:island/widgets/account/account_nameplate.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -66,19 +65,17 @@ class CallParticipantCard extends HookConsumerWidget { | ||||
|                   children: [ | ||||
|                     const Icon(Symbols.wifi, size: 16), | ||||
|                     const Gap(8), | ||||
|                     Text(switch (live.remoteParticipant.connectionQuality) { | ||||
|                       ConnectionQuality.excellent => 'Excellent', | ||||
|                       ConnectionQuality.good => 'Good', | ||||
|                       ConnectionQuality.poor => 'Bad', | ||||
|                       ConnectionQuality.lost => 'Lost', | ||||
|                       _ => 'Connecting', | ||||
|                     }), | ||||
|                     Text( | ||||
|                       live.remoteParticipant.isConnected | ||||
|                           ? 'Connected' | ||||
|                           : 'Connecting', | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(horizontal: 20, top: 16), | ||||
|             AccountNameplate( | ||||
|               name: live.participant.identity, | ||||
|               name: live.remoteParticipant.userinfo.name, | ||||
|               isOutlined: false, | ||||
|             ), | ||||
|           ], | ||||
|   | ||||
| @@ -1,10 +1,9 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/chat/call.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/widgets/chat/call_participant_card.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -16,10 +15,8 @@ class SpeakingRippleAvatar extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final account = ref.watch(accountProvider(live.participant.identity)); | ||||
|  | ||||
|     final avatarRadius = size / 2; | ||||
|     final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0); | ||||
|     final clampedLevel = live.audioLevel.clamp(0.0, 1.0); | ||||
|     final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); | ||||
|     return SizedBox( | ||||
|       width: size + 8, | ||||
| @@ -27,7 +24,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget { | ||||
|       child: TweenAnimationBuilder<double>( | ||||
|         tween: Tween<double>( | ||||
|           begin: avatarRadius, | ||||
|           end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius, | ||||
|           end: live.isSpeaking ? rippleRadius : avatarRadius, | ||||
|         ), | ||||
|         duration: const Duration(milliseconds: 250), | ||||
|         curve: Curves.easeOut, | ||||
| @@ -35,7 +32,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget { | ||||
|           return Stack( | ||||
|             alignment: Alignment.center, | ||||
|             children: [ | ||||
|               if (live.remoteParticipant.isSpeaking) | ||||
|               if (live.isSpeaking) | ||||
|                 Container( | ||||
|                   width: animatedRadius * 2, | ||||
|                   height: animatedRadius * 2, | ||||
| @@ -49,28 +46,15 @@ class SpeakingRippleAvatar extends HookConsumerWidget { | ||||
|                 height: size, | ||||
|                 alignment: Alignment.center, | ||||
|                 decoration: BoxDecoration(shape: BoxShape.circle), | ||||
|                 child: account.when( | ||||
|                   data: | ||||
|                       (value) => CallParticipantGestureDetector( | ||||
|                         participant: live, | ||||
|                         child: ProfilePictureWidget( | ||||
|                           file: value.profile.picture, | ||||
|                           radius: size / 2, | ||||
|                         ), | ||||
|                       ), | ||||
|                   error: | ||||
|                       (_, _) => CircleAvatar( | ||||
|                         radius: size / 2, | ||||
|                         child: const Icon(Symbols.person_remove), | ||||
|                       ), | ||||
|                   loading: | ||||
|                       () => CircleAvatar( | ||||
|                         radius: size / 2, | ||||
|                         child: CircularProgressIndicator(), | ||||
|                       ), | ||||
|                 child: CallParticipantGestureDetector( | ||||
|                   participant: live, | ||||
|                   child: ProfilePictureWidget( | ||||
|                     file: live.remoteParticipant.userinfo.profile.picture, | ||||
|                     radius: size / 2, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               if (live.remoteParticipant.isMuted) | ||||
|               if (live.isMuted) | ||||
|                 Positioned( | ||||
|                   bottom: 4, | ||||
|                   right: 4, | ||||
| @@ -96,40 +80,65 @@ class SpeakingRippleAvatar extends HookConsumerWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class CallParticipantTile extends HookConsumerWidget { | ||||
| class CallParticipantTile extends StatefulWidget { | ||||
|   final CallParticipantLive live; | ||||
|  | ||||
|   const CallParticipantTile({super.key, required this.live}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final hasVideo = | ||||
|         live.hasVideo && | ||||
|         live.remoteParticipant.trackPublications.values | ||||
|             .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO) | ||||
|             .isNotEmpty; | ||||
|   State<CallParticipantTile> createState() => _CallParticipantTileState(); | ||||
| } | ||||
|  | ||||
|     if (hasVideo) { | ||||
| class _CallParticipantTileState extends State<CallParticipantTile> { | ||||
|   RTCVideoRenderer? _renderer; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _initRenderer(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void didUpdateWidget(CallParticipantTile oldWidget) { | ||||
|     super.didUpdateWidget(oldWidget); | ||||
|     // Update renderer source when the stream changes | ||||
|     if (_renderer != null && | ||||
|         widget.live.remoteParticipant.remoteStream != | ||||
|             oldWidget.live.remoteParticipant.remoteStream) { | ||||
|       _renderer!.srcObject = widget.live.remoteParticipant.remoteStream; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _initRenderer() async { | ||||
|     _renderer = RTCVideoRenderer(); | ||||
|     await _renderer!.initialize(); | ||||
|     _renderer!.srcObject = widget.live.remoteParticipant.remoteStream; | ||||
|     if (mounted) { | ||||
|       setState(() {}); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _renderer?.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (widget.live.hasVideo && | ||||
|         widget.live.remoteParticipant.remoteStream != null && | ||||
|         _renderer != null) { | ||||
|       return Stack( | ||||
|         fit: StackFit.loose, | ||||
|         children: [ | ||||
|           AspectRatio( | ||||
|             aspectRatio: 16 / 9, | ||||
|             child: VideoTrackRenderer( | ||||
|               live.remoteParticipant.trackPublications.values | ||||
|                       .where((track) => track.kind == TrackType.VIDEO) | ||||
|                       .first | ||||
|                       .track | ||||
|                   as VideoTrack, | ||||
|               renderMode: VideoRenderMode.platformView, | ||||
|             ), | ||||
|           ), | ||||
|           AspectRatio(aspectRatio: 16 / 9, child: RTCVideoView(_renderer!)), | ||||
|           Positioned( | ||||
|             left: 8, | ||||
|             right: 8, | ||||
|             bottom: 8, | ||||
|             child: Text( | ||||
|               '@${live.participant.name}', | ||||
|               '@${widget.live.participant.name}', | ||||
|               textAlign: TextAlign.center, | ||||
|               style: const TextStyle( | ||||
|                 fontSize: 14, | ||||
| @@ -148,7 +157,7 @@ class CallParticipantTile extends HookConsumerWidget { | ||||
|         ], | ||||
|       ); | ||||
|     } else { | ||||
|       return SpeakingRippleAvatar(size: 84, live: live); | ||||
|       return SpeakingRippleAvatar(size: 84, live: widget.live); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -44,10 +44,12 @@ void showInfoAlert(String message, String title) async { | ||||
|  | ||||
| Future<bool> showConfirmAlert(String message, String title) async { | ||||
|   final result = await js.context.callMethod('swal', [ | ||||
|     title, | ||||
|     message, | ||||
|     'question', | ||||
|     {'buttons': true}, | ||||
|     js.JsObject.jsify({ | ||||
|       'title': title, | ||||
|       'text': message, | ||||
|       'icon': 'info', | ||||
|       'buttons': {'cancel': true, 'confirm': true}, | ||||
|     }), | ||||
|   ]); | ||||
|   return result == true; | ||||
| } | ||||
|   | ||||
| @@ -193,10 +193,10 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> { | ||||
|       // Perform biometric authentication | ||||
|       final bool didAuthenticate = await _localAuth.authenticate( | ||||
|         localizedReason: 'biometricPrompt'.tr(), | ||||
|         options: const AuthenticationOptions( | ||||
|           biometricOnly: true, | ||||
|           stickyAuth: true, | ||||
|         ), | ||||
|         // options: const AuthenticationOptions( | ||||
|         //   biometricOnly: true, | ||||
|         //   stickyAuth: true, | ||||
|         // ), | ||||
|       ); | ||||
|  | ||||
|       if (didAuthenticate) { | ||||
|   | ||||
| @@ -15,7 +15,6 @@ | ||||
| #include <flutter_webrtc/flutter_web_r_t_c_plugin.h> | ||||
| #include <gtk/gtk_plugin.h> | ||||
| #include <irondash_engine_context/irondash_engine_context_plugin.h> | ||||
| #include <livekit_client/live_kit_plugin.h> | ||||
| #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> | ||||
| #include <media_kit_video/media_kit_video_plugin.h> | ||||
| #include <pasteboard/pasteboard_plugin.h> | ||||
| @@ -57,9 +56,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { | ||||
|   g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); | ||||
|   irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) livekit_client_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "LiveKitPlugin"); | ||||
|   live_kit_plugin_register_with_registrar(livekit_client_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); | ||||
|   | ||||
| @@ -12,7 +12,6 @@ list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   flutter_webrtc | ||||
|   gtk | ||||
|   irondash_engine_context | ||||
|   livekit_client | ||||
|   media_kit_libs_linux | ||||
|   media_kit_video | ||||
|   pasteboard | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import FlutterMacOS | ||||
| import Foundation | ||||
|  | ||||
| import app_links | ||||
| import connectivity_plus | ||||
| import device_info_plus | ||||
| import file_picker | ||||
| import file_saver | ||||
| @@ -24,7 +23,6 @@ import flutter_udid | ||||
| import flutter_webrtc | ||||
| import gal | ||||
| import irondash_engine_context | ||||
| import livekit_client | ||||
| import local_auth_darwin | ||||
| import media_kit_libs_macos_video | ||||
| import media_kit_video | ||||
| @@ -48,7 +46,6 @@ import window_manager | ||||
|  | ||||
| func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | ||||
|   AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) | ||||
|   ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) | ||||
|   DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) | ||||
|   FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) | ||||
|   FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) | ||||
| @@ -66,7 +63,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | ||||
|   FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) | ||||
|   GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) | ||||
|   IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) | ||||
|   LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) | ||||
|   LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) | ||||
|   MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) | ||||
|   MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) | ||||
|   | ||||
							
								
								
									
										88
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										88
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -289,22 +289,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.19.1" | ||||
|   connectivity_plus: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: connectivity_plus | ||||
|       sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.1.5" | ||||
|   connectivity_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: connectivity_plus_platform_interface | ||||
|       sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.1" | ||||
|   console: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -930,10 +914,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_local_notifications | ||||
|       sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5" | ||||
|       sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "19.4.2" | ||||
|     version: "19.5.0" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -991,10 +975,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_native_splash | ||||
|       sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" | ||||
|       sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.4.6" | ||||
|     version: "2.4.7" | ||||
|   flutter_otp_text_field: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1201,10 +1185,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: go_router | ||||
|       sha256: c752e2d08d088bf83742cb05bf83003f3e9d276ff1519b5c92f9d5e60e5ddd23 | ||||
|       sha256: e1d7ffb0db475e6e845eb58b44768f50b830e23960e3df6908924acd8f7f70ea | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "16.2.4" | ||||
|     version: "16.2.5" | ||||
|   google_fonts: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1321,10 +1305,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: image_picker_android | ||||
|       sha256: dd7a61daaa5896cc34b7bc95f66c60225ae6bee0d167dde0e21a9d9016cac0dc | ||||
|       sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.8.13+4" | ||||
|     version: "0.8.13+5" | ||||
|   image_picker_for_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1469,38 +1453,30 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.0.0" | ||||
|   livekit_client: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: livekit_client | ||||
|       sha256: "4c1663c1e6ac20a743d9a46c7bc71f17e1949db99d245750c68661d554e30cd2" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.5.1" | ||||
|   local_auth: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: local_auth | ||||
|       sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" | ||||
|       sha256: a4f1bf57f0236a4aeb5e8f0ec180e197f4b112a3456baa6c1e73b546630b0422 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.3.0" | ||||
|     version: "3.0.0" | ||||
|   local_auth_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: local_auth_android | ||||
|       sha256: b2446c74fab1db37f828d4c54adaa3f003df80a29f5cbd710bbb8883d302e991 | ||||
|       sha256: d836715ed95b16b2de3a8c47a88ba5e607976bb1e27c9446d193152ea1429fae | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.55" | ||||
|     version: "2.0.0" | ||||
|   local_auth_darwin: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: local_auth_darwin | ||||
|       sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" | ||||
|       sha256: "15d9db4ad4d58a11d7269e55d46ff8d49ed5e856226c8a5a91280f0d7c37b3a6" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.6.1" | ||||
|     version: "2.0.0" | ||||
|   local_auth_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1513,10 +1489,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: local_auth_windows | ||||
|       sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 | ||||
|       sha256: d95535a73eddf57ce5930d5e78a0fa4f294c31981fdeeee83325b797302be454 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.11" | ||||
|     version: "2.0.0" | ||||
|   logger: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1669,14 +1645,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.0" | ||||
|   mime_type: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: mime_type | ||||
|       sha256: d652b613e84dac1af28030a9fba82c0999be05b98163f9e18a0849c6e63838bb | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.1" | ||||
|   modal_bottom_sheet: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1709,14 +1677,6 @@ 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" | ||||
|   octo_image: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1933,14 +1893,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.4" | ||||
|   protobuf: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: protobuf | ||||
|       sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.2.0" | ||||
|   provider: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2206,14 +2158,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   sdp_transform: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: sdp_transform | ||||
|       sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.3.2" | ||||
|   share_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|   | ||||
							
								
								
									
										12
									
								
								pubspec.yaml
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								pubspec.yaml
									
									
									
									
									
								
							| @@ -38,7 +38,7 @@ dependencies: | ||||
|   cupertino_icons: ^1.0.8 | ||||
|   flutter_hooks: ^0.21.3+1 | ||||
|   hooks_riverpod: ^2.6.1 | ||||
|   go_router: ^16.2.4 | ||||
|   go_router: ^16.2.5 | ||||
|   styled_widget: ^0.4.1 | ||||
|   shared_preferences: ^2.5.3 | ||||
|   flutter_riverpod: ^2.6.1 | ||||
| @@ -75,7 +75,7 @@ dependencies: | ||||
|   file_picker: ^10.3.3 | ||||
|   riverpod_annotation: ^2.6.1 | ||||
|   image_picker_platform_interface: ^2.11.0 | ||||
|   image_picker_android: ^0.8.13+4 | ||||
|   image_picker_android: ^0.8.13+5 | ||||
|   super_context_menu: ^0.9.1 | ||||
|   modal_bottom_sheet: ^3.0.0 | ||||
|   firebase_messaging: ^16.0.3 | ||||
| @@ -97,12 +97,12 @@ dependencies: | ||||
|   avatar_stack: ^3.0.0 | ||||
|   markdown_widget: ^2.3.2+8 | ||||
|   visibility_detector: ^0.4.0+2 | ||||
|   flutter_native_splash: ^2.4.6 | ||||
|   flutter_native_splash: ^2.4.7 | ||||
|   photo_view: ^0.15.0 | ||||
|   gal: ^2.3.2 | ||||
|   dismissible_page: ^1.0.2 | ||||
|   super_sliver_list: ^0.4.1 | ||||
|   livekit_client: ^2.5.1 | ||||
|  | ||||
|   pasteboard: ^0.4.0 | ||||
|   flutter_colorpicker: ^1.1.0 | ||||
|   image: ^4.5.4 | ||||
| @@ -117,7 +117,7 @@ dependencies: | ||||
|   sign_in_with_apple: ^7.0.1 | ||||
|   flutter_svg: ^2.2.1 | ||||
|   native_exif: ^0.6.2 | ||||
|   local_auth: ^2.3.0 | ||||
|   local_auth: ^3.0.0 | ||||
|   flutter_secure_storage: ^9.2.4 | ||||
|   flutter_math_fork: ^0.7.4 | ||||
|   share_plus: ^12.0.0 | ||||
| @@ -142,7 +142,7 @@ dependencies: | ||||
|   file_saver: ^0.3.1 | ||||
|   tray_manager: ^0.5.1 | ||||
|   flutter_webrtc: ^1.2.0 | ||||
|   flutter_local_notifications: ^19.4.2 | ||||
|   flutter_local_notifications: ^19.5.0 | ||||
|   wakelock_plus: ^1.4.0 | ||||
|   slide_countdown: ^2.0.2 | ||||
|   shelf: ^1.4.2 | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| ; ================================================== | ||||
| #define AppVersion "3.2.0" | ||||
| #define BuildNumber "134" | ||||
| #define AppVersion "3.3.0" | ||||
| #define BuildNumber "136" | ||||
| ; ================================================== | ||||
|  | ||||
| #define FullVersion AppVersion + "." + BuildNumber | ||||
|   | ||||
| @@ -7,7 +7,6 @@ | ||||
| #include "generated_plugin_registrant.h" | ||||
|  | ||||
| #include <app_links/app_links_plugin_c_api.h> | ||||
| #include <connectivity_plus/connectivity_plus_windows_plugin.h> | ||||
| #include <dart_ipc/dart_ipc_plugin_c_api.h> | ||||
| #include <file_saver/file_saver_plugin.h> | ||||
| #include <file_selector_windows/file_selector_windows.h> | ||||
| @@ -20,7 +19,6 @@ | ||||
| #include <flutter_webrtc/flutter_web_r_t_c_plugin.h> | ||||
| #include <gal/gal_plugin_c_api.h> | ||||
| #include <irondash_engine_context/irondash_engine_context_plugin_c_api.h> | ||||
| #include <livekit_client/live_kit_plugin.h> | ||||
| #include <local_auth_windows/local_auth_plugin.h> | ||||
| #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> | ||||
| #include <media_kit_video/media_kit_video_plugin_c_api.h> | ||||
| @@ -40,8 +38,6 @@ | ||||
| void RegisterPlugins(flutter::PluginRegistry* registry) { | ||||
|   AppLinksPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("AppLinksPluginCApi")); | ||||
|   ConnectivityPlusWindowsPluginRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); | ||||
|   DartIpcPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("DartIpcPluginCApi")); | ||||
|   FileSaverPluginRegisterWithRegistrar( | ||||
| @@ -66,8 +62,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { | ||||
|       registry->GetRegistrarForPlugin("GalPluginCApi")); | ||||
|   IrondashEngineContextPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); | ||||
|   LiveKitPluginRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("LiveKitPlugin")); | ||||
|   LocalAuthPluginRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("LocalAuthPlugin")); | ||||
|   MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( | ||||
|   | ||||
| @@ -4,7 +4,6 @@ | ||||
|  | ||||
| list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   app_links | ||||
|   connectivity_plus | ||||
|   dart_ipc | ||||
|   file_saver | ||||
|   file_selector_windows | ||||
| @@ -17,7 +16,6 @@ list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   flutter_webrtc | ||||
|   gal | ||||
|   irondash_engine_context | ||||
|   livekit_client | ||||
|   local_auth_windows | ||||
|   media_kit_libs_windows_video | ||||
|   media_kit_video | ||||
|   | ||||
		Reference in New Issue
	
	Block a user