From 8eb9f2c83baa9edfc5bd1db85b2b387d8bde713b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 12:56:53 +0530 Subject: [PATCH] feat: Add cloud library support --- .../composeResources/values/strings.xml | 22 + .../commonMain/kotlin/com/nuvio/app/App.kt | 57 ++ .../app/features/cloud/CloudLibraryModels.kt | 75 ++ .../features/cloud/CloudLibraryProviderApi.kt | 29 + .../features/cloud/CloudLibraryRepository.kt | 134 +++ .../cloud/TorboxCloudLibraryProviderApi.kt | 169 ++++ .../app/features/debrid/DebridApiClients.kt | 81 ++ .../app/features/debrid/DebridApiModels.kt | 27 + .../app/features/debrid/DebridProvider.kt | 2 + .../app/features/library/LibraryScreen.kt | 849 ++++++++++++++++-- .../app/features/settings/SettingsScreen.kt | 11 + .../features/cloud/CloudLibraryStoreTest.kt | 110 +++ .../TorboxCloudLibraryProviderApiTest.kt | 131 +++ .../app/features/debrid/DebridProviderTest.kt | 2 + 14 files changed, 1647 insertions(+), 52 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 01a8c8a7..528d11ab 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1320,11 +1320,33 @@ Your library is empty Couldn't load library Other + Cloud + Saved Library Connect Trakt and save titles to your watchlist or personal lists. Your Trakt library is empty Couldn't load Trakt library Trakt Library + Connect account + Connect Torbox in Debrid settings to browse playable files from your cloud library. + No cloud account connected + No playable cloud files match the current filters. + Nothing here yet + Choose a file to play + Couldn't load %1$s cloud library + This item does not expose a playable video file. + No playable files + No playable files + Couldn't play this cloud file. + Play file + %1$d playable files + All + Refresh cloud library + Ready to play + All + Torrents + Usenet + Web Anime Channels Movies diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3d5e7c37..6cd778a3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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(null) } var selectedContinueWatchingForActions by remember { mutableStateOf(null) } + var requestedSettingsPageName by rememberSaveable { mutableStateOf(null) } var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt new file mode 100644 index 00000000..635192ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt @@ -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 = emptyList(), +) { + val stableKey: String + get() = "$providerId:${type.name}:$id" + + val playableFiles: List + get() = files.filter { it.playable } +} + +data class CloudLibraryProviderState( + val provider: DebridProvider, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val items: List = 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 = emptyList(), +) { + val items: List + 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt new file mode 100644 index 00000000..f87ca151 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt @@ -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> + + suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult +} + +internal object CloudLibraryProviderApis { + private val registered = listOf( + TorboxCloudLibraryProviderApi(), + ) + + fun all(): List = registered + + fun apiFor(providerId: String?): CloudLibraryProviderApi? { + val normalized = DebridProviders.byId(providerId)?.id ?: return null + return registered.firstOrNull { it.provider.id == normalized } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt new file mode 100644 index 00000000..0a2e63fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt @@ -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, + private val providerApis: List, +) { + 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 = 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 = + DebridProviders.configuredServices(DebridSettingsRepository.snapshot()) + .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } + + private fun connectedCloudConnectionKeys(): List = + 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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt new file mode 100644 index 00000000..43007dce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt @@ -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> = + 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>>.itemsOrThrow( + type: CloudLibraryItemType, + ): List { + 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.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", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt index 47ddac07..485d59dc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -115,6 +115,27 @@ internal object TorboxApiClient { apiKey = apiKey, ) + suspend fun listCloudTorrents(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/mylist", + apiKey = apiKey, + ) + + suspend fun listCloudUsenet(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/usenet/mylist", + apiKey = apiKey, + ) + + suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse>> = + 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> = + 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> = + 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> = + 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 request( method: String, url: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt index 909fd46a..a2c27de2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -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( @@ -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? = 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt index ac6aa352..93e75362 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt @@ -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, ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 863fa3b4..61ca78cd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -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(null) } + var selectedTypeName by rememberSaveable { mutableStateOf(null) } + val selectedType = remember(selectedTypeName) { + selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() } + } + var selectedCloudItemKey by rememberSaveable { mutableStateOf(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, watchedKeys: Set, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 21442208..fb85bc73 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -94,6 +94,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L fun SettingsScreen( modifier: Modifier = Modifier, rootActionRequests: Flow = 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 } }, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt new file mode 100644 index 00000000..297daf49 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt @@ -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, +) : CloudLibraryProviderApi { + override suspend fun listItems(apiKey: String): Result> = + 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, + ), + ), + ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt new file mode 100644 index 00000000..7648b8d3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -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)) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt index 2f0eccd2..31c65109 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt @@ -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)) } }