316 lines
8.4 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|