0.3.13
This commit is contained in:
@@ -62,7 +62,7 @@ val projectNoticeText =
|
|||||||
rootProject.file("NOTICE").readText()
|
rootProject.file("NOTICE").readText()
|
||||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||||
|
|
||||||
val defaultVersionName = "0.3.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 @@
|
|||||||
0.1.68
|
0.1.69
|
||||||
|
|||||||
Reference in New Issue
Block a user