♻️ Trying out the new built-in webrtc

This commit is contained in:
2025-10-19 17:30:06 +08:00
parent 001549b190
commit 3f83bbc1d8
27 changed files with 1420 additions and 580 deletions

View File

@@ -2,8 +2,6 @@ PODS:
- Alamofire (5.10.2) - Alamofire (5.10.2)
- app_links (6.4.1): - app_links (6.4.1):
- Flutter - Flutter
- connectivity_plus (0.0.1):
- Flutter
- croppy (0.0.1): - croppy (0.0.1):
- Flutter - Flutter
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
@@ -219,10 +217,6 @@ PODS:
- irondash_engine_context (0.0.1): - irondash_engine_context (0.0.1):
- Flutter - Flutter
- Kingfisher (8.6.0) - Kingfisher (8.6.0)
- livekit_client (2.5.0):
- Flutter
- flutter_webrtc
- WebRTC-SDK (= 137.7151.04)
- local_auth_darwin (0.0.1): - local_auth_darwin (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -309,7 +303,6 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- Alamofire - Alamofire
- app_links (from `.symlinks/plugins/app_links/ios`) - app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`) - croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
@@ -333,7 +326,6 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
- Kingfisher (~> 8.0) - Kingfisher (~> 8.0)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - 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_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
@@ -388,8 +380,6 @@ SPEC REPOS:
EXTERNAL SOURCES: EXTERNAL SOURCES:
app_links: app_links:
:path: ".symlinks/plugins/app_links/ios" :path: ".symlinks/plugins/app_links/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
croppy: croppy:
:path: ".symlinks/plugins/croppy/ios" :path: ".symlinks/plugins/croppy/ios"
device_info_plus: device_info_plus:
@@ -434,8 +424,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
irondash_engine_context: irondash_engine_context:
:path: ".symlinks/plugins/irondash_engine_context/ios" :path: ".symlinks/plugins/irondash_engine_context/ios"
livekit_client:
:path: ".symlinks/plugins/livekit_client/ios"
local_auth_darwin: local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin" :path: ".symlinks/plugins/local_auth_darwin/darwin"
media_kit_libs_ios_video: media_kit_libs_ios_video:
@@ -480,7 +468,6 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
@@ -520,7 +507,6 @@ SPEC CHECKSUMS:
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0 Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0
livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474

View File

@@ -149,6 +149,8 @@ sealed class CallParticipant with _$CallParticipant {
const factory CallParticipant({ const factory CallParticipant({
required String identity, required String identity,
required String name, required String name,
required String accountId,
@Default(null) SnAccount? account,
required DateTime joinedAt, required DateTime joinedAt,
}) = _CallParticipant; }) = _CallParticipant;

View File

@@ -2241,7 +2241,7 @@ as List<CallParticipant>,
/// @nodoc /// @nodoc
mixin _$CallParticipant { 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 /// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -2254,16 +2254,16 @@ $CallParticipantCopyWith<CallParticipant> get copyWith => _$CallParticipantCopyW
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt);
@override @override
String toString() { 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; factory $CallParticipantCopyWith(CallParticipant value, $Res Function(CallParticipant) _then) = _$CallParticipantCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String identity, String name, DateTime joinedAt String identity, String name, String accountId, SnAccount? account, DateTime joinedAt
}); });
$SnAccountCopyWith<$Res>? get account;
} }
/// @nodoc /// @nodoc
@@ -2291,15 +2291,29 @@ class _$CallParticipantCopyWithImpl<$Res>
/// Create a copy of CallParticipant /// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable 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,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, 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) { switch (_that) {
case _CallParticipant() when $default != null: 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(); 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) { switch (_that) {
case _CallParticipant(): 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` /// 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) { switch (_that) {
case _CallParticipant() when $default != null: 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; return null;
} }
@@ -2431,11 +2445,13 @@ return $default(_that.identity,_that.name,_that.joinedAt);case _:
@JsonSerializable() @JsonSerializable()
class _CallParticipant implements CallParticipant { 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); factory _CallParticipant.fromJson(Map<String, dynamic> json) => _$CallParticipantFromJson(json);
@override final String identity; @override final String identity;
@override final String name; @override final String name;
@override final String accountId;
@override@JsonKey() final SnAccount? account;
@override final DateTime joinedAt; @override final DateTime joinedAt;
/// Create a copy of CallParticipant /// Create a copy of CallParticipant
@@ -2451,16 +2467,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt);
@override @override
String toString() { 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; factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String identity, String name, DateTime joinedAt String identity, String name, String accountId, SnAccount? account, DateTime joinedAt
}); });
@override $SnAccountCopyWith<$Res>? get account;
} }
/// @nodoc /// @nodoc
@@ -2488,16 +2504,30 @@ class __$CallParticipantCopyWithImpl<$Res>
/// Create a copy of CallParticipant /// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_CallParticipant(
identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable 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,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, 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));
});
}
} }

View File

