This commit is contained in:
ponzischeme89
2026-08-23 18:21:18 +12:00
parent c809127996
commit aa8f16f21b
8 changed files with 118 additions and 57 deletions
+1 -1
View File
@@ -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()
@@ -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
@@ -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<Set<HomeSection>> = _state
.map(HomeUiState::loading)
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.loading)
/** Connection health. Does not emit when rows change. */
val status: StateFlow<HomeStatus> = _state
.map(HomeUiState::statusSlice)
@@ -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()
@@ -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)
@@ -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(
val scale = remember { Animatable(1f) }
LaunchedEffect(focused) {
scale.animateTo(
targetValue = if (focused) DetailCardFocusScale else 1f,
animationSpec = tween(DetailCardFocusMs),
label = "detail-card-focus",
)
}
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)
@@ -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 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
@@ -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
}