ref(cloud): ui adjustments

This commit is contained in:
tapframe 2026-05-21 21:29:39 +05:30
parent 914f4147e9
commit 996fd7f949
7 changed files with 160 additions and 67 deletions

View file

@ -84,30 +84,7 @@ private class TorboxDebridProviderApi(
val normalized = deviceCode.trim() val normalized = deviceCode.trim()
if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null)
val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized) val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized)
val envelope = response.body return torboxDeviceAuthorizationTokenResult(response)
val accessToken = envelope
?.takeIf { response.isSuccessful && it.success != false }
?.data
?.accessToken
?.takeIf { it.isNotBlank() }
if (accessToken != null) {
return DebridDeviceAuthorizationTokenResult.Authorized(accessToken)
}
val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody)
.joinToString(" ")
.lowercase()
return when {
message.contains("pending") || message.contains("not authorized") ->
DebridDeviceAuthorizationTokenResult.Pending
message.contains("expired") ->
DebridDeviceAuthorizationTokenResult.Expired
response.status == 404 || response.status == 409 || response.status == 425 ->
DebridDeviceAuthorizationTokenResult.Pending
response.status == 410 ->
DebridDeviceAuthorizationTokenResult.Expired
else ->
DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error)
}
} }
override suspend fun resolveClientStream( override suspend fun resolveClientStream(
@ -320,6 +297,39 @@ internal fun premiumizeDeviceAuthorizationFromResponse(
) )
} }
internal fun torboxDeviceAuthorizationTokenResult(
response: DebridApiResponse<TorboxEnvelopeDto<TorboxDeviceTokenDto>>,
): DebridDeviceAuthorizationTokenResult {
val envelope = response.body
val accessToken = envelope
?.takeIf { response.isSuccessful && it.success != false }
?.data
?.accessToken
?.takeIf { it.isNotBlank() }
if (accessToken != null) {
return DebridDeviceAuthorizationTokenResult.Authorized(accessToken)
}
val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody)
.joinToString(" ")
.lowercase()
return when {
message.contains("pending") ||
message.contains("not authorized") ||
message.contains("not been used") ||
message.contains("not used yet") ||
message.contains("scan the code") ->
DebridDeviceAuthorizationTokenResult.Pending
message.contains("expired") ->
DebridDeviceAuthorizationTokenResult.Expired
response.status == 404 || response.status == 409 || response.status == 425 ->
DebridDeviceAuthorizationTokenResult.Pending
response.status == 410 ->
DebridDeviceAuthorizationTokenResult.Expired
else ->
DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error)
}
}
internal fun premiumizeDeviceAuthorizationTokenResult( internal fun premiumizeDeviceAuthorizationTokenResult(
response: DebridApiResponse<PremiumizeDeviceTokenDto>, response: DebridApiResponse<PremiumizeDeviceTokenDto>,
): DebridDeviceAuthorizationTokenResult { ): DebridDeviceAuthorizationTokenResult {

View file

@ -100,7 +100,10 @@ fun HomeScreen(
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val homeSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val homeListState = rememberLazyListState() val homeListState = rememberLazyListState()
val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
@ -612,7 +615,10 @@ fun HomeScreen(
} }
} }
items(3) { items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) HomeSkeletonRow(
modifier = Modifier.padding(horizontal = 16.dp),
showHeaderAccent = !homeSettingsUiState.hideCatalogUnderline,
)
} }
} }

View file

@ -181,7 +181,10 @@ fun HomeSkeletonHero(
} }
@Composable @Composable
fun HomeSkeletonRow(modifier: Modifier = Modifier) { fun HomeSkeletonRow(
modifier: Modifier = Modifier,
showHeaderAccent: Boolean = true,
) {
val brush = rememberHomeSkeletonBrush() val brush = rememberHomeSkeletonBrush()
val posterCardStyle = rememberPosterCardStyleUiState() val posterCardStyle = rememberPosterCardStyleUiState()
val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) { val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) {
@ -207,15 +210,17 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) {
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(brush), .background(brush),
) )
// Accent bar if (showHeaderAccent) {
Box( // Accent bar
modifier = Modifier Box(
.width(60.dp) modifier = Modifier
.height(4.dp) .width(60.dp)
.clip(RoundedCornerShape(999.dp)) .height(4.dp)
.background(brush), .clip(RoundedCornerShape(999.dp))
) .background(brush),
Spacer(modifier = Modifier.height(2.dp)) )
Spacer(modifier = Modifier.height(2.dp))
}
// Poster row // Poster row
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),

View file

