This commit is contained in:
ponzischeme89
2026-08-22 08:26:10 +12:00
parent 3ae8ffad64
commit b131932a4e
34 changed files with 10740 additions and 9190 deletions
@@ -89,6 +89,7 @@ class MaintenanceMonitor(
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
private val _preferencesRevision = MutableStateFlow(0L)
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
private val _theme = MutableStateFlow(GatewayThemeStatus())
private val _heroRevision = MutableStateFlow("")
private val _installPermissionPrompt = MutableStateFlow(false)
@@ -131,6 +132,9 @@ class MaintenanceMonitor(
*/
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
/** One household-wide composition, delivered by the status poll to every viewer. */
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
/**
* Whether the operator wants TVs that cannot install their own updates to be asked for
* the permission. Pushed through the same poll as everything else, and false whenever
@@ -278,8 +282,9 @@ class MaintenanceMonitor(
// clearing it here would fight that loop for the same flow.
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_heroRevision.value = ""
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -314,6 +319,10 @@ class MaintenanceMonitor(
null
}
_preferencesRevision.value = status.preferencesRevision
_metadataHeroContentOrder.value = status.metadataHeroContentOrder
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
.distinct()
.ifEmpty { DEFAULT_METADATA_HERO_CONTENT_ORDER }
_theme.value = status.theme
_heroRevision.value = status.hero.revision
_installPermissionPrompt.value =
@@ -357,6 +366,7 @@ class MaintenanceMonitor(
_compatibility.value = null
_embyOutage.value = null
_preferencesRevision.value = 0
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_installPermissionPrompt.value = false
@@ -461,6 +471,10 @@ class MaintenanceMonitor(
}
companion object {
internal val DEFAULT_METADATA_HERO_CONTENT_ORDER =
listOf("title", "ratings", "facts", "summary")
internal val METADATA_HERO_CONTENT_OPTIONS =
DEFAULT_METADATA_HERO_CONTENT_ORDER + listOf("recommendation_reason", "time_remaining")
internal const val POLL_INTERVAL_MS = 10_000L
internal const val RATE_LIMIT_FALLBACK_MS = 60_000L
internal const val MAX_RATE_LIMIT_SECONDS = 3_600L
@@ -242,8 +242,6 @@ 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(),
@@ -365,8 +363,6 @@ 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. */
@@ -515,7 +511,6 @@ 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 = "",
@@ -595,7 +590,6 @@ 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")
@@ -846,8 +840,8 @@ class SettingsStore(private val context: Context) {
* Adopts the server's document for the signed-in viewer, in one write.
*
* One write is the point. Preferences DataStore rewrites and fsyncs the whole file per
* edit, and this touches nineteen keys plus the profiles blob — doing it through the
* individual setters would be eighteen rewrites for one sync, on a TV that has just
* edit, and this touches the viewer's full document plus the profiles blob — doing it
* through the individual setters would be many rewrites for one sync, on a TV that has just
* started up.
*
* The revision is stored in the same edit as the values it describes. If they could
@@ -868,7 +862,6 @@ 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
@@ -895,7 +888,6 @@ 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,
@@ -1356,8 +1348,6 @@ 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(),
@@ -1536,7 +1526,6 @@ 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
@@ -1580,8 +1569,6 @@ 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(),
@@ -1650,8 +1637,6 @@ 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(),
@@ -36,8 +36,6 @@ 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<String> = DEFAULT_METADATA_HERO_CONTENT_ORDER,
val hideWatchedMovies: Boolean = false,
val showTitleLogo: Boolean = true,
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
@@ -75,7 +73,6 @@ data class UserPreferences(
) {
companion object {
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
val DEFAULT_METADATA_HERO_CONTENT_ORDER = listOf("title", "ratings", "facts", "summary")
}
}
@@ -93,7 +90,6 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
homeArtworkStyle = homeArtworkStyle,
showHomeCardMetadata = showHomeCardMetadata,
showRatingsStrip = showRatingsStrip,
metadataHeroContentOrder = metadataHeroContentOrder.decodeLineList(),
hideWatchedMovies = hideWatchedMovies,
showTitleLogo = showTitleLogo,
welcomeQuoteStyle = welcomeQuoteStyle,
@@ -138,9 +134,6 @@ 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),
@@ -176,7 +169,6 @@ 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)
@@ -235,6 +235,8 @@ data class GatewayServiceStatus(
* on the poll the app is already making.
*/
val preferencesRevision: Long = 0,
/** Household-wide order of the focused metadata hero's content blocks. */
val metadataHeroContentOrder: List<String> = emptyList(),
/**
* The colour scheme this viewer's televisions should be painted, as an id and a
* revision rather than the palette itself — the [preferencesRevision] precedent, for
@@ -68,7 +68,6 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.decodeLineList
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
@@ -223,6 +222,8 @@ internal fun HomeScreen(
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle()
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val metadataHeroContentOrder by
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
@@ -1430,7 +1431,7 @@ internal fun HomeScreen(
} else {
FocusedHomeMetadata(
homeViewModel = homeViewModel,
metadataHeroContentOrder = settings.metadataHeroContentOrder.decodeLineList(),
metadataHeroContentOrder = metadataHeroContentOrder,
modifier = Modifier
.height(metadataHeight)
// LazyColumn is drawn later as a sibling. Keep the hero
@@ -82,6 +82,7 @@ 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.TextUnit
import androidx.compose.ui.zIndex
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
@@ -136,12 +137,19 @@ private val MutedText: Color get() = MembyMutedText
private val QuietText: Color get() = MembyQuietText
@Composable
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
fun MediaBadge(
label: String,
modifier: Modifier = Modifier,
fontSize: TextUnit = 11.sp,
lineHeight: TextUnit = TextUnit.Unspecified,
) {
Text(
label,
color = MembyOnSurface,
fontSize = 11.sp,
fontSize = fontSize,
lineHeight = lineHeight,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = modifier
.border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp))
.background(Color.Black.copy(alpha = 0.22f), RoundedCornerShape(4.dp))
@@ -24,12 +24,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.weight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
@@ -70,6 +72,8 @@ private val QuietText: Color get() = MembyQuietText
private val BACKDROP_SETTLE_DELAY = 240.milliseconds
private val MetadataHeroLogoMaxWidth = 360.dp
private val MetadataHeroLogoMaxHeight = 72.dp
private val MetadataHeroFactsFontSize = 13.sp
private val MetadataHeroFactsLineHeight = 18.sp
@Composable
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
@@ -229,19 +233,45 @@ private fun MetadataContent(
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
)
MetadataHeroSection.Facts -> {
// One information block between ratings and plot: catalogue facts on
// the first line, then the file's picture and sound options beneath.
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) }
// Catalogue facts and the outlined file badges are one metadata line.
// Badges are measured first so Stereo/5.1 and picture formats remain
// visible; a long genre list gives way through ellipsis before the
// file information wraps or escapes the hero.
if (facts.isNotEmpty() || badges.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
modifier = Modifier.fillMaxWidth().clipToBounds(),
) {
if (facts.isNotEmpty()) {
Text(
facts.joinToString(FactSeparator),
color = MutedText,
fontSize = MetadataHeroFactsFontSize,
lineHeight = MetadataHeroFactsLineHeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
badges.forEach { badge ->
MediaBadge(
label = badge,
fontSize = MetadataHeroFactsFontSize,
lineHeight = MetadataHeroFactsLineHeight,
)
}
}
}
}
MetadataHeroSection.RecommendationReason ->
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
RecommendationReasonCaption(
recommendationReasonSummary(it),
Modifier.fillMaxWidth(),
)
}
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item)
MetadataHeroSection.Summary -> Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
@@ -259,7 +289,10 @@ private object MetadataHeroSection {
const val Ratings = "ratings"
const val Facts = "facts"
const val Summary = "summary"
val all = listOf(Title, Ratings, Facts, Summary)
const val RecommendationReason = "recommendation_reason"
const val TimeRemaining = "time_remaining"
val defaults = listOf(Title, Ratings, Facts, Summary)
val all = defaults + listOf(RecommendationReason, TimeRemaining)
}
internal fun metadataHeroContentOrder(order: List<String>): List<String> =
@@ -269,7 +302,7 @@ internal fun metadataHeroContentOrder(order: List<String>): List<String> =
.map { if (it == "genres") MetadataHeroSection.Facts else it }
.filter { it in MetadataHeroSection.all }
.distinct()
.ifEmpty { MetadataHeroSection.all }
.ifEmpty { MetadataHeroSection.defaults }
/** Catalogue information shown as one block between the ratings strip and the plot. */
internal fun metadataHeroFacts(item: BaseItem): List<String> = buildList {
@@ -385,21 +418,10 @@ private fun ScheduleMetadataContent(
@Composable
private fun MetadataStatus(item: BaseItem) {
val position = item.userData?.playbackPositionTicks ?: 0L
val runtime = item.runTimeTicks ?: 0L
val progress = if (runtime > 0) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
if (progress > 0f) {
Box(
Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)),
) {
Box(Modifier.fillMaxWidth(progress).height(4.dp).background(EmbyGreen, RoundedCornerShape(2.dp)))
}
Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp)
}
if (item.userData?.played == true) {
Icon(MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
Text("Watched", color = MutedText, fontSize = 13.sp)
@@ -410,3 +432,41 @@ private fun MetadataStatus(item: BaseItem) {
}
}
}
@Composable
private fun MetadataTimeRemaining(item: BaseItem) {
val position = item.userData?.playbackPositionTicks ?: 0L
val runtime = item.runTimeTicks ?: 0L
val minutes = metadataHeroMinutesRemaining(item) ?: return
val progress = (position.toFloat() / runtime).coerceIn(0f, 1f)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.width(150.dp).height(4.dp)
.background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)),
) {
Box(
Modifier.fillMaxWidth(progress).height(4.dp)
.background(EmbyGreen, RoundedCornerShape(2.dp)),
)
}
Text(
if (minutes == 1) "1 MINUTE REMAINING" else "$minutes MINUTES REMAINING",
color = MutedText,
fontSize = 13.sp,
lineHeight = 18.sp,
maxLines = 1,
)
}
}
internal fun metadataHeroMinutesRemaining(item: BaseItem): Long? {
val position = item.userData?.playbackPositionTicks ?: return null
val runtime = item.runTimeTicks ?: return null
if (position <= 0L || runtime <= position) return null
return ((runtime - position) + TICKS_PER_MINUTE - 1L) / TICKS_PER_MINUTE
}
private const val TICKS_PER_MINUTE = 600_000_000L
@@ -4,10 +4,8 @@ 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
@@ -15,13 +13,9 @@ 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
@@ -33,89 +27,62 @@ 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
@@ -25,7 +25,6 @@ class UserPreferencesTest {
homeArtworkStyle = "poster",
showHomeCardMetadata = false,
showRatingsStrip = false,
metadataHeroContentOrder = listOf("title", "ratings", "facts", "summary", "genres"),
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "homicidal",
@@ -143,7 +142,6 @@ class UserPreferencesTest {
homeArtworkStyle = "backdrop",
showHomeCardMetadata = false,
showRatingsStrip = false,
metadataHeroContentOrder = "title\nratings\nfacts\ngenres\nsummary",
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "positive",
@@ -164,7 +162,6 @@ class UserPreferencesTest {
homeArtworkStyle = "backdrop",
showHomeCardMetadata = false,
showRatingsStrip = false,
metadataHeroContentOrder = listOf("title", "ratings", "facts", "genres", "summary"),
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "positive",
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaStream
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -32,7 +33,17 @@ class MetadataHeroOrderTest {
}
@Test
fun `metadata information includes genres while sound remains in its badge row`() {
fun `optional recommendation and time blocks retain their configured order`() {
assertEquals(
listOf("title", "recommendation_reason", "time_remaining", "summary"),
metadataHeroContentOrder(
listOf("title", "recommendation_reason", "time_remaining", "summary"),
),
)
}
@Test
fun `metadata information and sound provide the content for one joined facts row`() {
val item = BaseItem(
id = "film",
productionYear = 2026,
@@ -45,4 +56,15 @@ class MetadataHeroOrderTest {
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
assertEquals(listOf("STEREO"), mediaBadges(item))
}
@Test
fun `time remaining rounds up to a whole minute`() {
val item = BaseItem(
id = "film",
runTimeTicks = 100L * 60L * 10_000_000L,
userData = UserItemData(playbackPositionTicks = 40L * 60L * 10_000_000L + 1L),
)
assertEquals(60L, metadataHeroMinutesRemaining(item))
}
}