fix: player disposed race condition

This commit is contained in:
edde746
2026-03-04 14:37:51 +01:00
parent 5f4e4bfdd3
commit 551bd83ffe
6 changed files with 69 additions and 110 deletions
+31 -49
View File
@@ -55,7 +55,7 @@ class PlayerAndroid extends PlayerBase {
if (initialized) return;
try {
final result = await methodChannel.invokeMethod<bool>('initialize', {
final result = await invoke<bool>('initialize', {
'bufferSizeBytes': _bufferSizeBytes,
'tunnelingEnabled': _tunnelingEnabled,
});
@@ -88,13 +88,13 @@ class PlayerAndroid extends PlayerBase {
@override
Future<void> 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<void> play() async {
checkDisposed();
await methodChannel.invokeMethod('play');
await invoke('play');
}
@override
Future<void> pause() async {
checkDisposed();
await methodChannel.invokeMethod('pause');
await invoke('pause');
}
@override
Future<void> stop() async {
checkDisposed();
await methodChannel.invokeMethod('stop');
await invoke('stop');
await setVisible(false);
}
@override
Future<void> 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<void> selectAudioTrack(AudioTrack track) async {
checkDisposed();
await methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id});
await invoke('selectAudioTrack', {'trackId': track.id});
}
@override
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
checkDisposed();
await methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id});
await invoke('selectSubtitleTrack', {'trackId': track.id});
}
@override
Future<void> 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<void> setVolume(double volume) async {
checkDisposed();
await methodChannel.invokeMethod('setVolume', {'volume': volume});
await invoke('setVolume', {'volume': volume});
}
@override
Future<void> 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<void> 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<String?> 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<Map<String, dynamic>> getStats() async {
checkDisposed();
if (disposed) return {};
try {
final result = await methodChannel.invokeMethod<Map>('getStats');
final result = await invoke<Map>('getStats');
return Map<String, dynamic>.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<String> getPlayerType() async {
checkDisposed();
if (disposed) return 'unknown';
try {
final result = await methodChannel.invokeMethod<String>('getPlayerType');
final result = await invoke<String>('getPlayerType');
return result ?? 'unknown';
} catch (e) {
return 'unknown';
@@ -293,7 +284,7 @@ class PlayerAndroid extends PlayerBase {
@override
Future<void> command(List<String> 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<void> 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<void> 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<bool> requestAudioFocus() async {
checkDisposed();
if (disposed) return false;
await _ensureInitialized();
final result = await methodChannel.invokeMethod<bool>('requestAudioFocus');
return result ?? false;
return await invoke<bool>('requestAudioFocus') ?? false;
}
@override
Future<void> 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<void> setLogLevel(String level) async {
checkDisposed();
if (disposed) return;
await _ensureInitialized();
await methodChannel.invokeMethod('setLogLevel', {'level': level});
await invoke('setLogLevel', {'level': level});
}
}
+1 -5
View File
@@ -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,
+3
View File
@@ -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.
+10 -10
View File
@@ -103,7 +103,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
Future<void> 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<T?> invoke<T>(String method, [dynamic args]) async {
if (_disposed) return null;
return methodChannel.invokeMethod<T>(method, args);
}
// ============================================
@@ -474,7 +474,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
@override
Future<void> 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<bool> 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();
}
}
+22 -46
View File
@@ -42,7 +42,7 @@ class PlayerNative extends PlayerBase {
if (initialized) return;
try {
final result = await methodChannel.invokeMethod<Object>('initialize');
final result = await invoke<Object>('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<int?> _openContentFd(String contentUri) async {
try {
return await methodChannel.invokeMethod<int>('openContentFd', {'uri': contentUri});
return await invoke<int>('openContentFd', {'uri': contentUri});
} catch (e) {
return null;
}
@@ -112,7 +112,7 @@ class PlayerNative extends PlayerBase {
@override
Future<void> 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<void> play() async {
checkDisposed();
await setProperty('pause', 'no');
}
@override
Future<void> pause() async {
checkDisposed();
await setProperty('pause', 'yes');
}
@override
Future<void> stop() async {
checkDisposed();
await command(['stop']);
await methodChannel.invokeMethod('setVisible', {'visible': false});
await invoke('setVisible', {'visible': false});
}
@override
Future<void> seek(Duration position) async {
checkDisposed();
await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']);
}
@@ -182,25 +178,21 @@ class PlayerNative extends PlayerBase {
@override
Future<void> selectAudioTrack(AudioTrack track) async {
checkDisposed();
await setProperty('aid', track.id);
}
@override
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
checkDisposed();
await setProperty('sid', track.id);
}
@override
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {
checkDisposed();
await setProperty('secondary-sid', track.id);
}
@override
Future<void> 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<void> setVolume(double volume) async {
checkDisposed();
await setProperty('volume', volume.toString());
}
@override
Future<void> setRate(double rate) async {
checkDisposed();
await setProperty('speed', rate.toString());
}
@override
Future<void> setAudioDevice(AudioDevice device) async {
checkDisposed();
await setProperty('audio-device', device.name);
}
@@ -235,23 +224,23 @@ class PlayerNative extends PlayerBase {
@override
Future<void> 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<String?> getProperty(String name) async {
checkDisposed();
if (disposed) return null;
await _ensureInitialized();
return await methodChannel.invokeMethod<String>('getProperty', {'name': name});
return await invoke<String>('getProperty', {'name': name});
}
@override
Future<void> command(List<String> 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<void> 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<void> 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<void> 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<void> 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<void> 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<bool> requestAudioFocus() async {
checkDisposed();
if (disposed) return false;
if (!Platform.isAndroid) return true;
await _ensureInitialized();
final result = await methodChannel.invokeMethod<bool>('requestAudioFocus');
return result ?? false;
return await invoke<bool>('requestAudioFocus') ?? false;
}
@override
Future<void> abandonAudioFocus() async {
checkDisposed();
if (!Platform.isAndroid) return;
if (!initialized) return;
await methodChannel.invokeMethod('abandonAudioFocus');
if (!Platform.isAndroid || disposed || !initialized) return;
await invoke('abandonAudioFocus');
}
}
@@ -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();