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.
This commit is contained in:
182
lib/frame_server.dart
Normal file
182
lib/frame_server.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user