This commit is contained in:
ponzischeme89
2026-08-23 09:40:45 +12:00
parent 9a1f44da46
commit 766cea2199
19 changed files with 828 additions and 164 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.05"
val defaultVersionName = "0.3.06"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -32,12 +32,12 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CornerRadius
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Offset
import androidx.compose.ui.graphics.Size
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
@@ -342,7 +342,18 @@ internal fun FocusedDetailsOverlay(
airingNotice: AiringNotice? = null,
) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
val item = focusedItem?.takeIf { it.id == selected.id } ?: selected
// Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
// still consulted for live user state, but it cannot replace the full record with the
// lightweight Search card when focus returns behind the overlay.
var detailMetadata by remember(selected.id) {
mutableStateOf(homeViewModel.detailMetadataSnapshot(selected.id))
}
LaunchedEffect(selected.id, selected.isRadarrOnly, selected.isSchedule) {
if (!selected.isRadarrOnly && !selected.isSchedule) {
homeViewModel.loadDetailMetadata(selected.id)?.let { detailMetadata = it }
}
}
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
if (item.isRadarrOnly) {
// A film Radarr is tracking that Emby has never imported. It is the one card with a
// page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why
@@ -402,6 +413,20 @@ internal fun FocusedDetailsOverlay(
}
}
/**
* Combines the three owners involved in a detail page without confusing their lifetimes:
* the selected route fixes identity, current focus contributes live user state only when
* it is still the same item, and the full detail response supplies descriptive metadata.
*/
internal fun detailItemForRoute(
selected: BaseItem,
focused: BaseItem?,
detailMetadata: BaseItem?,
): BaseItem = focusedItemWithMetadata(
item = focused?.takeIf { it.id == selected.id } ?: selected,
metadata = detailMetadata,
)
@Composable
internal fun FocusedQuickActionsOverlay(
homeViewModel: HomeViewModel,
@@ -17,8 +17,11 @@ import com.ponzischeme89.memby.data.millisecondsToTicks
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -173,6 +176,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
}
private val metadataInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
/** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics()
@@ -417,12 +421,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
detailPrefetchJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
if (cached == null && !item.isSchedule) {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
if (!item.isSchedule) {
val details = loadDetailMetadata(item.id) ?: return@launch
val taggedDetails = focusedItemWithMetadata(item, details)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails
}
@@ -631,6 +632,57 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* The already-loaded detail record for [itemId], without any row or route state folded
* into it. A detail overlay uses this for its opening frame, then [loadDetailMetadata]
* joins an existing request or starts the missing one.
*/
internal fun detailMetadataSnapshot(itemId: String): BaseItem? =
synchronized(metadataCache) { metadataCache[itemId] }
/**
* Loads one full item record, shared by focus prefetch and the detail overlay.
*
* The cache owns only the repository's detail response. Search cards, home rows and
* recommendation routes are presentation inputs and are merged at the reader; caching
* one of those merged objects lets a lightweight Search result overwrite the state a
* reopened detail page relies on. The request lives in [viewModelScope], so losing
* focus or closing the first page does not cancel work a reopened page is awaiting.
*/
internal suspend fun loadDetailMetadata(itemId: String): BaseItem? {
if (itemId.isBlank()) return null
var cached: BaseItem? = null
val request = synchronized(metadataCache) {
cached = metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }
if (cached != null) {
null
} else {
metadataInFlight[itemId] ?: newDetailMetadataRequest(itemId)
}
}
return cached ?: request?.await()
}
private fun newDetailMetadataRequest(itemId: String): Deferred<BaseItem?> {
val request = viewModelScope.async(
context = Dispatchers.IO,
start = CoroutineStart.LAZY,
) {
try {
runCatching { repository.getItemDetails(itemId) }
.getOrNull()
?.also { details ->
synchronized(metadataCache) { metadataCache[itemId] = details }
}
} finally {
synchronized(metadataCache) { metadataInFlight.remove(itemId) }
}
}
metadataInFlight[itemId] = request
request.start()
return request
}
fun removeFromContinueWatching(item: BaseItem) {
val previous = _state.value
_state.update { state ->
@@ -830,6 +882,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
(metadata ?: item).copy(
// A cache entry from an older/incomplete route must not erase identity or prose
// the current route already knows. Null Overview means the field was not present;
// an explicitly empty Overview is still a complete answer from Emby.
id = item.id,
name = metadata?.name?.takeIf(String::isNotBlank) ?: item.name,
type = metadata?.type?.takeIf(String::isNotBlank) ?: item.type,
overview = metadata?.overview ?: item.overview,
// Detail requests enrich the facts panel, but their Fields list does not ask Emby
// for artwork. Keep the row card's image identity when that sparse response arrives
// after focus settles. This matters again after the 32-entry metadata cache turns
@@ -854,11 +913,31 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI
?: item.parentLogoImageTag,
userData = item.userData ?: metadata?.userData,
membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
// A detail response is Emby's item with gateway decorations only where the
// gateway can resolve them again. Keep lifecycle metadata already carried by a
// schedule or personalised row so the detail page does not lose a better answer.
membyLifecycle = metadata?.membyLifecycle ?: item.membyLifecycle,
membyLifecycleText = metadata?.membyLifecycleText ?: item.membyLifecycleText,
membyRecommendationReason = item.membyRecommendationReason
?: metadata?.membyRecommendationReason,
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
)
/**
* Whether a cached object can stand in for the detail endpoint's response.
*
* Search and row payloads deliberately omit Overview, so null is the useful provenance
* signal: it means a lightweight or stale object must not suppress the full-item request.
* An empty string is different — Emby answered the requested field and the title genuinely
* has no description.
*/
internal fun detailMetadataComplete(itemId: String, metadata: BaseItem?): Boolean =
metadata != null &&
metadata.id == itemId &&
metadata.name.isNotBlank() &&
metadata.type.isNotBlank() &&
metadata.overview != null
/**
* Applies a live launcher response without making cached recommendation shelves disappear
* while the gateway rebuilds its recommendation cache in the background.
@@ -383,7 +383,15 @@ internal fun SeriesDetailContent(
// Held rather than rebuilt per composition, as on the movie page: the scaffold takes
// lists, and a new identity for them on every focus move recomposes the fact row.
val heroFacts = remember(item.id, seasons.size, item.productionYear, item.officialRating) {
val heroFacts = remember(
item.id,
seasons.size,
item.productionYear,
item.officialRating,
item.status,
item.membyLifecycle,
item.membyLifecycleText,
) {
heroFacts(item, seasons.size)
}
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
@@ -64,9 +64,9 @@ fun remainingLabel(item: BaseItem): String? {
}
/**
* The quiet line directly under the title: year, length, certificate. Deliberately short
* the score sits beside the title and the genres are a credit row, so this stays at three
* items and never has to compete for the width.
* The quiet line directly under the title: year, length, certificate and, for a series,
* its production status. The score sits beside the title and the genres are a credit row,
* so non-series titles stay at three items and never have to compete for the width.
*
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
*/
@@ -78,6 +78,28 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
item.runtimeMinutes?.let { add(formatRuntime(it)) }
}
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
seriesStatusLabel(item)?.let(::add)
}
/**
* The reliable subset of series lifecycle values carried by Emby or the gateway.
*
* Unknown values are omitted instead of being reformatted into a claim the client does not
* understand. The American spelling is accepted only as an upstream wire value; viewers
* always see New Zealand English.
*/
fun seriesStatusLabel(item: BaseItem): String? {
if (!item.isSeries) return null
val status = item.membyLifecycleText?.takeIf(String::isNotBlank)
?: item.membyLifecycle?.takeIf(String::isNotBlank)
?: item.status?.takeIf(String::isNotBlank)
?: return null
return when (status.trim().lowercase(Locale.US)) {
"continuing" -> "Continuing"
"ended" -> "Ended"
"cancelled", "canceled" -> "Cancelled"
else -> null
}
}
/**
@@ -71,6 +71,28 @@ class ContinueWatchingResumeTest {
assertEquals("The richer record fetched after focus.", focused.overview)
}
@Test
fun `detail metadata keeps server lifecycle carried by the row card`() {
val card = BaseItem(
id = "series",
name = "The Series",
type = "Series",
membyLifecycle = "ended",
membyLifecycleText = "ENDED",
)
val sparseDetails = BaseItem(
id = "series",
name = "The Series",
type = "Series",
status = null,
)
val focused = focusedItemWithMetadata(card, sparseDetails)
assertEquals("ended", focused.membyLifecycle)
assertEquals("ENDED", focused.membyLifecycleText)
}
@Test
fun `pressed card resume point outranks a prefetched zero`() {
assertEquals(
@@ -0,0 +1,90 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DetailItemStateTest {
@Test
fun `lightweight cached item does not count as loaded detail metadata`() {
val searchResult = BaseItem(id = "series-1", name = "The Show", type = "Series")
val noDescription = searchResult.copy(overview = "")
assertFalse(detailMetadataComplete(searchResult.id, searchResult))
assertFalse(detailMetadataComplete("series-2", noDescription))
assertTrue(detailMetadataComplete(noDescription.id, noDescription))
}
@Test
fun `sparse Search focus cannot erase loaded detail metadata`() {
val searchResult = BaseItem(id = "series-1", name = "The Show", type = "Series")
val details = searchResult.copy(
overview = "The full description.",
genres = listOf("Drama"),
)
val reopened = detailItemForRoute(
selected = searchResult,
focused = searchResult,
detailMetadata = details,
)
assertEquals("series-1", reopened.id)
assertEquals("The full description.", reopened.overview)
assertEquals(listOf("Drama"), reopened.genres)
}
@Test
fun `focus from another Search result cannot change the open detail identity`() {
val selected = BaseItem(id = "series-1", name = "First Show", type = "Series")
val details = selected.copy(overview = "First show description.")
val otherFocus = BaseItem(
id = "series-2",
name = "Second Show",
type = "Series",
overview = "Second show description.",
)
val openItem = detailItemForRoute(selected, otherFocus, details)
assertEquals("series-1", openItem.id)
assertEquals("First Show", openItem.name)
assertEquals("First show description.", openItem.overview)
}
@Test
fun `current route user state is merged with cached details`() {
val selected = BaseItem(id = "movie-1", name = "The Film", type = "Movie")
val focused = selected.copy(
userData = UserItemData(isFavorite = true, playbackPositionTicks = 42_000_000L),
)
val details = selected.copy(overview = "The full description.")
val openItem = detailItemForRoute(selected, focused, details)
assertEquals("The full description.", openItem.overview)
assertEquals(true, openItem.userData?.isFavorite)
assertEquals(42_000_000L, openItem.userData?.playbackPositionTicks)
}
@Test
fun `incomplete cached metadata cannot erase route description`() {
val selected = BaseItem(
id = "series-1",
name = "The Show",
type = "Series",
overview = "Description already known by this route.",
)
val incomplete = BaseItem(id = "series-1")
val openItem = detailItemForRoute(selected, selected, incomplete)
assertEquals("series-1", openItem.id)
assertEquals("The Show", openItem.name)
assertEquals("Series", openItem.type)
assertEquals("Description already known by this route.", openItem.overview)
}
}
@@ -5,6 +5,7 @@ import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.availableSeasons
import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import com.ponzischeme89.memby.ui.detail.seasonLabel
@@ -91,4 +92,56 @@ class SeriesDetailsTest {
assertEquals("Specials", seasonLabel(0))
assertEquals("Season 3", seasonLabel(3))
}
@Test
fun `series status is appended to the shared hero facts`() {
val series = BaseItem(
id = "series",
name = "Series",
type = "Series",
productionYear = 2022,
officialRating = "TV-MA",
status = "Continuing",
)
assertEquals(
listOf("2022", "4 Seasons", "TV-MA", "Continuing"),
heroFacts(series, seasonCount = 4),
)
}
@Test
fun `series status is canonicalised from existing server metadata`() {
val series = BaseItem(
id = "series",
name = "Series",
type = "Series",
status = "Ended",
membyLifecycleText = "CANCELED",
)
assertEquals("Cancelled", heroFacts(series).last())
assertEquals("Ended", heroFacts(series.copy(membyLifecycleText = null)).last())
}
@Test
fun `unknown or unsupported status is omitted cleanly`() {
val unknownSeries = BaseItem(
id = "series",
name = "Series",
type = "Series",
productionYear = 2022,
status = "Upcoming",
)
val movie = BaseItem(
id = "movie",
name = "Movie",
type = "Movie",
productionYear = 2022,
status = "Continuing",
)
assertEquals(listOf("2022"), heroFacts(unknownSeries))
assertEquals(listOf("2022"), heroFacts(movie))
}
}