import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../services/signalling.dart'; // platform channel for injecting input into the OS const _inputChannel = MethodChannel('com.pulsar/input'); class HostScreen extends StatefulWidget { final String serverUrl; const HostScreen({super.key, required this.serverUrl}); @override State createState() => _HostScreenState(); } class _HostScreenState extends State { final _sig = SignallingService(); RTCPeerConnection? _pc; RTCDataChannel? _dataChannel; MediaStream? _localStream; String _roomId = ''; String _status = 'Initialising...'; bool _connected = false; StreamSubscription? _sigSub; @override void initState() { super.initState(); _roomId = _generateRoomId(); _init(); } String _generateRoomId() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; final rng = Random.secure(); return List.generate(6, (_) => chars[rng.nextInt(chars.length)]).join(); } Future _init() async { _setState('Connecting to server...'); try { _sig.connect(widget.serverUrl); } catch (e) { _setState('Failed to connect: $e'); return; } _sig.send({'type': 'create', 'roomId': _roomId}); _sigSub = _sig.messages.listen(_onSignal, onError: (e) { _setState('Signal error: $e'); }); } Future _onSignal(Map msg) async { final type = msg['type'] as String?; switch (type) { case 'created': _setState('Waiting for viewer...'); await _startCapture(); break; case 'peer_joined': _setState('Peer joined — creating offer...'); await _createOffer(); break; case 'signal': final data = msg['data'] as Map; await _handleSignalData(data); break; case 'peer_disconnected': _setState('Viewer disconnected'); setState(() => _connected = false); break; case 'error': _setState('Error: ${msg['message']}'); break; } } Future _startCapture() async { try { _localStream = await navigator.mediaDevices.getDisplayMedia({ 'video': {'cursor': 'always'}, 'audio': false, }); } catch (e) { _setState('Screen capture failed: $e'); } } Future _createOffer() async { final config = { 'iceServers': [ {'urls': 'stun:stun.l.google.com:19302'}, ], }; _pc = await createPeerConnection(config); // data channel for input events (host creates it as offerer) _dataChannel = await _pc!.createDataChannel( 'input', RTCDataChannelInit()..ordered = true, ); _dataChannel!.onMessage = (msg) { _handleInputEvent(msg.text); }; // add screen tracks if (_localStream != null) { for (final track in _localStream!.getTracks()) { await _pc!.addTrack(track, _localStream!); } } _pc!.onIceCandidate = (candidate) { _sig.send({ 'type': 'signal', 'roomId': _roomId, 'data': { 'type': 'candidate', 'candidate': candidate.toMap(), }, }); }; _pc!.onConnectionState = (state) { if (state == RTCPeerConnectionState.RTCPeerConnectionStateConnected) { _setState('Connected'); setState(() => _connected = true); } else if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed || state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { _setState('Connection lost'); setState(() => _connected = false); } }; final offer = await _pc!.createOffer(); await _pc!.setLocalDescription(offer); _sig.send({ 'type': 'signal', 'roomId': _roomId, 'data': {'type': 'offer', 'sdp': offer.sdp}, }); } Future _handleSignalData(Map data) async { final sigType = data['type'] as String?; if (sigType == 'answer') { final answer = RTCSessionDescription(data['sdp'] as String, 'answer'); await _pc?.setRemoteDescription(answer); } else if (sigType == 'candidate') { final raw = data['candidate'] as Map; final candidate = RTCIceCandidate( raw['candidate'] as String, raw['sdpMid'] as String?, raw['sdpMLineIndex'] as int?, ); await _pc?.addCandidate(candidate); } } void _handleInputEvent(String json) async { try { final ev = jsonDecode(json) as Map; await _inputChannel.invokeMethod('injectInput', ev); } catch (_) {} } void _setState(String s) { if (mounted) setState(() => _status = s); } @override void dispose() { _sigSub?.cancel(); _sig.dispose(); _pc?.close(); _localStream?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Pulsar — Host'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.monitor, size: 80, color: Colors.indigo), const SizedBox(height: 24), Text('Room Code', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), SelectableText( _roomId, style: Theme.of(context).textTheme.displaySmall?.copyWith( fontFamily: 'monospace', fontWeight: FontWeight.bold, letterSpacing: 8, ), ), const SizedBox(height: 32), Row( mainAxisSize: MainAxisSize.min, children: [ Icon( _connected ? Icons.circle : Icons.circle_outlined, color: _connected ? Colors.green : Colors.orange, size: 14, ), const SizedBox(width: 8), Text(_status), ], ), ], ), ), ); } }