From 0c459bf7e3f897abd10eca0ae1d551ef9744b07e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 19 Oct 2025 19:16:40 +0800 Subject: [PATCH] :recycle: Proper singaling --- lib/models/chat.freezed.dart | 43 ++++++++++----------- lib/models/chat.g.dart | 2 - lib/pods/chat/call.g.dart | 2 +- lib/pods/chat/webrtc_manager.dart | 58 +++++++++++++++++++++++++---- lib/pods/chat/webrtc_signaling.dart | 7 +--- 5 files changed, 73 insertions(+), 39 deletions(-) diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart index 646c8e49..378bcc03 100644 --- a/lib/models/chat.freezed.dart +++ b/lib/models/chat.freezed.dart @@ -2241,7 +2241,7 @@ as List, /// @nodoc mixin _$CallParticipant { - String get identity; String get name; String get accountId; SnAccount? get account; DateTime get joinedAt; bool get isLocal; + 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 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.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isLocal, isLocal) || other.isLocal == isLocal)); + 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,accountId,account,joinedAt,isLocal); +int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt); @override String toString() { - return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt, isLocal: $isLocal)'; + return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt)'; } @@ -2274,7 +2274,7 @@ abstract mixin class $CallParticipantCopyWith<$Res> { factory $CallParticipantCopyWith(CallParticipant value, $Res Function(CallParticipant) _then) = _$CallParticipantCopyWithImpl; @useResult $Res call({ - String identity, String name, String accountId, SnAccount? account, DateTime joinedAt, bool isLocal + String identity, String name, String accountId, SnAccount? account, DateTime joinedAt }); @@ -2291,15 +2291,14 @@ 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? accountId = null,Object? account = freezed,Object? joinedAt = null,Object? isLocal = 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,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,isLocal: null == isLocal ? _self.isLocal : isLocal // ignore: cast_nullable_to_non_nullable -as bool, +as DateTime, )); } /// Create a copy of CallParticipant @@ -2393,10 +2392,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String identity, String name, String accountId, SnAccount? account, DateTime joinedAt, bool isLocal)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(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.accountId,_that.account,_that.joinedAt,_that.isLocal);case _: +return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);case _: return orElse(); } @@ -2414,10 +2413,10 @@ return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.jo /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String identity, String name, String accountId, SnAccount? account, DateTime joinedAt, bool isLocal) $default,) {final _that = this; +@optionalTypeArgs TResult when(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.accountId,_that.account,_that.joinedAt,_that.isLocal);} +return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);} } /// A variant of `when` that fallback to returning `null` /// @@ -2431,10 +2430,10 @@ return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.jo /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String identity, String name, String accountId, SnAccount? account, DateTime joinedAt, bool isLocal)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(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.accountId,_that.account,_that.joinedAt,_that.isLocal);case _: +return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);case _: return null; } @@ -2446,7 +2445,7 @@ return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.jo @JsonSerializable() class _CallParticipant implements CallParticipant { - const _CallParticipant({required this.identity, required this.name, required this.accountId, this.account = null, required this.joinedAt, this.isLocal = false}); + const _CallParticipant({required this.identity, required this.name, required this.accountId, this.account = null, required this.joinedAt}); factory _CallParticipant.fromJson(Map json) => _$CallParticipantFromJson(json); @override final String identity; @@ -2454,7 +2453,6 @@ class _CallParticipant implements CallParticipant { @override final String accountId; @override@JsonKey() final SnAccount? account; @override final DateTime joinedAt; -@override@JsonKey() final bool isLocal; /// Create a copy of CallParticipant /// with the given fields replaced by the non-null parameter values. @@ -2469,16 +2467,16 @@ Map 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.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isLocal, isLocal) || other.isLocal == isLocal)); + 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,accountId,account,joinedAt,isLocal); +int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt); @override String toString() { - return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt, isLocal: $isLocal)'; + return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt)'; } @@ -2489,7 +2487,7 @@ abstract mixin class _$CallParticipantCopyWith<$Res> implements $CallParticipant factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl; @override @useResult $Res call({ - String identity, String name, String accountId, SnAccount? account, DateTime joinedAt, bool isLocal + String identity, String name, String accountId, SnAccount? account, DateTime joinedAt }); @@ -2506,15 +2504,14 @@ 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? accountId = null,Object? account = freezed,Object? joinedAt = null,Object? isLocal = 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,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,isLocal: null == isLocal ? _self.isLocal : isLocal // ignore: cast_nullable_to_non_nullable -as bool, +as DateTime, )); } diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart index 39438c1a..2b559957 100644 --- a/lib/models/chat.g.dart +++ b/lib/models/chat.g.dart @@ -281,7 +281,6 @@ _CallParticipant _$CallParticipantFromJson(Map json) => ? null : SnAccount.fromJson(json['account'] as Map), joinedAt: DateTime.parse(json['joined_at'] as String), - isLocal: json['is_local'] as bool? ?? false, ); Map _$CallParticipantToJson(_CallParticipant instance) => @@ -291,7 +290,6 @@ Map _$CallParticipantToJson(_CallParticipant instance) => 'account_id': instance.accountId, 'account': instance.account?.toJson(), 'joined_at': instance.joinedAt.toIso8601String(), - 'is_local': instance.isLocal, }; _SnRealtimeCall _$SnRealtimeCallFromJson(Map json) => diff --git a/lib/pods/chat/call.g.dart b/lib/pods/chat/call.g.dart index e22eca71..5eb75c77 100644 --- a/lib/pods/chat/call.g.dart +++ b/lib/pods/chat/call.g.dart @@ -6,7 +6,7 @@ part of 'call.dart'; // RiverpodGenerator // ************************************************************************** -String _$callNotifierHash() => r'6db330370d473eaea313dd9f9439d261c355095f'; +String _$callNotifierHash() => r'4015d326388553c46859fe537e84d2c9da4236c9'; /// See also [CallNotifier]. @ProviderFor(CallNotifier) diff --git a/lib/pods/chat/webrtc_manager.dart b/lib/pods/chat/webrtc_manager.dart index 49169ff5..187478bf 100644 --- a/lib/pods/chat/webrtc_manager.dart +++ b/lib/pods/chat/webrtc_manager.dart @@ -3,6 +3,7 @@ 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 { @@ -11,6 +12,7 @@ class WebRTCParticipant { final SnAccount userinfo; RTCPeerConnection? peerConnection; MediaStream? remoteStream; + List remoteCandidates = []; bool isAudioEnabled = true; bool isVideoEnabled = false; bool isConnected = false; @@ -20,6 +22,8 @@ class WebRTCParticipant { required this.id, required this.name, required this.userinfo, + this.isAudioEnabled = true, + this.isVideoEnabled = false, this.isLocal = false, }); } @@ -48,6 +52,10 @@ class WebRTCManager { } Future 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); @@ -60,6 +68,19 @@ class WebRTCManager { 'video': true, }); talker.info('[WebRTC] Local stream initialized'); + + // Add local participant + bool videoEnabled = _localStream!.getVideoTracks().isNotEmpty; + WebRTCParticipant localParticipant = WebRTCParticipant( + id: _signaling.userId, + name: _signaling.userName, + userinfo: _signaling.user, + isLocal: true, + isAudioEnabled: true, + isVideoEnabled: videoEnabled, + ); + _participants[_signaling.userId] = localParticipant; + _participantController.add(localParticipant); } catch (e) { talker.error('[WebRTC] Failed to initialize local stream: $e'); rethrow; @@ -156,6 +177,7 @@ class WebRTCManager { final peerConnection = await createPeerConnection(configuration); _peerConnections[participantId] = peerConnection; + _participants[participantId]!.peerConnection = peerConnection; if (_localStream != null) { for (final track in _localStream!.getTracks()) { @@ -233,6 +255,15 @@ class WebRTCManager { 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 _handleAnswer(String from, Map data) async { @@ -243,6 +274,15 @@ class WebRTCManager { 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(); + } } } @@ -257,11 +297,14 @@ class WebRTCManager { data['sdpMLineIndex'], ); - final peerConnection = _peerConnections[participantId]; - if (peerConnection != null) { - // It's possible for candidates to arrive before the remote description is set. - // A robust implementation might queue them, but for now, we'll just add them. - await peerConnection.addCandidate(candidate); + final participant = _participants[participantId]; + if (participant != null) { + final pc = participant.peerConnection; + if (pc != null) { + await pc.addCandidate(candidate); + } else { + participant.remoteCandidates.add(candidate); + } } } @@ -273,7 +316,7 @@ class WebRTCManager { } } - _participants.values.where((e) => e.isLocal).firstOrNull?.isAudioEnabled = true; + _participants[_signaling.userId]?.isAudioEnabled = enabled; } Future toggleCamera(bool enabled) async { @@ -283,7 +326,7 @@ class WebRTCManager { }); } - _participants.values.where((e) => e.isLocal).firstOrNull?.isVideoEnabled = true; + _participants[_signaling.userId]?.isVideoEnabled = enabled; } List get participants => _participants.values.toList(); @@ -294,6 +337,7 @@ class WebRTCManager { pc.close(); } _peerConnections.clear(); + _participants.values.forEach((p) => p.remoteCandidates.clear()); _participants.clear(); _localStream?.dispose(); _participantController.close(); diff --git a/lib/pods/chat/webrtc_signaling.dart b/lib/pods/chat/webrtc_signaling.dart index 46f3e44a..a2e5a925 100644 --- a/lib/pods/chat/webrtc_signaling.dart +++ b/lib/pods/chat/webrtc_signaling.dart @@ -8,7 +8,6 @@ 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/userinfo.dart'; import 'package:island/pods/websocket.dart'; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -51,7 +50,7 @@ class WebRTCSignaling { final String roomId; late final String userId; late final String userName; - late final SnAccount user; + late SnAccount user; final StreamController _messageController = StreamController.broadcast(); final StreamController _welcomeController = @@ -65,13 +64,9 @@ class WebRTCSignaling { WebRTCSignaling({required this.roomId}); Future connect(Ref ref) async { - user = ref.watch(userInfoProvider).value!; final baseUrl = ref.watch(serverUrlProvider); final token = await getToken(ref.watch(tokenProvider)); - userId = user.id; - userName = user.name; - final url = '$baseUrl/sphere/chat/realtime/$roomId'.replaceFirst( 'http', 'ws',