From 02247a6287fdbdd38a343f6e629272e4058523ca Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Fri, 21 Aug 2026 21:04:57 +1200 Subject: [PATCH] 0.2.94 --- app/build.gradle.kts | 2 +- .../ponzischeme89/memby/data/SettingsStore.kt | 15 ++ .../memby/data/UserPreferences.kt | 8 + .../ponzischeme89/memby/ui/HomeOverlays.kt | 3 + .../com/ponzischeme89/memby/ui/HomeScreen.kt | 1 + .../ponzischeme89/memby/ui/MetadataHero.kt | 2 + .../memby/ui/home/HomeMetadataPanel.kt | 240 +++++++----------- .../memby/data/UserPreferencesTest.kt | 3 + .../memby/ui/MetadataHeroOrderTest.kt | 23 ++ server/internal/api/preferences.go | 15 ++ 10 files changed, 156 insertions(+), 156 deletions(-) create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6d06a51..668a52d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,7 +64,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.93" +val defaultVersionName = "0.2.94" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index d5b11ac..3527fda 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -242,6 +242,8 @@ internal object SettingsMigrationLogic { booleanPreferencesKey("show_home_card_metadata") ] ?: true, showRatingsStrip = preferences[booleanPreferencesKey("show_ratings_strip")] ?: true, + metadataHeroContentOrder = preferences[stringPreferencesKey("metadata_hero_content_order")] + ?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"), hideWatchedMovies = preferences[booleanPreferencesKey("hide_watched_movies")] ?: false, homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(), homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(), @@ -363,6 +365,8 @@ data class Settings( val showHomeCardMetadata: Boolean = true, /** Show third-party ratings on browse and detail surfaces. Profile-specific. */ val showRatingsStrip: Boolean = true, + /** Newline-separated, operator-controlled content order for the focused metadata hero. */ + val metadataHeroContentOrder: String = UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"), /** Hide fully watched movies from browse rows and hero selections. */ val hideWatchedMovies: Boolean = false, /** Newline-separated server row ids. These are profile-specific home customisations. */ @@ -511,6 +515,7 @@ data class EmbyProfile( val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE, val showHomeCardMetadata: Boolean = true, val showRatingsStrip: Boolean = true, + val metadataHeroContentOrder: String = UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"), val hideWatchedMovies: Boolean = false, val homeRowOrder: String = "", val homePinnedRows: String = "", @@ -590,6 +595,7 @@ class SettingsStore(private val context: Context) { val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style") val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata") val SHOW_RATINGS_STRIP = booleanPreferencesKey("show_ratings_strip") + val METADATA_HERO_CONTENT_ORDER = stringPreferencesKey("metadata_hero_content_order") val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies") val HOME_ROW_ORDER = stringPreferencesKey("home_row_order") val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows") @@ -862,6 +868,7 @@ class SettingsStore(private val context: Context) { store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata store[Keys.SHOW_RATINGS_STRIP] = preferences.showRatingsStrip + store[Keys.METADATA_HERO_CONTENT_ORDER] = preferences.metadataHeroContentOrder.joinToString("\n") store[Keys.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle @@ -888,6 +895,7 @@ class SettingsStore(private val context: Context) { homeArtworkStyle = preferences.homeArtworkStyle, showHomeCardMetadata = preferences.showHomeCardMetadata, showRatingsStrip = preferences.showRatingsStrip, + metadataHeroContentOrder = preferences.metadataHeroContentOrder.joinToString("\n"), hideWatchedMovies = preferences.hideWatchedMovies, showTitleLogo = preferences.showTitleLogo, welcomeQuoteStyle = preferences.welcomeQuoteStyle, @@ -1348,6 +1356,8 @@ class SettingsStore(private val context: Context) { ?: Settings.DEFAULT_HOME_ARTWORK_STYLE, showHomeCardMetadata = previous?.showHomeCardMetadata ?: true, showRatingsStrip = previous?.showRatingsStrip ?: true, + metadataHeroContentOrder = previous?.metadataHeroContentOrder + ?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"), hideWatchedMovies = previous?.hideWatchedMovies ?: false, homeRowOrder = previous?.homeRowOrder.orEmpty(), homePinnedRows = previous?.homePinnedRows.orEmpty(), @@ -1526,6 +1536,7 @@ class SettingsStore(private val context: Context) { preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip + preferences[Keys.METADATA_HERO_CONTENT_ORDER] = profile.metadataHeroContentOrder preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows @@ -1569,6 +1580,8 @@ class SettingsStore(private val context: Context) { ?: Settings.DEFAULT_HOME_ARTWORK_STYLE, showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true, showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true, + metadataHeroContentOrder = preferences[Keys.METADATA_HERO_CONTENT_ORDER] + ?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"), hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false, homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(), homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(), @@ -1637,6 +1650,8 @@ class SettingsStore(private val context: Context) { ?: Settings.DEFAULT_HOME_ARTWORK_STYLE, showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true, showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true, + metadataHeroContentOrder = preferences[Keys.METADATA_HERO_CONTENT_ORDER] + ?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"), hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false, homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(), homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(), diff --git a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt index 0f3ee76..09db26d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt @@ -36,6 +36,8 @@ data class UserPreferences( val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE, val showHomeCardMetadata: Boolean = true, val showRatingsStrip: Boolean = true, + /** Operator-controlled order of the focused metadata hero's content blocks. */ + val metadataHeroContentOrder: List = DEFAULT_METADATA_HERO_CONTENT_ORDER, val hideWatchedMovies: Boolean = false, val showTitleLogo: Boolean = true, val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE, @@ -73,6 +75,7 @@ data class UserPreferences( ) { companion object { val DEFAULT_SECTIONS: List = Settings.DEFAULT_HOME_SECTIONS.split(",") + val DEFAULT_METADATA_HERO_CONTENT_ORDER = listOf("title", "ratings", "facts", "genres", "summary") } } @@ -90,6 +93,7 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences( homeArtworkStyle = homeArtworkStyle, showHomeCardMetadata = showHomeCardMetadata, showRatingsStrip = showRatingsStrip, + metadataHeroContentOrder = metadataHeroContentOrder.decodeLineList(), hideWatchedMovies = hideWatchedMovies, showTitleLogo = showTitleLogo, welcomeQuoteStyle = welcomeQuoteStyle, @@ -134,6 +138,9 @@ fun decodeUserPreferences( homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle), showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata), showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip), + metadataHeroContentOrder = json.stringList( + "metadataHeroContentOrder", fallback.metadataHeroContentOrder, + ), hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies), showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo), welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle), @@ -169,6 +176,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject { put("homeArtworkStyle", homeArtworkStyle) put("showHomeCardMetadata", showHomeCardMetadata) put("showRatingsStrip", showRatingsStrip) + putJsonArray("metadataHeroContentOrder") { metadataHeroContentOrder.forEach { add(JsonPrimitive(it)) } } put("hideWatchedMovies", hideWatchedMovies) put("showTitleLogo", showTitleLogo) put("welcomeQuoteStyle", welcomeQuoteStyle) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index 830a00f..ae4065e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.mark +import com.ponzischeme89.memby.data.decodeLineList import android.os.Build import android.text.format.DateFormat import androidx.compose.foundation.background @@ -283,6 +284,7 @@ internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { @Composable internal fun FocusedHomeMetadata( homeViewModel: HomeViewModel, + metadataHeroContentOrder: List, modifier: Modifier = Modifier, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() @@ -293,6 +295,7 @@ internal fun FocusedHomeMetadata( MetadataHero( item = focusedItem, loading = homeContent.loading.isNotEmpty(), + contentOrder = metadataHeroContentOrder, modifier = modifier, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 3862665..40739d9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -1376,6 +1376,7 @@ internal fun HomeScreen( } else { FocusedHomeMetadata( homeViewModel = homeViewModel, + metadataHeroContentOrder = settings.metadataHeroContentOrder.decodeLineList(), modifier = Modifier .height(metadataHeight) // LazyColumn is drawn later as a sibling. Keep the hero diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt index f14004e..87f9a72 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt @@ -34,6 +34,7 @@ import com.ponzischeme89.memby.ui.theme.MembySurface internal fun MetadataHero( item: BaseItem?, loading: Boolean, + contentOrder: List = emptyList(), modifier: Modifier = Modifier, ) { Box( @@ -69,6 +70,7 @@ internal fun MetadataHero( MediaMetadataPanel( item = item, loading = loading, + contentOrder = contentOrder, modifier = Modifier .fillMaxSize() .padding( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt index 0249888..ba76935 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt @@ -2,122 +2,55 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.mark -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.onLongClick -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.R -import com.ponzischeme89.memby.BuildConfig -import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.model.BaseItem -import com.ponzischeme89.memby.data.model.MembyViewer -import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel -import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH -import com.ponzischeme89.memby.ui.detail.channelLabel -import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel import com.ponzischeme89.memby.ui.detail.formatRuntime import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyAccentInk -import com.ponzischeme89.memby.ui.theme.MembyCardCorner -import com.ponzischeme89.memby.ui.theme.MembyChipCorner -import com.ponzischeme89.memby.ui.theme.MembyControlSurface import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised import com.ponzischeme89.memby.ui.theme.MembyMutedText -import com.ponzischeme89.memby.ui.theme.MembyOnSurface -import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySurface -import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.ValueSeparator -import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig -import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels -import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig import java.util.Locale import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds // The launcher's names for the shared tokens. Both surfaces read from one palette now @@ -196,6 +129,7 @@ fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) { fun MediaMetadataPanel( item: BaseItem?, loading: Boolean, + contentOrder: List, modifier: Modifier = Modifier, ) { BoxWithConstraints(modifier) { @@ -219,7 +153,7 @@ fun MediaMetadataPanel( Text("Choose something to watch.", color = MutedText, fontSize = 14.sp) } } - else -> MetadataContent(item, contentWidth, compactLayout) + else -> MetadataContent(item, contentWidth, compactLayout, contentOrder) } } } @@ -262,6 +196,7 @@ private fun MetadataContent( item: BaseItem, contentWidth: Dp, compact: Boolean, + contentOrder: List, ) { if (item.isSchedule) { ScheduleMetadataContent(item, contentWidth, compact) @@ -278,104 +213,99 @@ private fun MetadataContent( if (repository.showTitleLogo) repository.logoUrl(item) else null } val logo = logoUrl.takeIf { !useTextTitleForLogo(it) } + val facts = buildList { + item.productionYear?.let { add(it.toString()) } + item.runtimeMinutes?.let { add(formatRuntime(it)) } + item.officialRating?.takeIf(String::isNotBlank)?.let(::add) + } + val badges = buildList { + airingBadgeLabel(item)?.let(::add) + addAll(mediaBadges(item)) + } + val genres = item.genres.take(2).joinToString(ValueSeparator) Column( modifier = Modifier.width(contentWidth).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp), ) { - Box( - modifier = Modifier - .fillMaxWidth() - .then( - if (logoUrl != null) { - Modifier.height(MetadataHeroLogoMaxHeight) - } else { - Modifier.heightIn(max = MetadataHeroLogoMaxHeight) - }, - ), - contentAlignment = Alignment.TopStart, - ) { - if (logo != null) { - TitleLogoImage( - logoUrl = logo, - contentDescription = item.seriesName?.takeIf { item.isEpisode } ?: item.name, - alignment = Alignment.TopStart, - modifier = Modifier - .width(MetadataHeroLogoMaxWidth) - .height(MetadataHeroLogoMaxHeight), + metadataHeroContentOrder(contentOrder).forEach { section -> + when (section) { + MetadataHeroSection.Title -> MetadataHeroTitle(item, logoUrl, logo, compact) + MetadataHeroSection.Ratings -> ItemRatingsStrip( + item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(), ) - } else { - Text( - item.seriesName?.takeIf { item.isEpisode } ?: item.name, - color = Color.White, - fontSize = if (compact) 25.sp else 29.sp, - lineHeight = if (compact) 28.sp else 32.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + MetadataHeroSection.Facts -> { + // Ratings identify their provider in the strip; this line is deliberately factual only. + if (facts.isNotEmpty()) { + Text(facts.joinToString(FactSeparator), color = MutedText, fontSize = 13.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis) + } + item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let { + RecommendationReasonCaption(recommendationReasonSummary(it), Modifier.fillMaxWidth()) + } + if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + badges.forEach { MediaBadge(it) } + } + } + MetadataHeroSection.Genres -> if (genres.isNotBlank()) Text( + genres, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + MetadataHeroSection.Summary -> Text( + item.overview?.takeIf(String::isNotBlank) ?: "No description available.", + color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp, + maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) } } - if (item.isEpisode && item.seriesName != null) { - Text( - listOfNotNull(item.episodeCode, item.name.takeIf(String::isNotBlank)).joinToString(FactSeparator), - color = MutedText, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - val facts = buildList { - item.productionYear?.let { add(it.toString()) } - item.runtimeMinutes?.let { add(formatRuntime(it)) } - item.officialRating?.takeIf(String::isNotBlank)?.let(::add) - item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) } - } - // No score on this line. Emby's community rating stood beside these facts in gold - // with nothing saying whose number it was; the strip below says that for every - // score it shows, and two ratings in one panel only invited the comparison. - if (facts.isNotEmpty()) { - Text( - facts.joinToString(FactSeparator), - color = MutedText, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - ItemRatingsStrip( - item = item, - load = true, - compact = true, - modifier = Modifier.fillMaxWidth(), - ) - val badges = buildList { - airingBadgeLabel(item)?.let(::add) - addAll(mediaBadges(item)) - } - item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let { - RecommendationReasonCaption( - text = recommendationReasonSummary(it), - modifier = Modifier.fillMaxWidth(), - ) - } - if (badges.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { - badges.forEach { MediaBadge(it) } - } - } - Text( - item.overview?.takeIf(String::isNotBlank) ?: "No description available.", - color = MembyMutedText, - fontSize = 14.sp, - lineHeight = 18.sp, - maxLines = if (compact) 2 else 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) MetadataStatus(item) } } +private object MetadataHeroSection { + const val Title = "title" + const val Ratings = "ratings" + const val Facts = "facts" + const val Genres = "genres" + const val Summary = "summary" + val all = listOf(Title, Ratings, Facts, Genres, Summary) +} + +internal fun metadataHeroContentOrder(order: List): List = + order.filter { it in MetadataHeroSection.all }.distinct() + .ifEmpty { MetadataHeroSection.all } + +@Composable +private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, compact: Boolean) { + Box( + modifier = Modifier.fillMaxWidth().then( + if (logoUrl != null) Modifier.height(MetadataHeroLogoMaxHeight) + else Modifier.heightIn(max = MetadataHeroLogoMaxHeight), + ), + contentAlignment = Alignment.TopStart, + ) { + if (logo != null) { + TitleLogoImage( + logoUrl = logo, + contentDescription = item.seriesName?.takeIf { item.isEpisode } ?: item.name, + alignment = Alignment.TopStart, + modifier = Modifier.width(MetadataHeroLogoMaxWidth).height(MetadataHeroLogoMaxHeight), + ) + } else { + Text(item.seriesName?.takeIf { item.isEpisode } ?: item.name, color = Color.White, + fontSize = if (compact) 25.sp else 29.sp, lineHeight = if (compact) 28.sp else 32.sp, + fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + if (item.isEpisode && item.seriesName != null) Text( + item.episodeCode + ?.filter { it.isLetterOrDigit() } + ?.lowercase(Locale.ROOT) + .orEmpty(), + color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) +} + @Composable private fun ScheduleMetadataContent( item: BaseItem, @@ -440,7 +370,7 @@ private fun ScheduleMetadataContent( modifier = Modifier.fillMaxWidth(), ) Text( - if (item.isMovieSchedule) "Upcoming digital releases" else "TV this week", + if (item.isMovieSchedule) "Upcoming Movies" else "Upcoming TV", color = QuietText, fontSize = 12.sp, ) diff --git a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt index 19755f6..22e4fa8 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt @@ -25,6 +25,7 @@ class UserPreferencesTest { homeArtworkStyle = "poster", showHomeCardMetadata = false, showRatingsStrip = false, + metadataHeroContentOrder = listOf("title", "ratings", "facts", "summary", "genres"), hideWatchedMovies = true, showTitleLogo = false, welcomeQuoteStyle = "homicidal", @@ -142,6 +143,7 @@ class UserPreferencesTest { homeArtworkStyle = "backdrop", showHomeCardMetadata = false, showRatingsStrip = false, + metadataHeroContentOrder = "title\nratings\nfacts\ngenres\nsummary", hideWatchedMovies = true, showTitleLogo = false, welcomeQuoteStyle = "positive", @@ -162,6 +164,7 @@ class UserPreferencesTest { homeArtworkStyle = "backdrop", showHomeCardMetadata = false, showRatingsStrip = false, + metadataHeroContentOrder = listOf("title", "ratings", "facts", "genres", "summary"), hideWatchedMovies = true, showTitleLogo = false, welcomeQuoteStyle = "positive", diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt new file mode 100644 index 0000000..179d1f4 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt @@ -0,0 +1,23 @@ +package com.ponzischeme89.memby.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MetadataHeroOrderTest { + + @Test + fun `server order is retained exactly`() { + assertEquals( + listOf("ratings", "title"), + metadataHeroContentOrder(listOf("ratings", "title")), + ) + } + + @Test + fun `unknown sections fall back to the default layout`() { + assertEquals( + listOf("title", "ratings", "facts", "genres", "summary"), + metadataHeroContentOrder(listOf("unknown")), + ) + } +} diff --git a/server/internal/api/preferences.go b/server/internal/api/preferences.go index 4be1a40..52c5d50 100644 --- a/server/internal/api/preferences.go +++ b/server/internal/api/preferences.go @@ -130,6 +130,21 @@ var preferenceCatalogue = []preferenceDefinition{ Description: "Show third-party ratings on browse and detail pages.", Kind: preferenceToggle, Default: true, }, + { + // This is operator-owned because the hero is a shared piece of the launcher's + // composition, not a per-television preference. The multi value keeps its order. + Key: "metadataHeroContentOrder", Name: "Metadata hero content order", Area: "Home layout", + Description: "The order of title, ratings, facts, genre and plot summary in the focused metadata hero.", + Kind: preferenceMulti, AdminOnly: true, + Default: []string{"title", "ratings", "facts", "genres", "summary"}, + Options: []preferenceOption{ + option("title", "Movie logo or title"), + option("ratings", "Ratings"), + option("facts", "Metadata facts"), + option("genres", "Genre"), + option("summary", "Plot summary"), + }, + }, { Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home", Description: "Keep fully watched films out of browse rows and the hero.",