370 lines
10 KiB
Dart
370 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
|
|
import '../services/signalling.dart';
|
|
|
|
const _inputChannel = MethodChannel('com.pulsar/input');
|
|
const _videoChannel = MethodChannel('com.pulsar/video');
|
|
const _frameEvents = EventChannel('com.pulsar/video/frames');
|
|
|
|
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;
|
|
|
|
String _roomId = '';
|
|
String _status = 'Initialising...';
|
|
bool _connected = false;
|
|
|
|
StreamSubscription? _sigSub;
|
|
StreamSubscription? _frameSub;
|
|
|
|
// stats
|
|
bool _showStats = false;
|
|
int _statFps = 0;
|
|
double _statBitrateMbps = 0;
|
|
int _statDropped = 0;
|
|
int _statBufferKb = 0;
|
|
double _statSendMs = 0;
|
|
|
|
int _frameCount = 0;
|
|
int _byteCount = 0;
|
|
int _droppedCount = 0;
|
|
double _sendMsSum = 0;
|
|
int _sendMsCount = 0;
|
|
Timer? _statsTimer;
|
|
|
|
// viewer stats received over data channel
|
|
Map<String, dynamic>? _viewerStats;
|
|
|
|
@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...');
|
|
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');
|
|
_stopCapture();
|
|
setState(() => _connected = false);
|
|
break;
|
|
|
|
case 'error':
|
|
_setState('Error: ${msg['message']}');
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _createOffer() async {
|
|
final config = {
|
|
'iceServers': [
|
|
{'urls': 'stun:stun.l.google.com:19302'},
|
|
],
|
|
};
|
|
|
|
_pc = await createPeerConnection(config);
|
|
|
|
// unordered + unreliable — drop stale frames, only the latest matters
|
|
final dcInit = RTCDataChannelInit()
|
|
..ordered = false
|
|
..maxRetransmits = 0;
|
|
|
|
_dataChannel = await _pc!.createDataChannel('frames', dcInit);
|
|
|
|
_dataChannel!.onMessage = (msg) {
|
|
if (msg.isBinary) return;
|
|
try {
|
|
final parsed = jsonDecode(msg.text) as Map<String, dynamic>;
|
|
if (parsed['type'] == 'stats') {
|
|
setState(() {
|
|
_viewerStats = parsed;
|
|
});
|
|
} else {
|
|
_handleInputEvent(msg.text);
|
|
}
|
|
} catch (_) {
|
|
_handleInputEvent(msg.text);
|
|
}
|
|
};
|
|
|
|
_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);
|
|
_startCapture();
|
|
} else if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
|
state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) {
|
|
_setState('Connection lost');
|
|
_stopCapture();
|
|
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);
|
|
}
|
|
}
|
|
|
|
Future<void> _startCapture() async {
|
|
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flushStats());
|
|
|
|
// subscribe BEFORE starting the encoder so the initial SPS+PPS+keyframe isn't dropped
|
|
_frameSub = _frameEvents.receiveBroadcastStream().listen((dynamic raw) {
|
|
final bytes = (raw as Uint8List);
|
|
final dc = _dataChannel;
|
|
if (dc == null) return;
|
|
|
|
final buffered = dc.bufferedAmount ?? 0;
|
|
if (buffered > 262144) {
|
|
_droppedCount++;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final sendStart = DateTime.now();
|
|
dc.send(RTCDataChannelMessage.fromBinary(bytes));
|
|
_sendMsSum += DateTime.now().difference(sendStart).inMicroseconds / 1000.0;
|
|
_sendMsCount++;
|
|
_frameCount++;
|
|
_byteCount += bytes.length;
|
|
} catch (_) {}
|
|
});
|
|
|
|
await _videoChannel.invokeMethod('start');
|
|
await _videoChannel.invokeMethod('forceKeyframe');
|
|
}
|
|
|
|
void _stopCapture() {
|
|
_frameSub?.cancel();
|
|
_frameSub = null;
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
_videoChannel.invokeMethod('stop');
|
|
}
|
|
|
|
void _flushStats() {
|
|
final buf = _dataChannel?.bufferedAmount ?? 0;
|
|
setState(() {
|
|
_statFps = _frameCount;
|
|
_statBitrateMbps = _byteCount * 8 / 1e6;
|
|
_statDropped = _droppedCount;
|
|
_statBufferKb = (buf / 1024).round();
|
|
_statSendMs = _sendMsCount > 0 ? _sendMsSum / _sendMsCount : 0;
|
|
_frameCount = 0;
|
|
_byteCount = 0;
|
|
_droppedCount = 0;
|
|
_sendMsSum = 0; _sendMsCount = 0;
|
|
});
|
|
}
|
|
|
|
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() {
|
|
_stopCapture();
|
|
_sigSub?.cancel();
|
|
_sig.dispose();
|
|
_pc?.close();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Pulsar — Host'),
|
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
mini: true,
|
|
tooltip: 'Stats',
|
|
onPressed: () => setState(() => _showStats = !_showStats),
|
|
child: const Icon(Icons.bar_chart),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
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),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (_showStats)
|
|
Positioned(
|
|
top: 12,
|
|
left: 12,
|
|
child: _StatsOverlay(lines: [
|
|
'Host:',
|
|
' FPS: $_statFps',
|
|
' Bitrate: ${_statBitrateMbps.toStringAsFixed(2)} Mbps',
|
|
' Dropped: $_statDropped/s',
|
|
' Buffer: $_statBufferKb KB',
|
|
' Send: ${_statSendMs.toStringAsFixed(1)}ms',
|
|
'',
|
|
if (_viewerStats == null)
|
|
'Viewer: no data'
|
|
else ...[
|
|
'Viewer:',
|
|
' FPS: ${_viewerStats!['fps']}',
|
|
' Bitrate: ${(_viewerStats!['bitrateMbps'] as num).toStringAsFixed(2)} Mbps',
|
|
' Dropped: ${_viewerStats!['dropped']}/s',
|
|
' Res: ${_viewerStats!['resolution']}',
|
|
' Latency: ${_viewerStats!['latencyMs']}ms',
|
|
' Submit: ${_viewerStats!['decodeMs'] ?? '—'}ms',
|
|
],
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatsOverlay extends StatelessWidget {
|
|
final List<String> lines;
|
|
const _StatsOverlay({required this.lines});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: lines.map((l) => Text(
|
|
l,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
),
|
|
)).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|