Realtime call

This commit is contained in:
2025-05-26 01:42:59 +08:00
parent edf4ff1c5b
commit f39a066f71
19 changed files with 1433 additions and 112 deletions

View File

@ -1,7 +1,12 @@
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/pods/network.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/websocket.dart';
part 'call.g.dart';
part 'call.freezed.dart';
@ -9,43 +14,244 @@ part 'call.freezed.dart';
@freezed
sealed class CallState with _$CallState {
const factory CallState({
required bool isMuted,
required bool isConnected,
required bool isMicrophoneEnabled,
required bool isCameraEnabled,
required bool isScreenSharing,
String? error,
}) = _CallState;
}
@freezed
sealed class CallParticipantLive with _$CallParticipantLive {
const CallParticipantLive._();
const factory CallParticipantLive({
required CallParticipant participant,
required Participant remoteParticipant,
}) = _CallParticipantLive;
bool get isSpeaking => remoteParticipant.isSpeaking;
bool get isMuted => remoteParticipant.isMuted;
bool get isScreenSharing => remoteParticipant.isScreenShareEnabled();
bool get isScreenSharingWithAudio =>
remoteParticipant.isScreenShareAudioEnabled();
bool get hasVideo => remoteParticipant.hasVideo;
bool get hasAudio => remoteParticipant.hasAudio;
}
@riverpod
class CallNotifier extends _$CallNotifier {
Room? _room;
LocalParticipant? _localParticipant;
LocalAudioTrack? _localAudioTrack;
List<CallParticipantLive> _participants = [];
final Map<String, CallParticipant> _participantInfoByIdentity = {};
StreamSubscription? _wsSubscription;
EventsListener? _roomListener;
List<CallParticipantLive> get participants =>
List.unmodifiable(_participants);
LocalParticipant? get localParticipant => _localParticipant;
@override
CallState build() {
return const CallState(isMuted: false, isConnected: false);
// Subscribe to websocket updates
_subscribeToParticipantsUpdate();
return const CallState(
isConnected: false,
isMicrophoneEnabled: true,
isCameraEnabled: false,
isScreenSharing: false,
);
}
void _subscribeToParticipantsUpdate() {
// Only subscribe once
if (_wsSubscription != null) return;
final ws = ref.read(websocketProvider);
_wsSubscription = ws.dataStream.listen((packet) {
if (packet.type == 'call.participants.update' && packet.data != null) {
final participantsData = packet.data!["participants"];
if (participantsData is List) {
final parsed =
participantsData
.map(
(e) =>
CallParticipant.fromJson(Map<String, dynamic>.from(e)),
)
.toList();
_updateLiveParticipants(parsed);
}
}
});
}
void _initRoomListeners() {
if (_room == null) return;
_roomListener?.dispose();
_roomListener = _room!.createListener();
_room!.addListener(_onRoomChange);
_roomListener!
..on<ParticipantConnectedEvent>((e) {
_refreshLiveParticipants();
})
..on<RoomDisconnectedEvent>((e) {
_participants = [];
state = state.copyWith();
});
}
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(),
accountId: null,
profile: null,
);
return CallParticipantLive(
participant: match,
remoteParticipant: remote,
);
}),
);
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];
}
final userInfo = ref.read(userInfoProvider);
final roomIdentity = ref.read(chatroomIdentityProvider(_roomId));
// 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(),
accountId: userInfo.value?.id,
profile: roomIdentity.value,
);
}
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!,
),
);
}
// Add remote participants
_participants.addAll(
participants.map((p) {
RemoteParticipant? remote;
for (final r in remotes) {
if (r.identity == p.identity) {
remote = r;
break;
}
}
if (_localParticipant != null &&
p.identity == _localParticipant!.identity) {
return null; // Already added local
}
return remote != null
? CallParticipantLive(participant: p, remoteParticipant: remote)
: null;
}).whereType<CallParticipantLive>(),
);
state = state.copyWith();
}
String? _roomId;
Future<void> joinRoom(String roomId) async {
_roomId = roomId;
try {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/chat/realtime/$roomId/join');
if (response.statusCode == 200 && response.data != null) {
final data = response.data;
final String endpoint = data['endpoint'];
final String token = data['token'];
// Parse join response
final joinResponse = ChatRealtimeJoinResponse.fromJson(data);
final participants = joinResponse.participants;
final String endpoint = joinResponse.endpoint;
final String token = joinResponse.token;
// Connect to LiveKit
_room = Room();
await _room!.connect(endpoint, token);
await _room!.connect(
endpoint,
token,
connectOptions: ConnectOptions(autoSubscribe: true),
roomOptions: RoomOptions(adaptiveStream: true, dynacast: true),
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(enabled: true),
),
);
_localParticipant = _room!.localParticipant;
// Create local audio track and publish
_localAudioTrack = await LocalAudioTrack.create();
await _localParticipant!.publishAudioTrack(_localAudioTrack!);
_initRoomListeners();
_updateLiveParticipants(participants);
// Listen for connection updates
_room!.addListener(() {
state = state.copyWith(
isConnected: _room!.connectionState == ConnectionState.connected,
isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(),
isCameraEnabled: _localParticipant!.isCameraEnabled(),
isScreenSharing: _localParticipant!.isScreenShareEnabled(),
);
});
state = state.copyWith(isConnected: true);
@ -57,27 +263,55 @@ class CallNotifier extends _$CallNotifier {
}
}
void toggleMute() {
final newMuted = !state.isMuted;
state = state.copyWith(isMuted: newMuted);
if (_localAudioTrack != null) {
if (newMuted) {
_localAudioTrack!.mute();
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 {
_localAudioTrack!.unmute();
await _localParticipant!.audioTrackPublications.firstOrNull?.mute(
stopOnMute: autostop,
);
}
}
}
Future<void> toggleCamera() async {
if (_localParticipant != null) {
final target = !_localParticipant!.isCameraEnabled();
state = state.copyWith(isCameraEnabled: target);
await _localParticipant!.setCameraEnabled(target);
}
}
Future<void> toggleScreenShare() async {
if (_localParticipant != null) {
final target = !_localParticipant!.isScreenShareEnabled();
state = state.copyWith(isScreenSharing: target);
await _localParticipant!.setScreenShareEnabled(target);
}
}
Future<void> disconnect() async {
if (_room != null) {
await _room!.disconnect();
state = state.copyWith(isConnected: false);
state = state.copyWith(
isConnected: false,
isMicrophoneEnabled: false,
isCameraEnabled: false,
isScreenSharing: false,
);
}
}
void dispose() {
_localAudioTrack?.dispose();
_wsSubscription?.cancel();
_roomListener?.dispose();
_room?.removeListener(_onRoomChange);
_room?.dispose();
}
}

View File

@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$CallState {
bool get isMuted; bool get isConnected; String? get error;
bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; String? get error;
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -26,16 +26,16 @@ $CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isMuted, isMuted) || other.isMuted == isMuted)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.error, error) || other.error == error));
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,isMuted,isConnected,error);
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error);
@override
String toString() {
return 'CallState(isMuted: $isMuted, isConnected: $isConnected, error: $error)';
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)';
}
@ -46,7 +46,7 @@ abstract mixin class $CallStateCopyWith<$Res> {
factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl;
@useResult
$Res call({
bool isMuted, bool isConnected, String? error
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error
});
@ -63,10 +63,12 @@ class _$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isMuted = null,Object? isConnected = null,Object? error = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) {
return _then(_self.copyWith(
isMuted: null == isMuted ? _self.isMuted : isMuted // ignore: cast_nullable_to_non_nullable
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
@ -79,11 +81,13 @@ as String?,
class _CallState implements CallState {
const _CallState({required this.isMuted, required this.isConnected, this.error});
const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.error});
@override final bool isMuted;
@override final bool isConnected;
@override final bool isMicrophoneEnabled;
@override final bool isCameraEnabled;
@override final bool isScreenSharing;
@override final String? error;
/// Create a copy of CallState
@ -96,16 +100,16 @@ _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallSt
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isMuted, isMuted) || other.isMuted == isMuted)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.error, error) || other.error == error));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,isMuted,isConnected,error);
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error);
@override
String toString() {
return 'CallState(isMuted: $isMuted, isConnected: $isConnected, error: $error)';
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)';
}
@ -116,7 +120,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re
factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl;
@override @useResult
$Res call({
bool isMuted, bool isConnected, String? error
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error
});
@ -133,10 +137,12 @@ class __$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isMuted = null,Object? isConnected = null,Object? error = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) {
return _then(_CallState(
isMuted: null == isMuted ? _self.isMuted : isMuted // ignore: cast_nullable_to_non_nullable
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
@ -145,4 +151,152 @@ as String?,
}
/// @nodoc
mixin _$CallParticipantLive {
CallParticipant get participant; Participant get remoteParticipant;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$CallParticipantLiveCopyWith<CallParticipantLive> get copyWith => _$CallParticipantLiveCopyWithImpl<CallParticipantLive>(this as CallParticipantLive, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipantLive&&(identical(other.participant, participant) || other.participant == participant)&&(identical(other.remoteParticipant, remoteParticipant) || other.remoteParticipant == remoteParticipant));
}
@override
int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
@override
String toString() {
return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
}
}
/// @nodoc
abstract mixin class $CallParticipantLiveCopyWith<$Res> {
factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl;
@useResult
$Res call({
CallParticipant participant, Participant remoteParticipant
});
$CallParticipantCopyWith<$Res> get participant;
}
/// @nodoc
class _$CallParticipantLiveCopyWithImpl<$Res>
implements $CallParticipantLiveCopyWith<$Res> {
_$CallParticipantLiveCopyWithImpl(this._self, this._then);
final CallParticipantLive _self;
final $Res Function(CallParticipantLive) _then;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? participant = null,Object? remoteParticipant = null,}) {
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 Participant,
));
}
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$CallParticipantCopyWith<$Res> get participant {
return $CallParticipantCopyWith<$Res>(_self.participant, (value) {
return _then(_self.copyWith(participant: value));
});
}
}
/// @nodoc
class _CallParticipantLive extends CallParticipantLive {
const _CallParticipantLive({required this.participant, required this.remoteParticipant}): super._();
@override final CallParticipant participant;
@override final Participant remoteParticipant;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$CallParticipantLiveCopyWith<_CallParticipantLive> get copyWith => __$CallParticipantLiveCopyWithImpl<_CallParticipantLive>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipantLive&&(identical(other.participant, participant) || other.participant == participant)&&(identical(other.remoteParticipant, remoteParticipant) || other.remoteParticipant == remoteParticipant));
}
@override
int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
@override
String toString() {
return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
}
}
/// @nodoc
abstract mixin class _$CallParticipantLiveCopyWith<$Res> implements $CallParticipantLiveCopyWith<$Res> {
factory _$CallParticipantLiveCopyWith(_CallParticipantLive value, $Res Function(_CallParticipantLive) _then) = __$CallParticipantLiveCopyWithImpl;
@override @useResult
$Res call({
CallParticipant participant, Participant remoteParticipant
});
@override $CallParticipantCopyWith<$Res> get participant;
}
/// @nodoc
class __$CallParticipantLiveCopyWithImpl<$Res>
implements _$CallParticipantLiveCopyWith<$Res> {
__$CallParticipantLiveCopyWithImpl(this._self, this._then);
final _CallParticipantLive _self;
final $Res Function(_CallParticipantLive) _then;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? participant = null,Object? remoteParticipant = null,}) {
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 Participant,
));
}
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$CallParticipantCopyWith<$Res> get participant {
return $CallParticipantCopyWith<$Res>(_self.participant, (value) {
return _then(_self.copyWith(participant: value));
});
}
}
// dart format on

View File

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