0.3.06
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.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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user