From 9bd2774820030ca075e3fb1440f923380291cbdd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:48:48 +0100 Subject: [PATCH] fix: race conditions in mpv dispose and track manager, add debug fallback trigger --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 6 ++++ .../plezy/exoplayer/ExoPlayerPlugin.kt | 4 +++ .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 20 +++++++------ lib/services/track_manager.dart | 30 +++++++++++-------- .../sheets/video_settings_sheet.dart | 13 ++++++++ 5 files changed, 52 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index c9fa7b12..6accf04c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -1582,6 +1582,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return "Off" } + fun triggerFallback() { + val uri = currentMediaUri ?: return + val pos = exoPlayer?.currentPosition ?: 0L + delegate?.onFormatUnsupported(uri, currentHeaders, pos, "debug: manual fallback trigger") + } + // Cleanup fun dispose() { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 4a71ee3f..6330fd21 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -147,6 +147,10 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, playerCore?.debugLoggingEnabled = debugLoggingEnabled result.success(null) } + "triggerFallback" -> { + playerCore?.triggerFallback() + result.success(null) + } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index c7851b6a..ccd43f9a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -43,6 +43,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { // Audio focus private var audioFocusManager: AudioFocusManager? = null + @Volatile private var cachedPaused: Boolean = true private var flutterOverlayApplied = false @@ -71,6 +72,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { try { disposing = false + cachedPaused = true pendingSurface = null // Initialize audio focus handling @@ -89,10 +91,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { catch (e: Exception) { Log.w(TAG, "Failed to resume after focus gain", e) } } }, - isPaused = { - try { runBlocking { player?.getFlag("pause") == true } } - catch (e: Exception) { true } - } + isPaused = { cachedPaused } ) frameRateManager = FrameRateManager( activity = activity, @@ -231,6 +230,9 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { is PropertyChange.Str -> change.value is PropertyChange.None -> null } + if (change.name == "pause" && change is PropertyChange.Flag) { + cachedPaused = change.value + } delegate?.onPropertyChange(change.name, value) } } @@ -327,7 +329,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { // Public API fun setProperty(name: String, value: String) { - if (!isInitialized) return + if (!isInitialized || disposing) return scope.launch { try { player?.setProperty(name, value) } catch (e: Exception) { Log.w(TAG, "setProperty($name) failed", e) } @@ -335,9 +337,9 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { } fun getProperty(name: String): String? { - if (!isInitialized) return null + if (!isInitialized || disposing) return null return try { - runBlocking { player?.getString(name) } + runBlocking(Dispatchers.IO) { player?.getString(name) } } catch (e: Exception) { null } @@ -356,7 +358,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { } fun command(args: Array) { - if (!isInitialized || args.isEmpty()) return + if (!isInitialized || disposing || args.isEmpty()) return scope.launch { try { player?.command(*args) } catch (e: Exception) { Log.w(TAG, "command failed", e) } @@ -435,7 +437,6 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { // Close player on background thread val p = player - player = null if (p != null) { Thread { try { @@ -443,6 +444,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { } catch (e: Exception) { Log.w(TAG, "MPV close failed", e) } + player = null Log.d(TAG, "Disposed (native)") Handler(Looper.getMainLooper()).post { onComplete?.invoke() } }.start() diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index 6e5b14fd..53d408a1 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -51,6 +51,7 @@ class TrackManager { bool _isApplyingTrackSelection = false; List _lastExternalSubtitles = const []; StreamSubscription? _trackLoadingSubscription; + Timer? _subtitleFallbackTimer; /// Cached external subtitles for re-use after backend fallback. List get lastExternalSubtitles => _lastExternalSubtitles; @@ -121,7 +122,8 @@ class TrackManager { } // Fallback if playbackRestart doesn't fire - Future.delayed(const Duration(seconds: 3), () { + _subtitleFallbackTimer?.cancel(); + _subtitleFallbackTimer = Timer(const Duration(seconds: 3), () { if (waitingForExternalSubsTrackSelection && isActive()) { waitingForExternalSubsTrackSelection = false; applyTrackSelection(); @@ -255,13 +257,14 @@ class TrackManager { /// Handle audio track changes — save stream selection and language preference. Future onAudioTrackChanged(AudioTrack track) async { - final partId = await _guardTrackChange(); - if (partId == null) return; + final info = mediaInfo; + final partId = await _guardTrackChange(info); + if (partId == null || info == null) return; int? streamID = _matchTrackByAttributes( mpvLanguage: track.language, mpvTitle: track.title, - plexTracks: mediaInfo!.audioTracks, + plexTracks: info.audioTracks, getLanguageCode: (t) => t.languageCode, getDisplayTitle: (t) => t.displayTitle, getTitle: (t) => t.title, @@ -271,7 +274,7 @@ class TrackManager { if (streamID != null) { appLogger.d('Matched audio by lang/title: streamID $streamID'); } else { - final matchedPlex = findPlexTrackForMpvAudio(track, mediaInfo!.audioTracks); + final matchedPlex = findPlexTrackForMpvAudio(track, info.audioTracks); streamID = matchedPlex?.id; if (streamID != null) { appLogger.d('Matched audio by properties: streamID $streamID'); @@ -285,7 +288,8 @@ class TrackManager { /// Handle subtitle track changes — save stream selection and language preference. Future onSubtitleTrackChanged(SubtitleTrack track) async { - final partId = await _guardTrackChange(); + final info = mediaInfo; + final partId = await _guardTrackChange(info); if (partId == null) return; String? languageCode; @@ -295,13 +299,13 @@ class TrackManager { languageCode = 'none'; streamID = 0; appLogger.i('User turned subtitles off, saving preference'); - } else { + } else if (info != null) { languageCode = track.language; streamID = _matchTrackByAttributes( mpvLanguage: track.language, mpvTitle: track.title, - plexTracks: mediaInfo!.subtitleTracks, + plexTracks: info.subtitleTracks, getLanguageCode: (t) => t.languageCode, getDisplayTitle: (t) => t.displayTitle, getTitle: (t) => t.title, @@ -311,7 +315,7 @@ class TrackManager { if (streamID != null) { appLogger.d('Matched subtitle by lang/title: streamID $streamID'); } else { - final matchedPlex = findPlexTrackForMpvSubtitle(track, mediaInfo!.subtitleTracks); + final matchedPlex = findPlexTrackForMpvSubtitle(track, info.subtitleTracks); streamID = matchedPlex?.id; if (streamID != null) { appLogger.d('Matched subtitle by properties: streamID $streamID'); @@ -340,16 +344,16 @@ class TrackManager { } /// Common guard checks for track change handlers. - Future _guardTrackChange() async { + Future _guardTrackChange(PlexMediaInfo? info) async { final settings = await SettingsService.getInstance(); if (!settings.getRememberTrackSelections()) return null; - if (mediaInfo == null) { + if (info == null) { appLogger.w('No media info available, cannot save stream selection'); return null; } - final partId = mediaInfo!.getPartId(); + final partId = info.getPartId(); if (partId == null) { appLogger.w('No part ID available, cannot save stream selection'); } @@ -439,5 +443,7 @@ class TrackManager { void dispose() { _trackLoadingSubscription?.cancel(); _trackLoadingSubscription = null; + _subtitleFallbackTimer?.cancel(); + _subtitleFallbackTimer = null; } } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index c2220d8d..676670ce 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:path/path.dart' as path; @@ -509,6 +511,17 @@ class _VideoSettingsSheetState extends State { ), onTap: _togglePerformanceOverlay, ), + + // Debug: Trigger MPV Fallback (Android ExoPlayer only) + if (kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer') + FocusableListTile( + leading: AppIcon(Symbols.swap_horiz_rounded, fill: 1, color: tokens(context).textMuted), + title: const Text('Trigger MPV Fallback'), + onTap: () { + const MethodChannel('com.plezy/exo_player').invokeMethod('triggerFallback'); + OverlaySheetController.of(context).close(); + }, + ), ], ); }