0.3.13
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.12"
|
||||
val defaultVersionName = "0.3.13"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -289,6 +289,8 @@ internal fun HomeMovieHero(
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp)
|
||||
val minis = movies.drop(1).take(3)
|
||||
val firstMiniFocusRequester = remember { FocusRequester() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -310,6 +312,7 @@ internal fun HomeMovieHero(
|
||||
.fillMaxHeight()
|
||||
.focusProperties {
|
||||
left = navigationFocusRequester
|
||||
if (minis.isNotEmpty()) right = firstMiniFocusRequester
|
||||
if (downFocusRequester != null) down = downFocusRequester
|
||||
}
|
||||
if (contentEntryFocusRequester != null) {
|
||||
@@ -330,9 +333,11 @@ internal fun HomeMovieHero(
|
||||
modifier = Modifier.width(miniWidth).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val minis = movies.drop(1).take(3)
|
||||
minis.forEachIndexed { index, pick ->
|
||||
var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth()
|
||||
if (index == 0) {
|
||||
miniModifier = miniModifier.focusRequester(firstMiniFocusRequester)
|
||||
}
|
||||
// Only the bottom mini leaves the hero by Down; the ones above it are
|
||||
// still walking their own column.
|
||||
if (downFocusRequester != null && index == minis.lastIndex) {
|
||||
|
||||
@@ -279,14 +279,7 @@ internal fun HomeArtworkPreloader(
|
||||
|
||||
@Composable
|
||||
internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
|
||||
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
|
||||
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
|
||||
}
|
||||
val settledItem by homeViewModel.settledFocusedItem.collectAsStateWithLifecycle()
|
||||
BackdropLayer(item = settledItem, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
@@ -298,12 +291,7 @@ internal fun FocusedHomeMetadata(
|
||||
isContinueWatchingItem: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
|
||||
var settledItem by remember { mutableStateOf(focusedItem) }
|
||||
LaunchedEffect(focusedItem) {
|
||||
delay(HOME_HERO_UPDATE_DEBOUNCE_MS)
|
||||
settledItem = focusedItem
|
||||
}
|
||||
val settledItem by homeViewModel.settledFocusedItem.collectAsStateWithLifecycle()
|
||||
val loading by homeViewModel.loading.collectAsStateWithLifecycle()
|
||||
MetadataHero(
|
||||
item = settledItem,
|
||||
@@ -431,8 +419,6 @@ 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
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -24,6 +25,7 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -177,6 +179,13 @@ internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp =
|
||||
(viewportHeight * 0.48f).coerceIn(250.dp, 330.dp)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private val HomeLazyListPrefetchStrategy = LazyListPrefetchStrategy(
|
||||
// A shelf is expensive enough to compose that preparing more than the next one makes
|
||||
// rapid vertical movement compete with the frame that is currently being navigated.
|
||||
nestedPrefetchItemCount = 1,
|
||||
)
|
||||
|
||||
private val LazyListStateMapSaver: Saver<MutableMap<String, LazyListState>, Any> = listSaver(
|
||||
save = { states ->
|
||||
states.flatMap { (key, state) ->
|
||||
@@ -188,13 +197,18 @@ private val LazyListStateMapSaver: Saver<MutableMap<String, LazyListState>, Any>
|
||||
values.chunked(3).forEach { saved ->
|
||||
put(
|
||||
saved[0] as String,
|
||||
LazyListState(saved[1] as Int, saved[2] as Int),
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = saved[1] as Int,
|
||||
firstVisibleItemScrollOffset = saved[2] as Int,
|
||||
prefetchStrategy = HomeLazyListPrefetchStrategy,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun HomeScreen(
|
||||
settings: Settings,
|
||||
@@ -473,7 +487,10 @@ internal fun HomeScreen(
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
}
|
||||
}
|
||||
var initialFocusRequested by remember { mutableStateOf(false) }
|
||||
// Initial focus belongs to each destination, not to the launcher as a whole. Home can
|
||||
// settle before Shows or Movies have returned their rows; sharing one latch meant the
|
||||
// later page's only request happened while its first card was still absent.
|
||||
var initialFocusDestination by remember { mutableStateOf<BrowseDestination?>(null) }
|
||||
// Incremented when a rail selection needs to restore a card in the lazy row list.
|
||||
// The list owns the scroll state, so it also owns the actual restoration below.
|
||||
var rowListFocusRestoreRequest by remember { mutableStateOf(0) }
|
||||
@@ -916,10 +933,16 @@ internal fun HomeScreen(
|
||||
val firstItem = contextualHeroItems.firstOrNull()?.item
|
||||
?: rows.firstNotNullOfOrNull { it.items.firstOrNull() }
|
||||
firstItem?.let(homeViewModel::focusItem)
|
||||
if (firstItem != null && !initialFocusRequested) {
|
||||
delay(16.milliseconds)
|
||||
if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) {
|
||||
initialFocusRequested = true
|
||||
if (firstItem != null && initialFocusDestination != selectedDestination) {
|
||||
// A lazy row may still need one or two frames to attach its first card after the
|
||||
// data arrives. Retry briefly rather than falling back to the rail and leaving the
|
||||
// viewer unable to reach the page's rows with the remote.
|
||||
repeat(8) {
|
||||
delay(16.milliseconds)
|
||||
if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) {
|
||||
initialFocusDestination = selectedDestination
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1215,7 +1238,7 @@ internal fun HomeScreen(
|
||||
|
||||
FocusedHomeBackdrop(homeViewModel)
|
||||
val verticalState = verticalStates.getOrPut(selectedDestination.name) {
|
||||
LazyListState()
|
||||
LazyListState(prefetchStrategy = HomeLazyListPrefetchStrategy)
|
||||
}
|
||||
val homeListAtTop by remember(verticalState) {
|
||||
derivedStateOf {
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -165,6 +166,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
||||
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
||||
/**
|
||||
* The item whose expensive hero presentation is allowed to catch up with focus.
|
||||
*
|
||||
* [focusedItem] remains immediate so the cards and D-pad never wait for hero work. A
|
||||
* single shared settled stream keeps the backdrop and metadata panel in step, while
|
||||
* debounce cancels the pending emission whenever focus moves again.
|
||||
*/
|
||||
val settledFocusedItem: StateFlow<BaseItem?> = _focusedItem
|
||||
.debounce(HERO_UPDATE_DEBOUNCE_MS)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, _focusedItem.value)
|
||||
// Genre pages own paged copies that do not live in HomeUiState. This small mutation
|
||||
// stream lets those copies reflect a heart press immediately, including a rollback
|
||||
// when the server rejects it, without refreshing or losing the active category.
|
||||
@@ -911,6 +922,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L
|
||||
private const val HERO_UPDATE_DEBOUNCE_MS = 120L
|
||||
|
||||
/**
|
||||
* How long focus must rest on a card before lightweight detail work is warmed, measured
|
||||
|
||||
@@ -39,6 +39,8 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
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
|
||||
@@ -48,6 +50,7 @@ import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
@@ -153,7 +156,11 @@ internal fun MyShowsStrip(
|
||||
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(shows, key = MyShow::itemId) { show ->
|
||||
items(
|
||||
items = shows,
|
||||
key = MyShow::itemId,
|
||||
contentType = { "my-show" },
|
||||
) { show ->
|
||||
MyShowCard(
|
||||
show = show,
|
||||
repository = repository,
|
||||
@@ -209,10 +216,26 @@ private fun MyShowCard(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val subtitle = remember(show) { myShowCardSubtitle(show) }
|
||||
val badge = remember(show) { myShowBadge(show) }
|
||||
val density = LocalDensity.current
|
||||
val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
|
||||
val heightPx = (widthPx * 3f / 2f).toInt().coerceAtLeast(1)
|
||||
val context = LocalContext.current
|
||||
val imageRequest = remember(show.itemId, show.imageTag, widthPx, heightPx, context) {
|
||||
repository.myShowImageUrl(show.itemId, show.imageTag, widthPx)?.let { url ->
|
||||
ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.size(widthPx, heightPx)
|
||||
.allowHardware(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onClick,
|
||||
contentDescription = "${show.title}, ${myShowCardSubtitle(show)}",
|
||||
contentDescription = "${show.title}, $subtitle",
|
||||
modifier = modifier.width(width),
|
||||
) { focused ->
|
||||
Column {
|
||||
@@ -231,12 +254,12 @@ private fun MyShowCard(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = repository.myShowImageUrl(show.itemId, show.imageTag),
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
myShowBadge(show)?.let { (status, label) ->
|
||||
badge?.let { (status, label) ->
|
||||
LifecycleBadge(
|
||||
status = status,
|
||||
label = label,
|
||||
@@ -254,7 +277,7 @@ private fun MyShowCard(
|
||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
myShowCardSubtitle(show),
|
||||
subtitle,
|
||||
color = MembyQuietText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -76,6 +77,8 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -127,6 +130,7 @@ fun FocusScaleContainer(
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
focusedContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
content: @Composable BoxScope.(focused: Boolean) -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
@@ -156,12 +160,14 @@ fun FocusScaleContainer(
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
.focusable()
|
||||
// The hold, the swallowed repeats and the cancel-on-focus-loss are
|
||||
// [Modifier.remoteLongPress]'s — shared with the rail's user item, so the length
|
||||
// of press that opens a menu is the same wherever a menu can be opened.
|
||||
.remoteLongPress(onClick = onClick, onLongClick = onLongClick)
|
||||
.semantics(mergeDescendants = true) {
|
||||
this.contentDescription = contentDescription
|
||||
this.role = Role.Button
|
||||
if (onLongClick != null) {
|
||||
onLongClick("Quick actions") {
|
||||
onLongClick()
|
||||
@@ -169,6 +175,35 @@ fun FocusScaleContainer(
|
||||
}
|
||||
}
|
||||
},
|
||||
content = { content(focused) },
|
||||
content = {
|
||||
content(focused)
|
||||
if (focused) focusedContent?.invoke(this)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus container for content whose pixels do not depend on focus.
|
||||
*
|
||||
* Focus moves frequently on a TV. Keeping the poster body out of the focus-state lambda
|
||||
* lets Compose skip it when only the scale or focus chrome changes.
|
||||
*/
|
||||
@Composable
|
||||
fun PosterFocusScaleContainer(
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
focusedContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
onLongClick = onLongClick,
|
||||
focusedContent = focusedContent,
|
||||
) { _ -> content() }
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -64,7 +63,6 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
@@ -73,7 +71,6 @@ import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -89,7 +86,6 @@ import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
@@ -142,35 +138,6 @@ fun MediaBadge(
|
||||
fontSize: TextUnit = 11.sp,
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
) {
|
||||
if (label == "CC") {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.icon_cc),
|
||||
contentDescription = "Closed captions available",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier
|
||||
.height(18.dp)
|
||||
.aspectRatio(1.5f),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val surroundSoundDescription = when (label) {
|
||||
"5.1" -> "5.1 surround sound"
|
||||
"7.1" -> "7.1 surround sound"
|
||||
"DOLBY ATMOS" -> "Dolby Atmos surround sound"
|
||||
else -> null
|
||||
}
|
||||
if (surroundSoundDescription != null) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.icon_surround_sound),
|
||||
contentDescription = surroundSoundDescription,
|
||||
modifier = modifier
|
||||
.height(18.dp)
|
||||
.aspectRatio(1.5f),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Text(
|
||||
label,
|
||||
color = MembyOnSurface,
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -43,6 +42,7 @@ import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.PosterFocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
@@ -271,33 +271,36 @@ internal fun MediaCard(
|
||||
.build()
|
||||
}
|
||||
}
|
||||
FocusScaleContainer(
|
||||
val cardShape = remember { RoundedCornerShape(MembyCardCorner) }
|
||||
PosterFocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
contentDescription = model.contentDescription,
|
||||
modifier = modifier.width(width),
|
||||
) { focused ->
|
||||
focusedContent = {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.width(width)
|
||||
.aspectRatio(aspectRatio)
|
||||
.border(1.5.dp, Color.White.copy(alpha = 0.76f), cardShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
MembyArtworkPlayCue()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.width(width)
|
||||
.aspectRatio(aspectRatio)
|
||||
// Keep the modifier node stable while focus changes. Adding/removing
|
||||
// the shadow node forces the poster subtree to be rebuilt exactly
|
||||
// when a row is moving into view, which shows up as a small hitch on
|
||||
// lower-powered TV hardware.
|
||||
.shadow(
|
||||
elevation = if (focused) 5.dp else 0.dp,
|
||||
shape = RoundedCornerShape(MembyCardCorner),
|
||||
)
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.clip(cardShape)
|
||||
.background(MembySurfaceRaised)
|
||||
.border(
|
||||
if (focused) 1.5.dp else 1.dp,
|
||||
if (focused) Color.White.copy(alpha = 0.76f) else Color.White.copy(alpha = 0.06f),
|
||||
RoundedCornerShape(MembyCardCorner),
|
||||
),
|
||||
// The focus border is drawn by the overlay, so the poster body keeps a
|
||||
// stable modifier chain and focus does not rebuild its content.
|
||||
.border(1.dp, Color.White.copy(alpha = 0.06f), cardShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (loading) {
|
||||
@@ -382,15 +385,12 @@ internal fun MediaCard(
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||
)
|
||||
}
|
||||
if (focused) {
|
||||
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
model.name,
|
||||
color = if (focused) Color.White else MembyOnSurface,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 9.dp).fillMaxWidth(),
|
||||
|
||||
@@ -190,7 +190,11 @@ internal fun MediaRow(
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(count = 5) {
|
||||
items(
|
||||
count = 5,
|
||||
key = { index -> "loading-$index" },
|
||||
contentType = { "loading" },
|
||||
) {
|
||||
TvLoadingPlaceholder(
|
||||
portrait = isPortrait,
|
||||
availableWidth = availableWidth,
|
||||
@@ -250,16 +254,22 @@ internal fun MediaRow(
|
||||
}
|
||||
}
|
||||
|
||||
val focused = remember(item, index) {
|
||||
// Focus can enrich the same item with metadata. Key these handlers by
|
||||
// identity rather than the whole wire object so that an enrichment
|
||||
// update does not rebuild every callback in the visible row.
|
||||
val currentItem by rememberUpdatedState(item)
|
||||
val focused = remember(item.id, index) {
|
||||
{
|
||||
currentOnContentFocused()
|
||||
currentOnItemFocused(item, index)
|
||||
currentOnItemFocused(currentItem, index)
|
||||
}
|
||||
}
|
||||
val onClick = remember(item) { { currentOnItemSelected(item) } }
|
||||
val onLongClick = remember(item) { { currentOnItemLongPressed(item) } }
|
||||
val onClick = remember(item.id) { { currentOnItemSelected(currentItem) } }
|
||||
val onLongClick = remember(item.id) {
|
||||
{ currentOnItemLongPressed(currentItem) }
|
||||
}
|
||||
|
||||
val format = remember(row.kind, item, artworkStyle) {
|
||||
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle) {
|
||||
cardFormat(row.kind, item, artworkStyle)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -256,6 +257,7 @@ private fun CastMemberCard(
|
||||
.fillMaxHeight()
|
||||
.detailCardFocus(focused)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.focusable()
|
||||
.clickable(onClick = onClick)
|
||||
.testTag("guest-cast-${member.name}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -282,6 +284,7 @@ private fun CastMemberCard(
|
||||
.width(cardWidth)
|
||||
.detailCardFocus(focused)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.focusable()
|
||||
.clickable(onClick = onClick)
|
||||
.testTag("cast-member-${member.name}"),
|
||||
) {
|
||||
|
||||
@@ -72,15 +72,19 @@ class HomeMovieHeroScreenshotTest {
|
||||
@Test
|
||||
fun `hero focus treatment matches featured and mini cards`() {
|
||||
setHeroContent(movies)
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithContentDescription("Featured, NEW RELEASE, The Last Horizon")
|
||||
.requestFocus()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithContentDescription("Featured, NEW RELEASE, The Last Horizon")
|
||||
.assertIsFocused()
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/home-movie-hero/featured-focused.png",
|
||||
)
|
||||
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithContentDescription("POPULAR movie, Midnight Signal")
|
||||
.assertIsFocused()
|
||||
compose.onRoot().captureRoboImage(
|
||||
|
||||
@@ -3,11 +3,11 @@ package com.ponzischeme89.memby.ui.player
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertDoesNotExist
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
@@ -49,10 +49,12 @@ class CastPanelScreenshotTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag("cast-member-Ulrich Mühe").assertIsFocused()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-with-guests.png")
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("guest-cast-Marie Gruber").assertIsFocused()
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ class CastPanelScreenshotTest {
|
||||
),
|
||||
)
|
||||
|
||||
compose.onNodeWithText("GUEST CAST").assertDoesNotExist()
|
||||
compose.onAllNodesWithText("GUEST CAST").assertCountEquals(0)
|
||||
compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-regular.png")
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,24 @@ class HomeBenchmark {
|
||||
measureBlock = { exerciseHome() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Keeps the remote moving long enough to expose allocation, image-cache and GC churn
|
||||
* that a single trip through the first four shelves can hide. Run this on a constrained
|
||||
* television as the sustained-navigation gate for Home, Shows and Movies rows.
|
||||
*/
|
||||
@Test
|
||||
fun sustainedHomeDpadNavigation() = benchmarkRule.measureRepeated(
|
||||
packageName = PACKAGE_NAME,
|
||||
metrics = listOf(FrameTimingMetric()),
|
||||
compilationMode = CompilationMode.Partial(),
|
||||
startupMode = StartupMode.WARM,
|
||||
iterations = 3,
|
||||
setupBlock = { startActivityAndWait() },
|
||||
measureBlock = {
|
||||
repeat(SUSTAINED_CYCLES) { exerciseHome() }
|
||||
},
|
||||
)
|
||||
|
||||
private fun MacrobenchmarkScope.exerciseHome() {
|
||||
// Do not wait between presses: a real performance problem is most visible while a
|
||||
// viewer holds the remote and focus animations, lazy composition and image decode
|
||||
@@ -91,5 +109,6 @@ class HomeBenchmark {
|
||||
const val HORIZONTAL_PRESSES = 12
|
||||
const val HORIZONTAL_PRESSES_PER_ROW = 6
|
||||
const val VERTICAL_PRESSES = 4
|
||||
const val SUSTAINED_CYCLES = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.68
|
||||
0.1.69
|
||||
|
||||
Reference in New Issue
Block a user