This commit is contained in:
ponzischeme89
2026-08-22 12:38:26 +12:00
parent f982d391b9
commit b3e3f62c54
26 changed files with 1279 additions and 108 deletions
@@ -111,6 +111,10 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
// says which build it is.
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
.header("X-Memby-Config-Schema", "1")
.header("X-Memby-Components", setOf("mediaRow", "mediaGrid", "genreBrowser", "hero").joinToString(","))
.header("X-Memby-Platform", "android-tv")
.header("X-Memby-Device", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
.header(
"X-Memby-Capabilities",
(
@@ -1,34 +1,45 @@
package com.ponzischeme89.memby.data.remoteconfig
import android.content.Context
import android.os.Build
import android.graphics.BitmapFactory
import android.util.Log
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit
import java.io.File
import java.net.URI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.Request
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
private const val REMOTE_CONFIG_START_DELAY_MS = 5_000L
private const val REMOTE_CONFIG_REFRESH_INTERVAL_MS = 15 * 60 * 1_000L
private const val MAX_REMOTE_CONFIG_BYTES = 32L * 1024L
private const val PREFERENCES_NAME = "memby_remote_config"
private const val DOCUMENT_KEY = "last_known_good"
/**
* Typed, process-stable presentation configuration.
* Typed, server-driven configuration. The bundled or cached value is available synchronously;
* later valid revisions are published through [RemoteConfigManager.activeFlow].
*
* The active value is chosen once, before any Activity is created. A successful refresh
* only replaces the durable last-known-good document; it deliberately does not publish a
* Flow or mutable state that could repaint wording under somebody's focus.
* The cached value is chosen before any Activity is created. A successful refresh replaces
* the durable last-known-good document and publishes the new immutable snapshot atomically.
*/
@Serializable
data class MembyRemoteConfig(
@@ -39,7 +50,22 @@ data class MembyRemoteConfig(
val copy: RemoteCopy,
val features: RemoteFeatures,
val presentation: RemotePresentation,
val home: RemoteHomeConfig = RemoteHomeConfig(),
val movies: RemotePageConfig = RemotePageConfig(),
val tv: RemotePageConfig = RemotePageConfig(),
val continueWatching: RemoteContinueWatching = RemoteContinueWatching(),
val forYou: RemoteForYouConfig = RemoteForYouConfig(),
val recommendations: RemoteRecommendations = RemoteRecommendations(),
val search: RemoteSearchConfig = RemoteSearchConfig(),
val ui: RemoteUiConfig = RemoteUiConfig(),
val experimental: Map<String, Boolean> = emptyMap(),
val integrations: RemoteIntegrations = RemoteIntegrations(),
val branding: RemoteBranding = RemoteBranding(),
) {
/** Unknown flags deliberately fall back to the caller's compiled-safe behaviour. */
fun featureEnabled(key: String, bundledDefault: Boolean = false): Boolean =
features.flags[key] ?: experimental[key] ?: bundledDefault
val navigation: NavigationRemoteConfig
get() = NavigationRemoteConfig(
labels = copy.navigation,
@@ -75,13 +101,94 @@ data class NavigationLabels(
@Serializable
data class RemoteFeatures(
val showNavigationVersion: Boolean,
val showNavigationVersion: Boolean = true,
val flags: Map<String, Boolean> = emptyMap(),
)
@Serializable
data class RemotePresentation(
val navigationRailExpandedWidthDp: Int,
val navigationContentShiftDp: Int,
val navigationRailExpandedWidthDp: Int = 184,
val navigationContentShiftDp: Int = 112,
)
@Serializable
data class RemoteHomeConfig(
val sections: List<String> = listOf("continue", "for-you", "favorites", "latest-movies"),
val sectionDefinitions: List<RemoteSectionDefinition> = emptyList(),
val showForYou: Boolean = true,
val showSeasonal: Boolean = true,
val heroRefreshSeconds: Int = 60,
val maxItemsPerRow: Int = 20,
)
@Serializable
data class RemotePageConfig(
val sections: List<String> = listOf("genres", "library"),
val sectionDefinitions: List<RemoteSectionDefinition> = emptyList(),
val showGenres: Boolean = false,
)
@Serializable
data class RemoteSectionDefinition(
val id: String = "",
val type: String = "",
val title: String = "",
val enabled: Boolean = true,
val position: Int = 0,
val dataSource: String = "",
val component: String = "",
val maxItems: Int = 0,
val destination: String = "",
val settings: Map<String, JsonElement> = emptyMap(),
)
@Serializable
data class RemoteContinueWatching(
val enabled: Boolean = true,
val includeNextUp: Boolean = true,
val maxItems: Int = 20,
val progressColour: String = "emby",
)
@Serializable
data class RemoteForYouConfig(
val enabled: Boolean = true,
val maxRows: Int = 3,
val refreshHours: Int = 24,
)
@Serializable
data class RemoteRecommendations(
val enabled: Boolean = true,
val sections: List<String> = listOf("for-you", "because-you-watched"),
)
@Serializable
data class RemoteSearchConfig(
val enabled: Boolean = true,
val genresEnabled: Boolean = false,
val maxResults: Int = 50,
)
@Serializable
data class RemoteUiConfig(
val artworkStyle: String = "automatic",
val cardDensity: String = "standard",
val showWatchedBadges: Boolean = true,
val showMediaTypeIcons: Boolean = false,
)
@Serializable
data class RemoteIntegrations(
val tracearr: Boolean = false,
val sonarr: Boolean = false,
val radarr: Boolean = false,
)
@Serializable
data class RemoteBranding(
val markUrl: String = "",
val markVersion: String = "",
)
/** The single UI-facing projection, so Compose never performs stringly typed lookups. */
@@ -119,6 +226,17 @@ object BundledRemoteConfig {
navigationRailExpandedWidthDp = 184,
navigationContentShiftDp = 112,
),
home = RemoteHomeConfig(),
movies = RemotePageConfig(),
tv = RemotePageConfig(),
continueWatching = RemoteContinueWatching(),
forYou = RemoteForYouConfig(),
recommendations = RemoteRecommendations(),
search = RemoteSearchConfig(),
ui = RemoteUiConfig(),
experimental = emptyMap(),
integrations = RemoteIntegrations(),
branding = RemoteBranding(),
)
}
@@ -137,10 +255,12 @@ private val remoteConfigJson = Json {
* Owns synchronous activation and asynchronous revalidation.
*
* SharedPreferences gives this one small value an atomic file replacement. The network
* path commits only after decoding and validation, on an IO dispatcher, and never mutates
* [active]. A killed write therefore leaves either the old complete value or the new one.
* path commits only after decoding and validation, on an IO dispatcher, and publishes the
* new immutable snapshot only after the durable write succeeds. A killed write therefore
* leaves either the old complete value or the new one.
*/
class RemoteConfigManager(context: Context) {
private val appContext = context.applicationContext
private val preferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE,
@@ -149,7 +269,12 @@ class RemoteConfigManager(context: Context) {
?.let(::decodeCachedRemoteConfig)
?.takeIf { validateRemoteConfig(it.document, BuildConfig.VERSION_NAME) == null }
val active: MembyRemoteConfig = cachedAtStart?.document ?: BundledRemoteConfig.value
private val _active = MutableStateFlow(cachedAtStart?.document ?: BundledRemoteConfig.value)
val activeFlow: StateFlow<MembyRemoteConfig> = _active.asStateFlow()
val active: MembyRemoteConfig get() = _active.value
private val markFile = File(appContext.filesDir, "memby_mark.remote")
private val _markPath = MutableStateFlow(markFile.takeIf { it.isFile && it.length() > 0L }?.absolutePath)
val markPathFlow: StateFlow<String?> = _markPath.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -158,18 +283,26 @@ class RemoteConfigManager(context: Context) {
val gatewayUrl = ServerConfig.gatewayUrl ?: return
val cached = cachedAtStart
scope.launch {
refreshBranding(_active.value.branding)
delay(REMOTE_CONFIG_START_DELAY_MS)
fetch(gatewayUrl, cached)
var etag = cached?.etag
while (isActive) {
etag = fetch(gatewayUrl, etag)
delay(REMOTE_CONFIG_REFRESH_INTERVAL_MS)
}
}
}
private fun fetch(gatewayUrl: String, cached: CachedRemoteConfig?) {
private fun fetch(gatewayUrl: String, cachedEtag: String?): String? {
val request = Request.Builder()
.url(gatewayUrl.trimEnd('/') + "/v1/config")
.header("Accept", "application/json")
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.header("X-Memby-Config-Schema", REMOTE_CONFIG_SCHEMA_VERSION.toString())
.apply { cached?.etag?.takeIf(String::isNotBlank)?.let { header("If-None-Match", it) } }
.header("X-Memby-Components", SUPPORTED_REMOTE_COMPONENTS.joinToString(","))
.header("X-Memby-Platform", "android-tv")
.header("X-Memby-Device", "${Build.MANUFACTURER} ${Build.MODEL}")
.apply { cachedEtag?.takeIf(String::isNotBlank)?.let { header("If-None-Match", it) } }
.build()
val client = HttpStack.base.newBuilder()
// This request is speculative and affects only a later process. Give it less
@@ -181,34 +314,73 @@ class RemoteConfigManager(context: Context) {
.build()
runCatching {
client.newCall(request).execute().use { response ->
if (response.code == 304) return
if (!response.isSuccessful) return
val body = response.body ?: return
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return
if (response.code == 304) return cachedEtag
if (!response.isSuccessful) return cachedEtag
val body = response.body ?: return cachedEtag
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return cachedEtag
val source = body.source()
source.request(MAX_REMOTE_CONFIG_BYTES + 1)
if (source.buffer.size > MAX_REMOTE_CONFIG_BYTES) return
if (source.buffer.size > MAX_REMOTE_CONFIG_BYTES) return cachedEtag
val raw = source.readUtf8()
val document = decodeRemoteConfig(raw) ?: return
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return
val heldVersion = cached?.document?.configVersion ?: 0
if (document.configVersion <= heldVersion) return
val document = decodeRemoteConfig(raw) ?: return cachedEtag
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return cachedEtag
// Config versions are monotonic hints, not the change detector: a gateway
// may republish the same schema/config revision after changing a policy.
// The ETag is authoritative, and a 200 with a new ETag is always eligible.
val etag = response.header("ETag")?.takeIf {
it.isNotBlank() && it.length <= 128 && !it.contains('\n') && !it.contains('\r')
} ?: return
} ?: return cachedEtag
val encoded = remoteConfigJson.encodeToString(CachedRemoteConfig(etag, document))
if (!preferences.edit().putString(DOCUMENT_KEY, encoded).commit()) {
Log.w("MembyRemoteConfig", "Could not persist remote configuration")
}
if (_active.value != document) _active.value = document
refreshBranding(document.branding)
return etag
}
}.onFailure {
if (it is CancellationException) throw it
// A failed speculative refresh is ordinary offline behaviour. The active
// bundled/cached document remains complete and no screen needs to know.
Log.d("MembyRemoteConfig", "Remote configuration refresh skipped", it)
}
}.getOrNull() ?: cachedEtag
}
private fun refreshBranding(branding: RemoteBranding) {
val rawUrl = branding.markUrl.trim()
if (rawUrl.isBlank()) return
val uri = runCatching { URI(rawUrl) }.getOrNull() ?: return
if (uri.scheme != "https" || uri.host.isNullOrBlank()) return
val version = branding.markVersion.trim()
val key = "mark_etag"
val request = Request.Builder().url(rawUrl + if (version.isBlank()) "" else if (rawUrl.contains('?')) "&v=$version" else "?v=$version")
.header("Accept", "image/png,image/webp,image/jpeg")
.apply { preferences.getString(key, null)?.let { header("If-None-Match", it) } }
.build()
runCatching {
HttpStack.base.newCall(request).execute().use { response ->
if (response.code == 304) return
val contentType = response.body?.contentType()?.toString()?.lowercase().orEmpty()
if (!response.isSuccessful || response.body == null ||
(contentType != "image/png" && contentType != "image/webp" && contentType != "image/jpeg") ||
response.body!!.contentLength() > 2L * 1024L * 1024L) return
val bytes = response.body!!.bytes()
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth !in 32..1024 || bounds.outHeight !in 32..1024) return
val temporary = File(appContext.cacheDir, "memby_mark.remote.tmp")
temporary.writeBytes(bytes)
if (markFile.exists()) markFile.delete()
if (!temporary.renameTo(markFile)) return
preferences.edit().putString(key, response.header("ETag")).apply()
_markPath.value = markFile.absolutePath
}
}.onFailure { Log.d("MembyRemoteConfig", "Remote mark refresh skipped", it) }
}
}
private val SUPPORTED_REMOTE_COMPONENTS = setOf("mediaRow", "mediaGrid", "genreBrowser", "hero")
internal fun decodeRemoteConfig(raw: String): MembyRemoteConfig? = runCatching {
remoteConfigJson.decodeFromString<MembyRemoteConfig>(raw)
}.getOrNull()
@@ -252,6 +424,25 @@ internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: Strin
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
return "unsafe presentation values"
}
val sections = document.home.sections +
document.movies.sections + document.tv.sections + document.recommendations.sections
if (sections.size > 128 || sections.any { it.isBlank() || it != it.trim() || it.length > 64 }) {
return "unsafe section ordering"
}
if (document.features.flags.keys.any { it.isBlank() || it.length > 64 } ||
document.experimental.keys.any { it.isBlank() || it.length > 64 }) {
return "unsafe feature flag"
}
if (document.continueWatching.maxItems !in 1..100 ||
document.forYou.maxRows !in 0..20 ||
document.forYou.refreshHours !in 1..168 ||
document.search.maxResults !in 1..200) {
return "unsafe configuration limits"
}
if (document.ui.artworkStyle !in setOf("automatic", "poster", "backdrop") ||
document.ui.cardDensity !in setOf("standard", "compact", "large")) {
return "unsafe UI preferences"
}
return null
}
@@ -66,6 +66,9 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
import com.ponzischeme89.memby.data.remoteconfig.RemoteContinueWatching
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
@@ -438,22 +441,37 @@ internal fun FocusedQuickActionsOverlay(
* and whatever it grows next) is always shown, since the user never opted out of a row
* that did not exist when they last opened Settings.
*/
internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBrowseRow> {
internal fun serverHomeRows(
state: HomeUiState,
settings: Settings,
remoteConfig: MembyRemoteConfig = BundledRemoteConfig.value,
): List<HomeBrowseRow> {
val enabledSections = settings.homeSections.split(',').map(String::trim).toSet()
val continueEnabled = remoteConfig.continueWatching.enabled &&
remoteConfig.featureEnabled("continue_watching", true)
val forYouEnabled = remoteConfig.home.showForYou && remoteConfig.forYou.enabled &&
remoteConfig.featureEnabled("for_you", true)
val stillLoading = state.loading.isNotEmpty()
val mapped = state.rows
.foldNextUpIntoContinue()
.filter { row ->
when (row.kind) {
"continue", "nextup" -> "continue" in enabledSections
"continue", "nextup" ->
continueEnabled && "continue" in enabledSections
"favorites" -> "favorites" in enabledSections
"latest" -> "latest" in enabledSections
"recommendations", "recommendation" -> remoteConfig.recommendations.enabled
// The hero row is the four featured cards. It is drawn above the shelves
// by HomeMovieHero, so letting it through here would print the same four
// titles a second time as an unnamed row of posters directly beneath it.
SERVER_HERO_ROW_KIND -> false
else -> true
else -> {
val forYouRow = row.id.startsWith("for-you:") || row.id == "for-you"
val recommendationRow = row.id == "recommended"
(!forYouRow || forYouEnabled) &&
(!recommendationRow || remoteConfig.recommendations.enabled)
}
}
}
.map { row ->
@@ -464,7 +482,9 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
} else {
row.title
},
items = if (row.kind == "schedule" || row.kind == "movie-schedule") {
items = if (row.kind == "continue") {
continueWatchingItems(row.items, remoteConfig.continueWatching)
} else if (row.kind == "schedule" || row.kind == "movie-schedule") {
row.items.sortedByScheduleDate()
} else {
row.items
@@ -494,9 +514,40 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
},
)
}
// A server response is already context-ranked. Do not reapply the static bundled
// composition here: doing so would undo the gateway's per-user ordering. The fallback
// path below still uses the cached section definitions when no server rows arrived.
return applyHomeRowPreferences(mapped, settings)
}
private val KNOWN_REMOTE_SECTION_COMPONENTS = setOf("mediaRow", "mediaGrid", "genreBrowser")
internal fun continueWatchingItems(
items: List<BaseItem>,
config: RemoteContinueWatching,
): List<BaseItem> = items
.filterNot { item ->
!config.includeNextUp && item.isEpisode && item.userData?.played != true &&
(item.userData?.playbackPositionTicks ?: 0L) <= 0L
}
.take(config.maxItems.coerceAtLeast(1))
/** Applies household ordering while preserving rows unknown to this APK. */
internal fun applyRemoteHomeSections(
rows: List<HomeBrowseRow>,
sections: List<String>,
): List<HomeBrowseRow> {
val order = sections.map(String::trim).filter(String::isNotEmpty)
if (order.isEmpty()) return rows
fun rank(row: HomeBrowseRow): Int = order.indexOfFirst { section ->
row.id == section || row.id.startsWith("$section:") ||
row.kind.name.equals(section, ignoreCase = true)
}.takeIf { it >= 0 } ?: Int.MAX_VALUE
return rows.withIndex()
.sortedWith(compareBy<IndexedValue<HomeBrowseRow>>({ rank(it.value) }, { it.index }))
.map(IndexedValue<HomeBrowseRow>::value)
}
/**
* Continue Watching and Next Up are one row. This gateway no longer sends a `nextup` row,
* but two things still do: the home cache written by the previous build, which is what a
@@ -561,11 +612,12 @@ internal fun homeRowsFor(
destination: BrowseDestination,
state: HomeUiState,
settings: Settings,
remoteConfig: MembyRemoteConfig = BundledRemoteConfig.value,
): List<HomeBrowseRow> {
val continueRow = HomeBrowseRow(
id = "continue",
title = "Continue Watching",
items = state.continueWatching,
items = continueWatchingItems(state.continueWatching, remoteConfig.continueWatching),
kind = MediaRowKind.CONTINUE,
loading = HomeSection.CONTINUE in state.loading,
emptyMessage = "Nothing in progress",
@@ -593,7 +645,7 @@ internal fun homeRowsFor(
// The gateway composes the home screen — including rows this app has no concept
// of, like "Because you watched …" — so when it sends rows, they win.
BrowseDestination.HOME -> if (state.rows.isNotEmpty()) {
serverHomeRows(state, settings)
serverHomeRows(state, settings, remoteConfig)
// Genre/studio shelves have dedicated Movies and TV destinations. Home
// keeps personalised, current and new-release rows so each screen has a
// genuinely different browsing character.
@@ -612,7 +664,7 @@ internal fun homeRowsFor(
}
}
}
BrowseDestination.MOVIES -> serverHomeRows(state, settings)
BrowseDestination.MOVIES -> serverHomeRows(state, settings, remoteConfig)
// Movie discovery is authored by the server from this profile's genre and
// studio affinity. Broad latest/library dumps belong on Home, not here.
.filter { row ->
@@ -643,7 +695,7 @@ internal fun homeRowsFor(
title = "Favourite Shows",
items = state.favorites.filter { it.isSeries || it.isEpisode },
),
) + serverHomeRows(state, settings)
) + serverHomeRows(state, settings, remoteConfig)
// These shelves are authored and ordered by the per-user recommendation
// engine. Keep their server order: it is the user's affinity ranking.
.filter { row ->
@@ -671,13 +723,44 @@ internal fun homeRowsFor(
} else {
deduplicated
}
return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
applyHomeRowPreferences(populated, settings)
val sectionOrder = when (destination) {
BrowseDestination.HOME -> remoteSectionOrder(remoteConfig.home.sectionDefinitions, remoteConfig.home.sections)
BrowseDestination.MOVIES -> remoteSectionOrder(remoteConfig.movies.sectionDefinitions, remoteConfig.movies.sections)
BrowseDestination.SHOWS -> remoteSectionOrder(remoteConfig.tv.sectionDefinitions, remoteConfig.tv.sections)
else -> emptyList()
}
val continueEnabled = remoteConfig.continueWatching.enabled &&
remoteConfig.featureEnabled("continue_watching", true)
val remoteOrdered = if (destination == BrowseDestination.HOME) {
applyRemoteHomeSections(
populated.filterNot { it.id == "continue" && !continueEnabled },
sectionOrder,
)
} else if (sectionOrder.isNotEmpty()) {
applyRemoteHomeSections(
populated.filterNot { it.id.startsWith("continue") && !continueEnabled },
sectionOrder,
)
} else {
populated
populated.filterNot { it.id.startsWith("continue") && !continueEnabled }
}
return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
applyHomeRowPreferences(remoteOrdered, settings)
} else {
remoteOrdered
}
}
private fun remoteSectionOrder(
definitions: List<com.ponzischeme89.memby.data.remoteconfig.RemoteSectionDefinition>,
fallback: List<String>,
): List<String> = definitions.asSequence()
.filter { it.enabled && it.component in KNOWN_REMOTE_SECTION_COMPONENTS }
.sortedBy { it.position }
.map { it.id }
.toList()
.ifEmpty { fallback }
/**
* A card gets one place on a screen. Episodes collapse to their parent series so a show
* in Continue Watching cannot immediately reappear as a genre recommendation, and live
@@ -195,6 +195,7 @@ internal fun HomeScreen(
remoteConfig: MembyRemoteConfig,
) {
val repo = ServiceLocator.repository
val remoteMarkPath by ServiceLocator.remoteConfig.markPathFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val factory = remember(repo) { HomeViewModelFactory(repo) }
@@ -748,7 +749,7 @@ internal fun HomeScreen(
val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) {
forYouBrowseRows(forYouState)
} else {
homeRowsFor(selectedDestination, homeContent, settings)
homeRowsFor(selectedDestination, homeContent, settings, remoteConfig)
}
applyWatchedVisibility(destinationRows, settings.hideWatchedMovies)
}
@@ -926,6 +927,7 @@ internal fun HomeScreen(
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
config = remoteConfig.navigation,
markPath = remoteMarkPath,
selected = if (userSwitcherVisible) {
BrowseDestination.PROFILES
} else {
@@ -2339,6 +2341,7 @@ internal fun HomeScreen(
) {
TvNavigationRail(
config = remoteConfig.navigation,
markPath = remoteMarkPath,
selected = if (userSwitcherVisible) {
BrowseDestination.PROFILES
} else {
@@ -3,6 +3,8 @@ package com.ponzischeme89.memby.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.performance.PerformanceMonitor
import com.ponzischeme89.memby.ui.player.PrerollPreloader
@@ -13,10 +15,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Captured before composition and never observed as state: a downloaded document
// is for the next process, never a label change under the viewer's focus.
val remoteConfig = ServiceLocator.remoteConfig.active
setContent {
val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle()
MembyTheme {
AppRoot(
remoteConfig = remoteConfig,
@@ -66,9 +66,12 @@ data class ResumableMediaCardModel(
val primaryUrl: String? = null,
val played: Boolean = false,
val favourite: Boolean = false,
val isNextUp: Boolean = false,
) {
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
val showProgressTrack: Boolean get() = progress > 0f || isNextUp
val nextUpLabel: String? get() = "Next up".takeIf { isNextUp }
}
internal fun BaseItem.toResumableMediaCardModel(
@@ -77,6 +80,8 @@ internal fun BaseItem.toResumableMediaCardModel(
): ResumableMediaCardModel {
val episodeName = name.trim().takeIf(String::isNotEmpty)
val seriesTitle = seriesName?.trim().orEmpty()
val playbackPosition = userData?.playbackPositionTicks
val played = userData?.played == true
return ResumableMediaCardModel(
id = id,
title = if (isEpisode) {
@@ -87,12 +92,16 @@ internal fun BaseItem.toResumableMediaCardModel(
episodeName = episodeName.takeIf { isEpisode && it != seriesTitle },
seasonNumber = parentIndexNumber.takeIf { isEpisode },
episodeNumber = indexNumber.takeIf { isEpisode },
playbackPositionTicks = userData?.playbackPositionTicks,
playbackPositionTicks = playbackPosition,
runtimeTicks = runTimeTicks,
backdropUrl = backdropUrl,
primaryUrl = primaryUrl,
played = userData?.played == true,
played = played,
favourite = isFavorite,
// This adapter is used by the Continue Watching card. Within that merged row, an
// unplayed episode with no playhead is the Next Up half; resumable episodes have a
// positive playhead and films are never supplied by Emby's Next Up feed.
isNextUp = isEpisode && !played && (playbackPosition ?: 0L) <= 0L,
)
}
@@ -168,12 +177,21 @@ fun ResumableMediaCard(
delay(MINUTE_MILLIS - now % MINUTE_MILLIS)
}
}
val finishAt = remember(nowEpochMillis, model.playbackPositionTicks, model.runtimeTicks) {
expectedFinishEpochMillis(
nowEpochMillis = nowEpochMillis,
positionTicks = model.playbackPositionTicks,
runtimeTicks = model.runtimeTicks,
)
val finishAt = remember(
nowEpochMillis,
model.isNextUp,
model.playbackPositionTicks,
model.runtimeTicks,
) {
if (model.isNextUp) {
null
} else {
expectedFinishEpochMillis(
nowEpochMillis = nowEpochMillis,
positionTicks = model.playbackPositionTicks,
runtimeTicks = model.runtimeTicks,
)
}
}
val endsAt = remember(finishAt, context) {
finishAt?.let {
@@ -184,11 +202,12 @@ fun ResumableMediaCard(
"Ends at: $localTime"
}
}
val description = remember(model, endsAt) {
val progressLabel = model.nextUpLabel ?: endsAt
val description = remember(model, progressLabel) {
listOfNotNull(
model.title.takeIf(String::isNotBlank),
model.episodeLabel,
endsAt,
progressLabel,
model.progress.takeIf { it > 0f }?.let { "${(it * 100).toInt()} percent watched" },
).joinToString(", ")
}
@@ -256,7 +275,7 @@ fun ResumableMediaCard(
modifier = Modifier.size(30.dp),
)
}
if (model.progress > 0f) {
if (model.showProgressTrack) {
Box(
Modifier
.align(Alignment.BottomCenter)
@@ -264,12 +283,14 @@ fun ResumableMediaCard(
.height(5.dp)
.background(Color.Black.copy(alpha = 0.65f)),
) {
Box(
Modifier
.fillMaxWidth(model.progress)
.height(5.dp)
.background(MembyAccent),
)
if (model.progress > 0f) {
Box(
Modifier
.fillMaxWidth(model.progress)
.height(5.dp)
.background(MembyAccent),
)
}
}
}
if (model.played || model.favourite) {
@@ -312,7 +333,7 @@ fun ResumableMediaCard(
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
endsAt?.let { label ->
progressLabel?.let { label ->
Text(
text = label,
color = MembyQuietText,
@@ -11,6 +11,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.Image
import coil.compose.AsyncImage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -168,6 +169,7 @@ fun TvNavigationRail(
* beside screens that have nowhere to put a menu.
*/
onUserLongPressed: (() -> Unit)? = null,
markPath: String? = null,
) {
var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) }
@@ -243,8 +245,8 @@ fun TvNavigationRail(
// It carries its own colour and is deliberately not tinted — this is the
// app's icon, the same one the launcher on the television shows, so a
// seasonal palette repainting it would make it a different mark.
Image(
painter = painterResource(R.drawable.memby_mark),
if (markPath != null) AsyncImage(
model = markPath,
contentDescription = "Memby",
modifier = Modifier
.size(30.dp)
@@ -254,6 +256,12 @@ fun TvNavigationRail(
alpha = logoAlpha.value
rotationZ = logoRotation.value
},
) else Image(
painter = painterResource(R.drawable.memby_mark),
contentDescription = "Memby",
modifier = Modifier.size(30.dp).graphicsLayer {
scaleX = logoScale.value; scaleY = logoScale.value; alpha = logoAlpha.value; rotationZ = logoRotation.value
},
)
if (expanded) {
Column(verticalArrangement = Arrangement.Center) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -24,6 +24,15 @@ class MembyRemoteConfigTest {
assertEquals(false, document.navigation.showVersion)
}
@Test
fun olderDocumentReceivesSafeDefaultsForNewSections() {
val document = decodeRemoteConfig(validDocument)!!
assertEquals(true, document.continueWatching.enabled)
assertEquals(50, document.search.maxResults)
assertEquals("automatic", document.ui.artworkStyle)
}
@Test
fun incompatibleSchemaAndAppVersionsAreRejected() {
val document = decodeRemoteConfig(validDocument)!!
@@ -1,8 +1,11 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ResumableMediaCardTest {
@@ -60,6 +63,48 @@ class ResumableMediaCardTest {
assertEquals(0f, resumableProgress(500L, null))
}
@Test
fun `an unstarted episode in continue watching is next up`() {
val model = BaseItem(
id = "next",
name = "The Next Episode",
type = "Episode",
userData = UserItemData(playbackPositionTicks = 0L),
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
assertTrue(model.isNextUp)
assertEquals(0f, model.progress)
assertTrue(model.showProgressTrack)
assertEquals("Next up", model.nextUpLabel)
}
@Test
fun `a started episode is resumable rather than next up`() {
val model = BaseItem(
id = "resume",
name = "The Current Episode",
type = "Episode",
userData = UserItemData(playbackPositionTicks = 10_000L),
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
assertFalse(model.isNextUp)
assertNull(model.nextUpLabel)
}
@Test
fun `an unstarted film is not next up`() {
val model = BaseItem(
id = "film",
name = "A Film",
type = "Movie",
userData = UserItemData(playbackPositionTicks = 0L),
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
assertFalse(model.isNextUp)
assertFalse(model.showProgressTrack)
assertNull(model.nextUpLabel)
}
private companion object {
const val MILLIS_PER_SECOND = 1_000L
const val TICKS_PER_MILLISECOND_FOR_TEST = 10_000L
@@ -5,6 +5,7 @@ import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.data.remoteconfig.RemoteContinueWatching
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -16,6 +17,31 @@ import org.junit.Test
* the server invented, and surviving a cold start from cache.
*/
class ServerHomeRowsTest {
@Test
fun `remote home configuration orders rows and controls next up`() {
val nextUp = BaseItem(
id = "next", name = "Next", type = "Episode",
userData = UserItemData(playbackPositionTicks = 0L),
)
val resume = BaseItem(
id = "resume", name = "Resume", type = "Movie",
userData = UserItemData(playbackPositionTicks = 10L),
)
val rows = listOf(
HomeBrowseRow("latest-movies", "Latest", emptyList(), MediaRowKind.MOVIES, emptyMessage = ""),
HomeBrowseRow("continue", "Continue", listOf(resume, nextUp), MediaRowKind.CONTINUE, emptyMessage = ""),
)
assertEquals(listOf("continue", "latest-movies"), applyRemoteHomeSections(rows, listOf("continue", "latest-movies")).map { it.id })
assertEquals(
listOf("resume"),
continueWatchingItems(
listOf(resume, nextUp),
RemoteContinueWatching(includeNextUp = false),
).map(BaseItem::id),
)
}
@Test
fun `profile row preferences hide pin and order server rows`() {
val serverRows = listOf(