Files
external-cam/lib/viewer_screen.dart
Kai Chappell 1660696d7f 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.
2026-03-06 18:14:27 +00:00

391 lines
11 KiB
Dart

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,
),
),
),
),
);
}
}