0.3.11
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user