mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 02:02:04 +00:00
feat: Add cloud library support
This commit is contained in:
parent
20f8717cf0
commit
8eb9f2c83b
14 changed files with 1647 additions and 52 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue