From c5b7451fc6452e91c1ddecc38c3610920890a291 Mon Sep 17 00:00:00 2001 From: CGH0S7 <776459475@qq.com> Date: Wed, 22 Apr 2026 11:44:15 +0800 Subject: [PATCH] Add Android quick streaming and logout confirmation --- frontend/android/app/build.gradle.kts | 6 +- .../android/app/src/main/AndroidManifest.xml | 2 + frontend/lib/pages/home_page.dart | 31 +- frontend/lib/pages/my_stream_page.dart | 140 ++++-- frontend/lib/pages/settings_page.dart | 28 +- .../widgets/android_quick_stream_panel.dart | 470 ++++++++++++++++++ frontend/pubspec.lock | 56 +++ frontend/pubspec.yaml | 2 + 8 files changed, 672 insertions(+), 63 deletions(-) create mode 100644 frontend/lib/widgets/android_quick_stream_panel.dart diff --git a/frontend/android/app/build.gradle.kts b/frontend/android/app/build.gradle.kts index 77956a6..8958620 100644 --- a/frontend/android/app/build.gradle.kts +++ b/frontend/android/app/build.gradle.kts @@ -11,12 +11,12 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index f11babd..1e8ace0 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + { } } + Future _confirmLogout() async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Confirm Logout'), + content: const Text('Are you sure you want to log out now?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Logout'), + ), + ], + ); + }, + ); + + if (confirmed == true && mounted) { + await context.read().logout(); + } + } + @override Widget build(BuildContext context) { final settings = context.watch(); @@ -152,10 +178,7 @@ class _ExploreViewState extends State<_ExploreView> { icon: Icon(Icons.refresh), onPressed: () => _refreshRooms(), ), - IconButton( - icon: Icon(Icons.logout), - onPressed: () => context.read().logout(), - ), + IconButton(icon: Icon(Icons.logout), onPressed: _confirmLogout), ], ), floatingActionButton: FloatingActionButton.extended( diff --git a/frontend/lib/pages/my_stream_page.dart b/frontend/lib/pages/my_stream_page.dart index 205b77f..06f0559 100644 --- a/frontend/lib/pages/my_stream_page.dart +++ b/frontend/lib/pages/my_stream_page.dart @@ -5,10 +5,13 @@ import 'package:flutter/services.dart'; import '../providers/auth_provider.dart'; import '../providers/settings_provider.dart'; import '../services/api_service.dart'; +import '../widgets/android_quick_stream_panel.dart'; class MyStreamPage extends StatefulWidget { + const MyStreamPage({super.key}); + @override - _MyStreamPageState createState() => _MyStreamPageState(); + State createState() => _MyStreamPageState(); } class _MyStreamPageState extends State { @@ -29,13 +32,23 @@ class _MyStreamPageState extends State { try { final response = await api.getMyRoom(); + if (!mounted) { + return; + } if (response.statusCode == 200) { setState(() => _roomInfo = jsonDecode(response.body)); } } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to fetch room info"))); + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Failed to fetch room info"))); } finally { - setState(() => _isLoading = false); + if (mounted) { + setState(() => _isLoading = false); + } } } @@ -48,59 +61,76 @@ class _MyStreamPageState extends State { body: _isLoading ? Center(child: CircularProgressIndicator()) : _roomInfo == null - ? Center(child: Text("No room info found.")) - : SingleChildScrollView( - padding: EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoCard( - title: "Room Title", - value: _roomInfo!['title'], - icon: Icons.edit, - onTap: () { - // TODO: Implement title update API later - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Title editing coming soon!"))); - }, - ), - SizedBox(height: 20), - _buildInfoCard( - title: "RTMP Server URL", - value: settings.rtmpUrl, - icon: Icons.copy, - onTap: () { - Clipboard.setData(ClipboardData(text: settings.rtmpUrl)); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL copied to clipboard"))); - }, - ), - SizedBox(height: 20), - _buildInfoCard( - title: "Stream Key (Keep Secret!)", - value: _roomInfo!['stream_key'], - icon: Icons.copy, - isSecret: true, - onTap: () { - Clipboard.setData(ClipboardData(text: _roomInfo!['stream_key'])); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Stream Key copied to clipboard"))); - }, - ), - SizedBox(height: 30), - Center( - child: Column( - children: [ - Icon(Icons.info_outline, color: Colors.grey), - SizedBox(height: 8), - Text( - "Use OBS or other tools to stream to this address.", - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 12), - ), - ], - ), - ), - ], + ? Center(child: Text("No room info found.")) + : SingleChildScrollView( + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoCard( + title: "Room Title", + value: _roomInfo!['title'], + icon: Icons.edit, + onTap: () { + // TODO: Implement title update API later + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Title editing coming soon!")), + ); + }, ), - ), + SizedBox(height: 20), + _buildInfoCard( + title: "RTMP Server URL", + value: settings.rtmpUrl, + icon: Icons.copy, + onTap: () { + Clipboard.setData(ClipboardData(text: settings.rtmpUrl)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Server URL copied to clipboard"), + ), + ); + }, + ), + SizedBox(height: 20), + _buildInfoCard( + title: "Stream Key (Keep Secret!)", + value: _roomInfo!['stream_key'], + icon: Icons.copy, + isSecret: true, + onTap: () { + Clipboard.setData( + ClipboardData(text: _roomInfo!['stream_key']), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Stream Key copied to clipboard"), + ), + ); + }, + ), + SizedBox(height: 24), + AndroidQuickStreamPanel( + rtmpBaseUrl: settings.rtmpUrl, + streamKey: _roomInfo!['stream_key'], + ), + SizedBox(height: 30), + Center( + child: Column( + children: [ + Icon(Icons.info_outline, color: Colors.grey), + SizedBox(height: 8), + Text( + "Use OBS or other tools to stream to this address.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + ], + ), + ), ); } diff --git a/frontend/lib/pages/settings_page.dart b/frontend/lib/pages/settings_page.dart index 4a50c2e..5041550 100644 --- a/frontend/lib/pages/settings_page.dart +++ b/frontend/lib/pages/settings_page.dart @@ -88,6 +88,32 @@ class _SettingsPageState extends State { } } + Future _confirmLogout(AuthProvider auth) async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Confirm Logout"), + content: const Text("Are you sure you want to log out now?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Cancel"), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text("Logout"), + ), + ], + ); + }, + ); + + if (confirmed == true && mounted) { + await auth.logout(); + } + } + @override Widget build(BuildContext context) { final auth = context.watch(); @@ -278,7 +304,7 @@ class _SettingsPageState extends State { SizedBox( width: double.infinity, child: FilledButton.tonalIcon( - onPressed: auth.logout, + onPressed: () => _confirmLogout(auth), icon: const Icon(Icons.logout), label: const Text("Logout"), style: FilledButton.styleFrom( diff --git a/frontend/lib/widgets/android_quick_stream_panel.dart b/frontend/lib/widgets/android_quick_stream_panel.dart new file mode 100644 index 0000000..60f2a58 --- /dev/null +++ b/frontend/lib/widgets/android_quick_stream_panel.dart @@ -0,0 +1,470 @@ +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'; + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 0517524..2938c45 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -368,6 +368,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -416,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + rtmp_streaming: + dependency: "direct main" + description: + name: rtmp_streaming + sha256: f54c0c0443df65086d2936b0f3432fbb351fc35bffba69aa2b004ee7ecf45d40 + url: "https://pub.dev" + source: hosted + version: "1.0.5" shared_preferences: dependency: "direct main" description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index fe41163..afc7f26 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -40,6 +40,8 @@ dependencies: video_player: ^2.11.1 fvp: ^0.35.2 web_socket_channel: ^3.0.3 + permission_handler: ^12.0.1 + rtmp_streaming: ^1.0.5 dev_dependencies: flutter_test: