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:
2026-03-06 18:14:27 +00:00
commit 1660696d7f
32 changed files with 1962 additions and 0 deletions

324
lib/camera_screen.dart Normal file
View 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,
),
],
),
);
}
}