0.3.00
This commit is contained in:
+1
-18
@@ -38,15 +38,6 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
|
||||
// the complete legal documents offline. Deployments can override the public source URL
|
||||
// without changing application code.
|
||||
val membySourceUrl: String =
|
||||
(project.findProperty("memby.sourceUrl") as String?)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: "https://g.sublogue.com/admin/memby"
|
||||
|
||||
fun buildConfigString(value: String): String =
|
||||
"\"" + value
|
||||
.replace("\\", "\\\\")
|
||||
@@ -58,13 +49,8 @@ fun buildConfigString(value: String): String =
|
||||
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
|
||||
val changelogText = rootProject.file("CHANGELOG.md").readText()
|
||||
val gplLicenseText = rootProject.file("LICENSE").readText()
|
||||
val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
// 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.99"
|
||||
val defaultVersionName = "0.3.00"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
@@ -128,9 +114,6 @@ extensions.configure<ApplicationExtension> {
|
||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
|
||||
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
|
||||
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
|
||||
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
|
||||
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user