Compare commits

...

16 Commits

Author SHA1 Message Date
0622498f4e Fake call audio level 2025-10-19 19:34:22 +08:00
844efcda1a 👔 Disable the video by default 2025-10-19 19:23:17 +08:00
98e39cce6a 🐛 Fix the duplicate local participant 2025-10-19 19:23:01 +08:00
0c459bf7e3 ♻️ Proper singaling 2025-10-19 19:16:40 +08:00
a2576abee0 ♻️ Proper local participant 2025-10-19 19:06:14 +08:00
f4b28c3fa2 WebRTC signaling heartbeat 2025-10-19 18:58:54 +08:00
43d767bc03 🐛 Trying to fix something 2025-10-19 18:31:05 +08:00
0910be88ef 🐛 Bug fixes of webrtc 2025-10-19 18:22:03 +08:00
e96b1fd9d4 Impl todos for the webrtc 2025-10-19 17:59:25 +08:00
3f83bbc1d8 ♻️ Trying out the new built-in webrtc 2025-10-19 17:30:06 +08:00
001549b190 💄 Fix notification bottom sheet covered with sheet 2025-10-15 22:55:37 +08:00
4595865ad3 🐛 Bug fixes on windows 2025-10-15 22:49:43 +08:00
LittleSheep
1834643167 🔀 Merge pull request #184 from Texas0295/v3
[FIX] linux: restrict setAsFrameless to Wayland only
2025-10-15 20:05:40 +08:00
Texas0295
0e816eaa3e [FIX] wayland: restrict setAsFrameless to Wayland only
Prevent unconditional frameless calls on non-Wayland platforms.

Signed-off-by: Texas0295 <kimura@texas0295.top>
2025-10-15 02:24:40 +08:00
LittleSheep
7c1f24b824 🔀 Merge pull request #183 from Texas0295/v3
[FIX] linux: correct Wayland window buffer mismatch by setAsFreameless()
2025-10-15 00:47:58 +08:00
Texas0295
b7d44d96ba [FIX] linux: correct Wayland window buffer mismatch by setAsFreameless()
Wayland (Hyprland, Sway, etc.) compositors misreport window buffer geometry
when using transparent + hidden titlebar settings.

This causes Flutter to render outside the actual visible region
and the debug banner to be cropped offscreen.

Calling windowManager.setAsFrameless() once after window creation
forces compositor to reconfigure the surface and align buffer size
with the visible client area.

No effect on X11, Windows, or macOS.

Signed-off-by: Texas0295 <kimura@texas0295.top>
2025-10-15 00:06:03 +08:00
35 changed files with 1843 additions and 594 deletions

View File

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

View File

@@ -120,13 +120,24 @@ void main() async {
windowButtonVisibility: true,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
final env = Platform.environment;
final isWayland = env.containsKey('WAYLAND_DISPLAY');
if (isWayland) {
try {
await windowManager.setAsFrameless();
} catch (e) {
debugPrint('[Wayland] setAsFrameless failed: $e');
}
}
await windowManager.setMinimumSize(defaultSize);
await windowManager.show();
await windowManager.focus();
final opacity = prefs.getDouble(kAppWindowOpacity) ?? 1.0;
await windowManager.setOpacity(opacity);
talker.info(
"[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}",
"[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}"
"${isWayland ? " (Wayland frameless fix applied)" : ""}",
);
});
}

View File

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

View File

@@ -2241,7 +2241,7 @@ as List<CallParticipant>,
/// @nodoc
mixin _$CallParticipant {
String get identity; String get name; DateTime get joinedAt;
String get identity; String get name; String get accountId; SnAccount? get account; DateTime get joinedAt;
/// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -2254,16 +2254,16 @@ $CallParticipantCopyWith<CallParticipant> get copyWith => _$CallParticipantCopyW
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,identity,name,joinedAt);
int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt);
@override
String toString() {
return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)';
return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt)';
}
@@ -2274,11 +2274,11 @@ abstract mixin class $CallParticipantCopyWith<$Res> {
factory $CallParticipantCopyWith(CallParticipant value, $Res Function(CallParticipant) _then) = _$CallParticipantCopyWithImpl;
@useResult
$Res call({
String identity, String name, DateTime joinedAt
String identity, String name, String accountId, SnAccount? account, DateTime joinedAt
});
$SnAccountCopyWith<$Res>? get account;
}
/// @nodoc
@@ -2291,15 +2291,29 @@ class _$CallParticipantCopyWithImpl<$Res>
/// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? accountId = null,Object? account = freezed,Object? joinedAt = null,}) {
return _then(_self.copyWith(
identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable
as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
/// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}
}
@@ -2378,10 +2392,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String identity, String name, DateTime joinedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String identity, String name, String accountId, SnAccount? account, DateTime joinedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _CallParticipant() when $default != null:
return $default(_that.identity,_that.name,_that.joinedAt);case _:
return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);case _:
return orElse();
}
@@ -2399,10 +2413,10 @@ return $default(_that.identity,_that.name,_that.joinedAt);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String identity, String name, DateTime joinedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String identity, String name, String accountId, SnAccount? account, DateTime joinedAt) $default,) {final _that = this;
switch (_that) {
case _CallParticipant():
return $default(_that.identity,_that.name,_that.joinedAt);}
return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -2416,10 +2430,10 @@ return $default(_that.identity,_that.name,_that.joinedAt);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String identity, String name, DateTime joinedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String identity, String name, String accountId, SnAccount? account, DateTime joinedAt)? $default,) {final _that = this;
switch (_that) {
case _CallParticipant() when $default != null:
return $default(_that.identity,_that.name,_that.joinedAt);case _:
return $default(_that.identity,_that.name,_that.accountId,_that.account,_that.joinedAt);case _:
return null;
}
@@ -2431,11 +2445,13 @@ return $default(_that.identity,_that.name,_that.joinedAt);case _:
@JsonSerializable()
class _CallParticipant implements CallParticipant {
const _CallParticipant({required this.identity, required this.name, required this.joinedAt});
const _CallParticipant({required this.identity, required this.name, required this.accountId, this.account = null, required this.joinedAt});
factory _CallParticipant.fromJson(Map<String, dynamic> json) => _$CallParticipantFromJson(json);
@override final String identity;
@override final String name;
@override final String accountId;
@override@JsonKey() final SnAccount? account;
@override final DateTime joinedAt;
/// Create a copy of CallParticipant
@@ -2451,16 +2467,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,identity,name,joinedAt);
int get hashCode => Object.hash(runtimeType,identity,name,accountId,account,joinedAt);
@override
String toString() {
return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)';
return 'CallParticipant(identity: $identity, name: $name, accountId: $accountId, account: $account, joinedAt: $joinedAt)';
}
@@ -2471,11 +2487,11 @@ abstract mixin class _$CallParticipantCopyWith<$Res> implements $CallParticipant
factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl;
@override @useResult
$Res call({
String identity, String name, DateTime joinedAt
String identity, String name, String accountId, SnAccount? account, DateTime joinedAt
});
@override $SnAccountCopyWith<$Res>? get account;
}
/// @nodoc
@@ -2488,16 +2504,30 @@ class __$CallParticipantCopyWithImpl<$Res>
/// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? accountId = null,Object? account = freezed,Object? joinedAt = null,}) {
return _then(_CallParticipant(
identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable
as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
/// Create a copy of CallParticipant
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}
}

