309 lines
9.9 KiB
Dart
309 lines
9.9 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
import 'package:island/models/account.dart';
|
|
import 'package:island/pods/chat/webrtc_signaling.dart';
|
|
import 'package:island/talker.dart';
|
|
|
|
class WebRTCParticipant {
|
|
final String id;
|
|
final String name;
|
|
final SnAccount userinfo;
|
|
RTCPeerConnection? peerConnection;
|
|
MediaStream? remoteStream;
|
|
bool isAudioEnabled = true;
|
|
bool isVideoEnabled = false;
|
|
bool isConnected = false;
|
|
|
|
WebRTCParticipant({
|
|
required this.id,
|
|
required this.name,
|
|
required this.userinfo,
|
|
});
|
|
}
|
|
|
|
class WebRTCManager {
|
|
final String roomId;
|
|
final String serverUrl;
|
|
late WebRTCSignaling _signaling;
|
|
final Map<String, WebRTCParticipant> _participants = {};
|
|
final Map<String, RTCPeerConnection> _peerConnections = {};
|
|
|
|
MediaStream? _localStream;
|
|
|
|
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 {
|
|
await _initializeLocalStream();
|
|
_setupSignalingListeners();
|
|
await _signaling.connect(ref);
|
|
}
|
|
|
|
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;
|
|
|
|
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);
|
|
}
|
|
|
|
Future<void> _handleAnswer(String from, Map<String, dynamic> data) async {
|
|
final participantId = from;
|
|
talker.info('[WebRTC Manager] Handling answer from $participantId');
|
|
final answer = RTCSessionDescription(data['sdp'], data['type']);
|
|
|
|
final peerConnection = _peerConnections[participantId];
|
|
if (peerConnection != null) {
|
|
await peerConnection.setRemoteDescription(answer);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleIceCandidate(
|
|
String from,
|
|
Map<String, dynamic> data,
|
|
) async {
|
|
final participantId = from;
|
|
final candidate = RTCIceCandidate(
|
|
data['candidate'],
|
|
data['sdpMid'],
|
|
data['sdpMLineIndex'],
|
|
);
|
|
|
|
final peerConnection = _peerConnections[participantId];
|
|
if (peerConnection != null) {
|
|
// It's possible for candidates to arrive before the remote description is set.
|
|
// A robust implementation might queue them, but for now, we'll just add them.
|
|
await peerConnection.addCandidate(candidate);
|
|
}
|
|
}
|
|
|
|
Future<void> toggleMicrophone(bool enabled) async {
|
|
if (_localStream != null) {
|
|
final audioTracks = _localStream!.getAudioTracks();
|
|
for (final track in audioTracks) {
|
|
track.enabled = enabled;
|
|
}
|
|
}
|
|
|
|
// Update audio enabled state for all participants (they share the same local stream)
|
|
for (final participant in _participants.values) {
|
|
participant.isAudioEnabled = enabled;
|
|
_participantController.add(participant);
|
|
}
|
|
}
|
|
|
|
Future<void> toggleCamera(bool enabled) async {
|
|
if (_localStream != null) {
|
|
_localStream!.getVideoTracks().forEach((track) {
|
|
track.enabled = enabled;
|
|
});
|
|
}
|
|
|
|
// Update video enabled state for all participants (they share the same local stream)
|
|
for (final participant in _participants.values) {
|
|
participant.isVideoEnabled = enabled;
|
|
_participantController.add(participant);
|
|
}
|
|
}
|
|
|
|
List<WebRTCParticipant> get participants => _participants.values.toList();
|
|
|
|
void dispose() {
|
|
_signaling.disconnect();
|
|
for (final pc in _peerConnections.values) {
|
|
pc.close();
|
|
}
|
|
_peerConnections.clear();
|
|
_participants.clear();
|
|
_localStream?.dispose();
|
|
_participantController.close();
|
|
_participantLeftController.close();
|
|
}
|
|
}
|