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 9dd528f4..00e89edf 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 @@ -1,6 +1,7 @@ package com.edde746.plezy.exoplayer import android.app.Activity +import android.net.Uri import android.util.Log import com.edde746.plezy.mpv.MpvPlayerCore import com.edde746.plezy.mpv.MpvPlayerDelegate @@ -190,7 +191,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, options.add("http-header-fields-append=$key: $value") } val optionsStr = options.joinToString(",") - mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) + // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) + val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri + mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) } else { playerCore?.open(uri, headers, startPositionMs, autoPlay) } @@ -502,6 +505,25 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, eventSink?.success(event) } + /** + * Opens a content:// URI via ContentResolver and returns the raw FD number, + * or null if the URI is not a content:// scheme or opening fails. + * The returned FD is detached so MPV can own and close it via fdclose://. + */ + private fun openContentFd(uriString: String): Int? { + if (!uriString.startsWith("content://")) return null + return try { + val uri = Uri.parse(uriString) + val pfd = activity?.contentResolver?.openFileDescriptor(uri, "r") ?: return null + val fd = pfd.detachFd() + Log.d(TAG, "Opened content FD $fd for $uriString") + fd + } catch (e: Exception) { + Log.e(TAG, "Failed to open content FD: ${e.message}", e) + null + } + } + override fun onFormatUnsupported( uri: String, headers: Map?, @@ -561,7 +583,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, options.add("http-header-fields-append=$key: $value") } val optionsStr = options.joinToString(",") - mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) + // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) + val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri + mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) // Request audio focus mpvCore?.requestAudioFocus() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index 6b649df3..6511155e 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -1,6 +1,7 @@ package com.edde746.plezy.mpv import android.app.Activity +import android.net.Uri import android.util.Log import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -98,6 +99,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, "clearVideoFrameRate" -> handleClearVideoFrameRate(result) "requestAudioFocus" -> handleRequestAudioFocus(result) "abandonAudioFocus" -> handleAbandonAudioFocus(result) + "openContentFd" -> handleOpenContentFd(call, result) "isInitialized" -> result.success(playerCore?.isInitialized ?: false) else -> result.notImplemented() } @@ -236,6 +238,37 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, result.success(null) } + private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) { + val uriString = call.argument("uri") + if (uriString == null) { + result.error("INVALID_ARGS", "Missing 'uri'", null) + return + } + + try { + val uri = Uri.parse(uriString) + val contentResolver = activity?.contentResolver + if (contentResolver == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + val pfd = contentResolver.openFileDescriptor(uri, "r") + if (pfd == null) { + result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null) + return + } + + // detachFd() transfers ownership of the FD to the caller (MPV via fdclose://) + val fd = pfd.detachFd() + Log.d(TAG, "Opened content FD $fd for $uriString") + result.success(fd) + } catch (e: Exception) { + Log.e(TAG, "Failed to open content FD: ${e.message}", e) + result.error("OPEN_FAILED", e.message, null) + } + } + // MpvPlayerDelegate override fun onPropertyChange(name: String, value: Any?) { diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 5260c404..3e63444e 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -84,6 +84,17 @@ class PlayerNative extends PlayerBase { // Playback Control // ============================================ + /// Opens a content:// URI via the platform channel and returns the raw FD number. + /// Returns null if the call fails. + Future _openContentFd(String contentUri) async { + try { + final fd = await methodChannel.invokeMethod('openContentFd', {'uri': contentUri}); + return fd; + } catch (e) { + return null; + } + } + @override Future open(Media media, {bool play = true}) async { checkDisposed(); @@ -113,7 +124,16 @@ class PlayerNative extends PlayerBase { await setProperty('pause', 'yes'); } - await command(['loadfile', media.uri, 'replace']); + // Convert content:// URIs to fdclose:// for MPV on Android (SAF SD card downloads) + var uri = media.uri; + if (Platform.isAndroid && uri.startsWith('content://')) { + final fd = await _openContentFd(uri); + if (fd != null) { + uri = 'fdclose://$fd'; + } + } + + await command(['loadfile', uri, 'replace']); } @override diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d871e143..3597196c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -885,7 +885,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin return PlaybackInitializationResult( availableVersions: [], - videoUrl: 'file://$videoPath', + videoUrl: videoPath.contains('://') ? videoPath : 'file://$videoPath', mediaInfo: null, externalSubtitles: const [], isOffline: true,