@@ -275,6 +275,11 @@ _CallParticipant _$CallParticipantFromJson(Map<String, dynamic> json) =>
_CallParticipant( _CallParticipant(
identity: json['identity'] as String, identity: json['identity'] as String,
name: json['name'] 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), joinedAt: DateTime.parse(json['joined_at'] as String),
); );
@@ -282,6 +287,8 @@ Map<String, dynamic> _$CallParticipantToJson(_CallParticipant instance) =>
<String, dynamic>{ <String, dynamic>{
'identity': instance.identity, 'identity': instance.identity,
'name': instance.name, 'name': instance.name,
'account_id': instance.accountId,
'account': instance.account?.toJson(),
'joined_at': instance.joinedAt.toIso8601String(), 'joined_at': instance.joinedAt.toIso8601String(),
}; };

View File

@@ -2,14 +2,13 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:island/pods/config.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/chat/call_button.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:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/chat/webrtc_manager.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:island/talker.dart'; import 'package:island/talker.dart';
@@ -43,37 +42,35 @@ sealed class CallParticipantLive with _$CallParticipantLive {
const factory CallParticipantLive({ const factory CallParticipantLive({
required CallParticipant participant, required CallParticipant participant,
required lk.Participant remoteParticipant, required WebRTCParticipant remoteParticipant,
}) = _CallParticipantLive; }) = _CallParticipantLive;
bool get isSpeaking => remoteParticipant.isSpeaking; bool get isSpeaking => false; // TODO: Implement speaking detection
bool get isMuted => bool get isMuted => !remoteParticipant.isAudioEnabled;
remoteParticipant.isMuted || !remoteParticipant.isMicrophoneEnabled(); bool get isScreenSharing => remoteParticipant.isVideoEnabled; // Simplified
bool get isScreenSharing => remoteParticipant.isScreenShareEnabled(); bool get isScreenSharingWithAudio => false; // TODO: Implement screen sharing
bool get isScreenSharingWithAudio =>
remoteParticipant.isScreenShareAudioEnabled();
bool get hasVideo => remoteParticipant.hasVideo; bool get hasVideo => remoteParticipant.isVideoEnabled;
bool get hasAudio => remoteParticipant.hasAudio; bool get hasAudio => remoteParticipant.isAudioEnabled;
} }
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class CallNotifier extends _$CallNotifier { class CallNotifier extends _$CallNotifier {
lk.Room? _room; WebRTCManager? _webrtcManager;
lk.LocalParticipant? _localParticipant;
List<CallParticipantLive> _participants = []; List<CallParticipantLive> _participants = [];
final Map<String, CallParticipant> _participantInfoByIdentity = {}; final Map<String, CallParticipant> _participantInfoByIdentity = {};
lk.EventsListener? _roomListener; StreamSubscription<WebRTCParticipant>? _participantJoinedSubscription;
StreamSubscription<String>? _participantLeftSubscription;
List<CallParticipantLive> get participants => List<CallParticipantLive> get participants =>
List.unmodifiable(_participants); List.unmodifiable(_participants);
lk.LocalParticipant? get localParticipant => _localParticipant;
Map<String, double> participantsVolumes = {}; Map<String, double> participantsVolumes = {};
Timer? _durationTimer; Timer? _durationTimer;
lk.Room? get room => _room; String? _roomId;
String? get roomId => _roomId;
@override @override
CallState build() { CallState build() {
@@ -87,149 +84,62 @@ class CallNotifier extends _$CallNotifier {
); );
} }
void _initRoomListeners() { void _initWebRTCListeners() {
if (_room == null) return; _participantJoinedSubscription?.cancel();
_roomListener?.dispose(); _participantLeftSubscription?.cancel();
_roomListener = _room!.createListener();
_room!.addListener(_onRoomChange);
_roomListener!
..on<lk.ParticipantConnectedEvent>((e) {
_refreshLiveParticipants();
})
..on<lk.RoomDisconnectedEvent>((e) {
_participants = [];
state = state.copyWith();
});
}
void _onRoomChange() { _participantJoinedSubscription = _webrtcManager?.onParticipantJoined.listen(
_refreshLiveParticipants(); (participant) {
} _updateLiveParticipantsFromWebRTC();
},
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,
);
}),
); );
_participantLeftSubscription = _webrtcManager?.onParticipantLeft.listen((
participantId,
) {
_participants.removeWhere((p) => p.remoteParticipant.id == participantId);
state = state.copyWith();
});
}
void _updateLiveParticipantsFromWebRTC() {
if (_webrtcManager == null) return;
final webrtcParticipants = _webrtcManager!.participants;
_participants =
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();
state = state.copyWith(); 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];
}
// 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(),
);
}
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)
: null;
}).whereType<CallParticipantLive>(),
);
state = state.copyWith();
}
String? _roomId;
String? get roomId => _roomId;
Future<void> joinRoom(String roomId) async { Future<void> joinRoom(String roomId) async {
if (_roomId == roomId && _room != null) { if (_roomId == roomId && _webrtcManager != null) {
talker.info('[Call] Call skipped. Already has data'); talker.info('[Call] Call skipped. Already connected to this room');
return; // Ensure state is connected even if we skip the join process
} else if (_room != null) { if (!state.isConnected) {
if (!_room!.isDisposed && state = state.copyWith(isConnected: true);
_room!.connectionState != lk.ConnectionState.disconnected) {
throw Exception('Call already connected');
} }
return;
} }
_roomId = roomId; _roomId = roomId;
if (_room != null) {
await _room!.disconnect(); // Clean up existing connection
await _room!.dispose(); await disconnect();
_room = null;
_localParticipant = null;
_participants = [];
}
try { try {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
final ongoingCall = await ref.read(ongoingCallProvider(roomId).future); final ongoingCall = await ref.read(ongoingCallProvider(roomId).future);
@@ -241,8 +151,11 @@ class CallNotifier extends _$CallNotifier {
// Parse join response // Parse join response
final joinResponse = ChatRealtimeJoinResponse.fromJson(data); final joinResponse = ChatRealtimeJoinResponse.fromJson(data);
final participants = joinResponse.participants; 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 // Setup duration timer
_durationTimer?.cancel(); _durationTimer?.cancel();
@@ -257,47 +170,18 @@ class CallNotifier extends _$CallNotifier {
); );
}); });
// Connect to LiveKit // Initialize WebRTC manager
_room = lk.Room(); final serverUrl = ref.watch(serverUrlProvider);
await _room!.connect( _webrtcManager = WebRTCManager(roomId: roomId, serverUrl: serverUrl);
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;
_initRoomListeners(); await _webrtcManager!.initialize(ref);
_updateLiveParticipants(participants); _initWebRTCListeners();
if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) { 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); state = state.copyWith(isConnected: true);
// Enable wakelock when call connects // Enable wakelock when call connects
WakelockPlus.enable(); WakelockPlus.enable();
@@ -310,104 +194,53 @@ class CallNotifier extends _$CallNotifier {
} }
Future<void> toggleMicrophone() async { Future<void> toggleMicrophone() async {
if (_localParticipant != null) { final target = !state.isMicrophoneEnabled;
const autostop = true; state = state.copyWith(isMicrophoneEnabled: target);
final target = !_localParticipant!.isMicrophoneEnabled(); await _webrtcManager?.toggleMicrophone(target);
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();
}
} }
Future<void> toggleCamera() async { Future<void> toggleCamera() async {
if (_localParticipant != null) { final target = !state.isCameraEnabled;
final target = !_localParticipant!.isCameraEnabled(); state = state.copyWith(isCameraEnabled: target);
state = state.copyWith(isCameraEnabled: target); await _webrtcManager?.toggleCamera(target);
await _localParticipant!.setCameraEnabled(target);
state = state.copyWith();
}
} }
Future<void> toggleScreenShare(BuildContext context) async { Future<void> toggleScreenShare(BuildContext context) async {
if (_localParticipant != null) { // TODO: Implement screen sharing for WebRTC
final target = !_localParticipant!.isScreenShareEnabled(); state = state.copyWith(isScreenSharing: !state.isScreenSharing);
state = state.copyWith(isScreenSharing: target);
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;
} else {
await _localParticipant!.setScreenShareEnabled(target);
}
state = state.copyWith();
}
} }
Future<void> toggleSpeakerphone() async { Future<void> toggleSpeakerphone() async {
state = state.copyWith(isSpeakerphone: !state.isSpeakerphone); state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
await lk.Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone); // TODO: Implement speakerphone control for WebRTC
state = state.copyWith();
} }
Future<void> disconnect() async { Future<void> disconnect() async {
if (_room != null) { _webrtcManager?.dispose();
await _room!.disconnect(); _webrtcManager = null;
state = state.copyWith( _participantJoinedSubscription?.cancel();
isConnected: false, _participantLeftSubscription?.cancel();
isMicrophoneEnabled: false, _participants.clear();
isCameraEnabled: false, state = state.copyWith(
isScreenSharing: false, isConnected: false,
); isMicrophoneEnabled: false,
// Disable wakelock when call disconnects isCameraEnabled: false,
WakelockPlus.disable(); isScreenSharing: false,
} );
// Disable wakelock when call disconnects
WakelockPlus.disable();
} }
void setParticipantVolume(CallParticipantLive live, double volume) { void setParticipantVolume(CallParticipantLive live, double volume) {
if (participantsVolumes[live.remoteParticipant.sid] == null) { if (participantsVolumes[live.remoteParticipant.id] == null) {
participantsVolumes[live.remoteParticipant.sid] = 1; participantsVolumes[live.remoteParticipant.id] = 1;
} }
Helper.setVolume( // TODO: Implement volume control for WebRTC
volume, participantsVolumes[live.remoteParticipant.id] = volume;
live
.remoteParticipant
.audioTrackPublications
.first
.track!
.mediaStreamTrack,
);
participantsVolumes[live.remoteParticipant.sid] = volume;
} }
double getParticipantVolume(CallParticipantLive live) { double getParticipantVolume(CallParticipantLive live) {
return participantsVolumes[live.remoteParticipant.sid] ?? 1; return participantsVolumes[live.remoteParticipant.id] ?? 1;
} }
void dispose() { void dispose() {
@@ -418,9 +251,10 @@ class CallNotifier extends _$CallNotifier {
isCameraEnabled: false, isCameraEnabled: false,
isScreenSharing: false, isScreenSharing: false,
); );
_roomListener?.dispose(); _participantJoinedSubscription?.cancel();
_room?.removeListener(_onRoomChange); _participantLeftSubscription?.cancel();
_room?.dispose(); _webrtcManager?.dispose();
_webrtcManager = null;
_durationTimer?.cancel(); _durationTimer?.cancel();
_roomId = null; _roomId = null;
participantsVolumes = {}; participantsVolumes = {};

View File

@@ -295,7 +295,7 @@ as String?,
/// @nodoc /// @nodoc
mixin _$CallParticipantLive implements DiagnosticableTreeMixin { mixin _$CallParticipantLive implements DiagnosticableTreeMixin {
CallParticipant get participant; lk.Participant get remoteParticipant; CallParticipant get participant; WebRTCParticipant get remoteParticipant;
/// Create a copy of CallParticipantLive /// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -332,7 +332,7 @@ abstract mixin class $CallParticipantLiveCopyWith<$Res> {
factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl; factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
CallParticipant participant, lk.Participant remoteParticipant CallParticipant participant, WebRTCParticipant remoteParticipant
}); });
@@ -353,7 +353,7 @@ class _$CallParticipantLiveCopyWithImpl<$Res>
return _then(_self.copyWith( return _then(_self.copyWith(
participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable 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 CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
as lk.Participant, as WebRTCParticipant,
)); ));
} }
/// Create a copy of CallParticipantLive /// 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) { switch (_that) {
case _CallParticipantLive() when $default != null: case _CallParticipantLive() when $default != null:
return $default(_that.participant,_that.remoteParticipant);case _: 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) { switch (_that) {
case _CallParticipantLive(): case _CallParticipantLive():
return $default(_that.participant,_that.remoteParticipant);} 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) { switch (_that) {
case _CallParticipantLive() when $default != null: case _CallParticipantLive() when $default != null:
return $default(_that.participant,_that.remoteParticipant);case _: return $default(_that.participant,_that.remoteParticipant);case _:
@@ -501,7 +501,7 @@ class _CallParticipantLive extends CallParticipantLive with DiagnosticableTreeMi
@override final CallParticipant participant; @override final CallParticipant participant;
@override final lk.Participant remoteParticipant; @override final WebRTCParticipant remoteParticipant;
/// Create a copy of CallParticipantLive /// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values. /// 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; factory _$CallParticipantLiveCopyWith(_CallParticipantLive value, $Res Function(_CallParticipantLive) _then) = __$CallParticipantLiveCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
CallParticipant participant, lk.Participant remoteParticipant CallParticipant participant, WebRTCParticipant remoteParticipant
}); });
@@ -560,7 +560,7 @@ class __$CallParticipantLiveCopyWithImpl<$Res>
return _then(_CallParticipantLive( return _then(_CallParticipantLive(
participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable 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 CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
as lk.Participant, as WebRTCParticipant,
)); ));
} }

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281'; String _$callNotifierHash() => r'91e546c8711d1b46740ad592cbe481173b227e7b';
/// See also [CallNotifier]. /// See also [CallNotifier].
@ProviderFor(CallNotifier) @ProviderFor(CallNotifier)

