Add Android quick streaming and logout confirmation
This commit is contained in:
@@ -11,12 +11,12 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<application
|
<application
|
||||||
android:label="Hightube"
|
android:label="Hightube"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = context.watch<SettingsProvider>();
|
final settings = context.watch<SettingsProvider>();
|
||||||
@@ -152,10 +178,7 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
icon: Icon(Icons.refresh),
|
icon: Icon(Icons.refresh),
|
||||||
onPressed: () => _refreshRooms(),
|
onPressed: () => _refreshRooms(),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(icon: Icon(Icons.logout), onPressed: _confirmLogout),
|
||||||
icon: Icon(Icons.logout),
|
|
||||||
onPressed: () => context.read<AuthProvider>().logout(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import 'package:flutter/services.dart';
|
|||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
|
import '../widgets/android_quick_stream_panel.dart';
|
||||||
|
|
||||||
class MyStreamPage extends StatefulWidget {
|
class MyStreamPage extends StatefulWidget {
|
||||||
|
const MyStreamPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_MyStreamPageState createState() => _MyStreamPageState();
|
State<MyStreamPage> createState() => _MyStreamPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MyStreamPageState extends State<MyStreamPage> {
|
class _MyStreamPageState extends State<MyStreamPage> {
|
||||||
@@ -29,13 +32,23 @@ class _MyStreamPageState extends State<MyStreamPage> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await api.getMyRoom();
|
final response = await api.getMyRoom();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
setState(() => _roomInfo = jsonDecode(response.body));
|
setState(() => _roomInfo = jsonDecode(response.body));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,59 +61,76 @@ class _MyStreamPageState extends State<MyStreamPage> {
|
|||||||
body: _isLoading
|
body: _isLoading
|
||||||
? Center(child: CircularProgressIndicator())
|
? Center(child: CircularProgressIndicator())
|
||||||
: _roomInfo == null
|
: _roomInfo == null
|
||||||
? Center(child: Text("No room info found."))
|
? Center(child: Text("No room info found."))
|
||||||
: SingleChildScrollView(
|
: SingleChildScrollView(
|
||||||
padding: EdgeInsets.all(20),
|
padding: EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildInfoCard(
|
_buildInfoCard(
|
||||||
title: "Room Title",
|
title: "Room Title",
|
||||||
value: _roomInfo!['title'],
|
value: _roomInfo!['title'],
|
||||||
icon: Icons.edit,
|
icon: Icons.edit,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: Implement title update API later
|
// TODO: Implement title update API later
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Title editing coming soon!")));
|
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final auth = context.watch<AuthProvider>();
|
final auth = context.watch<AuthProvider>();
|
||||||
@@ -278,7 +304,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.tonalIcon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: auth.logout,
|
onPressed: () => _confirmLogout(auth),
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
label: const Text("Logout"),
|
label: const Text("Logout"),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
|
|||||||
470
frontend/lib/widgets/android_quick_stream_panel.dart
Normal file
470
frontend/lib/widgets/android_quick_stream_panel.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -368,6 +368,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -416,6 +464,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ dependencies:
|
|||||||
video_player: ^2.11.1
|
video_player: ^2.11.1
|
||||||
fvp: ^0.35.2
|
fvp: ^0.35.2
|
||||||
web_socket_channel: ^3.0.3
|
web_socket_channel: ^3.0.3
|
||||||
|
permission_handler: ^12.0.1
|
||||||
|
rtmp_streaming: ^1.0.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user