import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../services/signalling.dart'; class ViewerScreen extends StatefulWidget { final String serverUrl; const ViewerScreen({super.key, required this.serverUrl}); @override State createState() => _ViewerScreenState(); } class _ViewerScreenState extends State { final _sig = SignallingService(); RTCPeerConnection? _pc; RTCDataChannel? _dataChannel; final _remoteRenderer = RTCVideoRenderer(); final _roomCodeCtrl = TextEditingController(); String _status = 'Enter a room code to connect'; bool _streaming = false; bool _joining = false; StreamSubscription? _sigSub; // size of the rendered video widget, used to normalise pointer coords Size _videoSize = Size.zero; final _videoKey = GlobalKey(); @override void initState() { super.initState(); _remoteRenderer.initialize(); } @override void dispose() { _sigSub?.cancel(); _sig.dispose(); _pc?.close(); _remoteRenderer.dispose(); _roomCodeCtrl.dispose(); super.dispose(); } Future _join() async { final roomId = _roomCodeCtrl.text.trim().toUpperCase(); if (roomId.isEmpty) return; setState(() { _joining = true; _status = 'Connecting...'; }); try { _sig.connect(widget.serverUrl); } catch (e) { setState(() { _status = 'Failed to connect: $e'; _joining = false; }); return; } _sig.send({'type': 'join', 'roomId': roomId}); _sigSub = _sig.messages.listen( (msg) => _onSignal(msg, roomId), onError: (e) { setState(() => _status = 'Signal error: $e'); }, ); } Future _onSignal(Map msg, String roomId) async { final type = msg['type'] as String?; switch (type) { case 'joined': setState(() => _status = 'Waiting for host offer...'); await _initPeerConnection(roomId); break; case 'signal': final data = msg['data'] as Map; await _handleSignalData(data, roomId); break; case 'host_disconnected': setState(() { _status = 'Host disconnected'; _streaming = false; }); break; case 'error': setState(() { _status = 'Error: ${msg['message']}'; _joining = false; }); break; } } Future _initPeerConnection(String roomId) async { final config = { 'iceServers': [ {'urls': 'stun:stun.l.google.com:19302'}, ], }; _pc = await createPeerConnection(config); _pc!.onIceCandidate = (candidate) { _sig.send({ 'type': 'signal', 'roomId': roomId, 'data': { 'type': 'candidate', 'candidate': candidate.toMap(), }, }); }; _pc!.onConnectionState = (state) { if (state == RTCPeerConnectionState.RTCPeerConnectionStateConnected) { setState(() => _status = 'Connected'); } else if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed || state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { setState(() { _status = 'Connection lost'; _streaming = false; }); } }; _pc!.onTrack = (event) { if (event.track.kind == 'video') { setState(() { _remoteRenderer.srcObject = event.streams[0]; _streaming = true; _status = 'Streaming'; }); } }; _pc!.onDataChannel = (channel) { _dataChannel = channel; }; } Future _handleSignalData(Map data, String roomId) async { final sigType = data['type'] as String?; if (sigType == 'offer') { final offer = RTCSessionDescription(data['sdp'] as String, 'offer'); await _pc?.setRemoteDescription(offer); final answer = await _pc!.createAnswer(); await _pc!.setLocalDescription(answer); _sig.send({ 'type': 'signal', 'roomId': roomId, 'data': {'type': 'answer', 'sdp': answer.sdp}, }); } 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 _sendInput(Map ev) { if (_dataChannel == null) return; try { _dataChannel!.send(RTCDataChannelMessage(jsonEncode(ev))); } catch (_) {} } // get video widget size via the global key void _updateVideoSize() { final ctx = _videoKey.currentContext; if (ctx != null) { final box = ctx.findRenderObject() as RenderBox?; if (box != null) _videoSize = box.size; } } Offset _normalise(Offset local) { _updateVideoSize(); if (_videoSize.isEmpty) return Offset.zero; return Offset( (local.dx / _videoSize.width).clamp(0.0, 1.0), (local.dy / _videoSize.height).clamp(0.0, 1.0), ); } // ---- input capture void _onPointerMove(PointerMoveEvent e) { final n = _normalise(e.localPosition); _sendInput({'type': 'move', 'x': n.dx, 'y': n.dy}); } void _onPointerDown(PointerDownEvent e) { final n = _normalise(e.localPosition); // button 0 = left, 1 = right (PointerDeviceKind bit not directly available) _sendInput({'type': 'click', 'x': n.dx, 'y': n.dy, 'button': 0}); } void _onKeyEvent(KeyEvent e) { if (e is KeyDownEvent) { _sendInput({'type': 'keydown', 'keyCode': e.logicalKey.keyId}); } else if (e is KeyUpEvent) { _sendInput({'type': 'keyup', 'keyCode': e.logicalKey.keyId}); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Pulsar — Viewer'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, ), body: _streaming ? _buildStream() : _buildJoinView(), ); } Widget _buildJoinView() { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Icon(Icons.connected_tv, size: 64, color: Colors.indigo), const SizedBox(height: 24), TextField( controller: _roomCodeCtrl, textCapitalization: TextCapitalization.characters, maxLength: 6, textAlign: TextAlign.center, style: const TextStyle( fontSize: 28, fontFamily: 'monospace', letterSpacing: 8, fontWeight: FontWeight.bold, ), decoration: const InputDecoration( labelText: 'Room Code', border: OutlineInputBorder(), counterText: '', ), onSubmitted: (_) => _joining ? null : _join(), ), const SizedBox(height: 16), FilledButton( onPressed: _joining ? null : _join, child: _joining ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Text('Connect'), ), const SizedBox(height: 16), Text( _status, textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey), ), ], ), ), ), ); } Widget _buildStream() { return Focus( autofocus: true, onKeyEvent: (_, e) { _onKeyEvent(e); return KeyEventResult.handled; }, child: Listener( onPointerMove: _onPointerMove, onPointerDown: _onPointerDown, child: RTCVideoView( _remoteRenderer, key: _videoKey, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, ), ), ); } }