feat: Add cloud library support

This commit is contained in:
tapframe 2026-05-21 12:56:53 +05:30
parent 20f8717cf0
commit 8eb9f2c83b
14 changed files with 1647 additions and 52 deletions

View file

@ -1320,11 +1320,33 @@
<string name="library_empty_title">Your library is empty</string>
<string name="library_load_failed">Couldn't load library</string>
<string name="library_other">Other</string>
<string name="library_source_cloud">Cloud</string>
<string name="library_source_saved">Saved</string>
<string name="library_title">Library</string>
<string name="library_trakt_empty_message">Connect Trakt and save titles to your watchlist or personal lists.</string>
<string name="library_trakt_empty_title">Your Trakt library is empty</string>
<string name="library_trakt_load_failed">Couldn't load Trakt library</string>
<string name="library_trakt_title">Trakt Library</string>
<string name="cloud_library_connect_action">Connect account</string>
<string name="cloud_library_connect_message">Connect Torbox in Debrid settings to browse playable files from your cloud library.</string>
<string name="cloud_library_connect_title">No cloud account connected</string>
<string name="cloud_library_empty_message">No playable cloud files match the current filters.</string>
<string name="cloud_library_empty_title">Nothing here yet</string>
<string name="cloud_library_file_picker_title">Choose a file to play</string>
<string name="cloud_library_load_failed">Couldn't load %1$s cloud library</string>
<string name="cloud_library_no_files_message">This item does not expose a playable video file.</string>
<string name="cloud_library_no_files_title">No playable files</string>
<string name="cloud_library_no_playable_files">No playable files</string>
<string name="cloud_library_play_failed">Couldn't play this cloud file.</string>
<string name="cloud_library_play_file">Play file</string>
<string name="cloud_library_playable_file_count">%1$d playable files</string>
<string name="cloud_library_provider_all">All</string>
<string name="cloud_library_refresh">Refresh cloud library</string>
<string name="cloud_library_status_ready">Ready to play</string>
<string name="cloud_library_type_all">All</string>
<string name="cloud_library_type_torrents">Torrents</string>
<string name="cloud_library_type_usenet">Usenet</string>
<string name="cloud_library_type_web">Web</string>
<string name="media_anime">Anime</string>
<string name="media_channels">Channels</string>
<string name="media_movies">Movies</string>

View file

