From aa8f16f21b7ad49fb98ac8a8eec65e3b94cdaf8f Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 23 Aug 2026 18:21:18 +1200 Subject: [PATCH] 0.3.11 --- app/build.gradle.kts | 2 +- .../ponzischeme89/memby/ui/HomeOverlays.kt | 39 ++++++++---- .../ponzischeme89/memby/ui/HomeViewModel.kt | 6 ++ .../memby/ui/ResumableMediaCard.kt | 9 ++- .../ui/components/FocusScaleContainer.kt | 17 +++-- .../memby/ui/detail/DetailCardFocus.kt | 33 ++++++---- .../memby/ui/genre/GenreBrowseScreen.kt | 63 ++++++++++++------- .../ponzischeme89/memby/ui/home/HomeRow.kt | 6 +- 8 files changed, 118 insertions(+), 57 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66109de..5b9cac7 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.10" +val defaultVersionName = "0.3.11" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index 27c6727..2951ce3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -61,6 +61,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle import coil.imageLoader import coil.request.ImageRequest +import coil.request.Priority import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings @@ -227,10 +228,10 @@ internal fun HomeArtworkPreloader( val repo = ServiceLocator.repository val discovered = remember(rows) { // Warm the leading posters across several rows instead of exhausting the - // budget on the first shelf. Vertical navigation is then far less likely to + // budget on the first shelves. Vertical navigation is then far less likely to // compete with image fetch/decode as the next row enters the viewport. val perRow = rows.take(6).map { row -> - row.items.take(2).map { row.kind to it } + row.items.take(3).map { row.kind to it } } buildList { val depth = perRow.maxOfOrNull { it.size } ?: 0 @@ -241,15 +242,15 @@ internal fun HomeArtworkPreloader( } } .distinctBy { it.second.id } - .take(12) + .take(16) } val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } } LaunchedEffect(signature, availableWidth, lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { // Let visible cards win the first network/decode slots. Limiting the warm to - // the leading pair from the next six shelves covers the likely D-pad path - // without decoding a second screenful nobody may visit. - delay(350.milliseconds) + // the leading three cards from the next six shelves covers the likely D-pad + // path without decoding a second screenful nobody may visit. + delay(500.milliseconds) discovered.forEach { (kind, item) -> val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode val width = if (landscape) { @@ -269,6 +270,7 @@ internal fun HomeArtworkPreloader( .data(url) .size(widthPx, heightPx) .allowHardware(true) + .priority(Priority.LOW) .crossfade(false) .build(), ) @@ -280,7 +282,14 @@ internal fun HomeArtworkPreloader( @Composable internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - BackdropLayer(item = focusedItem, modifier = Modifier.fillMaxSize()) + var settledItem by remember { mutableStateOf(focusedItem) } + LaunchedEffect(focusedItem) { + // The backdrop is decorative. Debouncing it lets a held remote move through a row + // without starting a new hero transition for every intermediate card. + delay(HOME_HERO_UPDATE_DEBOUNCE_MS) + settledItem = focusedItem + } + BackdropLayer(item = settledItem, modifier = Modifier.fillMaxSize()) } @Composable @@ -292,13 +301,15 @@ internal fun FocusedHomeMetadata( modifier: Modifier = Modifier, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - // Only the loading flags matter here, so take the content projection rather than the - // whole state: this panel sits beside the hero and redraws on every focus change as - // it is. - val homeContent by homeViewModel.content.collectAsStateWithLifecycle() + var settledItem by remember { mutableStateOf(focusedItem) } + LaunchedEffect(focusedItem) { + delay(HOME_HERO_UPDATE_DEBOUNCE_MS) + settledItem = focusedItem + } + val loading by homeViewModel.loading.collectAsStateWithLifecycle() MetadataHero( - item = focusedItem, - loading = homeContent.loading.isNotEmpty(), + item = settledItem, + loading = loading.isNotEmpty(), contentOrder = metadataHeroContentOrder, timeRemainingColour = metadataHeroTimeRemainingColour, isContinueWatchingItem = isContinueWatchingItem, @@ -422,6 +433,8 @@ internal fun FocusedDetailsOverlay( } } +private const val HOME_HERO_UPDATE_DEBOUNCE_MS = 120L + /** * Combines the three owners involved in a detail page without confusing their lifetimes: * the selected route fixes identity, current focus contributes live user state only when 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 2fe198f..b82b185 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -151,6 +151,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice()) + /** The metadata panel only needs this small projection, not the row payloads. */ + val loading: StateFlow> = _state + .map(HomeUiState::loading) + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.loading) + /** Connection health. Does not emit when rows change. */ val status: StateFlow = _state .map(HomeUiState::statusSlice) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt index e7f4e88..ed8a1b5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -168,6 +169,7 @@ fun ResumableMediaCard( titleSupplement: (@Composable (focused: Boolean) -> Unit)? = null, ) { val context = LocalContext.current + val density = LocalDensity.current val artworkUrl = if (portraitArtwork) { model.primaryUrl ?: model.backdropUrl } else { @@ -216,12 +218,17 @@ fun ResumableMediaCard( ).joinToString(", ") } val aspectRatio = if (portraitArtwork) 2f / 3f else 16f / 9f + val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720) + val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1) var failed by remember(model.id, artworkUrl) { mutableStateOf(false) } var loading by remember(model.id, artworkUrl) { mutableStateOf(artworkUrl != null) } - val imageRequest = remember(artworkUrl, context) { + val imageRequest = remember(artworkUrl, widthPx, heightPx, context) { artworkUrl?.let { ImageRequest.Builder(context) .data(it) + // Continue Watching is the busiest Home row. Avoid decoding the server's + // full artwork when the card is only a few hundred pixels wide. + .size(widthPx, heightPx) .allowHardware(true) .crossfade(false) .build() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt index 57e7b92..4f47c2a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt @@ -130,11 +130,18 @@ fun FocusScaleContainer( content: @Composable BoxScope.(focused: Boolean) -> Unit, ) { var focused by remember { mutableStateOf(false) } - val scale = animateFloatAsState( - targetValue = if (focused) 1.018f else 1f, - animationSpec = tween(95), - label = "media-card-focus", - ) + // An Animatable driven from an effect, not animateFloatAsState — the GenreRailItem + // finding: the value is read only inside graphicsLayer, but animateFloatAsState still + // schedules a recomposition of this container on every animation frame, and every + // home row card runs through this container. Travelling a row is the most frequent + // animation on the launcher, and that per-card recompose during a held D-pad is most + // of what the focus animation cost on a weak box. Driving an Animatable from a + // LaunchedEffect keyed on [focused] keeps the animation in the draw phase: the frames + // invalidate draw only. + val scale = remember { Animatable(1f) } + LaunchedEffect(focused) { + scale.animateTo(targetValue = if (focused) 1.018f else 1f, animationSpec = tween(95)) + } Box( modifier = modifier .zIndex(if (focused) 1f else 0f) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt index 7318082..7d87e90 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt @@ -1,9 +1,10 @@ package com.ponzischeme89.memby.ui.detail -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.zIndex @@ -18,22 +19,28 @@ import androidx.compose.ui.zIndex * means a viewer learns what focus looks like once, and it means the *cost* of focus is one * decision rather than three. * - * The animated value is read only inside the `graphicsLayer` block, which is the draw - * phase, so travelling a grid costs a redraw of the two cards involved rather than a - * recomposition of every card in it. That rule is the whole reason this is a modifier and - * not a wrapper composable. + * An Animatable driven from an effect, not animateFloatAsState — the GenreRailItem / + * FocusScaleContainer finding: the value is read only inside the `graphicsLayer` block, + * the draw phase, but animateFloatAsState still schedules a recomposition of the card on + * every animation frame regardless of where its value is read. Driving an Animatable from + * a LaunchedEffect keyed on [focused] keeps the animation in the draw phase, so travelling + * a grid costs a redraw of the two cards involved rather than a recomposition of every + * card in it. That rule is the whole reason this is a modifier and not a wrapper + * composable. */ @Composable fun Modifier.detailCardFocus(focused: Boolean): Modifier { - val scale by animateFloatAsState( - targetValue = if (focused) DetailCardFocusScale else 1f, - animationSpec = tween(DetailCardFocusMs), - label = "detail-card-focus", - ) + val scale = remember { Animatable(1f) } + LaunchedEffect(focused) { + scale.animateTo( + targetValue = if (focused) DetailCardFocusScale else 1f, + animationSpec = tween(DetailCardFocusMs), + ) + } return this .graphicsLayer { - scaleX = scale - scaleY = scale + scaleX = scale.value + scaleY = scale.value translationY = if (focused) DetailCardFocusLiftPx else 0f } .zIndex(if (focused) 1f else 0f) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index 694292a..c728e26 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -4,7 +4,7 @@ package com.ponzischeme89.memby.ui.genre import com.ponzischeme89.memby.ui.theme.mark import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup @@ -80,6 +80,7 @@ import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -341,10 +342,15 @@ fun GenreBrowseScreen( ) else -> { LaunchedEffect(gridState, state.items.size, state.canLoadMore) { + // The producer runs on every scroll frame, so it is kept as + // cheap as possible — one Int out, deduped, and conflated so + // a burst of frames collapses to the latest rather than + // queueing a loadMore check per frame behind the scroll. snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } .distinctUntilChanged() + .conflate() .collect { last -> if (state.canLoadMore && last >= state.items.size - columns * 2) { browseViewModel.loadMore() @@ -370,6 +376,11 @@ fun GenreBrowseScreen( } else { item.withFavourite(favourite) } + // Hoisted out of the modifier lambdas below so the + // common case — a card that is neither the return + // target nor the entry card nor in the first column — + // attaches no extra focus node at all. + val isFirstColumn = index % columns == 0 PosterGridCard( item = displayedItem, width = cardWidth, @@ -404,16 +415,22 @@ fun GenreBrowseScreen( Modifier }, ) - .focusProperties { - // Left out of the first column is the way - // back to the genre this grid belongs to, - // named directly so the press cannot land - // on a genre merely travelled through. - if (index % columns == 0) { - left = railFocusRequesters[activeCategoryId] - ?: contentFocusRequester - } - }, + // Left out of the first column is the way back + // to the genre this grid belongs to, named + // directly so the press cannot land on a genre + // merely travelled through. Only the first + // column carries the node — the rest would run + // an empty focusProperties lambda for nothing. + .then( + if (isFirstColumn) { + Modifier.focusProperties { + left = railFocusRequesters[activeCategoryId] + ?: contentFocusRequester + } + } else { + Modifier + }, + ), ) } if (state.isLoadingMore) { @@ -477,7 +494,9 @@ internal fun GenreRail( } // Falls back to a private set only for a caller that has none of its own, which today // is the screenshot test: the rail must be renderable without the screen around it. - val ownedFocusRequesters = remember(categories) { + // Keyed on the ids rather than the list itself, so a fresh state emission — a new list + // identity with the same genres — does not rebuild every FocusRequester in it. + val ownedFocusRequesters = remember(categories.map { it.id }) { categories.associate { it.id to FocusRequester() } } val requesterFor: (String) -> FocusRequester = { id -> @@ -595,14 +614,16 @@ private fun GenreRailItem( modifier = modifier.fillMaxWidth(), ) { hasFocus -> val focused = hasFocus || focusedForCapture - // Read only inside drawBehind. The rail is travelled fast, and an animated value - // read in a composable body would recompose the item on every frame of its own - // focus animation — which on a weak box is most of what a held D-pad costs. - val emphasis = animateFloatAsState( - targetValue = if (focused) 1f else 0f, - animationSpec = tween(140), - label = "genre-rail-emphasis", - ) + // An Animatable driven from an effect, not animateFloatAsState. The value is read + // only inside drawBehind, but animateFloatAsState still schedules a recomposition + // of this item on every animation frame — and the rail is travelled fast, so on a + // weak box that per-item recompose during a held D-pad is most of what the focus + // animation costs. Driving an Animatable from a LaunchedEffect keyed on [focused] + // keeps the animation in the draw phase: the frames invalidate draw only. + val emphasis = remember { Animatable(if (focused) 1f else 0f) } + LaunchedEffect(focused) { + emphasis.animateTo(targetValue = if (focused) 1f else 0f, animationSpec = tween(140)) + } val plate = when { // White, not the accent. The rail sits on near-black beside a grid of artwork, // and a coloured plate with dark ink on it read as a filled-in shape rather @@ -740,4 +761,4 @@ private fun GenreRetry( ) } } -} +} \ No newline at end of file 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 24ff975..85d708b 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 @@ -229,10 +229,10 @@ internal fun MediaRow( }, ) { index, item -> val targetFocusRequester = when { - index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester - index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester item.id == returnFocusItemId -> returnFocusRequester index == requestedEntryIndex -> verticalEntryFocusRequester + index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester + index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester else -> null } @@ -324,4 +324,4 @@ private fun cardFormat( item.isEpisode -> MediaCardFormat.LANDSCAPE else -> MediaCardFormat.PORTRAIT } -} \ No newline at end of file +}