From 3103cb5ee1e2926a4ceebd60d347ebbc85157024 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:02:30 +0100 Subject: [PATCH] fix: harden offline playback and exoplayer fallback --- .../plezy/exoplayer/ExoPlayerPlugin.kt | 25 +++- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 121 +++++++++++++++--- lib/screens/video_player_screen.dart | 87 +++++++++---- lib/services/download_storage_service.dart | 85 ++++++++---- 4 files changed, 246 insertions(+), 72 deletions(-) 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 5bd490ac..4bdb1165 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 @@ -29,6 +29,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private var playerCore: ExoPlayerCore? = null private var mpvCore: MpvPlayerCore? = null // MPV fallback player private var usingMpvFallback: Boolean = false + private var fallbackInProgress: Boolean = false private var activity: Activity? = null private var activityBinding: ActivityPluginBinding? = null private val nameToId = mutableMapOf() @@ -66,6 +67,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, mpvCore?.dispose() mpvCore = null usingMpvFallback = false + fallbackInProgress = false activity = null activityBinding = null Log.d(TAG, "Detached from activity") @@ -169,14 +171,12 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handleDispose(result: MethodChannel.Result) { activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.dispose() - mpvCore = null - } else { - playerCore?.dispose() - playerCore = null - } + playerCore?.dispose() + playerCore = null + mpvCore?.dispose() + mpvCore = null usingMpvFallback = false + fallbackInProgress = false Log.d(TAG, "Disposed") result.success(null) } ?: result.success(null) @@ -571,7 +571,13 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, positionMs: Long, errorMessage: String ): Boolean { + if (usingMpvFallback || fallbackInProgress) { + Log.w(TAG, "Fallback already active/in-progress, ignoring duplicate request") + return true + } + val currentActivity = activity ?: return false + fallbackInProgress = true Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage") @@ -580,6 +586,8 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, // Dispose ExoPlayer playerCore?.dispose() playerCore = null + mpvCore?.dispose() + mpvCore = null // Create and initialize MPV mpvCore = MpvPlayerCore(currentActivity).apply { @@ -587,12 +595,14 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } mpvCore?.initialize { success -> if (!success) { + fallbackInProgress = false Log.e(TAG, "Failed to initialize MPV fallback") onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage")) return@initialize } usingMpvFallback = true + fallbackInProgress = false // Configure basic MPV properties for Plex playback mpvCore?.setProperty("hwdec", "auto") @@ -643,6 +653,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Log.i(TAG, "Successfully switched to MPV fallback") } } catch (e: Exception) { + fallbackInProgress = false Log.e(TAG, "Failed to switch to MPV fallback", e) onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) } 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 158af551..f0e83614 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 @@ -50,6 +50,9 @@ class MpvPlayerCore(private val activity: Activity) : private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null private var voInUse: String = "gpu" + @Volatile private var nativeReady: Boolean = false + @Volatile private var disposing: Boolean = false + private var pendingSurface: Surface? = null var delegate: MpvPlayerDelegate? = null var isInitialized: Boolean = false private set @@ -189,6 +192,9 @@ class MpvPlayerCore(private val activity: Activity) : } try { + disposing = false + pendingSurface = null + // Initialize AudioManager for audio focus handling audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager @@ -260,19 +266,50 @@ class MpvPlayerCore(private val activity: Activity) : Thread { try { synchronized(mpvLock) { + if (disposing) { + handler.post { onResult(false) } + return@Thread + } MPVLib.create(ctx) setupMpvDefaults() MPVLib.init() + nativeReady = true } handler.post { + if (disposing) { + if (nativeReady) { + Thread { + synchronized(mpvLock) { + try { + MPVLib.destroy() + } catch (_: Exception) { + } finally { + nativeReady = false + } + } + }.start() + } + onResult(false) + return@post + } + MPVLib.addObserver(this) MPVLib.addLogObserver(this) isInitialized = true + + // surfaceCreated can fire before MPV init finishes. + // Defer attaching the surface until native init is ready. + pendingSurface?.takeIf { it.isValid }?.let { + attachSurfaceInternal(it) + } + pendingSurface = null + Log.d(TAG, "Initialized successfully") onResult(true) } } catch (e: Exception) { Log.e(TAG, "Failed to initialize native: ${e.message}", e) + nativeReady = false handler.post { onResult(false) } } }.start() @@ -360,25 +397,59 @@ class MpvPlayerCore(private val activity: Activity) : override fun surfaceCreated(holder: SurfaceHolder) { Log.d(TAG, "Surface created") - MPVLib.attachSurface(holder.surface) - MPVLib.setOptionString("force-window", "yes") - // Restore video output after surface is available - MPVLib.setPropertyString("vo", voInUse) + if (disposing) return + + val surface = holder.surface + if (!nativeReady) { + pendingSurface = surface + Log.d(TAG, "Deferring surface attach until MPV native init completes") + return + } + + attachSurfaceInternal(surface) // Reassert overlay order whenever the surface is recreated ensureFlutterOverlayOnTop() } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { Log.d(TAG, "Surface changed: ${width}x${height}") - MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + if (!nativeReady || disposing) return + try { + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + } catch (e: Exception) { + Log.w(TAG, "Failed to apply surface size to MPV", e) + } } override fun surfaceDestroyed(holder: SurfaceHolder) { Log.d(TAG, "Surface destroyed") + pendingSurface = null + if (!nativeReady || disposing) return + detachSurfaceInternal() + } + + private fun attachSurfaceInternal(surface: Surface) { + if (!nativeReady || disposing || !surface.isValid) return + try { + MPVLib.attachSurface(surface) + MPVLib.setOptionString("force-window", "yes") + // Restore video output after surface is available + MPVLib.setPropertyString("vo", voInUse) + } catch (e: Exception) { + Log.w(TAG, "Failed to attach MPV surface", e) + } + } + + private fun detachSurfaceInternal() { + if (!nativeReady) return // Disable video output before detaching (like mpv-android) - MPVLib.setPropertyString("vo", "null") - MPVLib.setOptionString("force-window", "no") - MPVLib.detachSurface() + try { + MPVLib.setPropertyString("vo", "null") + MPVLib.setOptionString("force-window", "no") + MPVLib.detachSurface() + } catch (e: Exception) { + Log.w(TAG, "Failed to detach MPV surface", e) + } } // MPVLib.EventObserver @@ -713,6 +784,8 @@ class MpvPlayerCore(private val activity: Activity) : // Cleanup fun dispose() { + if (disposing) return + disposing = true Log.d(TAG, "Disposing") // Shutdown command executor @@ -725,8 +798,15 @@ class MpvPlayerCore(private val activity: Activity) : abandonAudioFocus() audioManager = null - MPVLib.removeObserver(this) - MPVLib.removeLogObserver(this) + if (nativeReady) { + try { + MPVLib.removeObserver(this) + MPVLib.removeLogObserver(this) + } catch (e: Exception) { + Log.w(TAG, "Failed to remove MPV observers during dispose", e) + } + detachSurfaceInternal() + } overlayLayoutListener?.let { listener -> val contentView = activity.findViewById(android.R.id.content) @@ -747,16 +827,25 @@ class MpvPlayerCore(private val activity: Activity) : } surfaceContainer = null surfaceView = null + pendingSurface = null isInitialized = false // Run native destroy on background thread to avoid ANR — // MPVLib.destroy() blocks on pthread_cond_wait while mpv's // internal threads (lua, demux, vo) shut down. - Thread { - synchronized(mpvLock) { - MPVLib.destroy() - } - Log.d(TAG, "Disposed (native)") - }.start() + if (nativeReady) { + Thread { + synchronized(mpvLock) { + try { + MPVLib.destroy() + } catch (e: Exception) { + Log.w(TAG, "MPV destroy failed", e) + } finally { + nativeReady = false + } + } + Log.d(TAG, "Disposed (native)") + }.start() + } } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5b436a85..67a54592 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -140,6 +140,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; bool _waitingForExternalSubsTrackSelection = false; + bool _isApplyingTrackSelection = false; bool _isHandlingBack = false; BifThumbnailService? _bifService; @@ -552,6 +553,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Listen to position for completion detection (fallback for unreliable MPV events) _positionSubscription = player!.streams.position.listen((position) { + // Fallback for cases where playbackRestart doesn't fire (observed on some + // offline Android playback flows). Prevents a permanent loading spinner. + if (!_hasFirstFrame.value && position.inMilliseconds > 0) { + _hasFirstFrame.value = true; + } + final duration = player!.state.duration; if (duration.inMilliseconds > 0 && position.inMilliseconds >= duration.inMilliseconds - 1000 && @@ -1149,16 +1156,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { final serverId = widget.metadata.serverId; if (serverId != null) { - final cached = await PlexApiCache.instance.get( - serverId, - '/library/metadata/${widget.metadata.ratingKey}', - ); + final cached = await PlexApiCache.instance.get(serverId, '/library/metadata/${widget.metadata.ratingKey}'); final metadataJson = PlexCacheParser.extractFirstMetadata(cached); if (metadataJson != null) { mediaInfo = PlexMediaInfo.fromMetadataJson(metadataJson); } - appLogger.d('Offline media info: cached=${cached != null}, hasMedia=${metadataJson?['Media'] != null}, ' - 'audioTracks=${mediaInfo?.audioTracks.length ?? 0}, subtitleTracks=${mediaInfo?.subtitleTracks.length ?? 0}'); + appLogger.d( + 'Offline media info: cached=${cached != null}, hasMedia=${metadataJson?['Media'] != null}, ' + 'audioTracks=${mediaInfo?.audioTracks.length ?? 0}, subtitleTracks=${mediaInfo?.subtitleTracks.length ?? 0}', + ); } } catch (e) { appLogger.d('Could not load cached media info for offline playback', error: e); @@ -2081,26 +2087,63 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } + /// Wait briefly for profile settings to load in offline mode. + /// This prevents default-track fallback when playback starts before + /// UserProfileProvider finishes initialization. + Future _waitForProfileSettingsIfNeeded() async { + if (!widget.isOffline || !mounted) return; + + final provider = context.read(); + if (provider.profileSettings != null) return; + + final completer = Completer(); + late VoidCallback listener; + listener = () { + if (provider.profileSettings != null && !completer.isCompleted) { + completer.complete(); + } + }; + + provider.addListener(listener); + try { + await Future.any([completer.future, Future.delayed(const Duration(seconds: 2))]); + } finally { + provider.removeListener(listener); + } + } + /// Apply track selection using the TrackSelectionService Future _applyTrackSelection() async { - if (!mounted || player == null) return; + if (!mounted || player == null || _isApplyingTrackSelection) return; - final profileSettings = context.read().profileSettings; - final settingsService = await SettingsService.getInstance(); - final trackService = TrackSelectionService( - player: player!, - profileSettings: profileSettings, - metadata: widget.metadata, - plexMediaInfo: _currentMediaInfo, - ); + _isApplyingTrackSelection = true; + try { + await _waitForProfileSettingsIfNeeded(); + if (!mounted || player == null) return; - await trackService.selectAndApplyTracks( - preferredAudioTrack: widget.preferredAudioTrack, - preferredSubtitleTrack: widget.preferredSubtitleTrack, - defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(), - onAudioTrackChanged: _onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - ); + final profileSettings = context.read().profileSettings; + final settingsService = await SettingsService.getInstance(); + if (!mounted || player == null) return; + + final trackService = TrackSelectionService( + player: player!, + profileSettings: profileSettings, + metadata: widget.metadata, + plexMediaInfo: _currentMediaInfo, + ); + + await trackService.selectAndApplyTracks( + preferredAudioTrack: widget.preferredAudioTrack, + preferredSubtitleTrack: widget.preferredSubtitleTrack, + defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(), + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + ); + } catch (e) { + appLogger.w('Failed to apply track selection', error: e); + } finally { + _isApplyingTrackSelection = false; + } } /// Rating key used for series/movie level language preferences. diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 1841a028..b3e10bf3 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -404,37 +404,68 @@ class DownloadStorageService { /// (e.g. "data/user/0/.../app_flutter/downloads/..."). Future ensureAbsolutePath(String storedPath) async { appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}'); + final baseDir = await _getBaseAppDir(); + final normalizedCandidates = []; - String result; - if (path.isAbsolute(storedPath)) { - // Already absolute - check if file exists at this path - if (await File(storedPath).exists()) { - result = storedPath; - } else { - // File doesn't exist at absolute path - try to reconstruct - // Extract the relative portion (everything after 'downloads/') - final downloadsIndex = storedPath.indexOf('downloads/'); - if (downloadsIndex != -1) { - final relativePart = storedPath.substring(downloadsIndex); - result = await toAbsolutePath(relativePart); - } else { - // Can't reconstruct, return original - result = storedPath; - } - } - } else { - // Relative path — if it contains a nested base-dir fragment - // (e.g. "data/.../app_flutter/downloads/..."), extract from downloads/ onward - final downloadsIndex = storedPath.indexOf('downloads/'); - if (downloadsIndex > 0) { - result = await toAbsolutePath(storedPath.substring(downloadsIndex)); - } else { - result = await toAbsolutePath(storedPath); + void addCandidate(String candidate) { + if (candidate.isEmpty) return; + final normalized = path.normalize(candidate); + if (!normalizedCandidates.contains(normalized)) { + normalizedCandidates.add(normalized); } } - appLogger.d('ensureAbsolutePath: resolved="$result"'); - return result; + String trimLeadingSeparators(String value) => value.replaceFirst(RegExp(r'^[\\/]+'), ''); + + if (path.isAbsolute(storedPath)) { + // Keep the original absolute path first (covers valid custom download paths). + addCandidate(storedPath); + + // Recover from doubled app base path corruption: + // /data/.../app_flutter/data/.../app_flutter/downloads/... + final firstBaseIndex = storedPath.indexOf(baseDir.path); + final secondBaseIndex = storedPath.indexOf(baseDir.path, firstBaseIndex + baseDir.path.length); + if (firstBaseIndex != -1 && secondBaseIndex != -1) { + final tail = trimLeadingSeparators(storedPath.substring(secondBaseIndex + baseDir.path.length)); + addCandidate(path.join(baseDir.path, tail)); + } + + // Recover from paths that contain downloads/ but wrong prefix. + final downloadsIndex = storedPath.lastIndexOf('downloads/'); + if (downloadsIndex != -1) { + final relativePart = storedPath.substring(downloadsIndex); + addCandidate(await toAbsolutePath(relativePart)); + } + } else { + // Normal relative path. + addCandidate(await toAbsolutePath(storedPath)); + + // Recover from nested base-dir fragment without leading slash. + final baseIndex = storedPath.indexOf(baseDir.path); + if (baseIndex > 0) { + final tail = trimLeadingSeparators(storedPath.substring(baseIndex + baseDir.path.length)); + addCandidate(path.join(baseDir.path, tail)); + } + + // Recover from nested fragment containing downloads/. + final downloadsIndex = storedPath.lastIndexOf('downloads/'); + if (downloadsIndex >= 0) { + addCandidate(await toAbsolutePath(storedPath.substring(downloadsIndex))); + } + } + + // Prefer the first candidate that exists on disk. + for (final candidate in normalizedCandidates) { + if (await File(candidate).exists()) { + appLogger.d('ensureAbsolutePath: resolved="$candidate"'); + return candidate; + } + } + + // Fall back to the most conservative candidate if none currently exist. + final fallback = normalizedCandidates.isNotEmpty ? normalizedCandidates.first : await toAbsolutePath(storedPath); + appLogger.d('ensureAbsolutePath: resolved="$fallback" (fallback)'); + return fallback; } /// Calculate total storage used by downloads