Files
external-cam/lib/frame_server.dart
Kai Chappell 1660696d7f wireless camera app for Android
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.
2026-03-06 18:14:27 +00:00

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;
}