This commit is contained in:
ponzischeme89
2026-08-21 21:04:57 +12:00
parent 9c7dec32a8
commit 02247a6287
10 changed files with 156 additions and 156 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.93" val defaultVersionName = "0.2.94"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -242,6 +242,8 @@ internal object SettingsMigrationLogic {
booleanPreferencesKey("show_home_card_metadata") booleanPreferencesKey("show_home_card_metadata")
] ?: true, ] ?: true,
showRatingsStrip = preferences[booleanPreferencesKey("show_ratings_strip")] ?: 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, hideWatchedMovies = preferences[booleanPreferencesKey("hide_watched_movies")] ?: false,
homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(), homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(),
homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(), homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(),
@@ -363,6 +365,8 @@ data class Settings(
val showHomeCardMetadata: Boolean = true, val showHomeCardMetadata: Boolean = true,
/** Show third-party ratings on browse and detail surfaces. Profile-specific. */ /** Show third-party ratings on browse and detail surfaces. Profile-specific. */
val showRatingsStrip: Boolean = true, 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. */ /** Hide fully watched movies from browse rows and hero selections. */
val hideWatchedMovies: Boolean = false, val hideWatchedMovies: Boolean = false,
/** Newline-separated server row ids. These are profile-specific home customisations. */ /** 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 homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true, val showHomeCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true, val showRatingsStrip: Boolean = true,
val metadataHeroContentOrder: String = UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
val hideWatchedMovies: Boolean = false, val hideWatchedMovies: Boolean = false,
val homeRowOrder: String = "", val homeRowOrder: String = "",
val homePinnedRows: String = "", val homePinnedRows: String = "",
@@ -590,6 +595,7 @@ class SettingsStore(private val context: Context) {
val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style") val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata") val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
val SHOW_RATINGS_STRIP = booleanPreferencesKey("show_ratings_strip") 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 HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order") val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows") 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.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
store[Keys.SHOW_RATINGS_STRIP] = preferences.showRatingsStrip 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.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
@@ -888,6 +895,7 @@ class SettingsStore(private val context: Context) {
homeArtworkStyle = preferences.homeArtworkStyle, homeArtworkStyle = preferences.homeArtworkStyle,
showHomeCardMetadata = preferences.showHomeCardMetadata, showHomeCardMetadata = preferences.showHomeCardMetadata,
showRatingsStrip = preferences.showRatingsStrip, showRatingsStrip = preferences.showRatingsStrip,
metadataHeroContentOrder = preferences.metadataHeroContentOrder.joinToString("\n"),
hideWatchedMovies = preferences.hideWatchedMovies, hideWatchedMovies = preferences.hideWatchedMovies,
showTitleLogo = preferences.showTitleLogo, showTitleLogo = preferences.showTitleLogo,
welcomeQuoteStyle = preferences.welcomeQuoteStyle, welcomeQuoteStyle = preferences.welcomeQuoteStyle,
@@ -1348,6 +1356,8 @@ class SettingsStore(private val context: Context) {
?: Settings.DEFAULT_HOME_ARTWORK_STYLE, ?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true, showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
showRatingsStrip = previous?.showRatingsStrip ?: true, showRatingsStrip = previous?.showRatingsStrip ?: true,
metadataHeroContentOrder = previous?.metadataHeroContentOrder
?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
hideWatchedMovies = previous?.hideWatchedMovies ?: false, hideWatchedMovies = previous?.hideWatchedMovies ?: false,
homeRowOrder = previous?.homeRowOrder.orEmpty(), homeRowOrder = previous?.homeRowOrder.orEmpty(),
homePinnedRows = previous?.homePinnedRows.orEmpty(), homePinnedRows = previous?.homePinnedRows.orEmpty(),
@@ -1526,6 +1536,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip
preferences[Keys.METADATA_HERO_CONTENT_ORDER] = profile.metadataHeroContentOrder
preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
@@ -1569,6 +1580,8 @@ class SettingsStore(private val context: Context) {
?: Settings.DEFAULT_HOME_ARTWORK_STYLE, ?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true, showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: 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, hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(), homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(), homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
@@ -1637,6 +1650,8 @@ class SettingsStore(private val context: Context) {
?: Settings.DEFAULT_HOME_ARTWORK_STYLE, ?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true, showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: 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, hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(), homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(), homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
@@ -36,6 +36,8 @@ data class UserPreferences(
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE, val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true, val showHomeCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true, val showRatingsStrip: Boolean = true,
/** Operator-controlled order of the focused metadata hero's content blocks. */
val metadataHeroContentOrder: List<String> = DEFAULT_METADATA_HERO_CONTENT_ORDER,
val hideWatchedMovies: Boolean = false, val hideWatchedMovies: Boolean = false,
val showTitleLogo: Boolean = true, val showTitleLogo: Boolean = true,
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE, val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
@@ -73,6 +75,7 @@ data class UserPreferences(
) { ) {
companion object { companion object {
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",") val DEFAULT_SECTIONS: List<String> = 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, homeArtworkStyle = homeArtworkStyle,
showHomeCardMetadata = showHomeCardMetadata, showHomeCardMetadata = showHomeCardMetadata,
showRatingsStrip = showRatingsStrip, showRatingsStrip = showRatingsStrip,
metadataHeroContentOrder = metadataHeroContentOrder.decodeLineList(),
hideWatchedMovies = hideWatchedMovies, hideWatchedMovies = hideWatchedMovies,
showTitleLogo = showTitleLogo, showTitleLogo = showTitleLogo,
welcomeQuoteStyle = welcomeQuoteStyle, welcomeQuoteStyle = welcomeQuoteStyle,
@@ -134,6 +138,9 @@ fun decodeUserPreferences(
homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle), homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle),
showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata), showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata),
showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip), showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip),
metadataHeroContentOrder = json.stringList(
"metadataHeroContentOrder", fallback.metadataHeroContentOrder,
),
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies), hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo), showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle), welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
@@ -169,6 +176,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("homeArtworkStyle", homeArtworkStyle) put("homeArtworkStyle", homeArtworkStyle)
put("showHomeCardMetadata", showHomeCardMetadata) put("showHomeCardMetadata", showHomeCardMetadata)
put("showRatingsStrip", showRatingsStrip) put("showRatingsStrip", showRatingsStrip)
putJsonArray("metadataHeroContentOrder") { metadataHeroContentOrder.forEach { add(JsonPrimitive(it)) } }
put("hideWatchedMovies", hideWatchedMovies) put("hideWatchedMovies", hideWatchedMovies)
put("showTitleLogo", showTitleLogo) put("showTitleLogo", showTitleLogo)
put("welcomeQuoteStyle", welcomeQuoteStyle) put("welcomeQuoteStyle", welcomeQuoteStyle)
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark import com.ponzischeme89.memby.ui.theme.mark
import com.ponzischeme89.memby.data.decodeLineList
import android.os.Build import android.os.Build
import android.text.format.DateFormat import android.text.format.DateFormat
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -283,6 +284,7 @@ internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
@Composable @Composable
internal fun FocusedHomeMetadata( internal fun FocusedHomeMetadata(
homeViewModel: HomeViewModel, homeViewModel: HomeViewModel,
metadataHeroContentOrder: List<String>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
@@ -293,6 +295,7 @@ internal fun FocusedHomeMetadata(
MetadataHero( MetadataHero(
item = focusedItem, item = focusedItem,
loading = homeContent.loading.isNotEmpty(), loading = homeContent.loading.isNotEmpty(),
contentOrder = metadataHeroContentOrder,
modifier = modifier, modifier = modifier,
) )
} }
@@ -1376,6 +1376,7 @@ internal fun HomeScreen(
} else { } else {
FocusedHomeMetadata( FocusedHomeMetadata(
homeViewModel = homeViewModel, homeViewModel = homeViewModel,
metadataHeroContentOrder = settings.metadataHeroContentOrder.decodeLineList(),
modifier = Modifier modifier = Modifier
.height(metadataHeight) .height(metadataHeight)
// LazyColumn is drawn later as a sibling. Keep the hero // LazyColumn is drawn later as a sibling. Keep the hero
@@ -34,6 +34,7 @@ import com.ponzischeme89.memby.ui.theme.MembySurface
internal fun MetadataHero( internal fun MetadataHero(
item: BaseItem?, item: BaseItem?,
loading: Boolean, loading: Boolean,
contentOrder: List<String> = emptyList(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Box(
@@ -69,6 +70,7 @@ internal fun MetadataHero(
MediaMetadataPanel( MediaMetadataPanel(
item = item, item = item,
loading = loading, loading = loading,
contentOrder = contentOrder,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding( .padding(
@@ -2,122 +2,55 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark 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.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.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
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Brush
import androidx.compose.ui.graphics.Color 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.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.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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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 androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator 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.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.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent 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.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyMutedText 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.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface 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.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 java.util.Locale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
// The launcher's names for the shared tokens. Both surfaces read from one palette now // 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( fun MediaMetadataPanel(
item: BaseItem?, item: BaseItem?,
loading: Boolean, loading: Boolean,
contentOrder: List<String>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
@@ -219,7 +153,7 @@ fun MediaMetadataPanel(
Text("Choose something to watch.", color = MutedText, fontSize = 14.sp) 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, item: BaseItem,
contentWidth: Dp, contentWidth: Dp,
compact: Boolean, compact: Boolean,
contentOrder: List<String>,
) { ) {
if (item.isSchedule) { if (item.isSchedule) {
ScheduleMetadataContent(item, contentWidth, compact) ScheduleMetadataContent(item, contentWidth, compact)
@@ -278,19 +213,74 @@ private fun MetadataContent(
if (repository.showTitleLogo) repository.logoUrl(item) else null if (repository.showTitleLogo) repository.logoUrl(item) else null
} }
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) } 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( Column(
modifier = Modifier.width(contentWidth).fillMaxHeight(), modifier = Modifier.width(contentWidth).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp), verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp),
) { ) {
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(),
)
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(),
)
}
}
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<String>): List<String> =
order.filter { it in MetadataHeroSection.all }.distinct()
.ifEmpty { MetadataHeroSection.all }
@Composable
private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, compact: Boolean) {
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxWidth().then(
.fillMaxWidth() if (logoUrl != null) Modifier.height(MetadataHeroLogoMaxHeight)
.then( else Modifier.heightIn(max = MetadataHeroLogoMaxHeight),
if (logoUrl != null) {
Modifier.height(MetadataHeroLogoMaxHeight)
} else {
Modifier.heightIn(max = MetadataHeroLogoMaxHeight)
},
), ),
contentAlignment = Alignment.TopStart, contentAlignment = Alignment.TopStart,
) { ) {
@@ -299,81 +289,21 @@ private fun MetadataContent(
logoUrl = logo, logoUrl = logo,
contentDescription = item.seriesName?.takeIf { item.isEpisode } ?: item.name, contentDescription = item.seriesName?.takeIf { item.isEpisode } ?: item.name,
alignment = Alignment.TopStart, alignment = Alignment.TopStart,
modifier = Modifier modifier = Modifier.width(MetadataHeroLogoMaxWidth).height(MetadataHeroLogoMaxHeight),
.width(MetadataHeroLogoMaxWidth)
.height(MetadataHeroLogoMaxHeight),
) )
} else { } else {
Text( Text(item.seriesName?.takeIf { item.isEpisode } ?: item.name, color = Color.White,
item.seriesName?.takeIf { item.isEpisode } ?: item.name, fontSize = if (compact) 25.sp else 29.sp, lineHeight = if (compact) 28.sp else 32.sp,
color = Color.White, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
fontSize = if (compact) 25.sp else 29.sp, }
lineHeight = if (compact) 28.sp else 32.sp, }
fontWeight = FontWeight.SemiBold, if (item.isEpisode && item.seriesName != null) Text(
maxLines = 2, item.episodeCode
overflow = TextOverflow.Ellipsis, ?.filter { it.isLetterOrDigit() }
?.lowercase(Locale.ROOT)
.orEmpty(),
color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
) )
}
}
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)
}
} }
@Composable @Composable
@@ -440,7 +370,7 @@ private fun ScheduleMetadataContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Text( Text(
if (item.isMovieSchedule) "Upcoming digital releases" else "TV this week", if (item.isMovieSchedule) "Upcoming Movies" else "Upcoming TV",
color = QuietText, color = QuietText,
fontSize = 12.sp, fontSize = 12.sp,
) )
@@ -25,6 +25,7 @@ class UserPreferencesTest {
homeArtworkStyle = "poster", homeArtworkStyle = "poster",
showHomeCardMetadata = false, showHomeCardMetadata = false,
showRatingsStrip = false, showRatingsStrip = false,
metadataHeroContentOrder = listOf("title", "ratings", "facts", "summary", "genres"),
hideWatchedMovies = true, hideWatchedMovies = true,
showTitleLogo = false, showTitleLogo = false,
welcomeQuoteStyle = "homicidal", welcomeQuoteStyle = "homicidal",
@@ -142,6 +143,7 @@ class UserPreferencesTest {
homeArtworkStyle = "backdrop", homeArtworkStyle = "backdrop",
showHomeCardMetadata = false, showHomeCardMetadata = false,
showRatingsStrip = false, showRatingsStrip = false,
metadataHeroContentOrder = "title\nratings\nfacts\ngenres\nsummary",
hideWatchedMovies = true, hideWatchedMovies = true,
showTitleLogo = false, showTitleLogo = false,
welcomeQuoteStyle = "positive", welcomeQuoteStyle = "positive",
@@ -162,6 +164,7 @@ class UserPreferencesTest {
homeArtworkStyle = "backdrop", homeArtworkStyle = "backdrop",
showHomeCardMetadata = false, showHomeCardMetadata = false,
showRatingsStrip = false, showRatingsStrip = false,
metadataHeroContentOrder = listOf("title", "ratings", "facts", "genres", "summary"),
hideWatchedMovies = true, hideWatchedMovies = true,
showTitleLogo = false, showTitleLogo = false,
welcomeQuoteStyle = "positive", welcomeQuoteStyle = "positive",
@@ -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")),
)
}
}
+15
View File
@@ -130,6 +130,21 @@ var preferenceCatalogue = []preferenceDefinition{
Description: "Show third-party ratings on browse and detail pages.", Description: "Show third-party ratings on browse and detail pages.",
Kind: preferenceToggle, Default: true, 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", Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home",
Description: "Keep fully watched films out of browse rows and the hero.", Description: "Keep fully watched films out of browse rows and the hero.",