diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b3bd7a1..82edfcd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index c1fb195..22a9e6a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -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) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index ea18c97..dd164fe 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 7656d4f..8c9fbcf 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -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, Any> = listSaver( save = { states -> states.flatMap { (key, state) -> @@ -188,13 +197,18 @@ private val LazyListStateMapSaver: Saver, 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(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 { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index d6b044f..acf372c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -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(initialFocusedItem(_state.value)) val focusedItem: StateFlow = _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 = _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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index f94a7ad..d56c051 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt index 4f47c2a..3905688 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt @@ -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() } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt index 7e77974..ee8608b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt index 4a04c06..2e8fc69 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt @@ -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(), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt index 85d708b..52f05ab 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt @@ -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) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt index 1995280..50f8ea0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt @@ -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}"), ) { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt index d2d1e1e..61c9a07 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt @@ -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( diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt index 778dc35..ca450f5 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt @@ -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") } diff --git a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt index 13fb7b7..f936598 100644 --- a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt +++ b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt @@ -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 } } diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 72052e2..9adbab1 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.68 +0.1.69