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

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),
],
),
],
),
),
);
}
}