This commit is contained in:
ponzischeme89
2026-08-23 16:38:15 +12:00
parent 63c3df11b2
commit d4e1cb2fbf
17 changed files with 355 additions and 37 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.08"
val defaultVersionName = "0.3.09"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -391,10 +391,11 @@ internal fun AppRoot(
BackHandler {
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
}
key(loaded.userId, loaded.serverUrl) {
val browseIdentity = browseUserIdentity(loaded)
key(browseIdentity) {
HomeScreen(settings = loaded, remoteConfig = remoteConfig)
}
LaunchedEffect(loaded.userId, loaded.serverUrl) {
LaunchedEffect(browseIdentity) {
// Report after the launcher has submitted a frame, not merely when its
// composable was entered. StartupTimingMetric can now distinguish the
// quick opening surface from the point at which D-pad content is live.
@@ -202,14 +202,12 @@ internal fun HomeScreen(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val factory = remember(repo) { HomeViewModelFactory(repo) }
val browseIdentity = browseUserIdentity(settings)
// HomeViewModel owns user-scoped rows, recommendation state, metadata caches and
// in-flight requests. The Activity's default ViewModelStore outlives a Compose key,
// so using it here leaks the previous profile's state across a profile switch.
val profileViewModelOwner = remember(
settings.activeProfileId,
settings.serverUrl,
settings.userId,
) { ProfileViewModelStoreOwner() }
// in-flight requests. The Activity's default ViewModelStore outlives a Compose key.
// A household viewer changes without changing the Emby profile, so the complete browse
// identity must own this store or the outgoing viewer's rows and requests survive.
val profileViewModelOwner = remember(browseIdentity) { ProfileViewModelStoreOwner() }
DisposableEffect(profileViewModelOwner) {
onDispose { profileViewModelOwner.clear() }
}
@@ -1805,20 +1803,15 @@ internal fun HomeScreen(
// Selecting a person is the panel's own press now rather than a screen
// reached from it, which is the whole point: this is the thing a household
// changes nightly. Everything on the launcher belongs to the outgoing
// viewer, so the journey is closed and the rows refreshed rather than left
// standing under a different person's name — the same work the full picker
// does, because it is the same switch.
// viewer, so the journey is closed and the rows replaced rather than left
// standing under a different person's name. Changing the viewer rebuilds
// the shared launcher lifecycle, including its ViewModel and focus graph.
onViewerSelected = { viewer ->
userSwitcherVisible = false
navigationExpanded = false
scope.launch {
delay(16.milliseconds)
runCatching { navigationFocusRequester.requestFocus() }
}
scope.launch {
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
repo.switchViewer(viewer)
homeViewModel.refreshAll()
}
},
onAddViewer = {
@@ -1852,27 +1845,29 @@ internal fun HomeScreen(
)
}
if (showViewerPicker) {
val closeViewerPicker: () -> Unit = {
val closeViewerPicker: (restoreFocus: Boolean) -> Unit = { restoreFocus ->
showViewerPicker = false
scope.launch {
delay(16.milliseconds)
runCatching { navigationFocusRequester.requestFocus() }
if (restoreFocus) {
scope.launch {
delay(16.milliseconds)
runCatching { navigationFocusRequester.requestFocus() }
}
}
}
BackHandler(onBack = closeViewerPicker)
BackHandler { closeViewerPicker(true) }
Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) {
ViewerPicker(
viewers = viewers,
activeViewerId = settings.activeViewerId,
onViewerSelected = { viewer ->
closeViewerPicker()
// Do not aim focus back into the outgoing viewer's graph while its
// user-keyed launcher is being disposed.
closeViewerPicker(false)
scope.launch {
// Everything on the launcher belongs to the outgoing viewer, so
// the journey is closed and the rows are refreshed rather than
// left standing under a different person's name.
// the journey is closed before the viewer-keyed launcher is rebuilt.
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
repo.switchViewer(viewer)
homeViewModel.refreshAll()
}
},
// Both open over the picker rather than instead of it, so Back is one
@@ -1899,7 +1894,6 @@ internal fun HomeScreen(
val removed = repo.removeViewer(viewer)
if (removed && watching) {
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
homeViewModel.refreshAll()
}
refreshViewers()
viewerBusyId = null
@@ -424,10 +424,22 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
delay(FOCUS_METADATA_DEBOUNCE_MS)
if (!item.isSchedule) {
val details = loadDetailMetadata(item.id) ?: return@launch
val taggedDetails = focusedItemWithMetadata(item, details)
var taggedDetails = focusedItemWithMetadata(item, details)
// A restored/navigation episode can be thinner than the Continue Watching
// row it came from. If neither that route object nor its item response names
// the series logo, rehydrate the missing image identity from the series'
// shared detail record instead of treating absent fields as "no logo".
if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) {
taggedDetails = taggedDetails.withSeriesTitleLogo(
taggedDetails.seriesId?.let { loadDetailMetadata(it) },
)
}
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails
}
if (repository.showTitleLogo) {
warmTitleLogo(repository.logoUrl(taggedDetails))
}
}
}
detailPrefetchJob = viewModelScope.launch(Dispatchers.IO) {
@@ -947,6 +959,29 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
)
/** Whether the item carries enough wire metadata to build its canonical logo URL. */
internal val BaseItem.hasTitleLogoMetadata: Boolean
get() = !imageTags["Logo"].isNullOrBlank() ||
(!parentLogoItemId.isNullOrBlank() && !parentLogoImageTag.isNullOrBlank())
/**
* Fills an episode's missing parent-logo identity from its fully hydrated series record.
*
* Existing episode metadata always wins. The series is accepted only when its id matches
* the episode's declared SeriesId, so a late response for another focused show can never
* lend this card the wrong artwork.
*/
internal fun BaseItem.withSeriesTitleLogo(series: BaseItem?): BaseItem {
if (hasTitleLogoMetadata || !isEpisode || series == null || series.id != seriesId) return this
val identity = when {
!series.imageTags["Logo"].isNullOrBlank() -> series.id to series.imageTags.getValue("Logo")
!series.parentLogoItemId.isNullOrBlank() && !series.parentLogoImageTag.isNullOrBlank() ->
series.parentLogoItemId to series.parentLogoImageTag
else -> null
} ?: return this
return copy(parentLogoItemId = identity.first, parentLogoImageTag = identity.second)
}
/**
* Whether a cached object can stand in for the detail endpoint's response.
*
@@ -3,7 +3,10 @@ package com.ponzischeme89.memby.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -20,6 +23,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlin.coroutines.cancellation.CancellationException
/**
@@ -111,10 +115,46 @@ internal object TitleLogoCache {
probe(url)?.also { dark -> synchronized(lock) { verdicts[url] = dark } }
}.also { fresh ->
inFlight[url] = fresh
fresh.invokeOnCompletion { synchronized(lock) { inFlight.remove(url) } }
fresh.invokeOnCompletion {
synchronized(lock) {
// A retry may already have replaced a completed null request.
if (inFlight[url] === fresh) inFlight.remove(url)
}
}
}
}
return work.await()
val result = work.await()
// A null result is deliberately retryable. Remove it synchronously rather than
// relying only on invokeOnCompletion: a bounded recovery can begin in the same
// coroutine immediately after await(), before that callback has run.
if (result == null) {
synchronized(lock) {
if (inFlight[url] === work) inFlight.remove(url)
}
}
return result
}
/**
* Resolves a known logo with one bounded recovery after an uncertain image fetch.
*
* [refresh] is false for the ordinary cache-backed attempt and true for recovery, so
* the image layer can bypass a broken local entry and replace it with fresh bytes. A
* failure is still never stored as an artwork verdict, and [attempts] is an explicit
* ceiling rather than a recomposition-driven retry loop.
*/
suspend fun resolveWithRetry(
url: String,
attempts: Int = LOGO_RESOLVE_ATTEMPTS,
retryDelayMillis: Long = LOGO_RETRY_DELAY_MS,
probe: suspend (url: String, refresh: Boolean) -> Boolean?,
): Boolean? {
require(attempts > 0) { "attempts must be positive" }
repeat(attempts) { attempt ->
resolve(url) { probe(it, attempt > 0) }?.let { return it }
if (attempt + 1 < attempts && retryDelayMillis > 0) delay(retryDelayMillis)
}
return null
}
/** Test seam only: a verdict cache that outlived one test would decide the next. */
@@ -147,7 +187,9 @@ internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
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
value = TitleLogoCache.resolveWithRetry(logoUrl) { url, refresh ->
probeLogoDarkness(context, url, refresh)
} ?: true
}
return isDark
}
@@ -166,12 +208,31 @@ internal fun TitleLogoImage(
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
) {
val context = LocalContext.current
var recoveryAttempt by remember(logoUrl) { mutableIntStateOf(0) }
val request = remember(context, logoUrl, recoveryAttempt) {
ImageRequest.Builder(context)
.data(logoUrl)
.apply {
if (recoveryAttempt > 0) {
// A known logo whose normal display request failed is a broken/missing
// cache entry, not proof that the artwork does not exist. Bypass both
// reads once and replace them with the successfully fetched bytes.
memoryCachePolicy(CachePolicy.WRITE_ONLY)
diskCachePolicy(CachePolicy.WRITE_ONLY)
}
}
.build()
}
AsyncImage(
model = logoUrl,
model = request,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
alignment = alignment,
modifier = modifier,
onError = {
if (recoveryAttempt + 1 < LOGO_DISPLAY_ATTEMPTS) recoveryAttempt++
},
)
}
@@ -199,7 +260,9 @@ internal suspend fun warmTitleLogo(logoUrl: String?) {
.build(),
)
}
TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) }
TitleLogoCache.resolveWithRetry(logoUrl) { url, refresh ->
probeLogoDarkness(context, url, refresh)
}
}
/**
@@ -220,12 +283,19 @@ private const val WARM_SIZE_PX = 512
* 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 {
private suspend fun probeLogoDarkness(
context: Context,
url: String,
refresh: Boolean,
): Boolean? = runCatching {
val result = context.imageLoader.execute(
ImageRequest.Builder(context)
.data(url)
.allowHardware(false)
.memoryCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(if (refresh) CachePolicy.WRITE_ONLY else CachePolicy.DISABLED)
.apply {
if (refresh) diskCachePolicy(CachePolicy.WRITE_ONLY)
}
.size(64, 64)
.build(),
) as? SuccessResult ?: return@runCatching null
@@ -253,3 +323,8 @@ private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
}
return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f
}
/** One ordinary request plus one cache-bypassing recovery; neither can loop on recomposition. */
private const val LOGO_RESOLVE_ATTEMPTS = 2
private const val LOGO_DISPLAY_ATTEMPTS = 2
private const val LOGO_RETRY_DELAY_MS = 250L
@@ -1,11 +1,34 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.viewers.viewerIsActive
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* The person whose browse state is allowed to share one Compose lifecycle.
*
* A gateway viewer changes without changing the signed-in Emby account, so [Settings.userId]
* alone is not a user identity. Keeping the viewer here makes every user-dependent launcher
* object rows, scroll positions, focus requesters and ViewModels leave composition as one
* unit when the person on the sofa changes.
*/
internal data class BrowseUserIdentity(
val profileId: String?,
val serverUrl: String?,
val userId: String?,
val viewerId: String,
)
internal fun browseUserIdentity(settings: Settings): BrowseUserIdentity = BrowseUserIdentity(
profileId = settings.activeProfileId,
serverUrl = settings.serverUrl,
userId = settings.userId,
viewerId = settings.activeViewerId,
)
/**
* The scrolling list at the top of the panel occupies [0, rowCount); the pinned actions
* follow it in order. Keeping this arithmetic outside Compose makes remote navigation
@@ -142,6 +142,18 @@ fun MediaBadge(
fontSize: TextUnit = 11.sp,
lineHeight: TextUnit = TextUnit.Unspecified,
) {
if (label == "CC") {
Image(
painter = painterResource(R.drawable.icon_cc),
contentDescription = "Closed captions available",
contentScale = ContentScale.Crop,
modifier = modifier
.height(18.dp)
.aspectRatio(1.5f),
)
return
}
val surroundSoundDescription = when (label) {
"5.1" -> "5.1 surround sound"
"7.1" -> "7.1 surround sound"
@@ -206,6 +206,7 @@ internal fun mediaBadges(item: BaseItem): List<String> {
audio?.channels == 8 -> add("7.1")
audio?.channels == 6 -> add("5.1")
}
if (item.mediaStreams.any { it.type.equals("Subtitle", ignoreCase = true) }) add("CC")
}.distinct()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -49,6 +49,61 @@ class ContinueWatchingResumeTest {
assertEquals("parent-logo-tag", focused.parentLogoImageTag)
}
@Test
fun `a thin restored episode rehydrates its logo from the series`() {
val episode = BaseItem(
id = "episode",
name = "The Episode",
type = "Episode",
seriesId = "series",
seriesName = "The Show",
)
val series = BaseItem(
id = "series",
name = "The Show",
type = "Series",
imageTags = mapOf("Logo" to "series-logo-tag"),
)
val rehydrated = episode.withSeriesTitleLogo(series)
assertTrue(rehydrated.hasTitleLogoMetadata)
assertEquals("series", rehydrated.parentLogoItemId)
assertEquals("series-logo-tag", rehydrated.parentLogoImageTag)
}
@Test
fun `series rehydration cannot replace richer continue watching artwork`() {
val episode = BaseItem(
id = "episode",
type = "Episode",
seriesId = "series",
parentLogoItemId = "series",
parentLogoImageTag = "row-logo-tag",
)
val series = BaseItem(
id = "series",
type = "Series",
imageTags = mapOf("Logo" to "detail-logo-tag"),
)
val rehydrated = episode.withSeriesTitleLogo(series)
assertEquals("row-logo-tag", rehydrated.parentLogoImageTag)
}
@Test
fun `a response for another series cannot lend an episode its logo`() {
val episode = BaseItem(id = "episode", type = "Episode", seriesId = "series")
val otherSeries = BaseItem(
id = "other-series",
type = "Series",
imageTags = mapOf("Logo" to "wrong-logo-tag"),
)
assertFalse(episode.withSeriesTitleLogo(otherSeries).hasTitleLogoMetadata)
}
@Test
fun `detail metadata keeps the continue watching playhead and resume label`() {
val card = BaseItem(
@@ -43,6 +43,54 @@ class MediaBadgesTest {
assertEquals(listOf("7.1"), badgesFor(8))
}
@Test
fun `includes closed captions for movies with a subtitle stream`() {
val item = BaseItem(
id = "captioned-movie",
type = "Movie",
mediaStreams = listOf(MediaStream(type = "Subtitle", language = "eng")),
)
assertEquals(listOf("CC"), mediaBadges(item))
}
@Test
fun `includes closed captions for episodes with a subtitle stream`() {
val item = BaseItem(
id = "captioned-episode",
type = "Episode",
mediaStreams = listOf(MediaStream(type = "subtitle", language = "eng")),
)
assertEquals(listOf("CC"), mediaBadges(item))
}
@Test
fun `places closed captions after surround sound when both are available`() {
val item = BaseItem(
id = "captioned-surround-movie",
type = "Movie",
mediaStreams = listOf(
MediaStream(type = "Audio", channels = 6),
MediaStream(type = "Subtitle", language = "eng"),
),
)
assertEquals(listOf("5.1", "CC"), mediaBadges(item))
}
@Test
fun `does not infer closed captions from presentation text`() {
val item = BaseItem(
id = "uncaptioned-movie",
name = "A Movie CC",
type = "Movie",
mediaStreams = listOf(MediaStream(type = "Audio", title = "English CC", channels = 2)),
)
assertEquals(emptyList<String>(), mediaBadges(item))
}
/** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
@Test
fun `names HDR10+ rather than collapsing it to HDR`() {
@@ -88,6 +88,42 @@ class TitleLogoCacheTest {
assertEquals(false, TitleLogoCache.verdict(url))
}
@Test
fun `an uncertain cache read is retried once with a refresh`() = runBlocking {
val url = "https://gw/v1/images/1/logo?tag=a"
val refreshes = mutableListOf<Boolean>()
val result = TitleLogoCache.resolveWithRetry(
url = url,
retryDelayMillis = 0,
) { _, refresh ->
refreshes += refresh
if (refresh) false else null
}
assertEquals(false, result)
assertEquals(listOf(false, true), refreshes)
assertEquals(false, TitleLogoCache.verdict(url))
}
@Test
fun `logo recovery stops at its attempt limit`() = runBlocking {
val probes = AtomicInteger()
assertNull(
TitleLogoCache.resolveWithRetry(
url = "https://gw/v1/images/1/logo?tag=a",
attempts = 2,
retryDelayMillis = 0,
) { _, _ ->
probes.incrementAndGet()
null
},
)
assertEquals(2, probes.get())
}
@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"
@@ -1,11 +1,41 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.ui.profiles.profileFocusIndexAfterRemoval
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
class UserSwitcherNavigationTest {
@Test
fun `browse identity changes when the active household viewer changes`() {
val account = Settings(
serverUrl = "https://memby.example",
userId = "account",
activeViewerId = "",
)
assertNotEquals(
browseUserIdentity(account),
browseUserIdentity(account.copy(activeViewerId = "viewer-b")),
)
}
@Test
fun `browse identity is stable for non-user preference changes`() {
val before = Settings(
serverUrl = "https://memby.example",
userId = "account",
activeViewerId = "viewer-b",
)
assertEquals(
browseUserIdentity(before),
browseUserIdentity(before.copy(homeCardDensity = "compact")),
)
}
@Test
fun `removed profile hands focus to its stable neighbour`() {
assertEquals(1, profileFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3))