0.3.01
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user