View File

@@ -0,0 +1,287 @@
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/talker.dart';
class WebRTCParticipant {
final String id;
final String name;
final SnAccount userinfo;
RTCPeerConnection? peerConnection;
MediaStream? remoteStream;
bool isAudioEnabled = true;
bool isVideoEnabled = false;
bool isConnected = false;
WebRTCParticipant({
required this.id,
required this.name,
required this.userinfo,
});
}
class WebRTCManager {
final String roomId;
final String serverUrl;
late WebRTCSignaling _signaling;
final Map<String, WebRTCParticipant> _participants = {};
final Map<String, RTCPeerConnection> _peerConnections = {};
MediaStream? _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 {
await _initializeLocalStream();
_setupSignalingListeners();
await _signaling.connect(ref);
}
Future<void> _initializeLocalStream() async {
try {
_localStream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': false,
});
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;
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;
_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);
}
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);
}
}
Future<void> _handleIceCandidate(
String from,
Map<String, dynamic> data,
) async {
final participantId = from;
final candidate = RTCIceCandidate(
data['candidate'],
data['sdpMid'],
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);
}
}
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;
});
}
}
List<WebRTCParticipant> get participants => _participants.values.toList();
void dispose() {
_signaling.disconnect();
for (final pc in _peerConnections.values) {
pc.close();
}
_peerConnections.clear();
_participants.clear();
_localStream?.dispose();
_participantController.close();
_participantLeftController.close();
}
}