View File

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

View File

@@ -3,13 +3,15 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/pods/network.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/chat/webrtc_manager.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:island/talker.dart';
@@ -43,193 +45,212 @@ sealed class CallParticipantLive with _$CallParticipantLive {
const factory CallParticipantLive({
required CallParticipant participant,
required lk.Participant remoteParticipant,
required WebRTCParticipant remoteParticipant,
}) = _CallParticipantLive;
bool get isSpeaking => remoteParticipant.isSpeaking;
bool get isMuted =>
remoteParticipant.isMuted || !remoteParticipant.isMicrophoneEnabled();
bool get isScreenSharing => remoteParticipant.isScreenShareEnabled();
bool get isScreenSharingWithAudio =>
remoteParticipant.isScreenShareAudioEnabled();
bool get isSpeaking {
// Use the actual audio level from WebRTC monitoring
return remoteParticipant.audioLevel > 0.1; // Threshold for speaking
}
bool get hasVideo => remoteParticipant.hasVideo;
bool get hasAudio => remoteParticipant.hasAudio;
double get audioLevel => remoteParticipant.audioLevel;
bool get isMuted => !remoteParticipant.isAudioEnabled;
bool get isScreenSharing => remoteParticipant.isVideoEnabled; // Simplified
bool get isScreenSharingWithAudio => false; // TODO: Implement screen sharing
bool get hasVideo => remoteParticipant.isVideoEnabled;
bool get hasAudio => remoteParticipant.isAudioEnabled;
}
@Riverpod(keepAlive: true)
class CallNotifier extends _$CallNotifier {
lk.Room? _room;
lk.LocalParticipant? _localParticipant;
WebRTCManager? _webrtcManager;
List<CallParticipantLive> _participants = [];
final Map<String, CallParticipant> _participantInfoByIdentity = {};
lk.EventsListener? _roomListener;
StreamSubscription<WebRTCParticipant>? _participantJoinedSubscription;
StreamSubscription<String>? _participantLeftSubscription;
List<CallParticipantLive> get participants =>
List.unmodifiable(_participants);
lk.LocalParticipant? get localParticipant => _localParticipant;
Map<String, double> participantsVolumes = {};
Timer? _durationTimer;
lk.Room? get room => _room;
String? _roomId;
String? get roomId => _roomId;
WebRTCManager? get webrtcManager => _webrtcManager;
@override
CallState build() {
// Subscribe to websocket updates
return const CallState(
isConnected: false,
isMicrophoneEnabled: true,
isCameraEnabled: false,
isMicrophoneEnabled:
true, // Audio enabled by default (matches WebRTC init)
isCameraEnabled: true, // Video enabled by default (matches WebRTC init)
isScreenSharing: false,
isSpeakerphone: true,
);
}
void _initRoomListeners() {
if (_room == null) return;
_roomListener?.dispose();
_roomListener = _room!.createListener();
_room!.addListener(_onRoomChange);
_roomListener!
..on<lk.ParticipantConnectedEvent>((e) {
_refreshLiveParticipants();
})
..on<lk.RoomDisconnectedEvent>((e) {
_participants = [];
state = state.copyWith();
});
}
void _initWebRTCListeners() {
_participantJoinedSubscription?.cancel();
_participantLeftSubscription?.cancel();
void _onRoomChange() {
_refreshLiveParticipants();
}
void _refreshLiveParticipants() {
if (_room == null) return;
final remoteParticipants = _room!.remoteParticipants;
_participants = [];
// Add local participant first if available
if (_localParticipant != null) {
final localInfo = _buildParticipant();
_participants.add(
CallParticipantLive(
participant: localInfo,
remoteParticipant: _localParticipant!,
),
);
}
// Add remote participants
_participants.addAll(
remoteParticipants.values.map((remote) {
final match =
_participantInfoByIdentity[remote.identity] ??
CallParticipant(
identity: remote.identity,
name: remote.identity,
joinedAt: DateTime.now(),
);
return CallParticipantLive(
participant: match,
remoteParticipant: remote,
);
}),
_participantJoinedSubscription = _webrtcManager?.onParticipantJoined.listen(
(participant) {
_updateLiveParticipantsFromWebRTC();
},
);
_participantLeftSubscription = _webrtcManager?.onParticipantLeft.listen((
participantId,
) {
_participants.removeWhere((p) => p.remoteParticipant.id == participantId);
state = state.copyWith();
});
// Add local participant immediately when WebRTC is initialized
final userinfo = ref.watch(userInfoProvider);
if (userinfo.value != null) {
_addLocalParticipant(userinfo.value!);
}
}
void _addLocalParticipant(SnAccount userinfo) {
if (_webrtcManager == null) return;
// Remove any existing local participant first
_participants.removeWhere((p) => p.participant.identity == userinfo.id);
// Add local participant (current user)
final localParticipant = CallParticipantLive(
participant: CallParticipant(
identity: userinfo.id, // Use roomId as local identity
name: userinfo.name,
accountId: userinfo.id,
account: userinfo,
joinedAt: DateTime.now(),
),
remoteParticipant: WebRTCParticipant(
id: _webrtcManager!.roomId,
name: userinfo.nick,
userinfo: userinfo,
isLocal: true,
)..remoteStream = _webrtcManager!.localStream, // Access local stream
);
_participants.insert(0, localParticipant); // Add at the beginning
state = state.copyWith();
}
/// Builds the CallParticipant object for the local participant.
/// Optionally, pass [participants] if you want to prioritize info from the latest list.
CallParticipant _buildParticipant({List<CallParticipant>? participants}) {
if (_localParticipant == null) {
throw StateError('No local participant available');
}
// Prefer info from the latest participants list if available
if (participants != null) {
final idx = participants.indexWhere(
(p) => p.identity == _localParticipant!.identity,
);
if (idx != -1) return participants[idx];
}
void _updateLiveParticipantsFromWebRTC() {
if (_webrtcManager == null) return;
// Otherwise, use info from the identity map or fallback to minimal
return _participantInfoByIdentity[_localParticipant!.identity] ??
CallParticipant(
identity: _localParticipant!.identity,
name: _localParticipant!.identity,
joinedAt: DateTime.now(),
);
}
final webrtcParticipants = _webrtcManager!.participants;
void _updateLiveParticipants(List<CallParticipant> participants) {
// Update the info map for lookup
for (final p in participants) {
_participantInfoByIdentity[p.identity] = p;
}
if (_room == null) {
// Can't build live objects, just store empty
_participants = [];
state = state.copyWith();
return;
}
final remoteParticipants = _room!.remoteParticipants;
final remotes = remoteParticipants.values.toList();
_participants = [];
// Add local participant if present in the list
if (_localParticipant != null) {
final localInfo = _buildParticipant(participants: participants);
_participants.add(
CallParticipantLive(
participant: localInfo,
remoteParticipant: _localParticipant!,
),
);
state = state.copyWith();
}
// Add remote participants
_participants.addAll(
participants.map((p) {
lk.RemoteParticipant? remote;
for (final r in remotes) {
if (r.identity == p.identity) {
remote = r;
break;
}
}
if (_localParticipant != null &&
p.identity == _localParticipant!.identity) {
return null; // Already added local
}
return remote != null
? CallParticipantLive(participant: p, remoteParticipant: remote)
// Always ensure local participant exists
final existingLocalParticipant =
_participants.isNotEmpty &&
_participants[0].remoteParticipant.id == _webrtcManager!.roomId
? _participants[0]
: null;
}).whereType<CallParticipantLive>(),
);
final localParticipant =
existingLocalParticipant ?? _createLocalParticipant();
// Add remote participants
final remoteParticipants =
webrtcParticipants.map((p) {
final participantInfo =
_participantInfoByIdentity[p.id] ??
CallParticipant(
identity: p.id,
name: p.name,
accountId: p.userinfo.id,
account: p.userinfo,
joinedAt: DateTime.now(),
);
return CallParticipantLive(
participant: participantInfo,
remoteParticipant: p,
);
}).toList();
// Combine local participant with remote participants
_participants = [localParticipant, ...remoteParticipants];
state = state.copyWith();
}
String? _roomId;
String? get roomId => _roomId;
CallParticipantLive _createLocalParticipant() {
return CallParticipantLive(
participant: CallParticipant(
identity: _webrtcManager!.roomId, // Use roomId as local identity
name: 'You',
accountId: '',
account: null,
joinedAt: DateTime.now(),
),
remoteParticipant: WebRTCParticipant(
id: _webrtcManager!.roomId,
name: 'You',
userinfo: SnAccount(
id: '',
name: '',
nick: '',
language: '',
isSuperuser: false,
automatedId: null,
profile: SnAccountProfile(
id: '',
firstName: '',
middleName: '',
lastName: '',
bio: '',
gender: '',
pronouns: '',
location: '',
timeZone: '',
links: [],
experience: 0,
level: 0,
socialCredits: 0,
socialCreditsLevel: 0,
levelingProgress: 0,
picture: null,
background: null,
verification: null,
usernameColor: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
),
perkSubscription: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
),
)..remoteStream = _webrtcManager!.localStream, // Access local stream
);
}
Future<void> joinRoom(String roomId) async {
if (_roomId == roomId && _room != null) {
talker.info('[Call] Call skipped. Already has data');
return;
} else if (_room != null) {
if (!_room!.isDisposed &&
_room!.connectionState != lk.ConnectionState.disconnected) {
throw Exception('Call already connected');
if (_roomId == roomId && _webrtcManager != null) {
talker.info('[Call] Call skipped. Already connected to this room');
// Ensure state is connected even if we skip the join process
if (!state.isConnected) {
state = state.copyWith(isConnected: true);
}
return;
}
_roomId = roomId;
if (_room != null) {
await _room!.disconnect();
await _room!.dispose();
_room = null;
_localParticipant = null;
_participants = [];
}
// Clean up existing connection
await disconnect();
try {
final apiClient = ref.read(apiClientProvider);
final ongoingCall = await ref.read(ongoingCallProvider(roomId).future);
@@ -241,8 +262,11 @@ class CallNotifier extends _$CallNotifier {
// Parse join response
final joinResponse = ChatRealtimeJoinResponse.fromJson(data);
final participants = joinResponse.participants;
final String endpoint = joinResponse.endpoint;
final String token = joinResponse.token;
// Update participant info map
for (final p in participants) {
_participantInfoByIdentity[p.identity] = p;
}
// Setup duration timer
_durationTimer?.cancel();
@@ -257,47 +281,18 @@ class CallNotifier extends _$CallNotifier {
);
});
// Connect to LiveKit
_room = lk.Room();
// Initialize WebRTC manager
final serverUrl = ref.watch(serverUrlProvider);
await _room!.connect(
endpoint,
token,
connectOptions: lk.ConnectOptions(autoSubscribe: true),
roomOptions: lk.RoomOptions(adaptiveStream: true, dynacast: true),
fastConnectOptions: lk.FastConnectOptions(
microphone: lk.TrackOption(enabled: true),
),
);
_localParticipant = _room!.localParticipant;
_webrtcManager = WebRTCManager(roomId: roomId, serverUrl: serverUrl);
_initRoomListeners();
_updateLiveParticipants(participants);
await _webrtcManager!.initialize(ref);
_initWebRTCListeners();
if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
lk.Hardware.instance.setSpeakerphoneOn(true);
// TODO: Implement speakerphone control for WebRTC
}
// Listen for connection updates
_room!.addListener(() {
final wasConnected = state.isConnected;
final isNowConnected =
_room!.connectionState == lk.ConnectionState.connected;
state = state.copyWith(
isConnected: isNowConnected,
isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(),
isCameraEnabled: _localParticipant!.isCameraEnabled(),
isScreenSharing: _localParticipant!.isScreenShareEnabled(),
);
// Enable wakelock when call connects
if (!wasConnected && isNowConnected) {
WakelockPlus.enable();
}
// Disable wakelock when call disconnects
else if (wasConnected && !isNowConnected) {
WakelockPlus.disable();
}
});
state = state.copyWith(isConnected: true);
// Enable wakelock when call connects
WakelockPlus.enable();
@@ -310,104 +305,114 @@ class CallNotifier extends _$CallNotifier {
}
Future<void> toggleMicrophone() async {
if (_localParticipant != null) {
const autostop = true;
final target = !_localParticipant!.isMicrophoneEnabled();
state = state.copyWith(isMicrophoneEnabled: target);
if (target) {
await _localParticipant!.audioTrackPublications.firstOrNull?.unmute(
stopOnMute: autostop,
);
} else {
await _localParticipant!.audioTrackPublications.firstOrNull?.mute(
stopOnMute: autostop,
);
}
state = state.copyWith();
final target = !state.isMicrophoneEnabled;
state = state.copyWith(isMicrophoneEnabled: target);
await _webrtcManager?.toggleMicrophone(target);
// Update local participant's audio state
if (_participants.isNotEmpty) {
_participants[0].remoteParticipant.isAudioEnabled = target;
state = state.copyWith(); // Trigger UI update
}
}
Future<void> toggleCamera() async {
if (_localParticipant != null) {
final target = !_localParticipant!.isCameraEnabled();
state = state.copyWith(isCameraEnabled: target);
await _localParticipant!.setCameraEnabled(target);
state = state.copyWith();
final target = !state.isCameraEnabled;
state = state.copyWith(isCameraEnabled: target);
await _webrtcManager?.toggleCamera(target);
// Update local participant's video state
if (_participants.isNotEmpty) {
_participants[0].remoteParticipant.isVideoEnabled = target;
state = state.copyWith(); // Trigger UI update
}
}
Future<void> toggleScreenShare(BuildContext context) async {
if (_localParticipant != null) {
final target = !_localParticipant!.isScreenShareEnabled();
state = state.copyWith(isScreenSharing: target);
if (_webrtcManager == null) return;
if (target && lk.lkPlatformIsDesktop()) {
try {
final source = await showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => lk.ScreenSelectDialog(),
);
if (source == null) {
return;
}
var track = await lk.LocalVideoTrack.createScreenShareTrack(
lk.ScreenShareCaptureOptions(
sourceId: source.id,
maxFrameRate: 30.0,
captureScreenAudio: true,
),
);
await _localParticipant!.publishVideoTrack(track);
} catch (err) {
showErrorAlert(err);
}
return;
try {
if (state.isScreenSharing) {
// Stop screen sharing - switch back to camera
await _webrtcManager!.toggleCamera(state.isCameraEnabled);
state = state.copyWith(isScreenSharing: false);
} else {
await _localParticipant!.setScreenShareEnabled(target);
}
// Start screen sharing
if (WebRTC.platformIsDesktop) {
// For desktop, we need to get screen capture source
// This would require implementing a screen selection dialog
// For now, just toggle the state
state = state.copyWith(isScreenSharing: true);
} else if (WebRTC.platformIsWeb) {
// For web, get display media directly
await navigator.mediaDevices.getDisplayMedia({
'video': true,
'audio':
false, // Screen sharing typically doesn't include system audio
});
state = state.copyWith();
// Replace video track with screen sharing track
// This is a simplified implementation
state = state.copyWith(isScreenSharing: true);
}
}
} catch (e) {
talker.error('[Call] Screen sharing error: $e');
state = state.copyWith(error: 'Failed to toggle screen sharing: $e');
}
}
Future<void> toggleSpeakerphone() async {
state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
await lk.Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone);
state = state.copyWith();
if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
try {
// For mobile platforms, we can control audio routing
// This is a simplified implementation
final newSpeakerphoneState = !state.isSpeakerphone;
state = state.copyWith(isSpeakerphone: newSpeakerphoneState);
// Note: Actual speakerphone control would require platform-specific code
// For a full implementation, you'd need to use platform channels
// to control audio routing on iOS/Android
talker.info('[Call] Speakerphone toggled to: $newSpeakerphoneState');
} catch (e) {
talker.error('[Call] Speakerphone control error: $e');
state = state.copyWith(error: 'Failed to toggle speakerphone: $e');
}
} else {
// For web/desktop, speakerphone control is handled by the browser/OS
state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
}
}
Future<void> disconnect() async {
if (_room != null) {
await _room!.disconnect();
state = state.copyWith(
isConnected: false,
isMicrophoneEnabled: false,
isCameraEnabled: false,
isScreenSharing: false,
);
// Disable wakelock when call disconnects
WakelockPlus.disable();
}
_webrtcManager?.dispose();
_webrtcManager = null;
_participantJoinedSubscription?.cancel();
_participantLeftSubscription?.cancel();
_participants.clear();
state = state.copyWith(
isConnected: false,
isMicrophoneEnabled: false,
isCameraEnabled: false,
isScreenSharing: false,
);
// Disable wakelock when call disconnects
WakelockPlus.disable();
}
void setParticipantVolume(CallParticipantLive live, double volume) {
if (participantsVolumes[live.remoteParticipant.sid] == null) {
participantsVolumes[live.remoteParticipant.sid] = 1;
}
Helper.setVolume(
volume,
live
.remoteParticipant
.audioTrackPublications
.first
.track!
.mediaStreamTrack,
// Store volume setting for this participant
// Note: WebRTC doesn't have built-in per-participant volume control
// This is just storing the preference for UI purposes
// Actual volume control would need to be implemented at the audio rendering level
participantsVolumes[live.remoteParticipant.id] = volume.clamp(0.0, 1.0);
talker.info(
'[Call] Volume set to $volume for participant ${live.remoteParticipant.id}',
);
participantsVolumes[live.remoteParticipant.sid] = volume;
}
double getParticipantVolume(CallParticipantLive live) {
return participantsVolumes[live.remoteParticipant.sid] ?? 1;
return participantsVolumes[live.remoteParticipant.id] ?? 1.0;
}
void dispose() {
@@ -418,9 +423,10 @@ class CallNotifier extends _$CallNotifier {
isCameraEnabled: false,
isScreenSharing: false,
);
_roomListener?.dispose();
_room?.removeListener(_onRoomChange);
_room?.dispose();
_participantJoinedSubscription?.cancel();
_participantLeftSubscription?.cancel();
_webrtcManager?.dispose();
_webrtcManager = null;
_durationTimer?.cancel();
_roomId = null;
participantsVolumes = {};

View File

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

View File

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

View File

@@ -0,0 +1,476 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/chat/webrtc_signaling.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/talker.dart';
class WebRTCParticipant {
final String id;
final String name;
final SnAccount userinfo;
RTCPeerConnection? peerConnection;
MediaStream? remoteStream;
List<RTCIceCandidate> remoteCandidates = [];
bool isAudioEnabled = true;
bool isVideoEnabled = false;
bool isConnected = false;
bool isLocal = false;
double audioLevel = 0.0;
WebRTCParticipant({
required this.id,
required this.name,
required this.userinfo,
this.isAudioEnabled = true,
this.isVideoEnabled = false,
this.isLocal = false,
});
}
class WebRTCManager {
final String roomId;
final String serverUrl;
late WebRTCSignaling _signaling;
final Map<String, WebRTCParticipant> _participants = {};
final Map<String, RTCPeerConnection> _peerConnections = {};
MediaStream? _localStream;
Timer? _audioLevelTimer;
MediaStream? get localStream => _localStream;
final StreamController<WebRTCParticipant> _participantController =
StreamController<WebRTCParticipant>.broadcast();
final StreamController<String> _participantLeftController =
StreamController<String>.broadcast();
Stream<WebRTCParticipant> get onParticipantJoined =>
_participantController.stream;
Stream<String> get onParticipantLeft => _participantLeftController.stream;
WebRTCManager({required this.roomId, required this.serverUrl}) {
_signaling = WebRTCSignaling(roomId: roomId);
}
Future<void> initialize(Ref ref) async {
final user = ref.watch(userInfoProvider).value!;
_signaling.userId = user.id;
_signaling.userName = user.name;
_signaling.user = user;
await _initializeLocalStream();
_setupSignalingListeners();
await _signaling.connect(ref);
_startAudioLevelMonitoring();
}
Future<void> _initializeLocalStream() async {
try {
_localStream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': true,
});
talker.info('[WebRTC] Local stream initialized');
} catch (e) {
talker.error('[WebRTC] Failed to initialize local stream: $e');
rethrow;
}
}
void _setupSignalingListeners() {
_signaling.messages.listen((message) async {
switch (message.type) {
case 'offer':
await _handleOffer(message.accountId, message.account, message.data);
break;
case 'answer':
await _handleAnswer(message.accountId, message.data);
break;
case 'ice-candidate':
await _handleIceCandidate(message.accountId, message.data);
break;
// CHANGED: Listen for new users joining the room.
case 'user-joined':
await _handleUserJoined(message.accountId, message.account);
break;
default:
talker.warning(
'[WebRTC Manager] Receieved an unknown type singaling message: ${message.type} with ${message.data}',
);
}
});
// CHANGED: The welcome message now drives connection initiation.
_signaling.welcomeMessages.listen((welcome) {
talker.info('[WebRTC Manager] Connected to room: ${welcome.roomId}');
final existingParticipants =
welcome.participants; // Assuming the server sends this.
talker.info(
'[WebRTC Manager] Existing participants: $existingParticipants',
);
// The newcomer is responsible for initiating the connection to everyone else.
for (final participant in existingParticipants) {
if (participant.identity != _signaling.userId) {
if (!_participants.containsKey(participant.identity)) {
final webrtcParticipant = WebRTCParticipant(
id: participant.identity,
name: participant.name,
userinfo: participant.account!,
);
_participants[participant.identity] = webrtcParticipant;
_participantController.add(webrtcParticipant);
}
_createPeerConnection(participant.identity, isInitiator: true);
}
}
});
}
// CHANGED: New handler for when an existing user is notified of a new peer.
Future<void> _handleUserJoined(
String participantId,
SnAccount account,
) async {
talker.info(
'[WebRTC Manager] User joined: $participantId. Waiting for their offer.',
);
// We don't need to be the initiator here. The newcomer will send us an offer.
// We just create the peer connection to be ready for it.
if (!_peerConnections.containsKey(participantId)) {
// Create a participant object to represent the new user
if (!_participants.containsKey(participantId)) {
final participant = WebRTCParticipant(
id: participantId,
name: participantId,
userinfo: account,
); // Placeholder name
_participants[participantId] = participant;
_participantController.add(participant);
}
await _createPeerConnection(participantId, isInitiator: false);
}
}
Future<void> _createPeerConnection(
String participantId, {
bool isInitiator = false,
}) async {
talker.info(
'[WebRTC] Creating peer connection to $participantId (initiator: $isInitiator)',
);
final configuration = {
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
],
};
final peerConnection = await createPeerConnection(configuration);
_peerConnections[participantId] = peerConnection;
_participants[participantId]!.peerConnection = peerConnection;
if (_localStream != null) {
for (final track in _localStream!.getTracks()) {
await peerConnection.addTrack(track, _localStream!);
}
}
peerConnection.onTrack = (event) {
if (event.streams.isNotEmpty) {
final participant = _participants[participantId];
if (participant != null) {
participant.remoteStream = event.streams[0];
participant.isConnected = true;
// Detect video tracks and update video enabled state
final videoTracks = event.streams[0].getVideoTracks();
if (videoTracks.isNotEmpty) {
participant.isVideoEnabled = true;
}
_participantController.add(participant);
}
}
};
peerConnection.onIceCandidate = (candidate) {
// CHANGED: Send candidate to the specific participant
_signaling.sendIceCandidate(participantId, candidate);
};
peerConnection.onConnectionState = (state) {
talker.info('[WebRTC] Connection state for $participantId: $state');
final participant = _participants[participantId];
if (participant != null) {
participant.isConnected =
state == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
_participantController.add(participant);
}
};
if (isInitiator) {
final offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
// CHANGED: Send offer to the specific participant
_signaling.sendOffer(participantId, offer);
}
}
Future<void> _handleOffer(
String from,
SnAccount account,
Map<String, dynamic> data,
) async {
final participantId = from;
talker.info('[WebRTC Manager] Handling offer from $participantId');
final offer = RTCSessionDescription(data['sdp'], data['type']);
if (!_peerConnections.containsKey(participantId)) {
if (!_participants.containsKey(participantId)) {
final participant = WebRTCParticipant(
id: participantId,
name: participantId,
userinfo: account,
);
_participants[participantId] = participant;
_participantController.add(participant);
}
await _createPeerConnection(participantId, isInitiator: false);
}
final peerConnection = _peerConnections[participantId]!;
await peerConnection.setRemoteDescription(offer);
final answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
// CHANGED: Send answer to the specific participant
_signaling.sendAnswer(participantId, answer);
// Process any queued ICE candidates
final participant = _participants[participantId];
if (participant != null) {
for (final candidate in participant.remoteCandidates) {
await peerConnection.addCandidate(candidate);
}
participant.remoteCandidates.clear();
}
}
Future<void> _handleAnswer(String from, Map<String, dynamic> data) async {
final participantId = from;
talker.info('[WebRTC Manager] Handling answer from $participantId');
final answer = RTCSessionDescription(data['sdp'], data['type']);
final peerConnection = _peerConnections[participantId];
if (peerConnection != null) {
await peerConnection.setRemoteDescription(answer);
// Process any queued ICE candidates
final participant = _participants[participantId];
if (participant != null) {
for (final candidate in participant.remoteCandidates) {
await peerConnection.addCandidate(candidate);
}
participant.remoteCandidates.clear();
}
}
}
Future<void> _handleIceCandidate(
String from,
Map<String, dynamic> data,
) async {
final participantId = from;
final candidate = RTCIceCandidate(
data['candidate'],
data['sdpMid'],
data['sdpMLineIndex'],
);
final participant = _participants[participantId];
if (participant != null) {
final pc = participant.peerConnection;
if (pc != null) {
await pc.addCandidate(candidate);
} else {
participant.remoteCandidates.add(candidate);
}
}
}
Future<void> replaceMediaStream(Map<String, dynamic> constraints) async {
try {
final newStream = await navigator.mediaDevices.getUserMedia(constraints);
final newVideoTrack = newStream.getVideoTracks().firstOrNull;
final newAudioTrack = newStream.getAudioTracks().firstOrNull;
if (_localStream != null) {
final oldVideoTrack = _localStream!.getVideoTracks().firstOrNull;
final oldAudioTrack = _localStream!.getAudioTracks().firstOrNull;
// Replace tracks in all existing peer connections
for (final pc in _peerConnections.values) {
final senders = await pc.getSenders();
for (final sender in senders) {
if (newVideoTrack != null && sender.track == oldVideoTrack) {
await sender.replaceTrack(newVideoTrack);
} else if (newAudioTrack != null && sender.track == oldAudioTrack) {
await sender.replaceTrack(newAudioTrack);
}
}
}
// Stop old tracks and update local stream
for (final track in _localStream!.getTracks()) {
track.stop();
}
}
_localStream = newStream;
talker.info('[WebRTC] Media stream replaced with new constraints');
} catch (e) {
talker.error('[WebRTC] Failed to replace media stream: $e');
rethrow;
}
}
Future<void> toggleMicrophone(bool enabled) async {
if (_localStream != null) {
final audioTracks = _localStream!.getAudioTracks();
for (final track in audioTracks) {
track.enabled = enabled;
}
}
}
Future<void> toggleCamera(bool enabled) async {
if (_localStream != null) {
_localStream!.getVideoTracks().forEach((track) {
track.enabled = enabled;
});
}
}
Future<void> switchCamera(String deviceId) async {
await replaceMediaStream({
'audio': _localStream?.getAudioTracks().isNotEmpty ?? true,
'video': {'deviceId': deviceId},
});
talker.info('[WebRTC] Switched to camera device: $deviceId');
}
Future<void> switchMicrophone(String deviceId) async {
await replaceMediaStream({
'audio': {'deviceId': deviceId},
'video': _localStream?.getVideoTracks().isNotEmpty ?? true,
});
talker.info('[WebRTC] Switched to microphone device: $deviceId');
}
Future<List<MediaDeviceInfo>> getVideoDevices() async {
try {
final devices = await navigator.mediaDevices.enumerateDevices();
return devices.where((device) => device.kind == 'videoinput').toList();
} catch (e) {
talker.error('[WebRTC] Failed to enumerate video devices: $e');
return [];
}
}
Future<List<MediaDeviceInfo>> getAudioDevices() async {
try {
final devices = await navigator.mediaDevices.enumerateDevices();
return devices.where((device) => device.kind == 'audioinput').toList();
} catch (e) {
talker.error('[WebRTC] Failed to enumerate audio devices: $e');
return [];
}
}
void _startAudioLevelMonitoring() {
_audioLevelTimer?.cancel();
_audioLevelTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
_updateAudioLevels();
});
}
void _stopAudioLevelMonitoring() {
_audioLevelTimer?.cancel();
_audioLevelTimer = null;
}
Future<void> _updateAudioLevels() async {
bool hasUpdates = false;
for (final participant in _participants.values) {
if (participant.remoteStream != null && participant.isAudioEnabled) {
final audioTracks = participant.remoteStream!.getAudioTracks();
if (audioTracks.isNotEmpty) {
try {
// Try to get stats for more accurate audio level detection
final pc = participant.peerConnection;
if (pc != null) {
final stats = await pc.getStats();
double maxAudioLevel = 0.0;
// Look for audio receiver stats
for (var report in stats) {
if (report.type == 'inbound-rtp' &&
report.values['mediaType'] == 'audio') {
final audioLevel = report.values['audioLevel'] as double?;
if (audioLevel != null && audioLevel > maxAudioLevel) {
maxAudioLevel = audioLevel;
}
}
}
// If we got stats, use them; otherwise use a simple heuristic
if (maxAudioLevel > 0) {
participant.audioLevel = maxAudioLevel.clamp(0.0, 1.0);
} else {
// Simple heuristic: if audio track is enabled, assume some level
// In a real app, you'd analyze the actual audio data
participant.audioLevel = audioTracks[0].enabled ? 0.5 : 0.0;
}
} else {
// Fallback for local participant or when no PC available
participant.audioLevel = participant.isLocal ? 0.0 : 0.3;
}
hasUpdates = true;
} catch (e) {
talker.warning('[WebRTC] Failed to update audio level for ${participant.id}: $e');
participant.audioLevel = 0.0;
}
} else {
participant.audioLevel = 0.0;
}
} else {
participant.audioLevel = 0.0;
}
}
// Notify listeners if there were updates (throttled to avoid excessive updates)
if (hasUpdates) {
// This will trigger UI updates for speaking indicators
}
}
List<WebRTCParticipant> get participants => _participants.values.toList();
void dispose() {
_stopAudioLevelMonitoring();
_signaling.disconnect();
for (final pc in _peerConnections.values) {
pc.close();
}
_peerConnections.clear();
for (var p in _participants.values) {
p.remoteCandidates.clear();
}
_participants.clear();
_localStream?.dispose();
_participantController.close();
_participantLeftController.close();
}
}

View File

@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/account.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:island/talker.dart';
part 'webrtc_signaling.freezed.dart';
part 'webrtc_signaling.g.dart';
@freezed
sealed class SignalingMessage with _$SignalingMessage {
const factory SignalingMessage({
required String type,
// CHANGED: Added 'to' field for directed messaging
String? to,
required String accountId,
required SnAccount account,
required Map<String, dynamic> data,
}) = _SignalingMessage;
factory SignalingMessage.fromJson(Map<String, dynamic> json) =>
_$SignalingMessageFromJson(json);
}
@freezed
sealed class WebRTCWelcomeMessage with _$WebRTCWelcomeMessage {
const factory WebRTCWelcomeMessage({
required String userId,
required String roomId,
required String message,
required String timestamp,
// CHANGED: Added participants list
@Default([]) List<CallParticipant> participants,
}) = _WebRTCWelcomeMessage;
factory WebRTCWelcomeMessage.fromJson(Map<String, dynamic> json) =>
_$WebRTCWelcomeMessageFromJson(json);
}
class WebRTCSignaling {
final String roomId;
late final String userId;
late final String userName;
late SnAccount user;
final StreamController<SignalingMessage> _messageController =
StreamController<SignalingMessage>.broadcast();
final StreamController<WebRTCWelcomeMessage> _welcomeController =
StreamController<WebRTCWelcomeMessage>.broadcast();
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
Stream<SignalingMessage> get messages => _messageController.stream;
Stream<WebRTCWelcomeMessage> get welcomeMessages => _welcomeController.stream;
WebRTCSignaling({required this.roomId});
Future<void> connect(Ref ref) async {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
final url = '$baseUrl/sphere/chat/realtime/$roomId'.replaceFirst(
'http',
'ws',
);
talker.info('[WebRTC Signaling] Trying connecting to $url');
try {
if (kIsWeb) {
_channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token'));
} else {
_channel = IOWebSocketChannel.connect(
Uri.parse(url),
headers: {'Authorization': 'AtField $token'},
);
}
await _channel!.ready;
// Start heartbeat timer
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) => _sendHeartbeat());
_channel!.stream.listen(
(data) {
final dataStr =
data is Uint8List ? utf8.decode(data) : data.toString();
final packet = WebSocketPacket.fromJson(jsonDecode(dataStr));
talker.info(
'[WebRTC Signaling] Recieved a singal message with packet type: ${packet.type}',
);
if (packet.type == 'webrtc') {
try {
final welcomeMessage = WebRTCWelcomeMessage.fromJson(
packet.data!,
);
_welcomeController.add(welcomeMessage);
talker.info(
'[WebRTC Signaling] Welcome message received: ${welcomeMessage.message}',
);
} catch (e) {
talker.error(
'[WebRTC Signaling] Failed to parse welcome message: $e',
);
}
} else if (packet.type == 'webrtc.signal') {
try {
final signalingMessage = SignalingMessage.fromJson(packet.data!);
// CHANGED: Ensure we only process messages intended for us if the 'to' field is present
if (signalingMessage.to == null ||
signalingMessage.to == userId) {
_messageController.add(signalingMessage);
}
} catch (e) {
talker.error(
'[WebRTC Signaling] Failed to parse signaling message: $e',
);
}
}
},
onError: (error) {
talker.error('[WebRTC Signaling] WebSocket error: $error');
_messageController.addError(error);
_welcomeController.addError(error);
},
onDone: () {
talker.info('[WebRTC Signaling] WebSocket connection closed');
_messageController.close();
_welcomeController.close();
},
);
} catch (err) {
talker.error('[WebRTC Signaling] Failed to connect: $err');
_messageController.addError(err);
_welcomeController.addError(err);
}
}
void sendMessage(SignalingMessage message) {
if (_channel == null) return;
talker.info(
'[WebRTC Signaling] Sending a message with message type: ${message.type} to ${message.to}',
);
final packet = WebSocketPacket(
type: 'webrtc.signal',
data: message.toJson(),
);
_channel!.sink.add(jsonEncode(packet.toJson()));
}
// CHANGED: All send methods now correctly use the `to` parameter
void sendOffer(String to, RTCSessionDescription offer) {
sendMessage(
SignalingMessage(
type: 'offer',
to: to,
accountId: userId,
account: user,
data: {'sdp': offer.sdp, 'type': offer.type},
),
);
}
void sendAnswer(String to, RTCSessionDescription answer) {
sendMessage(
SignalingMessage(
type: 'answer',
to: to,
accountId: userId,
account: user,
data: {'sdp': answer.sdp, 'type': answer.type},
),
);
}
void sendIceCandidate(String to, RTCIceCandidate candidate) {
sendMessage(
SignalingMessage(
type: 'ice-candidate',
to: to,
accountId: userId,
account: user,
data: {
'candidate': candidate.candidate,
'sdpMid': candidate.sdpMid,
'sdpMLineIndex': candidate.sdpMLineIndex,
},
),
);
}
void _sendHeartbeat() {
if (_channel == null) return;
talker.info('[WebRTC Signaling] Sending heartbeat');
final packet = WebSocketPacket(type: 'heartbeat', data: null);
_channel!.sink.add(jsonEncode(packet.toJson()));
}
void disconnect() {
_heartbeatTimer?.cancel();
_channel?.sink.close();
_messageController.close();
_welcomeController.close();
}
}

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: () {
talker.info('[WebSocket] Connection closed, attempting to reconnect...');
talker.info(
'[WebSocket] Connection closed, attempting to reconnect...',
);
_scheduleReconnect();
_statusStreamController.sink.add(WebSocketState.disconnected());
},
onError: (error) {
talker.error('[WebSocket] Error occurred: $error, attempting to reconnect...');
talker.error(
'[WebSocket] Error occurred: $error, attempting to reconnect...',
);
_scheduleReconnect();
_statusStreamController.sink.add(
WebSocketState.error(error.toString()),

View File

@@ -25,7 +25,6 @@ import 'package:island/screens/tabs.dart';
import 'package:island/screens/explore.dart';
import 'package:island/screens/discovery/article_detail.dart';
import 'package:island/screens/account.dart';
import 'package:island/screens/notification.dart';
import 'package:island/screens/wallet.dart';
import 'package:island/screens/account/relationship.dart';
import 'package:island/screens/account/profile.dart';
@@ -392,11 +391,6 @@ final routerProvider = Provider<GoRouter>((ref) {
),
],
),
GoRoute(
name: 'notifications',
path: '/account/notifications',
builder: (context, state) => const NotificationScreen(),
),
GoRoute(
name: 'wallet',
path: '/account/wallet',

View File

@@ -285,7 +285,8 @@ class AccountScreen extends HookConsumerWidget {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const NotificationScreen(),
useRootNavigator: true,
builder: (context) => const NotificationSheet(),
);
},
),

View File

@@ -1,5 +1,5 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -9,8 +9,6 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/alert.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -26,32 +24,13 @@ class CallScreen extends HookConsumerWidget {
useEffect(() {
talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) {
showConfirmAlert(
'Seems there already has a call connected, do you want override it?',
'Call already connected',
).then((value) {
if (value != true) return;
talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(roomId);
});
Future(() {
callNotifier.joinRoom(roomId);
});
return null;
}, []);
final allAudioOnly = callNotifier.participants.every(
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
final allAudioOnly = callNotifier.participants.every((p) => !p.hasVideo);
return AppScaffold(
isNoBackground: false,
@@ -67,12 +46,7 @@ class CallScreen extends HookConsumerWidget {
Text(
callState.isConnected
? formatDuration(callState.duration)
: (switch (callNotifier.room?.connectionState) {
ConnectionState.connected => 'connected',
ConnectionState.connecting => 'connecting',
ConnectionState.reconnecting => 'reconnecting',
_ => 'disconnected',
}).tr(),
: 'connecting'.tr(),
style: const TextStyle(fontSize: 14),
),
],
@@ -159,19 +133,7 @@ class CallScreen extends HookConsumerWidget {
// Stage view: show main speaker(s) large, others in row
final mainSpeakers =
participants
.where(
(p) => p
.remoteParticipant
.trackPublications
.values
.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO,
),
)
.toList();
participants.where((p) => p.hasVideo).toList();
if (mainSpeakers.isEmpty && participants.isNotEmpty) {
mainSpeakers.add(participants.first);
}

View File

@@ -57,7 +57,8 @@ Widget notificationIndicatorWidget(
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const NotificationScreen(),
useRootNavigator: true,
builder: (context) => const NotificationSheet(),
);
},
),

View File

@@ -112,8 +112,8 @@ class NotificationListNotifier extends _$NotificationListNotifier
}
}
class NotificationScreen extends HookConsumerWidget {
const NotificationScreen({super.key});
class NotificationSheet extends HookConsumerWidget {
const NotificationSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {

View File

@@ -374,10 +374,10 @@ class PageBackButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDesktop =
!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows);
final hasPageAction =
!kIsWeb && Platform.isMacOS;
if (isDesktop && isWideScreen(context)) return const SizedBox.shrink();
if (hasPageAction && isWideScreen(context)) return const SizedBox.shrink();
return IconButton(
onPressed: () {

View File

@@ -16,7 +16,7 @@ Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
if (roomId.isEmpty) return null;
try {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/chat/realtime/$roomId');
final resp = await apiClient.get('/sphere/chat/realtime/$roomId/status');
return SnRealtimeCall.fromJson(resp.data);
} catch (e) {
if (e is DioException && e.response?.statusCode == 404) {

View File

@@ -6,7 +6,7 @@ part of 'call_button.dart';
// RiverpodGenerator
// **************************************************************************
String _$ongoingCallHash() => r'48031badb79efa07aefb3a4fc51635be457bd3f9';
String _$ongoingCallHash() => r'0f14b36393276720a06190cab3dc8d5e4c88cd57';
/// Copied from Dart SDK
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:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key});
@@ -194,9 +194,16 @@ class CallControlsBar extends HookConsumerWidget {
String deviceType,
) async {
try {
final devices = await Hardware.instance.enumerateDevices(
type: deviceType,
);
final devices = await navigator.mediaDevices.enumerateDevices();
final filteredDevices =
devices.where((device) {
if (deviceType == 'videoinput') {
return device.kind == 'videoinput';
} else if (deviceType == 'audioinput') {
return device.kind == 'audioinput';
}
return false;
}).toList();
if (!context.mounted) return;
@@ -209,9 +216,9 @@ class CallControlsBar extends HookConsumerWidget {
? 'selectCamera'.tr()
: 'selectMicrophone'.tr(),
child: ListView.builder(
itemCount: devices.length,
itemCount: filteredDevices.length,
itemBuilder: (context, index) {
final device = devices[index];
final device = filteredDevices[index];
return ListTile(
title: Text(
device.label.isNotEmpty
@@ -236,33 +243,17 @@ class CallControlsBar extends HookConsumerWidget {
Future<void> _switchDevice(
BuildContext context,
WidgetRef ref,
MediaDevice device,
MediaDeviceInfo device,
String deviceType,
) async {
try {
final callNotifier = ref.read(callNotifierProvider.notifier);
if (callNotifier.webrtcManager == null) return;
if (deviceType == 'videoinput') {
// Switch camera device
final localParticipant = callNotifier.room?.localParticipant;
final videoTrack =
localParticipant?.videoTrackPublications.firstOrNull?.track;
if (videoTrack is LocalVideoTrack) {
await videoTrack.switchCamera(device.deviceId);
}
await callNotifier.webrtcManager!.switchCamera(device.deviceId);
} else if (deviceType == 'audioinput') {
// Switch microphone device
final localParticipant = callNotifier.room?.localParticipant;
final audioTrack =
localParticipant?.audioTrackPublications.firstOrNull?.track;
if (audioTrack is LocalAudioTrack) {
// For audio devices, we need to restart the track with new device
await audioTrack.restartTrack(
AudioCaptureOptions(deviceId: device.deviceId),
);
}
await callNotifier.webrtcManager!.switchMicrophone(device.deviceId);
}
if (context.mounted) {
@@ -289,31 +280,9 @@ class CallOverlayBar extends HookConsumerWidget {
if (!callState.isConnected) return const SizedBox.shrink();
final lastSpeaker =
callNotifier.participants
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.isEmpty
callNotifier.participants.isNotEmpty
? callNotifier.participants.first
: callNotifier.participants
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.fold(
callNotifier.participants.first,
(value, element) =>
element.remoteParticipant.lastSpokeAt != null &&
(value.remoteParticipant.lastSpokeAt == null ||
element.remoteParticipant.lastSpokeAt!
.compareTo(
value
.remoteParticipant
.lastSpokeAt!,
) >
0)
? element
: value,
);
: null;
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
@@ -330,17 +299,16 @@ class CallOverlayBar extends HookConsumerWidget {
children: [
Builder(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
if (lastSpeaker == null) {
return const CircularProgressIndicator();
}
return SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
live: lastSpeaker,
size: 36,
).center(),
child: SpeakingRippleAvatar(
live: lastSpeaker,
size: 36,
),
);
},
),
@@ -348,7 +316,9 @@ class CallOverlayBar extends HookConsumerWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('@${lastSpeaker.participant.identity}').bold(),
Text(
'@${lastSpeaker?.participant.identity ?? 'Unknown'}',
),
Text(
formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall,

View File

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

View File

@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/screens/account/profile.dart';
import 'package:island/widgets/chat/call_participant_card.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -16,10 +15,8 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider(live.participant.identity));
final avatarRadius = size / 2;
final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0);
final clampedLevel = live.audioLevel.clamp(0.0, 1.0);
final rippleRadius = avatarRadius + clampedLevel * (size * 0.333);
return SizedBox(
width: size + 8,
@@ -27,7 +24,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: avatarRadius,
end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius,
end: live.isSpeaking ? rippleRadius : avatarRadius,
),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
@@ -35,7 +32,7 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
return Stack(
alignment: Alignment.center,
children: [
if (live.remoteParticipant.isSpeaking)
if (live.isSpeaking)
Container(
width: animatedRadius * 2,
height: animatedRadius * 2,
@@ -49,28 +46,15 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(shape: BoxShape.circle),
child: account.when(
data:
(value) => CallParticipantGestureDetector(
participant: live,
child: ProfilePictureWidget(
file: value.profile.picture,
radius: size / 2,
),
),
error:
(_, _) => CircleAvatar(
radius: size / 2,
child: const Icon(Symbols.person_remove),
),
loading:
() => CircleAvatar(
radius: size / 2,
child: CircularProgressIndicator(),
),
child: CallParticipantGestureDetector(
participant: live,
child: ProfilePictureWidget(
file: live.remoteParticipant.userinfo.profile.picture,
radius: size / 2,
),
),
),
if (live.remoteParticipant.isMuted)
if (live.isMuted)
Positioned(
bottom: 4,
right: 4,
@@ -96,40 +80,65 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
}
}
class CallParticipantTile extends HookConsumerWidget {
class CallParticipantTile extends StatefulWidget {
final CallParticipantLive live;
const CallParticipantTile({super.key, required this.live});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasVideo =
live.hasVideo &&
live.remoteParticipant.trackPublications.values
.where((pub) => pub.track != null && pub.kind == TrackType.VIDEO)
.isNotEmpty;
State<CallParticipantTile> createState() => _CallParticipantTileState();
}
if (hasVideo) {
class _CallParticipantTileState extends State<CallParticipantTile> {
RTCVideoRenderer? _renderer;
@override
void initState() {
super.initState();
_initRenderer();
}
@override
void didUpdateWidget(CallParticipantTile oldWidget) {
super.didUpdateWidget(oldWidget);
// Update renderer source when the stream changes
if (_renderer != null &&
widget.live.remoteParticipant.remoteStream !=
oldWidget.live.remoteParticipant.remoteStream) {
_renderer!.srcObject = widget.live.remoteParticipant.remoteStream;
}
}
Future<void> _initRenderer() async {
_renderer = RTCVideoRenderer();
await _renderer!.initialize();
_renderer!.srcObject = widget.live.remoteParticipant.remoteStream;
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
_renderer?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.live.hasVideo &&
widget.live.remoteParticipant.remoteStream != null &&
_renderer != null) {
return Stack(
fit: StackFit.loose,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: VideoTrackRenderer(
live.remoteParticipant.trackPublications.values
.where((track) => track.kind == TrackType.VIDEO)
.first
.track
as VideoTrack,
renderMode: VideoRenderMode.platformView,
),
),
AspectRatio(aspectRatio: 16 / 9, child: RTCVideoView(_renderer!)),
Positioned(
left: 8,
right: 8,
bottom: 8,
child: Text(
'@${live.participant.name}',
'@${widget.live.participant.name}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
@@ -148,7 +157,7 @@ class CallParticipantTile extends HookConsumerWidget {
],
);
} else {
return SpeakingRippleAvatar(size: 84, live: live);
return SpeakingRippleAvatar(size: 84, live: widget.live);
}
}
}

View File

@@ -14,7 +14,6 @@ import 'package:island/pods/chat/messages_notifier.dart';
import 'package:island/pods/translate.dart';
import 'package:island/pods/config.dart';
import 'package:island/widgets/account/account_pfc.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/message_content.dart';
import 'package:island/widgets/chat/message_indicators.dart';
import 'package:island/widgets/chat/message_sender_info.dart';
@@ -666,17 +665,11 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainer;
final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null;
final remoteMessage = message.toRemoteMessage();
final sender = remoteMessage.sender;
return Material(
color:
hasBackground
? Colors.transparent
: Theme.of(context).colorScheme.surface,
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(

View File

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

View File

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

View File

@@ -15,7 +15,6 @@
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <gtk/gtk_plugin.h>
#include <irondash_engine_context/irondash_engine_context_plugin.h>
#include <livekit_client/live_kit_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
@@ -57,9 +56,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin");
irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar);
g_autoptr(FlPluginRegistrar) livekit_client_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "LiveKitPlugin");
live_kit_plugin_register_with_registrar(livekit_client_registrar);
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
; ==================================================
#define AppVersion "3.2.0"
#define BuildNumber "134"
#define AppVersion "3.3.0"
#define BuildNumber "136"
; ==================================================
#define FullVersion AppVersion + "." + BuildNumber

View File

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

View File

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