pulsar/lib/screens/host_screen.dart
2026-05-04 08:21:31 +01:00

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