From 551bd83ffe187827adef4add638fc34c3b94286d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:34:12 +0100 Subject: [PATCH] fix: player disposed race condition --- lib/mpv/player/platform/player_android.dart | 80 ++++++++------------- lib/mpv/player/platform/player_windows.dart | 6 +- lib/mpv/player/player.dart | 3 + lib/mpv/player/player_base.dart | 20 +++--- lib/mpv/player/player_native.dart | 68 ++++++------------ lib/services/track_selection_service.dart | 2 + 6 files changed, 69 insertions(+), 110 deletions(-) diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 2899c11b..1c8ffb75 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -55,7 +55,7 @@ class PlayerAndroid extends PlayerBase { if (initialized) return; try { - final result = await methodChannel.invokeMethod('initialize', { + final result = await invoke('initialize', { 'bufferSizeBytes': _bufferSizeBytes, 'tunnelingEnabled': _tunnelingEnabled, }); @@ -88,13 +88,13 @@ class PlayerAndroid extends PlayerBase { @override Future open(Media media, {bool play = true, bool isLive = false}) async { - checkDisposed(); + if (disposed) return; await _ensureInitialized(); // Show the video layer await setVisible(true); - await methodChannel.invokeMethod('open', { + await invoke('open', { 'uri': media.uri, 'headers': media.headers, 'startPositionMs': media.start?.inMilliseconds ?? 0, @@ -105,27 +105,23 @@ class PlayerAndroid extends PlayerBase { @override Future play() async { - checkDisposed(); - await methodChannel.invokeMethod('play'); + await invoke('play'); } @override Future pause() async { - checkDisposed(); - await methodChannel.invokeMethod('pause'); + await invoke('pause'); } @override Future stop() async { - checkDisposed(); - await methodChannel.invokeMethod('stop'); + await invoke('stop'); await setVisible(false); } @override Future seek(Duration position) async { - checkDisposed(); - await methodChannel.invokeMethod('seek', {'positionMs': position.inMilliseconds}); + await invoke('seek', {'positionMs': position.inMilliseconds}); } // ============================================ @@ -134,20 +130,17 @@ class PlayerAndroid extends PlayerBase { @override Future selectAudioTrack(AudioTrack track) async { - checkDisposed(); - await methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id}); + await invoke('selectAudioTrack', {'trackId': track.id}); } @override Future selectSubtitleTrack(SubtitleTrack track) async { - checkDisposed(); - await methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id}); + await invoke('selectSubtitleTrack', {'trackId': track.id}); } @override Future addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { - checkDisposed(); - await methodChannel.invokeMethod('addSubtitleTrack', { + await invoke('addSubtitleTrack', { 'uri': uri, 'title': title, 'language': language, @@ -161,14 +154,12 @@ class PlayerAndroid extends PlayerBase { @override Future setVolume(double volume) async { - checkDisposed(); - await methodChannel.invokeMethod('setVolume', {'volume': volume}); + await invoke('setVolume', {'volume': volume}); } @override Future setRate(double rate) async { - checkDisposed(); - await methodChannel.invokeMethod('setRate', {'rate': rate}); + await invoke('setRate', {'rate': rate}); } // ============================================ @@ -177,7 +168,7 @@ class PlayerAndroid extends PlayerBase { @override Future setProperty(String name, String value) async { - checkDisposed(); + if (disposed) return; // ExoPlayer doesn't use MPV properties, but we handle common ones switch (name) { case 'pause': @@ -228,7 +219,7 @@ class PlayerAndroid extends PlayerBase { @override Future getProperty(String name) async { - checkDisposed(); + if (disposed) return null; // Return state-based values for common properties switch (name) { case 'pause': @@ -260,9 +251,9 @@ class PlayerAndroid extends PlayerBase { /// Get all playback stats from ExoPlayer. /// Returns a map with video/audio codec info, buffer state, and performance metrics. Future> getStats() async { - checkDisposed(); + if (disposed) return {}; try { - final result = await methodChannel.invokeMethod('getStats'); + final result = await invoke('getStats'); return Map.from(result ?? {}); } catch (e) { return {}; @@ -282,9 +273,9 @@ class PlayerAndroid extends PlayerBase { /// Get the current player type ('exoplayer' or 'mpv' if fallback is active). Future getPlayerType() async { - checkDisposed(); + if (disposed) return 'unknown'; try { - final result = await methodChannel.invokeMethod('getPlayerType'); + final result = await invoke('getPlayerType'); return result ?? 'unknown'; } catch (e) { return 'unknown'; @@ -293,7 +284,7 @@ class PlayerAndroid extends PlayerBase { @override Future command(List args) async { - checkDisposed(); + if (disposed) return; // Handle MPV commands by translating to ExoPlayer equivalents if (args.isEmpty) return; @@ -344,9 +335,8 @@ class PlayerAndroid extends PlayerBase { required int bgOpacity, int subtitlePosition = 100, }) async { - checkDisposed(); - if (!initialized) return; - await methodChannel.invokeMethod('setSubtitleStyle', { + if (disposed || !initialized) return; + await invoke('setSubtitleStyle', { 'fontSize': fontSize, 'textColor': textColor, 'borderSize': borderSize, @@ -363,18 +353,14 @@ class PlayerAndroid extends PlayerBase { @override Future setVideoFrameRate(double fps, int durationMs) async { - checkDisposed(); - if (!initialized) return; - - await methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); + if (disposed || !initialized) return; + await invoke('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); } @override Future clearVideoFrameRate() async { - checkDisposed(); - if (!initialized) return; - - await methodChannel.invokeMethod('clearVideoFrameRate'); + if (disposed || !initialized) return; + await invoke('clearVideoFrameRate'); } // ============================================ @@ -383,19 +369,15 @@ class PlayerAndroid extends PlayerBase { @override Future requestAudioFocus() async { - checkDisposed(); + if (disposed) return false; await _ensureInitialized(); - - final result = await methodChannel.invokeMethod('requestAudioFocus'); - return result ?? false; + return await invoke('requestAudioFocus') ?? false; } @override Future abandonAudioFocus() async { - checkDisposed(); - if (!initialized) return; - - await methodChannel.invokeMethod('abandonAudioFocus'); + if (disposed || !initialized) return; + await invoke('abandonAudioFocus'); } // ============================================ @@ -404,8 +386,8 @@ class PlayerAndroid extends PlayerBase { @override Future setLogLevel(String level) async { - checkDisposed(); + if (disposed) return; await _ensureInitialized(); - await methodChannel.invokeMethod('setLogLevel', {'level': level}); + await invoke('setLogLevel', {'level': level}); } } diff --git a/lib/mpv/player/platform/player_windows.dart b/lib/mpv/player/platform/player_windows.dart index 0832b298..eacfc180 100644 --- a/lib/mpv/player/platform/player_windows.dart +++ b/lib/mpv/player/platform/player_windows.dart @@ -1,5 +1,3 @@ -import 'package:flutter/services.dart'; - import '../player_native.dart'; import '../video_rect_support.dart'; @@ -9,8 +7,6 @@ import '../video_rect_support.dart'; /// The mpv video window is positioned behind the Flutter window, /// with transparent regions allowing the video to show through. class PlayerWindows extends PlayerNative with VideoRectSupport { - static const _methodChannel = MethodChannel('com.plezy/mpv_player'); - @override int? get textureId => null; // Uses native window embedding, not Flutter texture @@ -22,7 +18,7 @@ class PlayerWindows extends PlayerNative with VideoRectSupport { required int bottom, required double devicePixelRatio, }) async { - await _methodChannel.invokeMethod('setVideoRect', { + await invoke('setVideoRect', { 'left': left, 'top': top, 'right': right, diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 3ed2e5bc..3e4b1228 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -238,6 +238,9 @@ abstract class Player { // Lifecycle // ============================================ + /// Whether the player has been disposed. + bool get disposed; + /// Dispose of the player and release resources. /// /// After calling this, the player instance should not be used. diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 5b8ba6ac..e7846fbb 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -103,7 +103,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Future observeProperty(String name, String format) async { final propId = _nextPropId++; _propIdToName[propId] = name; - await methodChannel.invokeMethod('observeProperty', {'name': name, 'format': format, 'id': propId}); + await invoke('observeProperty', {'name': name, 'format': format, 'id': propId}); } void _handleEvent(dynamic event) { @@ -461,11 +461,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { _state = update(_state); } - /// Throws if the player has been disposed. - void checkDisposed() { - if (_disposed) { - throw StateError('Player has been disposed'); - } + /// Safe method channel invocation — no-ops if player is disposed. + @protected + Future invoke(String method, [dynamic args]) async { + if (_disposed) return null; + return methodChannel.invokeMethod(method, args); } // ============================================ @@ -474,7 +474,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { @override Future playOrPause() async { - checkDisposed(); + if (_disposed) return; if (_state.playing) { await pause(); } else { @@ -484,9 +484,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { @override Future setVisible(bool visible) async { - checkDisposed(); + if (_disposed) return false; try { - await methodChannel.invokeMethod('setVisible', {'visible': visible}); + await invoke('setVisible', {'visible': visible}); return true; } catch (e) { errorController.add('Failed to set visibility: $e'); @@ -546,7 +546,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { await _eventSubscription?.cancel(); await _logSubscription?.cancel(); - await methodChannel.invokeMethod('dispose'); + await methodChannel.invokeMethod('dispose'); // Direct call — already guarded by _disposed check above await closeStreamControllers(); } } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index cfe45cae..345efb7a 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -42,7 +42,7 @@ class PlayerNative extends PlayerBase { if (initialized) return; try { - final result = await methodChannel.invokeMethod('initialize'); + final result = await invoke('initialize'); if (result is int) { // Linux: initialize returns the texture ID _textureIdValue = result; @@ -104,7 +104,7 @@ class PlayerNative extends PlayerBase { /// Returns null if the call fails. Future _openContentFd(String contentUri) async { try { - return await methodChannel.invokeMethod('openContentFd', {'uri': contentUri}); + return await invoke('openContentFd', {'uri': contentUri}); } catch (e) { return null; } @@ -112,7 +112,7 @@ class PlayerNative extends PlayerBase { @override Future open(Media media, {bool play = true, bool isLive = false}) async { - checkDisposed(); + if (disposed) return; await _ensureInitialized(); // Show the video layer @@ -153,26 +153,22 @@ class PlayerNative extends PlayerBase { @override Future play() async { - checkDisposed(); await setProperty('pause', 'no'); } @override Future pause() async { - checkDisposed(); await setProperty('pause', 'yes'); } @override Future stop() async { - checkDisposed(); await command(['stop']); - await methodChannel.invokeMethod('setVisible', {'visible': false}); + await invoke('setVisible', {'visible': false}); } @override Future seek(Duration position) async { - checkDisposed(); await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']); } @@ -182,25 +178,21 @@ class PlayerNative extends PlayerBase { @override Future selectAudioTrack(AudioTrack track) async { - checkDisposed(); await setProperty('aid', track.id); } @override Future selectSubtitleTrack(SubtitleTrack track) async { - checkDisposed(); await setProperty('sid', track.id); } @override Future selectSecondarySubtitleTrack(SubtitleTrack track) async { - checkDisposed(); await setProperty('secondary-sid', track.id); } @override Future addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { - checkDisposed(); final args = ['sub-add', uri, select ? 'select' : 'auto']; if (title != null) args.add('title=$title'); if (language != null) args.add('lang=$language'); @@ -213,19 +205,16 @@ class PlayerNative extends PlayerBase { @override Future setVolume(double volume) async { - checkDisposed(); await setProperty('volume', volume.toString()); } @override Future setRate(double rate) async { - checkDisposed(); await setProperty('speed', rate.toString()); } @override Future setAudioDevice(AudioDevice device) async { - checkDisposed(); await setProperty('audio-device', device.name); } @@ -235,23 +224,23 @@ class PlayerNative extends PlayerBase { @override Future setProperty(String name, String value) async { - checkDisposed(); + if (disposed) return; await _ensureInitialized(); - await methodChannel.invokeMethod('setProperty', {'name': name, 'value': value}); + await invoke('setProperty', {'name': name, 'value': value}); } @override Future getProperty(String name) async { - checkDisposed(); + if (disposed) return null; await _ensureInitialized(); - return await methodChannel.invokeMethod('getProperty', {'name': name}); + return await invoke('getProperty', {'name': name}); } @override Future command(List args) async { - checkDisposed(); + if (disposed) return; await _ensureInitialized(); - await methodChannel.invokeMethod('command', {'args': args}); + await invoke('command', {'args': args}); } // ============================================ @@ -260,9 +249,9 @@ class PlayerNative extends PlayerBase { @override Future setLogLevel(String level) async { - checkDisposed(); + if (disposed) return; await _ensureInitialized(); - await methodChannel.invokeMethod('setLogLevel', {'level': level}); + await invoke('setLogLevel', {'level': level}); } // ============================================ @@ -271,7 +260,6 @@ class PlayerNative extends PlayerBase { @override Future setAudioPassthrough(bool enabled) async { - checkDisposed(); if (enabled) { await setProperty('audio-spdif', 'ac3,eac3,dts,dts-hd,truehd'); await setProperty('audio-exclusive', 'yes'); @@ -287,47 +275,35 @@ class PlayerNative extends PlayerBase { @override Future updateFrame() async { - checkDisposed(); - if (!initialized) return; + if (disposed || !initialized) return; if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) { - await methodChannel.invokeMethod('updateFrame'); + await invoke('updateFrame'); } } @override Future setVideoFrameRate(double fps, int durationMs) async { - checkDisposed(); - if (!Platform.isAndroid) return; - if (!initialized) return; - - await methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); + if (!Platform.isAndroid || disposed || !initialized) return; + await invoke('setVideoFrameRate', {'fps': fps, 'duration': durationMs}); } @override Future clearVideoFrameRate() async { - checkDisposed(); - if (!Platform.isAndroid) return; - if (!initialized) return; - - await methodChannel.invokeMethod('clearVideoFrameRate'); + if (!Platform.isAndroid || disposed || !initialized) return; + await invoke('clearVideoFrameRate'); } @override Future requestAudioFocus() async { - checkDisposed(); + if (disposed) return false; if (!Platform.isAndroid) return true; await _ensureInitialized(); - - final result = await methodChannel.invokeMethod('requestAudioFocus'); - return result ?? false; + return await invoke('requestAudioFocus') ?? false; } @override Future abandonAudioFocus() async { - checkDisposed(); - if (!Platform.isAndroid) return; - if (!initialized) return; - - await methodChannel.invokeMethod('abandonAudioFocus'); + if (!Platform.isAndroid || disposed || !initialized) return; + await invoke('abandonAudioFocus'); } } diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 9be8924c..4812f43a 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -765,6 +765,8 @@ class TrackSelectionService { attempts++; } + if (player.disposed) return; + // Get real tracks (excluding auto and no) final realAudioTracks = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList(); final realSubtitleTracks = player.state.tracks.subtitle.where((t) => t.id != 'auto' && t.id != 'no').toList();