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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user