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 createState() => _ViewerScreenState(); } class _ViewerScreenState extends State { 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 _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 _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) { setState(() { _currentFrame = Uint8List.fromList(data); _frameCount++; }); } else if (data is String) { _handleState(data); } } void _handleState(String json) { try { final state = jsonDecode(json) as Map; 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 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 _buildChipGroup(Iterable 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, ), ), ), ), ); } }