This commit is contained in:
ponzischeme89
2026-08-23 17:45:46 +12:00
parent d4e1cb2fbf
commit c809127996
11 changed files with 279 additions and 189 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.09" val defaultVersionName = "0.3.10"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -344,6 +344,10 @@ class EmbyRepository internal constructor(
private val seriesEpisodesCache = private val seriesEpisodesCache =
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true) LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
private val seriesEpisodesInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>() private val seriesEpisodesInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
private val itemDetailsMutex = Mutex()
private val itemDetailsCache =
LinkedHashMap<String, BaseItem>(DETAIL_CACHE_SIZE, 0.75f, true)
private val itemDetailsInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
private val relatedMutex = Mutex() private val relatedMutex = Mutex()
private val relatedCache = private val relatedCache =
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true) LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
@@ -1281,6 +1285,27 @@ class EmbyRepository internal constructor(
/** Full item metadata, requested only after focus settles on an item. */ /** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem { suspend fun getItemDetails(itemId: String): BaseItem {
require(itemId.isNotBlank()) { "Item id is required" }
val key = detailCacheKey(itemId)
val request = itemDetailsMutex.withLock {
itemDetailsCache[key]?.let { return it }
itemDetailsInFlight[key] ?: newItemDetailsRequest(key, itemId)
}
return request.await() ?: error("Could not load item $itemId")
}
/** Focus enrichment bypasses the shared flight so cancelling D-pad work cancels I/O too. */
suspend fun getItemDetailsUncached(itemId: String): BaseItem =
loadItemDetails(itemId).also { details ->
itemDetailsMutex.withLock {
itemDetailsCache[detailCacheKey(itemId)] = details
while (itemDetailsCache.size > DETAIL_CACHE_SIZE) {
itemDetailsCache.entries.iterator().run { next(); remove() }
}
}
}
private suspend fun loadItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId) if (ServerConfig.isGateway) return requireGateway().item(itemId)
val userId = snapshot.userId ?: error("Not connected") val userId = snapshot.userId ?: error("Not connected")
return requireApi().getItem( return requireApi().getItem(
@@ -1290,6 +1315,32 @@ class EmbyRepository internal constructor(
) )
} }
private fun detailCacheKey(itemId: String): String =
"${snapshot.userId.orEmpty()}:${snapshot.activeViewerId}:$itemId"
private fun newItemDetailsRequest(key: String, itemId: String): Deferred<BaseItem?> {
lateinit var request: Deferred<BaseItem?>
request = scope.async(start = CoroutineStart.LAZY) {
try {
loadItemDetails(itemId).also { details ->
itemDetailsMutex.withLock {
itemDetailsCache[key] = details
while (itemDetailsCache.size > DETAIL_CACHE_SIZE) {
itemDetailsCache.entries.iterator().run { next(); remove() }
}
}
}
} finally {
itemDetailsMutex.withLock {
if (itemDetailsInFlight[key] === request) itemDetailsInFlight.remove(key)
}
}
}
itemDetailsInFlight[key] = request
request.start()
return request
}
// Bounded, access-ordered and synchronised rather than a plain concurrent map. These // Bounded, access-ordered and synchronised rather than a plain concurrent map. These
// are process-lifetime caches on a device that is commonly left on for weeks, and an // are process-lifetime caches on a device that is commonly left on for weeks, and an
// unbounded one holds an entry for every person, filmography and title the household // unbounded one holds an entry for every person, filmography and title the household
@@ -2485,6 +2536,11 @@ class EmbyRepository internal constructor(
} }
private suspend fun clearSeriesEpisodeCache() { private suspend fun clearSeriesEpisodeCache() {
itemDetailsMutex.withLock {
itemDetailsCache.clear()
itemDetailsInFlight.values.forEach { it.cancel() }
itemDetailsInFlight.clear()
}
seriesEpisodesMutex.withLock { seriesEpisodesMutex.withLock {
// Episodes carry this viewer's watched and resume state, so the same reasoning // Episodes carry this viewer's watched and resume state, so the same reasoning
// as below applies: another profile must not inherit them, and a request // as below applies: another profile must not inherit them, and a request
@@ -3175,7 +3231,7 @@ class EmbyRepository internal constructor(
// --- URL helpers --------------------------------------------------------- // --- URL helpers ---------------------------------------------------------
/** Backdrop image URL for an item, or null if it has none. */ /** Backdrop image URL for an item, or null if it has none. */
fun backdropUrl(item: BaseItem, maxWidth: Int = 1920): String? { fun backdropUrl(item: BaseItem, maxWidth: Int = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH): String? {
val (id, tag) = when { val (id, tag) = when {
item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first() item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first()
item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() -> item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() ->
@@ -3201,7 +3257,7 @@ class EmbyRepository internal constructor(
} }
/** Primary (poster) image URL, used as a card fallback. */ /** Primary (poster) image URL, used as a card fallback. */
fun primaryUrl(item: BaseItem, maxWidth: Int = 500): String? { fun primaryUrl(item: BaseItem, maxWidth: Int = ARTWORK_CARD_MAX_WIDTH): String? {
val tag = item.imageTags["Primary"] ?: return null val tag = item.imageTags["Primary"] ?: return null
return imageUrl(item.id, "Primary", tag, maxWidth) return imageUrl(item.id, "Primary", tag, maxWidth)
} }
@@ -3351,6 +3407,13 @@ class EmbyRepository internal constructor(
} }
} }
/** Canonical decoded-artwork tiers shared by launcher, detail and playback surfaces. */
const val ARTWORK_CARD_MAX_WIDTH = 500
const val ARTWORK_HERO_BACKDROP_MAX_WIDTH = 1280
const val ARTWORK_HERO_PRIMARY_MAX_WIDTH = 960
const val ARTWORK_DETAIL_BACKDROP_MAX_WIDTH = 1920
const val ARTWORK_DETAIL_PRIMARY_MAX_WIDTH = 1280
/** /**
* A bounded, access-ordered, thread-safe cache. * A bounded, access-ordered, thread-safe cache.
* *
@@ -3567,6 +3630,7 @@ private const val TRICKPLAY_INDEX_WINDOW = 64L * 1024L
* the ones they passed on the way to it — which is the entire case the warm exists for. * the ones they passed on the way to it — which is the entire case the warm exists for.
*/ */
private const val SERIES_EPISODE_CACHE_SIZE = 10 private const val SERIES_EPISODE_CACHE_SIZE = 10
private const val DETAIL_CACHE_SIZE = 64
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
private const val RELATED_CACHE_SIZE = 12 private const val RELATED_CACHE_SIZE = 12
private const val RELATED_LIMIT = 12 private const val RELATED_LIMIT = 12
@@ -312,12 +312,18 @@ internal fun AppRoot(
// poster snapshot never reloads while update/session/onboarding checks settle. // poster snapshot never reloads while update/session/onboarding checks settle.
// There is deliberately no decorative hold: the instant the next screen is ready, // There is deliberately no decorative hold: the instant the next screen is ready,
// it replaces this one. // it replaces this one.
// A persisted Home snapshot is already usable content for an existing signed-in
// viewer. Do not hide it behind the update check: a confirmed mandatory update still
// replaces this surface below, while an optional or failed check can continue in the
// background without delaying navigation.
val cachedSignedInHome = loaded?.isSignedIn == true &&
!loaded.homeCacheJson.isNullOrBlank()
val opening = when { val opening = when {
// A required update is a service gate, not launcher content. Once the gateway // A required update is a service gate, not launcher content. Once the gateway
// answers, uncover it immediately instead of making the viewer finish the // answers, uncover it immediately instead of making the viewer finish the
// opening artwork first. Optional prompts can replace it as soon as ready. // opening artwork first. Optional prompts can replace it as soon as ready.
update?.isMandatory == true -> false update?.isMandatory == true -> false
!initialUpdateCheckComplete -> true !initialUpdateCheckComplete && !cachedSignedInHome -> true
update != null -> false update != null -> false
// Retired, verdict not here yet. The retired-build screen takes the frame // Retired, verdict not here yet. The retired-build screen takes the frame
// rather than the opening screen: it says what has happened and offers the one // rather than the opening screen: it says what has happened and offers the one
@@ -83,6 +83,8 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaRating import com.ponzischeme89.memby.data.model.MediaRating
@@ -249,7 +251,8 @@ internal fun DetailBackdrop(
val context = LocalContext.current val context = LocalContext.current
val repository = ServiceLocator.repository val repository = ServiceLocator.repository
val artwork = remember(item.id, item.backdropImageTags, item.imageTags) { val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280) repository.backdropUrl(item, ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repository.primaryUrl(item, ARTWORK_DETAIL_PRIMARY_MAX_WIDTH)
} }
// The one place in the app where a crossfade earns its keep. Artwork is loaded without // The one place in the app where a crossfade earns its keep. Artwork is loaded without
// one everywhere else because a row of posters snapping in is faster and reads fine at // one everywhere else because a row of posters snapping in is faster and reads fine at
@@ -75,6 +75,9 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.analytics.PlaybackJourney import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.ARTWORK_CARD_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow import com.ponzischeme89.memby.data.model.MyShow
@@ -648,9 +651,9 @@ internal fun HomeScreen(
PlayerActivity.intent( PlayerActivity.intent(
context = context, context = context,
request = request, request = request,
posterUrl = repo.primaryUrl(item, maxWidth = 500), posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
backdropUrl = repo.backdropUrl(item, maxWidth = 1920) backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(item, maxWidth = 1920), ?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
requestStartedAtMs = playbackRequestedAtMs, requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id, journeySource = entryPoint.id,
), ),
@@ -697,9 +700,9 @@ internal fun HomeScreen(
PlayerActivity.intent( PlayerActivity.intent(
context = context, context = context,
playable = playable, playable = playable,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920) backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(item, maxWidth = 1920), ?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
posterUrl = repo.primaryUrl(item, maxWidth = 500), posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
requestStartedAtMs = playbackRequestedAtMs, requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id, journeySource = entryPoint.id,
), ),
@@ -203,7 +203,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
repository.playbackPositions.collect(::applyPlaybackPosition) repository.playbackPositions.collect(::applyPlaybackPosition)
} }
viewModelScope.launch { viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() } repository.playbackStops.collect { refreshWatching(authoritative = false) }
} }
viewModelScope.launch { viewModelScope.launch {
// A D-pad produces focus changes far faster than anything should produce // A D-pad produces focus changes far faster than anything should produce
@@ -423,7 +423,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
metadataJob = viewModelScope.launch(Dispatchers.IO) { metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS) delay(FOCUS_METADATA_DEBOUNCE_MS)
if (!item.isSchedule) { if (!item.isSchedule) {
val details = loadDetailMetadata(item.id) ?: return@launch // Focus enrichment belongs to this cancellable job. It must not register its
// request in the shared detail-flight map: otherwise cancelling this job only
// cancels the waiter while the view-model-owned request continues for a card
// the viewer has already crossed.
val details = loadFocusedMetadata(item.id) ?: return@launch
var taggedDetails = focusedItemWithMetadata(item, details) var taggedDetails = focusedItemWithMetadata(item, details)
// A restored/navigation episode can be thinner than the Continue Watching // 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 // row it came from. If neither that route object nor its item response names
@@ -431,7 +435,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// shared detail record instead of treating absent fields as "no logo". // shared detail record instead of treating absent fields as "no logo".
if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) { if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) {
taggedDetails = taggedDetails.withSeriesTitleLogo( taggedDetails = taggedDetails.withSeriesTitleLogo(
taggedDetails.seriesId?.let { loadDetailMetadata(it) }, taggedDetails.seriesId?.let { loadFocusedMetadata(it) },
) )
} }
if (_focusedItem.value?.id == item.id) { if (_focusedItem.value?.id == item.id) {
@@ -449,23 +453,15 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
/** /**
* The two requests a detail page still opened cold, warmed while the card is focused. * Lightweight optional detail work, warmed only after a card has remained focused.
* *
* The item record is warmed separately for the launcher's metadata panel. The larger * The item record is warmed separately for the launcher's metadata panel. Related content,
* detail-only requests wait here, so a series page does not open with an empty * trailer availability and the logo are cheap enough to prepare for a deliberate pause;
* Episodes pane, no progress, no next episode and no estimated finish, and every page * the complete episode browser is deliberately left to the detail page.
* opened with its trailer button missing until the network answered. Continue Watching
* is the case that matters most: every card on the launcher's busiest row is an
* episode, and all of them want the same show's list.
* *
* **It waits longer than the metadata warm does**, and that is the whole cost control. * The delay is deliberately longer than the metadata warm. A long-running show's episode
* An episode list is the largest request the client makes a long-running show is a * list can contain a thousand records, so it must never be fetched merely because focus
* thousand records so warming one per card as somebody scans across a shelf would * paused on a card.
* spend more bandwidth than it saves. This job is cancelled the moment the D-pad moves,
* so a viewer travelling along a row never reaches it; one who has stopped on a card,
* which is what precedes a press, does. Both requests are single-flighted and cached on
* the repository, so the press that follows finds the answer rather than a second copy
* of the request.
*/ */
private suspend fun warmDetailPage(item: BaseItem) { private suspend fun warmDetailPage(item: BaseItem) {
// A movie-schedule card whose film is not in the library opens a page of its own, // A movie-schedule card whose film is not in the library opens a page of its own,
@@ -483,22 +479,15 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// nothing waits on it and it cannot fail in a way anybody should hear about. // nothing waits on it and it cannot fail in a way anybody should hear about.
if (item.membyPlayable) repository.warmStreamConnection() if (item.membyPlayable) repository.warmStreamConnection()
coroutineScope { coroutineScope {
// Related, episodes and trailers are detail-page work. Waiting until focus has // Related content and trailer availability are detail-page work. Waiting until focus has
// genuinely settled prevents a held D-pad from starting long-lived requests // genuinely settled prevents a held D-pad from starting long-lived requests
// for every card it crosses; a press still shares the resulting single flight. // for every card it crosses; a press still shares the resulting single flight.
if (item.isMovie || item.isSeries) { if (item.isMovie || item.isSeries) {
launch { runCatching { repository.getRelated(item) } } launch { runCatching { repository.getRelated(item) } }
} }
// A series is keyed on itself, an episode on the show it belongs to — which is // Do not warm the complete episode browser from focus. A long-running series can
// exactly what its own detail page will ask for. // contain hundreds of records; the detail page requests that list when the viewer
val seriesId = when { // actually opens it, where the result is still shared and cached.
item.isSeries -> item.id
item.isEpisode -> item.seriesId
else -> null
}
if (!seriesId.isNullOrBlank()) {
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
}
launch { runCatching { repository.hasTrailer(item.id) } } launch { runCatching { repository.hasTrailer(item.id) } }
// The logo is the one part of a detail page's hero that arrived after the page // The logo is the one part of a detail page's hero that arrived after the page
// did: it is a second image, and it cannot be drawn until it has been judged // did: it is a second image, and it cannot be drawn until it has been judged
@@ -680,6 +669,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
return cached ?: request?.await() return cached ?: request?.await()
} }
/**
* Loads metadata for the currently focused card without creating a shared background
* request. D-pad focus is speculative: cancellation must reach the network call when the
* viewer moves on. A committed detail page uses [loadDetailMetadata] instead, which keeps
* its shared request alive for navigation and re-entry.
*/
private suspend fun loadFocusedMetadata(itemId: String): BaseItem? {
if (itemId.isBlank()) return null
synchronized(metadataCache) {
metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }?.let { return it }
}
return try {
repository.getItemDetailsUncached(itemId).also { details ->
synchronized(metadataCache) { metadataCache[itemId] = details }
}
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
} catch (_: Throwable) {
null
}
}
/** Must be called while synchronised on [metadataCache]. */ /** Must be called while synchronised on [metadataCache]. */
private fun acceptSeriesStatusRevision(revision: Long) { private fun acceptSeriesStatusRevision(revision: Long) {
if (revision <= 0L || revision == seriesStatusRevision) return if (revision <= 0L || revision == seriesStatusRevision) return
@@ -811,9 +822,17 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
viewModelScope.launch { persistCurrentHome() } viewModelScope.launch { persistCurrentHome() }
} }
private suspend fun refreshWatching() { private suspend fun refreshWatching(authoritative: Boolean = true) {
if (!_continueWatchingEnabled.value) return if (!_continueWatchingEnabled.value) return
refreshMutex.withLock { refreshMutex.withLock {
if (!authoritative) {
// PlaybackPosition has already applied the player's final position (or
// removed a completed item) to the local Continue Watching row. A full gateway
// Home response here reloads unrelated favourites, latest and recommendation
// rows immediately after every playback session.
persistCurrentHome()
return
}
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) } _state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
if (repository.supportsBatchHome) { if (repository.supportsBatchHome) {
// Playback just invalidated this user's rows on the gateway anyway. // Playback just invalidated this user's rows on the gateway anyway.
@@ -888,14 +907,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L private const val FOCUS_METADATA_DEBOUNCE_MS = 200L
/** /**
* How long focus must rest on a card before its detail page is warmed, measured * How long focus must rest on a card before lightweight detail work is warmed, measured
* from the press that focused it. Deliberately well past * from the press that focused it. Deliberately well past
* [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides * [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides
* what the panel beside the row says, so it should follow the D-pad closely, while * what the panel beside the row says, so it should follow the D-pad closely, while
* this one can be a thousand episode records and should only follow a viewer who * this one can be a thousand episode records and should only follow a viewer who
* has stopped. See [warmDetailPage]. * has stopped. See [warmDetailPage].
*/ */
private const val DETAIL_PREFETCH_DELAY_MS = 500L private const val DETAIL_PREFETCH_DELAY_MS = 1_200L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val HOME_RETRY_INITIAL_MS = 2_000L private const val HOME_RETRY_INITIAL_MS = 2_000L
private const val HOME_RETRY_MAX_MS = 60_000L private const val HOME_RETRY_MAX_MS = 60_000L
@@ -20,6 +20,8 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.ARTWORK_HERO_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_HERO_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
@@ -104,7 +106,10 @@ private fun MetadataHeroArtwork(
item?.parentBackdropImageTags, item?.parentBackdropImageTags,
item?.imageTags, item?.imageTags,
) { ) {
item?.let { repository.backdropUrl(it, maxWidth = 1280) ?: repository.primaryUrl(it, maxWidth = 960) } item?.let {
repository.backdropUrl(it, maxWidth = ARTWORK_HERO_BACKDROP_MAX_WIDTH)
?: repository.primaryUrl(it, maxWidth = ARTWORK_HERO_PRIMARY_MAX_WIDTH)
}
} }
val request = remember(artwork, context) { val request = remember(artwork, context) {
artwork?.let { artwork?.let {
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val MAX_CACHED_CATEGORIES = 15
data class GenreBrowseUiState( data class GenreBrowseUiState(
val categories: List<GenreCategory> = emptyList(), val categories: List<GenreCategory> = emptyList(),
val selectedCategoryId: String? = null, val selectedCategoryId: String? = null,
@@ -58,7 +60,15 @@ class GenreBrowseViewModel(
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories)) private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow() val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null private var pageJob: Job? = null
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
/** LRU cache bounding the memory footprint for large library category pages. */
private val categoryPages = object : LinkedHashMap<String, CachedCategoryPage>(
16, 0.75f, true
) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CachedCategoryPage>?): Boolean {
return size > MAX_CACHED_CATEGORIES
}
}
/** /**
* First pages being warmed for the categories either side of the selected one. * First pages being warmed for the categories either side of the selected one.
@@ -119,16 +129,22 @@ class GenreBrowseViewModel(
fun loadMore() { fun loadMore() {
val current = state.value val current = state.value
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return val categoryId = current.selectedCategoryId ?: return
val category = categories.firstOrNull { it.id == categoryId } ?: return
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
// Direct cache query prevents offset races caused by rapid concurrent state updates.
val nextOffset = categoryPages[categoryId]?.readOffset ?: current.readOffset
_state.update { it.copy(isLoadingMore = true) } _state.update { it.copy(isLoadingMore = true) }
pageJob = viewModelScope.launch { loadPage(category, current.readOffset) } pageJob = viewModelScope.launch { loadPage(category, nextOffset) }
} }
fun retry() { fun retry() {
val current = state.value val current = state.value
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return val categoryId = current.selectedCategoryId ?: return
val offset = current.readOffset val category = categories.firstOrNull { it.id == categoryId } ?: return
val offset = categoryPages[categoryId]?.readOffset ?: current.readOffset
_state.update { _state.update {
it.copy( it.copy(
isLoading = offset == 0, isLoading = offset == 0,
@@ -45,6 +45,8 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.ARTWORK_HERO_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_HERO_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.ui.detail.formatRuntime import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -80,7 +82,10 @@ private val MetadataHeroFactsLineHeight = 18.sp
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) { fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val imageUrl = item?.let { repo.backdropUrl(it, 1280) ?: repo.primaryUrl(it, 960) } val imageUrl = item?.let {
repo.backdropUrl(it, ARTWORK_HERO_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(it, ARTWORK_HERO_PRIMARY_MAX_WIDTH)
}
var displayedUrl by remember { mutableStateOf(imageUrl) } var displayedUrl by remember { mutableStateOf(imageUrl) }
LaunchedEffect(imageUrl) { LaunchedEffect(imageUrl) {
if (displayedUrl == null) { if (displayedUrl == null) {
@@ -1,7 +1,5 @@
package com.ponzischeme89.memby.ui package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -35,10 +33,10 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -47,13 +45,14 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES } enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
data class HomeBrowseRow( data class HomeBrowseRow(
@@ -67,63 +66,21 @@ data class HomeBrowseRow(
val showWatchedEpisodeCount: Boolean = false, val showWatchedEpisodeCount: Boolean = false,
) )
private data class HomeRowVisual( private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when {
val icon: ImageVector, row.id == "continue" || row.id == "continue-shows" -> MembyIcon.PlayCircle.mark
) row.kind == MediaRowKind.FAVORITES -> MembyIcon.Favourite.mark
row.id == "latest-movies" -> MembyIcon.Movie.mark
private val QuietText: Color get() = MembyQuietText row.id == "sonarr-airing-today" -> MembyIcon.Calendar.mark
row.id == "curated:apple-tv" -> MembyIcon.LiveTv.mark
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when { row.id == "curated:drama-shows" -> MembyIcon.Drama.mark
row.id == "continue" -> HomeRowVisual( row.id == "curated:comedy-shows" -> MembyIcon.Happy.mark
MembyIcon.PlayCircle.mark, row.id.startsWith("similar:") || row.id.startsWith("for-you:") -> MembyIcon.Sparkle.mark
) row.id == "recommended" -> MembyIcon.Recommend.mark
row.id == "continue-shows" -> HomeRowVisual( else -> MembyIcon.VideoLibrary.mark
MembyIcon.PlayCircle.mark,
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
MembyIcon.Favourite.mark,
)
row.id == "latest-movies" -> HomeRowVisual(
MembyIcon.Movie.mark,
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
MembyIcon.Calendar.mark,
)
row.id == "curated:apple-tv" -> HomeRowVisual(
MembyIcon.LiveTv.mark,
)
row.id == "curated:drama-shows" -> HomeRowVisual(
MembyIcon.Drama.mark,
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
MembyIcon.Happy.mark,
)
row.id.startsWith("similar:") -> HomeRowVisual(
MembyIcon.Sparkle.mark,
)
row.id == "recommended" -> HomeRowVisual(
MembyIcon.Recommend.mark,
)
row.id.startsWith("for-you:") -> HomeRowVisual(
MembyIcon.Sparkle.mark,
)
else -> HomeRowVisual(
MembyIcon.VideoLibrary.mark,
)
} }
/**
* The 28dp icon chip every home row header leads with, and the gap after it.
*
* Extracted because a header that skipped the chip started its title 38dp further left than
* the rows above and below it. The launcher's row titles are read as one column, and one
* that steps sideways breaks the column.
*/
internal val HomeRowHeaderIconGap = 10.dp internal val HomeRowHeaderIconGap = 10.dp
/** Vertical gap between a row's header and its cards. One value for every row. */
internal val HomeRowHeaderSpacing = 10.dp internal val HomeRowHeaderSpacing = 10.dp
private val HomeRowCardsTopPadding = 12.dp private val HomeRowCardsTopPadding = 12.dp
private val HomeRowCardsBottomPadding = 32.dp private val HomeRowCardsBottomPadding = 32.dp
@@ -170,24 +127,26 @@ internal fun MediaRow(
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val rowState = horizontalState ?: savedRowState val rowState = horizontalState ?: savedRowState
val verticalEntryFocusRequester = remember { FocusRequester() } val verticalEntryFocusRequester = remember { FocusRequester() }
val requestedEntryIndex = verticalFocusRequest
val requestedEntryIndex = remember(verticalFocusRequest, row.id, row.items.size) {
verticalFocusRequest
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() } ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
?.itemIndex ?.itemIndex
?.coerceIn(0, row.items.lastIndex) ?.coerceIn(0, row.items.lastIndex)
val currentOnVerticalFocusRequestConsumed by rememberUpdatedState( }
onVerticalFocusRequestConsumed,
) val currentOnVerticalFocusRequestConsumed by rememberUpdatedState(onVerticalFocusRequestConsumed)
val currentOnContentFocused by rememberUpdatedState(onContentFocused)
val currentOnItemFocused by rememberUpdatedState(onItemFocused)
val currentOnItemSelected by rememberUpdatedState(onItemSelected)
val currentOnItemLongPressed by rememberUpdatedState(onItemLongPressed)
val currentOnMoveVertical by rememberUpdatedState(onMoveVertical)
LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) { LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) {
val entryIndex = requestedEntryIndex ?: return@LaunchedEffect val entryIndex = requestedEntryIndex ?: return@LaunchedEffect
val request = verticalFocusRequest val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect
?.takeIf { it.rowId == row.id }
?: return@LaunchedEffect
rowState.scrollToItem(entryIndex) rowState.scrollToItem(entryIndex)
// LazyRow applies scrollToItem during layout. Wait for the requested card's focus
// node to attach before transferring focus; retrying covers slower TV frames. Six
// attempts rather than three because giving up here is a press that visibly does
// nothing, and the shelf this arrives at is commonly one being composed for the
// first time — the frame budget on a Chromecast is not the one a warm row gets.
repeat(6) { repeat(6) {
delay(16.milliseconds) delay(16.milliseconds)
if (verticalEntryFocusRequester.requestFocusIfAttached()) { if (verticalEntryFocusRequester.requestFocusIfAttached()) {
@@ -197,6 +156,7 @@ internal fun MediaRow(
} }
currentOnVerticalFocusRequestConsumed(request.requestId) currentOnVerticalFocusRequestConsumed(request.requestId)
} }
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) { Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -205,18 +165,22 @@ internal fun MediaRow(
.focusGroup(), .focusGroup(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val visual = homeRowVisual(row) val icon = remember(row) { getHomeRowIcon(row) }
HomeRowHeaderIcon(visual.icon) HomeRowHeaderIcon(icon)
Spacer(Modifier.width(HomeRowHeaderIconGap)) Spacer(Modifier.width(HomeRowHeaderIconGap))
Text( Text(
row.title, text = row.title,
color = MembyOnSurface, color = MembyOnSurface,
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
when { when {
row.items.isEmpty() && row.loading -> { row.items.isEmpty() && row.loading -> {
val isPortrait = remember(row.kind) {
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
}
LazyRow( LazyRow(
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = HomeContentHorizontalInset, start = HomeContentHorizontalInset,
@@ -226,9 +190,9 @@ internal fun MediaRow(
), ),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(5) { items(count = 5) {
TvLoadingPlaceholder( TvLoadingPlaceholder(
portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS), portrait = isPortrait,
availableWidth = availableWidth, availableWidth = availableWidth,
) )
} }
@@ -236,8 +200,8 @@ internal fun MediaRow(
} }
row.items.isEmpty() -> { row.items.isEmpty() -> {
Text( Text(
row.emptyMessage, text = row.emptyMessage,
color = QuietText, color = MembyQuietText,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding( modifier = Modifier.padding(
horizontal = HomeContentHorizontalInset, horizontal = HomeContentHorizontalInset,
@@ -255,77 +219,84 @@ internal fun MediaRow(
bottom = HomeRowCardsBottomPadding, bottom = HomeRowCardsBottomPadding,
), ),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
// focusRestorer pins a lazy item. Replacing a For You result set
// can dispose that item during a focus transfer and make Compose
// release the same pin twice. LazyListState already preserves the
// row position; native TV spatial search safely handles row changes.
modifier = Modifier.fillMaxWidth().focusGroup(), modifier = Modifier.fillMaxWidth().focusGroup(),
) { ) {
itemsIndexed( itemsIndexed(
row.items, items = row.items,
key = { _, item -> item.id }, key = { _, item -> item.id },
contentType = { _, item -> if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" }, contentType = { _, item ->
if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape"
},
) { index, item -> ) { index, item ->
var cardModifier: Modifier = Modifier val targetFocusRequester = when {
if (index == 0) { index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester
if (contentEntryFocusRequester != null) { index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester) item.id == returnFocusItemId -> returnFocusRequester
index == requestedEntryIndex -> verticalEntryFocusRequester
else -> null
} }
// Where Down out of the home hero lands. It is a second
// requester rather than the entry one because while the hero is var cardModifier = Modifier as Modifier
// up, that one belongs to the hero. if (targetFocusRequester != null) {
if (heroEntryFocusRequester != null) { cardModifier = cardModifier.focusRequester(targetFocusRequester)
cardModifier = cardModifier.focusRequester(heroEntryFocusRequester)
}
}
if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester)
}
if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
} }
cardModifier = cardModifier.onPreviewKeyEvent { event -> cardModifier = cardModifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) { if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
return@onPreviewKeyEvent false
}
when (event.key) { when (event.key) {
Key.DirectionUp -> { Key.DirectionUp -> currentOnMoveVertical(index, RowFocusDirection.UP)
onMoveVertical(index, RowFocusDirection.UP) Key.DirectionDown -> currentOnMoveVertical(index, RowFocusDirection.DOWN)
}
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
else -> false else -> false
} }
} }
val focused: () -> Unit = {
onContentFocused() val focused = remember(item, index) {
// LazyRow already knows the semantic position. Passing it on {
// avoids searching the row again on every D-pad focus move. currentOnContentFocused()
onItemFocused(item, index) currentOnItemFocused(item, index)
} }
val format = cardFormat(row.kind, item, artworkStyle) }
val onClick = remember(item) { { currentOnItemSelected(item) } }
val onLongClick = remember(item) { { currentOnItemLongPressed(item) } }
val format = remember(row.kind, item, artworkStyle) {
cardFormat(row.kind, item, artworkStyle)
}
if (row.kind == MediaRowKind.CONTINUE) { if (row.kind == MediaRowKind.CONTINUE) {
ContinueWatchingCard( ContinueWatchingCard(
item = item, item = item,
availableWidth = availableWidth, availableWidth = availableWidth,
portraitArtwork = format == MediaCardFormat.PORTRAIT, portraitArtwork = format == MediaCardFormat.PORTRAIT,
onFocused = focused, onFocused = focused,
onClick = { onItemSelected(item) }, onClick = onClick,
onLongClick = { onItemLongPressed(item) }, onLongClick = onLongClick,
modifier = cardModifier, modifier = cardModifier,
density = density, density = density,
) )
} else when (format) { } else when (format) {
MediaCardFormat.PORTRAIT -> PosterCard( MediaCardFormat.PORTRAIT -> PosterCard(
item, availableWidth, row.showSecondaryMetadata, focused, item = item,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, availableWidth = availableWidth,
density, row.showWatchedEpisodeCount, showSecondaryMetadata = row.showSecondaryMetadata,
onFocused = focused,
onClick = onClick,
onLongClick = onLongClick,
modifier = cardModifier,
density = density,
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES, showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
) )
MediaCardFormat.LANDSCAPE -> { MediaCardFormat.LANDSCAPE -> LandscapeCard(
LandscapeCard( item = item,
item, availableWidth, row.showSecondaryMetadata, focused, availableWidth = availableWidth,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, showSecondaryMetadata = row.showSecondaryMetadata,
density, row.showWatchedEpisodeCount, onFocused = focused,
onClick = onClick,
onLongClick = onLongClick,
modifier = cardModifier,
density = density,
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES, showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
) )
} }
@@ -335,7 +306,6 @@ internal fun MediaRow(
} }
} }
} }
}
private fun FocusRequester.requestFocusIfAttached(): Boolean = private fun FocusRequester.requestFocusIfAttached(): Boolean =
runCatching { requestFocus() }.getOrDefault(false) runCatching { requestFocus() }.getOrDefault(false)
@@ -346,8 +316,7 @@ private fun cardFormat(
kind: MediaRowKind, kind: MediaRowKind,
item: BaseItem, item: BaseItem,
artworkStyle: String = "automatic", artworkStyle: String = "automatic",
): MediaCardFormat = ): MediaCardFormat = when (artworkStyle) {
when (artworkStyle) {
"poster" -> MediaCardFormat.PORTRAIT "poster" -> MediaCardFormat.PORTRAIT
"backdrop" -> MediaCardFormat.LANDSCAPE "backdrop" -> MediaCardFormat.LANDSCAPE
else -> when { else -> when {
+1 -1
View File
@@ -1 +1 @@
0.1.66 0.1.68