fix: harden offline playback and exoplayer fallback
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user