Add Android quick streaming and logout confirmation

This commit is contained in:
2026-04-22 11:44:15 +08:00
parent b07f243c88
commit c5b7451fc6
8 changed files with 672 additions and 63 deletions

View File

@@ -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 {

View File

@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="Hightube"
android:name="${applicationName}"

View File

@@ -140,6 +140,32 @@ class _ExploreViewState extends State<_ExploreView> {
}
}
Future<void> _confirmLogout() async {
final confirmed = await showDialog<bool>(
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<AuthProvider>().logout();
}
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>();
@@ -152,10 +178,7 @@ class _ExploreViewState extends State<_ExploreView> {
icon: Icon(Icons.refresh),
onPressed: () => _refreshRooms(),
),
IconButton(
icon: Icon(Icons.logout),
onPressed: () => context.read<AuthProvider>().logout(),
),
IconButton(icon: Icon(Icons.logout), onPressed: _confirmLogout),
],
),
floatingActionButton: FloatingActionButton.extended(

View File

@@ -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<MyStreamPage> createState() => _MyStreamPageState();
}
class _MyStreamPageState extends State<MyStreamPage> {
@@ -29,13 +32,23 @@ class _MyStreamPageState extends State<MyStreamPage> {
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<MyStreamPage> {
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),
),
],
),
),
],
),
),
);
}

View File

@@ -88,6 +88,32 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
Future<void> _confirmLogout(AuthProvider auth) async {
final confirmed = await showDialog<bool>(
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<AuthProvider>();
@@ -278,7 +304,7 @@ class _SettingsPageState extends State<SettingsPage> {
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(

View File

@@ -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<AndroidQuickStreamPanel> createState() =>
_AndroidQuickStreamPanelState();
}
class _AndroidQuickStreamPanelState extends State<AndroidQuickStreamPanel> {
final CameraController _controller = CameraController(
ResolutionPreset.medium,
enableAudio: true,
androidUseOpenGL: true,
);
List<CameraDescription> _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<void> _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<void> _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<void> _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<void> _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<void> _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';
}
}

View File

@@ -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:

View File

@@ -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: