import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:rtmp_streaming/camera.dart'; class AndroidQuickStreamPanel extends StatefulWidget { final String rtmpBaseUrl; final String streamKey; const AndroidQuickStreamPanel({ super.key, required this.rtmpBaseUrl, required this.streamKey, }); @override State createState() => _AndroidQuickStreamPanelState(); } class _AndroidQuickStreamPanelState extends State { final CameraController _controller = CameraController( ResolutionPreset.medium, enableAudio: true, androidUseOpenGL: true, ); List _cameras = const []; CameraDescription? _currentCamera; StreamStatistics? _stats; Timer? _statsTimer; bool _permissionsGranted = false; bool _isPreparing = true; bool _isBusy = false; bool _audioEnabled = true; String? _statusMessage; String get _streamUrl => '${widget.rtmpBaseUrl}/${widget.streamKey}'; bool get _isInitialized => _controller.value.isInitialized ?? false; bool get _isStreaming => _controller.value.isStreamingVideoRtmp ?? false; @override void initState() { super.initState(); _controller.addListener(_onControllerChanged); _initialize(); } @override void dispose() { _statsTimer?.cancel(); _controller.removeListener(_onControllerChanged); _controller.dispose(); super.dispose(); } Future _initialize() async { if (!mounted) { return; } setState(() { _isPreparing = true; _statusMessage = null; }); final cameraStatus = await Permission.camera.request(); final micStatus = await Permission.microphone.request(); final granted = cameraStatus.isGranted && micStatus.isGranted; if (!mounted) { return; } if (!granted) { setState(() { _permissionsGranted = false; _isPreparing = false; _statusMessage = 'Camera and microphone permissions are required for quick streaming.'; }); return; } try { final cameras = await availableCameras(); if (!mounted) { return; } if (cameras.isEmpty) { setState(() { _permissionsGranted = true; _isPreparing = false; _statusMessage = 'No available cameras were found on this device.'; }); return; } _cameras = cameras; _currentCamera = cameras.first; await _controller.initialize(_currentCamera!); if (!mounted) { return; } setState(() { _permissionsGranted = true; _isPreparing = false; _statusMessage = 'Ready to go live.'; }); } on CameraException catch (e) { if (!mounted) { return; } setState(() { _permissionsGranted = true; _isPreparing = false; _statusMessage = e.description ?? e.code; }); } catch (e) { if (!mounted) { return; } setState(() { _permissionsGranted = true; _isPreparing = false; _statusMessage = 'Failed to initialize camera: $e'; }); } } void _onControllerChanged() { if (!mounted) { return; } final event = _controller.value.event; final eventType = event is Map ? event['eventType']?.toString() : null; if (eventType == 'rtmp_stopped' || eventType == 'camera_closing') { _statsTimer?.cancel(); setState(() { _statusMessage = _controller.value.errorDescription ?? 'Streaming stopped.'; }); return; } if (eventType == 'error' || eventType == 'rtmp_retry') { setState(() { _statusMessage = _controller.value.errorDescription ?? 'Streaming error occurred.'; }); return; } setState(() {}); } Future _startStreaming() async { if (!_isInitialized || _isBusy) { return; } setState(() { _isBusy = true; _statusMessage = 'Connecting to stream server...'; }); try { await _controller.startVideoStreaming(_streamUrl, bitrate: 1500 * 1024); if (_isStreaming && !_audioEnabled) { await _controller.switchAudio(false); } _startStatsPolling(); if (!mounted) { return; } setState(() => _statusMessage = 'Quick stream is live.'); } on CameraException catch (e) { if (!mounted) { return; } setState(() => _statusMessage = e.description ?? e.code); } finally { if (mounted) { setState(() => _isBusy = false); } } } Future _stopStreaming() async { if (!_isStreaming || _isBusy) { return; } setState(() => _isBusy = true); try { await _controller.stopVideoStreaming(); _statsTimer?.cancel(); if (!mounted) { return; } setState(() { _stats = null; _statusMessage = 'Quick stream stopped.'; }); } on CameraException catch (e) { if (!mounted) { return; } setState(() => _statusMessage = e.description ?? e.code); } finally { if (mounted) { setState(() => _isBusy = false); } } } Future _switchCamera() async { if (_cameras.length < 2 || !_isInitialized || _isBusy) { return; } final currentIndex = _cameras.indexOf(_currentCamera!); final nextCamera = _cameras[(currentIndex + 1) % _cameras.length]; final nextCameraName = nextCamera.name; if (nextCameraName == null || nextCameraName.isEmpty) { setState( () => _statusMessage = 'Unable to switch camera on this device.', ); return; } setState(() => _isBusy = true); try { await _controller.switchCamera(nextCameraName); if (!mounted) { return; } setState(() { _currentCamera = nextCamera; _statusMessage = 'Switched camera.'; }); } on CameraException catch (e) { if (!mounted) { return; } setState(() => _statusMessage = e.description ?? e.code); } finally { if (mounted) { setState(() => _isBusy = false); } } } Future _toggleAudio() async { final nextAudioEnabled = !_audioEnabled; if (!_isStreaming) { setState(() { _audioEnabled = nextAudioEnabled; _statusMessage = nextAudioEnabled ? 'Microphone will be enabled when streaming starts.' : 'Microphone will be muted after streaming starts.'; }); return; } setState(() => _isBusy = true); try { await _controller.switchAudio(nextAudioEnabled); if (!mounted) { return; } setState(() { _audioEnabled = nextAudioEnabled; _statusMessage = nextAudioEnabled ? 'Microphone enabled.' : 'Microphone muted.'; }); } on CameraException catch (e) { if (!mounted) { return; } setState(() => _statusMessage = e.description ?? e.code); } finally { if (mounted) { setState(() => _isBusy = false); } } } void _startStatsPolling() { _statsTimer?.cancel(); _statsTimer = Timer.periodic(const Duration(seconds: 2), (_) async { if (!_isStreaming) { return; } try { final stats = await _controller.getStreamStatistics(); if (!mounted) { return; } setState(() => _stats = stats); } catch (_) { // Ignore transient stats failures while streaming starts up. } }); } @override Widget build(BuildContext context) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { return const SizedBox.shrink(); } return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.rocket_launch, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 10), Text( 'Quick Stream (Experimental)', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 8), Text( 'Start streaming directly from your phone camera. For advanced scenes and overlays, continue using OBS.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), _buildPreview(context), const SizedBox(height: 12), _buildStatus(context), const SizedBox(height: 12), Wrap( spacing: 10, runSpacing: 10, children: [ FilledButton.icon( onPressed: !_permissionsGranted || _isPreparing || _isBusy ? null : _isStreaming ? _stopStreaming : _startStreaming, icon: Icon(_isStreaming ? Icons.stop_circle : Icons.wifi), label: Text(_isStreaming ? 'Stop Stream' : 'Start Stream'), ), OutlinedButton.icon( onPressed: _cameras.length > 1 && !_isBusy && _isInitialized ? _switchCamera : null, icon: const Icon(Icons.cameraswitch), label: const Text('Switch Camera'), ), OutlinedButton.icon( onPressed: _isBusy || !_permissionsGranted ? null : _toggleAudio, icon: Icon(_audioEnabled ? Icons.mic : Icons.mic_off), label: Text(_audioEnabled ? 'Mic On' : 'Mic Off'), ), TextButton.icon( onPressed: _isBusy ? null : _initialize, icon: const Icon(Icons.refresh), label: const Text('Reinitialize'), ), ], ), const SizedBox(height: 12), Text( 'Target: $_streamUrl', style: Theme.of(context).textTheme.bodySmall, ), if (_stats != null) ...[ const SizedBox(height: 8), Text( 'Stats: ${_stats!.width ?? '-'}x${_stats!.height ?? '-'} | ${_stats!.fps ?? '-'} fps | ${_formatKbps(_stats!.bitrate)} | dropped ${_stats!.droppedVideoFrames ?? 0} video', style: Theme.of(context).textTheme.bodySmall, ), ], ], ), ), ); } Widget _buildPreview(BuildContext context) { return Container( height: 220, width: double.infinity, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, child: _isPreparing ? const Center(child: CircularProgressIndicator()) : !_permissionsGranted ? _buildPreviewMessage('Grant camera and microphone permissions.') : !_isInitialized ? _buildPreviewMessage('Camera is not ready yet.') : AspectRatio( aspectRatio: _controller.value.aspectRatio, child: CameraPreview(_controller), ), ); } Widget _buildPreviewMessage(String text) { return Center( child: Padding( padding: const EdgeInsets.all(20), child: Text( text, style: const TextStyle(color: Colors.white70), textAlign: TextAlign.center, ), ), ); } Widget _buildStatus(BuildContext context) { final statusText = _statusMessage ?? (_isStreaming ? 'Quick stream is live.' : 'Waiting for camera.'); return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon( _isStreaming ? Icons.circle : Icons.info_outline, size: _isStreaming ? 12 : 18, color: _isStreaming ? Colors.red : null, ), const SizedBox(width: 10), Expanded(child: Text(statusText)), ], ), ); } String _formatKbps(int? bitrate) { if (bitrate == null || bitrate <= 0) { return '- kbps'; } return '${(bitrate / 1000).toStringAsFixed(0)} kbps'; } }