fix: ios player viewport container issues on downloads

This commit is contained in:
tapframe 2026-05-01 02:59:35 +05:30
parent cbbe65aab3
commit 12232cebe9
8 changed files with 312 additions and 111 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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?
}

View file

@ -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

View file

@ -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

View file

@ -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<IosDownloadResult>()
private var result: IosDownloadResult? = null
private var fileError: Throwable? = null
private var outputFile: CPointer<FILE>? = 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(

View file

@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
)
}
func applicationDidEnterBackground(_ application: UIApplication) {
DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground()
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,

View file

@ -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