241 lines
6.2 KiB
Dart
241 lines
6.2 KiB
Dart
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<HostScreen> createState() => _HostScreenState();
|
|
}
|
|
|
|
class _HostScreenState extends State<HostScreen> {
|
|
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<void> _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<void> _onSignal(Map<String, dynamic> 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<String, dynamic>;
|
|
await _handleSignalData(data);
|
|
break;
|
|
|
|
case 'peer_disconnected':
|
|
_setState('Viewer disconnected');
|
|
setState(() => _connected = false);
|
|
break;
|
|
|
|
case 'error':
|
|
_setState('Error: ${msg['message']}');
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _startCapture() async {
|
|
try {
|
|
_localStream = await navigator.mediaDevices.getDisplayMedia({
|
|
'video': {'cursor': 'always'},
|
|
'audio': false,
|
|
});
|
|
} catch (e) {
|
|
_setState('Screen capture failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _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<void> _handleSignalData(Map<String, dynamic> 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<String, dynamic>;
|
|
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<String, dynamic>;
|
|
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),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|