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 if (!tempFile.exists()) return true
return runCatching { tempFile.delete() }.getOrDefault(false) 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( private class AndroidDownloadsTaskHandle(

View file

@ -596,7 +596,9 @@ private fun MainAppContent(
NetworkCondition.ServersUnreachable, NetworkCondition.ServersUnreachable,
-> { -> {
offlineLaunchRouteHandled = true offlineLaunchRouteHandled = true
val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } val hasPlayableDownload = downloadsUiState.completedItems.any {
DownloadsRepository.playableLocalFileUri(it) != null
}
if (hasPlayableDownload) { if (hasPlayableDownload) {
selectedTab = AppScreenTab.Settings selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) { navController.navigate(DownloadsSettingsRoute) {
@ -689,7 +691,7 @@ private fun MainAppContent(
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
videoId = videoId, videoId = videoId,
) )
val localSourceUrl = downloadedItem?.localFileUri val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) { if (!localSourceUrl.isNullOrBlank()) {
val launchId = PlayerLaunchStore.put( val launchId = PlayerLaunchStore.put(
PlayerLaunch( PlayerLaunch(
@ -1533,7 +1535,7 @@ private fun MainAppContent(
DownloadsScreen( DownloadsScreen(
onBack = onBack, onBack = onBack,
onOpenDownload = { item -> onOpenDownload = { item ->
val sourceUrl = item.localFileUri ?: return@DownloadsScreen val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
val resumeEntry = item.videoId val resumeEntry = item.videoId
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
?.let(WatchProgressRepository::progressForVideo) ?.let(WatchProgressRepository::progressForVideo)

View file

@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader {
fun removeFile(localFileUri: String?): Boolean fun removeFile(localFileUri: String?): Boolean
fun removePartialFile(destinationFileName: 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() val normalizedVideoId = videoId?.trim().orEmpty()
if (normalizedVideoId.isBlank()) return null if (normalizedVideoId.isBlank()) return null
return _uiState.value.items.firstOrNull { item -> 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.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == seasonNumber && item.seasonNumber == seasonNumber &&
item.episodeNumber == episodeNumber && item.episodeNumber == episodeNumber &&
item.isPlayable && item.hasPlayableLocalFile()
!item.localFileUri.isNullOrBlank()
} }
} else { } else {
items.firstOrNull { item -> items.firstOrNull { item ->
item.parentMetaId == normalizedParentMetaId && item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == null && item.seasonNumber == null &&
item.episodeNumber == null && item.episodeNumber == null &&
item.isPlayable && item.hasPlayableLocalFile()
!item.localFileUri.isNullOrBlank()
} }
} }
} }
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( fun enqueueFromStream(
contentType: String, contentType: String,
videoId: String, videoId: String,
@ -117,7 +139,7 @@ object DownloadsRepository {
if (existing != null) { if (existing != null) {
replacedExisting = true replacedExisting = true
activeHandles.remove(existing.id)?.cancel() activeHandles.remove(existing.id)?.cancel()
DownloadsPlatformDownloader.removeFile(existing.localFileUri) DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri)
DownloadsPlatformDownloader.removePartialFile(existing.fileName) DownloadsPlatformDownloader.removePartialFile(existing.fileName)
currentItems.removeAll { it.id == existing.id } 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) { fun resumeDownload(downloadId: String) {
ensureLoaded() ensureLoaded()
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return 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 val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
activeHandles.remove(downloadId)?.cancel() activeHandles.remove(downloadId)?.cancel()
DownloadsPlatformDownloader.removeFile(item.localFileUri) DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri)
DownloadsPlatformDownloader.removePartialFile(item.fileName) DownloadsPlatformDownloader.removePartialFile(item.fileName)
publish(_uiState.value.items.filterNot { it.id == downloadId }) publish(_uiState.value.items.filterNot { it.id == downloadId })
@ -233,9 +263,10 @@ object DownloadsRepository {
return return
} }
var shouldPersistNormalized = false
val normalized = DownloadsCodec.decodeItems(payload) val normalized = DownloadsCodec.decodeItems(payload)
.map { item -> .map { item ->
if (item.status == DownloadStatus.Downloading) { val statusNormalized = if (item.status == DownloadStatus.Downloading) {
item.copy( item.copy(
status = DownloadStatus.Paused, status = DownloadStatus.Paused,
errorMessage = null, errorMessage = null,
@ -243,10 +274,19 @@ object DownloadsRepository {
} else { } else {
item item
} }
val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized)
if (localUriNormalized != item) {
shouldPersistNormalized = true
}
localUriNormalized
} }
_uiState.value = DownloadsUiState(normalized) _uiState.value = DownloadsUiState(normalized)
notifyLiveStatusPlatform() notifyLiveStatusPlatform()
if (shouldPersistNormalized) {
persist()
}
} }
private fun startDownload(item: DownloadItem) { private fun startDownload(item: DownloadItem) {
@ -359,6 +399,26 @@ object DownloadsRepository {
append(nextDownloadOrdinal.toString(36)) 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 @Serializable

View file

@ -864,7 +864,7 @@ fun PlayerScreen(
} }
fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
val localFileUri = downloadItem.localFileUri ?: return val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return
showNextEpisodeCard = false showNextEpisodeCard = false
showSourcesPanel = false showSourcesPanel = false
showEpisodesPanel = false showEpisodesPanel = false

View file

@ -1,9 +1,8 @@
package com.nuvio.app.features.downloads package com.nuvio.app.features.downloads
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf import kotlinx.cinterop.CPointer
import kotlinx.cinterop.convert import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -13,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import platform.Foundation.NSError import platform.Foundation.NSError
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSData
import platform.Foundation.NSFileManager import platform.Foundation.NSFileManager
import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSHTTPURLResponse
import platform.Foundation.NSHomeDirectory import platform.Foundation.NSHomeDirectory
@ -23,16 +23,17 @@ import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData
import platform.Foundation.NSURLResponse import platform.Foundation.NSURLResponse
import platform.Foundation.NSURLSession import platform.Foundation.NSURLSession
import platform.Foundation.NSURLSessionConfiguration import platform.Foundation.NSURLSessionConfiguration
import platform.Foundation.NSURLSessionDownloadDelegateProtocol import platform.Foundation.NSURLSessionDataDelegateProtocol
import platform.Foundation.NSURLSessionDownloadTask import platform.Foundation.NSURLSessionDataTask
import platform.Foundation.NSURLSessionTask import platform.Foundation.NSURLSessionTask
import platform.Foundation.setHTTPMethod import platform.Foundation.setHTTPMethod
import platform.Foundation.setValue import platform.Foundation.setValue
import platform.Foundation.timeIntervalSince1970 import platform.Foundation.timeIntervalSince1970
import platform.darwin.NSObject import platform.darwin.NSObject
import platform.posix.fopen import platform.posix.FILE
import platform.posix.fclose import platform.posix.fclose
import platform.posix.fread import platform.posix.fflush
import platform.posix.fopen
import platform.posix.fwrite import platform.posix.fwrite
private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0 private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
@ -49,6 +50,10 @@ fun handleDownloadsBackgroundEvents(
backgroundSessionCompletionHandlers[identifier] = completionHandler backgroundSessionCompletionHandlers[identifier] = completionHandler
} }
fun pauseDownloadsForAppBackground() {
DownloadsRepository.pauseActiveDownloads()
}
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
internal actual object DownloadsPlatformDownloader { internal actual object DownloadsPlatformDownloader {
actual fun start( actual fun start(
@ -132,22 +137,45 @@ internal actual object DownloadsPlatformDownloader {
actual fun removeFile(localFileUri: String?): Boolean { actual fun removeFile(localFileUri: String?): Boolean {
if (localFileUri.isNullOrBlank()) return false if (localFileUri.isNullOrBlank()) return false
val path = localFileUri.toLocalPath() ?: 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 { actual fun removePartialFile(destinationFileName: String): Boolean {
val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part" val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part"
return removePathIfExists(tempPath) 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 class IosDownloadsTaskHandle(
private val job: Job, private val job: Job,
) : DownloadsTaskHandle { ) : DownloadsTaskHandle {
private var task: NSURLSessionDownloadTask? = null private var task: NSURLSessionTask? = null
private var session: NSURLSession? = null private var session: NSURLSession? = null
fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) { fun attach(task: NSURLSessionTask, session: NSURLSession) {
this.task = task this.task = task
this.session = session this.session = session
} }
@ -177,10 +205,14 @@ private class IosDownloadDelegate(
private val resumeFromBytes: Long, private val resumeFromBytes: Long,
private val tempPath: String, private val tempPath: String,
private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
) : NSObject(), NSURLSessionDownloadDelegateProtocol { ) : NSObject(), NSURLSessionDataDelegateProtocol {
private val completion = CompletableDeferred<IosDownloadResult>() private val completion = CompletableDeferred<IosDownloadResult>()
private var result: IosDownloadResult? = null private var result: IosDownloadResult? = null
private var fileError: Throwable? = 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 lastProgressBytes = -1L
private var lastProgressTimestampSeconds = 0.0 private var lastProgressTimestampSeconds = 0.0
@ -188,12 +220,13 @@ private class IosDownloadDelegate(
override fun URLSession( override fun URLSession(
session: NSURLSession, session: NSURLSession,
downloadTask: NSURLSessionDownloadTask, dataTask: NSURLSessionDataTask,
didFinishDownloadingToURL: NSURL, didReceiveResponse: NSURLResponse,
completionHandler: (Long) -> Unit,
) { ) {
val httpResponse = downloadTask.response as? NSHTTPURLResponse val httpResponse = didReceiveResponse as? NSHTTPURLResponse
val statusCode = httpResponse?.statusCode?.toInt() ?: 200 val statusCode = httpResponse?.statusCode?.toInt() ?: 200
result = IosDownloadResult( val nextResult = IosDownloadResult(
statusCode = statusCode, statusCode = statusCode,
contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"),
contentLength = httpResponse contentLength = httpResponse
@ -201,51 +234,59 @@ private class IosDownloadDelegate(
?.toLongOrNull() ?.toLongOrNull()
?.takeIf { it > 0L }, ?.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 outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
if (sourcePath.isNullOrBlank()) { fileError = IllegalStateException("Failed to open partial download file")
fileError = IllegalStateException("Downloaded file was not available") null
return }
reportProgress(startingBytesForResponse, totalBytesForResponse)
} }
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L completionHandler(1L)
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")
}
} }
override fun URLSession( override fun URLSession(
session: NSURLSession, session: NSURLSession,
downloadTask: NSURLSessionDownloadTask, dataTask: NSURLSessionDataTask,
didWriteData: Long, didReceiveData: NSData,
totalBytesWritten: Long,
totalBytesExpectedToWrite: Long,
) { ) {
val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt() if (fileError != null) return
val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) {
resumeFromBytes val file = outputFile ?: run {
} else { fileError = IllegalStateException("Partial download file is not open")
0L return
} }
val expectedTotal = totalBytesExpectedToWrite
.takeIf { it > 0L } val bytesToWrite = didReceiveData.length.toLong()
?.let { startingBytes + it } 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( reportProgress(
downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L), downloadedBytes = startingBytesForResponse + bytesWrittenForResponse,
totalBytes = expectedTotal, totalBytes = totalBytesForResponse,
) )
} }
@ -254,6 +295,8 @@ private class IosDownloadDelegate(
task: NSURLSessionTask, task: NSURLSessionTask,
didCompleteWithError: NSError?, didCompleteWithError: NSError?,
) { ) {
closeOutputFile()
if (didCompleteWithError != null) { if (didCompleteWithError != null) {
completion.completeExceptionally( completion.completeExceptionally(
IllegalStateException(didCompleteWithError.localizedDescription), IllegalStateException(didCompleteWithError.localizedDescription),
@ -275,6 +318,14 @@ private class IosDownloadDelegate(
backgroundSessionCompletionHandlers.remove(identifier)?.invoke() backgroundSessionCompletionHandlers.remove(identifier)?.invoke()
} }
private fun closeOutputFile() {
outputFile?.let { file ->
fflush(file)
fclose(file)
}
outputFile = null
}
private fun reportProgress( private fun reportProgress(
downloadedBytes: Long, downloadedBytes: Long,
totalBytes: Long?, totalBytes: Long?,
@ -374,9 +425,11 @@ private suspend fun performDownloadRequest(
val session = NSURLSession.sessionWithConfiguration( val session = NSURLSession.sessionWithConfiguration(
configuration = configuration, configuration = configuration,
delegate = delegate, delegate = delegate,
delegateQueue = NSOperationQueue(), delegateQueue = NSOperationQueue().apply {
maxConcurrentOperationCount = 1
},
) )
val task = session.downloadTaskWithRequest(nativeRequest) val task = session.dataTaskWithRequest(nativeRequest)
handle.attach(task, session) handle.attach(task, session)
onProgress(resumeFromBytes.coerceAtLeast(0L), null) 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) @OptIn(ExperimentalForeignApi::class)
private fun fileSizeOrNull(path: String): Long? { private fun fileSizeOrNull(path: String): Long? {
val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null)
@ -439,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? {
} }
private fun String.toLocalPath(): String? { private fun String.toLocalPath(): String? {
if (startsWith("file://")) { val value = trim()
return removePrefix("file://") if (value.startsWith("file:")) {
return NSURL(string = value).path ?: value.removePrefix("file://")
} }
return takeIf { it.isNotBlank() } return value.takeIf { it.isNotBlank() }
} }
private fun resolveTotalBytes( private fun resolveTotalBytes(

View file

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

View file

@ -137,12 +137,22 @@ struct TrackInfo {
let selected: Bool let selected: Bool
} }
private struct PendingLoadRequest {
let urlString: String
let audioUrl: String?
let requestHeaders: [String: String]
let queuedAtUptime: TimeInterval
}
// MARK: - MPV Player View Controller // MARK: - MPV Player View Controller
final class MPVPlayerViewController: UIViewController { final class MPVPlayerViewController: UIViewController {
private let errorStateLock = NSLock() private let errorStateLock = NSLock()
private var metalLayer = MetalLayer() private var metalLayer = MetalLayer()
private var lastAppliedDrawableSize: CGSize = .zero
private var pendingLoadRequest: PendingLoadRequest?
private var pendingLoadRetryWorkItem: DispatchWorkItem?
private var mpv: OpaquePointer? private var mpv: OpaquePointer?
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
private var recentPlaybackLogs: [String] = [] private var recentPlaybackLogs: [String] = []
@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .black view.backgroundColor = .black
view.layer.masksToBounds = true
metalLayer.frame = view.bounds metalLayer.contentsGravity = .resize
metalLayer.contentsScale = UIScreen.main.nativeScale metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
metalLayer.framebufferOnly = true metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor metalLayer.backgroundColor = UIColor.black.cgColor
view.layer.addSublayer(metalLayer) view.layer.addSublayer(metalLayer)
layoutMetalLayer()
setupMpv() setupMpv()
setupNotifications() setupNotifications()
@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController {
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
metalLayer.frame = view.bounds layoutMetalLayer()
attemptStartPendingLoad()
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
refreshImmersiveSystemUI() refreshImmersiveSystemUI()
attemptStartPendingLoad()
} }
override func viewSafeAreaInsetsDidChange() { override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange() super.viewSafeAreaInsetsDidChange()
layoutMetalLayer()
refreshImmersiveSystemUI() 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 // MARK: - MPV Setup
@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController {
// MARK: - Playback API // MARK: - Playback API
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { 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 } 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() clearPlaybackError()
let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders) let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders)
activeRequestHeaders = sanitizedHeaders activeRequestHeaders = sanitizedHeaders
applyRequestHeaders(sanitizedHeaders) applyRequestHeaders(sanitizedHeaders)
isPlayerLoading = true isPlayerLoading = true
isPlayerEnded = false isPlayerEnded = false
command("loadfile", args: [urlString, "replace"]) command("loadfile", args: [request.urlString, "replace"])
if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) 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() { func playPlayback() {
guard mpv != nil else { return } guard mpv != nil else { return }
setFlag("pause", false) 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, "panscan", "1.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
case 2: // Zoom case 2: // Zoom
checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "panscan", "1.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
default: // Fit default: // Fit
checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController {
func destroyPlayer() { func destroyPlayer() {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
pendingLoadRetryWorkItem?.cancel()
pendingLoadRetryWorkItem = nil
pendingLoadRequest = nil
clearPlaybackError() clearPlaybackError()
guard let ctx = mpv else { return } guard let ctx = mpv else { return }
mpv = nil // nil first so event loop stops reading mpv = nil // nil first so event loop stops reading