From 8d5b5d2e0cd9cd2078881dd1f1dd7a224ecc9d6c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:20:48 +0100 Subject: [PATCH] fix: guard against post-disposal stream controller access --- lib/mpv/player/player_base.dart | 8 ++- lib/services/download_manager_service.dart | 72 ++++++++++++---------- lib/services/sleep_timer_service.dart | 1 + 3 files changed, 48 insertions(+), 33 deletions(-) diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 1fff43fa..cfca222b 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -36,6 +36,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { int? get textureId => null; StreamSubscription? _eventSubscription; + StreamSubscription? _logSubscription; bool _disposed = false; final _throttleSw = Stopwatch()..start(); int _lastEmitMs = 0; @@ -63,7 +64,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { PlayerBase() { _streams = createStreams(); _setupEventListener(); - logController.stream.listen(_forwardToAppLogger); + _logSubscription = logController.stream.listen(_forwardToAppLogger); } void _forwardToAppLogger(PlayerLog log) { @@ -89,6 +90,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { _eventSubscription = eventChannel.receiveBroadcastStream().listen( _handleEvent, onError: (error) { + if (_disposed) return; errorController.add(error.toString()); }, ); @@ -108,6 +110,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } void _handleEvent(dynamic event) { + if (_disposed) return; if (event is List && event.length == 2) { final name = _propIdToName[event[0] as int]; if (name != null) { @@ -125,6 +128,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { /// Handle a property change event from the platform. /// Subclasses can override this to handle platform-specific properties. void handlePropertyChange(String name, dynamic value) { + if (_disposed) return; switch (name) { case 'pause': final playing = value == false; @@ -258,6 +262,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { /// Handle a player event from the platform. /// Subclasses can override this to handle platform-specific events. void handlePlayerEvent(String name, Map? data) { + if (_disposed) return; switch (name) { case 'end-file': final reason = data?['reason'] as String?; @@ -450,6 +455,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { _disposed = true; await _eventSubscription?.cancel(); + await _logSubscription?.cancel(); await methodChannel.invokeMethod('dispose'); await closeStreamControllers(); } diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 24831784..0bd98cf7 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -240,6 +240,7 @@ class DownloadManagerService { // Prevents concurrent _processQueue calls bool _isProcessingQueue = false; + bool _disposed = false; // Debounce timers for DB progress writes (keyed by globalKey). // UI progress streams are still real-time; only the DB write is debounced. @@ -626,6 +627,7 @@ class DownloadManagerService { /// Callback: background_downloader progress update void _onTaskProgress(TaskProgressUpdate update) { + if (_disposed) return; final globalKey = update.task.metaData; if (globalKey.isEmpty || update.progress < 0) return; @@ -666,44 +668,49 @@ class DownloadManagerService { /// Callback: background_downloader status change void _onTaskStatusChanged(TaskStatusUpdate update) { + if (_disposed) return; final globalKey = update.task.metaData; if (globalKey.isEmpty) return; appLogger.d('Background task status: ${update.status} for $globalKey'); - switch (update.status) { - case TaskStatus.complete: - _onDownloadComplete(globalKey, update.task); - case TaskStatus.failed: - _onDownloadFailed(globalKey, update.exception?.description ?? 'Download failed'); - case TaskStatus.notFound: - _onDownloadFailed(globalKey, 'File not found (404)'); - case TaskStatus.canceled: - if (_pausingKeys.contains(globalKey)) { - // Expected cancel from holding-queue promotion during pause — ignore + try { + switch (update.status) { + case TaskStatus.complete: + _onDownloadComplete(globalKey, update.task); + case TaskStatus.failed: + _onDownloadFailed(globalKey, update.exception?.description ?? 'Download failed'); + case TaskStatus.notFound: + _onDownloadFailed(globalKey, 'File not found (404)'); + case TaskStatus.canceled: + if (_pausingKeys.contains(globalKey)) { + // Expected cancel from holding-queue promotion during pause — ignore + break; + } + final ctx = _pendingDownloadContext.remove(globalKey); + if (ctx != null) { + // Context still present → OS cancelled the task, not user code + // (user-initiated pause/cancel/delete removes context before cancellation completes) + appLogger.w('Download cancelled by system for $globalKey, re-queuing'); + _database.updateBgTaskId(globalKey, null); + _transitionStatus(globalKey, DownloadStatus.queued); + _database.addToQueue(mediaGlobalKey: globalKey); + if (_lastClient != null) _processQueue(_lastClient!); + } + case TaskStatus.paused: + appLogger.d('Download paused by system for $globalKey'); + case TaskStatus.waitingToRetry: + appLogger.d('Download waiting to retry for $globalKey'); + case TaskStatus.enqueued: + case TaskStatus.running: + // If this item is being paused, the holding queue promoted it — cancel it + if (_pausingKeys.contains(globalKey)) { + FileDownloader().cancelTaskWithId(update.task.taskId); + } break; - } - final ctx = _pendingDownloadContext.remove(globalKey); - if (ctx != null) { - // Context still present → OS cancelled the task, not user code - // (user-initiated pause/cancel/delete removes context before cancellation completes) - appLogger.w('Download cancelled by system for $globalKey, re-queuing'); - _database.updateBgTaskId(globalKey, null); - _transitionStatus(globalKey, DownloadStatus.queued); - _database.addToQueue(mediaGlobalKey: globalKey); - if (_lastClient != null) _processQueue(_lastClient!); - } - case TaskStatus.paused: - appLogger.d('Download paused by system for $globalKey'); - case TaskStatus.waitingToRetry: - appLogger.d('Download waiting to retry for $globalKey'); - case TaskStatus.enqueued: - case TaskStatus.running: - // If this item is being paused, the holding queue promoted it — cancel it - if (_pausingKeys.contains(globalKey)) { - FileDownloader().cancelTaskWithId(update.task.taskId); - } - break; + } + } catch (e) { + appLogger.e('Error handling download status change for $globalKey', error: e); } } @@ -1661,6 +1668,7 @@ class DownloadManagerService { } void dispose() { + _disposed = true; for (final timer in _progressDebounceTimers.values) { timer.cancel(); } diff --git a/lib/services/sleep_timer_service.dart b/lib/services/sleep_timer_service.dart index 94db244a..f0b22608 100644 --- a/lib/services/sleep_timer_service.dart +++ b/lib/services/sleep_timer_service.dart @@ -101,6 +101,7 @@ class SleepTimerService extends ChangeNotifier { @override void dispose() { _timer?.cancel(); + _completedController.close(); super.dispose(); } }