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 _participants = {}; final Map _peerConnections = {}; MediaStream? _localStream; MediaStream? get localStream => _localStream; final StreamController _participantController = StreamController.broadcast(); final StreamController _participantLeftController = StreamController.broadcast(); Stream get onParticipantJoined => _participantController.stream; Stream get onParticipantLeft => _participantLeftController.stream; WebRTCManager({required this.roomId, required this.serverUrl}) { _signaling = WebRTCSignaling(roomId: roomId); } Future initialize(Ref ref) async { await _initializeLocalStream(); _setupSignalingListeners(); await _signaling.connect(ref); } Future _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 _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 _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 _handleOffer( String from, SnAccount account, Map 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 _handleAnswer(String from, Map 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 _handleIceCandidate( String from, Map 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 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 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 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(); } }