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.
325 lines
8.1 KiB
Dart
325 lines
8.1 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|