♻️ Trying out the new built-in webrtc

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

View File

@@ -2,14 +2,13 @@ import 'dart:async';
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/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/pods/chat/webrtc_manager.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:island/talker.dart';
@@ -43,37 +42,35 @@ 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 => false; // TODO: Implement speaking detection
bool get isMuted => !remoteParticipant.isAudioEnabled;
bool get isScreenSharing => remoteParticipant.isVideoEnabled; // Simplified
bool get isScreenSharingWithAudio => false; // TODO: Implement screen sharing
bool get hasVideo => remoteParticipant.hasVideo;
bool get hasAudio => remoteParticipant.hasAudio;
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;
@override
CallState build() {
@@ -87,149 +84,62 @@ class CallNotifier extends _$CallNotifier {
);
}
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();
});
}
void _updateLiveParticipantsFromWebRTC() {
if (_webrtcManager == null) return;
final webrtcParticipants = _webrtcManager!.participants;
_participants =
webrtcParticipants.map((p) {
final participantInfo =
_participantInfoByIdentity[p.id] ??
CallParticipant(
identity: p.id,
name: p.name,
accountId: p.userinfo.id,
account: p.userinfo,
joinedAt: DateTime.now(),
);
return CallParticipantLive(
participant: participantInfo,
remoteParticipant: p,
);
}).toList();
state = state.copyWith();
}
/// Builds the CallParticipant object for the local participant.
/// Optionally, pass [participants] if you want to prioritize info from the latest list.
CallParticipant _buildParticipant({List<CallParticipant>? participants}) {
if (_localParticipant == null) {
throw StateError('No local participant available');
}
// Prefer info from the latest participants list if available
if (participants != null) {
final idx = participants.indexWhere(
(p) => p.identity == _localParticipant!.identity,
);
if (idx != -1) return participants[idx];
}
// Otherwise, use info from the identity map or fallback to minimal
return _participantInfoByIdentity[_localParticipant!.identity] ??
CallParticipant(
identity: _localParticipant!.identity,
name: _localParticipant!.identity,
joinedAt: DateTime.now(),
);
}
void _updateLiveParticipants(List<CallParticipant> participants) {
// Update the info map for lookup
for (final p in participants) {
_participantInfoByIdentity[p.identity] = p;
}
if (_room == null) {
// Can't build live objects, just store empty
_participants = [];
state = state.copyWith();
return;
}
final remoteParticipants = _room!.remoteParticipants;
final remotes = remoteParticipants.values.toList();
_participants = [];
// Add local participant if present in the list
if (_localParticipant != null) {
final localInfo = _buildParticipant(participants: participants);
_participants.add(
CallParticipantLive(
participant: localInfo,
remoteParticipant: _localParticipant!,
),
);
state = state.copyWith();
}
// Add remote participants
_participants.addAll(
participants.map((p) {
lk.RemoteParticipant? remote;
for (final r in remotes) {
if (r.identity == p.identity) {
remote = r;
break;
}
}
if (_localParticipant != null &&
p.identity == _localParticipant!.identity) {
return null; // Already added local
}
return remote != null
? CallParticipantLive(participant: p, remoteParticipant: remote)
: null;
}).whereType<CallParticipantLive>(),
);
state = state.copyWith();
}
String? _roomId;
String? get roomId => _roomId;
Future<void> joinRoom(String roomId) async {
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 +151,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 +170,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 +194,53 @@ 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);
}
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);
}
Future<void> toggleScreenShare(BuildContext context) async {
if (_localParticipant != null) {
final target = !_localParticipant!.isScreenShareEnabled();
state = state.copyWith(isScreenSharing: target);
if (target && lk.lkPlatformIsDesktop()) {
try {
final source = await showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => lk.ScreenSelectDialog(),
);
if (source == null) {
return;
}
var track = await lk.LocalVideoTrack.createScreenShareTrack(
lk.ScreenShareCaptureOptions(
sourceId: source.id,
maxFrameRate: 30.0,
captureScreenAudio: true,
),
);
await _localParticipant!.publishVideoTrack(track);
} catch (err) {
showErrorAlert(err);
}
return;
} else {
await _localParticipant!.setScreenShareEnabled(target);
}
state = state.copyWith();
}
// TODO: Implement screen sharing for WebRTC
state = state.copyWith(isScreenSharing: !state.isScreenSharing);
}
Future<void> toggleSpeakerphone() async {
state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
await lk.Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone);
state = state.copyWith();
// TODO: Implement speakerphone control for WebRTC
}
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;
if (participantsVolumes[live.remoteParticipant.id] == null) {
participantsVolumes[live.remoteParticipant.id] = 1;
}
Helper.setVolume(
volume,
live
.remoteParticipant
.audioTrackPublications
.first
.track!
.mediaStreamTrack,
);
participantsVolumes[live.remoteParticipant.sid] = volume;
// TODO: Implement volume control for WebRTC
participantsVolumes[live.remoteParticipant.id] = volume;
}
double getParticipantVolume(CallParticipantLive live) {
return participantsVolumes[live.remoteParticipant.sid] ?? 1;
return participantsVolumes[live.remoteParticipant.id] ?? 1;
}
void dispose() {
@@ -418,9 +251,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 = {};