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
+9 -1
View File
@@ -2608,7 +2608,15 @@ logo. Things to preserve:
page's `produceState` and a focus warm and both die when the D-pad moves. page's `produceState` and a focus warm and both die when the D-pad moves.
- **"Could not tell" is not remembered.** A failed or cancelled probe returns null and is - **"Could not tell" is not remembered.** A failed or cancelled probe returns null and is
not recorded — a hiccup must not condemn a title to its text heading for the life of the not recorded — a hiccup must not condemn a title to its text heading for the life of the
process. `TitleLogoCacheTest` pins that, the single flight and the eviction. process. The composing resolver retries once by bypassing and replacing Coil's cache, and
the display request has the same one-retry ceiling; both are explicit attempt counts rather
than state that can loop on recomposition. `TitleLogoCacheTest` pins the recovery, its bound,
the single flight and the eviction.
- **A thin episode route rehydrates from its series.** Returning from an episode page can
reconstruct the Continue Watching selection without its `ParentLogo*` fields, so
`HomeViewModel` reads the matching series' shared detail record before concluding that the
logo is absent. Existing row artwork wins and a response whose series id does not match is
refused; `ContinueWatchingResumeTest` pins both halves.
- **One width, `data/LOGO_MAX_WIDTH` (720).** It was four — 800 in the screensaver, 720 on - **One width, `data/LOGO_MAX_WIDTH` (720).** It was four — 800 in the screensaver, 720 on
the detail page and in the player, 640 on the home hero, 420 in the TV calendar — and a the detail page and in the player, 640 on the home hero, 420 in the TV calendar — and a
width is part of the URL, so one show's logo was four cache keys, four fetches and four width is part of the URL, so one show's logo was four cache keys, four fetches and four
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.08" val defaultVersionName = "0.3.09"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -391,10 +391,11 @@ internal fun AppRoot(
BackHandler { BackHandler {
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings() if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
} }
key(loaded.userId, loaded.serverUrl) { val browseIdentity = browseUserIdentity(loaded)
key(browseIdentity) {
HomeScreen(settings = loaded, remoteConfig = remoteConfig) HomeScreen(settings = loaded, remoteConfig = remoteConfig)
} }
LaunchedEffect(loaded.userId, loaded.serverUrl) { LaunchedEffect(browseIdentity) {
// Report after the launcher has submitted a frame, not merely when its // Report after the launcher has submitted a frame, not merely when its
// composable was entered. StartupTimingMetric can now distinguish the // composable was entered. StartupTimingMetric can now distinguish the
// quick opening surface from the point at which D-pad content is live. // 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 context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val factory = remember(repo) { HomeViewModelFactory(repo) } val factory = remember(repo) { HomeViewModelFactory(repo) }
val browseIdentity = browseUserIdentity(settings)
// HomeViewModel owns user-scoped rows, recommendation state, metadata caches and // HomeViewModel owns user-scoped rows, recommendation state, metadata caches and
// in-flight requests. The Activity's default ViewModelStore outlives a Compose key, // 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. // A household viewer changes without changing the Emby profile, so the complete browse
val profileViewModelOwner = remember( // identity must own this store or the outgoing viewer's rows and requests survive.
settings.activeProfileId, val profileViewModelOwner = remember(browseIdentity) { ProfileViewModelStoreOwner() }
settings.serverUrl,
settings.userId,
) { ProfileViewModelStoreOwner() }
DisposableEffect(profileViewModelOwner) { DisposableEffect(profileViewModelOwner) {
onDispose { profileViewModelOwner.clear() } onDispose { profileViewModelOwner.clear() }
} }
@@ -1805,20 +1803,15 @@ internal fun HomeScreen(
// Selecting a person is the panel's own press now rather than a screen // 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 // reached from it, which is the whole point: this is the thing a household
// changes nightly. Everything on the launcher belongs to the outgoing // changes nightly. Everything on the launcher belongs to the outgoing
// viewer, so the journey is closed and the rows refreshed rather than left // viewer, so the journey is closed and the rows replaced rather than left
// standing under a different person's name — the same work the full picker // standing under a different person's name. Changing the viewer rebuilds
// does, because it is the same switch. // the shared launcher lifecycle, including its ViewModel and focus graph.
onViewerSelected = { viewer -> onViewerSelected = { viewer ->
userSwitcherVisible = false userSwitcherVisible = false
navigationExpanded = false navigationExpanded = false
scope.launch {
delay(16.milliseconds)
runCatching { navigationFocusRequester.requestFocus() }
}
scope.launch { scope.launch {
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
repo.switchViewer(viewer) repo.switchViewer(viewer)
homeViewModel.refreshAll()
} }
}, },
onAddViewer = { onAddViewer = {
@@ -1852,27 +1845,29 @@ internal fun HomeScreen(
) )
} }
if (showViewerPicker) { if (showViewerPicker) {
val closeViewerPicker: () -> Unit = { val closeViewerPicker: (restoreFocus: Boolean) -> Unit = { restoreFocus ->
showViewerPicker = false showViewerPicker = false
scope.launch { if (restoreFocus) {
delay(16.milliseconds) scope.launch {
runCatching { navigationFocusRequester.requestFocus() } delay(16.milliseconds)
runCatching { navigationFocusRequester.requestFocus() }
}
} }
} }
BackHandler(onBack = closeViewerPicker) BackHandler { closeViewerPicker(true) }
Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) { Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) {
ViewerPicker( ViewerPicker(
viewers = viewers, viewers = viewers,
activeViewerId = settings.activeViewerId, activeViewerId = settings.activeViewerId,
onViewerSelected = { viewer -> 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 { scope.launch {
// Everything on the launcher belongs to the outgoing viewer, so // Everything on the launcher belongs to the outgoing viewer, so
// the journey is closed and the rows are refreshed rather than // the journey is closed before the viewer-keyed launcher is rebuilt.
// left standing under a different person's name.
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
repo.switchViewer(viewer) repo.switchViewer(viewer)
homeViewModel.refreshAll()
} }
}, },
// Both open over the picker rather than instead of it, so Back is one // 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) val removed = repo.removeViewer(viewer)
if (removed && watching) { if (removed && watching) {
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
homeViewModel.refreshAll()
} }
refreshViewers() refreshViewers()
viewerBusyId = null viewerBusyId = null
@@ -424,10 +424,22 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
delay(FOCUS_METADATA_DEBOUNCE_MS) delay(FOCUS_METADATA_DEBOUNCE_MS)
if (!item.isSchedule) { if (!item.isSchedule) {
val details = loadDetailMetadata(item.id) ?: return@launch 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) { if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails _focusedItem.value = taggedDetails
} }
if (repository.showTitleLogo) {
warmTitleLogo(repository.logoUrl(taggedDetails))
}
} }
} }
detailPrefetchJob = viewModelScope.launch(Dispatchers.IO) { detailPrefetchJob = viewModelScope.launch(Dispatchers.IO) {
@@ -947,6 +959,29 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility, 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. * 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 android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -20,6 +23,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
/** /**
@@ -111,10 +115,46 @@ internal object TitleLogoCache {
probe(url)?.also { dark -> synchronized(lock) { verdicts[url] = dark } } probe(url)?.also { dark -> synchronized(lock) { verdicts[url] = dark } }
}.also { fresh -> }.also { fresh ->
inFlight[url] = 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. */ /** 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 known = TitleLogoCache.verdict(logoUrl)
val isDark by produceState(initialValue = known ?: true, logoUrl, known) { val isDark by produceState(initialValue = known ?: true, logoUrl, known) {
if (known != null) return@produceState 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 return isDark
} }
@@ -166,12 +208,31 @@ internal fun TitleLogoImage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center, 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( AsyncImage(
model = logoUrl, model = request,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
alignment = alignment, alignment = alignment,
modifier = modifier, modifier = modifier,
onError = {
if (recoveryAttempt + 1 < LOGO_DISPLAY_ATTEMPTS) recoveryAttempt++
},
) )
} }
@@ -199,7 +260,9 @@ internal suspend fun warmTitleLogo(logoUrl: String?) {
.build(), .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 * 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. * 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( val result = context.imageLoader.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(url) .data(url)
.allowHardware(false) .allowHardware(false)
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(if (refresh) CachePolicy.WRITE_ONLY else CachePolicy.DISABLED)
.apply {
if (refresh) diskCachePolicy(CachePolicy.WRITE_ONLY)
}
.size(64, 64) .size(64, 64)
.build(), .build(),
) as? SuccessResult ?: return@runCatching null ) 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 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 package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.viewers.viewerIsActive import com.ponzischeme89.memby.ui.viewers.viewerIsActive
internal enum class UserSwitcherDirection { UP, DOWN } 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 * 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 * follow it in order. Keeping this arithmetic outside Compose makes remote navigation
@@ -142,6 +142,18 @@ fun MediaBadge(
fontSize: TextUnit = 11.sp, fontSize: TextUnit = 11.sp,
lineHeight: TextUnit = TextUnit.Unspecified, 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) { val surroundSoundDescription = when (label) {
"5.1" -> "5.1 surround sound" "5.1" -> "5.1 surround sound"
"7.1" -> "7.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 == 8 -> add("7.1")
audio?.channels == 6 -> add("5.1") audio?.channels == 6 -> add("5.1")
} }
if (item.mediaStreams.any { it.type.equals("Subtitle", ignoreCase = true) }) add("CC")
}.distinct() }.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) 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 @Test
fun `detail metadata keeps the continue watching playhead and resume label`() { fun `detail metadata keeps the continue watching playhead and resume label`() {
val card = BaseItem( val card = BaseItem(
@@ -43,6 +43,54 @@ class MediaBadgesTest {
assertEquals(listOf("7.1"), badgesFor(8)) 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. */ /** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
@Test @Test
fun `names HDR10+ rather than collapsing it to HDR`() { fun `names HDR10+ rather than collapsing it to HDR`() {
@@ -88,6 +88,42 @@ class TitleLogoCacheTest {
assertEquals(false, TitleLogoCache.verdict(url)) 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 @Test
fun `the cache is bounded and keeps what is still being looked at`() = runBlocking { fun `the cache is bounded and keeps what is still being looked at`() = runBlocking {
val kept = "https://gw/v1/images/kept/logo?tag=a" val kept = "https://gw/v1/images/kept/logo?tag=a"
@@ -1,11 +1,41 @@
package com.ponzischeme89.memby.ui package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.ui.profiles.profileFocusIndexAfterRemoval import com.ponzischeme89.memby.ui.profiles.profileFocusIndexAfterRemoval
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
class UserSwitcherNavigationTest { 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 @Test
fun `removed profile hands focus to its stable neighbour`() { fun `removed profile hands focus to its stable neighbour`() {
assertEquals(1, profileFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3)) assertEquals(1, profileFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3))
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB