From 36253aacbeabea3867ed065e68712a0b4093f3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Tue, 28 Jul 2026 13:47:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BD=95=E5=88=B6=E9=A1=B5?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/recording/pages/page_record.dart | 203 ++++++++++++------ .../utils/recording_performance.dart | 25 +++ .../view-model/view_model_recording.dart | 117 +++++----- .../livestream/FlutterLiveStreamView.kt | 152 ++++++++++--- .../livestream/MethodCallHandlerImpl.kt | 93 ++++++-- .../lib/src/apivideo_camera_preview.dart | 1 - .../src/apivideo_live_stream_controller.dart | 26 ++- 7 files changed, 450 insertions(+), 167 deletions(-) create mode 100644 lib/features/recording/utils/recording_performance.dart diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 1b7a772..5d5b373 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -9,9 +9,11 @@ import 'package:permission_handler/permission_handler.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/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/model/model_recording_context.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/widgets/widget_camera_preview.dart'; import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart'; @@ -45,15 +47,23 @@ class _RecordingPageState extends ConsumerState { var _immersiveApplied = false; var _previewReady = false; var _stoppingByUser = false; + var _bootstrapScheduled = false; + var _bootstrapStarted = false; + var _releasingResources = false; var _controllerDisposed = false; + Animation? _routeAnimation; + DeviceHealthSnapshot? _deviceHealthSnapshot; + Future? _deviceHealthFuture; + final Stopwatch _pageEntryStopwatch = Stopwatch(); String? _mainCameraId; String? _ultraWideCameraId; double _ultraWideZoomRatio = 1.0; @override - /// 首帧后初始化录制流程 + /// 创建推流控制器,业务初始化等待路由动画结束。 void initState() { super.initState(); + _pageEntryStopwatch.start(); _streamController = ApiVideoLiveStreamController( initialAudioConfig: AudioConfig(bitrate: 128000), initialVideoConfig: VideoConfig.withDefaultBitrate( @@ -88,20 +98,65 @@ class _RecordingPageState extends ConsumerState { .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((_) { + if (!mounted || _releasingResources || _bootstrapStarted) return; + _bootstrapStarted = true; + _bootstrapScheduled = false; + _routeAnimation?.removeStatusListener(_handleRouteAnimationStatus); + _routeAnimation = null; ref .read(recordingViewModelProvider.notifier) .setRecordingContext(widget.recordingContext); - _bootstrap(); + unawaited(_bootstrap()); }); } - /// 检查设备健康状态并弹窗提示 - Future _checkAndShowDeviceHealthAlerts() async { - final snapshot = await AppPlatformInfo.deviceHealth(); - if (!mounted) return; + Future _loadDeviceHealth() async { + try { + _deviceHealthSnapshot = await measureRecordingOperation( + 'device health', + AppPlatformInfo.deviceHealth, + ); + } catch (error) { + debugPrint('读取设备健康状态失败: $error'); + } + } + /// 使用当前页面缓存的健康状态,在开始录制前按需提示。 + Future _checkAndShowDeviceHealthAlerts() async { + _deviceHealthFuture ??= _loadDeviceHealth(); + await _deviceHealthFuture; + if (!mounted) return; + final snapshot = _deviceHealthSnapshot; + if (snapshot == null) return; final lines = DeviceHealthChecker.warningLines(snapshot); if (lines.isEmpty) return; @@ -112,16 +167,40 @@ class _RecordingPageState extends ConsumerState { ); } - /// 页面启动:健康检查、进入录制模式、准备相机会话 + /// 路由动画完成后再准备权限和相机,避免阻塞转场。 Future _bootstrap() async { - await _checkAndShowDeviceHealthAlerts(); + await measureRecordingOperation( + 'enter immersive mode', + _enterRecordingMode, + ); 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(); - if (!mounted) return; - await ref.read(recordingViewModelProvider.notifier).prepareSession(); - if (!mounted) return; - await _initializeLiveStreamPreview(); + Future _loadPostPreviewTasks() async { + try { + await Future.wait([ + measureRecordingOperation( + 'camera capabilities', + _loadBackCameraCapabilities, + ), + ref.read(recordingViewModelProvider.notifier).loadSystemAdvisories(), + _deviceHealthFuture ??= _loadDeviceHealth(), + ]); + } catch (error) { + debugPrint('加载录制页辅助状态失败: $error'); + } } Future _initializeLiveStreamPreview() async { @@ -132,7 +211,6 @@ class _RecordingPageState extends ConsumerState { ref .read(recordingViewModelProvider.notifier) .setPreviewReady(ready: true); - await _loadBackCameraCapabilities(); } on PlatformException catch (error) { if (!mounted) return; ref @@ -154,6 +232,7 @@ class _RecordingPageState extends ConsumerState { Future _loadBackCameraCapabilities() async { try { final cameras = await _streamController.getBackCameras(); + if (!mounted || _releasingResources) return; if (cameras.isEmpty) return; final main = cameras.first; final widest = cameras.reduce( @@ -179,6 +258,7 @@ class _RecordingPageState extends ConsumerState { maxZoomRatio: 1.0, ); } catch (_) { + if (!mounted || _releasingResources) return; ref .read(recordingViewModelProvider.notifier) .updateZoomCapabilities( @@ -217,8 +297,17 @@ class _RecordingPageState extends ConsumerState { final ready = ref.read(recordingViewModelProvider).session.isPreviewReady; if (ready) return true; if (!mounted) return false; - AppToast.show('相机预览启动失败,请重试'); - return false; + await measureRecordingOperation( + 'preview initialize after permission', + _initializeLiveStreamPreview, + ); + if (!mounted || !_previewReady) { + AppToast.show('相机预览启动失败,请重试'); + return false; + } + _deviceHealthFuture ??= _loadDeviceHealth(); + unawaited(_loadPostPreviewTasks()); + return true; } if (!mounted) return false; @@ -337,50 +426,49 @@ class _RecordingPageState extends ConsumerState { ); } - /// 退出沉浸式并释放录制会话。 - /// 先恢复系统栏,再错开一帧释放编码器,减轻返回动画卡顿。 - Future _exitRecordingMode() async { - await _restoreSystemUiIfNeeded(); - if (mounted) { - await ref.read(recordingViewModelProvider.notifier).teardown(); - } - - // 让 pop 动画先跑起来,再做 MediaCodec 释放。 - await Future.delayed(Duration.zero); - await _disposeStreamController(); - } - - Future _restoreSystemUiIfNeeded() async { - if (!_immersiveApplied) return; - _immersiveApplied = false; - await SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - await RecordingPlatform.setImmersiveMode(enabled: false); - } - @override - /// 页面销毁时兜底恢复系统 UI 并释放推流控制器 + /// 路由反向动画结束、页面销毁后统一释放资源。 void dispose() { - if (_immersiveApplied) { - _immersiveApplied = false; - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - unawaited(RecordingPlatform.setImmersiveMode(enabled: false)); - } - unawaited(_disposeStreamController()); + _releasingResources = true; + _routeAnimation?.removeStatusListener(_handleRouteAnimationStatus); + _routeAnimation = null; + final viewModel = ref.read(recordingViewModelProvider.notifier); + unawaited(_releaseResources(viewModel)); super.dispose(); } - /// 只 dispose 一次;原生 dispose 内部已 stopStream,避免 stop+dispose 双次停流。 + Future _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 _disposeStreamController() async { if (_controllerDisposed) return; _controllerDisposed = true; try { - await _streamController.dispose(); + await measureRecordingOperation( + 'stream controller dispose', + _streamController.dispose, + ); } catch (error, stackTrace) { debugPrint('释放推流控制器失败: $error\n$stackTrace'); } @@ -390,7 +478,6 @@ class _RecordingPageState extends ConsumerState { /// 构建录制页 UI Widget build(BuildContext context) { return _RecordingPopScope( - onExitRecordingMode: _exitRecordingMode, child: Scaffold( backgroundColor: Colors.black, body: Column( @@ -426,12 +513,8 @@ class _RecordingPageState extends ConsumerState { } class _RecordingPopScope extends ConsumerWidget { - const _RecordingPopScope({ - required this.onExitRecordingMode, - required this.child, - }); + const _RecordingPopScope({required this.child}); - final Future Function() onExitRecordingMode; final Widget child; @override @@ -443,11 +526,7 @@ class _RecordingPopScope extends ConsumerWidget { return PopScope( canPop: !isRecording, onPopInvokedWithResult: (didPop, result) { - if (didPop) { - // 不 await,避免把 MediaCodec 释放堵在 Pop 回调上。 - unawaited(onExitRecordingMode()); - return; - } + if (didPop) return; if (isRecording) { AppToast.show('录制中无法返回,请先停止录制'); } diff --git a/lib/features/recording/utils/recording_performance.dart b/lib/features/recording/utils/recording_performance.dart new file mode 100644 index 0000000..331dae3 --- /dev/null +++ b/lib/features/recording/utils/recording_performance.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +Future measureRecordingOperation( + String label, + Future 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'); +} diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index cf687f3..77f8cfe 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -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_session.dart'; import 'package:recording_tool/features/recording/platform/recording_platform.dart'; +import 'package:recording_tool/features/recording/utils/recording_performance.dart'; /// 录制页状态 Provider。 final recordingViewModelProvider = @@ -32,6 +33,8 @@ class RecordingRequiredPermissions { class RecordingViewModel extends Notifier { Timer? _elapsedTimer; DateTime? _recordingStartedAt; + RecordingRequiredPermissions? _cachedRequiredPermissions; + var _sessionGeneration = 0; /// 初始化状态并注册销毁回调。 @override @@ -52,55 +55,88 @@ class RecordingViewModel extends Notifier { state = state.copyWith(recordingContext: recordingContext); } - /// 申请权限并检查系统设置。 - Future prepareSession() async { + /// 准备相机和麦克风权限;已授权时复用当前会话结果。 + Future prepareRequiredPermissions({ + bool forceRefresh = false, + }) async { + final generation = _sessionGeneration; if (!RecordingPlatform.isSupported) { _updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制')); - return; + return const RecordingRequiredPermissions( + cameraGranted: false, + microphoneGranted: false, + ); } - final permissions = await PermissionService.requestMissing([ - Permission.camera, - Permission.microphone, - if (Platform.isAndroid) Permission.notification, - ]); - - final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; - if (!cameraGranted) { - _updateSession((s) => s.copyWith(errorMessage: '需要相机权限才能录制')); - return; + final cached = _cachedRequiredPermissions; + if (!forceRefresh && cached?.allGranted == true) { + return cached!; } - final microphoneGranted = - permissions[Permission.microphone]?.isGranted ?? false; + final permissions = await measureRecordingOperation( + '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 loadSystemAdvisories() async { + final generation = _sessionGeneration; + final notificationFuture = Platform.isAndroid + ? PermissionService.requestMissing([Permission.notification]) + : Future.value({}); + final results = await measureRecordingOperation( + 'system advisories', + () => Future.wait([ + notificationFuture, + RecordingPlatform.hasNotificationPolicyAccess(), + RecordingPlatform.isIgnoringBatteryOptimizations(), + ]), + ); + final notificationPermissions = + results[0] as Map; final notificationsGranted = Platform.isAndroid - ? (permissions[Permission.notification]?.isGranted ?? false) + ? _isPermissionGranted(notificationPermissions[Permission.notification]) : true; - + final hasDnd = results[1] as bool; + final batteryIgnored = results[2] as bool; + if (generation != _sessionGeneration) return; final warnings = []; if (Platform.isAndroid && !notificationsGranted) { warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制'); } - if (!microphoneGranted) { - warnings.add('未授予麦克风权限,当前将以静音模式录制'); - } - final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); - final batteryIgnored = - await RecordingPlatform.isIgnoringBatteryOptimizations(); _updateSession( (s) => s.copyWith( hasDndAccess: hasDnd, isBatteryOptimizedIgnored: batteryIgnored, - isMicrophoneGranted: microphoneGranted, notificationsGranted: notificationsGranted, permissionWarning: warnings.isEmpty ? null : warnings.join('\n'), - errorMessage: null, clearPermissionWarning: warnings.isEmpty, ), ); - - _updateSession((s) => s.copyWith(errorMessage: null)); } void setPreviewReady({required bool ready, String? errorMessage}) { @@ -112,27 +148,8 @@ class RecordingViewModel extends Notifier { /// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。 Future ensureCameraAndMicrophonePermissions() async { - final permissions = await PermissionService.requestMissing([ - Permission.camera, - 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, - ); + final cached = _cachedRequiredPermissions; + return prepareRequiredPermissions(forceRefresh: cached?.allGranted != true); } bool _isPermissionGranted(PermissionStatus? status) { @@ -282,11 +299,13 @@ class RecordingViewModel extends Notifier { /// 退出录制页时释放勿扰和会话状态(沉浸式由页面统一恢复)。 Future teardown() async { - await RecordingPlatform.disableDoNotDisturb(); + _sessionGeneration++; + _cachedRequiredPermissions = null; _recordingStartedAt = null; _elapsedTimer?.cancel(); _elapsedTimer = null; state = state.copyWith(session: const RecordingSessionState()); + await RecordingPlatform.disableDoNotDisturb(); } /// Provider 销毁时取消状态流订阅。 diff --git a/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt b/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt index c399bfc..8d264b7 100644 --- a/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt +++ b/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/FlutterLiveStreamView.kt @@ -2,6 +2,8 @@ package video.api.flutter.livestream import android.Manifest import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Size import android.view.Surface 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.isExternalCamera 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.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class FlutterLiveStreamView( private val context: Context, @@ -39,9 +49,21 @@ class FlutterLiveStreamView( initialOnConnectionListener = 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 Unit, (Exception) -> Unit>>() + @Volatile private var _isPreviewing = false + @Volatile private var _isStreaming = false + @Volatile + private var _isDisposing = false + @Volatile + private var _isDisposed = false val isStreaming: Boolean get() = _isStreaming @@ -63,7 +85,7 @@ class FlutterLiveStreamView( val wasPreviewing = _isPreviewing if (wasPreviewing) { - stopPreview() + stopPreviewInternal() } streamer.configure(videoConfig) _videoConfig = videoConfig @@ -174,23 +196,58 @@ class FlutterLiveStreamView( setCamera(cameraList.first(), onSuccess, onError) } - fun dispose() { - try { - stopStream() - } catch (e: Exception) { - android.util.Log.w("ApiVideoLiveStream", "stopStream during dispose failed", e) - _isStreaming = false + fun dispose(onSuccess: () -> Unit, onError: (Exception) -> Unit) { + var shouldStartDispose = false + synchronized(disposeLock) { + if (_isDisposed) { + mainHandler.post(onSuccess) + return + } + disposeCallbacks.add(onSuccess to onError) + if (!_isDisposing) { + _isDisposing = true + shouldStartDispose = true + } } - try { - streamer.stopPreview() - } catch (e: Exception) { - android.util.Log.w("ApiVideoLiveStream", "stopPreview during dispose failed", e) + if (!shouldStartDispose) return + + operationScope.launch { + 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) { + check(!_isDisposing && !_isDisposed) { "Live stream has been disposed" } runBlocking { streamer.connect(url) try { @@ -204,29 +261,31 @@ class FlutterLiveStreamView( } } - fun stopStream() { - if (!_isStreaming && !streamer.isConnected) { - return + fun stopStream(onSuccess: () -> Unit, onError: (Exception) -> Unit) { + runBackgroundOperation(onSuccess, onError) { + stopStreamInternal() } + } + + private suspend fun stopStreamInternal() { + if (!_isStreaming && !streamer.isConnected) return val isConnected = streamer.isConnected + var failure: Exception? = null try { - runBlocking { - streamer.stopStream() - streamer.disconnect() - if (isConnected) { - onDisconnected() - } - _isStreaming = false - } + streamer.stopStream() } catch (e: Exception) { - android.util.Log.w("ApiVideoLiveStream", "stopStream failed", e) - _isStreaming = false - try { - streamer.disconnect() - } catch (_: Exception) { - // ignore secondary disconnect failures - } + failure = e } + 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) { @@ -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() _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 { val surfaceTexture = flutterTexture.surfaceTexture().apply { setDefaultBufferSize( diff --git a/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/MethodCallHandlerImpl.kt b/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/MethodCallHandlerImpl.kt index 39d96f8..aabd975 100644 --- a/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/MethodCallHandlerImpl.kt +++ b/plugins/apivideo_live_stream/android/src/main/kotlin/video/api/flutter/livestream/MethodCallHandlerImpl.kt @@ -50,27 +50,44 @@ class MethodCallHandlerImpl( override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "create" -> { - try { - flutterView?.dispose() - flutterView = FlutterLiveStreamView( - context, - textureRegistry, - permissionsManager, - { sendConnected() }, - { sendDisconnected() }, - { sendConnectionFailed(it) }, - { sendError(it) }, - { sendVideoSizeChanged(it) } + val previousView = flutterView + if (previousView == null) { + createFlutterView(result) + } else { + previousView.dispose( + onSuccess = { + if (flutterView === previousView) { + flutterView = null + } + createFlutterView(result) + }, + 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" -> { - flutterView?.dispose() - flutterView = null + val view = flutterView + 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" -> { @@ -128,8 +145,17 @@ class MethodCallHandlerImpl( } "stopPreview" -> { - flutterView?.stopPreview() - result.success(null) + val view = flutterView + if (view == null) { + result.success(null) + } else { + view.stopPreview( + onSuccess = { result.success(null) }, + onError = { + result.error("failed_to_stop_preview", it.message, null) + }, + ) + } } "startStreaming" -> { @@ -163,8 +189,17 @@ class MethodCallHandlerImpl( } "stopStreaming" -> { - flutterView?.stopStream() - result.success(null) + val view = flutterView + 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)) @@ -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() { sendEvent("connected") } diff --git a/plugins/apivideo_live_stream/lib/src/apivideo_camera_preview.dart b/plugins/apivideo_live_stream/lib/src/apivideo_camera_preview.dart index 75e802b..e7f439b 100644 --- a/plugins/apivideo_live_stream/lib/src/apivideo_camera_preview.dart +++ b/plugins/apivideo_live_stream/lib/src/apivideo_camera_preview.dart @@ -68,7 +68,6 @@ class _ApiVideoCameraPreviewState extends State { @override void dispose() { - widget.controller.stopPreview(); widget.controller.removeWidgetListener(_widgetListener); widget.controller.removeEventsListener(_eventsListener); super.dispose(); diff --git a/plugins/apivideo_live_stream/lib/src/apivideo_live_stream_controller.dart b/plugins/apivideo_live_stream/lib/src/apivideo_live_stream_controller.dart index 578a861..10a8811 100644 --- a/plugins/apivideo_live_stream/lib/src/apivideo_live_stream_controller.dart +++ b/plugins/apivideo_live_stream/lib/src/apivideo_live_stream_controller.dart @@ -26,6 +26,8 @@ class ApiVideoLiveStreamController { int get textureId => _textureId; bool _isInitialized = false; + Future? _initializeFuture; + Future? _disposeFuture; /// Gets the current state of the video player. bool get isInitialized => _isInitialized; @@ -68,7 +70,14 @@ class ApiVideoLiveStreamController { } /// Creates a new live stream instance with initial audio and video configurations. - Future initialize() async { + Future initialize() { + if (_disposeFuture != null) { + throw StateError('Cannot initialize a disposed controller'); + } + return _initializeFuture ??= _initialize(); + } + + Future _initialize() async { _textureId = await _platform.initialize() ?? kUninitializedTextureId; _eventSubscription = _platform @@ -91,12 +100,23 @@ class ApiVideoLiveStreamController { } /// Disposes the live stream instance. - Future dispose() async { + Future dispose() { + return _disposeFuture ??= _dispose(); + } + + Future _dispose() async { + try { + await _initializeFuture; + } catch (_) { + // A partially initialized native view still needs to be disposed. + } await _eventSubscription?.cancel(); + _eventSubscription = null; _eventsListeners.clear(); _widgetListeners.clear(); await _platform.dispose(); - return; + _isInitialized = false; + _textureId = kUninitializedTextureId; } /// Sets new video parameters.