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:
324
lib/camera_screen.dart
Normal file
324
lib/camera_screen.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import 'package:external_cam/frame_server.dart';
|
||||
import 'package:external_cam/jpeg_encoder.dart';
|
||||
|
||||
class CameraScreen extends StatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CameraScreen> createState() => _CameraScreenState();
|
||||
}
|
||||
|
||||
class _CameraScreenState extends State<CameraScreen> {
|
||||
CameraController? _controller;
|
||||
final _server = FrameServer();
|
||||
final _beacon = DiscoveryBeacon();
|
||||
bool _isStreaming = false;
|
||||
bool _isProcessing = false;
|
||||
bool _flashOn = false;
|
||||
String _error = '';
|
||||
List<String> _addresses = [];
|
||||
List<CameraDescription> _cameras = [];
|
||||
int _cameraIndex = 0;
|
||||
int _fps = 0;
|
||||
int _frameCount = 0;
|
||||
double _zoom = 1.0;
|
||||
double _minZoom = 1.0;
|
||||
double _maxZoom = 1.0;
|
||||
int _quality = 50;
|
||||
Timer? _fpsTimer;
|
||||
StreamSubscription<String>? _commandSub;
|
||||
StreamSubscription<void>? _connectSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
if (!await Permission.camera.request().isGranted) {
|
||||
setState(() => _error = 'Camera permission required');
|
||||
return;
|
||||
}
|
||||
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras.isEmpty) {
|
||||
setState(() => _error = 'No cameras found');
|
||||
return;
|
||||
}
|
||||
|
||||
await _initCamera(_cameras[_cameraIndex]);
|
||||
await _startServer();
|
||||
} catch (error) {
|
||||
setState(() => _error = error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initCamera(CameraDescription camera) async {
|
||||
await _controller?.dispose();
|
||||
_flashOn = false;
|
||||
_zoom = 1.0;
|
||||
final controller = CameraController(
|
||||
camera,
|
||||
ResolutionPreset.high,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
|
||||
_controller = controller;
|
||||
_minZoom = await controller.getMinZoomLevel();
|
||||
_maxZoom = await controller.getMaxZoomLevel();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _startServer() async {
|
||||
await _server.start();
|
||||
await _beacon.start();
|
||||
_addresses = await _networkAddresses();
|
||||
|
||||
_commandSub = _server.commands.listen(_handleCommand);
|
||||
_connectSub = _server.clientConnected.listen((_) => _broadcastState());
|
||||
|
||||
_fpsTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fps = _frameCount;
|
||||
_frameCount = 0;
|
||||
});
|
||||
});
|
||||
|
||||
setState(() {});
|
||||
_startStreaming();
|
||||
}
|
||||
|
||||
void _handleCommand(String command) {
|
||||
if (command.startsWith('zoom:')) {
|
||||
final level = double.tryParse(command.substring(5));
|
||||
if (level != null) _setZoom(level);
|
||||
return;
|
||||
}
|
||||
if (command.startsWith('quality:')) {
|
||||
final q = int.tryParse(command.substring(8));
|
||||
if (q != null) _setQuality(q);
|
||||
return;
|
||||
}
|
||||
switch (command) {
|
||||
case 'switch_camera':
|
||||
_switchCamera();
|
||||
case 'toggle_flash':
|
||||
_toggleFlash();
|
||||
}
|
||||
}
|
||||
|
||||
void _broadcastState() {
|
||||
if (_cameras.isEmpty) return;
|
||||
final camera = _cameras[_cameraIndex];
|
||||
_server.sendText(
|
||||
jsonEncode({
|
||||
'cameras': _cameras.length,
|
||||
'current': _cameraIndex,
|
||||
'flash': _flashOn,
|
||||
'lens': camera.lensDirection.name,
|
||||
'name': camera.name,
|
||||
'zoom': _zoom,
|
||||
'minZoom': _minZoom,
|
||||
'maxZoom': _maxZoom,
|
||||
'quality': _quality,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _startStreaming() {
|
||||
if (_isStreaming || _controller == null) return;
|
||||
_controller!.startImageStream(_onFrame);
|
||||
_isStreaming = true;
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
|
||||
void _stopStreaming() {
|
||||
if (!_isStreaming || _controller == null) return;
|
||||
_controller!.stopImageStream();
|
||||
_isStreaming = false;
|
||||
}
|
||||
|
||||
void _onFrame(CameraImage image) {
|
||||
if (_isProcessing || _server.clientCount == 0) return;
|
||||
_isProcessing = true;
|
||||
|
||||
encodeJpeg(image, quality: _quality).then((jpeg) {
|
||||
if (jpeg != null) {
|
||||
_server.sendFrame(jpeg);
|
||||
_frameCount++;
|
||||
}
|
||||
}).whenComplete(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<String>> _networkAddresses() async {
|
||||
final interfaces = await NetworkInterface.list();
|
||||
final results = <String>[];
|
||||
for (final iface in interfaces) {
|
||||
for (final addr in iface.addresses) {
|
||||
if (addr.type != InternetAddressType.IPv4 || addr.isLoopback) continue;
|
||||
results.add('${addr.address} (${iface.name})');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<void> _switchCamera() async {
|
||||
if (_cameras.length < 2) return;
|
||||
_stopStreaming();
|
||||
_cameraIndex = (_cameraIndex + 1) % _cameras.length;
|
||||
await _initCamera(_cameras[_cameraIndex]);
|
||||
_startStreaming();
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
Future<void> _setZoom(double level) async {
|
||||
final controller = _controller;
|
||||
if (controller == null) return;
|
||||
|
||||
_zoom = level.clamp(_minZoom, _maxZoom);
|
||||
await controller.setZoomLevel(_zoom);
|
||||
setState(() {});
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
void _setQuality(int value) {
|
||||
_quality = value.clamp(10, 100);
|
||||
setState(() {});
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
Future<void> _toggleFlash() async {
|
||||
final controller = _controller;
|
||||
if (controller == null) return;
|
||||
|
||||
try {
|
||||
_stopStreaming();
|
||||
_flashOn = !_flashOn;
|
||||
await controller.setFlashMode(
|
||||
_flashOn ? FlashMode.torch : FlashMode.off,
|
||||
);
|
||||
} catch (_) {
|
||||
_flashOn = false;
|
||||
} finally {
|
||||
_startStreaming();
|
||||
setState(() {});
|
||||
_broadcastState();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fpsTimer?.cancel();
|
||||
_commandSub?.cancel();
|
||||
_connectSub?.cancel();
|
||||
_stopStreaming();
|
||||
_controller?.dispose();
|
||||
_beacon.stop();
|
||||
_server.stop();
|
||||
WakelockPlus.disable();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error.isNotEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Camera')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(_error, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_controller?.value.isInitialized ?? false)
|
||||
Center(child: CameraPreview(_controller!)),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [_buildInfoBar(), const Spacer(), _buildControls()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBar() {
|
||||
final addressText =
|
||||
_addresses.isNotEmpty ? _addresses.join('\n') : 'No network';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.circle,
|
||||
color: _server.clientCount > 0 ? Colors.green : Colors.red,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(addressText, style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Text('${_server.clientCount} viewers $_fps fps'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
iconSize: 28,
|
||||
),
|
||||
if (_cameras.length > 1)
|
||||
IconButton.filled(
|
||||
onPressed: _switchCamera,
|
||||
icon: const Icon(Icons.flip_camera_android),
|
||||
iconSize: 28,
|
||||
),
|
||||
IconButton.filled(
|
||||
onPressed: _toggleFlash,
|
||||
icon: Icon(_flashOn ? Icons.flash_on : Icons.flash_off),
|
||||
iconSize: 28,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
97
lib/home_screen.dart
Normal file
97
lib/home_screen.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:external_cam/camera_screen.dart';
|
||||
import 'package:external_cam/viewer_screen.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('External Cam')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_ModeCard(
|
||||
icon: Icons.camera_alt,
|
||||
title: 'Camera',
|
||||
subtitle: 'Stream this device\'s camera over USB',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const CameraScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ModeCard(
|
||||
icon: Icons.tv,
|
||||
title: 'Viewer',
|
||||
subtitle: 'View the camera stream from a connected device',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ViewerScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Connect both devices with USB-C, then enable\n'
|
||||
'USB tethering on the camera device.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeCard extends StatelessWidget {
|
||||
const _ModeCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 48),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
Text(subtitle),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/jpeg_encoder.dart
Normal file
18
lib/jpeg_encoder.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const _channel = MethodChannel('com.kschappell.external_cam/jpeg');
|
||||
|
||||
Future<Uint8List?> encodeJpeg(CameraImage image, {int quality = 50}) async {
|
||||
return _channel.invokeMethod<Uint8List>('encodeJpeg', {
|
||||
'width': image.width,
|
||||
'height': image.height,
|
||||
'y': image.planes[0].bytes,
|
||||
'u': image.planes[1].bytes,
|
||||
'v': image.planes[2].bytes,
|
||||
'yRowStride': image.planes[0].bytesPerRow,
|
||||
'uvRowStride': image.planes[1].bytesPerRow,
|
||||
'uvPixelStride': image.planes[1].bytesPerPixel,
|
||||
'quality': quality,
|
||||
});
|
||||
}
|
||||
25
lib/main.dart
Normal file
25
lib/main.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:external_cam/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ExternalCamApp());
|
||||
}
|
||||
|
||||
class ExternalCamApp extends StatelessWidget {
|
||||
const ExternalCamApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'External Cam',
|
||||
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.teal,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
390
lib/viewer_screen.dart
Normal file
390
lib/viewer_screen.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import 'package:external_cam/frame_server.dart';
|
||||
|
||||
class ViewerScreen extends StatefulWidget {
|
||||
const ViewerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ViewerScreen> createState() => _ViewerScreenState();
|
||||
}
|
||||
|
||||
class _ViewerScreenState extends State<ViewerScreen> {
|
||||
final _ipController = TextEditingController();
|
||||
WebSocket? _socket;
|
||||
Uint8List? _currentFrame;
|
||||
bool _isConnected = false;
|
||||
bool _isSearching = true;
|
||||
bool _isConnecting = false;
|
||||
int _fps = 0;
|
||||
int _frameCount = 0;
|
||||
int _rotation = 0;
|
||||
bool _mirrored = false;
|
||||
Timer? _fpsTimer;
|
||||
|
||||
int _cameraCount = 0;
|
||||
bool _flashOn = false;
|
||||
String _lensDirection = '';
|
||||
String _cameraName = '';
|
||||
double _zoom = 1.0;
|
||||
double _minZoom = 1.0;
|
||||
double _maxZoom = 1.0;
|
||||
int _quality = 50;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
_autoDiscover();
|
||||
}
|
||||
|
||||
Future<void> _autoDiscover() async {
|
||||
setState(() => _isSearching = true);
|
||||
final ip = await discoverServer();
|
||||
if (!mounted) return;
|
||||
|
||||
if (ip != null) {
|
||||
_ipController.text = ip;
|
||||
_connectTo(ip);
|
||||
} else {
|
||||
setState(() => _isSearching = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectTo(String ip) async {
|
||||
if (_isConnecting) return;
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_isSearching = false;
|
||||
});
|
||||
|
||||
try {
|
||||
_socket = await WebSocket.connect(
|
||||
'ws://$ip:$serverPort/stream',
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
_fpsTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fps = _frameCount;
|
||||
_frameCount = 0;
|
||||
});
|
||||
});
|
||||
|
||||
_socket!.listen(
|
||||
_onData,
|
||||
onDone: _onDisconnected,
|
||||
onError: (_) => _onDisconnected(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isConnected = true;
|
||||
_isConnecting = false;
|
||||
});
|
||||
WakelockPlus.enable();
|
||||
} catch (error) {
|
||||
setState(() => _isConnecting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Connection failed: $error')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _connect() {
|
||||
final ip = _ipController.text.trim();
|
||||
if (ip.isNotEmpty) _connectTo(ip);
|
||||
}
|
||||
|
||||
void _onData(dynamic data) {
|
||||
if (data is List<int>) {
|
||||
setState(() {
|
||||
_currentFrame = Uint8List.fromList(data);
|
||||
_frameCount++;
|
||||
});
|
||||
} else if (data is String) {
|
||||
_handleState(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleState(String json) {
|
||||
try {
|
||||
final state = jsonDecode(json) as Map<String, dynamic>;
|
||||
setState(() {
|
||||
_cameraCount = state['cameras'] as int? ?? 0;
|
||||
_flashOn = state['flash'] as bool? ?? false;
|
||||
_lensDirection = state['lens'] as String? ?? '';
|
||||
_cameraName = state['name'] as String? ?? '';
|
||||
_zoom = (state['zoom'] as num?)?.toDouble() ?? 1.0;
|
||||
_minZoom = (state['minZoom'] as num?)?.toDouble() ?? 1.0;
|
||||
_maxZoom = (state['maxZoom'] as num?)?.toDouble() ?? 1.0;
|
||||
_quality = state['quality'] as int? ?? 50;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _sendCommand(String command) => _socket?.add(command);
|
||||
|
||||
void _onDisconnected() {
|
||||
_fpsTimer?.cancel();
|
||||
_fpsTimer = null;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isConnected = false;
|
||||
_currentFrame = null;
|
||||
});
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
void _disconnect() {
|
||||
_socket?.close();
|
||||
_onDisconnected();
|
||||
}
|
||||
|
||||
void _cycleRotation() {
|
||||
setState(() => _rotation = (_rotation + 1) % 4);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fpsTimer?.cancel();
|
||||
_socket?.close();
|
||||
_ipController.dispose();
|
||||
WakelockPlus.disable();
|
||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isConnected) return _buildViewer();
|
||||
return _buildConnectScreen();
|
||||
}
|
||||
|
||||
Widget _buildConnectScreen() {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Viewer')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSearching) ...[
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Searching for camera...'),
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: _ipController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Camera IP address',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _connect(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _isConnecting ? null : _connect,
|
||||
icon: _isConnecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(_isConnecting ? 'Connecting...' : 'Connect'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isConnecting ? null : _autoDiscover,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Search'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewer() {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_currentFrame != null)
|
||||
Center(
|
||||
child: Transform.flip(
|
||||
flipX: _mirrored,
|
||||
child: RotatedBox(
|
||||
quarterTurns: _rotation,
|
||||
child: Image.memory(
|
||||
_currentFrame!,
|
||||
gaplessPlayback: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildTopBar(),
|
||||
_buildBottomControls(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.circle, color: Colors.green, size: 12),
|
||||
const SizedBox(width: 8),
|
||||
Text('$_fps fps'),
|
||||
if (_cameraName.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$_lensDirection $_cameraName',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<double> get _availableZoomPresets {
|
||||
const presets = [0.5, 1.0, 2.0, 3.0, 5.0, 10.0];
|
||||
return presets.where((z) => z >= _minZoom && z <= _maxZoom).toList();
|
||||
}
|
||||
|
||||
Widget _buildBottomControls() {
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black54, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _disconnect,
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
if (_cameraCount > 1)
|
||||
IconButton(
|
||||
onPressed: () => _sendCommand('switch_camera'),
|
||||
icon: const Icon(Icons.flip_camera_android, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _sendCommand('toggle_flash'),
|
||||
icon: Icon(
|
||||
_flashOn ? Icons.flash_on : Icons.flash_off,
|
||||
size: 20,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _cycleRotation,
|
||||
icon: const Icon(Icons.rotate_right, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _mirrored = !_mirrored),
|
||||
icon: const Icon(Icons.flip, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const Spacer(),
|
||||
if (_availableZoomPresets.length > 1)
|
||||
..._buildChipGroup(
|
||||
_availableZoomPresets.map((level) {
|
||||
final active = (_zoom - level).abs() < 0.1;
|
||||
final label = level == level.roundToDouble()
|
||||
? '${level.toInt()}x'
|
||||
: '${level}x';
|
||||
return _chip(label, active, () => _sendCommand('zoom:$level'));
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
..._buildChipGroup(
|
||||
[30, 50, 70, 90].map(
|
||||
(q) => _chip('$q%', _quality == q, () => _sendCommand('quality:$q')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildChipGroup(Iterable<Widget> chips) => chips.toList();
|
||||
|
||||
Widget _chip(String label, bool active, VoidCallback onTap) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? Colors.white24 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: active ? Colors.white70 : Colors.white30,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: active ? Colors.white : Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user