inital
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user