@ -72,6 +72,7 @@ import com.nuvio.app.features.cloud.CloudLibraryItemType
import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.CloudLibraryUiState import com.nuvio.app.features.cloud.CloudLibraryUiState
import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomePosterCard
import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeSkeletonRow
@ -107,6 +108,10 @@ fun LibraryScreen(
WatchedRepository.ensureLoaded() WatchedRepository.ensureLoaded()
WatchedRepository.uiState WatchedRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) } var observedOfflineState by remember { mutableStateOf(false) }
var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) } var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) }
@ -230,7 +235,10 @@ fun LibraryScreen(
when { when {
!uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
items(3) { items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) HomeSkeletonRow(
modifier = Modifier.padding(horizontal = 16.dp),
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
)
} }
} }
@ -288,6 +296,7 @@ fun LibraryScreen(
librarySections( librarySections(
sections = uiState.sections, sections = uiState.sections,
watchedKeys = watchedUiState.watchedKeys, watchedKeys = watchedUiState.watchedKeys,
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
onPosterClick = onPosterClick, onPosterClick = onPosterClick,
onSectionViewAllClick = onSectionViewAllClick, onSectionViewAllClick = onSectionViewAllClick,
onPosterLongClick = onPosterLongClick, onPosterLongClick = onPosterLongClick,
@ -423,7 +432,7 @@ private fun LazyListScope.cloudLibrarySkeletonItems() {
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
} }
items(4) { items(3) {
CloudLibrarySkeletonRow() CloudLibrarySkeletonRow()
} }
} }
@ -856,24 +865,17 @@ private fun CloudLibrarySkeletonToolbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val brush = rememberCloudLibrarySkeletonBrush() val brush = rememberCloudLibrarySkeletonBrush()
Column( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
CloudSkeletonBlock(brush = brush, width = 92.dp, height = 34.dp, cornerRadius = 12.dp) CloudSkeletonBlock(brush = brush, width = 112.dp, height = 36.dp, cornerRadius = 12.dp)
CloudSkeletonBlock(brush = brush, width = 78.dp, height = 34.dp, cornerRadius = 12.dp) CloudSkeletonBlock(brush = brush, width = 92.dp, height = 36.dp, cornerRadius = 12.dp)
CloudSkeletonBlock(
brush = brush,
modifier = Modifier.weight(1f),
height = 34.dp,
cornerRadius = 12.dp,
)
CloudSkeletonBlock(brush = brush, width = 40.dp, height = 40.dp, cornerRadius = 20.dp)
} }
} }
} }
@ -903,35 +905,29 @@ private fun CloudLibrarySkeletonRow(
) { ) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(7.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
CloudSkeletonBlock( CloudSkeletonBlock(
brush = brush, brush = brush,
modifier = Modifier.fillMaxWidth(0.74f), modifier = Modifier.fillMaxWidth(0.74f),
height = 17.dp, height = 18.dp,
cornerRadius = 6.dp, cornerRadius = 6.dp,
) )
CloudSkeletonBlock( CloudSkeletonBlock(
brush = brush, brush = brush,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(0.9f),
height = 12.dp, height = 14.dp,
cornerRadius = 6.dp, cornerRadius = 6.dp,
) )
CloudSkeletonBlock( CloudSkeletonBlock(
brush = brush, brush = brush,
modifier = Modifier.fillMaxWidth(0.58f), modifier = Modifier.fillMaxWidth(0.52f),
height = 12.dp, height = 12.dp,
cornerRadius = 6.dp, cornerRadius = 6.dp,
) )
} }
CloudSkeletonBlock(brush = brush, width = 32.dp, height = 32.dp, cornerRadius = 16.dp) CloudSkeletonBlock(brush = brush, width = 48.dp, height = 48.dp, cornerRadius = 24.dp)
} }
CloudSkeletonBlock(
brush = brush,
modifier = Modifier.fillMaxWidth(0.44f),
height = 4.dp,
cornerRadius = 999.dp,
)
} }
} }
} }
@ -987,6 +983,7 @@ private enum class LibraryViewMode {
private fun LazyListScope.librarySections( private fun LazyListScope.librarySections(
sections: List<LibrarySection>, sections: List<LibrarySection>,
watchedKeys: Set<String>, watchedKeys: Set<String>,
showHeaderAccent: Boolean,
onPosterClick: ((LibraryItem) -> Unit)?, onPosterClick: ((LibraryItem) -> Unit)?,
onSectionViewAllClick: ((LibrarySection) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
@ -1001,6 +998,7 @@ private fun LazyListScope.librarySections(
entries = previewItems, entries = previewItems,
headerHorizontalPadding = 16.dp, headerHorizontalPadding = 16.dp,
rowContentPadding = PaddingValues(horizontal = 16.dp), rowContentPadding = PaddingValues(horizontal = 16.dp),
showHeaderAccent = showHeaderAccent,
onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) { onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) {
onSectionViewAllClick?.let { { it(section) } } onSectionViewAllClick?.let { { it(section) } }
} else { } else {

View file

@ -104,7 +104,10 @@ fun SearchScreen(
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val homeCatalogSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
@ -305,13 +308,19 @@ fun SearchScreen(
when { when {
isWaitingForSearch -> { isWaitingForSearch -> {
items(2) { items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) HomeSkeletonRow(
modifier = Modifier.padding(horizontal = homeSectionPadding),
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
)
} }
} }
uiState.isLoading && uiState.sections.isEmpty() -> { uiState.isLoading && uiState.sections.isEmpty() -> {
items(2) { items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) HomeSkeletonRow(
modifier = Modifier.padding(horizontal = homeSectionPadding),
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
)
} }
} }
@ -351,7 +360,10 @@ fun SearchScreen(
} }
if (uiState.isLoading) { if (uiState.isLoading) {
item(key = "search_loading_more") { item(key = "search_loading_more") {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) HomeSkeletonRow(
modifier = Modifier.padding(horizontal = homeSectionPadding),
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
)
} }
} }
} }

View file

@ -123,12 +123,15 @@ import nuvio.composeapp.generated.resources.settings_debrid_section_providers
import nuvio.composeapp.generated.resources.settings_debrid_section_title import nuvio.composeapp.generated.resources.settings_debrid_section_title
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
private const val CLOUD_SERVICES_FAQ_URL = "https://nuvioapp.space/faq#common-cloud-library-and-cloud-services"
internal fun LazyListScope.debridSettingsContent( internal fun LazyListScope.debridSettingsContent(
isTablet: Boolean, isTablet: Boolean,
settings: DebridSettings, settings: DebridSettings,
) { ) {
item { item {
var showResolverProviderDialog by rememberSaveable { mutableStateOf(false) } var showResolverProviderDialog by rememberSaveable { mutableStateOf(false) }
val uriHandler = LocalUriHandler.current
val resolverProviders = settings.resolverServices.map { it.provider } val resolverProviders = settings.resolverServices.map { it.provider }
val activeResolverProvider = settings.activeResolverCredential?.provider val activeResolverProvider = settings.activeResolverCredential?.provider
SettingsSection( SettingsSection(
@ -141,6 +144,15 @@ internal fun LazyListScope.debridSettingsContent(
text = stringResource(Res.string.settings_debrid_experimental_notice), text = stringResource(Res.string.settings_debrid_experimental_notice),
) )
SettingsGroupDivider(isTablet = isTablet) SettingsGroupDivider(isTablet = isTablet)
DebridPreferenceRow(
isTablet = isTablet,
title = "Learn more",
description = "Cloud Library, connected accounts, and playable-link preparation.",
value = "Open",
enabled = true,
onClick = { runCatching { uriHandler.openUri(CLOUD_SERVICES_FAQ_URL) } },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow( SettingsSwitchRow(
title = stringResource(Res.string.settings_debrid_cloud_library), title = stringResource(Res.string.settings_debrid_cloud_library),
description = stringResource(Res.string.settings_debrid_cloud_library_description), description = stringResource(Res.string.settings_debrid_cloud_library_description),

View file

@ -0,0 +1,50 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TorboxDeviceAuthTest {
@Test
fun `maps unused device code response to pending`() {
val response = DebridApiResponse(
status = 400,
body = TorboxEnvelopeDto<TorboxDeviceTokenDto>(
success = false,
detail = "This device code has not been used yet. Please wait for the user to scan the code.",
),
rawBody = "",
)
assertEquals(
DebridDeviceAuthorizationTokenResult.Pending,
torboxDeviceAuthorizationTokenResult(response),
)
}
@Test
fun `maps authorized and expired Torbox device states`() {
assertTrue(
torboxDeviceAuthorizationTokenResult(
DebridApiResponse(
status = 200,
body = TorboxEnvelopeDto(
success = true,
data = TorboxDeviceTokenDto(accessToken = "tb-token", tokenType = "Bearer"),
),
rawBody = "",
),
) is DebridDeviceAuthorizationTokenResult.Authorized,
)
assertEquals(
DebridDeviceAuthorizationTokenResult.Expired,
torboxDeviceAuthorizationTokenResult(
DebridApiResponse(
status = 410,
body = TorboxEnvelopeDto(success = false, detail = "Device code expired."),
rawBody = "",
),
),
)
}
}