Merge branch 'postercard' into cmp-rewrite

This commit is contained in:
tapframe 2026-04-13 19:21:37 +05:30
commit a37c962b98
28 changed files with 1031 additions and 148 deletions

View file

@ -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)

View file

@ -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",

View file

@ -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()
}
}

View file

@ -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()

View file

@ -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()),

View file

@ -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,
) {

View file

@ -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,
) {

View file

@ -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,6 +182,7 @@ fun NuvioPosterCard(
.padding(6.dp),
)
}
if (shouldShowTitleBelow) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
@ -158,6 +201,9 @@ fun NuvioPosterCard(
} 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)

View file

@ -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

View file

@ -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
}

View file

@ -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<PosterCardStyleUiState> = _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<StoredPosterCardStylePreferences>(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,
),
),
)
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.core.ui
internal expect object PosterCardStyleStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -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,6 +272,7 @@ private fun CatalogPosterTile(
)
}
}
if (!hideLabels) {
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@ -285,14 +294,15 @@ private fun CatalogPosterTile(
}
}
}
}
@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),
)
}

View file

@ -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,14 +626,15 @@ 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),
)
if (!isLandscapeShelfMode) {
Spacer(modifier = Modifier.height(6.dp))
SkeletonLine(
widthFraction = 1f,
@ -620,6 +648,7 @@ private fun PersonDetailSkeleton(
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
}

View file

@ -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)),
)
}

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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),
)
}

View file

@ -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()

View file

@ -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,6 +404,7 @@ private fun DiscoverPosterTile(
.padding(6.dp),
)
}
if (!hideLabels) {
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@ -418,12 +426,15 @@ private fun DiscoverPosterTile(
}
}
}
}
@Composable
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),
)
}

View file

@ -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,
)
}
}
}

View file

@ -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<PresetOption>,
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,
)

View file

@ -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,

View file

@ -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<HomeCatalogSettingsItem>,
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<HomeCatalogSettingsItem>,
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,

View file

@ -32,6 +32,7 @@ object TmdbMetadataService {
private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>()
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
private val previewArtworkCache = mutableMapOf<String, TmdbPreviewArtwork>()
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,19 +117,34 @@ object TmdbMetadataService {
return department.isNotBlank() && department != "acting" && department != "actors"
}
private fun mapPersonMovieCreditsFromCast(cast: List<TmdbPersonCreditCast>): List<MetaPreview> {
private suspend fun mapPersonMovieCreditsFromCast(
cast: List<TmdbPersonCreditCast>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>()
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
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 = buildImageUrl(credit.posterPath, "w500"),
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,
@ -136,20 +152,38 @@ object TmdbMetadataService {
)
}
}
.awaitAll()
.filterNotNull()
}
private fun mapPersonMovieCreditsFromCrew(crew: List<TmdbPersonCreditCrew>): List<MetaPreview> {
private suspend fun mapPersonMovieCreditsFromCrew(
crew: List<TmdbPersonCreditCrew>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>()
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
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 = buildImageUrl(credit.posterPath, "w500"),
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,
@ -157,20 +191,38 @@ object TmdbMetadataService {
)
}
}
.awaitAll()
.filterNotNull()
}
private fun mapPersonTvCreditsFromCast(cast: List<TmdbPersonCreditCast>): List<MetaPreview> {
private suspend fun mapPersonTvCreditsFromCast(
cast: List<TmdbPersonCreditCast>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>()
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
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 = buildImageUrl(credit.posterPath, "w500"),
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,
@ -178,20 +230,38 @@ object TmdbMetadataService {
)
}
}
.awaitAll()
.filterNotNull()
}
private fun mapPersonTvCreditsFromCrew(crew: List<TmdbPersonCreditCrew>): List<MetaPreview> {
private suspend fun mapPersonTvCreditsFromCrew(
crew: List<TmdbPersonCreditCrew>,
language: String,
): List<MetaPreview> = coroutineScope {
val seen = mutableSetOf<Int>()
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
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 = buildImageUrl(credit.posterPath, "w500"),
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,
@ -199,6 +269,9 @@ object TmdbMetadataService {
)
}
}
.awaitAll()
.filterNotNull()
}
suspend fun fetchEntityBrowse(
entityKind: TmdbEntityKind,
@ -321,10 +394,16 @@ object TmdbMetadataService {
val results = response?.results.orEmpty()
val totalPages = response?.totalPages ?: page
val mappedItems = results
val mappedItems = coroutineScope {
results
.filter { it.id > 0 }
.mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) }
.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<TmdbDetailsResponse>(
endpoint = "$mediaType/$tmdbId",
query = mapOf("language" to normalizedLanguage),
)
}
val images = async {
fetch<TmdbImagesResponse>(
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,

View file

@ -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",

View file

@ -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))
}
}