feat: downloads init

This commit is contained in:
tapframe 2026-04-11 21:03:15 +05:30
parent fa7e3aabfc
commit 7437f54ab8
19 changed files with 1592 additions and 2 deletions

View file

@ -13,6 +13,8 @@ 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.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 +71,8 @@ class MainActivity : ComponentActivity() {
StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext)
CollectionStorage.initialize(applicationContext)
DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.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,135 @@
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()
}
onSuccess(destination.toURI().toString(), totalBytes)
}
} 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

@ -88,6 +88,7 @@ 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.DownloadsScreen
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaDetailsScreen
import com.nuvio.app.features.details.MetaPerson
@ -192,6 +193,9 @@ object MetaScreenSettingsRoute
@Serializable
object ContinueWatchingSettingsRoute
@Serializable
object DownloadsSettingsRoute
@Serializable
object AddonsSettingsRoute
@ -710,6 +714,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 +1241,43 @@ 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 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 = 0L,
),
)
navController.navigate(PlayerRoute(launchId = launchId))
},
)
}
composable<AddonsSettingsRoute> { backStackEntry ->
val onBack = rememberGuardedPopBackStack(
navController = navController,
@ -1511,6 +1553,7 @@ private fun AppTabHost(
onHomescreenSettingsClick: () -> Unit = {},
onMetaScreenSettingsClick: () -> Unit = {},
onContinueWatchingSettingsClick: () -> Unit = {},
onDownloadsSettingsClick: () -> Unit = {},
onAddonsSettingsClick: () -> Unit = {},
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
@ -1559,6 +1602,7 @@ private fun AppTabHost(
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,
onContinueWatchingClick = onContinueWatchingSettingsClick,
onDownloadsClick = onDownloadsSettingsClick,
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,

View file

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

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

@ -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,182 @@
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"
onSuccess(localFileUri, totalBytes)
} 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)
}
}
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))
}
}