pulsar/lib/screens/viewer_screen.dart
2026-04-30 04:39:20 +01:00

316 lines
8.4 KiB
Dart

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<ViewerScreen> createState() => _ViewerScreenState();
}
class _ViewerScreenState extends State<ViewerScreen> {
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<void> _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<void> _onSignal(Map<String, dynamic> 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<String, dynamic>;
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<void> _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<void> _handleSignalData(Map<String, dynamic> 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<String, dynamic>;
final candidate = RTCIceCandidate(
raw['candidate'] as String,
raw['sdpMid'] as String?,
raw['sdpMLineIndex'] as int?,
);
await _pc?.addCandidate(candidate);
}
}
void _sendInput(Map<String, dynamic> 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,
),
),
);
}
}