mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 09:09:29 +00:00
Merge branch 'downloads' into cmp-rewrite
This commit is contained in:
commit
b6a4b72381
36 changed files with 2838 additions and 70 deletions
|
|
@ -1,20 +1,24 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory
|
||||
|
||||
internal object PlatformPlaybackDataSourceFactory {
|
||||
fun create(
|
||||
context: Context,
|
||||
defaultRequestHeaders: Map<String, String>,
|
||||
defaultResponseHeaders: Map<String, String>,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
): DataSource.Factory {
|
||||
val baseFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) {
|
||||
val networkFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) {
|
||||
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders)
|
||||
} else {
|
||||
DefaultHttpDataSource.Factory().setDefaultRequestProperties(defaultRequestHeaders)
|
||||
}
|
||||
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, networkFactory)
|
||||
return if (defaultResponseHeaders.isEmpty()) {
|
||||
baseFactory
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@
|
|||
android:path="/trakt" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".features.downloads.DownloadsNotificationActionReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import com.nuvio.app.core.deeplink.handleAppUrl
|
|||
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
||||
import com.nuvio.app.features.addons.AddonStorage
|
||||
import com.nuvio.app.features.collection.CollectionStorage
|
||||
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
||||
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
||||
import com.nuvio.app.features.downloads.DownloadsStorage
|
||||
import com.nuvio.app.features.library.LibraryStorage
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
|
||||
|
|
@ -69,6 +72,9 @@ class MainActivity : ComponentActivity() {
|
|||
StreamLinkCacheStorage.initialize(applicationContext)
|
||||
PluginStorage.initialize(applicationContext)
|
||||
CollectionStorage.initialize(applicationContext)
|
||||
DownloadsStorage.initialize(applicationContext)
|
||||
DownloadsPlatformDownloader.initialize(applicationContext)
|
||||
DownloadsLiveStatusPlatform.initialize(applicationContext)
|
||||
PlatformLocalAccountDataCleaner.initialize(applicationContext)
|
||||
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
|
||||
EpisodeReleaseNotificationPlatform.bindActivity(this)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
internal actual object DownloadsClock {
|
||||
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl
|
||||
import kotlin.math.abs
|
||||
|
||||
internal actual object DownloadsLiveStatusPlatform {
|
||||
private const val channelId = "downloads_live_status"
|
||||
private const val channelName = "Downloads"
|
||||
private const val channelDescription = "Shows live download progress and controls."
|
||||
private const val notificationsPrefName = "nuvio_download_live_notifications"
|
||||
private const val trackedDownloadIdsKey = "tracked_download_ids"
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val lastRenderStateById = mutableMapOf<String, RenderState>()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
ensureNotificationChannel()
|
||||
}
|
||||
|
||||
actual fun onItemsChanged(items: List<DownloadItem>) {
|
||||
val context = appContext ?: return
|
||||
if (!canPostNotifications(context)) return
|
||||
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
val trackedBefore = preferences(context)
|
||||
.getStringSet(trackedDownloadIdsKey, emptySet())
|
||||
.orEmpty()
|
||||
.toMutableSet()
|
||||
|
||||
val activeItems = items.filter { item ->
|
||||
item.status == DownloadStatus.Downloading ||
|
||||
item.status == DownloadStatus.Paused ||
|
||||
item.status == DownloadStatus.Failed
|
||||
}
|
||||
|
||||
val trackedNow = mutableSetOf<String>()
|
||||
activeItems.forEach { item ->
|
||||
val renderState = RenderState(
|
||||
status = item.status,
|
||||
progressPercent = progressPercent(item),
|
||||
downloadedBucket = item.downloadedBytes / (512L * 1024L),
|
||||
totalBytes = item.totalBytes,
|
||||
errorMessage = item.errorMessage,
|
||||
)
|
||||
|
||||
val existingState = lastRenderStateById[item.id]
|
||||
if (existingState == renderState) {
|
||||
trackedNow += item.id
|
||||
return@forEach
|
||||
}
|
||||
|
||||
manager.notify(notificationId(item.id), buildNotification(context, item))
|
||||
lastRenderStateById[item.id] = renderState
|
||||
trackedNow += item.id
|
||||
}
|
||||
|
||||
val staleIds = trackedBefore - trackedNow
|
||||
staleIds.forEach { downloadId ->
|
||||
manager.cancel(notificationId(downloadId))
|
||||
lastRenderStateById.remove(downloadId)
|
||||
}
|
||||
|
||||
preferences(context)
|
||||
.edit()
|
||||
.putStringSet(trackedDownloadIdsKey, trackedNow)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun buildNotification(context: Context, item: DownloadItem): android.app.Notification {
|
||||
val subtitle = buildSubtitle(item)
|
||||
val launchIntent = Intent(context, com.nuvio.app.MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = android.net.Uri.parse(buildDownloadsDeepLinkUrl())
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val launchPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId(item.id),
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(com.nuvio.app.R.drawable.ic_notification_small)
|
||||
.setContentTitle(item.title)
|
||||
.setContentText(subtitle)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(subtitle))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(launchPendingIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
|
||||
when (item.status) {
|
||||
DownloadStatus.Downloading -> {
|
||||
notificationBuilder
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.addAction(
|
||||
0,
|
||||
"Pause",
|
||||
buildActionPendingIntent(
|
||||
context = context,
|
||||
action = DownloadsNotificationActionReceiver.actionPause,
|
||||
downloadId = item.id,
|
||||
),
|
||||
)
|
||||
|
||||
val progress = progressPercent(item)
|
||||
if (progress >= 0) {
|
||||
notificationBuilder.setProgress(100, progress, false)
|
||||
} else {
|
||||
notificationBuilder.setProgress(100, 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
DownloadStatus.Paused,
|
||||
DownloadStatus.Failed,
|
||||
DownloadStatus.Completed,
|
||||
-> {
|
||||
notificationBuilder
|
||||
.setOngoing(false)
|
||||
.setAutoCancel(false)
|
||||
.setPriority(
|
||||
if (item.status == DownloadStatus.Failed) {
|
||||
NotificationCompat.PRIORITY_DEFAULT
|
||||
} else {
|
||||
NotificationCompat.PRIORITY_LOW
|
||||
},
|
||||
)
|
||||
.setProgress(0, 0, false)
|
||||
.addAction(
|
||||
0,
|
||||
"Resume",
|
||||
buildActionPendingIntent(
|
||||
context = context,
|
||||
action = DownloadsNotificationActionReceiver.actionResume,
|
||||
downloadId = item.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return notificationBuilder.build()
|
||||
}
|
||||
|
||||
private fun buildSubtitle(item: DownloadItem): String {
|
||||
val detail = item.displaySubtitle
|
||||
return when (item.status) {
|
||||
DownloadStatus.Downloading -> {
|
||||
val downloaded = formatBytes(item.downloadedBytes)
|
||||
val total = item.totalBytes?.let(::formatBytes)
|
||||
if (total != null) {
|
||||
"Downloading $detail • $downloaded / $total"
|
||||
} else {
|
||||
"Downloading $detail • $downloaded"
|
||||
}
|
||||
}
|
||||
|
||||
DownloadStatus.Paused -> "Paused $detail"
|
||||
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed"
|
||||
DownloadStatus.Completed -> "Download completed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
val safe = bytes.coerceAtLeast(0L).toDouble()
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
var value = safe
|
||||
var unitIndex = 0
|
||||
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||
value /= 1024.0
|
||||
unitIndex += 1
|
||||
}
|
||||
return if (unitIndex == 0) {
|
||||
"${value.toLong()} ${units[unitIndex]}"
|
||||
} else {
|
||||
"${"%.1f".format(value)} ${units[unitIndex]}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressPercent(item: DownloadItem): Int {
|
||||
val total = item.totalBytes?.takeIf { it > 0L } ?: return -1
|
||||
return ((item.downloadedBytes.toDouble() / total.toDouble()) * 100.0)
|
||||
.toInt()
|
||||
.coerceIn(0, 100)
|
||||
}
|
||||
|
||||
private fun buildActionPendingIntent(
|
||||
context: Context,
|
||||
action: String,
|
||||
downloadId: String,
|
||||
): PendingIntent {
|
||||
val intent = Intent(context, DownloadsNotificationActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(DownloadsNotificationActionReceiver.extraDownloadId, downloadId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId("$action:$downloadId"),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
val context = appContext ?: return
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return
|
||||
if (manager.getNotificationChannel(channelId) != null) return
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = channelDescription
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun canPostNotifications(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permissionState = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
if (permissionState != PackageManager.PERMISSION_GRANTED) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
private fun preferences(context: Context) =
|
||||
context.getSharedPreferences(notificationsPrefName, Context.MODE_PRIVATE)
|
||||
|
||||
private fun notificationId(downloadId: String): Int = abs(downloadId.hashCode())
|
||||
|
||||
private data class RenderState(
|
||||
val status: DownloadStatus,
|
||||
val progressPercent: Int,
|
||||
val downloadedBucket: Long,
|
||||
val totalBytes: Long?,
|
||||
val errorMessage: String?,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class DownloadsNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
val downloadId = intent.getStringExtra(extraDownloadId)?.trim().orEmpty()
|
||||
if (downloadId.isBlank()) return
|
||||
|
||||
DownloadsStorage.initialize(context.applicationContext)
|
||||
DownloadsPlatformDownloader.initialize(context.applicationContext)
|
||||
DownloadsLiveStatusPlatform.initialize(context.applicationContext)
|
||||
DownloadsRepository.ensureLoaded()
|
||||
|
||||
when (action) {
|
||||
actionPause -> DownloadsRepository.pauseDownload(downloadId)
|
||||
actionResume -> DownloadsRepository.resumeDownload(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val actionPause = "com.nuvio.app.downloads.action.PAUSE"
|
||||
const val actionResume = "com.nuvio.app.downloads.action.RESUME"
|
||||
const val extraDownloadId = "download_id"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URI
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val downloadHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
|
||||
internal actual object DownloadsPlatformDownloader {
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
actual fun start(
|
||||
request: DownloadPlatformRequest,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit,
|
||||
onFailure: (message: String) -> Unit,
|
||||
): DownloadsTaskHandle {
|
||||
val job = SupervisorJob()
|
||||
val scope = CoroutineScope(job + Dispatchers.IO)
|
||||
var call: Call? = null
|
||||
|
||||
scope.launch {
|
||||
val context = appContext
|
||||
if (context == null) {
|
||||
onFailure("Download system is not initialized")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val downloadsDir = File(context.filesDir, "downloads").apply { mkdirs() }
|
||||
val destination = File(downloadsDir, request.destinationFileName)
|
||||
val tempFile = File(downloadsDir, "${request.destinationFileName}.part")
|
||||
if (tempFile.exists()) tempFile.delete()
|
||||
|
||||
val requestBuilder = Request.Builder().url(request.sourceUrl)
|
||||
request.sourceHeaders.forEach { (key, value) ->
|
||||
requestBuilder.header(key, value)
|
||||
}
|
||||
val httpRequest = requestBuilder.get().build()
|
||||
call = downloadHttpClient.newCall(httpRequest)
|
||||
|
||||
try {
|
||||
call?.execute()?.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Request failed with HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body ?: error("Empty response body")
|
||||
val totalBytes = body.contentLength().takeIf { it > 0L }
|
||||
var downloadedBytes = 0L
|
||||
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(tempFile, false).use { output ->
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read.toLong()
|
||||
onProgress(downloadedBytes, totalBytes)
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
if (destination.exists()) {
|
||||
destination.delete()
|
||||
}
|
||||
if (!tempFile.renameTo(destination)) {
|
||||
tempFile.copyTo(destination, overwrite = true)
|
||||
tempFile.delete()
|
||||
}
|
||||
|
||||
val finalSize = destination.length()
|
||||
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
tempFile.delete()
|
||||
} catch (error: Throwable) {
|
||||
tempFile.delete()
|
||||
onFailure(error.message ?: "Download failed")
|
||||
}
|
||||
}
|
||||
|
||||
job.invokeOnCompletion {
|
||||
call?.cancel()
|
||||
}
|
||||
|
||||
return AndroidDownloadsTaskHandle(job)
|
||||
}
|
||||
|
||||
actual fun removeFile(localFileUri: String?): Boolean {
|
||||
if (localFileUri.isNullOrBlank()) return false
|
||||
val file = localFileUri.toLocalFileOrNull() ?: return false
|
||||
return runCatching { file.delete() }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidDownloadsTaskHandle(
|
||||
private val job: Job,
|
||||
) : DownloadsTaskHandle {
|
||||
override fun cancel() {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toLocalFileOrNull(): File? {
|
||||
return runCatching {
|
||||
if (startsWith("file:")) {
|
||||
File(URI(this))
|
||||
} else {
|
||||
File(this)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object DownloadsStorage {
|
||||
private const val preferencesName = "nuvio_downloads"
|
||||
private const val payloadKey = "downloads_payload"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(payloadKey), payload)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +128,7 @@ actual fun PlatformPlayerSurface(
|
|||
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
|
||||
|
||||
val dataSourceFactory = PlatformPlaybackDataSourceFactory.create(
|
||||
context = context,
|
||||
defaultRequestHeaders = sanitizedSourceHeaders,
|
||||
defaultResponseHeaders = sanitizedSourceResponseHeaders,
|
||||
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
|
||||
internal object PlatformPlaybackDataSourceFactory {
|
||||
fun create(
|
||||
context: Context,
|
||||
defaultRequestHeaders: Map<String, String>,
|
||||
defaultResponseHeaders: Map<String, String>,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
): DataSource.Factory {
|
||||
val baseFactory = DefaultHttpDataSource.Factory()
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setDefaultRequestProperties(defaultRequestHeaders)
|
||||
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpFactory)
|
||||
return if (defaultResponseHeaders.isEmpty()) {
|
||||
baseFactory
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ import com.nuvio.app.features.auth.AuthScreen
|
|||
import com.nuvio.app.features.catalog.CatalogRepository
|
||||
import com.nuvio.app.features.catalog.CatalogScreen
|
||||
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||
import com.nuvio.app.features.downloads.DownloadsScreen
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsScreen
|
||||
import com.nuvio.app.features.details.MetaPerson
|
||||
|
|
@ -192,6 +194,9 @@ object MetaScreenSettingsRoute
|
|||
@Serializable
|
||||
object ContinueWatchingSettingsRoute
|
||||
|
||||
@Serializable
|
||||
object DownloadsSettingsRoute
|
||||
|
||||
@Serializable
|
||||
object AddonsSettingsRoute
|
||||
|
||||
|
|
@ -486,61 +491,150 @@ private fun MainAppContent(
|
|||
AppDeepLinkRepository.markConsumed(deepLink)
|
||||
}
|
||||
|
||||
AppDeepLink.Downloads -> {
|
||||
selectedTab = AppScreenTab.Settings
|
||||
navController.navigate(DownloadsSettingsRoute) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
AppDeepLinkRepository.markConsumed(deepLink)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun launchPlaybackWithDownloadPreference(
|
||||
type: String,
|
||||
videoId: String,
|
||||
parentMetaId: String,
|
||||
parentMetaType: String,
|
||||
title: String,
|
||||
logo: String?,
|
||||
poster: String?,
|
||||
background: String?,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
episodeThumbnail: String?,
|
||||
pauseDescription: String?,
|
||||
resumePositionMs: Long?,
|
||||
resumeProgressFraction: Float?,
|
||||
manualSelection: Boolean,
|
||||
startFromBeginning: Boolean,
|
||||
) {
|
||||
val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L)
|
||||
val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction
|
||||
|
||||
if (!manualSelection) {
|
||||
val downloadedItem = DownloadsRepository.findPlayableDownload(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
videoId = videoId,
|
||||
)
|
||||
val localSourceUrl = downloadedItem?.localFileUri
|
||||
if (!localSourceUrl.isNullOrBlank()) {
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
title = title,
|
||||
sourceUrl = localSourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
streamTitle = downloadedItem.streamTitle.ifBlank { title },
|
||||
streamSubtitle = downloadedItem.streamSubtitle,
|
||||
pauseDescription = pauseDescription,
|
||||
providerName = downloadedItem.providerName.ifBlank { "Downloaded" },
|
||||
providerAddonId = downloadedItem.providerAddonId,
|
||||
contentType = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
initialPositionMs = targetResumePositionMs,
|
||||
initialProgressFraction = targetResumeProgressFraction,
|
||||
),
|
||||
)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val streamContextId = pauseDescription
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
|
||||
navController.navigate(
|
||||
StreamRoute(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
streamContextId = streamContextId,
|
||||
resumePositionMs = if (startFromBeginning) 0L else resumePositionMs,
|
||||
resumeProgressFraction = targetResumeProgressFraction,
|
||||
manualSelection = manualSelection,
|
||||
startFromBeginning = startFromBeginning,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
|
||||
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
|
||||
val streamContextId = pauseDescription
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
|
||||
navController.navigate(
|
||||
StreamRoute(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
streamContextId = streamContextId,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = null,
|
||||
)
|
||||
launchPlaybackWithDownloadPreference(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
pauseDescription = pauseDescription,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = null,
|
||||
manualSelection = false,
|
||||
startFromBeginning = false,
|
||||
)
|
||||
}
|
||||
|
||||
val onPlayManually: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
|
||||
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
|
||||
val streamContextId = pauseDescription
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
|
||||
navController.navigate(
|
||||
StreamRoute(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
streamContextId = streamContextId,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = null,
|
||||
manualSelection = true,
|
||||
)
|
||||
launchPlaybackWithDownloadPreference(
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
pauseDescription = pauseDescription,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = null,
|
||||
manualSelection = true,
|
||||
startFromBeginning = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -575,29 +669,24 @@ private fun MainAppContent(
|
|||
}
|
||||
|
||||
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
|
||||
val streamContextId = item.pauseDescription
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
|
||||
navController.navigate(
|
||||
StreamRoute(
|
||||
type = item.parentMetaType,
|
||||
videoId = item.videoId,
|
||||
parentMetaId = item.parentMetaId,
|
||||
parentMetaType = item.parentMetaType,
|
||||
title = item.title,
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
seasonNumber = item.seasonNumber,
|
||||
episodeNumber = item.episodeNumber,
|
||||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
streamContextId = streamContextId,
|
||||
resumePositionMs = if (startFromBeginning) 0L else item.resumePositionMs,
|
||||
resumeProgressFraction = if (startFromBeginning) null else item.resumeProgressFraction,
|
||||
manualSelection = manualSelection,
|
||||
startFromBeginning = startFromBeginning,
|
||||
),
|
||||
launchPlaybackWithDownloadPreference(
|
||||
type = item.parentMetaType,
|
||||
videoId = item.videoId,
|
||||
parentMetaId = item.parentMetaId,
|
||||
parentMetaType = item.parentMetaType,
|
||||
title = item.title,
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
seasonNumber = item.seasonNumber,
|
||||
episodeNumber = item.episodeNumber,
|
||||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
pauseDescription = item.pauseDescription,
|
||||
resumePositionMs = item.resumePositionMs,
|
||||
resumeProgressFraction = item.resumeProgressFraction,
|
||||
manualSelection = manualSelection,
|
||||
startFromBeginning = startFromBeginning,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -710,6 +799,7 @@ private fun MainAppContent(
|
|||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||
onPluginsSettingsClick = {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
|
|
@ -1236,6 +1326,49 @@ private fun MainAppContent(
|
|||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
composable<DownloadsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
navController = navController,
|
||||
backStackEntry = backStackEntry,
|
||||
)
|
||||
DownloadsScreen(
|
||||
onBack = onBack,
|
||||
onOpenDownload = { item ->
|
||||
val sourceUrl = item.localFileUri ?: return@DownloadsScreen
|
||||
val resumeEntry = item.videoId
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(WatchProgressRepository::progressForVideo)
|
||||
?.takeIf { it.isResumable }
|
||||
|
||||
val launchId = PlayerLaunchStore.put(
|
||||
PlayerLaunch(
|
||||
title = item.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
seasonNumber = item.seasonNumber,
|
||||
episodeNumber = item.episodeNumber,
|
||||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
streamTitle = item.streamTitle,
|
||||
streamSubtitle = item.streamSubtitle,
|
||||
providerName = item.providerName,
|
||||
providerAddonId = item.providerAddonId,
|
||||
contentType = item.contentType,
|
||||
videoId = item.videoId,
|
||||
parentMetaId = item.parentMetaId,
|
||||
parentMetaType = item.parentMetaType,
|
||||
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
|
||||
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
|
||||
),
|
||||
)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<AddonsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
navController = navController,
|
||||
|
|
@ -1511,6 +1644,7 @@ private fun AppTabHost(
|
|||
onHomescreenSettingsClick: () -> Unit = {},
|
||||
onMetaScreenSettingsClick: () -> Unit = {},
|
||||
onContinueWatchingSettingsClick: () -> Unit = {},
|
||||
onDownloadsSettingsClick: () -> Unit = {},
|
||||
onAddonsSettingsClick: () -> Unit = {},
|
||||
onPluginsSettingsClick: () -> Unit = {},
|
||||
onAccountSettingsClick: () -> Unit = {},
|
||||
|
|
@ -1559,6 +1693,7 @@ private fun AppTabHost(
|
|||
onHomescreenClick = onHomescreenSettingsClick,
|
||||
onMetaScreenClick = onMetaScreenSettingsClick,
|
||||
onContinueWatchingClick = onContinueWatchingSettingsClick,
|
||||
onDownloadsClick = onDownloadsSettingsClick,
|
||||
onAddonsClick = onAddonsSettingsClick,
|
||||
onPluginsClick = onPluginsSettingsClick,
|
||||
onAccountClick = onAccountSettingsClick,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ sealed interface AppDeepLink {
|
|||
val type: String,
|
||||
val id: String,
|
||||
) : AppDeepLink
|
||||
|
||||
data object Downloads : AppDeepLink
|
||||
}
|
||||
|
||||
object AppDeepLinkRepository {
|
||||
|
|
@ -49,6 +51,8 @@ fun buildMetaDeepLinkUrl(
|
|||
append(id.trim().encodeURLParameter())
|
||||
}
|
||||
|
||||
fun buildDownloadsDeepLinkUrl(): String = "nuvio://downloads"
|
||||
|
||||
private fun parseAppDeepLink(url: String): AppDeepLink? {
|
||||
val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null
|
||||
if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null
|
||||
|
|
@ -60,6 +64,8 @@ private fun parseAppDeepLink(url: String): AppDeepLink? {
|
|||
if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
|
||||
}
|
||||
|
||||
"downloads" -> AppDeepLink.Downloads
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
internal expect object DownloadsClock {
|
||||
fun nowEpochMs(): Long
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
internal expect object DownloadsLiveStatusPlatform {
|
||||
fun onItemsChanged(items: List<DownloadItem>)
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class DownloadStatus {
|
||||
Downloading,
|
||||
Paused,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val contentType: String,
|
||||
val parentMetaId: String,
|
||||
val parentMetaType: String,
|
||||
val videoId: String,
|
||||
val title: String,
|
||||
val logo: String? = null,
|
||||
val poster: String? = null,
|
||||
val background: String? = null,
|
||||
val seasonNumber: Int? = null,
|
||||
val episodeNumber: Int? = null,
|
||||
val episodeTitle: String? = null,
|
||||
val episodeThumbnail: String? = null,
|
||||
val streamTitle: String,
|
||||
val streamSubtitle: String? = null,
|
||||
val providerName: String,
|
||||
val providerAddonId: String? = null,
|
||||
val sourceUrl: String,
|
||||
val sourceHeaders: Map<String, String> = emptyMap(),
|
||||
val sourceResponseHeaders: Map<String, String> = emptyMap(),
|
||||
val localFileUri: String? = null,
|
||||
val fileName: String,
|
||||
val status: DownloadStatus,
|
||||
val downloadedBytes: Long = 0L,
|
||||
val totalBytes: Long? = null,
|
||||
val errorMessage: String? = null,
|
||||
val createdAtEpochMs: Long,
|
||||
val updatedAtEpochMs: Long,
|
||||
) {
|
||||
val isEpisode: Boolean
|
||||
get() = seasonNumber != null && episodeNumber != null
|
||||
|
||||
val isPlayable: Boolean
|
||||
get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank()
|
||||
|
||||
val displaySubtitle: String
|
||||
get() = if (isEpisode) {
|
||||
buildString {
|
||||
append("S")
|
||||
append(seasonNumber)
|
||||
append("E")
|
||||
append(episodeNumber)
|
||||
episodeTitle
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
append(" • ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Movie"
|
||||
}
|
||||
|
||||
val progressFraction: Float
|
||||
get() {
|
||||
val total = totalBytes?.takeIf { it > 0L } ?: return 0f
|
||||
return (downloadedBytes.toDouble() / total.toDouble())
|
||||
.toFloat()
|
||||
.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
val logicalContentKey: String
|
||||
get() = if (isEpisode) {
|
||||
"${parentMetaId.trim()}|${seasonNumber ?: -1}|${episodeNumber ?: -1}"
|
||||
} else {
|
||||
"${parentMetaId.trim()}|movie"
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadsUiState(
|
||||
val items: List<DownloadItem> = emptyList(),
|
||||
) {
|
||||
val activeItems: List<DownloadItem>
|
||||
get() = items.filter { it.status != DownloadStatus.Completed }
|
||||
|
||||
val completedItems: List<DownloadItem>
|
||||
get() = items.filter { it.status == DownloadStatus.Completed }
|
||||
}
|
||||
|
||||
enum class DownloadEnqueueResult(
|
||||
val toastMessage: String,
|
||||
) {
|
||||
Started("Download started"),
|
||||
Replaced("Replaced previous download"),
|
||||
MissingUrl("No direct stream link available"),
|
||||
UnsupportedFormat("Unsupported stream format for downloads"),
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
internal data class DownloadPlatformRequest(
|
||||
val sourceUrl: String,
|
||||
val sourceHeaders: Map<String, String>,
|
||||
val destinationFileName: String,
|
||||
)
|
||||
|
||||
internal interface DownloadsTaskHandle {
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
internal expect object DownloadsPlatformDownloader {
|
||||
fun start(
|
||||
request: DownloadPlatformRequest,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit,
|
||||
onFailure: (message: String) -> Unit,
|
||||
): DownloadsTaskHandle
|
||||
|
||||
fun removeFile(localFileUri: String?): Boolean
|
||||
}
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object DownloadsRepository {
|
||||
private val _uiState = MutableStateFlow(DownloadsUiState())
|
||||
val uiState: StateFlow<DownloadsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val activeHandles = mutableMapOf<String, DownloadsTaskHandle>()
|
||||
private var hasLoaded = false
|
||||
private var nextDownloadOrdinal = 0L
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun onProfileChanged() {
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
activeHandles.values.forEach(DownloadsTaskHandle::cancel)
|
||||
activeHandles.clear()
|
||||
hasLoaded = false
|
||||
_uiState.value = DownloadsUiState()
|
||||
notifyLiveStatusPlatform()
|
||||
}
|
||||
|
||||
fun findPlayableDownloadByVideoId(videoId: String?): DownloadItem? {
|
||||
ensureLoaded()
|
||||
val normalizedVideoId = videoId?.trim().orEmpty()
|
||||
if (normalizedVideoId.isBlank()) return null
|
||||
return _uiState.value.items.firstOrNull { item ->
|
||||
item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
fun findPlayableDownload(
|
||||
parentMetaId: String,
|
||||
seasonNumber: Int? = null,
|
||||
episodeNumber: Int? = null,
|
||||
videoId: String? = null,
|
||||
): DownloadItem? {
|
||||
ensureLoaded()
|
||||
val items = _uiState.value.items
|
||||
val normalizedParentMetaId = parentMetaId.trim()
|
||||
|
||||
findPlayableDownloadByVideoId(videoId)?.let { return it }
|
||||
|
||||
return if (seasonNumber != null && episodeNumber != null) {
|
||||
items.firstOrNull { item ->
|
||||
item.parentMetaId == normalizedParentMetaId &&
|
||||
item.seasonNumber == seasonNumber &&
|
||||
item.episodeNumber == episodeNumber &&
|
||||
item.isPlayable &&
|
||||
!item.localFileUri.isNullOrBlank()
|
||||
}
|
||||
} else {
|
||||
items.firstOrNull { item ->
|
||||
item.parentMetaId == normalizedParentMetaId &&
|
||||
item.seasonNumber == null &&
|
||||
item.episodeNumber == null &&
|
||||
item.isPlayable &&
|
||||
!item.localFileUri.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueueFromStream(
|
||||
contentType: String,
|
||||
videoId: String,
|
||||
parentMetaId: String,
|
||||
parentMetaType: String,
|
||||
title: String,
|
||||
logo: String?,
|
||||
poster: String?,
|
||||
background: String?,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
episodeThumbnail: String?,
|
||||
stream: StreamItem,
|
||||
): DownloadEnqueueResult {
|
||||
ensureLoaded()
|
||||
|
||||
val sourceUrl = stream.directPlaybackUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return DownloadEnqueueResult.MissingUrl
|
||||
|
||||
if (!sourceUrl.isSupportedDownloadUrl()) {
|
||||
return DownloadEnqueueResult.UnsupportedFormat
|
||||
}
|
||||
|
||||
val now = DownloadsClock.nowEpochMs()
|
||||
val logicalKey = buildLogicalKey(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
)
|
||||
|
||||
var replacedExisting = false
|
||||
val currentItems = _uiState.value.items.toMutableList()
|
||||
val existing = currentItems.firstOrNull { it.logicalContentKey == logicalKey }
|
||||
if (existing != null) {
|
||||
replacedExisting = true
|
||||
activeHandles.remove(existing.id)?.cancel()
|
||||
DownloadsPlatformDownloader.removeFile(existing.localFileUri)
|
||||
currentItems.removeAll { it.id == existing.id }
|
||||
}
|
||||
|
||||
val downloadId = nextDownloadId(now)
|
||||
val fileName = buildFileName(
|
||||
title = title,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
fallbackTitle = stream.streamLabel,
|
||||
sourceUrl = sourceUrl,
|
||||
nowEpochMs = now,
|
||||
)
|
||||
|
||||
val item = DownloadItem(
|
||||
id = downloadId,
|
||||
contentType = contentType,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
streamTitle = stream.streamLabel,
|
||||
streamSubtitle = stream.streamSubtitle,
|
||||
providerName = stream.addonName,
|
||||
providerAddonId = stream.addonId,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = sanitizeRequestHeaders(stream.behaviorHints.proxyHeaders?.request),
|
||||
sourceResponseHeaders = sanitizeResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
|
||||
localFileUri = null,
|
||||
fileName = fileName,
|
||||
status = DownloadStatus.Downloading,
|
||||
downloadedBytes = 0L,
|
||||
totalBytes = null,
|
||||
errorMessage = null,
|
||||
createdAtEpochMs = now,
|
||||
updatedAtEpochMs = now,
|
||||
)
|
||||
|
||||
currentItems.add(0, item)
|
||||
publish(currentItems)
|
||||
persist()
|
||||
startDownload(item)
|
||||
|
||||
return if (replacedExisting) {
|
||||
DownloadEnqueueResult.Replaced
|
||||
} else {
|
||||
DownloadEnqueueResult.Started
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseDownload(downloadId: String) {
|
||||
ensureLoaded()
|
||||
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
|
||||
if (item.status != DownloadStatus.Downloading) return
|
||||
|
||||
activeHandles.remove(downloadId)?.cancel()
|
||||
mutateItem(downloadId) { current ->
|
||||
current.copy(
|
||||
status = DownloadStatus.Paused,
|
||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeDownload(downloadId: String) {
|
||||
ensureLoaded()
|
||||
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
|
||||
if (item.status != DownloadStatus.Paused && item.status != DownloadStatus.Failed) return
|
||||
|
||||
val reset = item.copy(
|
||||
status = DownloadStatus.Downloading,
|
||||
downloadedBytes = 0L,
|
||||
totalBytes = null,
|
||||
errorMessage = null,
|
||||
localFileUri = null,
|
||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||
)
|
||||
|
||||
replaceItem(reset)
|
||||
persist()
|
||||
startDownload(reset)
|
||||
}
|
||||
|
||||
fun retryDownload(downloadId: String) {
|
||||
resumeDownload(downloadId)
|
||||
}
|
||||
|
||||
fun cancelDownload(downloadId: String) {
|
||||
ensureLoaded()
|
||||
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
|
||||
|
||||
activeHandles.remove(downloadId)?.cancel()
|
||||
DownloadsPlatformDownloader.removeFile(item.localFileUri)
|
||||
|
||||
publish(_uiState.value.items.filterNot { it.id == downloadId })
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
hasLoaded = true
|
||||
val payload = DownloadsStorage.loadPayload().orEmpty().trim()
|
||||
if (payload.isEmpty()) {
|
||||
_uiState.value = DownloadsUiState()
|
||||
notifyLiveStatusPlatform()
|
||||
return
|
||||
}
|
||||
|
||||
val normalized = DownloadsCodec.decodeItems(payload)
|
||||
.map { item ->
|
||||
if (item.status == DownloadStatus.Downloading) {
|
||||
item.copy(
|
||||
status = DownloadStatus.Paused,
|
||||
errorMessage = null,
|
||||
)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
.sortedByDescending { it.updatedAtEpochMs }
|
||||
|
||||
_uiState.value = DownloadsUiState(normalized)
|
||||
notifyLiveStatusPlatform()
|
||||
}
|
||||
|
||||
private fun startDownload(item: DownloadItem) {
|
||||
val request = DownloadPlatformRequest(
|
||||
sourceUrl = item.sourceUrl,
|
||||
sourceHeaders = item.sourceHeaders,
|
||||
destinationFileName = item.fileName,
|
||||
)
|
||||
|
||||
val handle = DownloadsPlatformDownloader.start(
|
||||
request = request,
|
||||
onProgress = { downloadedBytes, totalBytes ->
|
||||
mutateItem(item.id) { current ->
|
||||
if (current.status != DownloadStatus.Downloading) {
|
||||
current
|
||||
} else {
|
||||
current.copy(
|
||||
downloadedBytes = downloadedBytes.coerceAtLeast(0L),
|
||||
totalBytes = totalBytes?.takeIf { it > 0L },
|
||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess = { localFileUri, totalBytes ->
|
||||
activeHandles.remove(item.id)
|
||||
mutateItem(item.id) { current ->
|
||||
current.copy(
|
||||
status = DownloadStatus.Completed,
|
||||
localFileUri = localFileUri,
|
||||
downloadedBytes = if (totalBytes != null && totalBytes > 0L) {
|
||||
totalBytes
|
||||
} else {
|
||||
current.downloadedBytes
|
||||
},
|
||||
totalBytes = totalBytes?.takeIf { it > 0L } ?: current.totalBytes,
|
||||
errorMessage = null,
|
||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { message ->
|
||||
activeHandles.remove(item.id)
|
||||
mutateItem(item.id) { current ->
|
||||
if (current.status != DownloadStatus.Downloading) {
|
||||
current
|
||||
} else {
|
||||
current.copy(
|
||||
status = DownloadStatus.Failed,
|
||||
errorMessage = message.ifBlank { "Download failed" },
|
||||
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
activeHandles[item.id] = handle
|
||||
}
|
||||
|
||||
private fun mutateItem(downloadId: String, transform: (DownloadItem) -> DownloadItem) {
|
||||
var changed = false
|
||||
val updated = _uiState.value.items.map { item ->
|
||||
if (item.id == downloadId) {
|
||||
changed = true
|
||||
transform(item)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
publish(updated)
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceItem(item: DownloadItem) {
|
||||
val updated = _uiState.value.items.map { existing ->
|
||||
if (existing.id == item.id) item else existing
|
||||
}
|
||||
publish(updated)
|
||||
}
|
||||
|
||||
private fun publish(items: List<DownloadItem>) {
|
||||
_uiState.value = DownloadsUiState(
|
||||
items = items.sortedByDescending { it.updatedAtEpochMs },
|
||||
)
|
||||
notifyLiveStatusPlatform()
|
||||
}
|
||||
|
||||
private fun notifyLiveStatusPlatform() {
|
||||
runCatching {
|
||||
DownloadsLiveStatusPlatform.onItemsChanged(_uiState.value.items)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
DownloadsStorage.savePayload(
|
||||
DownloadsCodec.encodeItems(_uiState.value.items),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextDownloadId(nowEpochMs: Long): String {
|
||||
nextDownloadOrdinal += 1L
|
||||
return buildString {
|
||||
append(nowEpochMs.toString(36))
|
||||
append('_')
|
||||
append(nextDownloadOrdinal.toString(36))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class StoredDownloadsPayload(
|
||||
val items: List<DownloadItem> = emptyList(),
|
||||
)
|
||||
|
||||
private object DownloadsCodec {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeItems(payload: String): List<DownloadItem> =
|
||||
runCatching {
|
||||
json.decodeFromString<StoredDownloadsPayload>(payload).items
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
fun encodeItems(items: Collection<DownloadItem>): String =
|
||||
json.encodeToString(
|
||||
StoredDownloadsPayload(
|
||||
items = items.toList().sortedByDescending { it.updatedAtEpochMs },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizeRequestHeaders(headers: Map<String, String>?): Map<String, String> =
|
||||
headers
|
||||
.orEmpty()
|
||||
.mapNotNull { (key, value) ->
|
||||
val normalizedKey = key.trim()
|
||||
val normalizedValue = value.trim()
|
||||
if (
|
||||
normalizedKey.isBlank() ||
|
||||
normalizedValue.isBlank() ||
|
||||
normalizedKey.equals("Accept-Encoding", ignoreCase = true) ||
|
||||
normalizedKey.equals("Range", ignoreCase = true)
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
normalizedKey to normalizedValue
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
|
||||
private fun sanitizeResponseHeaders(headers: Map<String, String>?): Map<String, String> =
|
||||
headers
|
||||
.orEmpty()
|
||||
.mapNotNull { (key, value) ->
|
||||
val normalizedKey = key.trim()
|
||||
val normalizedValue = value.trim()
|
||||
if (normalizedKey.isBlank() || normalizedValue.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
normalizedKey to normalizedValue
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
|
||||
private fun buildLogicalKey(
|
||||
parentMetaId: String,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
): String = if (seasonNumber != null && episodeNumber != null) {
|
||||
"${parentMetaId.trim()}|$seasonNumber|$episodeNumber"
|
||||
} else {
|
||||
"${parentMetaId.trim()}|movie"
|
||||
}
|
||||
|
||||
private fun buildFileName(
|
||||
title: String,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
fallbackTitle: String,
|
||||
sourceUrl: String,
|
||||
nowEpochMs: Long,
|
||||
): String {
|
||||
val baseTitle = if (seasonNumber != null && episodeNumber != null) {
|
||||
buildString {
|
||||
append(title)
|
||||
append(" S")
|
||||
append(seasonNumber.toString().padStart(2, '0'))
|
||||
append('E')
|
||||
append(episodeNumber.toString().padStart(2, '0'))
|
||||
if (!episodeTitle.isNullOrBlank()) {
|
||||
append(' ')
|
||||
append(episodeTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
title.ifBlank { fallbackTitle }
|
||||
}
|
||||
|
||||
val extension = sourceUrl.fileExtensionFromUrl()
|
||||
return buildString {
|
||||
append(baseTitle.sanitizeFileName().ifBlank { "download" }.take(92))
|
||||
append('_')
|
||||
append(nowEpochMs.toString(36))
|
||||
append('.')
|
||||
append(extension)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sanitizeFileName(): String =
|
||||
trim().replace(Regex("[^A-Za-z0-9._ -]"), "_")
|
||||
|
||||
private fun String.fileExtensionFromUrl(): String {
|
||||
val withoutQuery = substringBefore('?').substringBefore('#')
|
||||
val suffix = withoutQuery.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase()
|
||||
.trim()
|
||||
|
||||
return if (suffix.length in 2..5 && suffix.all { it.isLetterOrDigit() }) {
|
||||
suffix
|
||||
} else {
|
||||
"mp4"
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isSupportedDownloadUrl(): Boolean {
|
||||
val normalized = trim().lowercase()
|
||||
if (normalized.startsWith("magnet:")) return false
|
||||
if (normalized.endsWith(".m3u8") || normalized.contains(".m3u8?")) return false
|
||||
if (normalized.endsWith(".mpd") || normalized.contains(".mpd?")) return false
|
||||
if (normalized.endsWith(".torrent") || normalized.contains(".torrent?")) return false
|
||||
return normalized.startsWith("http://") || normalized.startsWith("https://")
|
||||
}
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenDownload: (DownloadItem) -> Unit,
|
||||
) {
|
||||
val uiState by remember {
|
||||
DownloadsRepository.ensureLoaded()
|
||||
DownloadsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
var selectedShowId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val completedEpisodes = remember(uiState.items) {
|
||||
uiState.completedItems
|
||||
.filter { it.isEpisode }
|
||||
.sortedByDescending { it.updatedAtEpochMs }
|
||||
}
|
||||
|
||||
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
|
||||
selectedShowId?.let { showId ->
|
||||
completedEpisodes.firstOrNull { it.parentMetaId == showId }?.title
|
||||
}
|
||||
}
|
||||
|
||||
NuvioScreen {
|
||||
stickyHeader {
|
||||
NuvioScreenHeader(
|
||||
title = if (selectedShowId == null) {
|
||||
"Downloads"
|
||||
} else {
|
||||
selectedShowTitle ?: "Show Downloads"
|
||||
},
|
||||
onBack = {
|
||||
if (selectedShowId != null) {
|
||||
selectedShowId = null
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedShowId == null) {
|
||||
downloadsRootContent(
|
||||
uiState = uiState,
|
||||
onOpenDownload = onOpenDownload,
|
||||
onOpenShow = { showId -> selectedShowId = showId },
|
||||
)
|
||||
} else {
|
||||
downloadsShowContent(
|
||||
showId = selectedShowId.orEmpty(),
|
||||
episodes = completedEpisodes,
|
||||
onOpenDownload = onOpenDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.downloadsRootContent(
|
||||
uiState: DownloadsUiState,
|
||||
onOpenDownload: (DownloadItem) -> Unit,
|
||||
onOpenShow: (String) -> Unit,
|
||||
) {
|
||||
val activeItems = uiState.activeItems
|
||||
val completedMovies = uiState.completedItems.filterNot(DownloadItem::isEpisode)
|
||||
val completedShows = uiState.completedItems
|
||||
.filter(DownloadItem::isEpisode)
|
||||
.groupBy { it.parentMetaId }
|
||||
.mapNotNull { (_, episodes) ->
|
||||
episodes.firstOrNull()?.let { first ->
|
||||
first to episodes
|
||||
}
|
||||
}
|
||||
.sortedBy { (item, _) -> item.title.lowercase() }
|
||||
|
||||
if (activeItems.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle("ACTIVE")
|
||||
}
|
||||
items(activeItems.size) { index ->
|
||||
val item = activeItems[index]
|
||||
DownloadRow(
|
||||
item = item,
|
||||
onOpen = { onOpenDownload(item) },
|
||||
onPause = { DownloadsRepository.pauseDownload(item.id) },
|
||||
onResume = { DownloadsRepository.resumeDownload(item.id) },
|
||||
onRetry = { DownloadsRepository.retryDownload(item.id) },
|
||||
onDelete = { DownloadsRepository.cancelDownload(item.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedMovies.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle("MOVIES")
|
||||
}
|
||||
items(completedMovies.size) { index ->
|
||||
val item = completedMovies[index]
|
||||
DownloadRow(
|
||||
item = item,
|
||||
onOpen = { onOpenDownload(item) },
|
||||
onPause = { DownloadsRepository.pauseDownload(item.id) },
|
||||
onResume = { DownloadsRepository.resumeDownload(item.id) },
|
||||
onRetry = { DownloadsRepository.retryDownload(item.id) },
|
||||
onDelete = { DownloadsRepository.cancelDownload(item.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedShows.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle("SHOWS")
|
||||
}
|
||||
items(completedShows.size) { index ->
|
||||
val (item, episodes) = completedShows[index]
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
.clickable { onOpenShow(item.parentMetaId) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${episodes.size} downloaded episode${if (episodes.size == 1) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.items.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 40.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No downloads yet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.downloadsShowContent(
|
||||
showId: String,
|
||||
episodes: List<DownloadItem>,
|
||||
onOpenDownload: (DownloadItem) -> Unit,
|
||||
) {
|
||||
val showEpisodes = episodes
|
||||
.filter { it.parentMetaId == showId }
|
||||
|
||||
val seasons = showEpisodes
|
||||
.groupBy { it.seasonNumber ?: 0 }
|
||||
.toList()
|
||||
.sortedWith(
|
||||
compareBy<Pair<Int, List<DownloadItem>>> { (season, _) ->
|
||||
if (season == 0) 0 else 1
|
||||
}.thenBy { (season, _) -> if (season == 0) 0 else season },
|
||||
)
|
||||
|
||||
if (seasons.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 40.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No completed episodes",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
seasons.forEach { (seasonNumber, entries) ->
|
||||
val seasonTitle = if (seasonNumber == 0) {
|
||||
"Specials"
|
||||
} else {
|
||||
"Season $seasonNumber"
|
||||
}
|
||||
item {
|
||||
SectionTitle(seasonTitle)
|
||||
}
|
||||
|
||||
val sortedEpisodes = entries.sortedWith(
|
||||
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.updatedAtEpochMs },
|
||||
)
|
||||
|
||||
items(sortedEpisodes.size) { index ->
|
||||
val item = sortedEpisodes[index]
|
||||
DownloadRow(
|
||||
item = item,
|
||||
onOpen = { onOpenDownload(item) },
|
||||
onPause = { DownloadsRepository.pauseDownload(item.id) },
|
||||
onResume = { DownloadsRepository.resumeDownload(item.id) },
|
||||
onRetry = { DownloadsRepository.retryDownload(item.id) },
|
||||
onDelete = { DownloadsRepository.cancelDownload(item.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadRow(
|
||||
item: DownloadItem,
|
||||
onOpen: () -> Unit,
|
||||
onPause: () -> Unit,
|
||||
onResume: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
.clickable(enabled = item.isPlayable, onClick = onOpen),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = item.displaySubtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = statusText(item),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (item.status) {
|
||||
DownloadStatus.Downloading -> {
|
||||
IconButton(onClick = onPause) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Pause,
|
||||
contentDescription = "Pause",
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.Paused -> {
|
||||
IconButton(onClick = onResume) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PlayArrow,
|
||||
contentDescription = "Resume",
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.Failed -> {
|
||||
IconButton(onClick = onRetry) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
contentDescription = "Retry",
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.Completed -> {
|
||||
IconButton(onClick = onOpen) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.status == DownloadStatus.Downloading) {
|
||||
if (item.totalBytes != null && item.totalBytes > 0L) {
|
||||
LinearProgressIndicator(
|
||||
progress = item.progressFraction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
||||
private fun statusText(item: DownloadItem): String {
|
||||
val size = if (item.totalBytes != null && item.totalBytes > 0L) {
|
||||
"${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}"
|
||||
} else {
|
||||
formatBytes(item.downloadedBytes)
|
||||
}
|
||||
|
||||
return when (item.status) {
|
||||
DownloadStatus.Downloading -> "Downloading • $size"
|
||||
DownloadStatus.Paused -> "Paused • $size"
|
||||
DownloadStatus.Completed -> "Completed • ${formatBytes(item.totalBytes ?: item.downloadedBytes)}"
|
||||
DownloadStatus.Failed -> item.errorMessage ?: "Failed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
if (bytes <= 0L) return "0 B"
|
||||
val kib = 1024.0
|
||||
val mib = kib * 1024.0
|
||||
val gib = mib * 1024.0
|
||||
val value = bytes.toDouble()
|
||||
return when {
|
||||
value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} GB"
|
||||
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} MB"
|
||||
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} KB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
internal expect object DownloadsStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.downloads.DownloadItem
|
||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||
import com.nuvio.app.features.player.skip.NextEpisodeCard
|
||||
import com.nuvio.app.features.player.skip.NextEpisodeInfo
|
||||
import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
|
||||
|
|
@ -793,11 +795,70 @@ fun PlayerScreen(
|
|||
controlsVisible = true
|
||||
}
|
||||
|
||||
fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
|
||||
val localFileUri = downloadItem.localFileUri ?: return
|
||||
showNextEpisodeCard = false
|
||||
showSourcesPanel = false
|
||||
showEpisodesPanel = false
|
||||
episodeStreamsPanelState = EpisodeStreamsPanelState()
|
||||
nextEpisodeAutoPlayJob?.cancel()
|
||||
nextEpisodeAutoPlaySearching = false
|
||||
nextEpisodeAutoPlaySourceName = null
|
||||
nextEpisodeAutoPlayCountdown = null
|
||||
PlayerStreamsRepository.clearEpisodeStreams()
|
||||
flushWatchProgress()
|
||||
|
||||
val fallbackVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
fallbackVideoId = episode.id,
|
||||
)
|
||||
val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId
|
||||
val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId)
|
||||
?.takeIf { !it.isCompleted }
|
||||
val epResumeFraction = epEntry?.progressPercent
|
||||
?.takeIf { it > 0f }
|
||||
?.let { (it / 100f).coerceIn(0f, 1f) }
|
||||
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
|
||||
|
||||
activeSourceUrl = localFileUri
|
||||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeStreamTitle = downloadItem.streamTitle.ifBlank {
|
||||
episode.title.ifBlank { title }
|
||||
}
|
||||
activeStreamSubtitle = downloadItem.streamSubtitle
|
||||
activeProviderName = downloadItem.providerName.ifBlank { "Downloaded" }
|
||||
activeProviderAddonId = downloadItem.providerAddonId
|
||||
currentStreamBingeGroup = null
|
||||
activeSeasonNumber = episode.season
|
||||
activeEpisodeNumber = episode.episode
|
||||
activeEpisodeTitle = episode.title
|
||||
activeEpisodeThumbnail = episode.thumbnail
|
||||
activeVideoId = resolvedVideoId
|
||||
activeInitialPositionMs = epResumePositionMs
|
||||
activeInitialProgressFraction = epResumeFraction
|
||||
controlsVisible = true
|
||||
}
|
||||
|
||||
fun playNextEpisode() {
|
||||
val nextVideoId = nextEpisodeInfo?.videoId ?: return
|
||||
val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return
|
||||
if (nextEpisodeInfo?.hasAired != true) return
|
||||
|
||||
val downloadedNextEpisode = DownloadsRepository.findPlayableDownload(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = nextVideo.season,
|
||||
episodeNumber = nextVideo.episode,
|
||||
videoId = nextVideo.id,
|
||||
)
|
||||
if (downloadedNextEpisode != null) {
|
||||
switchToDownloadedEpisode(downloadedNextEpisode, nextVideo)
|
||||
return
|
||||
}
|
||||
|
||||
nextEpisodeAutoPlayJob?.cancel()
|
||||
nextEpisodeAutoPlaySearching = true
|
||||
nextEpisodeAutoPlaySourceName = null
|
||||
|
|
@ -1596,6 +1657,17 @@ fun PlayerScreen(
|
|||
),
|
||||
onSeasonSelected = { /* season tab change handled internally */ },
|
||||
onEpisodeSelected = { episode ->
|
||||
val downloadedEpisode = DownloadsRepository.findPlayableDownload(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
videoId = episode.id,
|
||||
)
|
||||
if (downloadedEpisode != null) {
|
||||
switchToDownloadedEpisode(downloadedEpisode, episode)
|
||||
return@PlayerEpisodesPanel
|
||||
}
|
||||
|
||||
val type = contentType ?: parentMetaType
|
||||
PlayerStreamsRepository.loadEpisodeStreams(
|
||||
type = type,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.nuvio.app.core.auth.isAnonymous
|
|||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
|
|
@ -147,6 +148,7 @@ object ProfileRepository {
|
|||
TraktAuthRepository.onProfileChanged()
|
||||
SearchHistoryRepository.onProfileChanged()
|
||||
CollectionRepository.onProfileChanged()
|
||||
DownloadsRepository.onProfileChanged()
|
||||
}
|
||||
|
||||
suspend fun pushProfiles(profiles: List<ProfilePushPayload>) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.AccountCircle
|
||||
import androidx.compose.material.icons.rounded.CloudDownload
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.Notifications
|
||||
|
|
@ -26,6 +27,7 @@ internal fun LazyListScope.settingsRootContent(
|
|||
onContentDiscoveryClick: () -> Unit,
|
||||
onIntegrationsClick: () -> Unit,
|
||||
onTraktClick: () -> Unit,
|
||||
onDownloadsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
onSwitchProfileClick: (() -> Unit)? = null,
|
||||
showAccountSection: Boolean = true,
|
||||
|
|
@ -98,6 +100,14 @@ internal fun LazyListScope.settingsRootContent(
|
|||
onClick = onPlaybackClick,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = "Downloads",
|
||||
description = "Manage your downloaded movies and episodes.",
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
isTablet = isTablet,
|
||||
onClick = onDownloadsClick,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = "Integrations",
|
||||
description = "Connect TMDB and MDBList services.",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ fun SettingsScreen(
|
|||
onContinueWatchingClick: () -> Unit = {},
|
||||
onAddonsClick: () -> Unit = {},
|
||||
onPluginsClick: () -> Unit = {},
|
||||
onDownloadsClick: () -> Unit = {},
|
||||
onAccountClick: () -> Unit = {},
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
|
|
@ -179,6 +180,7 @@ fun SettingsScreen(
|
|||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onDownloadsClick = onDownloadsClick,
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -218,6 +220,7 @@ fun SettingsScreen(
|
|||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onAddonsClick = onAddonsClick,
|
||||
onPluginsClick = onPluginsClick,
|
||||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
|
|
@ -262,6 +265,7 @@ private fun MobileSettingsScreen(
|
|||
onContinueWatchingClick: () -> Unit = {},
|
||||
onAddonsClick: () -> Unit = {},
|
||||
onPluginsClick: () -> Unit = {},
|
||||
onDownloadsClick: () -> Unit = {},
|
||||
onAccountClick: () -> Unit = {},
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
|
|
@ -283,6 +287,7 @@ private fun MobileSettingsScreen(
|
|||
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
|
||||
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
|
||||
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
|
||||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
)
|
||||
|
|
@ -400,6 +405,7 @@ private fun TabletSettingsScreen(
|
|||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
onSwitchProfile: (() -> Unit)? = null,
|
||||
onDownloadsClick: () -> Unit = {},
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
|
||||
|
|
@ -487,6 +493,7 @@ private fun TabletSettingsScreen(
|
|||
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
|
||||
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
|
||||
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
|
||||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
showAccountSection = activeCategory == SettingsCategory.Account,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -37,6 +38,8 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.SearchOff
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
|
@ -48,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -65,12 +69,22 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
||||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||
import com.nuvio.app.core.ui.NuvioToastController
|
||||
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
|
||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.round
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -105,7 +119,12 @@ fun StreamsScreen(
|
|||
WatchProgressRepository.ensureLoaded()
|
||||
WatchProgressRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
remember {
|
||||
DownloadsRepository.ensureLoaded()
|
||||
}
|
||||
val isEpisode = seasonNumber != null && episodeNumber != null
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var streamActionsTarget by remember(videoId) { mutableStateOf<StreamItem?>(null) }
|
||||
var preferredFilterApplied by remember(videoId) { mutableStateOf(false) }
|
||||
val storedProgress = if (startFromBeginning) {
|
||||
null
|
||||
|
|
@ -180,6 +199,7 @@ fun StreamsScreen(
|
|||
resumePositionMs = effectiveResumePositionMs,
|
||||
resumeProgressFraction = effectiveResumeProgressFraction,
|
||||
onStreamSelected = onStreamSelected,
|
||||
onStreamLongPress = { stream -> streamActionsTarget = stream },
|
||||
)
|
||||
} else {
|
||||
MobileStreamsLayout(
|
||||
|
|
@ -194,6 +214,7 @@ fun StreamsScreen(
|
|||
resumePositionMs = effectiveResumePositionMs,
|
||||
resumeProgressFraction = effectiveResumeProgressFraction,
|
||||
onStreamSelected = onStreamSelected,
|
||||
onStreamLongPress = { stream -> streamActionsTarget = stream },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +300,38 @@ fun StreamsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
StreamActionsSheet(
|
||||
stream = streamActionsTarget,
|
||||
onDismiss = { streamActionsTarget = null },
|
||||
onCopyLink = { stream ->
|
||||
val directUrl = stream.directPlaybackUrl
|
||||
if (!directUrl.isNullOrBlank()) {
|
||||
clipboardManager.setText(AnnotatedString(directUrl))
|
||||
NuvioToastController.show("Stream link copied")
|
||||
} else {
|
||||
NuvioToastController.show("No direct stream link available")
|
||||
}
|
||||
},
|
||||
onDownload = { stream ->
|
||||
val result = DownloadsRepository.enqueueFromStream(
|
||||
contentType = type,
|
||||
videoId = videoId,
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = parentMetaType,
|
||||
title = title,
|
||||
logo = logo,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
stream = stream,
|
||||
)
|
||||
NuvioToastController.show(result.toastMessage)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,6 +348,7 @@ private fun MobileStreamsLayout(
|
|||
resumePositionMs: Long?,
|
||||
resumeProgressFraction: Float?,
|
||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||
onStreamLongPress: (StreamItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
|
|
@ -372,6 +426,7 @@ private fun MobileStreamsLayout(
|
|||
StreamList(
|
||||
uiState = uiState,
|
||||
onStreamSelected = onStreamSelected,
|
||||
onStreamLongPress = onStreamLongPress,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = resumeProgressFraction,
|
||||
modifier = Modifier.weight(1f),
|
||||
|
|
@ -658,6 +713,7 @@ private fun FilterChip(
|
|||
internal fun StreamList(
|
||||
uiState: StreamsUiState,
|
||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||
onStreamLongPress: (StreamItem) -> Unit,
|
||||
resumePositionMs: Long?,
|
||||
resumeProgressFraction: Float?,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -694,6 +750,7 @@ internal fun StreamList(
|
|||
group = group,
|
||||
showHeader = uiState.selectedFilter == null,
|
||||
onStreamSelected = onStreamSelected,
|
||||
onStreamLongPress = onStreamLongPress,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = resumeProgressFraction,
|
||||
)
|
||||
|
|
@ -715,6 +772,7 @@ private fun LazyListScope.streamSection(
|
|||
group: AddonStreamGroup,
|
||||
showHeader: Boolean,
|
||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||
onStreamLongPress: (StreamItem) -> Unit,
|
||||
resumePositionMs: Long?,
|
||||
resumeProgressFraction: Float?,
|
||||
) {
|
||||
|
|
@ -756,6 +814,11 @@ private fun LazyListScope.streamSection(
|
|||
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
if (stream.directPlaybackUrl != null) {
|
||||
onStreamLongPress(stream)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
|
|
@ -830,6 +893,7 @@ private fun StreamSourceHeader(
|
|||
private fun StreamCard(
|
||||
stream: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isEnabled = stream.directPlaybackUrl != null
|
||||
|
|
@ -846,7 +910,11 @@ private fun StreamCard(
|
|||
)
|
||||
.clip(cardShape)
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.clickable(enabled = isEnabled, onClick = onClick)
|
||||
.combinedClickable(
|
||||
enabled = isEnabled,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
|
|
@ -883,6 +951,85 @@ private fun StreamCard(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StreamActionsSheet(
|
||||
stream: StreamItem?,
|
||||
onDismiss: () -> Unit,
|
||||
onCopyLink: (StreamItem) -> Unit,
|
||||
onDownload: (StreamItem) -> Unit,
|
||||
) {
|
||||
if (stream == null) return
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
NuvioModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp + nuvioPlatformExtraBottomPadding),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stream.streamLabel,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
stream.streamSubtitle
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Rounded.ContentCopy,
|
||||
title = "Copy stream link",
|
||||
onClick = {
|
||||
onCopyLink(stream)
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
)
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Rounded.Download,
|
||||
title = "Download file",
|
||||
onClick = {
|
||||
onDownload(stream)
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamFileSizeBadge(stream: StreamItem) {
|
||||
val bytes = stream.behaviorHints.videoSize ?: return
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ internal fun TabletStreamsLayout(
|
|||
resumePositionMs: Long?,
|
||||
resumeProgressFraction: Float?,
|
||||
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
|
||||
onStreamLongPress: (StreamItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hazeState = rememberHazeState()
|
||||
|
|
@ -202,6 +203,7 @@ internal fun TabletStreamsLayout(
|
|||
StreamList(
|
||||
uiState = uiState,
|
||||
onStreamSelected = onStreamSelected,
|
||||
onStreamLongPress = onStreamLongPress,
|
||||
resumePositionMs = resumePositionMs,
|
||||
resumeProgressFraction = resumeProgressFraction,
|
||||
modifier = Modifier.weight(1f),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.posix.time
|
||||
|
||||
internal actual object DownloadsClock {
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun nowEpochMs(): Long = time(null) * 1000L
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import platform.Foundation.NSNotificationCenter
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
internal actual object DownloadsLiveStatusPlatform {
|
||||
private const val notificationName = "NuvioDownloadsLiveStatusUpdated"
|
||||
private const val userDefaultsPayloadKey = "nuvio.downloads.live_status.payload"
|
||||
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private var lastPayload: String? = null
|
||||
|
||||
actual fun onItemsChanged(items: List<DownloadItem>) {
|
||||
val primary = items
|
||||
.filter { item ->
|
||||
item.status == DownloadStatus.Downloading ||
|
||||
item.status == DownloadStatus.Paused ||
|
||||
item.status == DownloadStatus.Failed
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<DownloadItem> { statusPriority(it.status) }
|
||||
.thenByDescending { it.updatedAtEpochMs },
|
||||
)
|
||||
.firstOrNull()
|
||||
|
||||
val payload = primary?.let { item ->
|
||||
json.encodeToString(
|
||||
DownloadsLiveStatusPayload(
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
subtitle = item.displaySubtitle,
|
||||
posterUrl = item.episodeThumbnail
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: item.poster?.takeIf { it.isNotBlank() }
|
||||
?: item.background?.takeIf { it.isNotBlank() },
|
||||
status = item.status.name,
|
||||
downloadedBytes = item.downloadedBytes,
|
||||
totalBytes = item.totalBytes,
|
||||
progressPercent = if (item.totalBytes != null && item.totalBytes > 0L) {
|
||||
((item.downloadedBytes.toDouble() / item.totalBytes.toDouble()) * 100.0)
|
||||
.toInt()
|
||||
.coerceIn(0, 100)
|
||||
} else {
|
||||
-1
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (payload == lastPayload) return
|
||||
lastPayload = payload
|
||||
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
if (payload == null) {
|
||||
defaults.removeObjectForKey(userDefaultsPayloadKey)
|
||||
} else {
|
||||
defaults.setObject(payload, forKey = userDefaultsPayloadKey)
|
||||
}
|
||||
|
||||
NSNotificationCenter.defaultCenter.postNotificationName(notificationName, null)
|
||||
}
|
||||
|
||||
private fun statusPriority(status: DownloadStatus): Int = when (status) {
|
||||
DownloadStatus.Downloading -> 0
|
||||
DownloadStatus.Paused -> 1
|
||||
DownloadStatus.Failed -> 2
|
||||
DownloadStatus.Completed -> 3
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class DownloadsLiveStatusPayload(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val posterUrl: String? = null,
|
||||
val status: String,
|
||||
val downloadedBytes: Long,
|
||||
val totalBytes: Long? = null,
|
||||
val progressPercent: Int,
|
||||
)
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.darwin.Darwin
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSHomeDirectory
|
||||
import platform.Foundation.NSURL
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fwrite
|
||||
|
||||
private val downloadHttpClient = HttpClient(Darwin) {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 60_000
|
||||
connectTimeoutMillis = 60_000
|
||||
socketTimeoutMillis = 60_000
|
||||
}
|
||||
expectSuccess = false
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal actual object DownloadsPlatformDownloader {
|
||||
actual fun start(
|
||||
request: DownloadPlatformRequest,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit,
|
||||
onFailure: (message: String) -> Unit,
|
||||
): DownloadsTaskHandle {
|
||||
val job = SupervisorJob()
|
||||
val scope = CoroutineScope(job + Dispatchers.Default)
|
||||
|
||||
scope.launch {
|
||||
val downloadsDirectory = downloadsDirectoryPath()
|
||||
val destinationPath = "$downloadsDirectory/${request.destinationFileName}"
|
||||
val tempPath = "$downloadsDirectory/${request.destinationFileName}.part"
|
||||
|
||||
removePathIfExists(tempPath)
|
||||
|
||||
try {
|
||||
val response = downloadHttpClient.get(request.sourceUrl) {
|
||||
request.sourceHeaders.forEach { (key, value) ->
|
||||
header(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
}
|
||||
|
||||
val totalBytes = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L }
|
||||
val channel = response.bodyAsChannel()
|
||||
val wrote = writeChannelToFile(
|
||||
channel = channel,
|
||||
path = tempPath,
|
||||
totalBytes = totalBytes,
|
||||
onProgress = onProgress,
|
||||
)
|
||||
if (!wrote) {
|
||||
error("Failed to write download file")
|
||||
}
|
||||
|
||||
removePathIfExists(destinationPath)
|
||||
val moved = NSFileManager.defaultManager.moveItemAtPath(
|
||||
srcPath = tempPath,
|
||||
toPath = destinationPath,
|
||||
error = null,
|
||||
)
|
||||
if (!moved) {
|
||||
error("Failed to finalize download file")
|
||||
}
|
||||
|
||||
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
|
||||
val finalSize = fileSizeOrNull(destinationPath)
|
||||
onSuccess(localFileUri, totalBytes ?: finalSize)
|
||||
} catch (_: CancellationException) {
|
||||
removePathIfExists(tempPath)
|
||||
} catch (error: Throwable) {
|
||||
removePathIfExists(tempPath)
|
||||
onFailure(error.message ?: "Download failed")
|
||||
}
|
||||
}
|
||||
|
||||
return IosDownloadsTaskHandle(job)
|
||||
}
|
||||
|
||||
actual fun removeFile(localFileUri: String?): Boolean {
|
||||
if (localFileUri.isNullOrBlank()) return false
|
||||
val path = localFileUri.toLocalPath() ?: return false
|
||||
return removePathIfExists(path)
|
||||
}
|
||||
}
|
||||
|
||||
private class IosDownloadsTaskHandle(
|
||||
private val job: Job,
|
||||
) : DownloadsTaskHandle {
|
||||
override fun cancel() {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun downloadsDirectoryPath(): String {
|
||||
val root = NSHomeDirectory().trimEnd('/')
|
||||
val path = "$root/Documents/nuvio_downloads"
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(
|
||||
path = path,
|
||||
withIntermediateDirectories = true,
|
||||
attributes = null,
|
||||
error = null,
|
||||
)
|
||||
return path
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun removePathIfExists(path: String): Boolean {
|
||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return true
|
||||
return NSFileManager.defaultManager.removeItemAtPath(path, null)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private suspend fun writeChannelToFile(
|
||||
channel: ByteReadChannel,
|
||||
path: String,
|
||||
totalBytes: Long?,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
): Boolean {
|
||||
val file = fopen(path, "wb") ?: return false
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var downloadedBytes = 0L
|
||||
|
||||
return try {
|
||||
while (true) {
|
||||
kotlinx.coroutines.currentCoroutineContext().ensureActive()
|
||||
val read = channel.readAvailable(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
if (read == 0) continue
|
||||
|
||||
val wroteChunk = buffer.usePinned { pinned ->
|
||||
val written = fwrite(
|
||||
pinned.addressOf(0),
|
||||
1.convert(),
|
||||
read.convert(),
|
||||
file,
|
||||
)
|
||||
written.toInt() == read
|
||||
}
|
||||
if (!wroteChunk) {
|
||||
return false
|
||||
}
|
||||
|
||||
downloadedBytes += read.toLong()
|
||||
onProgress(downloadedBytes, totalBytes)
|
||||
}
|
||||
true
|
||||
} finally {
|
||||
fclose(file)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun fileSizeOrNull(path: String): Long? {
|
||||
val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null)
|
||||
val value = attrs?.get("NSFileSize")
|
||||
return when (value) {
|
||||
is Long -> value
|
||||
is Number -> value.toLong()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toLocalPath(): String? {
|
||||
if (startsWith("file://")) {
|
||||
return removePrefix("file://")
|
||||
}
|
||||
return takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
internal actual object DownloadsStorage {
|
||||
private const val payloadKey = "downloads_payload"
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct DownloadsLiveActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
let status: String
|
||||
let progressPercent: Int
|
||||
let transferredText: String
|
||||
}
|
||||
|
||||
let downloadId: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let posterUrl: String?
|
||||
}
|
||||
|
||||
@available(iOSApplicationExtension 16.1, *)
|
||||
struct DownloadsLiveActivityWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: DownloadsLiveActivityAttributes.self) { context in
|
||||
DownloadActivityLockScreenView(context: context)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
PosterThumbnailView(urlString: context.attributes.posterUrl)
|
||||
.frame(width: 44, height: 64)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
Text(progressLabel(context.state.progressPercent))
|
||||
.font(.headline.monospacedDigit())
|
||||
.foregroundStyle(.primary)
|
||||
Text(statusLabel(context.state.status))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(context.attributes.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(context.attributes.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
ProgressView(value: normalizedProgress(context.state.progressPercent))
|
||||
.progressViewStyle(.linear)
|
||||
HStack {
|
||||
Text(context.state.transferredText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 6)
|
||||
Text(statusLabel(context.state.status))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
PosterGlyphView()
|
||||
} compactTrailing: {
|
||||
Text(progressLabel(context.state.progressPercent))
|
||||
.font(.caption2.monospacedDigit())
|
||||
} minimal: {
|
||||
PosterGlyphView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func progressLabel(_ progressPercent: Int) -> String {
|
||||
if progressPercent < 0 { return "--%" }
|
||||
return "\(max(0, min(100, progressPercent)))%"
|
||||
}
|
||||
|
||||
private func normalizedProgress(_ progressPercent: Int) -> Double {
|
||||
guard progressPercent >= 0 else { return 0 }
|
||||
return min(max(Double(progressPercent) / 100.0, 0), 1)
|
||||
}
|
||||
|
||||
private func statusLabel(_ status: String) -> String {
|
||||
switch status.lowercased() {
|
||||
case "downloading": return "Downloading"
|
||||
case "paused": return "Paused"
|
||||
case "failed": return "Failed"
|
||||
default: return "Active"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOSApplicationExtension 16.1, *)
|
||||
private struct DownloadActivityLockScreenView: View {
|
||||
let context: ActivityViewContext<DownloadsLiveActivityAttributes>
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
PosterThumbnailView(urlString: context.attributes.posterUrl)
|
||||
.frame(width: 62, height: 92)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(context.attributes.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(progressLabel(context.state.progressPercent))
|
||||
.font(.headline.monospacedDigit())
|
||||
}
|
||||
Text(context.attributes.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
ProgressView(value: normalizedProgress(context.state.progressPercent))
|
||||
.progressViewStyle(.linear)
|
||||
HStack {
|
||||
Text(statusLabel(context.state.status))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(context.state.transferredText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 6)
|
||||
.activityBackgroundTint(Color(red: 0.10, green: 0.11, blue: 0.16).opacity(0.88))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
}
|
||||
|
||||
private func progressLabel(_ progressPercent: Int) -> String {
|
||||
if progressPercent < 0 { return "--%" }
|
||||
return "\(max(0, min(100, progressPercent)))%"
|
||||
}
|
||||
|
||||
private func normalizedProgress(_ progressPercent: Int) -> Double {
|
||||
guard progressPercent >= 0 else { return 0 }
|
||||
return min(max(Double(progressPercent) / 100.0, 0), 1)
|
||||
}
|
||||
|
||||
private func statusLabel(_ status: String) -> String {
|
||||
switch status.lowercased() {
|
||||
case "downloading": return "Downloading"
|
||||
case "paused": return "Paused"
|
||||
case "failed": return "Failed"
|
||||
default: return "Active"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PosterGlyphView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.white.opacity(0.14))
|
||||
Image(systemName: "film")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PosterThumbnailView: View {
|
||||
let urlString: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.16, green: 0.17, blue: 0.22), Color(red: 0.09, green: 0.10, blue: 0.14)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing,
|
||||
),
|
||||
)
|
||||
|
||||
if let url = imageUrl {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
ProgressView()
|
||||
.tint(.white.opacity(0.85))
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
case .failure:
|
||||
fallbackIcon
|
||||
@unknown default:
|
||||
fallbackIcon
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallbackIcon
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.12), lineWidth: 1),
|
||||
)
|
||||
.shadow(color: .black.opacity(0.24), radius: 6, x: 0, y: 3)
|
||||
}
|
||||
|
||||
private var imageUrl: URL? {
|
||||
guard let urlString, let url = URL(string: urlString), !urlString.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private var fallbackIcon: some View {
|
||||
Image(systemName: "film")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct DownloadsWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
DownloadsLiveActivityWidget()
|
||||
}
|
||||
}
|
||||
31
iosApp/DownloadsWidgetExtension/Info.plist
Normal file
31
iosApp/DownloadsWidgetExtension/Info.plist
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Downloads</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>$(IPHONEOS_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -7,14 +7,47 @@
|
|||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0A1B2C3D4E5F60718293A4BD /* DownloadsWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */; };
|
||||
7397CF462F80FE3A00AC0F84 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* MPVKit */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
0A1B2C3D4E5F60718293A4C1 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = E819B502921EC7C68AC4965A /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0A1B2C3D4E5F60718293A4B8;
|
||||
remoteInfo = DownloadsWidgetExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
0A1B2C3D4E5F60718293A4BC /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
0A1B2C3D4E5F60718293A4BD /* DownloadsWidgetExtension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DownloadsWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
07921B4E8F8546BEE682C2FE /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
0A1B2C3D4E5F60718293A4B7 /* Exceptions for "DownloadsWidgetExtension" folder in "DownloadsWidgetExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */;
|
||||
};
|
||||
77B823F8A18A09B45EA3C7D4 /* Exceptions for "iosApp" folder in "iosApp" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
|
|
@ -25,6 +58,14 @@
|
|||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
0A1B2C3D4E5F60718293A4B7 /* Exceptions for "DownloadsWidgetExtension" folder in "DownloadsWidgetExtension" target */,
|
||||
);
|
||||
path = DownloadsWidgetExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
69649F6DF5D3AF53A24CA9C3 /* Configuration */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Configuration;
|
||||
|
|
@ -41,6 +82,13 @@
|
|||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
0A1B2C3D4E5F60718293A4BA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B8D8368DBB6E1F38F403E5AA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
|
@ -57,6 +105,7 @@
|
|||
children = (
|
||||
69649F6DF5D3AF53A24CA9C3 /* Configuration */,
|
||||
A2F97F59D21EB4D4B662C8D1 /* iosApp */,
|
||||
0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */,
|
||||
AD93033A33973C81BB77B73A /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -65,6 +114,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
07921B4E8F8546BEE682C2FE /* Nuvio.app */,
|
||||
0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -72,6 +122,28 @@
|
|||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 0A1B2C3D4E5F60718293A4BE /* Build configuration list for PBXNativeTarget "DownloadsWidgetExtension" */;
|
||||
buildPhases = (
|
||||
0A1B2C3D4E5F60718293A4B9 /* Sources */,
|
||||
0A1B2C3D4E5F60718293A4BA /* Frameworks */,
|
||||
0A1B2C3D4E5F60718293A4BB /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */,
|
||||
);
|
||||
name = DownloadsWidgetExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = DownloadsWidgetExtension;
|
||||
productReference = 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
E1B229BC363ABB711AF255E3 /* iosApp */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E4075507B4A6F771FEF44E6F /* Build configuration list for PBXNativeTarget "iosApp" */;
|
||||
|
|
@ -79,11 +151,13 @@
|
|||
8C96BAEBBE1F4269A0BADC2B /* Compile Kotlin Framework */,
|
||||
79E42700D221AF24510E3E09 /* Sources */,
|
||||
B8D8368DBB6E1F38F403E5AA /* Frameworks */,
|
||||
0A1B2C3D4E5F60718293A4BC /* Embed App Extensions */,
|
||||
2DAFDB313E32F227766A2072 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0A1B2C3D4E5F60718293A4C2 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A2F97F59D21EB4D4B662C8D1 /* iosApp */,
|
||||
|
|
@ -106,6 +180,9 @@
|
|||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1620;
|
||||
TargetAttributes = {
|
||||
0A1B2C3D4E5F60718293A4B8 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
E1B229BC363ABB711AF255E3 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
|
|
@ -128,12 +205,20 @@
|
|||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */,
|
||||
E1B229BC363ABB711AF255E3 /* iosApp */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
0A1B2C3D4E5F60718293A4BB /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
2DAFDB313E32F227766A2072 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
|
@ -166,6 +251,13 @@
|
|||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
0A1B2C3D4E5F60718293A4B9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
79E42700D221AF24510E3E09 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
|
@ -175,7 +267,63 @@
|
|||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
0A1B2C3D4E5F60718293A4C2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */;
|
||||
targetProxy = 0A1B2C3D4E5F60718293A4C1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0A1B2C3D4E5F60718293A4BF /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = DownloadsWidgetExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app.Nuvio.DownloadsWidgetExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0A1B2C3D4E5F60718293A4C0 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = DownloadsWidgetExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app.Nuvio.DownloadsWidgetExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
08C6158BC74ED5D18954BFD9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = 69649F6DF5D3AF53A24CA9C3 /* Configuration */;
|
||||
|
|
@ -358,6 +506,15 @@
|
|||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
0A1B2C3D4E5F60718293A4BE /* Build configuration list for PBXNativeTarget "DownloadsWidgetExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
0A1B2C3D4E5F60718293A4BF /* Debug */,
|
||||
0A1B2C3D4E5F60718293A4C0 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
BC8D3A48007CFD75F9C7AB47 /* Build configuration list for PBXProject "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
|
|
|||
132
iosApp/iosApp/DownloadsLiveActivityManager.swift
Normal file
132
iosApp/iosApp/DownloadsLiveActivityManager.swift
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import Foundation
|
||||
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
|
||||
import ActivityKit
|
||||
#endif
|
||||
|
||||
private let downloadsLiveStatusUpdatedNotification = Notification.Name("NuvioDownloadsLiveStatusUpdated")
|
||||
private let downloadsLiveStatusPayloadKey = "nuvio.downloads.live_status.payload"
|
||||
|
||||
final class DownloadsLiveActivityManager {
|
||||
static let shared = DownloadsLiveActivityManager()
|
||||
|
||||
private var observer: NSObjectProtocol?
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
guard observer == nil else { return }
|
||||
|
||||
observer = NotificationCenter.default.addObserver(
|
||||
forName: downloadsLiveStatusUpdatedNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.syncFromPayloadStore()
|
||||
}
|
||||
|
||||
syncFromPayloadStore()
|
||||
}
|
||||
|
||||
private func syncFromPayloadStore() {
|
||||
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
|
||||
let payload = loadPayload()
|
||||
Task {
|
||||
await apply(payload)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadPayload() -> DownloadsLiveStatusPayload? {
|
||||
guard let encoded = UserDefaults.standard.string(forKey: downloadsLiveStatusPayloadKey) else {
|
||||
return nil
|
||||
}
|
||||
let data = Data(encoded.utf8)
|
||||
return try? JSONDecoder().decode(DownloadsLiveStatusPayload.self, from: data)
|
||||
}
|
||||
|
||||
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
|
||||
@available(iOS 16.1, *)
|
||||
private func apply(_ payload: DownloadsLiveStatusPayload?) async {
|
||||
let existing = Activity<DownloadsLiveActivityAttributes>.activities.first
|
||||
|
||||
guard let payload else {
|
||||
if let existing {
|
||||
await existing.end(dismissalPolicy: .immediate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let state = DownloadsLiveActivityAttributes.ContentState(
|
||||
status: payload.status,
|
||||
progressPercent: payload.progressPercent,
|
||||
transferredText: transferredText(payload)
|
||||
)
|
||||
|
||||
if let existing, existing.attributes.downloadId == payload.id {
|
||||
await existing.update(using: state)
|
||||
return
|
||||
}
|
||||
|
||||
if let existing {
|
||||
await existing.end(dismissalPolicy: .immediate)
|
||||
}
|
||||
|
||||
let attributes = DownloadsLiveActivityAttributes(
|
||||
downloadId: payload.id,
|
||||
title: payload.title,
|
||||
subtitle: payload.subtitle,
|
||||
posterUrl: payload.posterUrl
|
||||
)
|
||||
|
||||
_ = try? Activity<DownloadsLiveActivityAttributes>.request(
|
||||
attributes: attributes,
|
||||
contentState: state,
|
||||
pushType: nil
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func transferredText(_ payload: DownloadsLiveStatusPayload) -> String {
|
||||
let downloaded = formatBytes(payload.downloadedBytes)
|
||||
if let total = payload.totalBytes {
|
||||
return "\(downloaded) / \(formatBytes(total))"
|
||||
}
|
||||
return downloaded
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
|
||||
@available(iOS 16.1, *)
|
||||
struct DownloadsLiveActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
let status: String
|
||||
let progressPercent: Int
|
||||
let transferredText: String
|
||||
}
|
||||
|
||||
let downloadId: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let posterUrl: String?
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct DownloadsLiveStatusPayload: Decodable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let posterUrl: String?
|
||||
let status: String
|
||||
let downloadedBytes: Int64
|
||||
let totalBytes: Int64?
|
||||
let progressPercent: Int
|
||||
}
|
||||
|
|
@ -17,5 +17,7 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
|
|||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
OrientationLockCoordinator.shared.start()
|
||||
DownloadsLiveActivityManager.shared.start()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue