diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 822df483..4a618795 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -32,6 +32,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.trakt.TraktAuthStorage import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage +import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage @@ -60,6 +61,7 @@ class MainActivity : ComponentActivity() { SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext) + PosterCardStyleStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 8f9d2dd4..410029d7 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -10,6 +10,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_player_settings", "nuvio_profile_cache", "nuvio_theme_settings", + "nuvio_poster_card_style", "nuvio_mdblist_settings", "nuvio_trakt_auth", "nuvio_watched", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.android.kt new file mode 100644 index 00000000..eff4dd80 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.core.ui + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object PosterCardStyleStorage { + private const val preferencesName = "nuvio_poster_card_style" + private const val payloadKey = "poster_card_style_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 13034a2a..ca9f2609 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -20,6 +20,7 @@ import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watched.WatchedRepository @@ -43,6 +44,7 @@ internal object LocalAccountDataCleaner { EpisodeReleaseNotificationsRepository.clearLocalState() CollectionRepository.clearLocalState() ThemeSettingsRepository.clearLocalState() + PosterCardStyleRepository.clearLocalState() TraktAuthRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() CatalogRepository.clear() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index aaf9fca0..a56aefb8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -13,6 +13,8 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepositor import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.core.ui.PosterCardStyleRepository +import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsStorage @@ -148,6 +150,7 @@ object ProfileSettingsSync { val signatureFlows = listOf( ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" }, + PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, @@ -190,6 +193,7 @@ object ProfileSettingsSync { return MobileProfileSettingsBlob( features = MobileProfileSettingsFeatures( themeSettings = ThemeSettingsStorage.exportToSyncPayload(), + posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), @@ -207,6 +211,9 @@ object ProfileSettingsSync { ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings) ThemeSettingsRepository.onProfileChanged() + PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload) + PosterCardStyleRepository.onProfileChanged() + PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsRepository.onProfileChanged() @@ -231,6 +238,7 @@ object ProfileSettingsSync { private fun ensureRepositoriesLoaded() { ThemeSettingsRepository.ensureLoaded() + PosterCardStyleRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() @@ -249,6 +257,7 @@ object ProfileSettingsSync { private fun currentObservedStateSignature(): String = listOf( "theme=${ThemeSettingsRepository.selectedTheme.value.name}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}", + "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", @@ -268,6 +277,7 @@ private data class MobileProfileSettingsBlob( @Serializable private data class MobileProfileSettingsFeatures( @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "", @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index 5c2d178d..1cf4db94 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -103,6 +103,8 @@ fun NuvioContinueWatchingActionSheet( private fun ContinueWatchingSheetHeader( item: ContinueWatchingItem, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + Row( modifier = Modifier .fillMaxWidth() @@ -113,7 +115,7 @@ private fun ContinueWatchingSheetHeader( Box( modifier = Modifier .size(width = 64.dp, height = 92.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index a3fc1897..d3ee69d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -140,6 +140,8 @@ fun NuvioAnimatedWatchedBadge( private fun PosterSheetHeader( item: MetaPreview, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + Row( modifier = Modifier .fillMaxWidth() @@ -150,7 +152,7 @@ private fun PosterSheetHeader( Box( modifier = Modifier .size(width = 64.dp, height = 92.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index 93c36030..b1139b86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -98,19 +99,31 @@ fun NuvioPosterCard( modifier: Modifier = Modifier, shape: NuvioPosterShape = NuvioPosterShape.Poster, detailLine: String? = null, + showTitleBelow: Boolean = true, + bottomLeftLogoUrl: String? = null, + bottomLeftText: String? = null, isWatched: Boolean = false, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp) + val cardShape = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp) + val catalogLogoOverlaySize = catalogLogoOverlaySize( + basePosterWidthDp = posterCardStyle.widthDp, + shape = shape, + ) + val shouldShowTitleBelow = showTitleBelow && !posterCardStyle.hideLabelsEnabled + Column( - modifier = modifier.width(shape.cardWidth), + modifier = modifier.width(cardWidth), verticalArrangement = Arrangement.spacedBy(6.dp), ) { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(shape.aspectRatio) - .clip(RoundedCornerShape(16.dp)) + .clip(cardShape) .background(MaterialTheme.colorScheme.surface) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), contentAlignment = Alignment.Center, @@ -133,6 +146,35 @@ fun NuvioPosterCard( overflow = TextOverflow.Ellipsis, ) } + + if (!bottomLeftLogoUrl.isNullOrBlank() || !bottomLeftText.isNullOrBlank()) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 10.dp, vertical = 10.dp), + ) { + if (!bottomLeftLogoUrl.isNullOrBlank()) { + AsyncImage( + model = bottomLeftLogoUrl, + contentDescription = "$title logo", + modifier = Modifier + .width(catalogLogoOverlaySize.width) + .height(catalogLogoOverlaySize.height), + contentScale = ContentScale.Fit, + ) + } else { + Text( + text = bottomLeftText.orEmpty(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = catalogLogoOverlaySize.textMaxWidth), + ) + } + } + } + NuvioAnimatedWatchedBadge( isVisible = isWatched, modifier = Modifier @@ -140,21 +182,25 @@ fun NuvioPosterCard( .padding(6.dp), ) } - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (!detailLine.isNullOrBlank()) { + if (shouldShowTitleBelow) { Text( - text = detailLine, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (!detailLine.isNullOrBlank()) { + Text( + text = detailLine, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Box(modifier = Modifier.height(0.dp)) + } } else { Box(modifier = Modifier.height(0.dp)) } @@ -251,14 +297,40 @@ private val NuvioPosterShape.aspectRatio: Float get() = when (this) { NuvioPosterShape.Poster -> 0.675f NuvioPosterShape.Square -> 1f - NuvioPosterShape.Landscape -> 1.77f + NuvioPosterShape.Landscape -> PosterLandscapeAspectRatio } -private val NuvioPosterShape.cardWidth: Dp - get() = when (this) { - NuvioPosterShape.Poster -> 110.dp - NuvioPosterShape.Square -> 110.dp - NuvioPosterShape.Landscape -> 180.dp +private data class CatalogLogoOverlaySize( + val width: Dp, + val height: Dp, + val textMaxWidth: Dp, +) + +private fun catalogLogoOverlaySize( + basePosterWidthDp: Int, + shape: NuvioPosterShape, +): CatalogLogoOverlaySize = + if (shape == NuvioPosterShape.Landscape) { + when { + basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 92.dp, height = 24.dp, textMaxWidth = 120.dp) + basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 104.dp, height = 28.dp, textMaxWidth = 132.dp) + basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 116.dp, height = 30.dp, textMaxWidth = 144.dp) + else -> CatalogLogoOverlaySize(width = 128.dp, height = 34.dp, textMaxWidth = 156.dp) + } + } else { + when { + basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 72.dp, height = 18.dp, textMaxWidth = 92.dp) + basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 80.dp, height = 20.dp, textMaxWidth = 104.dp) + basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 88.dp, height = 22.dp, textMaxWidth = 112.dp) + else -> CatalogLogoOverlaySize(width = 96.dp, height = 24.dp, textMaxWidth = 124.dp) + } + } + +private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp = + when (this) { + NuvioPosterShape.Poster -> basePosterWidthDp.dp + NuvioPosterShape.Square -> basePosterWidthDp.dp + NuvioPosterShape.Landscape -> landscapePosterWidth(basePosterWidthDp) } @OptIn(ExperimentalFoundationApi::class) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardDimensions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardDimensions.kt new file mode 100644 index 00000000..131f1eed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardDimensions.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal const val PosterLandscapeAspectRatio = 1.77f +private const val PosterLandscapeWidthScale = 180f / 110f + +internal fun landscapePosterWidth(basePosterWidthDp: Int): Dp = + (basePosterWidthDp * PosterLandscapeWidthScale).dp + +internal fun landscapePosterHeightForWidth(width: Dp): Dp = + (width.value / PosterLandscapeAspectRatio).dp diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt new file mode 100644 index 00000000..4d69e732 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState + +@Composable +internal fun rememberPosterCardStyleUiState(): PosterCardStyleUiState { + PosterCardStyleRepository.ensureLoaded() + val uiState by PosterCardStyleRepository.uiState.collectAsState() + return uiState +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt new file mode 100644 index 00000000..80bcb0b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt @@ -0,0 +1,139 @@ +package com.nuvio.app.core.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal const val DefaultPosterCardWidthDp = 126 +internal const val DefaultPosterCardHeightDp = 189 +internal const val DefaultPosterCardCornerRadiusDp = 12 + +@Serializable +private data class StoredPosterCardStylePreferences( + val widthDp: Int = DefaultPosterCardWidthDp, + val heightDp: Int = DefaultPosterCardHeightDp, + val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, + val catalogLandscapeModeEnabled: Boolean = false, + val hideLabelsEnabled: Boolean = false, +) + +data class PosterCardStyleUiState( + val widthDp: Int = DefaultPosterCardWidthDp, + val heightDp: Int = DefaultPosterCardHeightDp, + val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, + val catalogLandscapeModeEnabled: Boolean = false, + val hideLabelsEnabled: Boolean = false, +) + +object PosterCardStyleRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(PosterCardStyleUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = PosterCardStyleUiState() + } + + fun setWidthDp(widthDp: Int) { + ensureLoaded() + val nextWidth = widthDp + val nextHeight = (nextWidth * 3) / 2 + if (_uiState.value.widthDp == nextWidth && _uiState.value.heightDp == nextHeight) return + _uiState.value = _uiState.value.copy( + widthDp = nextWidth, + heightDp = nextHeight, + ) + persist() + } + + fun setCornerRadiusDp(cornerRadiusDp: Int) { + ensureLoaded() + if (_uiState.value.cornerRadiusDp == cornerRadiusDp) return + _uiState.value = _uiState.value.copy(cornerRadiusDp = cornerRadiusDp) + persist() + } + + fun setCatalogLandscapeModeEnabled(enabled: Boolean) { + ensureLoaded() + if (_uiState.value.catalogLandscapeModeEnabled == enabled) return + _uiState.value = _uiState.value.copy(catalogLandscapeModeEnabled = enabled) + persist() + } + + fun setHideLabelsEnabled(enabled: Boolean) { + ensureLoaded() + if (_uiState.value.hideLabelsEnabled == enabled) return + _uiState.value = _uiState.value.copy(hideLabelsEnabled = enabled) + persist() + } + + fun resetToDefaults() { + ensureLoaded() + if (_uiState.value == PosterCardStyleUiState()) return + _uiState.value = PosterCardStyleUiState() + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + + val payload = PosterCardStyleStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = PosterCardStyleUiState() + return + } + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + _uiState.value = if (stored != null) { + val widthDp = stored.widthDp.takeIf { it > 0 } ?: DefaultPosterCardWidthDp + val heightDp = stored.heightDp.takeIf { it > 0 } ?: ((widthDp * 3) / 2) + val cornerRadiusDp = stored.cornerRadiusDp.coerceAtLeast(0) + PosterCardStyleUiState( + widthDp = widthDp, + heightDp = heightDp, + cornerRadiusDp = cornerRadiusDp, + catalogLandscapeModeEnabled = stored.catalogLandscapeModeEnabled, + hideLabelsEnabled = stored.hideLabelsEnabled, + ) + } else { + PosterCardStyleUiState() + } + } + + private fun persist() { + PosterCardStyleStorage.savePayload( + json.encodeToString( + StoredPosterCardStylePreferences( + widthDp = _uiState.value.widthDp, + heightDp = _uiState.value.heightDp, + cornerRadiusDp = _uiState.value.cornerRadiusDp, + catalogLandscapeModeEnabled = _uiState.value.catalogLandscapeModeEnabled, + hideLabelsEnabled = _uiState.value.hideLabelsEnabled, + ), + ), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.kt new file mode 100644 index 00000000..207a4902 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +internal expect object PosterCardStyleStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index b2d4b790..55c4faaa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -47,6 +47,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding import com.nuvio.app.features.home.MetaPreview @@ -70,6 +71,7 @@ fun CatalogScreen( modifier: Modifier = Modifier, ) { val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() + val posterCardStyle = rememberPosterCardStyleUiState() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var headerHeightPx by remember { mutableIntStateOf(0) } @@ -148,7 +150,9 @@ fun CatalogScreen( verticalArrangement = Arrangement.spacedBy(18.dp), ) { if (uiState.items.isEmpty() && uiState.isLoading) { - items(columns * 3) { CatalogSkeletonTile() } + items(columns * 3) { + CatalogSkeletonTile(cornerRadiusDp = posterCardStyle.cornerRadiusDp) + } } else if (uiState.items.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { CatalogEmptyState( @@ -174,6 +178,8 @@ fun CatalogScreen( ) { item -> CatalogPosterTile( item = item, + cornerRadiusDp = posterCardStyle.cornerRadiusDp, + hideLabels = posterCardStyle.hideLabelsEnabled, onClick = onPosterClick?.let { { it(item) } }, ) } @@ -242,6 +248,8 @@ private fun CatalogHeader( @Composable private fun CatalogPosterTile( item: MetaPreview, + cornerRadiusDp: Int, + hideLabels: Boolean, onClick: (() -> Unit)? = null, ) { Column( @@ -251,7 +259,7 @@ private fun CatalogPosterTile( modifier = Modifier .fillMaxWidth() .aspectRatio(item.posterShape.catalogAspectRatio()) - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) .posterCardClickable(onClick = onClick, onLongClick = null), ) { @@ -264,35 +272,37 @@ private fun CatalogPosterTile( ) } } - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { + if (!hideLabels) { Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + text = item.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - } else { - Spacer(modifier = Modifier.height(8.dp)) + val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } } } } @Composable -private fun CatalogSkeletonTile() { +private fun CatalogSkeletonTile(cornerRadiusDp: Int) { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(0.68f) - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 627bc666..0a00b7a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -56,6 +56,9 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest +import com.nuvio.app.core.ui.landscapePosterHeightForWidth +import com.nuvio.app.core.ui.landscapePosterWidth +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.details.components.DetailPosterRailSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.tmdb.TmdbMetadataService @@ -155,6 +158,18 @@ private fun PersonDetailContent( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + val isLandscapeShelfMode = posterCardStyle.catalogLandscapeModeEnabled + val skeletonPosterWidth = if (isLandscapeShelfMode) { + landscapePosterWidth(posterCardStyle.widthDp) + } else { + posterCardStyle.widthDp.dp + } + val skeletonPosterHeight = if (isLandscapeShelfMode) { + landscapePosterHeightForWidth(skeletonPosterWidth) + } else { + posterCardStyle.heightDp.dp + } val accentColor = MaterialTheme.colorScheme.primary val allCredits = remember(person.movieCredits, person.tvCredits) { @@ -445,6 +460,18 @@ private fun PersonDetailSkeleton( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + val isLandscapeShelfMode = posterCardStyle.catalogLandscapeModeEnabled + val skeletonPosterWidth = if (isLandscapeShelfMode) { + landscapePosterWidth(posterCardStyle.widthDp) + } else { + posterCardStyle.widthDp.dp + } + val skeletonPosterHeight = if (isLandscapeShelfMode) { + landscapePosterHeightForWidth(skeletonPosterWidth) + } else { + posterCardStyle.heightDp.dp + } val accentColor = MaterialTheme.colorScheme.primary val avatarCacheKey = avatarTransitionKey val platformContext = LocalPlatformContext.current @@ -599,24 +626,26 @@ private fun PersonDetailSkeleton( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { repeat(4) { - Column(modifier = Modifier.width(110.dp)) { + Column(modifier = Modifier.width(skeletonPosterWidth)) { Box( modifier = Modifier - .width(110.dp) - .height(163.dp) - .clip(RoundedCornerShape(16.dp)) + .width(skeletonPosterWidth) + .height(skeletonPosterHeight) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surfaceVariant), ) - Spacer(modifier = Modifier.height(6.dp)) - SkeletonLine( - widthFraction = 1f, - height = 16.dp, - ) - Spacer(modifier = Modifier.height(4.dp)) - SkeletonLine( - widthFraction = 0.56f, - height = 12.dp, - ) + if (!isLandscapeShelfMode) { + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine( + widthFraction = 1f, + height = 16.dp, + ) + Spacer(modifier = Modifier.height(4.dp)) + SkeletonLine( + widthFraction = 0.56f, + height = 12.dp, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index a6a4e072..7b0793e1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -43,6 +43,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.landscapePosterHeightForWidth +import com.nuvio.app.core.ui.landscapePosterWidth +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.details.components.DetailPosterRailSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.tmdb.TmdbEntityBrowseData @@ -297,6 +300,19 @@ private fun EntityHeroSection( @Composable private fun EntityBrowseSkeleton() { + val posterCardStyle = rememberPosterCardStyleUiState() + val isLandscapeShelfMode = posterCardStyle.catalogLandscapeModeEnabled + val skeletonPosterWidth = if (isLandscapeShelfMode) { + landscapePosterWidth(posterCardStyle.widthDp) + } else { + posterCardStyle.widthDp.dp + } + val skeletonPosterHeight = if (isLandscapeShelfMode) { + landscapePosterHeightForWidth(skeletonPosterWidth) + } else { + posterCardStyle.heightDp.dp + } + Column( modifier = Modifier .fillMaxSize() @@ -352,9 +368,9 @@ private fun EntityBrowseSkeleton() { repeat(4) { Box( modifier = Modifier - .width(110.dp) - .height(163.dp) - .clip(RoundedCornerShape(16.dp)) + .width(skeletonPosterWidth) + .height(skeletonPosterHeight) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index d1b6be59..e7561e09 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioViewAllPillSize +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.stableKey @@ -62,6 +63,8 @@ private fun HomeCatalogRowSectionContent( onPosterClick: ((MetaPreview) -> Unit)?, onPosterLongClick: ((MetaPreview) -> Unit)?, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + NuvioShelfSection( title = section.title, entries = entries, @@ -74,6 +77,7 @@ private fun HomeCatalogRowSectionContent( ) { item -> HomePosterCard( item = item, + useLandscapeBackdropMode = posterCardStyle.catalogLandscapeModeEnabled, isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, item = item, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index 4300d3bc..2c3121aa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -1,6 +1,5 @@ package com.nuvio.app.features.home.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -25,7 +24,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nuvio.app.core.ui.NuvioShelfSection +import com.nuvio.app.core.ui.PosterLandscapeAspectRatio +import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.CollectionFolder import com.nuvio.app.features.home.PosterShape @@ -86,21 +88,23 @@ private fun CollectionFolderCard( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { - val shape = folder.posterShape + val posterCardStyle = rememberPosterCardStyleUiState() + val isLandscapeMode = posterCardStyle.catalogLandscapeModeEnabled + val shape = if (isLandscapeMode) PosterShape.Landscape else folder.posterShape val cardWidth: Dp val aspectRatio: Float when (shape) { PosterShape.Poster -> { - cardWidth = 110.dp + cardWidth = posterCardStyle.widthDp.dp aspectRatio = 0.675f } PosterShape.Landscape -> { - cardWidth = 180.dp - aspectRatio = 1.77f + cardWidth = landscapePosterWidth(posterCardStyle.widthDp) + aspectRatio = PosterLandscapeAspectRatio } PosterShape.Square -> { - cardWidth = 120.dp + cardWidth = posterCardStyle.widthDp.dp aspectRatio = 1f } } @@ -109,7 +113,7 @@ private fun CollectionFolderCard( modifier = modifier.width(cardWidth), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - val shapeCorner = RoundedCornerShape(16.dp) + val shapeCorner = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp) val imageUrl = collectionFolderCardImageUrl(folder) Card( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt index a2620501..fc8b4133 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioPosterCard import com.nuvio.app.core.ui.NuvioPosterShape +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape @@ -12,16 +13,23 @@ import com.nuvio.app.features.home.PosterShape fun HomePosterCard( item: MetaPreview, modifier: Modifier = Modifier, + useLandscapeBackdropMode: Boolean = false, isWatched: Boolean = false, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + val isLandscapeMode = useLandscapeBackdropMode || posterCardStyle.catalogLandscapeModeEnabled + NuvioPosterCard( title = item.name, - imageUrl = item.poster, + imageUrl = if (isLandscapeMode) (item.banner ?: item.poster) else item.poster, modifier = modifier, - shape = item.posterShape.toNuvioPosterShape(), - detailLine = item.releaseInfo?.let { formatReleaseDateForDisplay(it) }, + shape = if (isLandscapeMode) NuvioPosterShape.Landscape else item.posterShape.toNuvioPosterShape(), + detailLine = if (isLandscapeMode || posterCardStyle.hideLabelsEnabled) null else item.releaseInfo?.let { formatReleaseDateForDisplay(it) }, + showTitleBelow = !posterCardStyle.hideLabelsEnabled, + bottomLeftLogoUrl = if (isLandscapeMode) item.logo else null, + bottomLeftText = if (isLandscapeMode && item.logo.isNullOrBlank() && !posterCardStyle.hideLabelsEnabled) item.name else null, isWatched = isWatched, onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt index f034f7f3..3609fd00 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt @@ -32,6 +32,9 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.landscapePosterHeightForWidth +import com.nuvio.app.core.ui.landscapePosterWidth +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState @Composable private fun rememberHomeSkeletonBrush(): Brush { @@ -180,6 +183,17 @@ fun HomeSkeletonHero( @Composable fun HomeSkeletonRow(modifier: Modifier = Modifier) { val brush = rememberHomeSkeletonBrush() + val posterCardStyle = rememberPosterCardStyleUiState() + val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) { + landscapePosterWidth(posterCardStyle.widthDp) + } else { + posterCardStyle.widthDp.dp + } + val skeletonHeight = if (posterCardStyle.catalogLandscapeModeEnabled) { + landscapePosterHeightForWidth(skeletonWidth) + } else { + posterCardStyle.heightDp.dp + } Column( modifier = modifier.fillMaxWidth(), @@ -209,9 +223,9 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) { repeat(4) { Box( modifier = Modifier - .width(110.dp) - .height(163.dp) - .clip(RoundedCornerShape(16.dp)) + .width(skeletonWidth) + .height(skeletonHeight) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) .background(brush), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 522a0c40..b9871947 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -10,6 +10,7 @@ import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository +import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository @@ -138,6 +139,7 @@ object ProfileRepository { PluginRepository.onProfileChanged(profileIndex) } ThemeSettingsRepository.onProfileChanged() + PosterCardStyleRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged() HomeCatalogSettingsRepository.onProfileChanged() MetaScreenSettingsRepository.onProfileChanged() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index e52090cb..4e94528e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -56,6 +56,7 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape @@ -338,6 +339,8 @@ private fun DiscoverGridRow( onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -346,6 +349,8 @@ private fun DiscoverGridRow( items.forEach { item -> DiscoverPosterTile( item = item, + cornerRadiusDp = posterCardStyle.cornerRadiusDp, + hideLabels = posterCardStyle.hideLabelsEnabled, modifier = Modifier.weight(1f), isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, @@ -365,6 +370,8 @@ private fun DiscoverGridRow( @Composable private fun DiscoverPosterTile( item: MetaPreview, + cornerRadiusDp: Int, + hideLabels: Boolean, modifier: Modifier = Modifier, isWatched: Boolean = false, onClick: (() -> Unit)? = null, @@ -378,7 +385,7 @@ private fun DiscoverPosterTile( modifier = Modifier .fillMaxWidth() .aspectRatio(item.posterShape.discoverAspectRatio()) - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), ) { @@ -397,24 +404,26 @@ private fun DiscoverPosterTile( .padding(6.dp), ) } - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { + if (!hideLabels) { Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + text = item.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - } else { - Spacer(modifier = Modifier.height(8.dp)) + val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } } } } @@ -424,6 +433,8 @@ private fun DiscoverSkeletonRow( columns: Int, modifier: Modifier = Modifier, ) { + val posterCardStyle = rememberPosterCardStyleUiState() + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -433,7 +444,7 @@ private fun DiscoverSkeletonRow( modifier = Modifier .weight(1f) .aspectRatio(0.68f) - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index bf452f93..96239860 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.rounded.Style +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -41,6 +42,7 @@ internal fun LazyListScope.appearanceSettingsContent( amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, onContinueWatchingClick: () -> Unit, + onPosterCustomizationClick: () -> Unit, ) { item { SettingsSection( @@ -101,6 +103,14 @@ internal fun LazyListScope.appearanceSettingsContent( isTablet = isTablet, onClick = onContinueWatchingClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Poster Customization", + description = "Adjust shared poster card width and corner radius presets.", + icon = Icons.Rounded.Tune, + isTablet = isTablet, + onClick = onPosterCustomizationClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt new file mode 100644 index 00000000..17fc67d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt @@ -0,0 +1,310 @@ +package com.nuvio.app.features.settings + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioActionLabel +import com.nuvio.app.core.ui.PosterCardStyleRepository +import com.nuvio.app.core.ui.PosterCardStyleUiState + +internal fun LazyListScope.posterCustomizationSettingsContent( + isTablet: Boolean, + uiState: PosterCardStyleUiState, +) { + item { + SettingsSection( + title = "POSTER CARD STYLE", + isTablet = isTablet, + actions = { + NuvioActionLabel( + text = "Reset", + onClick = PosterCardStyleRepository::resetToDefaults, + ) + }, + ) { + SettingsGroup(isTablet = isTablet) { + PosterCardStyleControls( + isTablet = isTablet, + widthDp = uiState.widthDp, + cornerRadiusDp = uiState.cornerRadiusDp, + catalogLandscapeModeEnabled = uiState.catalogLandscapeModeEnabled, + hideLabelsEnabled = uiState.hideLabelsEnabled, + onWidthSelected = PosterCardStyleRepository::setWidthDp, + onCornerRadiusSelected = PosterCardStyleRepository::setCornerRadiusDp, + onCatalogLandscapeModeChange = PosterCardStyleRepository::setCatalogLandscapeModeEnabled, + onHideLabelsChange = PosterCardStyleRepository::setHideLabelsEnabled, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PosterCardStyleControls( + isTablet: Boolean, + widthDp: Int, + cornerRadiusDp: Int, + catalogLandscapeModeEnabled: Boolean, + hideLabelsEnabled: Boolean, + onWidthSelected: (Int) -> Unit, + onCornerRadiusSelected: (Int) -> Unit, + onCatalogLandscapeModeChange: (Boolean) -> Unit, + onHideLabelsChange: (Boolean) -> Unit, +) { + val widthOptions = listOf( + PresetOption("Compact", 104), + PresetOption("Dense", 112), + PresetOption("Standard", 120), + PresetOption("Balanced", 126), + PresetOption("Comfort", 134), + PresetOption("Large", 140), + ) + val radiusOptions = listOf( + PresetOption("Sharp", 0), + PresetOption("Subtle", 4), + PresetOption("Classic", 8), + PresetOption("Rounded", 12), + PresetOption("Pill", 16), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 18.dp else 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = "Customize card width and corner radius for shared poster cards across the app.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PosterCardLivePreview( + widthDp = widthDp, + cornerRadiusDp = cornerRadiusDp, + ) + PosterStyleOptionRow( + title = "Card Width", + selectedValue = widthDp, + options = widthOptions, + onSelected = onWidthSelected, + ) + PosterStyleOptionRow( + title = "Card Radius", + selectedValue = cornerRadiusDp, + options = radiusOptions, + onSelected = onCornerRadiusSelected, + ) + PosterLandscapeModeToggleRow( + checked = catalogLandscapeModeEnabled, + onCheckedChange = onCatalogLandscapeModeChange, + ) + PosterToggleRow( + title = "Hide labels", + checked = hideLabelsEnabled, + onCheckedChange = onHideLabelsChange, + ) + } +} + +@Composable +private fun PosterLandscapeModeToggleRow( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + PosterToggleRow( + title = "Landscape mode for shelf posters", + checked = checked, + onCheckedChange = onCheckedChange, + ) +} + +@Composable +private fun PosterToggleRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + } +} + +@Composable +private fun PosterCardLivePreview( + widthDp: Int, + cornerRadiusDp: Int, +) { + val targetHeightDp = (widthDp * 3) / 2 + val previewFrameWidthDp = 140 + val previewFrameHeightDp = 210 + val animatedWidth = animateDpAsState( + targetValue = widthDp.dp, + animationSpec = tween(durationMillis = 280), + label = "posterPreviewWidth", + ) + val animatedHeight = animateDpAsState( + targetValue = targetHeightDp.dp, + animationSpec = tween(durationMillis = 280), + label = "posterPreviewHeight", + ) + val animatedCornerRadius = animateDpAsState( + targetValue = cornerRadiusDp.dp, + animationSpec = tween(durationMillis = 220), + label = "posterPreviewCornerRadius", + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Live Preview", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(previewFrameWidthDp.dp) + .height(previewFrameHeightDp.dp), + ) { + Box( + modifier = Modifier + .width(animatedWidth.value) + .height(animatedHeight.value) + .clip(RoundedCornerShape(animatedCornerRadius.value)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(animatedCornerRadius.value), + ), + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Width: ${widthDp}dp", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Corner radius: ${cornerRadiusDp}dp", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Height: ${targetHeightDp}dp", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PosterStyleOptionRow( + title: String, + selectedValue: Int, + options: List, + onSelected: (Int) -> Unit, +) { + val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label ?: "Custom" + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "$title ($selectedLabel)", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + FilterChip( + selected = option.value == selectedValue, + onClick = { onSelected(option.value) }, + label = { Text(option.label) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + } +} + +private data class PresetOption( + val label: String, + val value: Int, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 2e71a4ba..8b0f5c15 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -49,6 +49,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Appearance, ), + PosterCustomization( + title = "Poster Customization", + category = SettingsCategory.General, + parentPage = Appearance, + ), ContentDiscovery( title = "Content & Discovery", category = SettingsCategory.General, 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 f5dfad2d..fd861d66 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 @@ -42,6 +42,8 @@ import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsUiState +import com.nuvio.app.core.ui.PosterCardStyleRepository +import com.nuvio.app.core.ui.PosterCardStyleUiState import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettings @@ -129,6 +131,10 @@ fun SettingsScreen( ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.uiState }.collectAsStateWithLifecycle() + val posterCardStyleUiState by remember { + PosterCardStyleRepository.ensureLoaded() + PosterCardStyleRepository.uiState + }.collectAsStateWithLifecycle() val episodeReleaseNotificationsUiState by remember { EpisodeReleaseNotificationsRepository.ensureLoaded() EpisodeReleaseNotificationsRepository.uiState @@ -179,6 +185,7 @@ fun SettingsScreen( homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, + posterCardStyleUiState = posterCardStyleUiState, onSwitchProfile = onSwitchProfile, onDownloadsClick = onDownloadsClick, onCollectionsClick = onCollectionsClick, @@ -214,6 +221,7 @@ fun SettingsScreen( homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, + posterCardStyleUiState = posterCardStyleUiState, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenClick, onMetaScreenClick = onMetaScreenClick, @@ -259,6 +267,7 @@ private fun MobileSettingsScreen( homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, + posterCardStyleUiState: PosterCardStyleUiState, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onMetaScreenClick: () -> Unit = {}, @@ -318,6 +327,7 @@ private fun MobileSettingsScreen( amoledEnabled = amoledEnabled, onAmoledToggle = onAmoledToggle, onContinueWatchingClick = onContinueWatchingClick, + onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, ) SettingsPage.Notifications -> notificationsSettingsContent( isTablet = false, @@ -330,6 +340,10 @@ private fun MobileSettingsScreen( upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) + SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( + isTablet = false, + uiState = posterCardStyleUiState, + ) SettingsPage.ContentDiscovery -> contentDiscoveryContent( isTablet = false, showPluginsEntry = AppFeaturePolicy.pluginsEnabled, @@ -404,6 +418,7 @@ private fun TabletSettingsScreen( homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, + posterCardStyleUiState: PosterCardStyleUiState, onSwitchProfile: (() -> Unit)? = null, onDownloadsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {}, @@ -526,6 +541,7 @@ private fun TabletSettingsScreen( amoledEnabled = amoledEnabled, onAmoledToggle = onAmoledToggle, onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, + onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, ) SettingsPage.Notifications -> notificationsSettingsContent( isTablet = true, @@ -538,6 +554,10 @@ private fun TabletSettingsScreen( upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) + SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( + isTablet = true, + uiState = posterCardStyleUiState, + ) SettingsPage.ContentDiscovery -> contentDiscoveryContent( isTablet = true, showPluginsEntry = AppFeaturePolicy.pluginsEnabled, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index e785c453..b59f9ad1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -32,6 +32,7 @@ object TmdbMetadataService { private val entityBrowseCache = mutableMapOf() private val entityHeaderCache = mutableMapOf() private val entityRailCache = mutableMapOf>() + private val previewArtworkCache = mutableMapOf() suspend fun fetchPersonDetail( personId: Int, @@ -75,16 +76,16 @@ object TmdbMetadataService { val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment) - val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty()) - val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty()) + val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty(), language) + val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty(), language) val movieCredits = when { preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits castMovieCredits.isNotEmpty() -> castMovieCredits else -> crewMovieCredits } - val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty()) - val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty()) + val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty(), language) + val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty(), language) val tvCredits = when { preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits castTvCredits.isNotEmpty() -> castTvCredits @@ -116,88 +117,160 @@ object TmdbMetadataService { return department.isNotBlank() && department != "acting" && department != "actors" } - private fun mapPersonMovieCreditsFromCast(cast: List): List { + private suspend fun mapPersonMovieCreditsFromCast( + cast: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return cast - .filter { it.mediaType == "movie" && it.posterPath != null } + cast + .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "movie", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.releaseDate?.take(4), - rawReleaseDate = credit.releaseDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "movie", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + rawReleaseDate = credit.releaseDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } - private fun mapPersonMovieCreditsFromCrew(crew: List): List { + private suspend fun mapPersonMovieCreditsFromCrew( + crew: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return crew - .filter { it.mediaType == "movie" && it.posterPath != null } + crew + .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "movie", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.releaseDate?.take(4), - rawReleaseDate = credit.releaseDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "movie", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + rawReleaseDate = credit.releaseDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } - private fun mapPersonTvCreditsFromCast(cast: List): List { + private suspend fun mapPersonTvCreditsFromCast( + cast: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return cast - .filter { it.mediaType == "tv" && it.posterPath != null } + cast + .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "series", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.firstAirDate?.take(4), - rawReleaseDate = credit.firstAirDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "tv", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + rawReleaseDate = credit.firstAirDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } - private fun mapPersonTvCreditsFromCrew(crew: List): List { + private suspend fun mapPersonTvCreditsFromCrew( + crew: List, + language: String, + ): List = coroutineScope { val seen = mutableSetOf() - return crew - .filter { it.mediaType == "tv" && it.posterPath != null } + crew + .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${credit.id}", - type = "series", - name = title, - poster = buildImageUrl(credit.posterPath, "w500"), - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.firstAirDate?.take(4), - rawReleaseDate = credit.firstAirDate, - popularity = credit.popularity, - ) + async { + val artwork = fetchPreviewArtwork( + tmdbId = credit.id, + mediaType = "tv", + language = language, + ) + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: artwork?.backdrop + ?: return@async null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + rawReleaseDate = credit.firstAirDate, + popularity = credit.popularity, + ) + } } + .awaitAll() + .filterNotNull() } suspend fun fetchEntityBrowse( @@ -321,10 +394,16 @@ object TmdbMetadataService { val results = response?.results.orEmpty() val totalPages = response?.totalPages ?: page - val mappedItems = results - .filter { it.id > 0 } - .mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) } - .take(ENTITY_RAIL_MAX_ITEMS) + val mappedItems = coroutineScope { + results + .filter { it.id > 0 } + .map { item -> + async { mapEntityDiscoverResult(item, mediaType, language) } + } + .awaitAll() + .filterNotNull() + .take(ENTITY_RAIL_MAX_ITEMS) + } TmdbEntityRailPageResult( items = mappedItems, @@ -406,17 +485,26 @@ object TmdbMetadataService { return header } - private fun mapEntityDiscoverResult( + private suspend fun mapEntityDiscoverResult( result: TmdbDiscoverResult, mediaType: TmdbEntityMediaType, + language: String, ): MetaPreview? { val title = result.title?.takeIf { it.isNotBlank() } ?: result.name?.takeIf { it.isNotBlank() } ?: result.originalTitle?.takeIf { it.isNotBlank() } ?: result.originalName?.takeIf { it.isNotBlank() } ?: return null + + val artwork = fetchPreviewArtwork( + tmdbId = result.id, + mediaType = mediaType.value, + language = language, + ) + val poster = buildImageUrl(result.posterPath, "w500") ?: buildImageUrl(result.backdropPath, "w780") + ?: artwork?.backdrop ?: return null val releaseInfo = when (mediaType) { TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4) @@ -427,11 +515,60 @@ object TmdbMetadataService { type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie", name = title, poster = poster, + banner = buildImageUrl(result.backdropPath, "w780") ?: artwork?.backdrop, + logo = artwork?.logo, description = result.overview?.takeIf { it.isNotBlank() }, releaseInfo = releaseInfo, ) } + private data class TmdbPreviewArtwork( + val backdrop: String?, + val logo: String?, + ) + + private suspend fun fetchPreviewArtwork( + tmdbId: Int, + mediaType: String, + language: String, + ): TmdbPreviewArtwork? = withContext(Dispatchers.Default) { + val normalizedLanguage = normalizeTmdbLanguage(language) + val cacheKey = "$tmdbId:$mediaType:$normalizedLanguage:preview_artwork" + previewArtworkCache[cacheKey]?.let { cached -> + return@withContext cached.takeIf { it.backdrop != null || it.logo != null } + } + + val includeImageLanguage = buildString { + append(normalizedLanguage.substringBefore("-")) + append(",") + append(normalizedLanguage) + append(",en,null") + } + + val response = coroutineScope { + val details = async { + fetch( + endpoint = "$mediaType/$tmdbId", + query = mapOf("language" to normalizedLanguage), + ) + } + val images = async { + fetch( + endpoint = "$mediaType/$tmdbId/images", + query = mapOf("include_image_language" to includeImageLanguage), + ) + } + details.await() to images.await() + } + + val artwork = TmdbPreviewArtwork( + backdrop = buildImageUrl(response.first?.backdropPath, "w1280"), + logo = buildImageUrl(response.second?.logos.orEmpty().selectBestLocalizedImagePath(normalizedLanguage), "w500"), + ) + previewArtworkCache[cacheKey] = artwork + artwork.takeIf { it.backdrop != null || it.logo != null } + } + private fun buildEntityMediaOrder( entityKind: TmdbEntityKind, sourceType: String, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 921ba2d8..f46b85e6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -14,6 +14,7 @@ internal actual object PlatformLocalAccountDataCleaner { private val profileScopedBaseKeys = listOf( "catalog_settings_payload", "continue_watching_preferences_payload", + "poster_card_style_payload", "episode_release_notifications_payload", "episode_release_notification_scheduled_ids", "selected_theme", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.ios.kt new file mode 100644 index 00000000..67a5083d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.core.ui + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object PosterCardStyleStorage { + private const val payloadKey = "poster_card_style_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} \ No newline at end of file