0.3.09
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user