diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt index 502c14b1..52c7e112 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -168,6 +168,24 @@ internal actual object DownloadsPlatformDownloader { if (!tempFile.exists()) return true return runCatching { tempFile.delete() }.getOrDefault(false) } + + actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { + localFileUri + ?.toLocalFileOrNull() + ?.takeIf { it.exists() } + ?.let { return it.toURI().toString() } + + val context = appContext ?: return null + val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } + ?: localFileUri + ?.toLocalFileOrNull() + ?.name + ?.takeIf { it.isNotBlank() } + ?: return null + val downloadsDir = File(context.filesDir, "downloads") + val localFile = File(downloadsDir, fileName) + return localFile.takeIf { it.exists() }?.toURI()?.toString() + } } private class AndroidDownloadsTaskHandle( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f3095f59..f9e85f6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -596,7 +596,9 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true - val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } + val hasPlayableDownload = downloadsUiState.completedItems.any { + DownloadsRepository.playableLocalFileUri(it) != null + } if (hasPlayableDownload) { selectedTab = AppScreenTab.Settings navController.navigate(DownloadsSettingsRoute) { @@ -689,7 +691,7 @@ private fun MainAppContent( episodeNumber = episodeNumber, videoId = videoId, ) - val localSourceUrl = downloadedItem?.localFileUri + val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) if (!localSourceUrl.isNullOrBlank()) { val launchId = PlayerLaunchStore.put( PlayerLaunch( @@ -1533,7 +1535,7 @@ private fun MainAppContent( DownloadsScreen( onBack = onBack, onOpenDownload = { item -> - val sourceUrl = item.localFileUri ?: return@DownloadsScreen + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen val resumeEntry = item.videoId .takeIf { it.isNotBlank() } ?.let(WatchProgressRepository::progressForVideo) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt index b2a331ad..9fb32ced 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt @@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader { fun removeFile(localFileUri: String?): Boolean fun removePartialFile(destinationFileName: String): Boolean + + fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index f6e715ba..7ed74677 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -43,7 +43,7 @@ object DownloadsRepository { val normalizedVideoId = videoId?.trim().orEmpty() if (normalizedVideoId.isBlank()) return null return _uiState.value.items.firstOrNull { item -> - item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank() + item.videoId == normalizedVideoId && item.hasPlayableLocalFile() } } @@ -64,20 +64,42 @@ object DownloadsRepository { item.parentMetaId == normalizedParentMetaId && item.seasonNumber == seasonNumber && item.episodeNumber == episodeNumber && - item.isPlayable && - !item.localFileUri.isNullOrBlank() + item.hasPlayableLocalFile() } } else { items.firstOrNull { item -> item.parentMetaId == normalizedParentMetaId && item.seasonNumber == null && item.episodeNumber == null && - item.isPlayable && - !item.localFileUri.isNullOrBlank() + item.hasPlayableLocalFile() } } } + fun playableLocalFileUri(item: DownloadItem): String? { + ensureLoaded() + if (item.status != DownloadStatus.Completed) return null + val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri( + localFileUri = item.localFileUri, + destinationFileName = item.fileName, + ) ?: return null + + if (resolvedUri != item.localFileUri) { + mutateItem(item.id) { current -> + if (current.fileName == item.fileName) { + current.copy( + localFileUri = resolvedUri, + updatedAtEpochMs = DownloadsClock.nowEpochMs(), + ) + } else { + current + } + } + } + + return resolvedUri + } + fun enqueueFromStream( contentType: String, videoId: String, @@ -117,7 +139,7 @@ object DownloadsRepository { if (existing != null) { replacedExisting = true activeHandles.remove(existing.id)?.cancel() - DownloadsPlatformDownloader.removeFile(existing.localFileUri) + DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri) DownloadsPlatformDownloader.removePartialFile(existing.fileName) currentItems.removeAll { it.id == existing.id } } @@ -191,6 +213,14 @@ object DownloadsRepository { } } + fun pauseActiveDownloads() { + ensureLoaded() + _uiState.value.items + .filter { it.status == DownloadStatus.Downloading } + .map { it.id } + .forEach(::pauseDownload) + } + fun resumeDownload(downloadId: String) { ensureLoaded() val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return @@ -217,7 +247,7 @@ object DownloadsRepository { val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return activeHandles.remove(downloadId)?.cancel() - DownloadsPlatformDownloader.removeFile(item.localFileUri) + DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri) DownloadsPlatformDownloader.removePartialFile(item.fileName) publish(_uiState.value.items.filterNot { it.id == downloadId }) @@ -233,9 +263,10 @@ object DownloadsRepository { return } + var shouldPersistNormalized = false val normalized = DownloadsCodec.decodeItems(payload) .map { item -> - if (item.status == DownloadStatus.Downloading) { + val statusNormalized = if (item.status == DownloadStatus.Downloading) { item.copy( status = DownloadStatus.Paused, errorMessage = null, @@ -243,10 +274,19 @@ object DownloadsRepository { } else { item } + + val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized) + if (localUriNormalized != item) { + shouldPersistNormalized = true + } + localUriNormalized } _uiState.value = DownloadsUiState(normalized) notifyLiveStatusPlatform() + if (shouldPersistNormalized) { + persist() + } } private fun startDownload(item: DownloadItem) { @@ -359,6 +399,26 @@ object DownloadsRepository { append(nextDownloadOrdinal.toString(36)) } } + + private fun normalizeCompletedLocalFileUri(item: DownloadItem): DownloadItem { + if (item.status != DownloadStatus.Completed) return item + val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri( + localFileUri = item.localFileUri, + destinationFileName = item.fileName, + ) ?: return item + return if (resolvedUri != item.localFileUri) { + item.copy(localFileUri = resolvedUri) + } else { + item + } + } + + private fun DownloadItem.hasPlayableLocalFile(): Boolean = + status == DownloadStatus.Completed && + DownloadsPlatformDownloader.resolveLocalFileUri( + localFileUri = localFileUri, + destinationFileName = fileName, + ) != null } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index c3c5dd75..3dea8937 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -864,7 +864,7 @@ fun PlayerScreen( } fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { - val localFileUri = downloadItem.localFileUri ?: return + val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return showNextEpisodeCard = false showSourcesPanel = false showEpisodesPanel = false diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index 2ce2b26a..733bec21 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -1,9 +1,8 @@ package com.nuvio.app.features.downloads import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf +import kotlinx.cinterop.CPointer import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -13,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import platform.Foundation.NSError import platform.Foundation.NSDate +import platform.Foundation.NSData import platform.Foundation.NSFileManager import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSHomeDirectory @@ -23,16 +23,17 @@ import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData import platform.Foundation.NSURLResponse import platform.Foundation.NSURLSession import platform.Foundation.NSURLSessionConfiguration -import platform.Foundation.NSURLSessionDownloadDelegateProtocol -import platform.Foundation.NSURLSessionDownloadTask +import platform.Foundation.NSURLSessionDataDelegateProtocol +import platform.Foundation.NSURLSessionDataTask import platform.Foundation.NSURLSessionTask import platform.Foundation.setHTTPMethod import platform.Foundation.setValue import platform.Foundation.timeIntervalSince1970 import platform.darwin.NSObject -import platform.posix.fopen +import platform.posix.FILE import platform.posix.fclose -import platform.posix.fread +import platform.posix.fflush +import platform.posix.fopen import platform.posix.fwrite private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0 @@ -49,6 +50,10 @@ fun handleDownloadsBackgroundEvents( backgroundSessionCompletionHandlers[identifier] = completionHandler } +fun pauseDownloadsForAppBackground() { + DownloadsRepository.pauseActiveDownloads() +} + @OptIn(ExperimentalForeignApi::class) internal actual object DownloadsPlatformDownloader { actual fun start( @@ -132,22 +137,45 @@ internal actual object DownloadsPlatformDownloader { actual fun removeFile(localFileUri: String?): Boolean { if (localFileUri.isNullOrBlank()) return false val path = localFileUri.toLocalPath() ?: return false - return removePathIfExists(path) + if (NSFileManager.defaultManager.fileExistsAtPath(path)) { + return removePathIfExists(path) + } + + val fileName = path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: return false + return removePathIfExists("${downloadsDirectoryPath()}/$fileName") } actual fun removePartialFile(destinationFileName: String): Boolean { val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part" return removePathIfExists(tempPath) } + + actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { + localFileUri?.toLocalPath() + ?.takeIf { NSFileManager.defaultManager.fileExistsAtPath(it) } + ?.let { path -> + return NSURL.fileURLWithPath(path).absoluteString ?: "file://$path" + } + + val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } + ?: localFileUri?.toLocalPath()?.substringAfterLast('/')?.takeIf { it.isNotBlank() } + ?: return null + val currentPath = "${downloadsDirectoryPath()}/$fileName" + return if (NSFileManager.defaultManager.fileExistsAtPath(currentPath)) { + NSURL.fileURLWithPath(currentPath).absoluteString ?: "file://$currentPath" + } else { + null + } + } } private class IosDownloadsTaskHandle( private val job: Job, ) : DownloadsTaskHandle { - private var task: NSURLSessionDownloadTask? = null + private var task: NSURLSessionTask? = null private var session: NSURLSession? = null - fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) { + fun attach(task: NSURLSessionTask, session: NSURLSession) { this.task = task this.session = session } @@ -177,10 +205,14 @@ private class IosDownloadDelegate( private val resumeFromBytes: Long, private val tempPath: String, private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, -) : NSObject(), NSURLSessionDownloadDelegateProtocol { +) : NSObject(), NSURLSessionDataDelegateProtocol { private val completion = CompletableDeferred() private var result: IosDownloadResult? = null private var fileError: Throwable? = null + private var outputFile: CPointer? = null + private var startingBytesForResponse = 0L + private var bytesWrittenForResponse = 0L + private var totalBytesForResponse: Long? = null private var lastProgressBytes = -1L private var lastProgressTimestampSeconds = 0.0 @@ -188,12 +220,13 @@ private class IosDownloadDelegate( override fun URLSession( session: NSURLSession, - downloadTask: NSURLSessionDownloadTask, - didFinishDownloadingToURL: NSURL, + dataTask: NSURLSessionDataTask, + didReceiveResponse: NSURLResponse, + completionHandler: (Long) -> Unit, ) { - val httpResponse = downloadTask.response as? NSHTTPURLResponse + val httpResponse = didReceiveResponse as? NSHTTPURLResponse val statusCode = httpResponse?.statusCode?.toInt() ?: 200 - result = IosDownloadResult( + val nextResult = IosDownloadResult( statusCode = statusCode, contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), contentLength = httpResponse @@ -201,51 +234,59 @@ private class IosDownloadDelegate( ?.toLongOrNull() ?.takeIf { it > 0L }, ) + result = nextResult - if (statusCode !in 200..299) return + if (statusCode in 200..299) { + val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L + startingBytesForResponse = if (isPartialResume) resumeFromBytes else 0L + bytesWrittenForResponse = 0L + totalBytesForResponse = resolveTotalBytes( + startingBytes = startingBytesForResponse, + isPartialResume = isPartialResume, + contentRangeHeader = nextResult.contentRange, + contentLength = nextResult.contentLength, + ) - val sourcePath = didFinishDownloadingToURL.path - if (sourcePath.isNullOrBlank()) { - fileError = IllegalStateException("Downloaded file was not available") - return + outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run { + fileError = IllegalStateException("Failed to open partial download file") + null + } + + reportProgress(startingBytesForResponse, totalBytesForResponse) } - val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L - val stored = if (isPartialResume) { - appendFile(sourcePath, tempPath) - } else { - removePathIfExists(tempPath) && - NSFileManager.defaultManager.moveItemAtPath( - srcPath = sourcePath, - toPath = tempPath, - error = null, - ) - } - - if (!stored) { - fileError = IllegalStateException("Failed to store download file") - } + completionHandler(1L) } override fun URLSession( session: NSURLSession, - downloadTask: NSURLSessionDownloadTask, - didWriteData: Long, - totalBytesWritten: Long, - totalBytesExpectedToWrite: Long, + dataTask: NSURLSessionDataTask, + didReceiveData: NSData, ) { - val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt() - val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) { - resumeFromBytes - } else { - 0L + if (fileError != null) return + + val file = outputFile ?: run { + fileError = IllegalStateException("Partial download file is not open") + return } - val expectedTotal = totalBytesExpectedToWrite - .takeIf { it > 0L } - ?.let { startingBytes + it } + + val bytesToWrite = didReceiveData.length.toLong() + val wrote = fwrite( + didReceiveData.bytes, + 1.convert(), + bytesToWrite.convert(), + file, + ).toLong() + if (wrote != bytesToWrite) { + fileError = IllegalStateException("Failed to write partial download file") + return + } + fflush(file) + + bytesWrittenForResponse += bytesToWrite reportProgress( - downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L), - totalBytes = expectedTotal, + downloadedBytes = startingBytesForResponse + bytesWrittenForResponse, + totalBytes = totalBytesForResponse, ) } @@ -254,6 +295,8 @@ private class IosDownloadDelegate( task: NSURLSessionTask, didCompleteWithError: NSError?, ) { + closeOutputFile() + if (didCompleteWithError != null) { completion.completeExceptionally( IllegalStateException(didCompleteWithError.localizedDescription), @@ -275,6 +318,14 @@ private class IosDownloadDelegate( backgroundSessionCompletionHandlers.remove(identifier)?.invoke() } + private fun closeOutputFile() { + outputFile?.let { file -> + fflush(file) + fclose(file) + } + outputFile = null + } + private fun reportProgress( downloadedBytes: Long, totalBytes: Long?, @@ -374,9 +425,11 @@ private suspend fun performDownloadRequest( val session = NSURLSession.sessionWithConfiguration( configuration = configuration, delegate = delegate, - delegateQueue = NSOperationQueue(), + delegateQueue = NSOperationQueue().apply { + maxConcurrentOperationCount = 1 + }, ) - val task = session.downloadTaskWithRequest(nativeRequest) + val task = session.dataTaskWithRequest(nativeRequest) handle.attach(task, session) onProgress(resumeFromBytes.coerceAtLeast(0L), null) @@ -389,44 +442,6 @@ private suspend fun performDownloadRequest( } } -@OptIn(ExperimentalForeignApi::class) -private fun appendFile(sourcePath: String, destinationPath: String): Boolean { - val source = fopen(sourcePath, "rb") ?: return false - val destination = fopen(destinationPath, "ab") ?: run { - fclose(source) - return false - } - val buffer = ByteArray(16 * 1024) - - return try { - while (true) { - val read = buffer.usePinned { pinned -> - fread( - pinned.addressOf(0), - 1.convert(), - buffer.size.convert(), - source, - ).toInt() - } - if (read <= 0) break - - val wrote = buffer.usePinned { pinned -> - fwrite( - pinned.addressOf(0), - 1.convert(), - read.convert(), - destination, - ).toInt() - } - if (wrote != read) return false - } - true - } finally { - fclose(source) - fclose(destination) - } -} - @OptIn(ExperimentalForeignApi::class) private fun fileSizeOrNull(path: String): Long? { val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) @@ -439,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? { } private fun String.toLocalPath(): String? { - if (startsWith("file://")) { - return removePrefix("file://") + val value = trim() + if (value.startsWith("file:")) { + return NSURL(string = value).path ?: value.removePrefix("file://") } - return takeIf { it.isNotBlank() } + return value.takeIf { it.isNotBlank() } } private fun resolveTotalBytes( diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index cf78e051..26d80c43 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN ) } + func applicationDidEnterBackground(_ application: UIApplication) { + DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground() + } + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index ae08f457..9839d1f0 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -137,12 +137,22 @@ struct TrackInfo { let selected: Bool } +private struct PendingLoadRequest { + let urlString: String + let audioUrl: String? + let requestHeaders: [String: String] + let queuedAtUptime: TimeInterval +} + // MARK: - MPV Player View Controller final class MPVPlayerViewController: UIViewController { private let errorStateLock = NSLock() private var metalLayer = MetalLayer() + private var lastAppliedDrawableSize: CGSize = .zero + private var pendingLoadRequest: PendingLoadRequest? + private var pendingLoadRetryWorkItem: DispatchWorkItem? private var mpv: OpaquePointer? private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private var recentPlaybackLogs: [String] = [] @@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black + view.layer.masksToBounds = true - metalLayer.frame = view.bounds - metalLayer.contentsScale = UIScreen.main.nativeScale + metalLayer.contentsGravity = .resize + metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor view.layer.addSublayer(metalLayer) + layoutMetalLayer() setupMpv() setupNotifications() @@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - metalLayer.frame = view.bounds + layoutMetalLayer() + attemptStartPendingLoad() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) refreshImmersiveSystemUI() + attemptStartPendingLoad() } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() + layoutMetalLayer() refreshImmersiveSystemUI() + attemptStartPendingLoad() + } + + private func layoutMetalLayer() { + let bounds = view.bounds + guard bounds.width > 1, bounds.height > 1 else { return } + + let scale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale + let drawableSize = CGSize( + width: (bounds.width * scale).rounded(.toNearestOrAwayFromZero), + height: (bounds.height * scale).rounded(.toNearestOrAwayFromZero) + ) + + CATransaction.begin() + CATransaction.setDisableActions(true) + metalLayer.contentsScale = scale + metalLayer.frame = CGRect(origin: .zero, size: bounds.size) + if drawableSize != lastAppliedDrawableSize { + metalLayer.drawableSize = drawableSize + lastAppliedDrawableSize = drawableSize + } + CATransaction.commit() } // MARK: - MPV Setup @@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController { // MARK: - Playback API func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { + let request = PendingLoadRequest( + urlString: urlString, + audioUrl: audioUrl, + requestHeaders: requestHeaders, + queuedAtUptime: ProcessInfo.processInfo.systemUptime + ) + + if Thread.isMainThread { + queueLoad(request) + } else { + DispatchQueue.main.async { [weak self] in + self?.queueLoad(request) + } + } + } + + private func queueLoad(_ request: PendingLoadRequest) { + pendingLoadRequest = request + attemptStartPendingLoad() + } + + private func attemptStartPendingLoad() { + guard let request = pendingLoadRequest else { return } guard mpv != nil else { return } + layoutMetalLayer() + guard isViewportReadyForPlayback(queuedAtUptime: request.queuedAtUptime) else { + schedulePendingLoadRetry() + return + } + + pendingLoadRequest = nil + pendingLoadRetryWorkItem?.cancel() + pendingLoadRetryWorkItem = nil + startLoad(request) + } + + private func startLoad(_ request: PendingLoadRequest) { + guard mpv != nil else { return } + layoutMetalLayer() clearPlaybackError() - let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders) + let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders) activeRequestHeaders = sanitizedHeaders applyRequestHeaders(sanitizedHeaders) isPlayerLoading = true isPlayerEnded = false - command("loadfile", args: [urlString, "replace"]) - if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + command("loadfile", args: [request.urlString, "replace"]) + if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) } } } + private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool { + guard isViewLoaded, view.window != nil else { return false } + let bounds = view.bounds + guard bounds.width > 1, bounds.height > 1 else { return false } + if bounds.width >= bounds.height { return true } + + let age = ProcessInfo.processInfo.systemUptime - queuedAtUptime + return age >= 0.9 + } + + private func schedulePendingLoadRetry() { + guard pendingLoadRetryWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + self?.pendingLoadRetryWorkItem = nil + self?.attemptStartPendingLoad() + } + pendingLoadRetryWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) + } + func playPlayback() { guard mpv != nil else { return } setFlag("pause", false) @@ -350,8 +446,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "panscan", "1.0")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) case 2: // Zoom - checkError(mpv_set_option_string(mpv, "panscan", "0.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big")) + checkError(mpv_set_option_string(mpv, "panscan", "1.0")) + checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) default: // Fit checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) @@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController { func destroyPlayer() { NotificationCenter.default.removeObserver(self) + pendingLoadRetryWorkItem?.cancel() + pendingLoadRetryWorkItem = nil + pendingLoadRequest = nil clearPlaybackError() guard let ctx = mpv else { return } mpv = nil // nil first so event loop stops reading