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.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage 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.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage
@ -60,6 +61,7 @@ class MainActivity : ComponentActivity() {
SearchHistoryStorage.initialize(applicationContext) SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext)

View file

@ -10,6 +10,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_player_settings", "nuvio_player_settings",
"nuvio_profile_cache", "nuvio_profile_cache",
"nuvio_theme_settings", "nuvio_theme_settings",
"nuvio_poster_card_style",
"nuvio_mdblist_settings", "nuvio_mdblist_settings",
"nuvio_trakt_auth", "nuvio_trakt_auth",
"nuvio_watched", "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.StreamContextStore
import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository 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.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.WatchedRepository
@ -43,6 +44,7 @@ internal object LocalAccountDataCleaner {
EpisodeReleaseNotificationsRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState()
CollectionRepository.clearLocalState() CollectionRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState() ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState() TraktAuthRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState()
CatalogRepository.clear() 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.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.profiles.ProfileRepository 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.ThemeSettingsStorage
import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage
@ -148,6 +150,7 @@ object ProfileSettingsSync {
val signatureFlows = listOf( val signatureFlows = listOf(
ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.selectedTheme.map { "theme" },
ThemeSettingsRepository.amoledEnabled.map { "amoled" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" },
PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" }, PlayerSettingsRepository.uiState.map { "player" },
TmdbSettingsRepository.uiState.map { "tmdb" }, TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" }, MdbListSettingsRepository.uiState.map { "mdblist" },
@ -190,6 +193,7 @@ object ProfileSettingsSync {
return MobileProfileSettingsBlob( return MobileProfileSettingsBlob(
features = MobileProfileSettingsFeatures( features = MobileProfileSettingsFeatures(
themeSettings = ThemeSettingsStorage.exportToSyncPayload(), themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
playerSettings = PlayerSettingsStorage.exportToSyncPayload(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
@ -207,6 +211,9 @@ object ProfileSettingsSync {
ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings) ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings)
ThemeSettingsRepository.onProfileChanged() ThemeSettingsRepository.onProfileChanged()
PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload)
PosterCardStyleRepository.onProfileChanged()
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged()
@ -231,6 +238,7 @@ object ProfileSettingsSync {
private fun ensureRepositoriesLoaded() { private fun ensureRepositoriesLoaded() {
ThemeSettingsRepository.ensureLoaded() ThemeSettingsRepository.ensureLoaded()
PosterCardStyleRepository.ensureLoaded()
PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded()
@ -249,6 +257,7 @@ object ProfileSettingsSync {
private fun currentObservedStateSignature(): String = listOf( private fun currentObservedStateSignature(): String = listOf(
"theme=${ThemeSettingsRepository.selectedTheme.value.name}", "theme=${ThemeSettingsRepository.selectedTheme.value.name}",
"amoled=${ThemeSettingsRepository.amoledEnabled.value}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}",
@ -268,6 +277,7 @@ private data class MobileProfileSettingsBlob(
@Serializable @Serializable
private data class MobileProfileSettingsFeatures( private data class MobileProfileSettingsFeatures(
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), @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("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),

View file

@ -103,6 +103,8 @@ fun NuvioContinueWatchingActionSheet(
private fun ContinueWatchingSheetHeader( private fun ContinueWatchingSheetHeader(
item: ContinueWatchingItem, item: ContinueWatchingItem,
) { ) {
val posterCardStyle = rememberPosterCardStyleUiState()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -113,7 +115,7 @@ private fun ContinueWatchingSheetHeader(
Box( Box(
modifier = Modifier modifier = Modifier
.size(width = 64.dp, height = 92.dp) .size(width = 64.dp, height = 92.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View file

@ -140,6 +140,8 @@ fun NuvioAnimatedWatchedBadge(
private fun PosterSheetHeader( private fun PosterSheetHeader(
item: MetaPreview, item: MetaPreview,
) { ) {
val posterCardStyle = rememberPosterCardStyleUiState()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -150,7 +152,7 @@ private fun PosterSheetHeader(
Box( Box(
modifier = Modifier modifier = Modifier
.size(width = 64.dp, height = 92.dp) .size(width = 64.dp, height = 92.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -98,19 +99,31 @@ fun NuvioPosterCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shape: NuvioPosterShape = NuvioPosterShape.Poster, shape: NuvioPosterShape = NuvioPosterShape.Poster,
detailLine: String? = null, detailLine: String? = null,
showTitleBelow: Boolean = true,
bottomLeftLogoUrl: String? = null,
bottomLeftText: String? = null,
isWatched: Boolean = false, isWatched: Boolean = false,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> 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( Column(
modifier = modifier.width(shape.cardWidth), modifier = modifier.width(cardWidth),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(shape.aspectRatio) .aspectRatio(shape.aspectRatio)
.clip(RoundedCornerShape(16.dp)) .clip(cardShape)
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick), .posterCardClickable(onClick = onClick, onLongClick = onLongClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -133,6 +146,35 @@ fun NuvioPosterCard(
overflow = TextOverflow.Ellipsis, 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( NuvioAnimatedWatchedBadge(
isVisible = isWatched, isVisible = isWatched,
modifier = Modifier modifier = Modifier
@ -140,6 +182,7 @@ fun NuvioPosterCard(
.padding(6.dp), .padding(6.dp),
) )
} }
if (shouldShowTitleBelow) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -158,6 +201,9 @@ fun NuvioPosterCard(
} else { } else {
Box(modifier = Modifier.height(0.dp)) 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) { get() = when (this) {
NuvioPosterShape.Poster -> 0.675f NuvioPosterShape.Poster -> 0.675f
NuvioPosterShape.Square -> 1f NuvioPosterShape.Square -> 1f
NuvioPosterShape.Landscape -> 1.77f NuvioPosterShape.Landscape -> PosterLandscapeAspectRatio
} }
private val NuvioPosterShape.cardWidth: Dp private data class CatalogLogoOverlaySize(
get() = when (this) { val width: Dp,
NuvioPosterShape.Poster -> 110.dp val height: Dp,
NuvioPosterShape.Square -> 110.dp val textMaxWidth: Dp,
NuvioPosterShape.Landscape -> 180.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) @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 coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioBackButton 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.posterCardClickable
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
@ -70,6 +71,7 @@ fun CatalogScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
val posterCardStyle = rememberPosterCardStyleUiState()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) } var headerHeightPx by remember { mutableIntStateOf(0) }
@ -148,7 +150,9 @@ fun CatalogScreen(
verticalArrangement = Arrangement.spacedBy(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp),
) { ) {
if (uiState.items.isEmpty() && uiState.isLoading) { if (uiState.items.isEmpty() && uiState.isLoading) {
items(columns * 3) { CatalogSkeletonTile() } items(columns * 3) {
CatalogSkeletonTile(cornerRadiusDp = posterCardStyle.cornerRadiusDp)
}
} else if (uiState.items.isEmpty()) { } else if (uiState.items.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
CatalogEmptyState( CatalogEmptyState(
@ -174,6 +178,8 @@ fun CatalogScreen(
) { item -> ) { item ->
CatalogPosterTile( CatalogPosterTile(
item = item, item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
hideLabels = posterCardStyle.hideLabelsEnabled,
onClick = onPosterClick?.let { { it(item) } }, onClick = onPosterClick?.let { { it(item) } },
) )
} }
@ -242,6 +248,8 @@ private fun CatalogHeader(
@Composable @Composable
private fun CatalogPosterTile( private fun CatalogPosterTile(
item: MetaPreview, item: MetaPreview,
cornerRadiusDp: Int,
hideLabels: Boolean,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
Column( Column(
@ -251,7 +259,7 @@ private fun CatalogPosterTile(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(item.posterShape.catalogAspectRatio()) .aspectRatio(item.posterShape.catalogAspectRatio())
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.posterCardClickable(onClick = onClick, onLongClick = null), .posterCardClickable(onClick = onClick, onLongClick = null),
) { ) {
@ -264,6 +272,7 @@ private fun CatalogPosterTile(
) )
} }
} }
if (!hideLabels) {
Text( Text(
text = item.name, text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@ -285,14 +294,15 @@ private fun CatalogPosterTile(
} }
} }
} }
}
@Composable @Composable
private fun CatalogSkeletonTile() { private fun CatalogSkeletonTile(cornerRadiusDp: Int) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(0.68f) .aspectRatio(0.68f)
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
) )
} }

View file

@ -56,6 +56,9 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest 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.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbMetadataService
@ -155,6 +158,18 @@ private fun PersonDetailContent(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = 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 accentColor = MaterialTheme.colorScheme.primary
val allCredits = remember(person.movieCredits, person.tvCredits) { val allCredits = remember(person.movieCredits, person.tvCredits) {
@ -445,6 +460,18 @@ private fun PersonDetailSkeleton(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = 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 accentColor = MaterialTheme.colorScheme.primary
val avatarCacheKey = avatarTransitionKey val avatarCacheKey = avatarTransitionKey
val platformContext = LocalPlatformContext.current val platformContext = LocalPlatformContext.current
@ -599,14 +626,15 @@ private fun PersonDetailSkeleton(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
repeat(4) { repeat(4) {
Column(modifier = Modifier.width(110.dp)) { Column(modifier = Modifier.width(skeletonPosterWidth)) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(110.dp) .width(skeletonPosterWidth)
.height(163.dp) .height(skeletonPosterHeight)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
) )
if (!isLandscapeShelfMode) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
SkeletonLine( SkeletonLine(
widthFraction = 1f, widthFraction = 1f,
@ -620,6 +648,7 @@ private fun PersonDetailSkeleton(
} }
} }
} }
}
Spacer(modifier = Modifier.height(32.dp)) 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage 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.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbEntityBrowseData import com.nuvio.app.features.tmdb.TmdbEntityBrowseData
@ -297,6 +300,19 @@ private fun EntityHeroSection(
@Composable @Composable
private fun EntityBrowseSkeleton() { 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -352,9 +368,9 @@ private fun EntityBrowseSkeleton() {
repeat(4) { repeat(4) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(110.dp) .width(skeletonPosterWidth)
.height(163.dp) .height(skeletonPosterHeight)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), .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 androidx.compose.ui.unit.Dp
import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioViewAllPillSize 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
@ -62,6 +63,8 @@ private fun HomeCatalogRowSectionContent(
onPosterClick: ((MetaPreview) -> Unit)?, onPosterClick: ((MetaPreview) -> Unit)?,
onPosterLongClick: ((MetaPreview) -> Unit)?, onPosterLongClick: ((MetaPreview) -> Unit)?,
) { ) {
val posterCardStyle = rememberPosterCardStyleUiState()
NuvioShelfSection( NuvioShelfSection(
title = section.title, title = section.title,
entries = entries, entries = entries,
@ -74,6 +77,7 @@ private fun HomeCatalogRowSectionContent(
) { item -> ) { item ->
HomePosterCard( HomePosterCard(
item = item, item = item,
useLandscapeBackdropMode = posterCardStyle.catalogLandscapeModeEnabled,
isWatched = WatchingState.isPosterWatched( isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
item = item, item = item,

View file

@ -1,6 +1,5 @@
package com.nuvio.app.features.home.components package com.nuvio.app.features.home.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.NuvioShelfSection 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.posterCardClickable
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionFolder import com.nuvio.app.features.collection.CollectionFolder
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
@ -86,21 +88,23 @@ private fun CollectionFolderCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, 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 cardWidth: Dp
val aspectRatio: Float val aspectRatio: Float
when (shape) { when (shape) {
PosterShape.Poster -> { PosterShape.Poster -> {
cardWidth = 110.dp cardWidth = posterCardStyle.widthDp.dp
aspectRatio = 0.675f aspectRatio = 0.675f
} }
PosterShape.Landscape -> { PosterShape.Landscape -> {
cardWidth = 180.dp cardWidth = landscapePosterWidth(posterCardStyle.widthDp)
aspectRatio = 1.77f aspectRatio = PosterLandscapeAspectRatio
} }
PosterShape.Square -> { PosterShape.Square -> {
cardWidth = 120.dp cardWidth = posterCardStyle.widthDp.dp
aspectRatio = 1f aspectRatio = 1f
} }
} }
@ -109,7 +113,7 @@ private fun CollectionFolderCard(
modifier = modifier.width(cardWidth), modifier = modifier.width(cardWidth),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
val shapeCorner = RoundedCornerShape(16.dp) val shapeCorner = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)
val imageUrl = collectionFolderCardImageUrl(folder) val imageUrl = collectionFolderCardImageUrl(folder)
Card( Card(
modifier = Modifier 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.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioPosterCard import com.nuvio.app.core.ui.NuvioPosterCard
import com.nuvio.app.core.ui.NuvioPosterShape 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.MetaPreview
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
@ -12,16 +13,23 @@ import com.nuvio.app.features.home.PosterShape
fun HomePosterCard( fun HomePosterCard(
item: MetaPreview, item: MetaPreview,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
useLandscapeBackdropMode: Boolean = false,
isWatched: Boolean = false, isWatched: Boolean = false,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
) { ) {
val posterCardStyle = rememberPosterCardStyleUiState()
val isLandscapeMode = useLandscapeBackdropMode || posterCardStyle.catalogLandscapeModeEnabled
NuvioPosterCard( NuvioPosterCard(
title = item.name, title = item.name,
imageUrl = item.poster, imageUrl = if (isLandscapeMode) (item.banner ?: item.poster) else item.poster,
modifier = modifier, modifier = modifier,
shape = item.posterShape.toNuvioPosterShape(), shape = if (isLandscapeMode) NuvioPosterShape.Landscape else item.posterShape.toNuvioPosterShape(),
detailLine = item.releaseInfo?.let { formatReleaseDateForDisplay(it) }, 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, isWatched = isWatched,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,

View file

@ -32,6 +32,9 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 @Composable
private fun rememberHomeSkeletonBrush(): Brush { private fun rememberHomeSkeletonBrush(): Brush {
@ -180,6 +183,17 @@ fun HomeSkeletonHero(
@Composable @Composable
fun HomeSkeletonRow(modifier: Modifier = Modifier) { fun HomeSkeletonRow(modifier: Modifier = Modifier) {
val brush = rememberHomeSkeletonBrush() 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( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@ -209,9 +223,9 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) {
repeat(4) { repeat(4) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(110.dp) .width(skeletonWidth)
.height(163.dp) .height(skeletonHeight)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(brush), .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.downloads.DownloadsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository 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.library.LibraryRepository
import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
@ -138,6 +139,7 @@ object ProfileRepository {
PluginRepository.onProfileChanged(profileIndex) PluginRepository.onProfileChanged(profileIndex)
} }
ThemeSettingsRepository.onProfileChanged() ThemeSettingsRepository.onProfileChanged()
PosterCardStyleRepository.onProfileChanged()
PlayerSettingsRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged()
HomeCatalogSettingsRepository.onProfileChanged() HomeCatalogSettingsRepository.onProfileChanged()
MetaScreenSettingsRepository.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.NuvioModalBottomSheet
import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding 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.core.ui.posterCardClickable
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
@ -338,6 +339,8 @@ private fun DiscoverGridRow(
onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) { ) {
val posterCardStyle = rememberPosterCardStyleUiState()
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -346,6 +349,8 @@ private fun DiscoverGridRow(
items.forEach { item -> items.forEach { item ->
DiscoverPosterTile( DiscoverPosterTile(
item = item, item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
hideLabels = posterCardStyle.hideLabelsEnabled,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
isWatched = WatchingState.isPosterWatched( isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
@ -365,6 +370,8 @@ private fun DiscoverGridRow(
@Composable @Composable
private fun DiscoverPosterTile( private fun DiscoverPosterTile(
item: MetaPreview, item: MetaPreview,
cornerRadiusDp: Int,
hideLabels: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isWatched: Boolean = false, isWatched: Boolean = false,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
@ -378,7 +385,7 @@ private fun DiscoverPosterTile(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(item.posterShape.discoverAspectRatio()) .aspectRatio(item.posterShape.discoverAspectRatio())
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick), .posterCardClickable(onClick = onClick, onLongClick = onLongClick),
) { ) {
@ -397,6 +404,7 @@ private fun DiscoverPosterTile(
.padding(6.dp), .padding(6.dp),
) )
} }
if (!hideLabels) {
Text( Text(
text = item.name, text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@ -418,12 +426,15 @@ private fun DiscoverPosterTile(
} }
} }
} }
}
@Composable @Composable
private fun DiscoverSkeletonRow( private fun DiscoverSkeletonRow(
columns: Int, columns: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val posterCardStyle = rememberPosterCardStyleUiState()
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -433,7 +444,7 @@ private fun DiscoverSkeletonRow(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.aspectRatio(0.68f) .aspectRatio(0.68f)
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surface), .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.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.rounded.Style import androidx.compose.material.icons.rounded.Style
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -41,6 +42,7 @@ internal fun LazyListScope.appearanceSettingsContent(
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
onContinueWatchingClick: () -> Unit, onContinueWatchingClick: () -> Unit,
onPosterCustomizationClick: () -> Unit,
) { ) {
item { item {
SettingsSection( SettingsSection(
@ -101,6 +103,14 @@ internal fun LazyListScope.appearanceSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onClick = onContinueWatchingClick, 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, category = SettingsCategory.General,
parentPage = Appearance, parentPage = Appearance,
), ),
PosterCustomization(
title = "Poster Customization",
category = SettingsCategory.General,
parentPage = Appearance,
),
ContentDiscovery( ContentDiscovery(
title = "Content & Discovery", title = "Content & Discovery",
category = SettingsCategory.General, 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.addons.AddonRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaScreenSettingsUiState 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.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettings import com.nuvio.app.features.mdblist.MdbListSettings
@ -129,6 +131,10 @@ fun SettingsScreen(
ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.uiState ContinueWatchingPreferencesRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val posterCardStyleUiState by remember {
PosterCardStyleRepository.ensureLoaded()
PosterCardStyleRepository.uiState
}.collectAsStateWithLifecycle()
val episodeReleaseNotificationsUiState by remember { val episodeReleaseNotificationsUiState by remember {
EpisodeReleaseNotificationsRepository.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded()
EpisodeReleaseNotificationsRepository.uiState EpisodeReleaseNotificationsRepository.uiState
@ -179,6 +185,7 @@ fun SettingsScreen(
homescreenItems = homescreenSettingsUiState.items, homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState, metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
posterCardStyleUiState = posterCardStyleUiState,
onSwitchProfile = onSwitchProfile, onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onCollectionsClick = onCollectionsClick, onCollectionsClick = onCollectionsClick,
@ -214,6 +221,7 @@ fun SettingsScreen(
homescreenItems = homescreenSettingsUiState.items, homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState, metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
posterCardStyleUiState = posterCardStyleUiState,
onSwitchProfile = onSwitchProfile, onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenClick, onHomescreenClick = onHomescreenClick,
onMetaScreenClick = onMetaScreenClick, onMetaScreenClick = onMetaScreenClick,
@ -259,6 +267,7 @@ private fun MobileSettingsScreen(
homescreenItems: List<HomeCatalogSettingsItem>, homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState, metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
posterCardStyleUiState: PosterCardStyleUiState,
onSwitchProfile: (() -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null,
onHomescreenClick: () -> Unit = {}, onHomescreenClick: () -> Unit = {},
onMetaScreenClick: () -> Unit = {}, onMetaScreenClick: () -> Unit = {},
@ -318,6 +327,7 @@ private fun MobileSettingsScreen(
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle, onAmoledToggle = onAmoledToggle,
onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingClick = onContinueWatchingClick,
onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) },
) )
SettingsPage.Notifications -> notificationsSettingsContent( SettingsPage.Notifications -> notificationsSettingsContent(
isTablet = false, isTablet = false,
@ -330,6 +340,10 @@ private fun MobileSettingsScreen(
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = false,
uiState = posterCardStyleUiState,
)
SettingsPage.ContentDiscovery -> contentDiscoveryContent( SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = false, isTablet = false,
showPluginsEntry = AppFeaturePolicy.pluginsEnabled, showPluginsEntry = AppFeaturePolicy.pluginsEnabled,
@ -404,6 +418,7 @@ private fun TabletSettingsScreen(
homescreenItems: List<HomeCatalogSettingsItem>, homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState, metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
posterCardStyleUiState: PosterCardStyleUiState,
onSwitchProfile: (() -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {}, onDownloadsClick: () -> Unit = {},
onCollectionsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {},
@ -526,6 +541,7 @@ private fun TabletSettingsScreen(
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle, onAmoledToggle = onAmoledToggle,
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) },
) )
SettingsPage.Notifications -> notificationsSettingsContent( SettingsPage.Notifications -> notificationsSettingsContent(
isTablet = true, isTablet = true,
@ -538,6 +554,10 @@ private fun TabletSettingsScreen(
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = true,
uiState = posterCardStyleUiState,
)
SettingsPage.ContentDiscovery -> contentDiscoveryContent( SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = true, isTablet = true,
showPluginsEntry = AppFeaturePolicy.pluginsEnabled, showPluginsEntry = AppFeaturePolicy.pluginsEnabled,

View file

@ -32,6 +32,7 @@ object TmdbMetadataService {
private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>() private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>()
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>() private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>() private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
private val previewArtworkCache = mutableMapOf<String, TmdbPreviewArtwork>()
suspend fun fetchPersonDetail( suspend fun fetchPersonDetail(
personId: Int, personId: Int,
@ -75,16 +76,16 @@ object TmdbMetadataService {
val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment) val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment)
val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty()) val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty(), language)
val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty()) val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty(), language)
val movieCredits = when { val movieCredits = when {
preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits
castMovieCredits.isNotEmpty() -> castMovieCredits castMovieCredits.isNotEmpty() -> castMovieCredits
else -> crewMovieCredits else -> crewMovieCredits
} }
val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty()) val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty(), language)
val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty()) val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty(), language)
val tvCredits = when { val tvCredits = when {
preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits
castTvCredits.isNotEmpty() -> castTvCredits castTvCredits.isNotEmpty() -> castTvCredits
@ -116,19 +117,34 @@ object TmdbMetadataService {
return department.isNotBlank() && department != "acting" && department != "actors" 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>() val seen = mutableSetOf<Int>()
return cast cast
.filter { it.mediaType == "movie" && it.posterPath != null } .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.title ?: credit.name ?: 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( MetaPreview(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = "movie", type = "movie",
name = title, 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() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.releaseDate?.take(4), releaseInfo = credit.releaseDate?.take(4),
rawReleaseDate = credit.releaseDate, 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>() val seen = mutableSetOf<Int>()
return crew crew
.filter { it.mediaType == "movie" && it.posterPath != null } .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.title ?: credit.name ?: 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( MetaPreview(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = "movie", type = "movie",
name = title, 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() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.releaseDate?.take(4), releaseInfo = credit.releaseDate?.take(4),
rawReleaseDate = credit.releaseDate, 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>() val seen = mutableSetOf<Int>()
return cast cast
.filter { it.mediaType == "tv" && it.posterPath != null } .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.name ?: credit.title ?: 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( MetaPreview(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = "series", type = "series",
name = title, 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() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.firstAirDate?.take(4), releaseInfo = credit.firstAirDate?.take(4),
rawReleaseDate = credit.firstAirDate, 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>() val seen = mutableSetOf<Int>()
return crew crew
.filter { it.mediaType == "tv" && it.posterPath != null } .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) }
.sortedByDescending { it.voteAverage ?: 0.0 } .sortedByDescending { it.voteAverage ?: 0.0 }
.mapNotNull { credit -> .mapNotNull { credit ->
if (!seen.add(credit.id)) return@mapNotNull null if (!seen.add(credit.id)) return@mapNotNull null
val title = credit.name ?: credit.title ?: 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( MetaPreview(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = "series", type = "series",
name = title, 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() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = credit.firstAirDate?.take(4), releaseInfo = credit.firstAirDate?.take(4),
rawReleaseDate = credit.firstAirDate, rawReleaseDate = credit.firstAirDate,
@ -199,6 +269,9 @@ object TmdbMetadataService {
) )
} }
} }
.awaitAll()
.filterNotNull()
}
suspend fun fetchEntityBrowse( suspend fun fetchEntityBrowse(
entityKind: TmdbEntityKind, entityKind: TmdbEntityKind,
@ -321,10 +394,16 @@ object TmdbMetadataService {
val results = response?.results.orEmpty() val results = response?.results.orEmpty()
val totalPages = response?.totalPages ?: page val totalPages = response?.totalPages ?: page
val mappedItems = results val mappedItems = coroutineScope {
results
.filter { it.id > 0 } .filter { it.id > 0 }
.mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) } .map { item ->
async { mapEntityDiscoverResult(item, mediaType, language) }
}
.awaitAll()
.filterNotNull()
.take(ENTITY_RAIL_MAX_ITEMS) .take(ENTITY_RAIL_MAX_ITEMS)
}
TmdbEntityRailPageResult( TmdbEntityRailPageResult(
items = mappedItems, items = mappedItems,
@ -406,17 +485,26 @@ object TmdbMetadataService {
return header return header
} }
private fun mapEntityDiscoverResult( private suspend fun mapEntityDiscoverResult(
result: TmdbDiscoverResult, result: TmdbDiscoverResult,
mediaType: TmdbEntityMediaType, mediaType: TmdbEntityMediaType,
language: String,
): MetaPreview? { ): MetaPreview? {
val title = result.title?.takeIf { it.isNotBlank() } val title = result.title?.takeIf { it.isNotBlank() }
?: result.name?.takeIf { it.isNotBlank() } ?: result.name?.takeIf { it.isNotBlank() }
?: result.originalTitle?.takeIf { it.isNotBlank() } ?: result.originalTitle?.takeIf { it.isNotBlank() }
?: result.originalName?.takeIf { it.isNotBlank() } ?: result.originalName?.takeIf { it.isNotBlank() }
?: return null ?: return null
val artwork = fetchPreviewArtwork(
tmdbId = result.id,
mediaType = mediaType.value,
language = language,
)
val poster = buildImageUrl(result.posterPath, "w500") val poster = buildImageUrl(result.posterPath, "w500")
?: buildImageUrl(result.backdropPath, "w780") ?: buildImageUrl(result.backdropPath, "w780")
?: artwork?.backdrop
?: return null ?: return null
val releaseInfo = when (mediaType) { val releaseInfo = when (mediaType) {
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4) TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
@ -427,11 +515,60 @@ object TmdbMetadataService {
type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie", type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie",
name = title, name = title,
poster = poster, poster = poster,
banner = buildImageUrl(result.backdropPath, "w780") ?: artwork?.backdrop,
logo = artwork?.logo,
description = result.overview?.takeIf { it.isNotBlank() }, description = result.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo, 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( private fun buildEntityMediaOrder(
entityKind: TmdbEntityKind, entityKind: TmdbEntityKind,
sourceType: String, sourceType: String,

View file

@ -14,6 +14,7 @@ internal actual object PlatformLocalAccountDataCleaner {
private val profileScopedBaseKeys = listOf( private val profileScopedBaseKeys = listOf(
"catalog_settings_payload", "catalog_settings_payload",
"continue_watching_preferences_payload", "continue_watching_preferences_payload",
"poster_card_style_payload",
"episode_release_notifications_payload", "episode_release_notifications_payload",
"episode_release_notification_scheduled_ids", "episode_release_notification_scheduled_ids",
"selected_theme", "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))
}
}