mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
fix: ios player viewport container issues on downloads
This commit is contained in:
parent
cbbe65aab3
commit
12232cebe9
8 changed files with 312 additions and 111 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
if (NSFileManager.defaultManager.fileExistsAtPath(path)) {
|
||||||
return removePathIfExists(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 sourcePath = didFinishDownloadingToURL.path
|
|
||||||
if (sourcePath.isNullOrBlank()) {
|
|
||||||
fileError = IllegalStateException("Downloaded file was not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
|
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
|
||||||
val stored = if (isPartialResume) {
|
startingBytesForResponse = if (isPartialResume) resumeFromBytes else 0L
|
||||||
appendFile(sourcePath, tempPath)
|
bytesWrittenForResponse = 0L
|
||||||
} else {
|
totalBytesForResponse = resolveTotalBytes(
|
||||||
removePathIfExists(tempPath) &&
|
startingBytes = startingBytesForResponse,
|
||||||
NSFileManager.defaultManager.moveItemAtPath(
|
isPartialResume = isPartialResume,
|
||||||
srcPath = sourcePath,
|
contentRangeHeader = nextResult.contentRange,
|
||||||
toPath = tempPath,
|
contentLength = nextResult.contentLength,
|
||||||
error = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
|
||||||
|
fileError = IllegalStateException("Failed to open partial download file")
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stored) {
|
reportProgress(startingBytesForResponse, totalBytesForResponse)
|
||||||
fileError = IllegalStateException("Failed to store download file")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completionHandler(1L)
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue