diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1cad99f..66109de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index e14860f..66246cd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -344,6 +344,10 @@ class EmbyRepository internal constructor( private val seriesEpisodesCache = LinkedHashMap(SERIES_EPISODE_CACHE_SIZE, 0.75f, true) private val seriesEpisodesInFlight = mutableMapOf?>>() + private val itemDetailsMutex = Mutex() + private val itemDetailsCache = + LinkedHashMap(DETAIL_CACHE_SIZE, 0.75f, true) + private val itemDetailsInFlight = mutableMapOf>() private val relatedMutex = Mutex() private val relatedCache = LinkedHashMap(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 { + lateinit var request: Deferred + 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt index 4491049..5de8a37 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index a9ed4c0..999a3aa 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 39aecd6..7656d4f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -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, ), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index c598ea4..2fe198f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt index f67bd00..32e91c1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt @@ -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 { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index 69f1661..00f97cc 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -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 = emptyList(), val selectedCategoryId: String? = null, @@ -58,7 +60,15 @@ class GenreBrowseViewModel( private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories)) val state: StateFlow = _state.asStateFlow() private var pageJob: Job? = null - private val categoryPages = mutableMapOf() + + /** LRU cache bounding the memory footprint for large library category pages. */ + private val categoryPages = object : LinkedHashMap( + 16, 0.75f, true + ) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): 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 create(modelClass: Class): T = GenreBrowseViewModel(repository, itemType) as T -} +} \ No newline at end of file diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt index 86b339e..ebad6a7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt @@ -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) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt index 3991307..24ff975 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt @@ -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 } +} \ No newline at end of file diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index c24b7ee..72052e2 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.66 +0.1.68