import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../services/signalling.dart'; const _inputChannel = MethodChannel('com.pulsar/input'); const _videoChannel = MethodChannel('com.pulsar/video'); const _frameEvents = EventChannel('com.pulsar/video/frames'); 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; String _roomId = ''; String _status = 'Initialising...'; bool _connected = false; StreamSubscription? _sigSub; StreamSubscription? _frameSub; // stats bool _showStats = false; int _statFps = 0; double _statBitrateMbps = 0; int _statDropped = 0; int _statBufferKb = 0; double _statSendMs = 0; int _frameCount = 0; int _byteCount = 0; int _droppedCount = 0; double _sendMsSum = 0; int _sendMsCount = 0; Timer? _statsTimer; // viewer stats received over data channel Map? _viewerStats; @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...'); 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'); _stopCapture(); setState(() => _connected = false); break; case 'error': _setState('Error: ${msg['message']}'); break; } } Future _createOffer() async { final config = { 'iceServers': [ {'urls': 'stun:stun.l.google.com:19302'}, ], }; _pc = await createPeerConnection(config); // unordered + unreliable — drop stale frames, only the latest matters final dcInit = RTCDataChannelInit() ..ordered = false ..maxRetransmits = 0; _dataChannel = await _pc!.createDataChannel('frames', dcInit); _dataChannel!.onMessage = (msg) { if (msg.isBinary) return; try { final parsed = jsonDecode(msg.text) as Map; if (parsed['type'] == 'stats') { setState(() { _viewerStats = parsed; }); } else { _handleInputEvent(msg.text); } } catch (_) { _handleInputEvent(msg.text); } }; _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); _startCapture(); } else if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed || state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { _setState('Connection lost'); _stopCapture(); 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); } } Future _startCapture() async { _statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flushStats()); // subscribe BEFORE starting the encoder so the initial SPS+PPS+keyframe isn't dropped _frameSub = _frameEvents.receiveBroadcastStream().listen((dynamic raw) { final bytes = (raw as Uint8List); final dc = _dataChannel; if (dc == null) return; final buffered = dc.bufferedAmount ?? 0; if (buffered > 262144) { _droppedCount++; return; } try { final sendStart = DateTime.now(); dc.send(RTCDataChannelMessage.fromBinary(bytes)); _sendMsSum += DateTime.now().difference(sendStart).inMicroseconds / 1000.0; _sendMsCount++; _frameCount++; _byteCount += bytes.length; } catch (_) {} }); await _videoChannel.invokeMethod('start'); await _videoChannel.invokeMethod('forceKeyframe'); } void _stopCapture() { _frameSub?.cancel(); _frameSub = null; _statsTimer?.cancel(); _statsTimer = null; _videoChannel.invokeMethod('stop'); } void _flushStats() { final buf = _dataChannel?.bufferedAmount ?? 0; setState(() { _statFps = _frameCount; _statBitrateMbps = _byteCount * 8 / 1e6; _statDropped = _droppedCount; _statBufferKb = (buf / 1024).round(); _statSendMs = _sendMsCount > 0 ? _sendMsSum / _sendMsCount : 0; _frameCount = 0; _byteCount = 0; _droppedCount = 0; _sendMsSum = 0; _sendMsCount = 0; }); } 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() { _stopCapture(); _sigSub?.cancel(); _sig.dispose(); _pc?.close(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Pulsar — Host'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, ), floatingActionButton: FloatingActionButton( mini: true, tooltip: 'Stats', onPressed: () => setState(() => _showStats = !_showStats), child: const Icon(Icons.bar_chart), ), body: Stack( children: [ 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), ], ), ], ), ), if (_showStats) Positioned( top: 12, left: 12, child: _StatsOverlay(lines: [ 'Host:', ' FPS: $_statFps', ' Bitrate: ${_statBitrateMbps.toStringAsFixed(2)} Mbps', ' Dropped: $_statDropped/s', ' Buffer: $_statBufferKb KB', ' Send: ${_statSendMs.toStringAsFixed(1)}ms', '', if (_viewerStats == null) 'Viewer: no data' else ...[ 'Viewer:', ' FPS: ${_viewerStats!['fps']}', ' Bitrate: ${(_viewerStats!['bitrateMbps'] as num).toStringAsFixed(2)} Mbps', ' Dropped: ${_viewerStats!['dropped']}/s', ' Res: ${_viewerStats!['resolution']}', ' Latency: ${_viewerStats!['latencyMs']}ms', ' Submit: ${_viewerStats!['decodeMs'] ?? '—'}ms', ], ]), ), ], ), ); } } class _StatsOverlay extends StatelessWidget { final List lines; const _StatsOverlay({required this.lines}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(6), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: lines.map((l) => Text( l, style: const TextStyle( color: Colors.white, fontFamily: 'monospace', fontSize: 12, ), )).toList(), ), ); } }