This commit is contained in:
ponzischeme89
2026-08-23 20:25:20 +12:00
parent 5107c4b0fc
commit 6ba8728dce
15 changed files with 185 additions and 96 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.12" val defaultVersionName = "0.3.13"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -289,6 +289,8 @@ internal fun HomeMovieHero(
BoxWithConstraints(modifier.fillMaxWidth()) { BoxWithConstraints(modifier.fillMaxWidth()) {
val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp) val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp)
val minis = movies.drop(1).take(3)
val firstMiniFocusRequester = remember { FocusRequester() }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -310,6 +312,7 @@ internal fun HomeMovieHero(
.fillMaxHeight() .fillMaxHeight()
.focusProperties { .focusProperties {
left = navigationFocusRequester left = navigationFocusRequester
if (minis.isNotEmpty()) right = firstMiniFocusRequester
if (downFocusRequester != null) down = downFocusRequester if (downFocusRequester != null) down = downFocusRequester
} }
if (contentEntryFocusRequester != null) { if (contentEntryFocusRequester != null) {
@@ -330,9 +333,11 @@ internal fun HomeMovieHero(
modifier = Modifier.width(miniWidth).fillMaxHeight(), modifier = Modifier.width(miniWidth).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
val minis = movies.drop(1).take(3)
minis.forEachIndexed { index, pick -> minis.forEachIndexed { index, pick ->
var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth() 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 // Only the bottom mini leaves the hero by Down; the ones above it are
// still walking their own column. // still walking their own column.
if (downFocusRequester != null && index == minis.lastIndex) { if (downFocusRequester != null && index == minis.lastIndex) {
@@ -279,14 +279,7 @@ internal fun HomeArtworkPreloader(
@Composable @Composable
internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val settledItem by homeViewModel.settledFocusedItem.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
}
BackdropLayer(item = settledItem, modifier = Modifier.fillMaxSize()) BackdropLayer(item = settledItem, modifier = Modifier.fillMaxSize())
} }
@@ -298,12 +291,7 @@ internal fun FocusedHomeMetadata(
isContinueWatchingItem: Boolean, isContinueWatchingItem: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val settledItem by homeViewModel.settledFocusedItem.collectAsStateWithLifecycle()
var settledItem by remember { mutableStateOf(focusedItem) }
LaunchedEffect(focusedItem) {
delay(HOME_HERO_UPDATE_DEBOUNCE_MS)
settledItem = focusedItem
}
val loading by homeViewModel.loading.collectAsStateWithLifecycle() val loading by homeViewModel.loading.collectAsStateWithLifecycle()
MetadataHero( MetadataHero(
item = settledItem, 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: * 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
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable 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) (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( private val LazyListStateMapSaver: Saver<MutableMap<String, LazyListState>, Any> = listSaver(
save = { states -> save = { states ->
states.flatMap { (key, state) -> states.flatMap { (key, state) ->
@@ -188,13 +197,18 @@ private val LazyListStateMapSaver: Saver<MutableMap<String, LazyListState>, Any>
values.chunked(3).forEach { saved -> values.chunked(3).forEach { saved ->
put( put(
saved[0] as String, 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 @Composable
internal fun HomeScreen( internal fun HomeScreen(
settings: Settings, settings: Settings,
@@ -473,7 +487,10 @@ internal fun HomeScreen(
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) 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. // 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. // The list owns the scroll state, so it also owns the actual restoration below.
var rowListFocusRestoreRequest by remember { mutableStateOf(0) } var rowListFocusRestoreRequest by remember { mutableStateOf(0) }
@@ -916,10 +933,16 @@ internal fun HomeScreen(
val firstItem = contextualHeroItems.firstOrNull()?.item val firstItem = contextualHeroItems.firstOrNull()?.item
?: rows.firstNotNullOfOrNull { it.items.firstOrNull() } ?: rows.firstNotNullOfOrNull { it.items.firstOrNull() }
firstItem?.let(homeViewModel::focusItem) firstItem?.let(homeViewModel::focusItem)
if (firstItem != null && !initialFocusRequested) { 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) delay(16.milliseconds)
if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) { if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) {
initialFocusRequested = true initialFocusDestination = selectedDestination
return@LaunchedEffect
}
} }
} }
} }
@@ -1215,7 +1238,7 @@ internal fun HomeScreen(
FocusedHomeBackdrop(homeViewModel) FocusedHomeBackdrop(homeViewModel)
val verticalState = verticalStates.getOrPut(selectedDestination.name) { val verticalState = verticalStates.getOrPut(selectedDestination.name) {
LazyListState() LazyListState(prefetchStrategy = HomeLazyListPrefetchStrategy)
} }
val homeListAtTop by remember(verticalState) { val homeListAtTop by remember(verticalState) {
derivedStateOf { derivedStateOf {
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -165,6 +166,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value)) private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow() 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 // 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 // stream lets those copies reflect a heart press immediately, including a rollback
// when the server rejects it, without refreshing or losing the active category. // 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 { companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L 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 * 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.input.key.type
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.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
@@ -48,6 +50,7 @@ import androidx.compose.ui.zIndex
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.model.MyShow import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOnSurface
@@ -153,7 +156,11 @@ internal fun MyShowsStrip(
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp), contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(shows, key = MyShow::itemId) { show -> items(
items = shows,
key = MyShow::itemId,
contentType = { "my-show" },
) { show ->
MyShowCard( MyShowCard(
show = show, show = show,
repository = repository, repository = repository,
@@ -209,10 +216,26 @@ private fun MyShowCard(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, 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( FocusScaleContainer(
onFocused = {}, onFocused = {},
onClick = onClick, onClick = onClick,
contentDescription = "${show.title}, ${myShowCardSubtitle(show)}", contentDescription = "${show.title}, $subtitle",
modifier = modifier.width(width), modifier = modifier.width(width),
) { focused -> ) { focused ->
Column { Column {
@@ -231,12 +254,12 @@ private fun MyShowCard(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
AsyncImage( AsyncImage(
model = repository.myShowImageUrl(show.itemId, show.imageTag), model = imageRequest,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
myShowBadge(show)?.let { (status, label) -> badge?.let { (status, label) ->
LifecycleBadge( LifecycleBadge(
status = status, status = status,
label = label, label = label,
@@ -254,7 +277,7 @@ private fun MyShowCard(
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
) )
Text( Text(
myShowCardSubtitle(show), subtitle,
color = MembyQuietText, color = MembyQuietText,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.res.painterResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onLongClick 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.semantics.semantics
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
@@ -127,6 +130,7 @@ fun FocusScaleContainer(
contentDescription: String, contentDescription: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
focusedContent: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable BoxScope.(focused: Boolean) -> Unit, content: @Composable BoxScope.(focused: Boolean) -> Unit,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
@@ -156,12 +160,14 @@ fun FocusScaleContainer(
focused = it.isFocused focused = it.isFocused
if (it.isFocused) onFocused() if (it.isFocused) onFocused()
} }
.focusable()
// The hold, the swallowed repeats and the cancel-on-focus-loss are // 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 // [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. // of press that opens a menu is the same wherever a menu can be opened.
.remoteLongPress(onClick = onClick, onLongClick = onLongClick) .remoteLongPress(onClick = onClick, onLongClick = onLongClick)
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
this.contentDescription = contentDescription this.contentDescription = contentDescription
this.role = Role.Button
if (onLongClick != null) { if (onLongClick != null) {
onLongClick("Quick actions") { onLongClick("Quick actions") {
onLongClick() 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.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.Key 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.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -89,7 +86,6 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
@@ -142,35 +138,6 @@ fun MediaBadge(
fontSize: TextUnit = 11.sp, fontSize: TextUnit = 11.sp,
lineHeight: TextUnit = TextUnit.Unspecified, 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( Text(
label, label,
color = MembyOnSurface, color = MembyOnSurface,
@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -43,6 +42,7 @@ import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.detail.formatRuntime 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.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
@@ -271,33 +271,36 @@ internal fun MediaCard(
.build() .build()
} }
} }
FocusScaleContainer( val cardShape = remember { RoundedCornerShape(MembyCardCorner) }
PosterFocusScaleContainer(
onFocused = onFocused, onFocused = onFocused,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
contentDescription = model.contentDescription, contentDescription = model.contentDescription,
modifier = modifier.width(width), 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 { Column {
Box( Box(
Modifier Modifier
.width(width) .width(width)
.aspectRatio(aspectRatio) .aspectRatio(aspectRatio)
// Keep the modifier node stable while focus changes. Adding/removing .clip(cardShape)
// 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))
.background(MembySurfaceRaised) .background(MembySurfaceRaised)
.border( // The focus border is drawn by the overlay, so the poster body keeps a
if (focused) 1.5.dp else 1.dp, // stable modifier chain and focus does not rebuild its content.
if (focused) Color.White.copy(alpha = 0.76f) else Color.White.copy(alpha = 0.06f), .border(1.dp, Color.White.copy(alpha = 0.06f), cardShape),
RoundedCornerShape(MembyCardCorner),
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (loading) { if (loading) {
@@ -382,15 +385,12 @@ internal fun MediaCard(
modifier = Modifier.align(Alignment.TopStart).padding(8.dp), modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
) )
} }
if (focused) {
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
}
} }
Text( Text(
model.name, model.name,
color = if (focused) Color.White else MembyOnSurface, color = MembyOnSurface,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 9.dp).fillMaxWidth(), modifier = Modifier.padding(top = 9.dp).fillMaxWidth(),
@@ -190,7 +190,11 @@ internal fun MediaRow(
), ),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(count = 5) { items(
count = 5,
key = { index -> "loading-$index" },
contentType = { "loading" },
) {
TvLoadingPlaceholder( TvLoadingPlaceholder(
portrait = isPortrait, portrait = isPortrait,
availableWidth = availableWidth, 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() currentOnContentFocused()
currentOnItemFocused(item, index) currentOnItemFocused(currentItem, index)
} }
} }
val onClick = remember(item) { { currentOnItemSelected(item) } } val onClick = remember(item.id) { { currentOnItemSelected(currentItem) } }
val onLongClick = remember(item) { { currentOnItemLongPressed(item) } } 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) cardFormat(row.kind, item, artworkStyle)
} }
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -256,6 +257,7 @@ private fun CastMemberCard(
.fillMaxHeight() .fillMaxHeight()
.detailCardFocus(focused) .detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused } .onFocusChanged { focused = it.isFocused }
.focusable()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.testTag("guest-cast-${member.name}"), .testTag("guest-cast-${member.name}"),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -282,6 +284,7 @@ private fun CastMemberCard(
.width(cardWidth) .width(cardWidth)
.detailCardFocus(focused) .detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused } .onFocusChanged { focused = it.isFocused }
.focusable()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.testTag("cast-member-${member.name}"), .testTag("cast-member-${member.name}"),
) { ) {
@@ -72,15 +72,19 @@ class HomeMovieHeroScreenshotTest {
@Test @Test
fun `hero focus treatment matches featured and mini cards`() { fun `hero focus treatment matches featured and mini cards`() {
setHeroContent(movies) setHeroContent(movies)
compose.waitForIdle()
compose.onNodeWithContentDescription("Featured, NEW RELEASE, The Last Horizon") compose.onNodeWithContentDescription("Featured, NEW RELEASE, The Last Horizon")
.requestFocus() .requestFocus()
compose.waitForIdle()
compose.onNodeWithContentDescription("Featured, NEW RELEASE, The Last Horizon")
.assertIsFocused() .assertIsFocused()
compose.onRoot().captureRoboImage( compose.onRoot().captureRoboImage(
"build/screenshots/home-movie-hero/featured-focused.png", "build/screenshots/home-movie-hero/featured-focused.png",
) )
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
compose.onNodeWithContentDescription("POPULAR movie, Midnight Signal") compose.onNodeWithContentDescription("POPULAR movie, Midnight Signal")
.assertIsFocused() .assertIsFocused()
compose.onRoot().captureRoboImage( compose.onRoot().captureRoboImage(
@@ -3,11 +3,11 @@ package com.ponzischeme89.memby.ui.player
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key 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.assertIsFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag 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.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
@@ -49,10 +49,12 @@ class CastPanelScreenshotTest {
), ),
), ),
) )
compose.waitForIdle()
compose.onNodeWithTag("cast-member-Ulrich Mühe").assertIsFocused() compose.onNodeWithTag("cast-member-Ulrich Mühe").assertIsFocused()
compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-with-guests.png") compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-with-guests.png")
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
compose.onNodeWithTag("guest-cast-Marie Gruber").assertIsFocused() 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") compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-regular.png")
} }
@@ -63,6 +63,24 @@ class HomeBenchmark {
measureBlock = { exerciseHome() }, 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() { private fun MacrobenchmarkScope.exerciseHome() {
// Do not wait between presses: a real performance problem is most visible while a // 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 // 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 = 12
const val HORIZONTAL_PRESSES_PER_ROW = 6 const val HORIZONTAL_PRESSES_PER_ROW = 6
const val VERTICAL_PRESSES = 4 const val VERTICAL_PRESSES = 4
const val SUSTAINED_CYCLES = 8
} }
} }
+1 -1
View File
@@ -1 +1 @@
0.1.68 0.1.69