优化录制页性能

This commit is contained in:
2026-07-28 13:47:25 +08:00
parent a56a42c7c8
commit 36253aacbe
7 changed files with 450 additions and 167 deletions
+141 -62
View File
@@ -9,9 +9,11 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/app/router/app_navigator.dart'; import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart'; import 'package:recording_tool/core/platform/app_platform_info.dart';
import 'package:recording_tool/core/platform/device_health_checker.dart'; import 'package:recording_tool/core/platform/device_health_checker.dart';
import 'package:recording_tool/core/platform/device_health_snapshot.dart';
import 'package:recording_tool/features/recording/dialog/dialog-record.dart'; import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart'; import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart'; import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/utils/recording_performance.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart'; import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart'; import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
@@ -45,15 +47,23 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
var _immersiveApplied = false; var _immersiveApplied = false;
var _previewReady = false; var _previewReady = false;
var _stoppingByUser = false; var _stoppingByUser = false;
var _bootstrapScheduled = false;
var _bootstrapStarted = false;
var _releasingResources = false;
var _controllerDisposed = false; var _controllerDisposed = false;
Animation<double>? _routeAnimation;
DeviceHealthSnapshot? _deviceHealthSnapshot;
Future<void>? _deviceHealthFuture;
final Stopwatch _pageEntryStopwatch = Stopwatch();
String? _mainCameraId; String? _mainCameraId;
String? _ultraWideCameraId; String? _ultraWideCameraId;
double _ultraWideZoomRatio = 1.0; double _ultraWideZoomRatio = 1.0;
@override @override
/// 首帧后初始化录制流程 /// 创建推流控制器,业务初始化等待路由动画结束。
void initState() { void initState() {
super.initState(); super.initState();
_pageEntryStopwatch.start();
_streamController = ApiVideoLiveStreamController( _streamController = ApiVideoLiveStreamController(
initialAudioConfig: AudioConfig(bitrate: 128000), initialAudioConfig: AudioConfig(bitrate: 128000),
initialVideoConfig: VideoConfig.withDefaultBitrate( initialVideoConfig: VideoConfig.withDefaultBitrate(
@@ -88,20 +98,65 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
.setError(error.toString()); .setError(error.toString());
}, },
); );
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final animation = ModalRoute.of(context)?.animation;
if (identical(animation, _routeAnimation)) return;
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
_routeAnimation = animation;
if (animation == null || animation.status == AnimationStatus.completed) {
_scheduleBootstrap();
} else {
animation.addStatusListener(_handleRouteAnimationStatus);
}
}
void _handleRouteAnimationStatus(AnimationStatus status) {
if (status != AnimationStatus.completed) return;
logRecordingPerformance(
'route transition completed',
_pageEntryStopwatch.elapsed,
);
_scheduleBootstrap();
}
void _scheduleBootstrap() {
if (_bootstrapScheduled || _bootstrapStarted || _releasingResources) return;
_bootstrapScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _releasingResources || _bootstrapStarted) return;
_bootstrapStarted = true;
_bootstrapScheduled = false;
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
_routeAnimation = null;
ref ref
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.setRecordingContext(widget.recordingContext); .setRecordingContext(widget.recordingContext);
_bootstrap(); unawaited(_bootstrap());
}); });
} }
/// 检查设备健康状态并弹窗提示 Future<void> _loadDeviceHealth() async {
Future<void> _checkAndShowDeviceHealthAlerts() async { try {
final snapshot = await AppPlatformInfo.deviceHealth(); _deviceHealthSnapshot = await measureRecordingOperation(
if (!mounted) return; 'device health',
AppPlatformInfo.deviceHealth,
);
} catch (error) {
debugPrint('读取设备健康状态失败: $error');
}
}
/// 使用当前页面缓存的健康状态,在开始录制前按需提示。
Future<void> _checkAndShowDeviceHealthAlerts() async {
_deviceHealthFuture ??= _loadDeviceHealth();
await _deviceHealthFuture;
if (!mounted) return;
final snapshot = _deviceHealthSnapshot;
if (snapshot == null) return;
final lines = DeviceHealthChecker.warningLines(snapshot); final lines = DeviceHealthChecker.warningLines(snapshot);
if (lines.isEmpty) return; if (lines.isEmpty) return;
@@ -112,16 +167,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
); );
} }
/// 页面启动:健康检查、进入录制模式、准备相机会话 /// 路由动画完成后再准备权限和相机,避免阻塞转场。
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
await _checkAndShowDeviceHealthAlerts(); await measureRecordingOperation(
'enter immersive mode',
_enterRecordingMode,
);
if (!mounted) return; if (!mounted) return;
final permissions = await ref
.read(recordingViewModelProvider.notifier)
.prepareRequiredPermissions();
if (!mounted) return;
if (!permissions.allGranted) return;
await measureRecordingOperation(
'preview initialize',
_initializeLiveStreamPreview,
);
if (!mounted || !_previewReady) return;
_deviceHealthFuture ??= _loadDeviceHealth();
unawaited(_loadPostPreviewTasks());
}
await _enterRecordingMode(); Future<void> _loadPostPreviewTasks() async {
if (!mounted) return; try {
await ref.read(recordingViewModelProvider.notifier).prepareSession(); await Future.wait([
if (!mounted) return; measureRecordingOperation(
await _initializeLiveStreamPreview(); 'camera capabilities',
_loadBackCameraCapabilities,
),
ref.read(recordingViewModelProvider.notifier).loadSystemAdvisories(),
_deviceHealthFuture ??= _loadDeviceHealth(),
]);
} catch (error) {
debugPrint('加载录制页辅助状态失败: $error');
}
} }
Future<void> _initializeLiveStreamPreview() async { Future<void> _initializeLiveStreamPreview() async {
@@ -132,7 +211,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
ref ref
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.setPreviewReady(ready: true); .setPreviewReady(ready: true);
await _loadBackCameraCapabilities();
} on PlatformException catch (error) { } on PlatformException catch (error) {
if (!mounted) return; if (!mounted) return;
ref ref
@@ -154,6 +232,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
Future<void> _loadBackCameraCapabilities() async { Future<void> _loadBackCameraCapabilities() async {
try { try {
final cameras = await _streamController.getBackCameras(); final cameras = await _streamController.getBackCameras();
if (!mounted || _releasingResources) return;
if (cameras.isEmpty) return; if (cameras.isEmpty) return;
final main = cameras.first; final main = cameras.first;
final widest = cameras.reduce( final widest = cameras.reduce(
@@ -179,6 +258,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
maxZoomRatio: 1.0, maxZoomRatio: 1.0,
); );
} catch (_) { } catch (_) {
if (!mounted || _releasingResources) return;
ref ref
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.updateZoomCapabilities( .updateZoomCapabilities(
@@ -217,8 +297,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
final ready = ref.read(recordingViewModelProvider).session.isPreviewReady; final ready = ref.read(recordingViewModelProvider).session.isPreviewReady;
if (ready) return true; if (ready) return true;
if (!mounted) return false; if (!mounted) return false;
AppToast.show('相机预览启动失败,请重试'); await measureRecordingOperation(
return false; 'preview initialize after permission',
_initializeLiveStreamPreview,
);
if (!mounted || !_previewReady) {
AppToast.show('相机预览启动失败,请重试');
return false;
}
_deviceHealthFuture ??= _loadDeviceHealth();
unawaited(_loadPostPreviewTasks());
return true;
} }
if (!mounted) return false; if (!mounted) return false;
@@ -337,50 +426,49 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
); );
} }
/// 退出沉浸式并释放录制会话。
/// 先恢复系统栏,再错开一帧释放编码器,减轻返回动画卡顿。
Future<void> _exitRecordingMode() async {
await _restoreSystemUiIfNeeded();
if (mounted) {
await ref.read(recordingViewModelProvider.notifier).teardown();
}
// 让 pop 动画先跑起来,再做 MediaCodec 释放。
await Future<void>.delayed(Duration.zero);
await _disposeStreamController();
}
Future<void> _restoreSystemUiIfNeeded() async {
if (!_immersiveApplied) return;
_immersiveApplied = false;
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
await RecordingPlatform.setImmersiveMode(enabled: false);
}
@override @override
/// 页面销毁时兜底恢复系统 UI 并释放推流控制器 /// 路由反向动画结束、页面销毁后统一释放资源。
void dispose() { void dispose() {
if (_immersiveApplied) { _releasingResources = true;
_immersiveApplied = false; _routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
SystemChrome.setEnabledSystemUIMode( _routeAnimation = null;
SystemUiMode.manual, final viewModel = ref.read(recordingViewModelProvider.notifier);
overlays: SystemUiOverlay.values, unawaited(_releaseResources(viewModel));
);
unawaited(RecordingPlatform.setImmersiveMode(enabled: false));
}
unawaited(_disposeStreamController());
super.dispose(); super.dispose();
} }
/// 只 dispose 一次;原生 dispose 内部已 stopStream,避免 stop+dispose 双次停流。 Future<void> _releaseResources(RecordingViewModel viewModel) async {
await measureRecordingOperation('page resource release', () async {
if (_immersiveApplied) {
_immersiveApplied = false;
try {
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
await RecordingPlatform.setImmersiveMode(enabled: false);
} catch (error) {
debugPrint('恢复系统栏失败: $error');
}
}
try {
await viewModel.teardown();
} catch (error) {
debugPrint('清理录制状态失败: $error');
}
await _disposeStreamController();
});
}
/// 只 dispose 一次;原生 dispose 统一停止推流和预览。
Future<void> _disposeStreamController() async { Future<void> _disposeStreamController() async {
if (_controllerDisposed) return; if (_controllerDisposed) return;
_controllerDisposed = true; _controllerDisposed = true;
try { try {
await _streamController.dispose(); await measureRecordingOperation(
'stream controller dispose',
_streamController.dispose,
);
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrint('释放推流控制器失败: $error\n$stackTrace'); debugPrint('释放推流控制器失败: $error\n$stackTrace');
} }
@@ -390,7 +478,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
/// 构建录制页 UI /// 构建录制页 UI
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _RecordingPopScope( return _RecordingPopScope(
onExitRecordingMode: _exitRecordingMode,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Column( body: Column(
@@ -426,12 +513,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
} }
class _RecordingPopScope extends ConsumerWidget { class _RecordingPopScope extends ConsumerWidget {
const _RecordingPopScope({ const _RecordingPopScope({required this.child});
required this.onExitRecordingMode,
required this.child,
});
final Future<void> Function() onExitRecordingMode;
final Widget child; final Widget child;
@override @override
@@ -443,11 +526,7 @@ class _RecordingPopScope extends ConsumerWidget {
return PopScope( return PopScope(
canPop: !isRecording, canPop: !isRecording,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (didPop) { if (didPop) return;
// 不 await,避免把 MediaCodec 释放堵在 Pop 回调上。
unawaited(onExitRecordingMode());
return;
}
if (isRecording) { if (isRecording) {
AppToast.show('录制中无法返回,请先停止录制'); AppToast.show('录制中无法返回,请先停止录制');
} }
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
Future<T> measureRecordingOperation<T>(
String label,
Future<T> Function() operation,
) async {
if (kReleaseMode) {
return operation();
}
final stopwatch = Stopwatch()..start();
try {
return await operation();
} finally {
stopwatch.stop();
debugPrint(
'[RecordingPerformance] $label: ${stopwatch.elapsedMilliseconds}ms',
);
}
}
void logRecordingPerformance(String label, Duration elapsed) {
if (kReleaseMode) return;
debugPrint('[RecordingPerformance] $label: ${elapsed.inMilliseconds}ms');
}
@@ -8,6 +8,7 @@ import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart'; import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart'; import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/utils/recording_performance.dart';
/// 录制页状态 Provider。 /// 录制页状态 Provider。
final recordingViewModelProvider = final recordingViewModelProvider =
@@ -32,6 +33,8 @@ class RecordingRequiredPermissions {
class RecordingViewModel extends Notifier<RecordingModel> { class RecordingViewModel extends Notifier<RecordingModel> {
Timer? _elapsedTimer; Timer? _elapsedTimer;
DateTime? _recordingStartedAt; DateTime? _recordingStartedAt;
RecordingRequiredPermissions? _cachedRequiredPermissions;
var _sessionGeneration = 0;
/// 初始化状态并注册销毁回调。 /// 初始化状态并注册销毁回调。
@override @override
@@ -52,55 +55,88 @@ class RecordingViewModel extends Notifier<RecordingModel> {
state = state.copyWith(recordingContext: recordingContext); state = state.copyWith(recordingContext: recordingContext);
} }
/// 申请权限并检查系统设置 /// 准备相机和麦克风权限;已授权时复用当前会话结果
Future<void> prepareSession() async { Future<RecordingRequiredPermissions> prepareRequiredPermissions({
bool forceRefresh = false,
}) async {
final generation = _sessionGeneration;
if (!RecordingPlatform.isSupported) { if (!RecordingPlatform.isSupported) {
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制')); _updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
return; return const RecordingRequiredPermissions(
cameraGranted: false,
microphoneGranted: false,
);
} }
final permissions = await PermissionService.requestMissing([ final cached = _cachedRequiredPermissions;
Permission.camera, if (!forceRefresh && cached?.allGranted == true) {
Permission.microphone, return cached!;
if (Platform.isAndroid) Permission.notification,
]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
if (!cameraGranted) {
_updateSession((s) => s.copyWith(errorMessage: '需要相机权限才能录制'));
return;
} }
final microphoneGranted = final permissions = await measureRecordingOperation(
permissions[Permission.microphone]?.isGranted ?? false; 'required permissions',
() => PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
]),
);
final result = RecordingRequiredPermissions(
cameraGranted: _isPermissionGranted(permissions[Permission.camera]),
microphoneGranted: _isPermissionGranted(
permissions[Permission.microphone],
),
);
if (generation != _sessionGeneration) return result;
_cachedRequiredPermissions = result;
final errorMessage = !result.cameraGranted
? '需要相机权限才能录制'
: (!result.microphoneGranted ? '需要录音权限才能录制' : null);
_updateSession(
(s) => s.copyWith(
isMicrophoneGranted: result.microphoneGranted,
errorMessage: errorMessage,
),
);
return result;
}
/// 加载不影响相机首帧的通知、勿扰和电池状态。
Future<void> loadSystemAdvisories() async {
final generation = _sessionGeneration;
final notificationFuture = Platform.isAndroid
? PermissionService.requestMissing([Permission.notification])
: Future.value(<Permission, PermissionStatus>{});
final results = await measureRecordingOperation(
'system advisories',
() => Future.wait<dynamic>([
notificationFuture,
RecordingPlatform.hasNotificationPolicyAccess(),
RecordingPlatform.isIgnoringBatteryOptimizations(),
]),
);
final notificationPermissions =
results[0] as Map<Permission, PermissionStatus>;
final notificationsGranted = Platform.isAndroid final notificationsGranted = Platform.isAndroid
? (permissions[Permission.notification]?.isGranted ?? false) ? _isPermissionGranted(notificationPermissions[Permission.notification])
: true; : true;
final hasDnd = results[1] as bool;
final batteryIgnored = results[2] as bool;
if (generation != _sessionGeneration) return;
final warnings = <String>[]; final warnings = <String>[];
if (Platform.isAndroid && !notificationsGranted) { if (Platform.isAndroid && !notificationsGranted) {
warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制'); warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制');
} }
if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
await RecordingPlatform.isIgnoringBatteryOptimizations();
_updateSession( _updateSession(
(s) => s.copyWith( (s) => s.copyWith(
hasDndAccess: hasDnd, hasDndAccess: hasDnd,
isBatteryOptimizedIgnored: batteryIgnored, isBatteryOptimizedIgnored: batteryIgnored,
isMicrophoneGranted: microphoneGranted,
notificationsGranted: notificationsGranted, notificationsGranted: notificationsGranted,
permissionWarning: warnings.isEmpty ? null : warnings.join('\n'), permissionWarning: warnings.isEmpty ? null : warnings.join('\n'),
errorMessage: null,
clearPermissionWarning: warnings.isEmpty, clearPermissionWarning: warnings.isEmpty,
), ),
); );
_updateSession((s) => s.copyWith(errorMessage: null));
} }
void setPreviewReady({required bool ready, String? errorMessage}) { void setPreviewReady({required bool ready, String? errorMessage}) {
@@ -112,27 +148,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。 /// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
Future<RecordingRequiredPermissions> Future<RecordingRequiredPermissions>
ensureCameraAndMicrophonePermissions() async { ensureCameraAndMicrophonePermissions() async {
final permissions = await PermissionService.requestMissing([ final cached = _cachedRequiredPermissions;
Permission.camera, return prepareRequiredPermissions(forceRefresh: cached?.allGranted != true);
Permission.microphone,
]);
final cameraGranted = _isPermissionGranted(permissions[Permission.camera]);
final microphoneGranted = _isPermissionGranted(
permissions[Permission.microphone],
);
_updateSession((s) => s.copyWith(isMicrophoneGranted: microphoneGranted));
if (cameraGranted && !state.session.isPreviewReady) {
_updateSession((s) => s.copyWith(errorMessage: null));
_updateSession((s) => s.copyWith(isPreviewReady: true));
}
return RecordingRequiredPermissions(
cameraGranted: cameraGranted,
microphoneGranted: microphoneGranted,
);
} }
bool _isPermissionGranted(PermissionStatus? status) { bool _isPermissionGranted(PermissionStatus? status) {
@@ -282,11 +299,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 退出录制页时释放勿扰和会话状态(沉浸式由页面统一恢复)。 /// 退出录制页时释放勿扰和会话状态(沉浸式由页面统一恢复)。
Future<void> teardown() async { Future<void> teardown() async {
await RecordingPlatform.disableDoNotDisturb(); _sessionGeneration++;
_cachedRequiredPermissions = null;
_recordingStartedAt = null; _recordingStartedAt = null;
_elapsedTimer?.cancel(); _elapsedTimer?.cancel();
_elapsedTimer = null; _elapsedTimer = null;
state = state.copyWith(session: const RecordingSessionState()); state = state.copyWith(session: const RecordingSessionState());
await RecordingPlatform.disableDoNotDisturb();
} }
/// Provider 销毁时取消状态流订阅。 /// Provider 销毁时取消状态流订阅。
@@ -2,6 +2,8 @@ package video.api.flutter.livestream
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Size import android.util.Size
import android.view.Surface import android.view.Surface
import io.flutter.view.TextureRegistry import io.flutter.view.TextureRegistry
@@ -17,7 +19,15 @@ import io.github.thibaultbee.streampack.utils.frontCameraList
import io.github.thibaultbee.streampack.utils.isBackCamera import io.github.thibaultbee.streampack.utils.isBackCamera
import io.github.thibaultbee.streampack.utils.isExternalCamera import io.github.thibaultbee.streampack.utils.isExternalCamera
import io.github.thibaultbee.streampack.utils.isFrontCamera import io.github.thibaultbee.streampack.utils.isFrontCamera
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class FlutterLiveStreamView( class FlutterLiveStreamView(
private val context: Context, private val context: Context,
@@ -39,9 +49,21 @@ class FlutterLiveStreamView(
initialOnConnectionListener = this, initialOnConnectionListener = this,
initialOnErrorListener = this initialOnErrorListener = this
) )
private val mainHandler = Handler(Looper.getMainLooper())
private val operationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val operationMutex = Mutex()
private val disposeLock = Any()
private val disposeCallbacks =
mutableListOf<Pair<() -> Unit, (Exception) -> Unit>>()
@Volatile
private var _isPreviewing = false private var _isPreviewing = false
@Volatile
private var _isStreaming = false private var _isStreaming = false
@Volatile
private var _isDisposing = false
@Volatile
private var _isDisposed = false
val isStreaming: Boolean val isStreaming: Boolean
get() = _isStreaming get() = _isStreaming
@@ -63,7 +85,7 @@ class FlutterLiveStreamView(
val wasPreviewing = _isPreviewing val wasPreviewing = _isPreviewing
if (wasPreviewing) { if (wasPreviewing) {
stopPreview() stopPreviewInternal()
} }
streamer.configure(videoConfig) streamer.configure(videoConfig)
_videoConfig = videoConfig _videoConfig = videoConfig
@@ -174,23 +196,58 @@ class FlutterLiveStreamView(
setCamera(cameraList.first(), onSuccess, onError) setCamera(cameraList.first(), onSuccess, onError)
} }
fun dispose() { fun dispose(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
try { var shouldStartDispose = false
stopStream() synchronized(disposeLock) {
} catch (e: Exception) { if (_isDisposed) {
android.util.Log.w("ApiVideoLiveStream", "stopStream during dispose failed", e) mainHandler.post(onSuccess)
_isStreaming = false return
}
disposeCallbacks.add(onSuccess to onError)
if (!_isDisposing) {
_isDisposing = true
shouldStartDispose = true
}
} }
try { if (!shouldStartDispose) return
streamer.stopPreview()
} catch (e: Exception) { operationScope.launch {
android.util.Log.w("ApiVideoLiveStream", "stopPreview during dispose failed", e) var failure: Exception? = null
operationMutex.withLock {
try {
stopStreamInternal()
} catch (e: Exception) {
android.util.Log.w("ApiVideoLiveStream", "stopStream during dispose failed", e)
failure = e
}
try {
stopPreviewInternal()
} catch (e: Exception) {
android.util.Log.w("ApiVideoLiveStream", "stopPreview during dispose failed", e)
failure = failure ?: e
}
}
withContext(Dispatchers.Main.immediate) {
try {
flutterTexture.release()
} catch (e: Exception) {
failure = failure ?: e
}
val callbacks = synchronized(disposeLock) {
_isDisposed = true
_isDisposing = false
disposeCallbacks.toList().also { disposeCallbacks.clear() }
}
callbacks.forEach { (success, error) ->
failure?.let(error) ?: success()
}
}
operationScope.cancel()
} }
_isPreviewing = false
flutterTexture.release()
} }
fun startStream(url: String) { fun startStream(url: String) {
check(!_isDisposing && !_isDisposed) { "Live stream has been disposed" }
runBlocking { runBlocking {
streamer.connect(url) streamer.connect(url)
try { try {
@@ -204,29 +261,31 @@ class FlutterLiveStreamView(
} }
} }
fun stopStream() { fun stopStream(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
if (!_isStreaming && !streamer.isConnected) { runBackgroundOperation(onSuccess, onError) {
return stopStreamInternal()
} }
}
private suspend fun stopStreamInternal() {
if (!_isStreaming && !streamer.isConnected) return
val isConnected = streamer.isConnected val isConnected = streamer.isConnected
var failure: Exception? = null
try { try {
runBlocking { streamer.stopStream()
streamer.stopStream()
streamer.disconnect()
if (isConnected) {
onDisconnected()
}
_isStreaming = false
}
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("ApiVideoLiveStream", "stopStream failed", e) failure = e
_isStreaming = false
try {
streamer.disconnect()
} catch (_: Exception) {
// ignore secondary disconnect failures
}
} }
try {
streamer.disconnect()
} catch (e: Exception) {
failure = failure ?: e
}
if (isConnected) {
onDisconnected()
}
_isStreaming = false
failure?.let { throw it }
} }
fun startPreview(onSuccess: () -> Unit, onError: (Exception) -> Unit) { fun startPreview(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
@@ -262,11 +321,40 @@ class FlutterLiveStreamView(
}) })
} }
fun stopPreview() { fun stopPreview(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
runBackgroundOperation(onSuccess, onError) {
stopPreviewInternal()
}
}
private fun stopPreviewInternal() {
if (!_isPreviewing) return
streamer.stopPreview() streamer.stopPreview()
_isPreviewing = false _isPreviewing = false
} }
private fun runBackgroundOperation(
onSuccess: () -> Unit,
onError: (Exception) -> Unit,
operation: suspend () -> Unit,
) {
if (_isDisposing || _isDisposed) {
mainHandler.post(onSuccess)
return
}
operationScope.launch {
val failure = try {
operationMutex.withLock { operation() }
null
} catch (e: Exception) {
e
}
withContext(Dispatchers.Main.immediate) {
failure?.let(onError) ?: onSuccess()
}
}
}
private fun getSurface(resolution: Size): Surface { private fun getSurface(resolution: Size): Surface {
val surfaceTexture = flutterTexture.surfaceTexture().apply { val surfaceTexture = flutterTexture.surfaceTexture().apply {
setDefaultBufferSize( setDefaultBufferSize(
@@ -50,27 +50,44 @@ class MethodCallHandlerImpl(
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"create" -> { "create" -> {
try { val previousView = flutterView
flutterView?.dispose() if (previousView == null) {
flutterView = FlutterLiveStreamView( createFlutterView(result)
context, } else {
textureRegistry, previousView.dispose(
permissionsManager, onSuccess = {
{ sendConnected() }, if (flutterView === previousView) {
{ sendDisconnected() }, flutterView = null
{ sendConnectionFailed(it) }, }
{ sendError(it) }, createFlutterView(result)
{ sendVideoSizeChanged(it) } },
onError = {
result.error("failed_to_replace_live_stream", it.message, null)
},
) )
result.success(mapOf("textureId" to flutterView!!.textureId))
} catch (e: Exception) {
result.error("failed_to_create_live_stream", e.message, null)
} }
} }
"dispose" -> { "dispose" -> {
flutterView?.dispose() val view = flutterView
flutterView = null if (view == null) {
result.success(null)
} else {
view.dispose(
onSuccess = {
if (flutterView === view) {
flutterView = null
}
result.success(null)
},
onError = {
if (flutterView === view) {
flutterView = null
}
result.error("failed_to_dispose_live_stream", it.message, null)
},
)
}
} }
"setVideoConfig" -> { "setVideoConfig" -> {
@@ -128,8 +145,17 @@ class MethodCallHandlerImpl(
} }
"stopPreview" -> { "stopPreview" -> {
flutterView?.stopPreview() val view = flutterView
result.success(null) if (view == null) {
result.success(null)
} else {
view.stopPreview(
onSuccess = { result.success(null) },
onError = {
result.error("failed_to_stop_preview", it.message, null)
},
)
}
} }
"startStreaming" -> { "startStreaming" -> {
@@ -163,8 +189,17 @@ class MethodCallHandlerImpl(
} }
"stopStreaming" -> { "stopStreaming" -> {
flutterView?.stopStream() val view = flutterView
result.success(null) if (view == null) {
result.success(null)
} else {
view.stopStream(
onSuccess = { result.success(null) },
onError = {
result.error("failed_to_stop_stream", it.message, null)
},
)
}
} }
"getIsStreaming" -> result.success(mapOf("isStreaming" to flutterView!!.isStreaming)) "getIsStreaming" -> result.success(mapOf("isStreaming" to flutterView!!.isStreaming))
@@ -275,6 +310,24 @@ class MethodCallHandlerImpl(
} }
} }
private fun createFlutterView(result: MethodChannel.Result) {
try {
flutterView = FlutterLiveStreamView(
context,
textureRegistry,
permissionsManager,
{ sendConnected() },
{ sendDisconnected() },
{ sendConnectionFailed(it) },
{ sendError(it) },
{ sendVideoSizeChanged(it) },
)
result.success(mapOf("textureId" to flutterView!!.textureId))
} catch (e: Exception) {
result.error("failed_to_create_live_stream", e.message, null)
}
}
private fun sendConnected() { private fun sendConnected() {
sendEvent("connected") sendEvent("connected")
} }
@@ -68,7 +68,6 @@ class _ApiVideoCameraPreviewState extends State<ApiVideoCameraPreview> {
@override @override
void dispose() { void dispose() {
widget.controller.stopPreview();
widget.controller.removeWidgetListener(_widgetListener); widget.controller.removeWidgetListener(_widgetListener);
widget.controller.removeEventsListener(_eventsListener); widget.controller.removeEventsListener(_eventsListener);
super.dispose(); super.dispose();
@@ -26,6 +26,8 @@ class ApiVideoLiveStreamController {
int get textureId => _textureId; int get textureId => _textureId;
bool _isInitialized = false; bool _isInitialized = false;
Future<void>? _initializeFuture;
Future<void>? _disposeFuture;
/// Gets the current state of the video player. /// Gets the current state of the video player.
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
@@ -68,7 +70,14 @@ class ApiVideoLiveStreamController {
} }
/// Creates a new live stream instance with initial audio and video configurations. /// Creates a new live stream instance with initial audio and video configurations.
Future<void> initialize() async { Future<void> initialize() {
if (_disposeFuture != null) {
throw StateError('Cannot initialize a disposed controller');
}
return _initializeFuture ??= _initialize();
}
Future<void> _initialize() async {
_textureId = await _platform.initialize() ?? kUninitializedTextureId; _textureId = await _platform.initialize() ?? kUninitializedTextureId;
_eventSubscription = _platform _eventSubscription = _platform
@@ -91,12 +100,23 @@ class ApiVideoLiveStreamController {
} }
/// Disposes the live stream instance. /// Disposes the live stream instance.
Future<void> dispose() async { Future<void> dispose() {
return _disposeFuture ??= _dispose();
}
Future<void> _dispose() async {
try {
await _initializeFuture;
} catch (_) {
// A partially initialized native view still needs to be disposed.
}
await _eventSubscription?.cancel(); await _eventSubscription?.cancel();
_eventSubscription = null;
_eventsListeners.clear(); _eventsListeners.clear();
_widgetListeners.clear(); _widgetListeners.clear();
await _platform.dispose(); await _platform.dispose();
return; _isInitialized = false;
_textureId = kUninitializedTextureId;
} }
/// Sets new video parameters. /// Sets new video parameters.