0.2.81
This commit is contained in:
@@ -63,7 +63,7 @@ val projectNoticeText =
|
||||
|
||||
// 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.80"
|
||||
val defaultVersionName = "0.2.81"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -7,6 +7,7 @@ import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||
import com.ponzischeme89.memby.performance.StartupTrace
|
||||
import com.ponzischeme89.memby.ui.TitleLogoCache
|
||||
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -51,6 +52,9 @@ class MembyApp : Application() {
|
||||
.crossfade(false)
|
||||
.build(),
|
||||
)
|
||||
// A logo's darkness verdict and the address of a warm are both process-scoped, and
|
||||
// the launcher warms logos from a ViewModel that holds no context of its own.
|
||||
TitleLogoCache.install(this)
|
||||
ServiceLocator.init(this)
|
||||
// Registers an idle callback only: the local four-second clip is prepared after
|
||||
// the launcher's queued start-up work, never on its critical path.
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import android.os.SystemClock
|
||||
import com.ponzischeme89.memby.data.model.GenreAffinity
|
||||
import com.ponzischeme89.memby.data.model.GatewayCalendar
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevice
|
||||
@@ -372,6 +374,27 @@ class EmbyRepository internal constructor(
|
||||
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
|
||||
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
|
||||
|
||||
/**
|
||||
* What this viewer watches, for the order of the Genres browser's rail.
|
||||
*
|
||||
* Held as a plain volatile rather than behind a suspend call because the reader is a
|
||||
* ViewModel's constructor — the same reason [snapshot] exists. A screen that had to
|
||||
* await this before drawing its rail would be a screen whose genre list appears late,
|
||||
* and re-ordering a rail somebody is already looking at is worse than not personalising
|
||||
* it at all.
|
||||
*/
|
||||
@Volatile
|
||||
private var genreAffinity: GenreAffinity = GenreAffinity()
|
||||
|
||||
/**
|
||||
* When that reading was taken, in [SystemClock.elapsedRealtime], so a television
|
||||
* correcting its clock cannot make an hour-old answer look like a fresh one.
|
||||
*/
|
||||
@Volatile
|
||||
private var genreAffinityReadAt = 0L
|
||||
private val genreAffinityMutex = Mutex()
|
||||
private var genreAffinityInFlight: Deferred<GenreAffinity?>? = null
|
||||
|
||||
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||
|
||||
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
|
||||
@@ -471,6 +494,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
settings.ensureDeviceId()
|
||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||
|
||||
@@ -563,6 +587,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
|
||||
suspend fun signOut() {
|
||||
@@ -578,6 +603,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
|
||||
suspend fun switchProfile(profile: EmbyProfile) {
|
||||
@@ -588,6 +614,7 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
|
||||
suspend fun removeProfile(profile: EmbyProfile) {
|
||||
@@ -605,6 +632,8 @@ class EmbyRepository internal constructor(
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,6 +939,89 @@ class EmbyRepository internal constructor(
|
||||
return GenrePage(items, offset, total)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which genres this viewer actually watches, for the order of the Genres browser's rail.
|
||||
*
|
||||
* The answer comes from the gateway because that is where the evidence is: Tracearr's
|
||||
* sessions and the imported catalogue's genres are both already in Postgres, and a
|
||||
* television holds neither. It is a weight per Emby genre label and nothing more — the
|
||||
* category catalogue, its aliases and its product order all stay on the set, which is
|
||||
* what stops the two ends needing to agree about a list only one of them draws.
|
||||
*
|
||||
* Deliberately *not* on the path of drawing anything. [genreAffinitySnapshot] is what
|
||||
* the rail reads, and it returns whatever is already known; this is what fills that in,
|
||||
* behind the viewer. A genre browser that waited on a request before showing its
|
||||
* categories would have paid for personalisation with the one thing it was told not to
|
||||
* cost.
|
||||
*
|
||||
* Never throws, and every way it can fail produces the same empty answer: the direct
|
||||
* path has nobody to ask, a gateway that predates the route answers 404, a household
|
||||
* running no Tracearr has no history, and an operator may have switched it off. All four
|
||||
* are "use the catalogue's own order", which is a perfectly good rail.
|
||||
*/
|
||||
suspend fun getGenreAffinity(): GenreAffinity {
|
||||
if (!ServerConfig.isGateway) return GenreAffinity()
|
||||
val inFlight = genreAffinityMutex.withLock {
|
||||
if (!genreAffinityStale()) return genreAffinity
|
||||
genreAffinityInFlight ?: newGenreAffinityRequest()
|
||||
}
|
||||
// A failed reading leaves the previous one standing rather than clearing it: an
|
||||
// unreachable gateway is no evidence that somebody's taste has changed.
|
||||
return inFlight.await() ?: genreAffinity
|
||||
}
|
||||
|
||||
/**
|
||||
* The reading already in hand, with no request and no suspension.
|
||||
*
|
||||
* This is what the Genres browser's ViewModel reads when it is constructed, the
|
||||
* [snapshot] arrangement. The rail is ordered once, from this, and never re-ordered
|
||||
* while somebody is looking at it — a list that reshuffled under a D-pad would be worse
|
||||
* than one that was never personalised, and the warm below is what makes it almost
|
||||
* always already here.
|
||||
*/
|
||||
fun genreAffinitySnapshot(): GenreAffinity = genreAffinity
|
||||
|
||||
/**
|
||||
* Fetches the reading if it is missing or stale, and returns immediately.
|
||||
*
|
||||
* Called as the launcher settles, because the Genres browser is never the first thing a
|
||||
* television draws — a viewer has to travel the rail to reach it — so by the time it is
|
||||
* opened this has long since landed. The cost is one request per six hours per set.
|
||||
*/
|
||||
fun warmGenreAffinity() {
|
||||
if (!ServerConfig.isGateway) return
|
||||
scope.launch { runCatching { getGenreAffinity() } }
|
||||
}
|
||||
|
||||
private fun genreAffinityStale(): Boolean =
|
||||
genreAffinityReadAt == 0L ||
|
||||
SystemClock.elapsedRealtime() - genreAffinityReadAt >= GENRE_AFFINITY_REFRESH_MS
|
||||
|
||||
private fun newGenreAffinityRequest(): Deferred<GenreAffinity?> {
|
||||
val request = scope.async(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
val loaded = runCatching { requireGateway().genreAffinity() }.getOrNull()
|
||||
?: return@async null
|
||||
genreAffinityMutex.withLock {
|
||||
genreAffinity = loaded
|
||||
genreAffinityReadAt = SystemClock.elapsedRealtime()
|
||||
loaded
|
||||
}
|
||||
} finally {
|
||||
genreAffinityMutex.withLock { genreAffinityInFlight = null }
|
||||
}
|
||||
}
|
||||
genreAffinityInFlight = request
|
||||
request.start()
|
||||
return request
|
||||
}
|
||||
|
||||
/** Another viewer's taste is not this one's; cleared wherever the session changes. */
|
||||
private fun clearGenreAffinity() {
|
||||
genreAffinity = GenreAffinity()
|
||||
genreAffinityReadAt = 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* One stable, paged slice of all films, all series, or both.
|
||||
*
|
||||
@@ -3135,7 +3247,7 @@ class EmbyRepository internal constructor(
|
||||
* or null when the item has no logo. Used to show artwork in place of the
|
||||
* plain-text title in the screensaver.
|
||||
*/
|
||||
fun logoUrl(item: BaseItem, maxWidth: Int = 800): String? {
|
||||
fun logoUrl(item: BaseItem, maxWidth: Int = LOGO_MAX_WIDTH): String? {
|
||||
val (id, tag) = when {
|
||||
item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo")
|
||||
item.parentLogoItemId != null && item.parentLogoImageTag != null ->
|
||||
@@ -3457,6 +3569,18 @@ data class ChapterMarkers(
|
||||
/** An empty reading is a real answer — most titles have no markers — so it is cached too. */
|
||||
private data class CachedIntro(val value: ChapterMarkers)
|
||||
|
||||
/**
|
||||
* The one width every surface asks Emby for a logo at.
|
||||
*
|
||||
* It used to be four — 800 for the screensaver, 720 for the detail page and the player, 640
|
||||
* for the home hero, 420 for the TV calendar — and a width is part of the URL, so one show's
|
||||
* title treatment was four cache keys, four fetches and four darkness verdicts. One width
|
||||
* makes it one entry in Coil's memory and disk caches and one entry in `TitleLogoCache`,
|
||||
* shared by every screen that draws it. 720px comfortably covers the largest stage in the
|
||||
* app (the screensaver's 320dp) on a 1080p television.
|
||||
*/
|
||||
const val LOGO_MAX_WIDTH = 720
|
||||
|
||||
private const val PLAYABLE_CACHE_SIZE = 16
|
||||
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
|
||||
|
||||
@@ -25,6 +25,16 @@ data class GenrePage(
|
||||
/** A page this size, in items. Several television screenfuls, so the scroll stays ahead. */
|
||||
const val GENRE_PAGE_SIZE = 48
|
||||
|
||||
/**
|
||||
* How long a viewer's genre affinity is good for on the television.
|
||||
*
|
||||
* Matched to the gateway's own cache lifetime, so a set that refetches on the far side of it
|
||||
* is asking a question that has actually been recomputed rather than paying a round trip for
|
||||
* a cached repeat of what it already holds. Taste does not move in an evening; what this
|
||||
* span buys is that a household whose viewing changes sees the rail follow it within a day.
|
||||
*/
|
||||
const val GENRE_AFFINITY_REFRESH_MS = 6L * 60L * 60L * 1000L
|
||||
|
||||
/**
|
||||
* Whether the grid should ask for another page.
|
||||
*
|
||||
|
||||
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
|
||||
|
||||
private val _preferencesRevision = MutableStateFlow(0L)
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
private val _heroRevision = MutableStateFlow("")
|
||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||
private val _genreBrowserEnabled = MutableStateFlow(false)
|
||||
private val _tvCalendarEnabled = MutableStateFlow(false)
|
||||
@@ -107,6 +108,19 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
val theme: StateFlow<GatewayThemeStatus> = _theme.asStateFlow()
|
||||
|
||||
/**
|
||||
* The revision of the hero configuration the gateway would compose for this viewer.
|
||||
*
|
||||
* The launcher refetches its heroes only when this moves, which is what makes an
|
||||
* operator pinning a title arrive on the poll a set is already making rather than at
|
||||
* the next cold start. It rides this poll for the theme's reason: the change has to
|
||||
* reach a television that is already switched on and sitting on the home screen.
|
||||
*
|
||||
* Blank whenever nothing has said otherwise — signed out, on the direct path, or on a
|
||||
* gateway that predates the field — and blank never triggers a refetch.
|
||||
*/
|
||||
val heroRevision: StateFlow<String> = _heroRevision.asStateFlow()
|
||||
|
||||
/**
|
||||
* The viewer's server-held settings revision, as of the last successful poll. This is
|
||||
* how an operator's push reaches a television: the number changes, [PreferencesSync]
|
||||
@@ -242,6 +256,7 @@ class MaintenanceMonitor(
|
||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
@@ -275,6 +290,7 @@ class MaintenanceMonitor(
|
||||
}
|
||||
_preferencesRevision.value = status.preferencesRevision
|
||||
_theme.value = status.theme
|
||||
_heroRevision.value = status.hero.revision
|
||||
_installPermissionPrompt.value =
|
||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
||||
@@ -314,6 +330,7 @@ class MaintenanceMonitor(
|
||||
_embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
|
||||
@@ -201,11 +201,34 @@ data class GatewayServiceStatus(
|
||||
* the switcher rather than offering a menu item its own handlers would refuse.
|
||||
*/
|
||||
val requests: GatewayRequestAccess = GatewayRequestAccess(),
|
||||
/**
|
||||
* The hero configuration this viewer's televisions should be drawing, as one opaque
|
||||
* revision rather than the cards themselves — the [theme] precedent, for the same
|
||||
* reason: this poll runs every ten seconds on every open set.
|
||||
*
|
||||
* It moves when an operator pins, removes or reorders a hero, when a scheduled hero
|
||||
* comes into or goes out of force, and when the gateway's rotation slot turns over. A
|
||||
* server that predates it sends none, which decodes to "" and never triggers a refetch:
|
||||
* a set that cannot be told keeps the hero it was given with its home rows, which is
|
||||
* exactly how the launcher behaved before this existed.
|
||||
*/
|
||||
val hero: GatewayHeroStatus = GatewayHeroStatus(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRequestAccess(val allowed: Boolean = false)
|
||||
|
||||
/** The summary of the hero configuration that rides the status poll. */
|
||||
@Serializable
|
||||
data class GatewayHeroStatus(
|
||||
/**
|
||||
* Opaque, and compared only for equality. It is also what keys the gateway's cached
|
||||
* hero answers, so a revision that has moved always names a freshly composed hero
|
||||
* rather than the entry the television already holds.
|
||||
*/
|
||||
val revision: String = "",
|
||||
)
|
||||
|
||||
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
|
||||
@Serializable
|
||||
data class GatewayThemeStatus(
|
||||
@@ -540,6 +563,40 @@ data class GatewayGenrePage(
|
||||
val total: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* One genre label and how much of this viewer's watching it accounts for.
|
||||
*
|
||||
* [genre] is Emby's own spelling, verbatim — "Science Fiction", "Sci-Fi & Fantasy" and
|
||||
* "Sci Fi" all arrive as themselves. Folding them is the television's job, because the
|
||||
* television is what owns the category catalogue and its aliases; a gateway that folded
|
||||
* them would need a second copy of that list and the two would disagree the first time a
|
||||
* category gained a spelling.
|
||||
*
|
||||
* [weight] is scaled so the most-watched genre is 1, which is what lets the ordering rule be
|
||||
* written in shares rather than in counts.
|
||||
*/
|
||||
@Serializable
|
||||
data class GenreWeight(
|
||||
val genre: String = "",
|
||||
val weight: Double = 0.0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `GET /v1/genres/affinity` — which genres this viewer actually watches,
|
||||
* derived from Tracearr's sessions and the imported catalogue's genres.
|
||||
*
|
||||
* [sessions] is the evidence behind the weights, and it is on the wire because the ordering
|
||||
* rule refuses to personalise below a floor: a share of three sessions is not a taste. Every
|
||||
* field defaults to nothing, so a gateway that predates this route, a household running no
|
||||
* Tracearr and an operator who has switched it off all produce the same answer — the
|
||||
* catalogue's own order.
|
||||
*/
|
||||
@Serializable
|
||||
data class GenreAffinity(
|
||||
val sessions: Int = 0,
|
||||
val genres: List<GenreWeight> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
|
||||
*
|
||||
|
||||
@@ -90,6 +90,16 @@ interface GatewayApi {
|
||||
@Query("type") itemType: String? = null,
|
||||
): com.ponzischeme89.memby.data.model.GatewayGenrePage
|
||||
|
||||
/**
|
||||
* Which genres this viewer watches, for the order of the Genres browser's rail.
|
||||
*
|
||||
* Asked for at most once per session and never on the path of drawing anything: the
|
||||
* rail has a perfectly good default order, and this only decides which end of it a
|
||||
* household sees first.
|
||||
*/
|
||||
@GET("v1/genres/affinity")
|
||||
suspend fun genreAffinity(): com.ponzischeme89.memby.data.model.GenreAffinity
|
||||
|
||||
/** One paged media-type shelf without a genre filter. */
|
||||
@GET("v1/library/items")
|
||||
suspend fun libraryItems(
|
||||
|
||||
@@ -121,7 +121,7 @@ class MembyDreamService : DreamService() {
|
||||
TrailerPlaybackRequest(
|
||||
subjectId = item.id,
|
||||
title = item.name,
|
||||
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
|
||||
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item),
|
||||
),
|
||||
)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
@@ -617,7 +617,7 @@ private fun DetailHero(
|
||||
// treatment on this near-black scrim is an invisible heading.
|
||||
val repository = ServiceLocator.repository
|
||||
val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
|
||||
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
|
||||
if (repository.showTitleLogo) repository.logoUrl(item) else null
|
||||
}
|
||||
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
||||
val hasRatings = remember(ratings, showRatingsStrip) {
|
||||
@@ -964,10 +964,9 @@ private fun DetailIdentity(
|
||||
contentAlignment = Alignment.BottomStart,
|
||||
) {
|
||||
if (logo != null) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
TitleLogoImage(
|
||||
logoUrl = logo,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.BottomStart,
|
||||
modifier = Modifier
|
||||
.width(DetailHeroMetrics.LogoMaxWidth)
|
||||
|
||||
@@ -1526,13 +1526,27 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The size of the media-type mark on a card's metadata line.
|
||||
*
|
||||
* Deliberately smaller than any icon a viewer can aim at — the rail's marks are 21dp and
|
||||
* the circular detail actions larger still. This one is read, never pressed, and at 13dp
|
||||
* it sits inside the 12sp line's own height, so a card carrying it is exactly as tall as
|
||||
* one that does not.
|
||||
*/
|
||||
private val MediaTypeMarkSize = 13.dp
|
||||
|
||||
/**
|
||||
* Which of the two kinds of thing a card is, for a grid that holds both.
|
||||
*
|
||||
* Only the Genres destination genuinely mixes them — the Movies and TV Shows pages name a
|
||||
* type, so every card on those wears the same mark and it says nothing. It is a *mark*
|
||||
* rather than a word because it sits over artwork at three metres, where "TV SHOW" in a
|
||||
* pill would be read before the poster it is covering.
|
||||
* rather than a word because the metadata line is one line at three metres, and "TV SHOW"
|
||||
* spelled out there would crowd out the year and the runtime it sits beside.
|
||||
*
|
||||
* It belongs to that line rather than to the poster: the type is informational in the same
|
||||
* way the year and the runtime are, and grouping the three together leaves the artwork —
|
||||
* the only thing on the card worth looking at — carrying nothing it did not come with.
|
||||
*
|
||||
* A season or an episode is marked as television: both are parts of a show, and a viewer
|
||||
* separating films from shows is not asking about the difference between them. Anything
|
||||
@@ -2012,7 +2026,7 @@ private fun MediaCard(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount),
|
||||
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount, showMediaTypeIcon),
|
||||
modifier = modifier.width(width),
|
||||
) { focused ->
|
||||
Column {
|
||||
@@ -2119,19 +2133,6 @@ private fun MediaCard(
|
||||
airingLabel,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||
)
|
||||
} else if (showMediaTypeIcon) {
|
||||
// Which of the two things this is, for a grid that holds both. The
|
||||
// top-left is the airing badge's corner first: a schedule card already
|
||||
// says what it is by saying when it is on, and two marks in one corner
|
||||
// would overlap. The watched tick and the heart own the other corner.
|
||||
mediaTypeMark(item)?.let { (icon, description) ->
|
||||
MediaStatusIcon(
|
||||
icon = icon,
|
||||
description = description,
|
||||
tint = Color.White.copy(alpha = 0.92f),
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (focused) {
|
||||
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||
@@ -2147,15 +2148,37 @@ private fun MediaCard(
|
||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||
)
|
||||
if (showSecondaryMetadata) {
|
||||
Text(
|
||||
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// The media type sits *in* the metadata line rather than over the poster:
|
||||
// it is informational, like the year and the runtime beside it, and the
|
||||
// artwork is the one thing on the card worth looking at. The row is
|
||||
// vertically centred and the mark is smaller than the line it sits on, so
|
||||
// it cannot make the card taller than one without it.
|
||||
val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
||||
)
|
||||
) {
|
||||
typeMark?.let { (icon, _) ->
|
||||
Icon(
|
||||
icon,
|
||||
// The card's own description already names the type; a second
|
||||
// reading of it here would be the same fact said twice.
|
||||
contentDescription = null,
|
||||
tint = EmbyGreen,
|
||||
modifier = Modifier.size(MediaTypeMarkSize),
|
||||
)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
Text(
|
||||
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
}
|
||||
@@ -2395,8 +2418,12 @@ private fun cardDescription(
|
||||
item: BaseItem,
|
||||
progress: Float,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
showMediaTypeIcon: Boolean = false,
|
||||
): String = buildString {
|
||||
append(item.name)
|
||||
if (showMediaTypeIcon) mediaTypeMark(item)?.let { (_, description) ->
|
||||
append(", ").append(description)
|
||||
}
|
||||
item.seriesName?.let { append(", ").append(it) }
|
||||
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
|
||||
if (item.userData?.played == true) append(", watched")
|
||||
|
||||
@@ -367,7 +367,7 @@ private fun FeaturedMovieCard(
|
||||
val repository = ServiceLocator.repository
|
||||
val showLogo = repository.showTitleLogo
|
||||
val logoUrl = remember(item.id, item.imageTags, item.parentLogoItemId, item.parentLogoImageTag, showLogo) {
|
||||
if (showLogo) repository.logoUrl(item, 640) else null
|
||||
if (showLogo) repository.logoUrl(item) else null
|
||||
}
|
||||
val useTextTitle = useTextTitleForLogo(logoUrl)
|
||||
FocusScaleContainer(
|
||||
@@ -424,11 +424,10 @@ private fun FeaturedMovieCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = { titleLines = it.lineCount },
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
} else if (logoUrl != null) {
|
||||
TitleLogoImage(
|
||||
logoUrl = logoUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterStart,
|
||||
modifier = Modifier.width(240.dp).height(56.dp),
|
||||
)
|
||||
|
||||
@@ -183,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
init {
|
||||
refreshAll()
|
||||
// The Genres browser orders its rail from what this viewer watches, and reads that
|
||||
// answer synchronously when the page is constructed. Warmed here because the browser
|
||||
// is never the first thing a television draws — somebody has to travel the rail to
|
||||
// reach it — so by the time it is opened this has long since landed, and the page
|
||||
// pays nothing for being personalised. Costs one request per six hours per set.
|
||||
repository.warmGenreAffinity()
|
||||
viewModelScope.launch {
|
||||
// Arrives as the player exits, ahead of the report and well ahead of the rows
|
||||
// coming back, so the card a viewer is standing on already shows the progress
|
||||
@@ -463,6 +469,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
|
||||
}
|
||||
launch { runCatching { repository.hasTrailer(item.id) } }
|
||||
// The logo is the one part of a detail page's hero that arrived after the page
|
||||
// did: it is a second image, and it cannot be drawn until it has been judged
|
||||
// legible against the near-black scrim, so until that judgement lands the page
|
||||
// shows the plain-text title and swaps. Warming it here means the bytes and the
|
||||
// verdict are both held by the time the press composes the page, so the title
|
||||
// treatment is on its first frame. Skipped outright when the viewer has turned
|
||||
// logos off, which is the one case where the fetch could never be spent.
|
||||
if (repository.showTitleLogo) {
|
||||
launch { warmTitleLogo(repository.logoUrl(item)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,8 +183,10 @@ import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -2356,7 +2358,7 @@ private fun HomeScreen(
|
||||
subjectId = item.id,
|
||||
title = item.name,
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
||||
logoUrl = repo.logoUrl(item, maxWidth = 720),
|
||||
logoUrl = repo.logoUrl(item),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -2407,14 +2409,37 @@ private fun HomeScreen(
|
||||
// rendered as a shelf, so this is the only thing that can still see it.
|
||||
// Warm both section heroes while Home is being read. Navigation then normally draws
|
||||
// from memory, while the gateway's user/placement cache keeps this cheap across TVs.
|
||||
//
|
||||
// The same effect is what makes an operator's hero change arrive on a set that is
|
||||
// already switched on: the gateway announces the configuration behind the heroes as a
|
||||
// revision on the status poll, so this waits on that flow rather than on a timer, and
|
||||
// refetches only when it moves. Collected inside the effect rather than read as state
|
||||
// in the body, because the launcher must not recompose merely to be told a number.
|
||||
LaunchedEffect(settings.userId) {
|
||||
listOf(
|
||||
BrowseDestination.MOVIES to "movies",
|
||||
BrowseDestination.SHOWS to "tv_shows",
|
||||
).forEach { (destination, placement) ->
|
||||
launch {
|
||||
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
|
||||
sectionHeroRows = sectionHeroRows + (destination to resolved)
|
||||
var appliedHeroRevision: String? = null
|
||||
ServiceLocator.maintenance.heroRevision.collectLatest { revision ->
|
||||
val moved = appliedHeroRevision != null &&
|
||||
revision.isNotBlank() && revision != appliedHeroRevision
|
||||
if (revision.isNotBlank()) appliedHeroRevision = revision
|
||||
coroutineScope {
|
||||
// Home's hero arrives with the home rows, so it is asked for separately
|
||||
// only once the configuration behind it has actually moved. Fetching it at
|
||||
// launch would replace a hero already on screen with a second composition
|
||||
// of the same thing, which is a flash for nothing.
|
||||
listOfNotNull(
|
||||
BrowseDestination.MOVIES to "movies",
|
||||
BrowseDestination.SHOWS to "tv_shows",
|
||||
(BrowseDestination.HOME to "home").takeIf { moved },
|
||||
).forEach { (destination, placement) ->
|
||||
if (!moved && sectionHeroRows.containsKey(destination)) return@forEach
|
||||
launch {
|
||||
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
|
||||
// Replaced one placement at a time, never cleared first: the
|
||||
// cards that did not change keep the artwork already decoded
|
||||
// for them and the row never blanks between the two states.
|
||||
sectionHeroRows = sectionHeroRows + (destination to resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2432,7 +2457,15 @@ private fun HomeScreen(
|
||||
}
|
||||
val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) {
|
||||
when (selectedDestination) {
|
||||
BrowseDestination.HOME -> selectHomeHeroMovies(rows, heroDay, homeContent.rows)
|
||||
// The separately fetched home hero wins where there is one, which is only
|
||||
// after the configuration moved under a set sitting on this screen; otherwise
|
||||
// the hero row that came with the home payload is still the right answer.
|
||||
BrowseDestination.HOME -> selectHomeHeroMovies(
|
||||
rows,
|
||||
heroDay,
|
||||
sectionHeroRows[BrowseDestination.HOME]?.takeIf(List<HomeRow>::isNotEmpty)
|
||||
?: homeContent.rows,
|
||||
)
|
||||
BrowseDestination.MOVIES, BrowseDestination.SHOWS ->
|
||||
serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty())
|
||||
else -> emptyList()
|
||||
|
||||
@@ -1,14 +1,128 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.compose.AsyncImage
|
||||
import coil.imageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Everything the app knows about a title's logo artwork, remembered for the life of the
|
||||
* process.
|
||||
*
|
||||
* A logo is asked about far more often than it changes: the launcher's hero draws one, the
|
||||
* detail page it opens draws the same one, walking Back and pressing again draws it a third
|
||||
* time, and the screensaver and the TV calendar draw it for the same shows. Two things used
|
||||
* to be repeated on every one of those passes and both are visible to a viewer as a logo
|
||||
* that flashes into a text title and back:
|
||||
*
|
||||
* - **The darkness verdict was recomputed.** [useTextTitleForLogo] decoded a small copy on
|
||||
* every composition and started from "use the text title", so a page whose logo had
|
||||
* already been judged legible still drew its name first and swapped a frame or more later.
|
||||
* It is remembered here instead, keyed on the URL, so a title seen once resolves
|
||||
* synchronously and the logo is on the first frame.
|
||||
* - **The probe evicted the artwork it was probing.** Coil's memory cache is keyed on the
|
||||
* image's URL and *not* on the size it was decoded at, so a 64×64 inspection copy written
|
||||
* under that key replaced the full-size bitmap the hero had just cached — which is why a
|
||||
* detail page returned to a second time decoded its logo again from disk. The probe now
|
||||
* runs with the memory cache switched off, leaving the display copy alone.
|
||||
*
|
||||
* Invalidation is free and deliberate: an Emby logo URL carries the item id *and* the image
|
||||
* tag, so artwork changed on the server is a different key here, in Coil's memory cache and
|
||||
* in its disk cache alike. Nothing has to be told about it.
|
||||
*/
|
||||
internal object TitleLogoCache {
|
||||
|
||||
/**
|
||||
* Enough for every logo a household's launcher, calendar and screensaver could put on
|
||||
* screen in a session, and small enough to be nothing next to the bitmaps themselves —
|
||||
* a verdict is one boolean and a URL.
|
||||
*/
|
||||
private const val MAX_ENTRIES = 512
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/** Access-ordered, so the entries that survive are the ones still being looked at. */
|
||||
private val verdicts = object : LinkedHashMap<String, Boolean>(64, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>) =
|
||||
size > MAX_ENTRIES
|
||||
}
|
||||
|
||||
private val inFlight = mutableMapOf<String, Deferred<Boolean?>>()
|
||||
|
||||
/**
|
||||
* Its own scope, not the caller's, for the reason the repository's single flights have
|
||||
* one: the caller here is usually a detail page's [produceState] or a focus warm, both
|
||||
* of which die the moment the D-pad moves. Shared work that inherited that cancellation
|
||||
* would fail everybody who had joined it.
|
||||
*/
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* The application context, so a warm can be started from somewhere that holds no
|
||||
* Android context of its own — [com.ponzischeme89.memby.ui.HomeViewModel] is the caller
|
||||
* that matters, and giving a ViewModel a context to keep would be the worse trade.
|
||||
* Application-scoped, so it holds nothing that could outlive its owner.
|
||||
*/
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
|
||||
/** Called once, from `MembyApp.onCreate`. */
|
||||
fun install(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
internal fun context(): Context? = appContext
|
||||
|
||||
/** The remembered verdict for [url], or null if this logo has never been judged. */
|
||||
fun verdict(url: String): Boolean? = synchronized(lock) { verdicts[url] }
|
||||
|
||||
/**
|
||||
* Whether [url]'s artwork is too dark to use as a heading, judged once per URL.
|
||||
*
|
||||
* Null means "could not tell" — a failed or cancelled fetch — and is deliberately not
|
||||
* remembered: a title must not be condemned to its text heading for the life of the
|
||||
* process because the network hiccuped once.
|
||||
*
|
||||
* Concurrent callers for one URL share a single probe. That is not only a saving: the
|
||||
* home hero and the detail page it opens ask about the same logo within a frame of each
|
||||
* other, and two probes would be two decodes of the same bytes.
|
||||
*/
|
||||
suspend fun resolve(url: String, probe: suspend (String) -> Boolean?): Boolean? {
|
||||
verdict(url)?.let { return it }
|
||||
val work = synchronized(lock) {
|
||||
inFlight[url] ?: scope.async {
|
||||
probe(url)?.also { dark -> synchronized(lock) { verdicts[url] = dark } }
|
||||
}.also { fresh ->
|
||||
inFlight[url] = fresh
|
||||
fresh.invokeOnCompletion { synchronized(lock) { inFlight.remove(url) } }
|
||||
}
|
||||
}
|
||||
return work.await()
|
||||
}
|
||||
|
||||
/** Test seam only: a verdict cache that outlived one test would decide the next. */
|
||||
internal fun reset() = synchronized(lock) {
|
||||
verdicts.clear()
|
||||
inFlight.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a title's logo artwork should be replaced by its plain-text name.
|
||||
@@ -18,6 +132,9 @@ import coil.request.SuccessResult
|
||||
* overwhelmingly dark. Until the image has been inspected, text is the safe default — a
|
||||
* title that arrives a frame late is better than a title that never appears.
|
||||
*
|
||||
* A logo judged once is judged for the life of the process ([TitleLogoCache]), so that
|
||||
* frame is paid the first time a title is seen and never again.
|
||||
*
|
||||
* Shared by the screensaver and the detail hero: both draw a title over a near-black scrim,
|
||||
* and a logo that is invisible on one is invisible on the other.
|
||||
*/
|
||||
@@ -25,21 +142,99 @@ import coil.request.SuccessResult
|
||||
internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
|
||||
if (logoUrl == null) return true
|
||||
val context = LocalContext.current
|
||||
val isDark by produceState(initialValue = true, logoUrl) {
|
||||
value = runCatching {
|
||||
val result = context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(logoUrl)
|
||||
.allowHardware(false)
|
||||
.size(64, 64)
|
||||
.build(),
|
||||
) as? SuccessResult ?: return@runCatching true
|
||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||
}.getOrDefault(true)
|
||||
// Read outside the producer so a logo already judged is the *initial* value rather than
|
||||
// a correction applied a frame later. This is the whole of "no flash on the way back".
|
||||
val known = TitleLogoCache.verdict(logoUrl)
|
||||
val isDark by produceState(initialValue = known ?: true, logoUrl, known) {
|
||||
if (known != null) return@produceState
|
||||
value = TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) } ?: true
|
||||
}
|
||||
return isDark
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo itself, drawn the same way everywhere it appears.
|
||||
*
|
||||
* Every surface asks Emby for one width ([com.ponzischeme89.memby.data.LOGO_MAX_WIDTH]), so
|
||||
* the URL is identical on the launcher, the detail page, the calendar and the screensaver —
|
||||
* one entry in Coil's caches, one probe, one fetch, whatever size the box happens to be.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TitleLogoImage(
|
||||
logoUrl: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = alignment,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a logo and judges it before anybody asks to see it.
|
||||
*
|
||||
* Called while a card holds focus, which is the best warning of a press this app gets: by
|
||||
* the time the detail page composes, the bytes are in Coil's caches and the verdict is
|
||||
* known, so the logo is on the page's first frame instead of arriving after it.
|
||||
*
|
||||
* It requests the artwork at [WARM_SIZE_PX] rather than at any one screen's box, because
|
||||
* Coil's memory cache is keyed on the URL and validated against the requested size: a copy
|
||||
* decoded at least as large as the biggest stage that draws it satisfies every smaller one,
|
||||
* while a small copy would be rejected and re-decoded by the largest. Failure is silent —
|
||||
* this is a hint, and every consumer still works exactly as it did without it.
|
||||
*/
|
||||
internal suspend fun warmTitleLogo(logoUrl: String?) {
|
||||
if (logoUrl.isNullOrBlank()) return
|
||||
val context = TitleLogoCache.context() ?: return
|
||||
runCatching {
|
||||
context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(logoUrl)
|
||||
.size(WARM_SIZE_PX, WARM_SIZE_PX)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest logo stage in the app is the screensaver's 320×96dp; on a 1080p television
|
||||
* that is 320×96 device pixels at density 1.0 and 480×144 at the 1.5 a Chromecast reports.
|
||||
* Asking for a square of this side leaves [ContentScale.Fit] room for both a very wide and
|
||||
* a very tall title treatment without holding a needlessly large bitmap.
|
||||
*/
|
||||
private const val WARM_SIZE_PX = 512
|
||||
|
||||
/**
|
||||
* Decodes a small copy of [url] and reports whether it is too dark to read as a heading.
|
||||
*
|
||||
* **The memory cache is switched off for this request and that is load-bearing.** Coil keys
|
||||
* that cache on the image's URL alone, so writing this 64×64 copy under the same key as the
|
||||
* displayed logo replaced it — every page that probed a logo threw away the full-size
|
||||
* bitmap the last page had cached, and the next display request decoded it again. The disk
|
||||
* cache is left on deliberately: the bytes are worth keeping, and they are what the display
|
||||
* request reads instead of going back to the network.
|
||||
*/
|
||||
private suspend fun probeLogoDarkness(context: Context, url: String): Boolean? = runCatching {
|
||||
val result = context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.allowHardware(false)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.size(64, 64)
|
||||
.build(),
|
||||
) as? SuccessResult ?: return@runCatching null
|
||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||
}.getOrElse { error ->
|
||||
if (error is CancellationException) throw error
|
||||
null
|
||||
}
|
||||
|
||||
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
||||
var opaquePixels = 0
|
||||
var darkPixels = 0
|
||||
|
||||
@@ -53,6 +53,7 @@ import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||
import com.ponzischeme89.memby.ui.distinctItems
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
@@ -85,7 +86,7 @@ internal fun CalendarContent(
|
||||
modifier: Modifier = Modifier,
|
||||
posterUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.primaryUrl(it, 360) },
|
||||
backdropUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.backdropUrl(it, 720) },
|
||||
logoUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.logoUrl(it, 420) },
|
||||
logoUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.logoUrl(it) },
|
||||
) {
|
||||
val weeks = remember(state.calendar) { calendarAgendaWeeks(state.calendar) }
|
||||
val weekIndex = remember(weeks, state.selectedDate) {
|
||||
@@ -589,10 +590,9 @@ private fun ProgrammeCard(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (logoUrl != null) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
TitleLogoImage(
|
||||
logoUrl = logoUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.widthIn(max = 190.dp).height(34.dp),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -41,7 +41,20 @@ class GenreBrowseViewModel(
|
||||
private val repository: EmbyRepository,
|
||||
private val itemType: String,
|
||||
) : ViewModel() {
|
||||
private val categories = genreCategoryTabs(itemType)
|
||||
/**
|
||||
* The rail, in this viewer's order, settled once when the screen is constructed.
|
||||
*
|
||||
* Read from the repository's snapshot rather than awaited, and never recomputed while
|
||||
* the screen is alive. Both halves matter: the categories are the first thing the page
|
||||
* draws and must not appear late, and a rail that re-ordered itself under a D-pad
|
||||
* already travelling down it would be worse than one that was never personalised at all.
|
||||
* A set whose affinity has not landed yet gets the catalogue's own order and is
|
||||
* personalised the next time the page is opened.
|
||||
*/
|
||||
private val categories = personaliseGenreCategories(
|
||||
genreCategoryTabs(itemType),
|
||||
repository.genreAffinitySnapshot(),
|
||||
)
|
||||
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
|
||||
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
||||
private var pageJob: Job? = null
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GenreAffinity
|
||||
|
||||
/**
|
||||
* How the Genres browser's rail is ordered for one viewer.
|
||||
*
|
||||
* The catalogue itself is still product design — the same sixteen categories, the same
|
||||
* labels, the same aliases — and this only decides which of them a household sees first.
|
||||
* That separation is deliberate: the gateway knows *what* somebody watches, because
|
||||
* Tracearr's sessions and the imported catalogue's genres are both already in Postgres, but
|
||||
* it holds no copy of this list and could not be the thing that sorts it without growing
|
||||
* one. So the wire carries Emby's own genre spellings with a weight each and the fold
|
||||
* happens here, against the alias table this file's neighbour already owns.
|
||||
*
|
||||
* Nothing is ever hidden. A genre somebody has never watched is still in the rail, below
|
||||
* the ones they have — which is the whole difference between personalisation and a filter.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The least history worth re-ordering somebody's rail for.
|
||||
*
|
||||
* Below this the weights are not a taste, they are the last few things that happened to be
|
||||
* on: one Western on a wet Sunday would otherwise lead the rail of a household that has
|
||||
* never watched another. A viewer under the floor keeps the catalogue order and acquires
|
||||
* their own as they watch, which is exactly what a new account should do.
|
||||
*/
|
||||
const val MIN_GENRE_AFFINITY_SESSIONS = 12
|
||||
|
||||
/**
|
||||
* The share of the top genre a category must reach before it is lifted at all.
|
||||
*
|
||||
* The weights are already scaled so the most-watched genre is 1, so this is a proportion
|
||||
* rather than a count and means the same thing for a household that watches nightly and one
|
||||
* that watches on Sundays. Its job is the long tail: without it, a category holding a single
|
||||
* qualifying session outranks every category holding none, and the bottom of the rail
|
||||
* reshuffles on noise while the top — the half anybody looks at — says nothing new.
|
||||
*/
|
||||
const val GENRE_AFFINITY_FLOOR = 0.08
|
||||
|
||||
/**
|
||||
* Scores one category against a viewer's genre weights.
|
||||
*
|
||||
* A category is several Emby labels (Sci-Fi & Fantasy is six), so its score is their sum:
|
||||
* a household whose science fiction is catalogued by one agent as "Science Fiction" and by
|
||||
* another as "Sci-Fi & Fantasy" watches one shelf, and reading the two apart would rank it
|
||||
* as half of what it is. Matching is case-insensitive because the labels come from three
|
||||
* metadata agents and only one of them is consistent about it.
|
||||
*/
|
||||
fun genreCategoryScore(category: GenreCategory, weights: Map<String, Double>): Double {
|
||||
if (category.genres.isEmpty() || weights.isEmpty()) return 0.0
|
||||
var total = 0.0
|
||||
category.genres.forEach { label ->
|
||||
total += weights[label.lowercase()] ?: 0.0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail, in this viewer's order.
|
||||
*
|
||||
* Three properties hold it together and each is pinned by a test:
|
||||
*
|
||||
* - **"All" never moves.** It is the catalogue itself rather than a genre, it is the first
|
||||
* focus target on the page, and a rail whose first entry moved under somebody would lose
|
||||
* them the one place they can always return to.
|
||||
* - **The sort is stable, so the default order is the tie-break.** Every category the
|
||||
* viewer has no evidence for scores zero and therefore keeps its product order, below the
|
||||
* ones they watch — which is what makes an unpersonalised half of the rail still read as
|
||||
* a considered list rather than as an arbitrary one.
|
||||
* - **Nothing is dropped.** The output is a permutation of the input, always, whatever the
|
||||
* weights say.
|
||||
*
|
||||
* Insufficient history returns the catalogue untouched, which is also what a gateway that
|
||||
* predates the route, a household running no Tracearr, and an operator who has switched it
|
||||
* off all produce.
|
||||
*/
|
||||
fun personaliseGenreCategories(
|
||||
categories: List<GenreCategory>,
|
||||
affinity: GenreAffinity,
|
||||
): List<GenreCategory> {
|
||||
if (categories.size < 2) return categories
|
||||
if (affinity.sessions < MIN_GENRE_AFFINITY_SESSIONS) return categories
|
||||
val weights = affinity.weightsByLabel()
|
||||
if (weights.isEmpty()) return categories
|
||||
|
||||
val scores = categories.associate { category ->
|
||||
val score = genreCategoryScore(category, weights)
|
||||
category.id to if (score >= GENRE_AFFINITY_FLOOR) score else 0.0
|
||||
}
|
||||
if (scores.values.none { it > 0.0 }) return categories
|
||||
|
||||
// The pinned head is whatever leads the list without being a genre — the "All" entry —
|
||||
// and it is taken off before the sort rather than given an unbeatable score, because a
|
||||
// score is a claim about watching and this is a claim about layout.
|
||||
val pinned = categories.takeWhile { it.genres.isEmpty() }
|
||||
val sortable = categories.drop(pinned.size)
|
||||
return pinned + sortable.sortedByDescending { scores[it.id] ?: 0.0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The weights keyed for lookup, lower-cased once rather than per comparison.
|
||||
*
|
||||
* A repeated label keeps its largest weight rather than the last one seen: the list is the
|
||||
* gateway's, ordered by weight, and a duplicate can only be a spelling that reached it
|
||||
* twice — reading the smaller of the two would quietly understate the shelf.
|
||||
*/
|
||||
private fun GenreAffinity.weightsByLabel(): Map<String, Double> {
|
||||
if (genres.isEmpty()) return emptyMap()
|
||||
val out = HashMap<String, Double>(genres.size)
|
||||
genres.forEach { entry ->
|
||||
val key = entry.genre.trim().lowercase()
|
||||
if (key.isEmpty() || entry.weight <= 0.0) return@forEach
|
||||
val existing = out[key]
|
||||
if (existing == null || entry.weight > existing) out[key] = entry.weight
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class ScreensaverActivity : ComponentActivity() {
|
||||
TrailerPlaybackRequest(
|
||||
subjectId = item.id,
|
||||
title = item.name,
|
||||
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
|
||||
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -79,6 +79,7 @@ import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
||||
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
|
||||
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
||||
@@ -763,11 +764,10 @@ private fun InfoAndActions(
|
||||
// Prefer the item's Emby "Logo" artwork over a plain-text title, when the
|
||||
// user has it enabled and this item actually has a logo image.
|
||||
val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null
|
||||
if (!useTextTitleForLogo(logoUrl)) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
if (logoUrl != null && !useTextTitleForLogo(logoUrl)) {
|
||||
TitleLogoImage(
|
||||
logoUrl = logoUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
// A fixed logo stage keeps artwork consistently sized even when
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The remembering half of the logo work, which is the half a viewer notices.
|
||||
*
|
||||
* The probe itself needs a decoder and a network and is not testable here; what is testable
|
||||
* is that it is asked **once** per logo — a second ask is a second decode of bytes the app
|
||||
* already has, and, because the verdict starts at "use the text title", a second frame of a
|
||||
* page showing its name where its title treatment belongs.
|
||||
*/
|
||||
class TitleLogoCacheTest {
|
||||
|
||||
@Before
|
||||
fun clearCache() = TitleLogoCache.reset()
|
||||
|
||||
@Test
|
||||
fun `a logo never seen has no verdict`() {
|
||||
assertNull(TitleLogoCache.verdict("https://gw/v1/images/1/logo?tag=a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resolved verdict is remembered and the probe is not asked again`() = runBlocking {
|
||||
val probes = AtomicInteger()
|
||||
val url = "https://gw/v1/images/1/logo?tag=a"
|
||||
val probe: suspend (String) -> Boolean? = { probes.incrementAndGet(); false }
|
||||
|
||||
assertEquals(false, TitleLogoCache.resolve(url, probe))
|
||||
// The synchronous read is what lets the detail page draw the logo on its first
|
||||
// frame rather than correcting itself a frame later.
|
||||
assertEquals(false, TitleLogoCache.verdict(url))
|
||||
assertEquals(false, TitleLogoCache.resolve(url, probe))
|
||||
assertEquals(1, probes.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image tag change is a different logo`() = runBlocking {
|
||||
val probes = AtomicInteger()
|
||||
val probe: suspend (String) -> Boolean? = { probes.incrementAndGet(); true }
|
||||
|
||||
TitleLogoCache.resolve("https://gw/v1/images/1/logo?tag=old", probe)
|
||||
TitleLogoCache.resolve("https://gw/v1/images/1/logo?tag=new", probe)
|
||||
|
||||
// Artwork replaced on the server carries a new tag, so nothing has to invalidate
|
||||
// anything: the old verdict simply stops being asked for.
|
||||
assertEquals(2, probes.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent asks for one logo share a single probe`() = runBlocking {
|
||||
val probes = AtomicInteger()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val url = "https://gw/v1/images/1/logo?tag=a"
|
||||
val probe: suspend (String) -> Boolean? = {
|
||||
probes.incrementAndGet()
|
||||
release.await()
|
||||
false
|
||||
}
|
||||
|
||||
val first = async { TitleLogoCache.resolve(url, probe) }
|
||||
val second = async { TitleLogoCache.resolve(url, probe) }
|
||||
val third = async { TitleLogoCache.resolve(url, probe) }
|
||||
release.complete(Unit)
|
||||
|
||||
assertEquals(listOf(false, false, false), listOf(first.await(), second.await(), third.await()))
|
||||
// The home hero and the detail page it opens ask within a frame of each other.
|
||||
assertEquals(1, probes.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a probe that could not tell is not remembered as dark`() = runBlocking {
|
||||
val url = "https://gw/v1/images/1/logo?tag=a"
|
||||
|
||||
assertNull(TitleLogoCache.resolve(url) { null })
|
||||
|
||||
// A hiccup must not condemn a title to its text heading for the life of the process.
|
||||
assertNull(TitleLogoCache.verdict(url))
|
||||
assertEquals(false, TitleLogoCache.resolve(url) { false })
|
||||
assertEquals(false, TitleLogoCache.verdict(url))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cache is bounded and keeps what is still being looked at`() = runBlocking {
|
||||
val kept = "https://gw/v1/images/kept/logo?tag=a"
|
||||
TitleLogoCache.resolve(kept) { false }
|
||||
repeat(600) { index ->
|
||||
TitleLogoCache.resolve("https://gw/v1/images/$index/logo?tag=a") { true }
|
||||
// Touching it keeps it, which is the whole point of an access-ordered cache:
|
||||
// the logo a viewer keeps coming back to is the one worth holding.
|
||||
TitleLogoCache.verdict(kept)
|
||||
}
|
||||
|
||||
assertEquals(false, TitleLogoCache.verdict(kept))
|
||||
assertTrue("early entries should have been evicted", TitleLogoCache.verdict("https://gw/v1/images/0/logo?tag=a") == null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GenreAffinity
|
||||
import com.ponzischeme89.memby.data.model.GenreWeight
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The ordering rule for the Genres browser's rail.
|
||||
*
|
||||
* What is worth pinning here is not that a well-watched genre rises — that is one line —
|
||||
* but the three promises around it: nothing is ever hidden, a viewer with no history keeps
|
||||
* the catalogue's order, and the unpersonalised half of the rail stays in product order
|
||||
* rather than becoming arbitrary.
|
||||
*/
|
||||
class GenrePersonalisationTest {
|
||||
|
||||
private fun affinity(sessions: Int, vararg weights: Pair<String, Double>) = GenreAffinity(
|
||||
sessions = sessions,
|
||||
genres = weights.map { GenreWeight(it.first, it.second) },
|
||||
)
|
||||
|
||||
private val tabs get() = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE)
|
||||
|
||||
@Test
|
||||
fun `frequently watched genres rise to the top`() {
|
||||
val ordered = personaliseGenreCategories(
|
||||
tabs,
|
||||
affinity(120, "Crime" to 1.0, "Drama" to 0.8, "Thriller" to 0.6, "Documentary" to 0.4),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("all", "crime", "drama", "thriller", "documentary"),
|
||||
ordered.take(5).map(GenreCategory::id),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue itself is the first focus target on the page and the one place a viewer
|
||||
* can always return to, so it is pinned rather than sorted.
|
||||
*/
|
||||
@Test
|
||||
fun `the all entry never moves`() {
|
||||
val ordered = personaliseGenreCategories(tabs, affinity(200, "Western" to 1.0))
|
||||
assertEquals(ALL_MEDIA_CATEGORY_ID, ordered.first().id)
|
||||
}
|
||||
|
||||
/** Personalisation, not a filter: every genre stays reachable however little it is watched. */
|
||||
@Test
|
||||
fun `no genre is ever dropped`() {
|
||||
val ordered = personaliseGenreCategories(tabs, affinity(300, "Horror" to 1.0, "Comedy" to 0.5))
|
||||
assertEquals(tabs.size, ordered.size)
|
||||
assertEquals(tabs.map(GenreCategory::id).sorted(), ordered.map(GenreCategory::id).sorted())
|
||||
}
|
||||
|
||||
/**
|
||||
* Below the floor the weights are not a taste, they are the last few things that
|
||||
* happened to be on — and a new account must look like the product, not like a rail
|
||||
* built out of one wet Sunday.
|
||||
*/
|
||||
@Test
|
||||
fun `insufficient history keeps the default order`() {
|
||||
val ordered = personaliseGenreCategories(
|
||||
tabs,
|
||||
affinity(MIN_GENRE_AFFINITY_SESSIONS - 1, "Western" to 1.0),
|
||||
)
|
||||
assertEquals(tabs.map(GenreCategory::id), ordered.map(GenreCategory::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no history at all keeps the default order`() {
|
||||
assertEquals(
|
||||
tabs.map(GenreCategory::id),
|
||||
personaliseGenreCategories(tabs, GenreAffinity()).map(GenreCategory::id),
|
||||
)
|
||||
}
|
||||
|
||||
/** A gateway that answered with a count but no labels has said nothing about anybody. */
|
||||
@Test
|
||||
fun `sessions without weights keep the default order`() {
|
||||
assertEquals(
|
||||
tabs.map(GenreCategory::id),
|
||||
personaliseGenreCategories(tabs, affinity(500)).map(GenreCategory::id),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories a viewer has no evidence for keep their product order below the ones
|
||||
* they watch — the sort is stable, and that is what stops the bottom of the rail reading
|
||||
* as an arbitrary list.
|
||||
*/
|
||||
@Test
|
||||
fun `unwatched genres keep their catalogue order`() {
|
||||
val ordered = personaliseGenreCategories(tabs, affinity(90, "Horror" to 1.0))
|
||||
val defaultTail = tabs.map(GenreCategory::id).filterNot { it == "horror" || it == "all" }
|
||||
assertEquals(defaultTail, ordered.map(GenreCategory::id).filterNot { it == "horror" || it == "all" })
|
||||
}
|
||||
|
||||
/**
|
||||
* The long tail must not reshuffle on noise: a category holding a single stray session
|
||||
* has nothing to say, and lifting it above every category holding none would change the
|
||||
* bottom of the rail every week while the top said nothing new.
|
||||
*/
|
||||
@Test
|
||||
fun `a weight below the floor does not lift a category`() {
|
||||
val ordered = personaliseGenreCategories(
|
||||
tabs,
|
||||
affinity(80, "Comedy" to 1.0, "Western" to GENRE_AFFINITY_FLOOR / 2),
|
||||
)
|
||||
val ids = ordered.map(GenreCategory::id)
|
||||
assertTrue(ids.indexOf("western") > ids.indexOf("romance"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A category is several Emby labels because three metadata agents describe one shelf
|
||||
* three ways, so its score is their sum — reading them apart would rank science fiction
|
||||
* as a fraction of what a household actually watches.
|
||||
*/
|
||||
@Test
|
||||
fun `a category sums every spelling of its genre`() {
|
||||
val weights = mapOf("science fiction" to 0.4, "sci-fi & fantasy" to 0.5)
|
||||
val category = genreCategory(ALL_MEDIA_ITEM_TYPE, "sci-fi-fantasy")
|
||||
assertEquals(0.9, genreCategoryScore(category, weights), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matching a genre label ignores case`() {
|
||||
val ordered = personaliseGenreCategories(tabs, affinity(90, "cRiMe" to 1.0))
|
||||
assertEquals("crime", ordered[1].id)
|
||||
}
|
||||
|
||||
/** The All entry is the catalogue, not a genre; nothing it is scored against exists. */
|
||||
@Test
|
||||
fun `the all entry scores nothing`() {
|
||||
assertEquals(
|
||||
0.0,
|
||||
genreCategoryScore(genreCategory(ALL_MEDIA_ITEM_TYPE, ALL_MEDIA_CATEGORY_ID), mapOf("drama" to 1.0)),
|
||||
1e-9,
|
||||
)
|
||||
}
|
||||
|
||||
/** A viewer's taste orders the Movies and TV Series rails as well as the mixed one. */
|
||||
@Test
|
||||
fun `each media type is personalised from the same reading`() {
|
||||
val watching = affinity(150, "Documentary" to 1.0)
|
||||
assertEquals("documentary", personaliseGenreCategories(genreCategoryTabs("Movie"), watching)[1].id)
|
||||
assertEquals("documentary", personaliseGenreCategories(genreCategoryTabs("Series"), watching)[1].id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user