Stream a phone's camera to a tablet over WiFi/hotspot. WebSocket transport with native YUV-to-JPEG encoding, UDP auto-discovery, remote control (zoom, flash, camera switch, rotate, mirror) from the viewer.
183 lines
4.8 KiB
Dart
183 lines
4.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
const serverPort = 8080;
|
|
const discoveryPort = 8081;
|
|
const _beacon = 'EXTCAM';
|
|
|
|
class FrameServer {
|
|
HttpServer? _httpServer;
|
|
final List<WebSocket> _clients = [];
|
|
final _commands = StreamController<String>.broadcast();
|
|
final _connections = StreamController<void>.broadcast();
|
|
|
|
int get clientCount => _clients.length;
|
|
Stream<String> get commands => _commands.stream;
|
|
Stream<void> get clientConnected => _connections.stream;
|
|
|
|
Future<void> start() async {
|
|
_httpServer = await HttpServer.bind(InternetAddress.anyIPv4, serverPort);
|
|
_httpServer!.listen(_handleRequest);
|
|
}
|
|
|
|
void _handleRequest(HttpRequest request) {
|
|
if (request.uri.path == '/stream') {
|
|
_upgradeToWebSocket(request);
|
|
} else {
|
|
request.response
|
|
..statusCode = HttpStatus.ok
|
|
..headers.contentType = ContentType.json
|
|
..write('{"clients":${_clients.length}}')
|
|
..close();
|
|
}
|
|
}
|
|
|
|
Future<void> _upgradeToWebSocket(HttpRequest request) async {
|
|
final socket = await WebSocketTransformer.upgrade(request);
|
|
_clients.add(socket);
|
|
_connections.add(null);
|
|
socket.listen((data) {
|
|
if (data is String) _commands.add(data);
|
|
}, onDone: () => _clients.remove(socket));
|
|
}
|
|
|
|
void sendFrame(Uint8List jpeg) {
|
|
for (final client in _clients.toList()) {
|
|
try {
|
|
client.add(jpeg);
|
|
} catch (_) {
|
|
_clients.remove(client);
|
|
}
|
|
}
|
|
}
|
|
|
|
void sendText(String message) {
|
|
for (final client in _clients.toList()) {
|
|
try {
|
|
client.add(message);
|
|
} catch (_) {
|
|
_clients.remove(client);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
await _commands.close();
|
|
await _connections.close();
|
|
for (final client in _clients.toList()) {
|
|
await client.close();
|
|
}
|
|
_clients.clear();
|
|
await _httpServer?.close();
|
|
_httpServer = null;
|
|
}
|
|
}
|
|
|
|
class DiscoveryBeacon {
|
|
RawDatagramSocket? _socket;
|
|
Timer? _timer;
|
|
|
|
Future<void> start() async {
|
|
_socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
|
_socket!.broadcastEnabled = true;
|
|
_sendBroadcast();
|
|
_timer = Timer.periodic(
|
|
const Duration(seconds: 1),
|
|
(_) => _sendBroadcast(),
|
|
);
|
|
}
|
|
|
|
Future<void> _sendBroadcast() async {
|
|
final data = utf8.encode(_beacon);
|
|
final socket = _socket;
|
|
if (socket == null) return;
|
|
|
|
// global broadcast
|
|
socket.send(data, InternetAddress('255.255.255.255'), discoveryPort);
|
|
|
|
// subnet-specific broadcasts (needed on hotspot/tethering)
|
|
final interfaces = await NetworkInterface.list();
|
|
for (final iface in interfaces) {
|
|
for (final addr in iface.addresses) {
|
|
if (addr.type != InternetAddressType.IPv4 || addr.isLoopback) continue;
|
|
final parts = addr.address.split('.');
|
|
final subnetBroadcast = '${parts[0]}.${parts[1]}.${parts[2]}.255';
|
|
socket.send(data, InternetAddress(subnetBroadcast), discoveryPort);
|
|
}
|
|
}
|
|
}
|
|
|
|
void stop() {
|
|
_timer?.cancel();
|
|
_socket?.close();
|
|
}
|
|
}
|
|
|
|
Future<String?> discoverServer({
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
final results = await Future.wait([
|
|
_discoverViaBroadcast(timeout),
|
|
_discoverViaProbe(),
|
|
]);
|
|
return results.firstWhere((r) => r != null, orElse: () => null);
|
|
}
|
|
|
|
Future<String?> _discoverViaBroadcast(Duration timeout) async {
|
|
final socket = await RawDatagramSocket.bind(
|
|
InternetAddress.anyIPv4,
|
|
discoveryPort,
|
|
);
|
|
try {
|
|
await for (final event in socket.timeout(timeout)) {
|
|
if (event == RawSocketEvent.read) {
|
|
final datagram = socket.receive();
|
|
if (datagram == null) continue;
|
|
final message = utf8.decode(datagram.data);
|
|
if (message == _beacon) return datagram.address.address;
|
|
}
|
|
}
|
|
} on TimeoutException {
|
|
// no server found
|
|
} finally {
|
|
socket.close();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<String?> _discoverViaProbe() async {
|
|
final interfaces = await NetworkInterface.list();
|
|
final candidates = <String>{};
|
|
|
|
for (final iface in interfaces) {
|
|
for (final addr in iface.addresses) {
|
|
if (addr.type != InternetAddressType.IPv4 || addr.isLoopback) continue;
|
|
final parts = addr.address.split('.');
|
|
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
|
|
for (var suffix = 1; suffix <= 254; suffix++) {
|
|
final candidate = '$subnet.$suffix';
|
|
if (candidate != addr.address) candidates.add(candidate);
|
|
}
|
|
}
|
|
}
|
|
|
|
final futures = candidates.map((ip) async {
|
|
try {
|
|
final socket = await Socket.connect(
|
|
ip,
|
|
serverPort,
|
|
timeout: const Duration(milliseconds: 800),
|
|
);
|
|
socket.destroy();
|
|
return ip;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
final results = await Future.wait(futures);
|
|
return results.whereType<String>().firstOrNull;
|
|
}
|