This commit is contained in:
ImBenji
2026-04-30 04:39:20 +01:00
parent 58b10f25d0
commit a0dff5132c
141 changed files with 6567 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'screens/home_screen.dart';
void main() {
runApp(const PulsarApp());
}
class PulsarApp extends StatelessWidget {
const PulsarApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Pulsar',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'host_screen.dart';
import 'viewer_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _serverCtrl = TextEditingController(text: 'ws://localhost:3000');
@override
void dispose() {
_serverCtrl.dispose();
super.dispose();
}
void _goHost() {
final url = _serverCtrl.text.trim();
if (url.isEmpty) return;
Navigator.push(
context,
MaterialPageRoute(builder: (_) => HostScreen(serverUrl: url)),
);
}
void _goViewer() {
final url = _serverCtrl.text.trim();
if (url.isEmpty) return;
Navigator.push(
context,
MaterialPageRoute(builder: (_) => ViewerScreen(serverUrl: url)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Pulsar',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Remote Desktop',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: _serverCtrl,
decoration: const InputDecoration(
labelText: 'Signalling Server URL',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: _goHost,
icon: const Icon(Icons.monitor),
label: const Text('Host'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _goViewer,
icon: const Icon(Icons.connected_tv),
label: const Text('Connect'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
),
),
);
}
}
+241
View File
@@ -0,0 +1,241 @@
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),
],
),
],
),
),
);
}
}
+316
View File
@@ -0,0 +1,316 @@
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,
),
),
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
class SignallingService {
WebSocketChannel? _channel;
final _controller = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messages => _controller.stream;
void connect(String url) {
_channel = WebSocketChannel.connect(Uri.parse(url));
_channel!.stream.listen(
(data) {
try {
final msg = jsonDecode(data as String) as Map<String, dynamic>;
_controller.add(msg);
} catch (_) {}
},
onError: (err) {
_controller.addError(err);
},
onDone: () {
_controller.addError(Exception('WebSocket connection closed'));
},
);
}
void send(Map<String, dynamic> msg) {
_channel?.sink.add(jsonEncode(msg));
}
void dispose() {
_channel?.sink.close();
if (!_controller.isClosed) _controller.close();
}
}