fix: harden offline playback and exoplayer fallback

This commit is contained in:
edde746
2026-02-27 21:02:30 +01:00
parent 7a0a911cd9
commit 3103cb5ee1
4 changed files with 246 additions and 72 deletions
@@ -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<String, Int>()
@@ -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}"))
}
@@ -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<ViewGroup>(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()
}
}
}
+65 -22
View File
@@ -140,6 +140,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<void> _waitForProfileSettingsIfNeeded() async {
if (!widget.isOffline || !mounted) return;
final provider = context.read<UserProfileProvider>();
if (provider.profileSettings != null) return;
final completer = Completer<void>();
late VoidCallback listener;
listener = () {
if (provider.profileSettings != null && !completer.isCompleted) {
completer.complete();
}
};
provider.addListener(listener);
try {
await Future.any<void>([completer.future, Future.delayed(const Duration(seconds: 2))]);
} finally {
provider.removeListener(listener);
}
}
/// Apply track selection using the TrackSelectionService
Future<void> _applyTrackSelection() async {
if (!mounted || player == null) return;
if (!mounted || player == null || _isApplyingTrackSelection) return;
final profileSettings = context.read<UserProfileProvider>().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<UserProfileProvider>().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.
+58 -27
View File
@@ -404,37 +404,68 @@ class DownloadStorageService {
/// (e.g. "data/user/0/.../app_flutter/downloads/...").
Future<String> ensureAbsolutePath(String storedPath) async {
appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}');
final baseDir = await _getBaseAppDir();
final normalizedCandidates = <String>[];
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