@ -106,6 +106,10 @@ import com.nuvio.app.features.addons.AddonRepository
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.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
@ -565,6 +569,7 @@ private fun MainAppContent(
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var requestedSettingsPageName by rememberSaveable { mutableStateOf<String?>(null) }
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
var pickerTitle by remember { mutableStateOf("") }
@ -601,6 +606,7 @@ private fun MainAppContent(
val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured)
val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
@ -1157,6 +1163,45 @@ private fun MainAppContent(
)
},
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
onCloudFilePlay = { item, file ->
coroutineScope.launch {
when (
val resolved = CloudLibraryRepository.resolvePlayback(
item = item,
file = file,
)
) {
is CloudLibraryPlaybackResult.Success -> {
val playbackTitle = file.name.ifBlank { item.name }
val playerLaunch = PlayerLaunch(
title = playbackTitle,
sourceUrl = resolved.url,
streamTitle = playbackTitle,
streamSubtitle = item.name.takeIf { it != playbackTitle },
providerName = item.providerName,
providerAddonId = "cloud:${item.providerId}",
contentType = "cloud",
videoId = "${item.stableKey}:${file.stableKey}",
parentMetaId = item.stableKey,
parentMetaType = "cloud",
)
if (playerSettingsUiState.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
return@launch
}
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId))
}
else -> {
NuvioToastController.show(cloudLibraryPlayFailedText)
}
}
}
},
onConnectCloudClick = {
requestedSettingsPageName = "Debrid"
selectedTab = AppScreenTab.Settings
},
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onSwitchProfile = onSwitchProfile,
@ -1191,6 +1236,10 @@ private fun MainAppContent(
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
},
requestedSettingsPageName = requestedSettingsPageName,
onRequestedSettingsPageConsumed = {
requestedSettingsPageName = null
},
onInitialHomeContentRendered = { initialHomeReady = true },
)
}
@ -2282,6 +2331,8 @@ private fun AppTabHost(
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null,
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
onConnectCloudClick: (() -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
onSwitchProfile: (() -> Unit)? = null,
@ -2297,6 +2348,8 @@ private fun AppTabHost(
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
requestedSettingsPageName: String? = null,
onRequestedSettingsPageConsumed: () -> Unit = {},
onInitialHomeContentRendered: () -> Unit = {},
) {
val tabStateHolder = rememberSaveableStateHolder()
@ -2336,6 +2389,8 @@ private fun AppTabHost(
onPosterClick = onLibraryPosterClick,
onPosterLongClick = onLibraryPosterLongClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
onCloudFilePlay = onCloudFilePlay,
onConnectCloudClick = onConnectCloudClick,
)
}
@ -2343,6 +2398,8 @@ private fun AppTabHost(
SettingsScreen(
modifier = Modifier.fillMaxSize(),
rootActionRequests = settingsRootActionRequests,
requestedPageName = requestedSettingsPageName,
onRequestedPageConsumed = onRequestedSettingsPageConsumed,
rootActionsEnabled = rootActionsEnabled,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,

View file

@ -0,0 +1,75 @@
package com.nuvio.app.features.cloud
import com.nuvio.app.features.debrid.DebridProvider
enum class CloudLibraryItemType {
Torrent,
Usenet,
WebDownload,
}
data class CloudLibraryFile(
val id: String?,
val name: String,
val sizeBytes: Long? = null,
val mimeType: String? = null,
val playable: Boolean = true,
) {
val stableKey: String
get() = id ?: name
}
data class CloudLibraryItem(
val providerId: String,
val providerName: String,
val id: String,
val type: CloudLibraryItemType,
val name: String,
val status: String? = null,
val sizeBytes: Long? = null,
val progressFraction: Float? = null,
val files: List<CloudLibraryFile> = emptyList(),
) {
val stableKey: String
get() = "$providerId:${type.name}:$id"
val playableFiles: List<CloudLibraryFile>
get() = files.filter { it.playable }
}
data class CloudLibraryProviderState(
val provider: DebridProvider,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val items: List<CloudLibraryItem> = emptyList(),
) {
val providerId: String
get() = provider.id
val providerName: String
get() = provider.displayName
}
data class CloudLibraryUiState(
val isLoaded: Boolean = false,
val isRefreshing: Boolean = false,
val providers: List<CloudLibraryProviderState> = emptyList(),
) {
val items: List<CloudLibraryItem>
get() = providers.flatMap { it.items }
val hasConnectedProvider: Boolean
get() = providers.isNotEmpty()
}
sealed interface CloudLibraryPlaybackResult {
data class Success(
val url: String,
val filename: String? = null,
val videoSizeBytes: Long? = null,
) : CloudLibraryPlaybackResult
data object MissingCredentials : CloudLibraryPlaybackResult
data object NotPlayable : CloudLibraryPlaybackResult
data class Failed(val message: String? = null) : CloudLibraryPlaybackResult
}

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.cloud
import com.nuvio.app.features.debrid.DebridProvider
import com.nuvio.app.features.debrid.DebridProviders
internal interface CloudLibraryProviderApi {
val provider: DebridProvider
suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>>
suspend fun resolvePlayback(
apiKey: String,
item: CloudLibraryItem,
file: CloudLibraryFile,
): CloudLibraryPlaybackResult
}
internal object CloudLibraryProviderApis {
private val registered = listOf(
TorboxCloudLibraryProviderApi(),
)
fun all(): List<CloudLibraryProviderApi> = registered
fun apiFor(providerId: String?): CloudLibraryProviderApi? {
val normalized = DebridProviders.byId(providerId)?.id ?: return null
return registered.firstOrNull { it.provider.id == normalized }
}
}

View file

@ -0,0 +1,134 @@
package com.nuvio.app.features.cloud
import com.nuvio.app.features.debrid.DebridProviderCapability
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.DebridServiceCredential
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.supports
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
internal class CloudLibraryStore(
private val credentialsProvider: suspend () -> List<DebridServiceCredential>,
private val providerApis: List<CloudLibraryProviderApi>,
) {
suspend fun refresh(): CloudLibraryUiState {
val credentials = credentialsProvider()
.filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
val providerStates = credentials.map { credential ->
val api = providerApis.firstOrNull { it.provider.id == credential.provider.id }
if (api == null) {
return@map CloudLibraryProviderState(
provider = credential.provider,
errorMessage = "Cloud library is not available for ${credential.provider.displayName}.",
)
}
api.listItems(credential.apiKey)
.fold(
onSuccess = { items ->
CloudLibraryProviderState(
provider = credential.provider,
items = items,
)
},
onFailure = { error ->
CloudLibraryProviderState(
provider = credential.provider,
errorMessage = error.message,
)
},
)
}
return CloudLibraryUiState(
isLoaded = true,
isRefreshing = false,
providers = providerStates,
)
}
suspend fun resolvePlayback(
item: CloudLibraryItem,
file: CloudLibraryFile,
): CloudLibraryPlaybackResult {
if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
val credential = credentialsProvider()
.firstOrNull { credential -> credential.provider.id == item.providerId }
?: return CloudLibraryPlaybackResult.MissingCredentials
val api = providerApis.firstOrNull { it.provider.id == item.providerId }
?: return CloudLibraryPlaybackResult.Failed()
return api.resolvePlayback(
apiKey = credential.apiKey,
item = item,
file = file,
)
}
}
object CloudLibraryRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val store = CloudLibraryStore(
credentialsProvider = {
DebridSettingsRepository.ensureLoaded()
DebridProviders.configuredServices(DebridSettingsRepository.snapshot())
},
providerApis = CloudLibraryProviderApis.all(),
)
private val _uiState = MutableStateFlow(CloudLibraryUiState())
private var loadedConnectionKeys: List<CloudConnectionKey> = emptyList()
val uiState = _uiState.asStateFlow()
fun ensureLoaded() {
DebridSettingsRepository.ensureLoaded()
val current = _uiState.value
if (current.isRefreshing) return
val connectedKeys = connectedCloudConnectionKeys()
if (!current.isLoaded || connectedKeys != loadedConnectionKeys) {
refresh()
}
}
fun refresh() {
_uiState.update { current ->
current.copy(
isRefreshing = true,
providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) },
)
}
scope.launch {
val refreshed = store.refresh()
loadedConnectionKeys = connectedCloudConnectionKeys()
_uiState.value = refreshed
}
}
suspend fun resolvePlayback(
item: CloudLibraryItem,
file: CloudLibraryFile,
): CloudLibraryPlaybackResult =
store.resolvePlayback(item, file)
private fun connectedCloudCredentials(): List<DebridServiceCredential> =
DebridProviders.configuredServices(DebridSettingsRepository.snapshot())
.filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
private fun connectedCloudConnectionKeys(): List<CloudConnectionKey> =
connectedCloudCredentials().map { credential ->
CloudConnectionKey(
providerId = credential.provider.id,
apiKeyHash = credential.apiKey.hashCode(),
)
}.sortedBy { it.providerId }
private data class CloudConnectionKey(
val providerId: String,
val apiKeyHash: Int,
)
}

View file

@ -0,0 +1,169 @@
package com.nuvio.app.features.cloud
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.TorboxApiClient
import com.nuvio.app.features.debrid.TorboxCloudFileDto
import com.nuvio.app.features.debrid.TorboxCloudItemDto
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.JsonPrimitive
internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi {
override val provider = DebridProviders.Torbox
override suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>> =
runCatching {
val torrents = TorboxApiClient.listCloudTorrents(apiKey).itemsOrThrow(CloudLibraryItemType.Torrent)
val usenet = TorboxApiClient.listCloudUsenet(apiKey).itemsOrThrow(CloudLibraryItemType.Usenet)
val web = TorboxApiClient.listCloudWebDownloads(apiKey).itemsOrThrow(CloudLibraryItemType.WebDownload)
torrents + usenet + web
}
override suspend fun resolvePlayback(
apiKey: String,
item: CloudLibraryItem,
file: CloudLibraryFile,
): CloudLibraryPlaybackResult {
if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
return try {
val response = when (item.type) {
CloudLibraryItemType.Torrent -> TorboxApiClient.requestCloudTorrentDownloadLink(
apiKey = apiKey,
torrentId = item.id,
fileId = file.id,
)
CloudLibraryItemType.Usenet -> TorboxApiClient.requestCloudUsenetDownloadLink(
apiKey = apiKey,
usenetId = item.id,
fileId = file.id,
)
CloudLibraryItemType.WebDownload -> TorboxApiClient.requestCloudWebDownloadLink(
apiKey = apiKey,
webId = item.id,
fileId = file.id,
)
}
if (!response.isSuccessful || response.body?.success == false) {
return CloudLibraryPlaybackResult.Failed(response.body?.detail ?: response.body?.error)
}
val url = response.body?.data?.takeIf { it.isNotBlank() }
?: return CloudLibraryPlaybackResult.Failed()
CloudLibraryPlaybackResult.Success(
url = url,
filename = file.name.takeIf { it.isNotBlank() },
videoSizeBytes = file.sizeBytes,
)
} catch (error: Exception) {
if (error is CancellationException) throw error
CloudLibraryPlaybackResult.Failed(error.message)
}
}
private fun com.nuvio.app.features.debrid.DebridApiResponse<com.nuvio.app.features.debrid.TorboxEnvelopeDto<List<TorboxCloudItemDto>>>.itemsOrThrow(
type: CloudLibraryItemType,
): List<CloudLibraryItem> {
if (!isSuccessful || body?.success == false) {
throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() })
}
return body?.data.orEmpty().mapNotNull { dto ->
dto.toCloudLibraryItem(
providerId = provider.id,
providerName = provider.displayName,
type = type,
)
}
}
}
internal fun TorboxCloudItemDto.toCloudLibraryItem(
providerId: String,
providerName: String,
type: CloudLibraryItemType,
): CloudLibraryItem? {
val itemId = id.scalarString()
?: hash?.trim()?.takeIf { it.isNotBlank() }
?: return null
val mappedFiles = files.orEmpty().mapNotNull { file ->
file.toCloudLibraryFile()
}
val filesSize = mappedFiles
.mapNotNull { it.sizeBytes }
.takeIf { it.isNotEmpty() }
?.sum()
return CloudLibraryItem(
providerId = providerId,
providerName = providerName,
id = itemId,
type = type,
name = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId,
status = listOf(status, downloadState, state)
.firstNonBlank(),
sizeBytes = size ?: totalSize ?: filesSize,
progressFraction = listOfNotNull(progress, downloadProgress).firstOrNull()?.toProgressFraction(),
files = mappedFiles,
)
}
internal fun TorboxCloudFileDto.toCloudLibraryFile(): CloudLibraryFile? {
val name = listOf(name, shortName, absolutePath)
.firstNonBlank()
?: return null
val fileId = id.scalarString()
val mime = listOf(mimeType, mimeTypeAlt).firstNonBlank()
return CloudLibraryFile(
id = fileId,
name = name,
sizeBytes = size,
mimeType = mime,
playable = fileId != null && isPlayableCloudFile(name = name, mimeType = mime),
)
}
internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String =
when (type) {
CloudLibraryItemType.Torrent -> "torrent_id"
CloudLibraryItemType.Usenet -> "usenet_id"
CloudLibraryItemType.WebDownload -> "web_id"
}
private fun List<String?>.firstNonBlank(): String? =
firstOrNull { !it.isNullOrBlank() }?.trim()
private fun kotlinx.serialization.json.JsonElement?.scalarString(): String? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.content.trim().takeIf { it.isNotBlank() }
}
private fun Double.toProgressFraction(): Float {
val normalized = if (this > 1.0) this / 100.0 else this
return normalized.toFloat().coerceIn(0f, 1f)
}
private fun isPlayableCloudFile(name: String, mimeType: String?): Boolean {
val normalizedMime = mimeType?.lowercase().orEmpty()
if (normalizedMime.startsWith("video/")) return true
val extension = name.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
return extension in playableVideoExtensions
}
private val playableVideoExtensions = setOf(
"3g2",
"3gp",
"avi",
"divx",
"flv",
"m2ts",
"m4v",
"mkv",
"mov",
"mp4",
"mpeg",
"mpg",
"mts",
"ogm",
"ogv",
"ts",
"webm",
"wmv",
)

