mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +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
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
|
|||
)
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground()
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue