0.3.10
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.09"
|
||||
val defaultVersionName = "0.3.10"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -344,6 +344,10 @@ class EmbyRepository internal constructor(
|
||||
private val seriesEpisodesCache =
|
||||
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
||||
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 relatedCache =
|
||||
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. */
|
||||
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)
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
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
|
||||
// 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
|
||||
@@ -2485,6 +2536,11 @@ class EmbyRepository internal constructor(
|
||||
}
|
||||
|
||||
private suspend fun clearSeriesEpisodeCache() {
|
||||
itemDetailsMutex.withLock {
|
||||
itemDetailsCache.clear()
|
||||
itemDetailsInFlight.values.forEach { it.cancel() }
|
||||
itemDetailsInFlight.clear()
|
||||
}
|
||||
seriesEpisodesMutex.withLock {
|
||||
// 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
|
||||
@@ -3175,7 +3231,7 @@ class EmbyRepository internal constructor(
|
||||
// --- URL helpers ---------------------------------------------------------
|
||||
|
||||
/** 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 {
|
||||
item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first()
|
||||
item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() ->
|
||||
@@ -3201,7 +3257,7 @@ class EmbyRepository internal constructor(
|
||||
}
|
||||
|
||||
/** 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
|
||||
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.
|
||||
*
|
||||
@@ -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.
|
||||
*/
|
||||
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 RELATED_CACHE_SIZE = 12
|
||||
private const val RELATED_LIMIT = 12
|
||||
|
||||
@@ -312,12 +312,18 @@ internal fun AppRoot(
|
||||
// poster snapshot never reloads while update/session/onboarding checks settle.
|
||||
// There is deliberately no decorative hold: the instant the next screen is ready,
|
||||
// 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 {
|
||||
// A required update is a service gate, not launcher content. Once the gateway
|
||||
// answers, uncover it immediately instead of making the viewer finish the
|
||||
// opening artwork first. Optional prompts can replace it as soon as ready.
|
||||
update?.isMandatory == true -> false
|
||||
!initialUpdateCheckComplete -> true
|
||||
!initialUpdateCheckComplete && !cachedSignedInHome -> true
|
||||
update != null -> false
|
||||
// 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
|
||||
|
||||
@@ -83,6 +83,8 @@ import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
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.EmbyPerson
|
||||
import com.ponzischeme89.memby.data.model.MediaRating
|
||||
@@ -249,7 +251,8 @@ internal fun DetailBackdrop(
|
||||
val context = LocalContext.current
|
||||
val repository = ServiceLocator.repository
|
||||
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
|
||||
// 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.playbackEntryPointFor
|
||||
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.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
@@ -648,9 +651,9 @@ internal fun HomeScreen(
|
||||
PlayerActivity.intent(
|
||||
context = context,
|
||||
request = request,
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
|
||||
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
journeySource = entryPoint.id,
|
||||
),
|
||||
@@ -697,9 +700,9 @@ internal fun HomeScreen(
|
||||
PlayerActivity.intent(
|
||||
context = context,
|
||||
playable = playable,
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
|
||||
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
journeySource = entryPoint.id,
|
||||
),
|
||||
|
||||
@@ -203,7 +203,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
repository.playbackPositions.collect(::applyPlaybackPosition)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.playbackStops.collect { refreshWatching() }
|
||||
repository.playbackStops.collect { refreshWatching(authoritative = false) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
// 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) {
|
||||
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
||||
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)
|
||||
// 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
|
||||
@@ -431,7 +435,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// shared detail record instead of treating absent fields as "no logo".
|
||||
if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) {
|
||||
taggedDetails = taggedDetails.withSeriesTitleLogo(
|
||||
taggedDetails.seriesId?.let { loadDetailMetadata(it) },
|
||||
taggedDetails.seriesId?.let { loadFocusedMetadata(it) },
|
||||
)
|
||||
}
|
||||
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
|
||||
* detail-only requests wait here, so a series page does not open with an empty
|
||||
* Episodes pane, no progress, no next episode and no estimated finish, and every 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.
|
||||
* The item record is warmed separately for the launcher's metadata panel. Related content,
|
||||
* trailer availability and the logo are cheap enough to prepare for a deliberate pause;
|
||||
* the complete episode browser is deliberately left to the detail page.
|
||||
*
|
||||
* **It waits longer than the metadata warm does**, and that is the whole cost control.
|
||||
* An episode list is the largest request the client makes — a long-running show is a
|
||||
* thousand records — so warming one per card as somebody scans across a shelf would
|
||||
* 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.
|
||||
* The delay is deliberately longer than the metadata warm. A long-running show's episode
|
||||
* list can contain a thousand records, so it must never be fetched merely because focus
|
||||
* paused on a card.
|
||||
*/
|
||||
private suspend fun warmDetailPage(item: BaseItem) {
|
||||
// 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.
|
||||
if (item.membyPlayable) repository.warmStreamConnection()
|
||||
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
|
||||
// for every card it crosses; a press still shares the resulting single flight.
|
||||
if (item.isMovie || item.isSeries) {
|
||||
launch { runCatching { repository.getRelated(item) } }
|
||||
}
|
||||
// A series is keyed on itself, an episode on the show it belongs to — which is
|
||||
// exactly what its own detail page will ask for.
|
||||
val seriesId = when {
|
||||
item.isSeries -> item.id
|
||||
item.isEpisode -> item.seriesId
|
||||
else -> null
|
||||
}
|
||||
if (!seriesId.isNullOrBlank()) {
|
||||
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
|
||||
}
|
||||
// Do not warm the complete episode browser from focus. A long-running series can
|
||||
// contain hundreds of records; the detail page requests that list when the viewer
|
||||
// actually opens it, where the result is still shared and cached.
|
||||
launch { runCatching { repository.hasTrailer(item.id) } }
|
||||
// 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
|
||||
@@ -680,6 +669,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
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]. */
|
||||
private fun acceptSeriesStatusRevision(revision: Long) {
|
||||
if (revision <= 0L || revision == seriesStatusRevision) return
|
||||
@@ -811,9 +822,17 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
viewModelScope.launch { persistCurrentHome() }
|
||||
}
|
||||
|
||||
private suspend fun refreshWatching() {
|
||||
private suspend fun refreshWatching(authoritative: Boolean = true) {
|
||||
if (!_continueWatchingEnabled.value) return
|
||||
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) }
|
||||
if (repository.supportsBatchHome) {
|
||||
// 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
|
||||
|
||||
/**
|
||||
* 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
|
||||
* [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
|
||||
* this one can be a thousand episode records and should only follow a viewer who
|
||||
* 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 HOME_RETRY_INITIAL_MS = 2_000L
|
||||
private const val HOME_RETRY_MAX_MS = 60_000L
|
||||
|
||||
@@ -20,6 +20,8 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
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.MembyTrialTypography
|
||||
|
||||
@@ -104,7 +106,10 @@ private fun MetadataHeroArtwork(
|
||||
item?.parentBackdropImageTags,
|
||||
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) {
|
||||
artwork?.let {
|
||||
|
||||
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val MAX_CACHED_CATEGORIES = 15
|
||||
|
||||
data class GenreBrowseUiState(
|
||||
val categories: List<GenreCategory> = emptyList(),
|
||||
val selectedCategoryId: String? = null,
|
||||
@@ -58,7 +60,15 @@ class GenreBrowseViewModel(
|
||||
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
|
||||
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
||||
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.
|
||||
@@ -119,16 +129,22 @@ class GenreBrowseViewModel(
|
||||
|
||||
fun loadMore() {
|
||||
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
|
||||
|
||||
// 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) }
|
||||
pageJob = viewModelScope.launch { loadPage(category, current.readOffset) }
|
||||
pageJob = viewModelScope.launch { loadPage(category, nextOffset) }
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val current = state.value
|
||||
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
|
||||
val offset = current.readOffset
|
||||
val categoryId = current.selectedCategoryId ?: return
|
||||
val category = categories.firstOrNull { it.id == categoryId } ?: return
|
||||
val offset = categoryPages[categoryId]?.readOffset ?: current.readOffset
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = offset == 0,
|
||||
@@ -253,4 +269,4 @@ class GenreBrowseViewModelFactory(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
GenreBrowseViewModel(repository, itemType) as T
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
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.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
@@ -80,7 +82,10 @@ private val MetadataHeroFactsLineHeight = 18.sp
|
||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
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) }
|
||||
LaunchedEffect(imageUrl) {
|
||||
if (displayedUrl == null) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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.background
|
||||
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.graphics.Color
|
||||
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.KeyEventType
|
||||
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.text.font.FontWeight
|
||||
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.Text
|
||||
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.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import kotlinx.coroutines.delay
|
||||
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 }
|
||||
|
||||
data class HomeBrowseRow(
|
||||
@@ -67,63 +66,21 @@ data class HomeBrowseRow(
|
||||
val showWatchedEpisodeCount: Boolean = false,
|
||||
)
|
||||
|
||||
private data class HomeRowVisual(
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
row.id == "continue" -> HomeRowVisual(
|
||||
MembyIcon.PlayCircle.mark,
|
||||
)
|
||||
row.id == "continue-shows" -> HomeRowVisual(
|
||||
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,
|
||||
)
|
||||
private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when {
|
||||
row.id == "continue" || row.id == "continue-shows" -> MembyIcon.PlayCircle.mark
|
||||
row.kind == MediaRowKind.FAVORITES -> MembyIcon.Favourite.mark
|
||||
row.id == "latest-movies" -> MembyIcon.Movie.mark
|
||||
row.id == "sonarr-airing-today" -> MembyIcon.Calendar.mark
|
||||
row.id == "curated:apple-tv" -> MembyIcon.LiveTv.mark
|
||||
row.id == "curated:drama-shows" -> MembyIcon.Drama.mark
|
||||
row.id == "curated:comedy-shows" -> MembyIcon.Happy.mark
|
||||
row.id.startsWith("similar:") || row.id.startsWith("for-you:") -> MembyIcon.Sparkle.mark
|
||||
row.id == "recommended" -> MembyIcon.Recommend.mark
|
||||
else -> 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
|
||||
|
||||
/** Vertical gap between a row's header and its cards. One value for every row. */
|
||||
internal val HomeRowHeaderSpacing = 10.dp
|
||||
|
||||
private val HomeRowCardsTopPadding = 12.dp
|
||||
private val HomeRowCardsBottomPadding = 32.dp
|
||||
|
||||
@@ -170,24 +127,26 @@ internal fun MediaRow(
|
||||
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
||||
val rowState = horizontalState ?: savedRowState
|
||||
val verticalEntryFocusRequester = remember { FocusRequester() }
|
||||
val requestedEntryIndex = verticalFocusRequest
|
||||
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
|
||||
?.itemIndex
|
||||
?.coerceIn(0, row.items.lastIndex)
|
||||
val currentOnVerticalFocusRequestConsumed by rememberUpdatedState(
|
||||
onVerticalFocusRequestConsumed,
|
||||
)
|
||||
|
||||
val requestedEntryIndex = remember(verticalFocusRequest, row.id, row.items.size) {
|
||||
verticalFocusRequest
|
||||
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
|
||||
?.itemIndex
|
||||
?.coerceIn(0, row.items.lastIndex)
|
||||
}
|
||||
|
||||
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) {
|
||||
val entryIndex = requestedEntryIndex ?: return@LaunchedEffect
|
||||
val request = verticalFocusRequest
|
||||
?.takeIf { it.rowId == row.id }
|
||||
?: return@LaunchedEffect
|
||||
val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect
|
||||
|
||||
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) {
|
||||
delay(16.milliseconds)
|
||||
if (verticalEntryFocusRequester.requestFocusIfAttached()) {
|
||||
@@ -197,6 +156,7 @@ internal fun MediaRow(
|
||||
}
|
||||
currentOnVerticalFocusRequestConsumed(request.requestId)
|
||||
}
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -205,18 +165,22 @@ internal fun MediaRow(
|
||||
.focusGroup(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val visual = homeRowVisual(row)
|
||||
HomeRowHeaderIcon(visual.icon)
|
||||
val icon = remember(row) { getHomeRowIcon(row) }
|
||||
HomeRowHeaderIcon(icon)
|
||||
Spacer(Modifier.width(HomeRowHeaderIconGap))
|
||||
Text(
|
||||
row.title,
|
||||
text = row.title,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
row.items.isEmpty() && row.loading -> {
|
||||
val isPortrait = remember(row.kind) {
|
||||
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
|
||||
}
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(
|
||||
start = HomeContentHorizontalInset,
|
||||
@@ -226,9 +190,9 @@ internal fun MediaRow(
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(5) {
|
||||
items(count = 5) {
|
||||
TvLoadingPlaceholder(
|
||||
portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS),
|
||||
portrait = isPortrait,
|
||||
availableWidth = availableWidth,
|
||||
)
|
||||
}
|
||||
@@ -236,8 +200,8 @@ internal fun MediaRow(
|
||||
}
|
||||
row.items.isEmpty() -> {
|
||||
Text(
|
||||
row.emptyMessage,
|
||||
color = QuietText,
|
||||
text = row.emptyMessage,
|
||||
color = MembyQuietText,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = HomeContentHorizontalInset,
|
||||
@@ -255,80 +219,86 @@ internal fun MediaRow(
|
||||
bottom = HomeRowCardsBottomPadding,
|
||||
),
|
||||
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(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
row.items,
|
||||
items = row.items,
|
||||
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 ->
|
||||
var cardModifier: Modifier = Modifier
|
||||
if (index == 0) {
|
||||
if (contentEntryFocusRequester != null) {
|
||||
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
|
||||
}
|
||||
// Where Down out of the home hero lands. It is a second
|
||||
// requester rather than the entry one because while the hero is
|
||||
// up, that one belongs to the hero.
|
||||
if (heroEntryFocusRequester != null) {
|
||||
cardModifier = cardModifier.focusRequester(heroEntryFocusRequester)
|
||||
}
|
||||
val targetFocusRequester = when {
|
||||
index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester
|
||||
index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester
|
||||
item.id == returnFocusItemId -> returnFocusRequester
|
||||
index == requestedEntryIndex -> verticalEntryFocusRequester
|
||||
else -> null
|
||||
}
|
||||
if (item.id == returnFocusItemId) {
|
||||
cardModifier = cardModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
if (index == requestedEntryIndex) {
|
||||
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
|
||||
|
||||
var cardModifier = Modifier as Modifier
|
||||
if (targetFocusRequester != null) {
|
||||
cardModifier = cardModifier.focusRequester(targetFocusRequester)
|
||||
}
|
||||
|
||||
cardModifier = cardModifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> {
|
||||
onMoveVertical(index, RowFocusDirection.UP)
|
||||
}
|
||||
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
|
||||
Key.DirectionUp -> currentOnMoveVertical(index, RowFocusDirection.UP)
|
||||
Key.DirectionDown -> currentOnMoveVertical(index, RowFocusDirection.DOWN)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val focused: () -> Unit = {
|
||||
onContentFocused()
|
||||
// LazyRow already knows the semantic position. Passing it on
|
||||
// avoids searching the row again on every D-pad focus move.
|
||||
onItemFocused(item, index)
|
||||
|
||||
val focused = remember(item, index) {
|
||||
{
|
||||
currentOnContentFocused()
|
||||
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) {
|
||||
ContinueWatchingCard(
|
||||
item = item,
|
||||
availableWidth = availableWidth,
|
||||
portraitArtwork = format == MediaCardFormat.PORTRAIT,
|
||||
onFocused = focused,
|
||||
onClick = { onItemSelected(item) },
|
||||
onLongClick = { onItemLongPressed(item) },
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = cardModifier,
|
||||
density = density,
|
||||
)
|
||||
} else when (format) {
|
||||
MediaCardFormat.PORTRAIT -> PosterCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
item = item,
|
||||
availableWidth = availableWidth,
|
||||
showSecondaryMetadata = row.showSecondaryMetadata,
|
||||
onFocused = focused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = cardModifier,
|
||||
density = density,
|
||||
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
|
||||
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
||||
)
|
||||
MediaCardFormat.LANDSCAPE -> LandscapeCard(
|
||||
item = item,
|
||||
availableWidth = availableWidth,
|
||||
showSecondaryMetadata = row.showSecondaryMetadata,
|
||||
onFocused = focused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = cardModifier,
|
||||
density = density,
|
||||
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
|
||||
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
||||
)
|
||||
MediaCardFormat.LANDSCAPE -> {
|
||||
LandscapeCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,13 +316,12 @@ private fun cardFormat(
|
||||
kind: MediaRowKind,
|
||||
item: BaseItem,
|
||||
artworkStyle: String = "automatic",
|
||||
): MediaCardFormat =
|
||||
when (artworkStyle) {
|
||||
"poster" -> MediaCardFormat.PORTRAIT
|
||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when {
|
||||
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
|
||||
item.isEpisode -> MediaCardFormat.LANDSCAPE
|
||||
else -> MediaCardFormat.PORTRAIT
|
||||
}
|
||||
): MediaCardFormat = when (artworkStyle) {
|
||||
"poster" -> MediaCardFormat.PORTRAIT
|
||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when {
|
||||
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
|
||||
item.isEpisode -> MediaCardFormat.LANDSCAPE
|
||||
else -> MediaCardFormat.PORTRAIT
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.1.66
|
||||
0.1.68
|
||||
|
||||
Reference in New Issue
Block a user