From 5a4cf263fc2632805a4fc14a3b3b1ccbeef50e4a Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 11 Apr 2026 23:32:23 +0300 Subject: [PATCH 1/3] fix(ios): resolve threading issues and crash on dead links - Move all Compose state mutations to main thread (KVO observers, notification observers, metadata extraction) - Defer _hasMedia=true until AVPlayerItem reaches readyToPlay, preventing UIKitView creation with an invalid player item - Extract metadata only when item is ready, avoiding ObjC NSExceptions from accessing track properties on unloaded/failed assets - Guard periodic time observer with readyToPlay status check - Guard AVPictureInPictureController creation with isPictureInPictureSupported - Reduce position update timer from 60fps to 15fps - Use NSOperationQueue.mainQueue for all notification observers - Implement _error state properly (was hardcoded to null) --- .../VideoPlayerState.ios.kt | 286 ++++++++---------- .../VideoPlayerSurface.ios.kt | 4 +- 2 files changed, 137 insertions(+), 153 deletions(-) diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index e5f3689f..debf72a4 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -35,6 +35,7 @@ import platform.CoreMedia.CMTimeGetSeconds import platform.CoreMedia.CMTimeMake import platform.CoreMedia.CMTimeMakeWithSeconds import platform.Foundation.NSKeyValueChangeNewKey +import platform.Foundation.NSOperationQueue import platform.Foundation.NSKeyValueObservingOptionNew import platform.Foundation.NSKeyValueObservingOptions import platform.Foundation.NSKeyValueObservingProtocol @@ -45,11 +46,9 @@ import platform.Foundation.removeObserver import platform.UIKit.UIApplication import platform.UIKit.UIApplicationDidEnterBackgroundNotification import platform.UIKit.UIApplicationWillEnterForegroundNotification -import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT import platform.darwin.NSEC_PER_SEC import platform.darwin.NSObject import platform.darwin.dispatch_async -import platform.darwin.dispatch_get_global_queue import platform.darwin.dispatch_get_main_queue actual fun createVideoPlayerState( @@ -139,7 +138,8 @@ open class DefaultVideoPlayerState( override var isPipActive by mutableStateOf(false) - override val error: VideoPlayerError? = null + private var _error by mutableStateOf(null) + override val error: VideoPlayerError? get() = _error // Observable instance of AVPlayer var player: AVPlayer? by mutableStateOf(null) @@ -231,18 +231,23 @@ open class DefaultVideoPlayerState( } private fun startPositionUpdates(player: AVPlayer) { - val interval = CMTimeMakeWithSeconds(1.0 / 60.0, NSEC_PER_SEC.toInt()) // approx. 60 fps + val interval = CMTimeMakeWithSeconds(1.0 / 15.0, NSEC_PER_SEC.toInt()) // ~15 fps timeObserverToken = player.addPeriodicTimeObserverForInterval( interval = interval, queue = dispatch_get_main_queue(), - usingBlock = { time -> + usingBlock = block@{ time -> + // Only access item properties when the item is ready to play. + // Accessing duration/presentationSize on a failed or loading item + // can throw an ObjC NSException (abort). + val item = player.currentItem ?: return@block + if (item.status != AVPlayerItemStatusReadyToPlay) return@block + val currentSeconds = CMTimeGetSeconds(time) - val durationSeconds = player.currentItem?.duration?.let { CMTimeGetSeconds(it) } ?: 0.0 + val durationSeconds = CMTimeGetSeconds(item.duration) _currentTime = currentSeconds _duration = durationSeconds - // Update duration in metadata if (durationSeconds > 0 && !durationSeconds.isNaN()) { _metadata.duration = (durationSeconds * 1000).toLong() } @@ -257,14 +262,10 @@ open class DefaultVideoPlayerState( _positionText = if (currentSeconds.isNaN()) "00:00" else formatTime(currentSeconds.toFloat()) _durationText = if (durationSeconds.isNaN()) "00:00" else formatTime(durationSeconds.toFloat()) - player.currentItem?.presentationSize?.useContents { - // Only update if dimensions are valid (greater than 0) + item.presentationSize.useContents { if (width > 0 && height > 0) { - // Try to use real aspect ratio if available, fallback to 16:9 - val realAspect = width / height - _videoAspectRatio = realAspect + _videoAspectRatio = width / height - // Update width and height in metadata if they're not already set or if they're zero if (_metadata.width == null || _metadata.width == 0 || _metadata.height == null || @@ -296,37 +297,49 @@ open class DefaultVideoPlayerState( item: AVPlayerItem, ) { // KVO for timeControlStatus (Playing, Paused, Loading) + // Only read primitive/enum values in the callback — accessing ObjC object + // properties (like reasonForWaitingToPlay) can throw NSExceptions. timeControlStatusObserver = player.observe("timeControlStatus") { _ -> - when (player.timeControlStatus) { - AVPlayerTimeControlStatusPlaying -> { - _isPlaying = true - _isLoading = false - } - AVPlayerTimeControlStatusPaused -> { - if (player.reasonForWaitingToPlay == null) { + val status = player.timeControlStatus + dispatch_async(dispatch_get_main_queue()) { + when (status) { + AVPlayerTimeControlStatusPlaying -> { + _isPlaying = true + _isLoading = false + } + AVPlayerTimeControlStatusPaused -> { _isPlaying = false + _isLoading = false + } + AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> { + _isLoading = true } - _isLoading = false - } - AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> { - _isLoading = true } } } // KVO for status (Ready, Failed) + // Only capture status here — accessing item.error in the KVO callback + // throws an ObjC NSException (ForeignException) that crashes the app. + // Error details are read safely on the main thread. statusObserver = item.observe("status") { _ -> - when (item.status) { - AVPlayerItemStatusReadyToPlay -> { - _isLoading = false - iosLogger.d { "Player Item Ready" } - } - AVPlayerItemStatusFailed -> { - _isLoading = false - _isPlaying = false - iosLogger.e { "Player Item Failed: ${item.error?.localizedDescription}" } + val currentStatus = item.status + dispatch_async(dispatch_get_main_queue()) { + when (currentStatus) { + AVPlayerItemStatusReadyToPlay -> { + _hasMedia = true + _isLoading = false + extractMetadata(item) + iosLogger.d { "Player Item Ready" } + } + AVPlayerItemStatusFailed -> { + _isLoading = false + _isPlaying = false + _error = VideoPlayerError.SourceError("Playback failed") + iosLogger.e { "Player Item Failed" } + } } } } @@ -339,7 +352,7 @@ open class DefaultVideoPlayerState( NSNotificationCenter.defaultCenter.addObserverForName( name = AVPlayerItemDidPlayToEndTimeNotification, `object` = item, - queue = null, + queue = NSOperationQueue.mainQueue, ) { _ -> if (_loop) { val zeroTime = CMTimeMake(0, 1) @@ -381,7 +394,7 @@ open class DefaultVideoPlayerState( NSNotificationCenter.defaultCenter.addObserverForName( name = UIApplicationDidEnterBackgroundNotification, `object` = UIApplication.sharedApplication, - queue = null, + queue = NSOperationQueue.mainQueue, ) { _ -> iosLogger.d { "App entered background (screen locked)" } // Store current playing state before background @@ -401,7 +414,7 @@ open class DefaultVideoPlayerState( NSNotificationCenter.defaultCenter.addObserverForName( name = UIApplicationWillEnterForegroundNotification, `object` = UIApplication.sharedApplication, - queue = null, + queue = NSOperationQueue.mainQueue, ) { _ -> iosLogger.d { "App will enter foreground (screen unlocked)" } // If player was playing before going to background, resume playback @@ -419,6 +432,49 @@ open class DefaultVideoPlayerState( iosLogger.d { "App lifecycle observers set up" } } + /** + * Extracts metadata from a player item once it has reached readyToPlay status. + * Must be called on the main thread. + */ + private fun extractMetadata(item: AVPlayerItem) { + val asset = item.asset + val durationSeconds = CMTimeGetSeconds(item.duration) + if (durationSeconds > 0 && !durationSeconds.isNaN()) { + _metadata.duration = (durationSeconds * 1000).toLong() + } + + val videoTracks = asset.tracksWithMediaType(AVMediaTypeVideo) + if (videoTracks.isNotEmpty()) { + val videoTrack = videoTracks.firstOrNull() as? AVAssetTrack + videoTrack?.let { track -> + val nominalFrameRate = track.nominalFrameRate + if (nominalFrameRate > 0) { + _metadata.frameRate = nominalFrameRate + } + + val trackBitrate = track.estimatedDataRate + if (trackBitrate > 0) { + _metadata.bitrate = trackBitrate.toLong() + } + + track.naturalSize.useContents { + if (width > 0 && height > 0) { + _metadata.width = width.toInt() + _metadata.height = height.toInt() + _videoAspectRatio = width / height + iosLogger.d { "Video resolution: ${width.toInt()}x${height.toInt()}" } + } + } + } + } + + val audioTracks = asset.tracksWithMediaType(AVMediaTypeAudio) + if (audioTracks.isNotEmpty()) { + _metadata.audioChannels = 2 + _metadata.audioSampleRate = 44100 + } + } + private fun removeAppLifecycleObservers() { backgroundObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) @@ -486,8 +542,15 @@ open class DefaultVideoPlayerState( return } - // Clean up the current player completely before creating a new one - cleanupCurrentPlayer() + // Clear any previous error + _error = null + + // Stop the current player immediately to prevent stale KVO/notifications + // while background metadata extraction runs. Full cleanup happens on main + // after background work completes. + stopPositionUpdates() + removeObservers() + player?.pause() // Configure audio session configureAudioSession() @@ -502,131 +565,53 @@ open class DefaultVideoPlayerState( _metadata = VideoMetadata(audioChannels = 2) _hasMedia = false - // Don't set _isPlaying to true yet, as we haven't decided whether to play or pause - - // Process the asset on a background thread to avoid blocking the UI - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) { - // Create an AVAsset to extract metadata - val asset = AVURLAsset.URLAssetWithURL(nsUrl, null) - - // Extract metadata from tracks - var videoAspectRatioTemp = 16.0 / 9.0 - var widthTemp: Int? = null - var heightTemp: Int? = null - - // Process video tracks - val videoTracks = asset.tracksWithMediaType(AVMediaTypeVideo) - if (videoTracks.isNotEmpty()) { - val videoTrack = videoTracks.firstOrNull() as? AVAssetTrack - videoTrack?.let { track -> - // Get frame rate - val nominalFrameRate = track.nominalFrameRate - if (nominalFrameRate > 0) { - _metadata.frameRate = nominalFrameRate - } - // Get bitrate - val trackBitrate = track.estimatedDataRate - if (trackBitrate > 0) { - _metadata.bitrate = trackBitrate.toLong() - } - - // Get resolution from naturalSize - track.naturalSize.useContents { - if (width > 0 && height > 0) { - widthTemp = width.toInt() - heightTemp = height.toInt() - // Try to use real aspect ratio if available, fallback to 16:9 - videoAspectRatioTemp = width / height - iosLogger.d { "Video resolution from track: ${width.toInt()}x${height.toInt()}" } - } - } - } - } - - // Process audio tracks - val audioTracks = asset.tracksWithMediaType(AVMediaTypeAudio) - if (audioTracks.isNotEmpty()) { - // Set audio channels to 2 (stereo) as a more accurate default - // Most audio content is stereo, and we can't easily get the override channel count - // from AVAssetTrack in Kotlin/Native - _metadata.audioChannels = 2 // Default to stereo instead of using track count - - // Try to get sample rate (simplified approach) - _metadata.audioSampleRate = 44100 // Default to common value - } + // Clean up existing player before creating a new one + cleanupCurrentPlayer() - // Create player item from asset to get more accurate metadata - val playerItem = AVPlayerItem(asset) - val durationSeconds = CMTimeGetSeconds(playerItem.duration) - if (durationSeconds > 0 && !durationSeconds.isNaN()) { - _metadata.duration = (durationSeconds * 1000).toLong() + // Create player item and player directly on main thread. + // AVPlayer handles async loading internally — metadata is extracted + // safely in the KVO readyToPlay callback, avoiding ObjC exceptions + // from accessing track properties on an unloaded/failed asset. + val asset = AVURLAsset.URLAssetWithURL(nsUrl, null) + val playerItem = AVPlayerItem(asset) + + nsUrl.lastPathComponent?.let { _metadata.title = it } + + val newPlayer = + AVPlayer(playerItem = playerItem).apply { + volume = this@DefaultVideoPlayerState.volume + actionAtItemEnd = AVPlayerActionAtItemEndNone + automaticallyWaitsToMinimizeStalling = true + allowsExternalPlayback = false } - // Try to extract title from the file name - nsUrl.lastPathComponent?.let { _metadata.title = it } - - // Update UI on the main thread - dispatch_async(dispatch_get_main_queue()) { - // Check if disposed - if (isDisposed) { - iosLogger.d { "player disposed, canceling initialization" } - return@dispatch_async - } + player = newPlayer + // Don't set _hasMedia = true yet — wait until the item is readyToPlay. + // Setting it early causes VideoPlayerSurface to create a UIKitView with + // AVPictureInPictureController on a player whose item may be invalid, + // which throws an ObjC NSException during Compose recomposition. - // Clean up any existing player before creating the new one - cleanupCurrentPlayer() + setupObservers(newPlayer, playerItem) - // Update metadata - if (widthTemp != null && heightTemp != null) { - _metadata.width = widthTemp - _metadata.height = heightTemp - _videoAspectRatio = videoAspectRatioTemp - } - - // Create the final player with the fully loaded asset - val newPlayer = - AVPlayer(playerItem = playerItem).apply { - volume = this@DefaultVideoPlayerState.volume - // Don't set rate here, as it can cause auto-play - actionAtItemEnd = AVPlayerActionAtItemEndNone - - // For HLS auto-playing needs to be true - automaticallyWaitsToMinimizeStalling = true - - // Disable AirPlay - allowsExternalPlayback = false - } - - player = newPlayer - _hasMedia = true - - setupObservers(newPlayer, playerItem) - - // Control initial playback state based on the parameter - if (initializeplayerState == InitialPlayerState.PLAY) { - // For PLAY state, explicitly call play() which will set the rate - play() - } else { - // For PAUSE state, ensure the player is paused - newPlayer.pause() - } - } + if (initializeplayerState == InitialPlayerState.PLAY) { + play() + } else { + newPlayer.pause() } } override fun play() { iosLogger.d { "play called" } - val currentPlayer = player - if (currentPlayer == null) { + val currentPlayer = player ?: run { iosLogger.d { "play: player is null" } return } - // Configure audio session configureAudioSession() - // If the player has reached the end, seek to the beginning first + + // Only access item timing properties when ready — ObjC throws on failed items val currentItem = currentPlayer.currentItem - if (currentItem != null) { + if (currentItem != null && currentItem.status == AVPlayerItemStatusReadyToPlay) { val currentTime = CMTimeGetSeconds(currentItem.currentTime()) val duration = CMTimeGetSeconds(currentItem.duration) if (duration > 0 && currentTime >= duration) { @@ -646,7 +631,6 @@ open class DefaultVideoPlayerState( } } currentPlayer.playImmediatelyAtRate(_playbackSpeed) - // KVO will update isPlaying } override fun restart() { @@ -669,10 +653,7 @@ open class DefaultVideoPlayerState( override fun pause() { iosLogger.d { "pause called" } - // Ensure the pause call is on the main thread: - dispatch_async(dispatch_get_main_queue()) { - player?.pause() - } + player?.pause() // KVO will update isPlaying } @@ -720,6 +701,7 @@ open class DefaultVideoPlayerState( override fun clearError() { iosLogger.d { "clearError called" } + _error = null } override fun clearCache() { diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt index c98ff210..d91fa98b 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt @@ -99,7 +99,9 @@ fun VideoPlayerSurfaceImpl( (playerState as? DefaultVideoPlayerState)?.let { state -> val playerLayer = layer as? AVPlayerLayer ?: return@let state.playerLayer = playerLayer - state.pipController = AVPictureInPictureController(playerLayer = playerLayer) + if (AVPictureInPictureController.isPictureInPictureSupported()) { + state.pipController = AVPictureInPictureController(playerLayer = playerLayer) + } } } }, From 41ad9ec3fe07f5085efade00f0b123497eac686b Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 11 Apr 2026 23:46:31 +0300 Subject: [PATCH 2/3] fix(ios): add security-scoped resource access for local file playback Files picked via UIDocumentPickerViewController require security-scoped access. Without calling startAccessingSecurityScopedResource(), AVPlayer cannot read the file and playback silently fails. - Call startAccessingSecurityScopedResource() in openFile() before passing the URL to AVPlayer - Track the scoped file and call stopAccessingSecurityScopedResource() on cleanup - Refactor openUri into openNsUrl for shared logic reuse --- .../VideoPlayerState.ios.kt | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index debf72a4..74d49c4d 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -11,11 +11,13 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp +import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.kdroidfilter.composemediaplayer.util.PipResult import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger import io.github.kdroidfilter.composemediaplayer.util.formatTime -import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.startAccessingSecurityScopedResource +import io.github.vinceglb.filekit.stopAccessingSecurityScopedResource import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.useContents @@ -170,6 +172,9 @@ open class DefaultVideoPlayerState( // Flag to track if the state has been disposed private var isDisposed = false + // Security-scoped file that needs to be released on cleanup + private var securityScopedFile: PlatformFile? = null + init { if (cacheConfig.enabled) { IosVideoCache.configure(cacheConfig.maxCacheSizeBytes) @@ -516,6 +521,10 @@ open class DefaultVideoPlayerState( player?.pause() player?.replaceCurrentItemWithPlayerItem(null) player = null + + // Release security-scoped resource access from file picker + securityScopedFile?.stopAccessingSecurityScopedResource() + securityScopedFile = null } /** @@ -541,35 +550,32 @@ open class DefaultVideoPlayerState( iosLogger.d { "Failed to create NSURL from uri: $uri" } return } + openNsUrl(nsUrl, initializeplayerState) + } - // Clear any previous error + /** + * Core method to open media from an NSURL. + * Both [openUri] and [openFile] delegate to this. + */ + private fun openNsUrl( + nsUrl: NSURL, + initializeplayerState: InitialPlayerState, + ) { _error = null - // Stop the current player immediately to prevent stale KVO/notifications - // while background metadata extraction runs. Full cleanup happens on main - // after background work completes. stopPositionUpdates() removeObservers() player?.pause() - // Configure audio session configureAudioSession() - // Reset playback speed to 1.0f when opening a new video _playbackSpeed = 1.0f - - // Set loading state to true at the beginning of loading a new video _isLoading = true - - // Reset metadata to default values _metadata = VideoMetadata(audioChannels = 2) - _hasMedia = false - // Clean up existing player before creating a new one cleanupCurrentPlayer() - // Create player item and player directly on main thread. // AVPlayer handles async loading internally — metadata is extracted // safely in the KVO readyToPlay callback, avoiding ObjC exceptions // from accessing track properties on an unloaded/failed asset. @@ -587,10 +593,6 @@ open class DefaultVideoPlayerState( } player = newPlayer - // Don't set _hasMedia = true yet — wait until the item is readyToPlay. - // Setting it early causes VideoPlayerSurface to create a UIKitView with - // AVPictureInPictureController on a player whose item may be invalid, - // which throws an ObjC NSException during Compose recomposition. setupObservers(newPlayer, playerItem) @@ -733,8 +735,16 @@ open class DefaultVideoPlayerState( file: PlatformFile, initializeplayerState: InitialPlayerState, ) { - iosLogger.d { "openFile called with file: $file, initializeplayerState: $initializeplayerState" } - // Use the getUri extension function to get a proper file URL + iosLogger.d { "openFile called with file: $file" } + + // iOS requires security-scoped resource access for files picked via + // UIDocumentPickerViewController. Without this, AVPlayer cannot read the file. + val hasAccess = file.startAccessingSecurityScopedResource() + iosLogger.d { "Security-scoped access: $hasAccess" } + if (hasAccess) { + securityScopedFile = file + } + val fileUrl = file.getUri() iosLogger.d { "Opening file with URL: $fileUrl" } openUri(fileUrl, initializeplayerState) From 3ea6341fe931284c39ec97b73c68829e6c143d2b Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 11 Apr 2026 23:59:15 +0300 Subject: [PATCH 3/3] fix(sample): dismiss bottom sheet before launching file picker on iOS iOS cannot present a UIDocumentPickerViewController while a ModalBottomSheet is still visible (stacked modals not supported). Use a pendingPick flag + LaunchedEffect to launch the picker only after the sheet is fully dismissed. Also revert the unnecessary security-scoped resource access changes in VideoPlayerState. --- .../VideoPlayerState.ios.kt | 28 +------------------ .../kotlin/sample/app/player/PlayerScreen.kt | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index 74d49c4d..75992a8e 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -16,8 +16,6 @@ import io.github.kdroidfilter.composemediaplayer.util.PipResult import io.github.kdroidfilter.composemediaplayer.util.TaggedLogger import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.PlatformFile -import io.github.vinceglb.filekit.startAccessingSecurityScopedResource -import io.github.vinceglb.filekit.stopAccessingSecurityScopedResource import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.useContents @@ -172,8 +170,6 @@ open class DefaultVideoPlayerState( // Flag to track if the state has been disposed private var isDisposed = false - // Security-scoped file that needs to be released on cleanup - private var securityScopedFile: PlatformFile? = null init { if (cacheConfig.enabled) { @@ -522,9 +518,6 @@ open class DefaultVideoPlayerState( player?.replaceCurrentItemWithPlayerItem(null) player = null - // Release security-scoped resource access from file picker - securityScopedFile?.stopAccessingSecurityScopedResource() - securityScopedFile = null } /** @@ -550,17 +543,7 @@ open class DefaultVideoPlayerState( iosLogger.d { "Failed to create NSURL from uri: $uri" } return } - openNsUrl(nsUrl, initializeplayerState) - } - /** - * Core method to open media from an NSURL. - * Both [openUri] and [openFile] delegate to this. - */ - private fun openNsUrl( - nsUrl: NSURL, - initializeplayerState: InitialPlayerState, - ) { _error = null stopPositionUpdates() @@ -735,16 +718,7 @@ open class DefaultVideoPlayerState( file: PlatformFile, initializeplayerState: InitialPlayerState, ) { - iosLogger.d { "openFile called with file: $file" } - - // iOS requires security-scoped resource access for files picked via - // UIDocumentPickerViewController. Without this, AVPlayer cannot read the file. - val hasAccess = file.startAccessingSecurityScopedResource() - iosLogger.d { "Security-scoped access: $hasAccess" } - if (hasAccess) { - securityScopedFile = file - } - + iosLogger.d { "openFile called with file: $file, initializeplayerState: $initializeplayerState" } val fileUrl = file.getUri() iosLogger.d { "Opening file with URL: $fileUrl" } openUri(fileUrl, initializeplayerState) diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt index e94801cd..e75bf1c4 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/player/PlayerScreen.kt @@ -88,6 +88,12 @@ fun PlayerScreen(modifier: Modifier = Modifier) { var showSettingsSheet by remember { mutableStateOf(false) } var showSubtitleSheet by remember { mutableStateOf(false) } + // Flags to launch pickers after the bottom sheet is fully dismissed. + // On iOS, presenting a file picker while a ModalBottomSheet is still + // visible fails silently because iOS cannot stack two modals. + var pendingPickVideo by remember { mutableStateOf(false) } + var pendingPickSubtitle by remember { mutableStateOf(false) } + val videoFileLauncher = rememberFilePickerLauncher(type = FileKitType.Video) { file -> file?.let { playerState.openFile(it, initialPlayerState) } } @@ -102,6 +108,21 @@ fun PlayerScreen(modifier: Modifier = Modifier) { } } + // Launch pickers only after the sheet is gone + LaunchedEffect(pendingPickVideo, showSourceSheet) { + if (pendingPickVideo && !showSourceSheet) { + pendingPickVideo = false + videoFileLauncher.launch() + } + } + + LaunchedEffect(pendingPickSubtitle, showSubtitleSheet) { + if (pendingPickSubtitle && !showSubtitleSheet) { + pendingPickSubtitle = false + subtitleFileLauncher.launch() + } + } + // Example: detect when playback reaches the end playerState.onPlaybackEnded = { println("Playback ended") @@ -219,7 +240,7 @@ fun PlayerScreen(modifier: Modifier = Modifier) { showSourceSheet = false }, onPickFile = { - videoFileLauncher.launch() + pendingPickVideo = true showSourceSheet = false }, onSelectPreset = { url -> @@ -252,7 +273,10 @@ fun PlayerScreen(modifier: Modifier = Modifier) { selectedSubtitleTrack = null playerState.disableSubtitles() }, - onPickFile = { subtitleFileLauncher.launch() }, + onPickFile = { + pendingPickSubtitle = true + showSubtitleSheet = false + }, onAddTrack = { track -> subtitleTracks.add(track) selectedSubtitleTrack = track