View File

@@ -0,0 +1,204 @@
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/userinfo.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 final SnAccount user;
final StreamController<SignalingMessage> _messageController =
StreamController<SignalingMessage>.broadcast();
final StreamController<WebRTCWelcomeMessage> _welcomeController =
StreamController<WebRTCWelcomeMessage>.broadcast();
WebSocketChannel? _channel;
Stream<SignalingMessage> get messages => _messageController.stream;
Stream<WebRTCWelcomeMessage> get welcomeMessages => _welcomeController.stream;
WebRTCSignaling({required this.roomId});
Future<void> 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',
);
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;
_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 disconnect() {
_channel?.sink.close();
_messageController.close();
_welcomeController.close();
}
}

View 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

View 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(),
};

View File

@@ -100,12 +100,16 @@ class WebSocketService {
} }
}, },
onDone: () { onDone: () {
talker.info('[WebSocket] Connection closed, attempting to reconnect...'); talker.info(
'[WebSocket] Connection closed, attempting to reconnect...',
);
_scheduleReconnect(); _scheduleReconnect();
_statusStreamController.sink.add(WebSocketState.disconnected()); _statusStreamController.sink.add(WebSocketState.disconnected());
}, },
onError: (error) { onError: (error) {
talker.error('[WebSocket] Error occurred: $error, attempting to reconnect...'); talker.error(
'[WebSocket] Error occurred: $error, attempting to reconnect...',
);
_scheduleReconnect(); _scheduleReconnect();
_statusStreamController.sink.add( _statusStreamController.sink.add(
WebSocketState.error(error.toString()), WebSocketState.error(error.toString()),

View File

@@ -1,5 +1,5 @@
import 'package:easy_localization/easy_localization.dart'; 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:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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_button.dart';
import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.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:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -26,32 +24,13 @@ class CallScreen extends HookConsumerWidget {
useEffect(() { useEffect(() {
talker.info('[Call] Joining the call...'); talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) { Future(() {
showConfirmAlert( callNotifier.joinRoom(roomId);
'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);
});
}); });
return null; return null;
}, []); }, []);
final allAudioOnly = callNotifier.participants.every( final allAudioOnly = callNotifier.participants.every((p) => !p.hasVideo);
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
@@ -67,12 +46,7 @@ class CallScreen extends HookConsumerWidget {
Text( Text(
callState.isConnected callState.isConnected
? formatDuration(callState.duration) ? formatDuration(callState.duration)
: (switch (callNotifier.room?.connectionState) { : 'connecting'.tr(),
ConnectionState.connected => 'connected',
ConnectionState.connecting => 'connecting',
ConnectionState.reconnecting => 'reconnecting',
_ => 'disconnected',
}).tr(),
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
], ],
@@ -159,19 +133,7 @@ class CallScreen extends HookConsumerWidget {
// Stage view: show main speaker(s) large, others in row // Stage view: show main speaker(s) large, others in row
final mainSpeakers = final mainSpeakers =
participants participants.where((p) => p.hasVideo).toList();
.where(
(p) => p
.remoteParticipant
.trackPublications
.values
.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO,
),
)
.toList();
if (mainSpeakers.isEmpty && participants.isNotEmpty) { if (mainSpeakers.isEmpty && participants.isNotEmpty) {
mainSpeakers.add(participants.first); mainSpeakers.add(participants.first);
} }

View File

@@ -16,7 +16,7 @@ Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
if (roomId.isEmpty) return null; if (roomId.isEmpty) return null;
try { try {
final apiClient = ref.watch(apiClientProvider); 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); return SnRealtimeCall.fromJson(resp.data);
} catch (e) { } catch (e) {
if (e is DioException && e.response?.statusCode == 404) { if (e is DioException && e.response?.statusCode == 404) {

View File

@@ -6,7 +6,7 @@ part of 'call_button.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$ongoingCallHash() => r'48031badb79efa07aefb3a4fc51635be457bd3f9'; String _$ongoingCallHash() => r'0f14b36393276720a06190cab3dc8d5e4c88cd57';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -10,7 +10,7 @@ import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.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 { class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key}); const CallControlsBar({super.key});
@@ -194,9 +194,16 @@ class CallControlsBar extends HookConsumerWidget {
String deviceType, String deviceType,
) async { ) async {
try { try {
final devices = await Hardware.instance.enumerateDevices( final devices = await navigator.mediaDevices.enumerateDevices();
type: deviceType, 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; if (!context.mounted) return;
@@ -209,9 +216,9 @@ class CallControlsBar extends HookConsumerWidget {
? 'selectCamera'.tr() ? 'selectCamera'.tr()
: 'selectMicrophone'.tr(), : 'selectMicrophone'.tr(),
child: ListView.builder( child: ListView.builder(
itemCount: devices.length, itemCount: filteredDevices.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final device = devices[index]; final device = filteredDevices[index];
return ListTile( return ListTile(
title: Text( title: Text(
device.label.isNotEmpty device.label.isNotEmpty
@@ -236,35 +243,12 @@ class CallControlsBar extends HookConsumerWidget {
Future<void> _switchDevice( Future<void> _switchDevice(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
MediaDevice device, MediaDeviceInfo device,
String deviceType, String deviceType,
) async { ) async {
try { try {
final callNotifier = ref.read(callNotifierProvider.notifier); // TODO: Implement device switching for WebRTC
// This would require restarting the media stream with the new device
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);
}
} 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),
);
}
}
if (context.mounted) { if (context.mounted) {
showSnackBar( showSnackBar(
'switchedTo'.tr( 'switchedTo'.tr(
@@ -289,31 +273,9 @@ class CallOverlayBar extends HookConsumerWidget {
if (!callState.isConnected) return const SizedBox.shrink(); if (!callState.isConnected) return const SizedBox.shrink();
final lastSpeaker = final lastSpeaker =
callNotifier.participants callNotifier.participants.isNotEmpty
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.isEmpty
? callNotifier.participants.first ? callNotifier.participants.first
: callNotifier.participants : null;
.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,
);
final actionButtonStyle = ButtonStyle( final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)), minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
@@ -330,17 +292,16 @@ class CallOverlayBar extends HookConsumerWidget {
children: [ children: [
Builder( Builder(
builder: (context) { builder: (context) {
if (callNotifier.localParticipant == null) { if (lastSpeaker == null) {
return CircularProgressIndicator().center(); return const CircularProgressIndicator();
} }
return SizedBox( return SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: child: SpeakingRippleAvatar(
SpeakingRippleAvatar( live: lastSpeaker,
live: lastSpeaker, size: 36,
size: 36, ),
).center(),
); );
}, },
), ),
@@ -348,7 +309,9 @@ class CallOverlayBar extends HookConsumerWidget {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('@${lastSpeaker.participant.identity}').bold(), Text(
'@${lastSpeaker?.participant.identity ?? 'Unknown'}',
),
Text( Text(
formatDuration(callState.duration), formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,

View File

@@ -7,7 +7,6 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/call.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/widgets/account/account_nameplate.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:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -66,19 +65,17 @@ class CallParticipantCard extends HookConsumerWidget {
children: [ children: [
const Icon(Symbols.wifi, size: 16), const Icon(Symbols.wifi, size: 16),
const Gap(8), const Gap(8),
Text(switch (live.remoteParticipant.connectionQuality) { Text(
ConnectionQuality.excellent => 'Excellent', live.remoteParticipant.isConnected
ConnectionQuality.good => 'Good', ? 'Connected'
ConnectionQuality.poor => 'Bad', : 'Connecting',
ConnectionQuality.lost => 'Lost', ),
_ => 'Connecting',
}),
], ],
), ),
], ],
).padding(horizontal: 20, top: 16), ).padding(horizontal: 20, top: 16),
AccountNameplate( AccountNameplate(
name: live.participant.identity, name: live.remoteParticipant.userinfo.name,
isOutlined: false, isOutlined: false,
), ),
], ],

View File

@@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/call.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/chat/call_participant_card.dart';
import 'package:island/widgets/content/cloud_files.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:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -16,10 +15,9 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider(live.participant.identity));
final avatarRadius = size / 2; final avatarRadius = size / 2;
final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0); // TODO: Implement audio level detection for WebRTC
final clampedLevel = 0.0;
final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); final rippleRadius = avatarRadius + clampedLevel * (size * 0.333);
return SizedBox( return SizedBox(
width: size + 8, width: size + 8,
@@ -27,7 +25,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
tween: Tween<double>( tween: Tween<double>(
begin: avatarRadius, begin: avatarRadius,
end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius, end: live.isSpeaking ? rippleRadius : avatarRadius,
), ),
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeOut, curve: Curves.easeOut,
@@ -35,7 +33,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
if (live.remoteParticipant.isSpeaking) if (live.isSpeaking)
Container( Container(
width: animatedRadius * 2, width: animatedRadius * 2,
height: animatedRadius * 2, height: animatedRadius * 2,
@@ -49,28 +47,15 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
height: size, height: size,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration(shape: BoxShape.circle), decoration: BoxDecoration(shape: BoxShape.circle),
child: account.when( child: CallParticipantGestureDetector(
data: participant: live,
(value) => CallParticipantGestureDetector( child: ProfilePictureWidget(
participant: live, file: live.remoteParticipant.userinfo.profile.picture,
child: ProfilePictureWidget( radius: size / 2,
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(),
),
), ),
), ),
if (live.remoteParticipant.isMuted) if (live.isMuted)
Positioned( Positioned(
bottom: 4, bottom: 4,
right: 4, right: 4,
@@ -103,25 +88,15 @@ class CallParticipantTile extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final hasVideo = if (live.hasVideo && live.remoteParticipant.remoteStream != null) {
live.hasVideo &&
live.remoteParticipant.trackPublications.values
.where((pub) => pub.track != null && pub.kind == TrackType.VIDEO)
.isNotEmpty;
if (hasVideo) {
return Stack( return Stack(
fit: StackFit.loose, fit: StackFit.loose,
children: [ children: [
AspectRatio( AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: VideoTrackRenderer( child: RTCVideoView(
live.remoteParticipant.trackPublications.values RTCVideoRenderer()
.where((track) => track.kind == TrackType.VIDEO) ..srcObject = live.remoteParticipant.remoteStream,
.first
.track
as VideoTrack,
renderMode: VideoRenderMode.platformView,
), ),
), ),
Positioned( Positioned(

View File

@@ -44,10 +44,12 @@ void showInfoAlert(String message, String title) async {
Future<bool> showConfirmAlert(String message, String title) async { Future<bool> showConfirmAlert(String message, String title) async {
final result = await js.context.callMethod('swal', [ final result = await js.context.callMethod('swal', [
title, js.JsObject.jsify({
message, 'title': title,
'question', 'text': message,
{'buttons': true}, 'icon': 'info',
'buttons': {'cancel': true, 'confirm': true},
}),
]); ]);
return result == true; return result == true;
} }

View File

@@ -193,10 +193,10 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> {
// Perform biometric authentication // Perform biometric authentication
final bool didAuthenticate = await _localAuth.authenticate( final bool didAuthenticate = await _localAuth.authenticate(
localizedReason: 'biometricPrompt'.tr(), localizedReason: 'biometricPrompt'.tr(),
options: const AuthenticationOptions( // options: const AuthenticationOptions(
biometricOnly: true, // biometricOnly: true,
stickyAuth: true, // stickyAuth: true,
), // ),
); );
if (didAuthenticate) { if (didAuthenticate) {

View File

@@ -15,7 +15,6 @@
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h> #include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <gtk/gtk_plugin.h> #include <gtk/gtk_plugin.h>
#include <irondash_engine_context/irondash_engine_context_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_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
#include <pasteboard/pasteboard_plugin.h> #include <pasteboard/pasteboard_plugin.h>
@@ -57,9 +56,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin");
irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); 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 = g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);

View File

@@ -12,7 +12,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_webrtc flutter_webrtc
gtk gtk
irondash_engine_context irondash_engine_context
livekit_client
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video
pasteboard pasteboard

View File

@@ -6,7 +6,6 @@ import FlutterMacOS
import Foundation import Foundation
import app_links import app_links
import connectivity_plus
import device_info_plus import device_info_plus
import file_picker import file_picker
import file_saver import file_saver
@@ -24,7 +23,6 @@ import flutter_udid
import flutter_webrtc import flutter_webrtc
import gal import gal
import irondash_engine_context import irondash_engine_context
import livekit_client
import local_auth_darwin import local_auth_darwin
import media_kit_libs_macos_video import media_kit_libs_macos_video
import media_kit_video import media_kit_video
@@ -48,7 +46,6 @@ import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
@@ -66,7 +63,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))

View File

@@ -289,22 +289,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: console:
dependency: transitive dependency: transitive
description: description:
@@ -930,10 +914,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5" sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "19.4.2" version: "19.5.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
@@ -991,10 +975,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_native_splash name: flutter_native_splash
sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.6" version: "2.4.7"
flutter_otp_text_field: flutter_otp_text_field:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1201,10 +1185,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: c752e2d08d088bf83742cb05bf83003f3e9d276ff1519b5c92f9d5e60e5ddd23 sha256: e1d7ffb0db475e6e845eb58b44768f50b830e23960e3df6908924acd8f7f70ea
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "16.2.4" version: "16.2.5"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1321,10 +1305,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker_android name: image_picker_android
sha256: dd7a61daaa5896cc34b7bc95f66c60225ae6bee0d167dde0e21a9d9016cac0dc sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+4" version: "0.8.13+5"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@@ -1469,38 +1453,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: local_auth:
dependency: "direct main" dependency: "direct main"
description: description:
name: local_auth name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" sha256: a4f1bf57f0236a4aeb5e8f0ec180e197f4b112a3456baa6c1e73b546630b0422
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "3.0.0"
local_auth_android: local_auth_android:
dependency: transitive dependency: transitive
description: description:
name: local_auth_android name: local_auth_android
sha256: b2446c74fab1db37f828d4c54adaa3f003df80a29f5cbd710bbb8883d302e991 sha256: d836715ed95b16b2de3a8c47a88ba5e607976bb1e27c9446d193152ea1429fae
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.55" version: "2.0.0"
local_auth_darwin: local_auth_darwin:
dependency: transitive dependency: transitive
description: description:
name: local_auth_darwin name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" sha256: "15d9db4ad4d58a11d7269e55d46ff8d49ed5e856226c8a5a91280f0d7c37b3a6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.1" version: "2.0.0"
local_auth_platform_interface: local_auth_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1513,10 +1489,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: local_auth_windows name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 sha256: d95535a73eddf57ce5930d5e78a0fa4f294c31981fdeeee83325b797302be454
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.11" version: "2.0.0"
logger: logger:
dependency: transitive dependency: transitive
description: description:
@@ -1669,14 +1645,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: modal_bottom_sheet:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1709,14 +1677,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:
@@ -1933,14 +1893,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.4" version: "1.2.4"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e
url: "https://pub.dev"
source: hosted
version: "4.2.0"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@@ -2206,14 +2158,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" 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: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -38,7 +38,7 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_hooks: ^0.21.3+1 flutter_hooks: ^0.21.3+1
hooks_riverpod: ^2.6.1 hooks_riverpod: ^2.6.1
go_router: ^16.2.4 go_router: ^16.2.5
styled_widget: ^0.4.1 styled_widget: ^0.4.1
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
@@ -75,7 +75,7 @@ dependencies:
file_picker: ^10.3.3 file_picker: ^10.3.3
riverpod_annotation: ^2.6.1 riverpod_annotation: ^2.6.1
image_picker_platform_interface: ^2.11.0 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 super_context_menu: ^0.9.1
modal_bottom_sheet: ^3.0.0 modal_bottom_sheet: ^3.0.0
firebase_messaging: ^16.0.3 firebase_messaging: ^16.0.3
@@ -97,12 +97,12 @@ dependencies:
avatar_stack: ^3.0.0 avatar_stack: ^3.0.0
markdown_widget: ^2.3.2+8 markdown_widget: ^2.3.2+8
visibility_detector: ^0.4.0+2 visibility_detector: ^0.4.0+2
flutter_native_splash: ^2.4.6 flutter_native_splash: ^2.4.7
photo_view: ^0.15.0 photo_view: ^0.15.0
gal: ^2.3.2 gal: ^2.3.2
dismissible_page: ^1.0.2 dismissible_page: ^1.0.2
super_sliver_list: ^0.4.1 super_sliver_list: ^0.4.1
livekit_client: ^2.5.1
pasteboard: ^0.4.0 pasteboard: ^0.4.0
flutter_colorpicker: ^1.1.0 flutter_colorpicker: ^1.1.0
image: ^4.5.4 image: ^4.5.4
@@ -117,7 +117,7 @@ dependencies:
sign_in_with_apple: ^7.0.1 sign_in_with_apple: ^7.0.1
flutter_svg: ^2.2.1 flutter_svg: ^2.2.1
native_exif: ^0.6.2 native_exif: ^0.6.2
local_auth: ^2.3.0 local_auth: ^3.0.0
flutter_secure_storage: ^9.2.4 flutter_secure_storage: ^9.2.4
flutter_math_fork: ^0.7.4 flutter_math_fork: ^0.7.4
share_plus: ^12.0.0 share_plus: ^12.0.0
@@ -142,7 +142,7 @@ dependencies:
file_saver: ^0.3.1 file_saver: ^0.3.1
tray_manager: ^0.5.1 tray_manager: ^0.5.1
flutter_webrtc: ^1.2.0 flutter_webrtc: ^1.2.0
flutter_local_notifications: ^19.4.2 flutter_local_notifications: ^19.5.0
wakelock_plus: ^1.4.0 wakelock_plus: ^1.4.0
slide_countdown: ^2.0.2 slide_countdown: ^2.0.2
shelf: ^1.4.2 shelf: ^1.4.2

View File

@@ -7,7 +7,6 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.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 <dart_ipc/dart_ipc_plugin_c_api.h>
#include <file_saver/file_saver_plugin.h> #include <file_saver/file_saver_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
@@ -20,7 +19,6 @@
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h> #include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <gal/gal_plugin_c_api.h> #include <gal/gal_plugin_c_api.h>
#include <irondash_engine_context/irondash_engine_context_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 <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_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
@@ -40,8 +38,6 @@
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar( AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi")); registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DartIpcPluginCApiRegisterWithRegistrar( DartIpcPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DartIpcPluginCApi")); registry->GetRegistrarForPlugin("DartIpcPluginCApi"));
FileSaverPluginRegisterWithRegistrar( FileSaverPluginRegisterWithRegistrar(
@@ -66,8 +62,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("GalPluginCApi")); registry->GetRegistrarForPlugin("GalPluginCApi"));
IrondashEngineContextPluginCApiRegisterWithRegistrar( IrondashEngineContextPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi"));
LiveKitPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LiveKitPlugin"));
LocalAuthPluginRegisterWithRegistrar( LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin")); registry->GetRegistrarForPlugin("LocalAuthPlugin"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(

View File

@@ -4,7 +4,6 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
app_links app_links
connectivity_plus
dart_ipc dart_ipc
file_saver file_saver
file_selector_windows file_selector_windows
@@ -17,7 +16,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_webrtc flutter_webrtc
gal gal
irondash_engine_context irondash_engine_context
livekit_client
local_auth_windows local_auth_windows
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video