0.3.09
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 393 KiB |
Reference in New Issue
Block a user