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() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.10" val defaultVersionName = "0.3.11"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -61,6 +61,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.request.Priority
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.Settings
@@ -227,10 +228,10 @@ internal fun HomeArtworkPreloader(
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val discovered = remember(rows) { val discovered = remember(rows) {
// Warm the leading posters across several rows instead of exhausting the // 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. // compete with image fetch/decode as the next row enters the viewport.
val perRow = rows.take(6).map { row -> 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 { buildList {
val depth = perRow.maxOfOrNull { it.size } ?: 0 val depth = perRow.maxOfOrNull { it.size } ?: 0
@@ -241,15 +242,15 @@ internal fun HomeArtworkPreloader(
} }
} }
.distinctBy { it.second.id } .distinctBy { it.second.id }
.take(12) .take(16)
} }
val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } } val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } }
LaunchedEffect(signature, availableWidth, lifecycleOwner) { LaunchedEffect(signature, availableWidth, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Let visible cards win the first network/decode slots. Limiting the warm to // 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 // the leading three cards from the next six shelves covers the likely D-pad
// without decoding a second screenful nobody may visit. // path without decoding a second screenful nobody may visit.
delay(350.milliseconds) delay(500.milliseconds)
discovered.forEach { (kind, item) -> discovered.forEach { (kind, item) ->
val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode
val width = if (landscape) { val width = if (landscape) {
@@ -269,6 +270,7 @@ internal fun HomeArtworkPreloader(
.data(url) .data(url)
.size(widthPx, heightPx) .size(widthPx, heightPx)
.allowHardware(true) .allowHardware(true)
.priority(Priority.LOW)
.crossfade(false) .crossfade(false)
.build(), .build(),
) )
@@ -280,7 +282,14 @@ internal fun HomeArtworkPreloader(
@Composable @Composable
internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() 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 @Composable
@@ -292,13 +301,15 @@ internal fun FocusedHomeMetadata(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
// Only the loading flags matter here, so take the content projection rather than the var settledItem by remember { mutableStateOf(focusedItem) }
// whole state: this panel sits beside the hero and redraws on every focus change as LaunchedEffect(focusedItem) {
// it is. delay(HOME_HERO_UPDATE_DEBOUNCE_MS)
val homeContent by homeViewModel.content.collectAsStateWithLifecycle() settledItem = focusedItem
}
val loading by homeViewModel.loading.collectAsStateWithLifecycle()
MetadataHero( MetadataHero(
item = focusedItem, item = settledItem,
loading = homeContent.loading.isNotEmpty(), loading = loading.isNotEmpty(),
contentOrder = metadataHeroContentOrder, contentOrder = metadataHeroContentOrder,
timeRemainingColour = metadataHeroTimeRemainingColour, timeRemainingColour = metadataHeroTimeRemainingColour,
isContinueWatchingItem = isContinueWatchingItem, 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: * 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 * 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() .distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice()) .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. */ /** Connection health. Does not emit when rows change. */
val status: StateFlow<HomeStatus> = _state val status: StateFlow<HomeStatus> = _state
.map(HomeUiState::statusSlice) .map(HomeUiState::statusSlice)
@@ -28,6 +28,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -168,6 +169,7 @@ fun ResumableMediaCard(
titleSupplement: (@Composable (focused: Boolean) -> Unit)? = null, titleSupplement: (@Composable (focused: Boolean) -> Unit)? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current
val artworkUrl = if (portraitArtwork) { val artworkUrl = if (portraitArtwork) {
model.primaryUrl ?: model.backdropUrl model.primaryUrl ?: model.backdropUrl
} else { } else {
@@ -216,12 +218,17 @@ fun ResumableMediaCard(
).joinToString(", ") ).joinToString(", ")
} }
val aspectRatio = if (portraitArtwork) 2f / 3f else 16f / 9f 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 failed by remember(model.id, artworkUrl) { mutableStateOf(false) }
var loading by remember(model.id, artworkUrl) { mutableStateOf(artworkUrl != null) } var loading by remember(model.id, artworkUrl) { mutableStateOf(artworkUrl != null) }
val imageRequest = remember(artworkUrl, context) { val imageRequest = remember(artworkUrl, widthPx, heightPx, context) {
artworkUrl?.let { artworkUrl?.let {
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(it) .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) .allowHardware(true)
.crossfade(false) .crossfade(false)
.build() .build()
@@ -130,11 +130,18 @@ fun FocusScaleContainer(
content: @Composable BoxScope.(focused: Boolean) -> Unit, content: @Composable BoxScope.(focused: Boolean) -> Unit,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val scale = animateFloatAsState( // An Animatable driven from an effect, not animateFloatAsState — the GenreRailItem
targetValue = if (focused) 1.018f else 1f, // finding: the value is read only inside graphicsLayer, but animateFloatAsState still
animationSpec = tween(95), // schedules a recomposition of this container on every animation frame, and every
label = "media-card-focus", // 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( Box(
modifier = modifier modifier = modifier
.zIndex(if (focused) 1f else 0f) .zIndex(if (focused) 1f else 0f)
@@ -1,9 +1,10 @@
package com.ponzischeme89.memby.ui.detail 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.animation.core.tween
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex 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 * means a viewer learns what focus looks like once, and it means the *cost* of focus is one
* decision rather than three. * decision rather than three.
* *
* The animated value is read only inside the `graphicsLayer` block, which is the draw * An Animatable driven from an effect, not animateFloatAsState — the GenreRailItem /
* phase, so travelling a grid costs a redraw of the two cards involved rather than a * FocusScaleContainer finding: the value is read only inside the `graphicsLayer` block,
* recomposition of every card in it. That rule is the whole reason this is a modifier and * the draw phase, but animateFloatAsState still schedules a recomposition of the card on
* not a wrapper composable. * 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 @Composable
fun Modifier.detailCardFocus(focused: Boolean): Modifier { fun Modifier.detailCardFocus(focused: Boolean): Modifier {
val scale by animateFloatAsState( val scale = remember { Animatable(1f) }
targetValue = if (focused) DetailCardFocusScale else 1f, LaunchedEffect(focused) {
animationSpec = tween(DetailCardFocusMs), scale.animateTo(
label = "detail-card-focus", targetValue = if (focused) DetailCardFocusScale else 1f,
) animationSpec = tween(DetailCardFocusMs),
)
}
return this return this
.graphicsLayer { .graphicsLayer {
scaleX = scale scaleX = scale.value
scaleY = scale scaleY = scale.value
translationY = if (focused) DetailCardFocusLiftPx else 0f translationY = if (focused) DetailCardFocusLiftPx else 0f
} }
.zIndex(if (focused) 1f 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 com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler 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.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup 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.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -341,10 +342,15 @@ fun GenreBrowseScreen(
) )
else -> { else -> {
LaunchedEffect(gridState, state.items.size, state.canLoadMore) { 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 { snapshotFlow {
gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
} }
.distinctUntilChanged() .distinctUntilChanged()
.conflate()
.collect { last -> .collect { last ->
if (state.canLoadMore && last >= state.items.size - columns * 2) { if (state.canLoadMore && last >= state.items.size - columns * 2) {
browseViewModel.loadMore() browseViewModel.loadMore()
@@ -370,6 +376,11 @@ fun GenreBrowseScreen(
} else { } else {
item.withFavourite(favourite) 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( PosterGridCard(
item = displayedItem, item = displayedItem,
width = cardWidth, width = cardWidth,
@@ -404,16 +415,22 @@ fun GenreBrowseScreen(
Modifier Modifier
}, },
) )
.focusProperties { // Left out of the first column is the way back
// Left out of the first column is the way // to the genre this grid belongs to, named
// back to the genre this grid belongs to, // directly so the press cannot land on a genre
// named directly so the press cannot land // merely travelled through. Only the first
// on a genre merely travelled through. // column carries the node — the rest would run
if (index % columns == 0) { // an empty focusProperties lambda for nothing.
left = railFocusRequesters[activeCategoryId] .then(
?: contentFocusRequester if (isFirstColumn) {
} Modifier.focusProperties {
}, left = railFocusRequesters[activeCategoryId]
?: contentFocusRequester
}
} else {
Modifier
},
),
) )
} }
if (state.isLoadingMore) { 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 // 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. // 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() } categories.associate { it.id to FocusRequester() }
} }
val requesterFor: (String) -> FocusRequester = { id -> val requesterFor: (String) -> FocusRequester = { id ->
@@ -595,14 +614,16 @@ private fun GenreRailItem(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { hasFocus -> ) { hasFocus ->
val focused = hasFocus || focusedForCapture val focused = hasFocus || focusedForCapture
// Read only inside drawBehind. The rail is travelled fast, and an animated value // An Animatable driven from an effect, not animateFloatAsState. The value is read
// read in a composable body would recompose the item on every frame of its own // only inside drawBehind, but animateFloatAsState still schedules a recomposition
// focus animation — which on a weak box is most of what a held D-pad costs. // of this item on every animation frame — and the rail is travelled fast, so on a
val emphasis = animateFloatAsState( // weak box that per-item recompose during a held D-pad is most of what the focus
targetValue = if (focused) 1f else 0f, // animation costs. Driving an Animatable from a LaunchedEffect keyed on [focused]
animationSpec = tween(140), // keeps the animation in the draw phase: the frames invalidate draw only.
label = "genre-rail-emphasis", 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 { val plate = when {
// White, not the accent. The rail sits on near-black beside a grid of artwork, // 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 // and a coloured plate with dark ink on it read as a filled-in shape rather
@@ -740,4 +761,4 @@ private fun GenreRetry(
) )
} }
} }
} }
@@ -229,10 +229,10 @@ internal fun MediaRow(
}, },
) { index, item -> ) { index, item ->
val targetFocusRequester = when { val targetFocusRequester = when {
index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester
index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester
item.id == returnFocusItemId -> returnFocusRequester item.id == returnFocusItemId -> returnFocusRequester
index == requestedEntryIndex -> verticalEntryFocusRequester index == requestedEntryIndex -> verticalEntryFocusRequester
index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester
index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester
else -> null else -> null
} }
@@ -324,4 +324,4 @@ private fun cardFormat(
item.isEpisode -> MediaCardFormat.LANDSCAPE item.isEpisode -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT else -> MediaCardFormat.PORTRAIT
} }
} }