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