Merge branch 'downloads' into cmp-rewrite

This commit is contained in:
tapframe 2026-04-11 22:34:49 +05:30
commit b6a4b72381
36 changed files with 2838 additions and 70 deletions

View file

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

View file

@ -35,6 +35,10 @@
android:path="/trakt" />
</intent-filter>
</activity>
<receiver
android:name=".features.downloads.DownloadsNotificationActionReceiver"
android:exported="false" />
</application>
</manifest>

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.downloads
internal actual object DownloadsClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.downloads
internal expect object DownloadsClock {
fun nowEpochMs(): Long
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.downloads
internal expect object DownloadsLiveStatusPlatform {
fun onItemsChanged(items: List<DownloadItem>)
}

View file

@ -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"),
}

View file

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

View file

@ -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://")
}

View file

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

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.downloads
internal expect object DownloadsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

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

View file

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

View file

@ -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.",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct DownloadsWidgetBundle: WidgetBundle {
var body: some Widget {
DownloadsLiveActivityWidget()
}
}

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

View file

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

View 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
}

View file

@ -17,5 +17,7 @@
</array>
</dict>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View file

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