View file

@ -115,6 +115,27 @@ internal object TorboxApiClient {
apiKey = apiKey,
)
suspend fun listCloudTorrents(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/torrents/mylist",
apiKey = apiKey,
)
suspend fun listCloudUsenet(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/usenet/mylist",
apiKey = apiKey,
)
suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/webdl/mylist",
apiKey = apiKey,
)
suspend fun requestDownloadLink(
apiKey: String,
torrentId: Int,
@ -135,6 +156,66 @@ internal object TorboxApiClient {
apiKey = apiKey,
)
suspend fun requestCloudTorrentDownloadLink(
apiKey: String,
torrentId: String,
fileId: String?,
): DebridApiResponse<TorboxEnvelopeDto<String>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/torrents/requestdl?${
queryString(
"token" to apiKey,
"torrent_id" to torrentId,
"file_id" to fileId,
"zip_link" to "false",
"redirect" to "false",
"append_name" to "false",
)
}",
apiKey = apiKey,
)
suspend fun requestCloudUsenetDownloadLink(
apiKey: String,
usenetId: String,
fileId: String?,
): DebridApiResponse<TorboxEnvelopeDto<String>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/usenet/requestdl?${
queryString(
"token" to apiKey,
"usenet_id" to usenetId,
"file_id" to fileId,
"zip_link" to "false",
"redirect" to "false",
"append_name" to "false",
)
}",
apiKey = apiKey,
)
suspend fun requestCloudWebDownloadLink(
apiKey: String,
webId: String,
fileId: String?,
): DebridApiResponse<TorboxEnvelopeDto<String>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/webdl/requestdl?${
queryString(
"token" to apiKey,
"web_id" to webId,
"file_id" to fileId,
"zip_link" to "false",
"redirect" to "false",
"append_name" to "false",
)
}",
apiKey = apiKey,
)
private suspend inline fun <reified T> request(
method: String,
url: String,

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.debrid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
internal data class TorboxEnvelopeDto<T>(
@ -44,6 +45,32 @@ internal data class TorboxTorrentFileDto(
.orEmpty()
}
@Serializable
internal data class TorboxCloudItemDto(
val id: JsonElement? = null,
val hash: String? = null,
val name: String? = null,
val status: String? = null,
val state: String? = null,
@SerialName("download_state") val downloadState: String? = null,
val progress: Double? = null,
@SerialName("download_progress") val downloadProgress: Double? = null,
val size: Long? = null,
@SerialName("total_size") val totalSize: Long? = null,
val files: List<TorboxCloudFileDto>? = null,
)
@Serializable
internal data class TorboxCloudFileDto(
val id: JsonElement? = null,
val name: String? = null,
@SerialName("short_name") val shortName: String? = null,
@SerialName("absolute_path") val absolutePath: String? = null,
@SerialName("mimetype") val mimeType: String? = null,
@SerialName("mime_type") val mimeTypeAlt: String? = null,
val size: Long? = null,
)
@Serializable
internal data class TorboxCheckCachedRequestDto(
val hashes: List<String>,

View file

@ -18,6 +18,7 @@ enum class DebridProviderCapability {
ClientResolve,
LocalTorrentCacheCheck,
LocalTorrentResolve,
CloudLibrary,
}
enum class DebridProviderAuthMethod {
@ -38,6 +39,7 @@ object DebridProviders {
DebridProviderCapability.ClientResolve,
DebridProviderCapability.LocalTorrentCacheCheck,
DebridProviderCapability.LocalTorrentResolve,
DebridProviderCapability.CloudLibrary,
),
)

View file

@ -1,25 +1,60 @@
package com.nuvio.app.features.library
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
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.LazyRow
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.i18n.localizedByteUnit
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioScreen
@ -28,6 +63,11 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
import com.nuvio.app.features.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
import com.nuvio.app.features.cloud.CloudLibraryItemType
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.CloudLibraryUiState
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
@ -47,17 +87,30 @@ fun LibraryScreen(
onPosterClick: ((LibraryItem) -> Unit)? = null,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
onSectionViewAllClick: ((LibrarySection) -> Unit)? = null,
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
onConnectCloudClick: (() -> Unit)? = null,
) {
val uiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
val cloudUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by remember {
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) }
var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) }
val sourceMode = remember(sourceModeName) {
runCatching { LibraryViewMode.valueOf(sourceModeName) }.getOrDefault(LibraryViewMode.Saved)
}
var selectedProviderId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedTypeName by rememberSaveable { mutableStateOf<String?>(null) }
val selectedType = remember(selectedTypeName) {
selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
}
var selectedCloudItemKey by rememberSaveable { mutableStateOf<String?>(null) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
@ -98,6 +151,13 @@ fun LibraryScreen(
}
}
LaunchedEffect(sourceMode) {
if (sourceMode == LibraryViewMode.Cloud) {
CloudLibraryRepository.ensureLoaded()
selectedCloudItemKey = null
}
}
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
@ -111,87 +171,772 @@ fun LibraryScreen(
.background(MaterialTheme.colorScheme.background),
) {
NuvioScreenHeader(
title = if (isTraktSource) {
title = if (sourceMode == LibraryViewMode.Cloud) {
stringResource(Res.string.library_title)
} else if (isTraktSource) {
stringResource(Res.string.library_trakt_title)
} else {
stringResource(Res.string.library_title)
},
modifier = Modifier.padding(horizontal = 16.dp),
)
LibrarySourceSwitch(
selectedMode = sourceMode,
onModeSelected = { mode ->
sourceModeName = mode.name
},
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(6.dp))
}
}
when {
!uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
if (sourceMode == LibraryViewMode.Cloud) {
cloudLibraryContent(
uiState = cloudUiState,
selectedProviderId = selectedProviderId,
selectedType = selectedType,
selectedCloudItemKey = selectedCloudItemKey,
onProviderSelected = {
selectedProviderId = it
selectedCloudItemKey = null
},
onTypeSelected = {
selectedTypeName = it?.name
selectedCloudItemKey = null
},
onItemSelected = { item ->
val playableFiles = item.playableFiles
when {
playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first())
playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey
}
},
onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) },
onBackToItems = { selectedCloudItemKey = null },
onRefresh = { CloudLibraryRepository.refresh() },
onConnectCloudClick = onConnectCloudClick,
)
} else {
when {
!uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
!uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
item {
if (networkStatusUiState.isOfflineLike) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = retryLibraryLoad,
)
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_load_failed)
} else {
stringResource(Res.string.library_load_failed)
},
message = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(Res.string.action_retry),
onActionClick = retryLibraryLoad,
)
}
}
}
uiState.sections.isEmpty() -> {
item {
if (networkStatusUiState.isOfflineLike && isTraktSource) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = retryLibraryLoad,
)
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_title)
} else {
stringResource(Res.string.library_empty_title)
},
message = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_message)
} else {
stringResource(Res.string.library_empty_message)
},
)
}
}
}
else -> {
librarySections(
sections = uiState.sections,
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onSectionViewAllClick = onSectionViewAllClick,
onPosterLongClick = onPosterLongClick,
)
}
}
}
}
}
!uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
private fun LazyListScope.cloudLibraryContent(
uiState: CloudLibraryUiState,
selectedProviderId: String?,
selectedType: CloudLibraryItemType?,
selectedCloudItemKey: String?,
onProviderSelected: (String?) -> Unit,
onTypeSelected: (CloudLibraryItemType?) -> Unit,
onItemSelected: (CloudLibraryItem) -> Unit,
onFileSelected: (CloudLibraryItem, CloudLibraryFile) -> Unit,
onBackToItems: () -> Unit,
onRefresh: () -> Unit,
onConnectCloudClick: (() -> Unit)?,
) {
when {
!uiState.isLoaded -> {
cloudLibrarySkeletonItems()
}
!uiState.hasConnectedProvider -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = stringResource(Res.string.cloud_library_connect_title),
message = stringResource(Res.string.cloud_library_connect_message),
actionLabel = stringResource(Res.string.cloud_library_connect_action),
onActionClick = onConnectCloudClick,
)
}
}
else -> {
val filteredItems = uiState.items
.filter { item -> selectedProviderId == null || item.providerId == selectedProviderId }
.filter { item -> selectedType == null || item.type == selectedType }
val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey }
if (selectedItem != null) {
item {
if (networkStatusUiState.isOfflineLike) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = retryLibraryLoad,
)
} else {
CloudLibraryFilePicker(
item = selectedItem,
onBack = onBackToItems,
onFileSelected = { file -> onFileSelected(selectedItem, file) },
)
}
} else {
item {
CloudLibraryToolbar(
uiState = uiState,
selectedProviderId = selectedProviderId,
selectedType = selectedType,
onProviderSelected = onProviderSelected,
onTypeSelected = onTypeSelected,
onRefresh = onRefresh,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
uiState.providers
.filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId }
.filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() }
.forEach { providerState ->
item(key = "cloud-error-${providerState.providerId}") {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = stringResource(Res.string.cloud_library_load_failed, providerState.providerName),
message = providerState.errorMessage.orEmpty(),
actionLabel = stringResource(Res.string.action_retry),
onActionClick = onRefresh,
)
}
}
if (uiState.isRefreshing && filteredItems.isEmpty()) {
cloudLibrarySkeletonItems()
} else if (filteredItems.isEmpty()) {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_load_failed)
} else {
stringResource(Res.string.library_load_failed)
},
message = uiState.errorMessage.orEmpty(),
title = stringResource(Res.string.cloud_library_empty_title),
message = stringResource(Res.string.cloud_library_empty_message),
actionLabel = stringResource(Res.string.action_retry),
onActionClick = retryLibraryLoad,
onActionClick = onRefresh,
)
}
} else {
items(
items = filteredItems,
key = { item -> item.stableKey },
) { item ->
CloudLibraryRow(
item = item,
onClick = { onItemSelected(item) },
)
}
}
}
}
}
}
uiState.sections.isEmpty() -> {
private fun LazyListScope.cloudLibrarySkeletonItems() {
item(key = "cloud-library-skeleton-toolbar") {
CloudLibrarySkeletonToolbar(
modifier = Modifier.padding(horizontal = 16.dp),
)
}
items(4) {
CloudLibrarySkeletonRow()
}
}
@Composable
private fun LibrarySourceSwitch(
selectedMode: LibraryViewMode,
onModeSelected: (LibraryViewMode) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LibraryChip(
label = stringResource(Res.string.library_source_saved),
selected = selectedMode == LibraryViewMode.Saved,
onClick = { onModeSelected(LibraryViewMode.Saved) },
)
LibraryChip(
label = stringResource(Res.string.library_source_cloud),
selected = selectedMode == LibraryViewMode.Cloud,
onClick = { onModeSelected(LibraryViewMode.Cloud) },
)
}
}
@Composable
private fun CloudLibraryToolbar(
uiState: CloudLibraryUiState,
selectedProviderId: String?,
selectedType: CloudLibraryItemType?,
onProviderSelected: (String?) -> Unit,
onTypeSelected: (CloudLibraryItemType?) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LazyRow(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(end = 8.dp),
) {
item {
if (networkStatusUiState.isOfflineLike && isTraktSource) {
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = retryLibraryLoad,
)
} else {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_title)
} else {
stringResource(Res.string.library_empty_title)
},
message = if (isTraktSource) {
stringResource(Res.string.library_trakt_empty_message)
} else {
stringResource(Res.string.library_empty_message)
},
)
}
LibraryChip(
label = stringResource(Res.string.cloud_library_provider_all),
selected = selectedProviderId == null,
onClick = { onProviderSelected(null) },
)
}
items(
items = uiState.providers,
key = { provider -> provider.providerId },
) { provider ->
LibraryChip(
label = provider.providerName,
selected = selectedProviderId == provider.providerId,
loading = provider.isLoading,
error = !provider.errorMessage.isNullOrBlank(),
onClick = { onProviderSelected(provider.providerId) },
)
}
}
else -> {
librarySections(
sections = uiState.sections,
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onSectionViewAllClick = onSectionViewAllClick,
onPosterLongClick = onPosterLongClick,
IconButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = stringResource(Res.string.cloud_library_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(end = 16.dp),
) {
item {
LibraryChip(
label = stringResource(Res.string.cloud_library_type_all),
selected = selectedType == null,
onClick = { onTypeSelected(null) },
)
}
items(
items = CloudLibraryItemType.entries,
key = { type -> type.name },
) { type ->
LibraryChip(
label = cloudLibraryTypeLabel(type),
selected = selectedType == type,
onClick = { onTypeSelected(type) },
)
}
}
}
}
@Composable
private fun LibraryChip(
label: String,
selected: Boolean,
loading: Boolean = false,
error: Boolean = false,
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Surface(
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
color = if (selected) colorScheme.primaryContainer else colorScheme.surfaceContainerLow,
border = if (selected) BorderStroke(1.dp, colorScheme.primary.copy(alpha = 0.45f)) else null,
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = colorScheme.primary,
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = when {
error -> colorScheme.error
selected -> colorScheme.onPrimaryContainer
else -> colorScheme.onSurfaceVariant
},
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun CloudLibraryRow(
item: CloudLibraryItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val playableCount = item.playableFiles.size
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
.clickable(enabled = playableCount > 0, onClick = onClick),
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.name,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = cloudLibrarySubtitle(item),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = cloudLibraryStatusLine(item),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (playableCount > 0) {
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = stringResource(Res.string.action_play),
)
}
}
}
item.progressFraction?.takeIf { it in 0f..0.999f }?.let { progress ->
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
@Composable
private fun CloudLibraryFilePicker(
item: CloudLibraryItem,
onBack: () -> Unit,
onFileSelected: (CloudLibraryFile) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(Res.string.action_back),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.name,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(Res.string.cloud_library_file_picker_title),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val files = item.playableFiles
if (files.isEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(Res.string.cloud_library_no_files_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.cloud_library_no_files_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
files.forEach { file ->
CloudLibraryFileRow(
file = file,
onClick = { onFileSelected(file) },
)
}
}
}
}
}
@Composable
private fun CloudLibraryFileRow(
file: CloudLibraryFile,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.58f))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
file.sizeBytes?.let { size ->
Text(
text = formatCloudBytes(size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = stringResource(Res.string.cloud_library_play_file),
tint = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun cloudLibrarySubtitle(item: CloudLibraryItem): String {
val fileLine = when (val playableCount = item.playableFiles.size) {
0 -> stringResource(Res.string.cloud_library_no_playable_files)
1 -> item.playableFiles.first().name
else -> stringResource(Res.string.cloud_library_playable_file_count, playableCount)
}
return listOf(item.providerName, cloudLibraryTypeLabel(item.type), fileLine).joinToString("")
}
@Composable
private fun cloudLibraryStatusLine(item: CloudLibraryItem): String {
val fallback = if (item.playableFiles.isEmpty()) {
stringResource(Res.string.cloud_library_no_playable_files)
} else {
stringResource(Res.string.cloud_library_status_ready)
}
return listOfNotNull(
item.status?.toDisplayStatus(),
item.sizeBytes?.let(::formatCloudBytes),
item.progressFraction?.let { "${(it * 100f).toInt()}%" },
).joinToString("").ifBlank { fallback }
}
@Composable
private fun cloudLibraryTypeLabel(type: CloudLibraryItemType): String =
when (type) {
CloudLibraryItemType.Torrent -> stringResource(Res.string.cloud_library_type_torrents)
CloudLibraryItemType.Usenet -> stringResource(Res.string.cloud_library_type_usenet)
CloudLibraryItemType.WebDownload -> stringResource(Res.string.cloud_library_type_web)
}
private fun formatCloudBytes(bytes: Long): String {
if (bytes <= 0L) return "0 ${localizedByteUnit("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} ${localizedByteUnit("GB")}"
value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}"
value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}"
else -> "$bytes ${localizedByteUnit("B")}"
}
}
private fun String.toDisplayStatus(): String =
replace('_', ' ')
.lowercase()
.replaceFirstChar { it.titlecase() }
@Composable
private fun CloudLibrarySkeletonToolbar(
modifier: Modifier = Modifier,
) {
val brush = rememberCloudLibrarySkeletonBrush()
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp)
CloudSkeletonBlock(brush = brush, width = 86.dp, height = 34.dp, cornerRadius = 18.dp)
CloudSkeletonBlock(
brush = brush,
modifier = Modifier.weight(1f),
height = 34.dp,
cornerRadius = 18.dp,
)
CloudSkeletonBlock(brush = brush, width = 40.dp, height = 40.dp, cornerRadius = 20.dp)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp)
CloudSkeletonBlock(brush = brush, width = 82.dp, height = 34.dp, cornerRadius = 18.dp)
CloudSkeletonBlock(brush = brush, width = 72.dp, height = 34.dp, cornerRadius = 18.dp)
CloudSkeletonBlock(brush = brush, width = 60.dp, height = 34.dp, cornerRadius = 18.dp)
}
}
}
@Composable
private fun CloudLibrarySkeletonRow(
modifier: Modifier = Modifier,
) {
val brush = rememberCloudLibrarySkeletonBrush()
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
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(7.dp),
) {
CloudSkeletonBlock(
brush = brush,
modifier = Modifier.fillMaxWidth(0.74f),
height = 17.dp,
cornerRadius = 6.dp,
)
CloudSkeletonBlock(
brush = brush,
modifier = Modifier.fillMaxWidth(),
height = 12.dp,
cornerRadius = 6.dp,
)
CloudSkeletonBlock(
brush = brush,
modifier = Modifier.fillMaxWidth(0.58f),
height = 12.dp,
cornerRadius = 6.dp,
)
}
CloudSkeletonBlock(brush = brush, width = 32.dp, height = 32.dp, cornerRadius = 16.dp)
}
CloudSkeletonBlock(
brush = brush,
modifier = Modifier.fillMaxWidth(0.44f),
height = 4.dp,
cornerRadius = 999.dp,
)
}
}
}
@Composable
private fun rememberCloudLibrarySkeletonBrush(): Brush {
val shimmerColors = listOf(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f),
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
)
val transition = rememberInfiniteTransition()
val translateAnim by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
)
return Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnim - 200f, 0f),
end = Offset(translateAnim, 0f),
)
}
@Composable
private fun CloudSkeletonBlock(
brush: Brush,
modifier: Modifier = Modifier,
width: Dp? = null,
height: Dp,
cornerRadius: Dp,
) {
val sizeModifier = if (width != null) {
modifier.size(width = width, height = height)
} else {
modifier.height(height)
}
Box(
modifier = sizeModifier
.clip(RoundedCornerShape(cornerRadius))
.background(brush),
)
}
private enum class LibraryViewMode {
Saved,
Cloud,
}
private fun LazyListScope.librarySections(
sections: List<LibrarySection>,
watchedKeys: Set<String>,

View file

@ -94,6 +94,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L
fun SettingsScreen(
modifier: Modifier = Modifier,
rootActionRequests: Flow<Unit> = emptyFlow(),
requestedPageName: String? = null,
onRequestedPageConsumed: () -> Unit = {},
rootActionsEnabled: Boolean = true,
onSwitchProfile: (() -> Unit)? = null,
onHomescreenClick: () -> Unit = {},
@ -221,6 +223,15 @@ fun SettingsScreen(
}
}
LaunchedEffect(requestedPageName, rootActionsEnabled) {
val targetPage = requestedPageName
?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() }
?: return@LaunchedEffect
if (!rootActionsEnabled) return@LaunchedEffect
currentPage = targetPage.name
onRequestedPageConsumed()
}
PlatformBackHandler(
enabled = rootActionsEnabled && previousPage != null,
onBack = { previousPage?.let { currentPage = it.name } },

View file

@ -0,0 +1,110 @@
package com.nuvio.app.features.cloud
import com.nuvio.app.features.debrid.DebridProvider
import com.nuvio.app.features.debrid.DebridProviderCapability
import com.nuvio.app.features.debrid.DebridServiceCredential
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CloudLibraryStoreTest {
@Test
fun `refresh aggregates multiple providers without provider-specific assumptions`() = runBlocking {
val firstProvider = cloudProvider(id = "alpha", name = "Alpha")
val secondProvider = cloudProvider(id = "beta", name = "Beta")
val store = CloudLibraryStore(
credentialsProvider = {
listOf(
DebridServiceCredential(firstProvider, "alpha-token"),
DebridServiceCredential(secondProvider, "beta-token"),
)
},
providerApis = listOf(
FakeCloudProviderApi(
provider = firstProvider,
items = listOf(cloudItem(firstProvider, "one")),
),
FakeCloudProviderApi(
provider = secondProvider,
items = listOf(cloudItem(secondProvider, "two")),
),
),
)
val state = store.refresh()
assertTrue(state.isLoaded)
assertEquals(listOf("alpha", "beta"), state.providers.map { it.providerId })
assertEquals(listOf("one", "two"), state.items.map { it.id })
}
@Test
fun `refresh ignores connected providers without cloud library capability`() = runBlocking {
val cloudProvider = cloudProvider(id = "cloud", name = "Cloud")
val unsupportedProvider = DebridProvider(
id = "plain",
displayName = "Plain",
shortName = "P",
capabilities = setOf(DebridProviderCapability.ClientResolve),
)
val store = CloudLibraryStore(
credentialsProvider = {
listOf(
DebridServiceCredential(cloudProvider, "cloud-token"),
DebridServiceCredential(unsupportedProvider, "plain-token"),
)
},
providerApis = listOf(
FakeCloudProviderApi(
provider = cloudProvider,
items = listOf(cloudItem(cloudProvider, "cloud-item")),
),
),
)
val state = store.refresh()
assertEquals(listOf("cloud"), state.providers.map { it.providerId })
assertEquals(listOf("cloud-item"), state.items.map { it.id })
}
}
private class FakeCloudProviderApi(
override val provider: DebridProvider,
private val items: List<CloudLibraryItem>,
) : CloudLibraryProviderApi {
override suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>> =
Result.success(items)
override suspend fun resolvePlayback(
apiKey: String,
item: CloudLibraryItem,
file: CloudLibraryFile,
): CloudLibraryPlaybackResult =
CloudLibraryPlaybackResult.Success(url = "https://example.test/${item.id}/${file.id}")
}
private fun cloudProvider(id: String, name: String): DebridProvider =
DebridProvider(
id = id,
displayName = name,
shortName = name.take(1),
capabilities = setOf(DebridProviderCapability.CloudLibrary),
)
private fun cloudItem(provider: DebridProvider, id: String): CloudLibraryItem =
CloudLibraryItem(
providerId = provider.id,
providerName = provider.displayName,
id = id,
type = CloudLibraryItemType.Torrent,
name = id,
files = listOf(
CloudLibraryFile(
id = "file-$id",
name = "$id.mkv",
playable = true,
),
),
)

View file

@ -0,0 +1,131 @@
package com.nuvio.app.features.cloud
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.TorboxCloudFileDto
import com.nuvio.app.features.debrid.TorboxCloudItemDto
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TorboxCloudLibraryProviderApiTest {
@Test
fun `maps torrent dto with status progress size and playable files`() {
val item = TorboxCloudItemDto(
id = JsonPrimitive(42),
name = "Movie Pack",
status = "completed",
progress = 75.0,
size = 1_024L,
files = listOf(
TorboxCloudFileDto(
id = JsonPrimitive(8),
name = "movie.mkv",
mimeType = "video/x-matroska",
size = 512L,
),
),
).toCloudLibraryItem(
providerId = DebridProviders.Torbox.id,
providerName = DebridProviders.Torbox.displayName,
type = CloudLibraryItemType.Torrent,
)
assertNotNull(item)
assertEquals("42", item.id)
assertEquals(CloudLibraryItemType.Torrent, item.type)
assertEquals("completed", item.status)
assertEquals(0.75f, item.progressFraction)
assertEquals(1_024L, item.sizeBytes)
assertEquals(listOf("8"), item.files.map { it.id })
assertTrue(item.files.single().playable)
}
@Test
fun `mapping falls back to hash and file absolute path when friendly fields are missing`() {
val item = TorboxCloudItemDto(
hash = "abc123",
files = listOf(
TorboxCloudFileDto(
id = JsonPrimitive("file-1"),
absolutePath = "/downloads/show.mp4",
size = 256L,
),
),
).toCloudLibraryItem(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.Usenet,
)
assertNotNull(item)
assertEquals("abc123", item.id)
assertEquals("abc123", item.name)
assertEquals("/downloads/show.mp4", item.files.single().name)
assertTrue(item.files.single().playable)
}
@Test
fun `mapping handles missing item ids and empty file lists`() {
assertNull(
TorboxCloudItemDto(name = "No ID").toCloudLibraryItem(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.WebDownload,
),
)
val item = TorboxCloudItemDto(
id = JsonPrimitive(7),
name = "Empty",
files = emptyList(),
).toCloudLibraryItem(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.WebDownload,
)
assertNotNull(item)
assertTrue(item.files.isEmpty())
assertTrue(item.playableFiles.isEmpty())
}
@Test
fun `mapping keeps non-playable files but excludes them from playable files`() {
val item = TorboxCloudItemDto(
id = JsonPrimitive(9),
name = "Mixed",
files = listOf(
TorboxCloudFileDto(
id = JsonPrimitive(1),
name = "readme.txt",
mimeType = "text/plain",
),
TorboxCloudFileDto(
name = "missing-id.mkv",
mimeType = "video/x-matroska",
),
),
).toCloudLibraryItem(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.Torrent,
)
assertNotNull(item)
assertEquals(2, item.files.size)
assertFalse(item.files[0].playable)
assertFalse(item.files[1].playable)
assertTrue(item.playableFiles.isEmpty())
}
@Test
fun `request download parameter names match Torbox item type`() {
assertEquals("torrent_id", torboxRequestIdParameterName(CloudLibraryItemType.Torrent))
assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet))
assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload))
}
}

View file

@ -11,6 +11,7 @@ class DebridProviderTest {
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve))
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck))
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve))
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.CloudLibrary))
}
@Test
@ -20,5 +21,6 @@ class DebridProviderTest {
assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve))
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck))
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentResolve))
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.CloudLibrary))
}
}