0.3.11
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.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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user