0.3.37
This commit is contained in:
@@ -855,6 +855,41 @@ at a time. Things to preserve:
|
||||
results; a genre result claims its own requester when the first page lands, Back restores
|
||||
the strip, and an empty or failed genre hands focus back deterministically rather than
|
||||
leaving a television with nothing focused.
|
||||
- **The four focus zones are one graph, and it is a pure function.** `ui/genre/GenreNavigation.kt`
|
||||
— `GenreZone` (`APP_RAIL` / `SERVICE_FILTERS` / `GENRE_LIST` / `RESULTS_GRID`), `ResultsOrigin`,
|
||||
and `genreFocusMove(zone, key, ctx)` returning a `GenreFocusTarget` — is the whole of the
|
||||
zone-boundary behaviour, unit-tested exhaustively (`GenreNavigationTest`) the way `detailTabs`
|
||||
and `calendarMonthFocusAnchor` are. Interior travel (the next icon, genre, poster) stays with
|
||||
Compose's per-item `focusProperties` and comes back as `Fallthrough`. `GenreBrowseScreen` was
|
||||
split into `GenreBrowseScreen` (view-model glue) and `GenreBrowseContent` (rendering + the
|
||||
graph), the `SettingsPanelContent` precedent, so `GenreBrowserFocusTest` drives the regression
|
||||
scenarios through real key events with a canned `GenreBrowseUiState`. Things to preserve:
|
||||
- **The grid follows the committed filter, not the D-pad.** Moving the marker across the
|
||||
rail or the services strip only changes the highlight (`activeCategoryId`); the results
|
||||
pane, its heading and the rail's own accent wash stay on `state.selectedCategoryId`,
|
||||
which changes only on OK or Right-into-grid. This is the contract's focus-vs-selection
|
||||
split, and it is the opposite of the old rule (which recomposed the pane on every focus
|
||||
move). `GenreRailItem`/`ServiceIconButton` draw the bright plate/ring from `hasFocus`
|
||||
and the quiet wash from the committed id. The first commit is seeded once from the
|
||||
filter the screen opens on, since a fresh browser has nothing selected.
|
||||
- **OK on a service or genre filters *and* enters the grid.** The grid can only be
|
||||
focused once its first page has arrived, so the press is held in `pendingGridEntry`
|
||||
and acted on when the results land — and dropped if they land empty, because focus must
|
||||
never move into an empty grid. Right out of a genre commits it and enters its grid;
|
||||
Right off the services strip's last icon is a predictable dead edge, never a jump.
|
||||
- **Left off the grid's first column returns to the *originating* filter**, tracked as
|
||||
`ResultsOrigin` rather than inferred from which focusable sits nearest the edge. Enter a
|
||||
Netflix-filtered grid and Left comes back to Netflix; enter it from Drama and Left comes
|
||||
back to Drama.
|
||||
- **The Services strip walks with an `onKeyEvent` on the `LazyRow`, not per-icon
|
||||
`focusProperties`.** More services than fit the 214dp rail is a matter of time, and an
|
||||
off-screen icon has no attached requester for a `focusProperties` target to name — the
|
||||
press scrolls it into composition first, the `GenreRail` Right-into-grid precedent. Right
|
||||
off the last icon is a predictable dead edge; a service never enters the grid by a Right
|
||||
press.
|
||||
- **Re-entry restores the last provider/genre and zone** via `GenreBrowseFocusMemory`
|
||||
(process-scoped, the `DetailPositionStore` precedent; deliberately not persisted — a set
|
||||
switched on the next morning opens at the top of the catalogue).
|
||||
- **Episodes are excluded on both paths.** An episode inherits its series' genres, so
|
||||
including them fills a page with twenty entries of one comedy and buries the rest.
|
||||
- **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful
|
||||
@@ -2778,15 +2813,33 @@ recompositions of one number over ten seconds instead of ~600 of the whole bar.
|
||||
rule applies to collecting flows — collect in the smallest composable that needs the value,
|
||||
not at the top of `MainActivity`, or every emission recomposes the launcher.
|
||||
|
||||
**Where a composable is too large to split, narrow the state instead.** `HomeScreen` is
|
||||
the case: it cannot reasonably collect `HomeUiState` in one place, because reading the
|
||||
whole object there meant an arriving update verdict, a slow-connection banner or any one
|
||||
of the four section loads invalidated the launcher *and* rebuilt every row with it. So
|
||||
`HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and
|
||||
their loading flags), `status` (connection health) and `appUpdate` — and the screen
|
||||
subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather
|
||||
than introducing a separate type, which is what lets `homeRowsFor` keep taking a
|
||||
`HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it.
|
||||
**`HomeScreen` is split three ways, and it had to be.** It was one ~2670-line `@Composable`
|
||||
that exceeded ART's method compiler limit (20 926 instructions vs the 10 000 optimiser
|
||||
cutoff) — so it ran **fully interpreted** — and every rail-section switch or overlay toggle
|
||||
re-executed the whole tree, freezing a Chromecast for 1–2 s per press. It is now
|
||||
`HomeScreen` (view models, effects, the rail) → `HomeContentPane` (`HomeContentPane.kt`, the
|
||||
per-destination panes and row/hero machinery) and `HomeOverlays` (`HomeOverlayHost.kt`, the
|
||||
whole overlay stack: Settings, Profiles, the user picker, the detail page, My Shows,
|
||||
Requests, Alerts, the quick-actions menu, the loading overlay, the top banners), plus
|
||||
`rememberHomePlayback` (`HomePlayback.kt`, the `ActivityResultLauncher` and stream
|
||||
resolution). Things to preserve:
|
||||
- **Shared launcher state is `HomeScreenState`** (`HomeScreenState.kt`), a single `@Stable`
|
||||
holder `rememberSaveable`d keyed on `settings.userId` (matching the `remember(userId)`
|
||||
reset semantics it replaces; a `Saver` persists only the navigation fields). Passing it as
|
||||
one stable param is what keeps `HomeContentPane` and `HomeOverlays` independently
|
||||
skippable — each recomposes only for the properties it reads. Do not go back to threading
|
||||
40 value/setter pairs, and do not read the whole holder anywhere it isn't needed.
|
||||
- **The content area is not wrapped in `BoxWithConstraints`.** That was a `SubcomposeLayout`
|
||||
around the entire pane, so a section switch re-composed it *during the layout pass*
|
||||
(measure → recompose → measure) — 1.4 s of one frame on the Chromecast. Screen size comes
|
||||
from `LocalConfiguration` instead (`screenWidthDp - TvRailCollapsedWidth`); the window is
|
||||
the screen on a TV.
|
||||
- **Narrow the projection, not the split.** `HomeViewModel` still exposes
|
||||
`distinctUntilChanged` projections — `content` (rows + loading flags), `status`
|
||||
(connection health), `forYou`, `favoriteChanges`, `playedChanges` — collected where they
|
||||
are rendered so an arriving update verdict or a slow-connection banner does not rebuild
|
||||
every row. `contentSlice()` blanks the non-row fields rather than adding a type, so
|
||||
`homeRowsFor` keeps taking a `HomeUiState` and its tests keep working.
|
||||
|
||||
**Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read:
|
||||
`MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/
|
||||
|
||||
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.35"
|
||||
val defaultVersionName = "0.3.37"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
import com.ponzischeme89.memby.ui.calendar.CalendarScreen
|
||||
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
|
||||
import com.ponzischeme89.memby.ui.detail.scheduleMovieStub
|
||||
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
|
||||
import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID
|
||||
import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_ITEM_TYPE
|
||||
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
||||
import com.ponzischeme89.memby.ui.search.SearchScreen
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The per-destination content area beside the navigation rail: the maintenance screen, the
|
||||
* Search / Calendar / Genres panes, and the Home / Movies / TV Shows / For You row lists
|
||||
* with their hero and focus machinery.
|
||||
*
|
||||
* Extracted from `HomeScreen` (one ~2670-line @Composable, over ART's method compiler limit
|
||||
* and interpreted) so switching rail section recomposes this subtree rather than the whole
|
||||
* launcher including every overlay. Shared state is [HomeScreenState]; see its KDoc.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HomeContentPane(
|
||||
homeState: HomeScreenState,
|
||||
settings: Settings,
|
||||
remoteConfig: MembyRemoteConfig,
|
||||
homeViewModel: HomeViewModel,
|
||||
repo: EmbyRepository,
|
||||
scope: CoroutineScope,
|
||||
profileViewModelOwner: androidx.lifecycle.ViewModelStoreOwner,
|
||||
homeContent: HomeUiState,
|
||||
homeStatus: HomeStatus,
|
||||
forYouState: ForYouUiState,
|
||||
favoriteChanges: Map<String, Boolean>,
|
||||
playedChanges: Map<String, Boolean>,
|
||||
liveMaintenance: com.ponzischeme89.memby.data.MaintenanceNotice?,
|
||||
rows: List<HomeBrowseRow>,
|
||||
contextualHeroItems: List<HomeHeroPick>,
|
||||
metadataHeroContentOrder: List<String>,
|
||||
metadataHeroTimeRemainingColour: String,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
cardReturnFocusRequester: FocusRequester,
|
||||
heroRowEntryFocusRequester: FocusRequester,
|
||||
myShowsEntryFocusRequester: FocusRequester,
|
||||
myShowReturnFocusRequester: FocusRequester,
|
||||
verticalStates: MutableMap<String, LazyListState>,
|
||||
horizontalStates: MutableMap<String, LazyListState>,
|
||||
rememberedRowFocus: MutableMap<BrowseDestination, MutableMap<String, Int>>,
|
||||
destinationFocus: MutableMap<BrowseDestination, Pair<String, String>>,
|
||||
playItem: (BaseItem) -> Unit,
|
||||
) {
|
||||
val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage
|
||||
if (maintenanceMessage != null) {
|
||||
// The rows are gone but the rail is not: Settings and the active user are
|
||||
// local, so there is no reason to strand the viewer here.
|
||||
MaintenanceScreen(
|
||||
message = maintenanceMessage,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onRetry = homeViewModel::refreshAll,
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
delay(450.milliseconds)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (homeState.selectedDestination == BrowseDestination.SEARCH) {
|
||||
// Remembered, or this list is a fresh instance on every recomposition
|
||||
// and the screen re-derives its genre chips each time.
|
||||
val discovery = remember(homeContent.rows, homeContent.latestMovies, homeContent.continueWatching) {
|
||||
homeContent.rows.flatMap { it.items }
|
||||
.ifEmpty { homeContent.continueWatching + homeContent.latestMovies }
|
||||
.distinctBy(BaseItem::id)
|
||||
}
|
||||
// Its own pane rather than a row list: the keyboard and the results
|
||||
// need the whole content area. The rail stays beside it, and results
|
||||
// open the same details overlay the rows do.
|
||||
SearchScreen(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
// Already in memory from the home response — the empty state
|
||||
// costs nothing to fill.
|
||||
discoveryItems = discovery,
|
||||
returnFocusItemId = homeState.returnItemId.takeIf { homeState.returnRowId == SEARCH_ROW_ID },
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
// Scoped to this profile/viewer rather than the Activity's default
|
||||
// owner: SearchViewModel's query cache and on-screen results are one
|
||||
// person's search state, and profileViewModelOwner is cleared on
|
||||
// every switchProfile/switchViewer, so the next person opens Search
|
||||
// on a fresh instance instead of inheriting the previous one's.
|
||||
viewModelStoreOwner = profileViewModelOwner,
|
||||
onSearchStarted = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "search", action = "start", screen = "search",
|
||||
feature = "search", source = "search", target = "search_results",
|
||||
)
|
||||
},
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
homeState.returnRowId = SEARCH_ROW_ID
|
||||
homeState.returnRowKind = null
|
||||
homeState.returnItemId = item.id
|
||||
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
|
||||
homeViewModel.focusItem(item)
|
||||
if (item.membyPlayable) {
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "search",
|
||||
feature = "search", source = "search_results", target = "details",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
homeState.detailsAiringNotice = null
|
||||
homeState.detailsFromSearch = true
|
||||
homeState.detailsItem = item
|
||||
}
|
||||
},
|
||||
onContentFocused = { homeState.navigationExpanded = false },
|
||||
onExit = {
|
||||
homeState.selectedDestination = BrowseDestination.HOME
|
||||
scope.launch {
|
||||
delay(16.milliseconds)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (homeState.selectedDestination == BrowseDestination.CALENDAR) {
|
||||
// Its own pane rather than a row list: a month grid needs the whole
|
||||
// content area, and there is no shelf shape that answers "what is on
|
||||
// in three weeks". The rail stays beside it as it does for Search.
|
||||
CalendarScreen(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "calendar",
|
||||
feature = "tv_calendar", source = "calendar_day",
|
||||
target = "details", itemName = item.name, itemType = item.type,
|
||||
)
|
||||
// The same substitution the schedule row makes: a calendar card
|
||||
// is an episode that has not aired, so what was asked for is the
|
||||
// show, carrying the air time across because that is why it was
|
||||
// pressed.
|
||||
val seriesStub = scheduleSeriesStub(item)
|
||||
homeState.detailsFromSearch = false
|
||||
if (seriesStub != null) {
|
||||
homeState.detailsAiringNotice = airingNoticeFor(item)
|
||||
homeViewModel.focusItem(seriesStub)
|
||||
homeState.detailsItem = seriesStub
|
||||
} else if (item.membyPlayable) {
|
||||
homeState.detailsAiringNotice = null
|
||||
homeViewModel.focusItem(item)
|
||||
homeState.detailsItem = item
|
||||
}
|
||||
},
|
||||
onExit = {
|
||||
homeState.selectedDestination = BrowseDestination.HOME
|
||||
scope.launch {
|
||||
delay(16.milliseconds)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The rail's own Genres destination browses the catalogue, films and shows
|
||||
// together — a household browses "Comedy", not "comedy films". It is the
|
||||
// one way in: the Movies and TV Series pages carry no genre row of their
|
||||
// own, so neither of those grids can cross media types.
|
||||
if (homeState.selectedDestination == BrowseDestination.GENRES) {
|
||||
GenreBrowseScreen(
|
||||
itemType = ALL_MEDIA_ITEM_TYPE,
|
||||
initialCategoryId = ALL_MEDIA_CATEGORY_ID,
|
||||
favouriteStates = favoriteChanges,
|
||||
playedStates = playedChanges,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = homeState.returnItemId.takeIf { homeState.returnRowId == GENRE_BROWSER_ROW_ID },
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
homeState.returnRowId = GENRE_BROWSER_ROW_ID
|
||||
homeState.returnRowKind = null
|
||||
homeState.returnItemId = item.id
|
||||
destinationFocus[BrowseDestination.GENRES] =
|
||||
GENRE_BROWSER_ROW_ID to item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "genres",
|
||||
feature = "genre_browse", source = "genre_results", target = "details",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
homeState.detailsAiringNotice = null
|
||||
homeState.detailsFromSearch = false
|
||||
homeState.detailsItem = item
|
||||
},
|
||||
onContentFocused = { homeState.navigationExpanded = false },
|
||||
onClose = {
|
||||
homeState.selectedDestination = BrowseDestination.HOME
|
||||
homeState.railFocusDestination = BrowseDestination.HOME
|
||||
scope.launch {
|
||||
delay(16.milliseconds)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
FocusedHomeBackdrop(homeViewModel)
|
||||
val verticalState = verticalStates.getOrPut(homeState.selectedDestination.name) {
|
||||
LazyListState(prefetchStrategy = HomeLazyListPrefetchStrategy)
|
||||
}
|
||||
val homeListAtTop by remember(verticalState) {
|
||||
derivedStateOf {
|
||||
verticalState.firstVisibleItemIndex == 0 &&
|
||||
verticalState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
val hasContextualHero = contextualHeroItems.isNotEmpty()
|
||||
// Set by a card in a shelf taking focus and cleared by the hero taking it
|
||||
// back. Keyed on the destination so arriving at Home never inherits where
|
||||
// focus happened to be on another one.
|
||||
var rowFocusedBelowHero by remember(homeState.selectedDestination) { mutableStateOf(false) }
|
||||
val showHomeHero = shouldShowHomeMovieHero(
|
||||
hasMovies = hasContextualHero,
|
||||
listAtTop = homeListAtTop,
|
||||
rowFocused = rowFocusedBelowHero,
|
||||
)
|
||||
// Screen size from the configuration rather than a wrapping BoxWithConstraints: that
|
||||
// was a SubcomposeLayout around the whole content area, so every rail-section switch
|
||||
// re-composed this pane *during the layout pass* — measure → recompose → measure — and
|
||||
// a Chromecast spent 1.4 s of one frame on it. The window is the screen on a TV, and
|
||||
// the rail sits in a Row taking its collapsed width off the content column.
|
||||
val configuration = LocalConfiguration.current
|
||||
val metadataHeight = homeHeaderHeight(configuration.screenHeightDp.dp, showHomeHero)
|
||||
val contentWidth = configuration.screenWidthDp.dp - TvRailCollapsedWidth
|
||||
HomeArtworkPreloader(rows = rows, availableWidth = contentWidth)
|
||||
val rowFocusPositions = rememberedRowFocus.getOrPut(homeState.selectedDestination) {
|
||||
mutableMapOf()
|
||||
}
|
||||
val rowIndexes = remember(rows) {
|
||||
rows.mapIndexed { index, row -> row.id to index }.toMap()
|
||||
}
|
||||
val rowItemCounts = remember(rows) { rows.map { it.items.size } }
|
||||
var pendingRowFocus by remember(homeState.selectedDestination) {
|
||||
mutableStateOf<RowFocusRequest?>(null)
|
||||
}
|
||||
var rowFocusMoving by remember(homeState.selectedDestination) { mutableStateOf(false) }
|
||||
var rowFocusRequestId by remember(homeState.selectedDestination) { mutableStateOf(0) }
|
||||
// The latch above is what stops a held D-pad stacking one move on top of
|
||||
// another, and until now the only thing that lifted it was the destination
|
||||
// row reporting the request consumed. A request nothing consumes therefore
|
||||
// took every later Up and Down press with it — the row list stopped moving
|
||||
// where it stood and the only way out was the rail. Nothing may consume it
|
||||
// when the destination row was not composed by the scroll, when that scroll
|
||||
// was interrupted by another one, or when the row is replaced by an arriving
|
||||
// refresh while the request is in flight. So the latch is bounded rather
|
||||
// than trusted: whatever happened, one press can hold vertical navigation
|
||||
// for ROW_FOCUS_MOVE_TIMEOUT and no longer.
|
||||
LaunchedEffect(pendingRowFocus?.requestId, rowFocusMoving) {
|
||||
if (!rowFocusMoving) return@LaunchedEffect
|
||||
delay(ROW_FOCUS_MOVE_TIMEOUT)
|
||||
pendingRowFocus = null
|
||||
rowFocusMoving = false
|
||||
}
|
||||
val firstPopulatedRowId = remember(rows) {
|
||||
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
|
||||
}
|
||||
// The metadata panel only needs to know whether the focused row is
|
||||
// Continue Watching, and that answer changes when the viewer crosses into
|
||||
// or out of one row. Reading focusedHomeRowId where the panel is composed
|
||||
// read it in the scope that also declares the LazyColumn below, so every
|
||||
// vertical press invalidated the whole launcher and re-declared the row
|
||||
// list. Derived, the scope is invalidated only when the boolean actually
|
||||
// flips.
|
||||
val focusedRowIsContinueWatching by remember(rows) {
|
||||
derivedStateOf {
|
||||
rows.firstOrNull { it.id == homeState.focusedHomeRowId }?.kind ==
|
||||
MediaRowKind.CONTINUE
|
||||
}
|
||||
}
|
||||
// My Shows is a band of its own above the shelves, so on this page it is the
|
||||
// first stop below the hero and the thing Up out of the topmost shelf has to
|
||||
// return to. Both moves used to jump over it: the hero pointed Down straight
|
||||
// at the first shelf, and Up out of that shelf found no shelf above it and
|
||||
// went to the hero.
|
||||
val myShowsStripFocusable =
|
||||
homeState.selectedDestination == BrowseDestination.SHOWS && homeState.myShows.isNotEmpty()
|
||||
// Must agree exactly with the items placed above the rows in the LazyColumn
|
||||
// below, because it is what turns a row index into a scroll target.
|
||||
// Counting one that is not placed scrolls a row short of the destination,
|
||||
// which lands the requested card outside the composed window and leaves
|
||||
// the move with nothing to complete it.
|
||||
val leadingItemCount =
|
||||
(if (homeState.selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
||||
(if (homeState.selectedDestination == BrowseDestination.FOR_YOU) 1 else 0)
|
||||
LaunchedEffect(homeState.rowListFocusRestoreRequest, homeState.selectedDestination) {
|
||||
if (homeState.rowListFocusRestoreRequest == 0) return@LaunchedEffect
|
||||
val rowId = homeState.returnRowId ?: run {
|
||||
homeState.rowListFocusRestoreRequest = 0
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val rowIndex = rows.indexOfFirst { it.id == rowId }
|
||||
if (rowIndex < 0) {
|
||||
homeState.rowListFocusRestoreRequest = 0
|
||||
delay(16.milliseconds)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// FocusRequester cannot focus a lazy child that is not composed. The
|
||||
// airing shelf is commonly just beyond the retained viewport, which
|
||||
// made focus fall back to its still-attached neighbour above.
|
||||
verticalState.scrollToItem(leadingItemCount + rowIndex)
|
||||
repeat(3) {
|
||||
delay(16.milliseconds)
|
||||
if (runCatching { cardReturnFocusRequester.requestFocus() }.isSuccess) {
|
||||
homeState.rowListFocusRestoreRequest = 0
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
homeState.rowListFocusRestoreRequest = 0
|
||||
}
|
||||
// Leaving My Shows vertically. Neither direction can be a plain
|
||||
// focusProperties target: the shelf below is a lazy child composed by the
|
||||
// scroll rather than something already there to receive the press, and the
|
||||
// hero is not composed at all while a shelf holds focus.
|
||||
val enterFirstPopulatedRow: () -> Boolean = enter@{
|
||||
if (rowFocusMoving) return@enter true
|
||||
val destinationRowIndex = rows.indexOfFirst { it.items.isNotEmpty() }
|
||||
if (destinationRowIndex < 0) return@enter false
|
||||
val destinationRow = rows[destinationRowIndex]
|
||||
val destinationItemIndex = rowEntryItemIndex(
|
||||
sourceIndex = 0,
|
||||
destinationItemCount = destinationRow.items.size,
|
||||
rememberedDestinationIndex = rowFocusPositions[destinationRow.id],
|
||||
)
|
||||
rowFocusPositions[destinationRow.id] = destinationItemIndex
|
||||
rowFocusRequestId += 1
|
||||
val request = RowFocusRequest(
|
||||
rowId = destinationRow.id,
|
||||
itemIndex = destinationItemIndex,
|
||||
requestId = rowFocusRequestId,
|
||||
)
|
||||
rowFocusMoving = true
|
||||
scope.launch {
|
||||
try {
|
||||
verticalState.animateScrollToItem(
|
||||
leadingItemCount + destinationRowIndex,
|
||||
)
|
||||
} catch (cancelled: CancellationException) {
|
||||
// Same bargain the shelves' own moves strike: ask for the card
|
||||
// anyway, and let the request expire if nothing consumes it.
|
||||
pendingRowFocus = request
|
||||
throw cancelled
|
||||
}
|
||||
pendingRowFocus = request
|
||||
}
|
||||
true
|
||||
}
|
||||
val returnToMyShows: () -> Boolean = ret@{
|
||||
if (!myShowsStripFocusable) return@ret false
|
||||
// The strip sits above the shelves with the hero, so coming back up to
|
||||
// it brings the hero back with it.
|
||||
rowFocusedBelowHero = false
|
||||
scope.launch {
|
||||
verticalState.animateScrollToItem(0)
|
||||
delay(16.milliseconds)
|
||||
runCatching { myShowsEntryFocusRequester.requestFocus() }
|
||||
}
|
||||
true
|
||||
}
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (showHomeHero) {
|
||||
HomeMovieHero(
|
||||
movies = contextualHeroItems,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentEntryFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = homeState.returnItemId.takeIf { homeState.returnRowId == HOME_HERO_ROW_ID },
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
// Only when there is a card down there to attach it to: a
|
||||
// requester naming nothing throws the moment Down is pressed.
|
||||
// My Shows first where there is one, then the first shelf with
|
||||
// cards in it. Only ever a requester with a card behind it: one
|
||||
// naming nothing throws the moment Down is pressed.
|
||||
downFocusRequester = if (myShowsStripFocusable) {
|
||||
myShowsEntryFocusRequester
|
||||
} else {
|
||||
heroRowEntryFocusRequester.takeIf { firstPopulatedRowId != null }
|
||||
},
|
||||
onItemFocused = { item ->
|
||||
homeState.navigationExpanded = false
|
||||
rowFocusedBelowHero = false
|
||||
homeState.focusedHomeRowId = null
|
||||
destinationFocus[homeState.selectedDestination] = HOME_HERO_ROW_ID to item.id
|
||||
homeState.returnRowId = HOME_HERO_ROW_ID
|
||||
homeState.returnRowKind = null
|
||||
homeState.returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
},
|
||||
onItemSelected = { item ->
|
||||
homeState.returnRowId = HOME_HERO_ROW_ID
|
||||
homeState.returnRowKind = null
|
||||
homeState.returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = homeState.selectedDestination.name.lowercase(),
|
||||
feature = "hero", source = HOME_HERO_ROW_ID, target = "details",
|
||||
itemId = item.id, itemName = item.name, itemType = item.type,
|
||||
)
|
||||
homeState.detailsAiringNotice = null
|
||||
homeState.detailsFromSearch = false
|
||||
homeState.detailsItem = item
|
||||
},
|
||||
modifier = Modifier.height(metadataHeight),
|
||||
)
|
||||
} else {
|
||||
FocusedHomeMetadata(
|
||||
homeViewModel = homeViewModel,
|
||||
metadataHeroContentOrder = metadataHeroContentOrder,
|
||||
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
|
||||
isContinueWatchingItem = focusedRowIsContinueWatching,
|
||||
modifier = Modifier
|
||||
.height(metadataHeight)
|
||||
// LazyColumn is drawn later as a sibling. Keep the hero
|
||||
// above any transient row draw overflow during D-pad moves.
|
||||
.zIndex(1f),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = homeStatus.hasRefreshError,
|
||||
enter = fadeIn(tween(180)),
|
||||
exit = fadeOut(tween(120)),
|
||||
) {
|
||||
Text(
|
||||
// The gateway's maintenance notice when it sent one, and the
|
||||
// generic wording otherwise.
|
||||
homeStatus.statusMessage ?: "Memby Server is updating/busy.... Please wait.",
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = HomeContentHorizontalInset,
|
||||
vertical = 6.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = verticalState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 156.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
if (homeState.selectedDestination == BrowseDestination.SHOWS) {
|
||||
item(key = "my-shows", contentType = "my-shows") {
|
||||
MyShowsStrip(
|
||||
shows = homeState.myShows,
|
||||
loading = homeState.myShowsLoading,
|
||||
errorMessage = homeState.myShowsError,
|
||||
onRetry = {
|
||||
if (!homeState.myShowsLoading) scope.launch {
|
||||
homeState.myShowsLoading = true
|
||||
homeState.myShowsError = null
|
||||
runCatching { repo.getMyShows() }
|
||||
.onSuccess { homeState.myShows = it }
|
||||
.onFailure { homeState.myShowsError = friendlyEmbyError(it) }
|
||||
homeState.myShowsLoading = false
|
||||
}
|
||||
},
|
||||
repository = repo,
|
||||
availableWidth = contentWidth,
|
||||
density = settings.homeCardDensity,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
// My Shows is the first reachable content on this page —
|
||||
// unless the hero is up, which owns the rail's entry
|
||||
// target the way it does on every other page. Keyed on
|
||||
// the hero actually being drawn rather than on there
|
||||
// being one: a requester attached to two live nodes is
|
||||
// ambiguous, and one attached to a hero that is not
|
||||
// composed would leave the rail with nowhere to hand
|
||||
// focus to.
|
||||
contentFocusRequester = contentFocusRequester.takeIf {
|
||||
!showHomeHero
|
||||
},
|
||||
entryFocusRequester = myShowsEntryFocusRequester,
|
||||
returnFocusItemId = homeState.myShowReturnItemId,
|
||||
returnFocusRequester = myShowReturnFocusRequester,
|
||||
onMoveVertical = { direction ->
|
||||
when (direction) {
|
||||
RowFocusDirection.DOWN -> enterFirstPopulatedRow()
|
||||
RowFocusDirection.UP -> if (hasContextualHero) {
|
||||
rowFocusedBelowHero = false
|
||||
scope.launch {
|
||||
verticalState.animateScrollToItem(0)
|
||||
// Let the hero compose and attach its
|
||||
// entry target before focus is handed up.
|
||||
delay(16.milliseconds)
|
||||
runCatching {
|
||||
contentFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
onShowSelected = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "shows",
|
||||
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
||||
itemName = it.title,
|
||||
)
|
||||
homeState.myShowReturnItemId = it.itemId
|
||||
homeState.selectedMyShow = it
|
||||
},
|
||||
onContentFocused = { homeState.navigationExpanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (homeState.selectedDestination == BrowseDestination.FOR_YOU) {
|
||||
item(key = "for-you-time", contentType = "for-you-time") {
|
||||
ForYouTimeBudget(
|
||||
selectedMinutes = forYouState.availableMinutes,
|
||||
loading = forYouState.loading,
|
||||
error = forYouState.error,
|
||||
onSelected = { minutes ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "recommendations", action = "change", screen = "for_you",
|
||||
feature = "for_you_time", outcome = "success",
|
||||
)
|
||||
homeViewModel.loadForYou(minutes)
|
||||
scope.launch { repo.setForYouMinutes(minutes) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = rows,
|
||||
key = { it.id },
|
||||
contentType = { "media-row-${it.kind}" },
|
||||
) { row ->
|
||||
// LazyColumn composes a row as it scrolls into view, which is
|
||||
// as close to "the viewer saw it" as the TV can observe.
|
||||
LaunchedEffect(row.id) {
|
||||
// The first eight posters cover the initial TV viewport
|
||||
// with headroom across density settings. Later posters
|
||||
// only become evidence when focus actually reaches them.
|
||||
homeViewModel.trackRowImpression(
|
||||
row.id,
|
||||
row.kind.name,
|
||||
row.items.take(8).map { it.id },
|
||||
)
|
||||
}
|
||||
// Only the destination row can act on a vertical focus request,
|
||||
// but a plain read of pendingRowFocus here happened in every
|
||||
// composed row's scope — so one press recomposed all of them
|
||||
// twice, once as the request was posted and again as it was
|
||||
// consumed and set back to null. Derived per row, a row whose
|
||||
// id does not match sees null both times and is never
|
||||
// invalidated; the destination row behaves exactly as before.
|
||||
val rowFocusRequest by remember(row.id) {
|
||||
derivedStateOf {
|
||||
pendingRowFocus?.takeIf { it.rowId == row.id }
|
||||
}
|
||||
}
|
||||
// returnItemId and returnRowId are rewritten by *every* card
|
||||
// taking focus, horizontal travel included, and reading them
|
||||
// here read them in every composed row's scope — so moving one
|
||||
// card along a shelf recomposed every other shelf on screen.
|
||||
// Derived, a row that does not hold the return target sees null
|
||||
// and stays untouched.
|
||||
val rowReturnFocusItemId by remember(row.id) {
|
||||
derivedStateOf {
|
||||
homeState.returnItemId.takeIf { homeState.returnRowId == row.id }
|
||||
}
|
||||
}
|
||||
MediaRow(
|
||||
modifier = Modifier,
|
||||
row = row,
|
||||
availableWidth = contentWidth,
|
||||
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
||||
!hasContextualHero &&
|
||||
(homeState.selectedDestination != BrowseDestination.SHOWS ||
|
||||
homeState.myShows.isEmpty()) &&
|
||||
row.id == firstPopulatedRowId
|
||||
},
|
||||
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
|
||||
hasContextualHero && row.id == firstPopulatedRowId
|
||||
},
|
||||
returnFocusItemId = rowReturnFocusItemId,
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
verticalFocusRequest = rowFocusRequest,
|
||||
onVerticalFocusRequestConsumed = { requestId ->
|
||||
if (pendingRowFocus?.requestId == requestId) {
|
||||
pendingRowFocus = null
|
||||
rowFocusMoving = false
|
||||
}
|
||||
},
|
||||
onMoveVertical = moveVertical@{ sourceItemIndex, direction ->
|
||||
if (rowFocusMoving) return@moveVertical true
|
||||
val sourceRowIndex = rowIndexes[row.id] ?: return@moveVertical false
|
||||
val destinationRowIndex = adjacentFocusableRowIndex(
|
||||
itemCounts = rowItemCounts,
|
||||
currentIndex = sourceRowIndex,
|
||||
direction = direction,
|
||||
) ?: run {
|
||||
// Up out of the topmost shelf is the way back to the
|
||||
// hero, and it has to be handled here: the hero is
|
||||
// not composed while a row holds focus, so there is
|
||||
// nothing above for Compose's own focus search to
|
||||
// find and the press would otherwise be dead.
|
||||
// My Shows is the band directly above the shelves,
|
||||
// so it answers this press before the hero does.
|
||||
if (direction == RowFocusDirection.UP && returnToMyShows()) {
|
||||
return@moveVertical true
|
||||
}
|
||||
if (direction == RowFocusDirection.UP && hasContextualHero) {
|
||||
rowFocusedBelowHero = false
|
||||
scope.launch {
|
||||
verticalState.animateScrollToItem(0)
|
||||
// Let the hero compose and attach its entry
|
||||
// target before focus is handed to it.
|
||||
delay(16.milliseconds)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
return@moveVertical true
|
||||
}
|
||||
return@moveVertical false
|
||||
}
|
||||
val destinationRow = rows[destinationRowIndex]
|
||||
val destinationItemIndex = rowEntryItemIndex(
|
||||
sourceIndex = sourceItemIndex,
|
||||
destinationItemCount = destinationRow.items.size,
|
||||
rememberedDestinationIndex = rowFocusPositions[destinationRow.id],
|
||||
)
|
||||
rowFocusPositions[row.id] = sourceItemIndex
|
||||
rowFocusPositions[destinationRow.id] = destinationItemIndex
|
||||
rowFocusRequestId += 1
|
||||
val request = RowFocusRequest(
|
||||
rowId = destinationRow.id,
|
||||
itemIndex = destinationItemIndex,
|
||||
requestId = rowFocusRequestId,
|
||||
)
|
||||
rowFocusMoving = true
|
||||
scope.launch {
|
||||
try {
|
||||
// Compose the destination row before its MediaRow
|
||||
// tries to attach the requested card's focus node.
|
||||
verticalState.animateScrollToItem(
|
||||
leadingItemCount + destinationRowIndex,
|
||||
)
|
||||
} catch (cancelled: CancellationException) {
|
||||
// Another scroll took the list over mid-animation, so
|
||||
// this move is never going to post its request and
|
||||
// nothing downstream would ever lift the latch. Ask
|
||||
// for the card anyway — the row it is in is where the
|
||||
// list has been left, and the worst case is the
|
||||
// request expiring like any other.
|
||||
pendingRowFocus = request
|
||||
throw cancelled
|
||||
}
|
||||
pendingRowFocus = request
|
||||
}
|
||||
true
|
||||
},
|
||||
onContentFocused = { homeState.navigationExpanded = false },
|
||||
onItemFocused = { item, itemIndex ->
|
||||
rowFocusPositions[row.id] = itemIndex
|
||||
rowFocusedBelowHero = true
|
||||
if (homeState.selectedDestination == BrowseDestination.HOME) {
|
||||
homeState.focusedHomeRowId = row.id
|
||||
}
|
||||
destinationFocus[homeState.selectedDestination] = row.id to item.id
|
||||
homeState.returnRowId = row.id
|
||||
homeState.returnRowKind = row.kind.name
|
||||
homeState.returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackRowFocused(row.id, row.kind.name, item.id)
|
||||
},
|
||||
onItemSelected = { item ->
|
||||
homeState.returnRowId = row.id
|
||||
homeState.returnRowKind = row.kind.name
|
||||
homeState.returnItemId = item.id
|
||||
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open",
|
||||
screen = homeState.selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(),
|
||||
source = row.id, target = if (item.membyPlayable) "details" else "content_action",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
// A schedule card is an episode that has not aired, so
|
||||
// it is not playable and has no page of its own. What
|
||||
// the viewer asked for is the show — carrying the air
|
||||
// time across, since that is why they pressed it.
|
||||
val seriesStub = scheduleSeriesStub(item)
|
||||
// A movie-schedule card whose film Emby has since
|
||||
// imported is the ordinary movie page; one whose film
|
||||
// is still only Radarr's opens its own. Neither is
|
||||
// inert, which is what the card used to be.
|
||||
val movieStub = scheduleMovieStub(item)
|
||||
homeState.detailsFromSearch = false
|
||||
if (seriesStub != null) {
|
||||
homeState.detailsAiringNotice = airingNoticeFor(item)
|
||||
homeViewModel.focusItem(seriesStub)
|
||||
homeState.detailsItem = seriesStub
|
||||
} else if (movieStub != null) {
|
||||
homeState.detailsAiringNotice = null
|
||||
homeViewModel.focusItem(movieStub)
|
||||
homeState.detailsItem = movieStub
|
||||
} else {
|
||||
homeViewModel.focusItem(item)
|
||||
if (item.membyPlayable || item.isRadarrOnly) {
|
||||
homeState.detailsAiringNotice = null
|
||||
homeState.detailsItem = item
|
||||
}
|
||||
}
|
||||
},
|
||||
onItemLongPressed = { item ->
|
||||
homeState.returnRowId = row.id
|
||||
homeState.returnRowKind = row.kind.name
|
||||
homeState.returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
if (item.membyPlayable || item.isRadarrOnly) {
|
||||
// Cleared here rather than only in the effect that
|
||||
// answers it: the effect runs after the menu's
|
||||
// first frame, and the previous card's answer
|
||||
// showing on it would be an entry that appears and
|
||||
// then vanishes.
|
||||
homeState.quickMenuTrailerAvailable = false
|
||||
homeState.quickMenuRowId = row.id
|
||||
homeState.quickMenuItem = item
|
||||
}
|
||||
},
|
||||
density = settings.homeCardDensity,
|
||||
artworkStyle = settings.homeArtworkStyle,
|
||||
horizontalState = horizontalStates.getOrPut(
|
||||
"${homeState.selectedDestination.name}:${row.id}",
|
||||
) { LazyListState() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.ARTWORK_CARD_MAX_WIDTH
|
||||
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_BACKDROP_MAX_WIDTH
|
||||
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_PRIMARY_MAX_WIDTH
|
||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
import kotlinx.coroutines.delay
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/** The three playback entry points the launcher exposes; see [rememberHomePlayback]. */
|
||||
@Stable
|
||||
internal class HomePlaybackController(
|
||||
val playItem: (BaseItem) -> Unit,
|
||||
val playTrailer: (BaseItem) -> Unit,
|
||||
val abandonLaunch: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Owns the `ActivityResultLauncher` and the stream-resolution logic that opens
|
||||
* [PlayerActivity]. Extracted from `HomeScreen` because it was ~250 dense lines pushing
|
||||
* that composable over ART's method compiler limit — the launcher, the failure-recording,
|
||||
* the resume-vs-cold-start branch and the two-attempt source-resolution timeout.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberHomePlayback(
|
||||
homeState: HomeScreenState,
|
||||
homeViewModel: HomeViewModel,
|
||||
repo: EmbyRepository,
|
||||
scope: CoroutineScope,
|
||||
navigationFocusRequesters: Map<BrowseDestination, FocusRequester>,
|
||||
contentFocusRequester: FocusRequester,
|
||||
cardReturnFocusRequester: FocusRequester,
|
||||
): HomePlaybackController {
|
||||
val context = LocalContext.current
|
||||
val playbackLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (PlayerActivity.trailerUnavailable(result.resultCode, result.data)) {
|
||||
Toast.makeText(context, "No playable trailer is available", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "stop", screen = "player",
|
||||
feature = "playback", target = homeState.selectedDestination.name.lowercase(),
|
||||
)
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
// PlayerActivity has finished and this activity owns the window again. Compose
|
||||
// needs one frame to reattach the saved card's focus node before it can receive
|
||||
// focus, especially when playback progress refreshed the row behind the player.
|
||||
// Clearing the launch gate here rather than when the intent was fired covers both
|
||||
// routes into the player, including the one that hands over without waiting.
|
||||
homeState.launchingItem = null
|
||||
scope.launch {
|
||||
homeState.myShowsLoading = true
|
||||
homeState.notificationsLoading = true
|
||||
coroutineScope {
|
||||
launch {
|
||||
runCatching { repo.getMyShows() }
|
||||
.onSuccess { homeState.myShows = it; homeState.myShowsError = null }
|
||||
.onFailure {
|
||||
if (it is CancellationException) throw it
|
||||
homeState.myShowsError = friendlyEmbyError(it)
|
||||
}
|
||||
homeState.myShowsLoading = false
|
||||
}
|
||||
launch {
|
||||
runCatching { repo.getNotifications() }
|
||||
.onSuccess { homeState.notificationState = it; homeState.notificationsError = null }
|
||||
.onFailure {
|
||||
if (it is CancellationException) throw it
|
||||
homeState.notificationsError = friendlyEmbyError(it)
|
||||
}
|
||||
homeState.notificationsLoading = false
|
||||
}
|
||||
}
|
||||
delay(32.milliseconds)
|
||||
// Trailer playback leaves its detail page composed. Let Compose restore the
|
||||
// exact hero action instead of moving focus to the home card behind it.
|
||||
if (homeState.detailsItem != null) return@launch
|
||||
if (homeState.returnRowId != null && homeState.returnItemId != null) {
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequesters.getValue(homeState.railFocusDestination),
|
||||
)
|
||||
} else {
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequesters.getValue(homeState.railFocusDestination))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val abandonLaunch: () -> Unit = {
|
||||
homeState.resolveJob?.cancel()
|
||||
homeState.resolveJob = null
|
||||
homeState.resolvingItem = null
|
||||
homeState.launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
}
|
||||
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
||||
if (homeState.launchingItem != null || !item.membyPlayable) return@playItem
|
||||
// The shelf this press traces back to, resolved once and then carried all the way
|
||||
// into the player, so the step the launcher records and the step the player records
|
||||
// agree about where the viewer came from. `source` used to be the raw row id, which
|
||||
// the console could not group on: Continue Watching arrived as `continue` from the
|
||||
// gateway, as `nextup` from a cached row, and as a recommendation id nobody had seen
|
||||
// before every other day.
|
||||
val entryPoint = playbackEntryPointFor(homeState.returnRowId, homeState.returnRowKind)
|
||||
PlaybackJourney.requested(
|
||||
sink = homeViewModel.journeySink,
|
||||
entryPoint = entryPoint,
|
||||
screen = homeState.selectedDestination.name.lowercase(),
|
||||
itemId = item.id,
|
||||
itemName = item.name,
|
||||
itemType = item.type,
|
||||
)
|
||||
// Nothing here uploads: the buffer is drained once the player hands the window back.
|
||||
homeViewModel.pauseAnalyticsForPlayback()
|
||||
// Every way a launch can die before the player exists. The player records its own
|
||||
// failures once it has one; these are the ones it would never hear about, and
|
||||
// without them a journey ends on a request that appears simply to have gone nowhere.
|
||||
val playbackScreen = homeState.selectedDestination.name.lowercase()
|
||||
val recordPlaybackFailed = {
|
||||
PlaybackJourney.failed(
|
||||
sink = homeViewModel.journeySink,
|
||||
entryPoint = entryPoint,
|
||||
screen = playbackScreen,
|
||||
itemId = item.id,
|
||||
itemName = item.name,
|
||||
itemType = item.type,
|
||||
)
|
||||
}
|
||||
homeState.launchingItem = item
|
||||
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
||||
// Resuming: open the player now and let it resolve the stream while it starts.
|
||||
// Waiting here would spend the negotiation on a still launcher and only then begin
|
||||
// the activity, the layout and the decoder, none of which needed the answer. A cold
|
||||
// start still resolves first — the pre-roll it opens with needs a stream to run
|
||||
// behind it, and whether there is one to show is part of the same answer.
|
||||
// Everything up to the hand-over runs inside a catch that reopens the gate. The
|
||||
// gate is otherwise cleared only by the player coming back, so anything that
|
||||
// throws before one is started — a malformed cached item, an activity result
|
||||
// registry that has already been torn down — would leave Play dead for the rest
|
||||
// of the session with nothing on screen to say why.
|
||||
// Logo enrichment may need the associated series record. Keep it in the same
|
||||
// cancellable launch job as stream resolution so Back abandons the whole hand-off.
|
||||
homeState.resolveJob = scope.launch {
|
||||
try {
|
||||
val prepared = runCatching {
|
||||
val request = repo.playbackRequestForLaunch(item)
|
||||
request to repo.readyPlayableForLaunch(request)
|
||||
}.getOrElse {
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
homeState.launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
return@launch
|
||||
}
|
||||
val request = prepared.first
|
||||
val ready = prepared.second
|
||||
if (ready == null && request.resumePositionMs > 0L) {
|
||||
val launched = runCatching {
|
||||
playbackLauncher.launch(
|
||||
PlayerActivity.intent(
|
||||
context = context,
|
||||
request = request,
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
|
||||
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
journeySource = entryPoint.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (launched.isFailure) {
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
homeState.launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
// Only the route that waits on the server shows the launcher's own loading screen.
|
||||
// The route that hands over immediately would only be showing it behind the player.
|
||||
homeState.resolvingItem = item
|
||||
runCatching {
|
||||
val playable = ready ?: run {
|
||||
var timeout: TimeoutCancellationException? = null
|
||||
repeat(2) {
|
||||
try {
|
||||
// The request, not the card. `playbackRequestForLaunch`
|
||||
// above is what resolves an episode's title treatment from
|
||||
// its series, and re-deriving the request from the item
|
||||
// here threw that away — so a cold start (no resume
|
||||
// position, which is every unwatched pick) opened the
|
||||
// player with no logo while a resume opened with one.
|
||||
return@run withTimeout(PLAYBACK_SOURCE_RESOLUTION_TIMEOUT) {
|
||||
repo.resolvePlayableForLaunch(request)
|
||||
}
|
||||
} catch (error: TimeoutCancellationException) {
|
||||
timeout = error
|
||||
}
|
||||
}
|
||||
throw requireNotNull(timeout)
|
||||
}
|
||||
// A resolution that came back without a stream is a failure with a
|
||||
// success's shape. Handed on, PlayerActivity finds no URL and no
|
||||
// request to resolve one from, closes itself in onCreate, and the
|
||||
// viewer sees the screen flicker and nothing else — the one playback
|
||||
// failure that arrives with no explanation at all.
|
||||
check(playable.url.isNotBlank()) { "resolved playable has no stream" }
|
||||
playable
|
||||
}
|
||||
.onSuccess { playable ->
|
||||
val launched = runCatching {
|
||||
playbackLauncher.launch(
|
||||
PlayerActivity.intent(
|
||||
context = context,
|
||||
playable = playable,
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
|
||||
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
journeySource = entryPoint.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (launched.isFailure) {
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
homeState.launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
// A cancellation is the viewer having pressed Back out of the
|
||||
// wait, which has already reopened the gate and said so on screen.
|
||||
if (error is CancellationException) throw error
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
// Nothing was launched, so nothing will come back to reopen the gate.
|
||||
homeState.launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
}
|
||||
} finally {
|
||||
homeState.resolvingItem = null
|
||||
homeState.resolveJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
val playTrailer: (BaseItem) -> Unit = playTrailer@{ item ->
|
||||
if (homeState.launchingItem != null) return@playTrailer
|
||||
homeState.launchingItem = item
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "trailer", screen = "details",
|
||||
feature = "trailer", source = "details", target = "player",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
homeViewModel.pauseAnalyticsForPlayback()
|
||||
val launched = runCatching {
|
||||
playbackLauncher.launch(
|
||||
PlayerActivity.trailerIntent(
|
||||
context,
|
||||
TrailerPlaybackRequest(
|
||||
subjectId = item.id,
|
||||
title = item.name,
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
||||
logoUrl = repo.logoUrl(item),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (launched.isFailure) {
|
||||
homeState.launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
Toast.makeText(context, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
return remember(playbackLauncher) {
|
||||
HomePlaybackController(playItem = playItem, playTrailer = playTrailer, abandonLaunch = abandonLaunch)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
import com.ponzischeme89.memby.data.model.NotificationsResponse
|
||||
import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
/**
|
||||
* Mutable launcher state hoisted out of the [HomeScreen] composable body.
|
||||
*
|
||||
* `HomeScreen` was a single ~2670-line `@Composable` that exceeded ART's method compiler
|
||||
* limit — so it ran fully interpreted — and recomposed wholesale whenever any of its ~40
|
||||
* state flags changed, freezing the UI for 1–2 s on every rail-section switch. It is now
|
||||
* split into `HomeScreen` (state + effects + rail) → `HomeContentPane` (the per-destination
|
||||
* panes) and `HomeOverlays` (the overlay stack). That split needs one shared, stable state
|
||||
* object rather than threading ~70 value/setter parameters: a `@Stable` holder whose
|
||||
* identity never changes means each child recomposes only for the properties it reads.
|
||||
*
|
||||
* `remember`ed keyed on the viewer id in `HomeScreen`, which reproduces the reset semantics
|
||||
* the `remember(settings.userId) { … }` blocks it replaces already had — a viewer switch
|
||||
* opens a fresh launcher.
|
||||
*/
|
||||
@Stable
|
||||
internal class HomeScreenState {
|
||||
// Navigation — the rail selection and focus target. Persisted across process death by
|
||||
// [Saver], like the `rememberSaveable` fields this replaces.
|
||||
var selectedDestination by mutableStateOf(BrowseDestination.HOME)
|
||||
var navigationExpanded by mutableStateOf(false)
|
||||
var railFocusDestination by mutableStateOf(BrowseDestination.HOME)
|
||||
var returnRowId by mutableStateOf<String?>(null)
|
||||
var returnItemId by mutableStateOf<String?>(null)
|
||||
var returnRowKind by mutableStateOf<String?>(null)
|
||||
|
||||
var requestsRailExpanded by mutableStateOf(false)
|
||||
// Initial focus belongs to each destination, not the launcher as a whole.
|
||||
var initialFocusDestination by mutableStateOf<BrowseDestination?>(null)
|
||||
// Bumped when a rail selection needs the lazy row list to scroll a card back in.
|
||||
var rowListFocusRestoreRequest by mutableStateOf(0)
|
||||
|
||||
// Rail-adjacent overlays (Settings / Profiles / user picker).
|
||||
var showSettings by mutableStateOf(false)
|
||||
var showProfiles by mutableStateOf(false)
|
||||
var addingProfile by mutableStateOf(false)
|
||||
var userSwitcherVisible by mutableStateOf(false)
|
||||
var userQuickActionsVisible by mutableStateOf(false)
|
||||
var switchingProfileId by mutableStateOf<String?>(null)
|
||||
var removingProfileId by mutableStateOf<String?>(null)
|
||||
var restoreRailAfterSettings by mutableStateOf(false)
|
||||
|
||||
// Detail page (opened from any pane; walks its own Back trail).
|
||||
var detailsItem by mutableStateOf<BaseItem?>(null)
|
||||
var detailsTrail by mutableStateOf<List<BaseItem>>(emptyList())
|
||||
var restoreDetailPosition by mutableStateOf(false)
|
||||
var detailsResumeTarget by mutableStateOf<DetailsReturn?>(null)
|
||||
var detailsAiringNotice by mutableStateOf<AiringNotice?>(null)
|
||||
var detailsFromSearch by mutableStateOf(false)
|
||||
|
||||
// Long-press quick-actions menu.
|
||||
var quickMenuItem by mutableStateOf<BaseItem?>(null)
|
||||
var quickMenuTrailerAvailable by mutableStateOf(false)
|
||||
var quickMenuRowId by mutableStateOf<String?>(null)
|
||||
|
||||
var focusedHomeRowId by mutableStateOf<String?>(null)
|
||||
var sectionHeroRows by mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
|
||||
|
||||
// My Shows.
|
||||
var myShows by mutableStateOf<List<MyShow>>(emptyList())
|
||||
var myShowsLoading by mutableStateOf(true)
|
||||
var myShowsError by mutableStateOf<String?>(null)
|
||||
var myShowsMutationBusy by mutableStateOf(false)
|
||||
var selectedMyShow by mutableStateOf<MyShow?>(null)
|
||||
var myShowReturnItemId by mutableStateOf<String?>(null)
|
||||
var removingMyShow by mutableStateOf(false)
|
||||
|
||||
// Notifications / My Alerts / My Requests.
|
||||
var notificationState by mutableStateOf(NotificationsResponse())
|
||||
var notificationsLoading by mutableStateOf(true)
|
||||
var notificationsError by mutableStateOf<String?>(null)
|
||||
var notificationsMutationBusy by mutableStateOf(false)
|
||||
var showNotifications by mutableStateOf(false)
|
||||
var showRequests by mutableStateOf(false)
|
||||
|
||||
// Viewer management.
|
||||
var viewers by mutableStateOf<List<MembyViewer>>(emptyList())
|
||||
var showViewerPicker by mutableStateOf(false)
|
||||
var showViewerManage by mutableStateOf(false)
|
||||
var viewerNameTarget by mutableStateOf<ViewerNameTarget?>(null)
|
||||
var viewerName by mutableStateOf("")
|
||||
var viewerNameFailure by mutableStateOf<String?>(null)
|
||||
var viewerSaving by mutableStateOf(false)
|
||||
var viewerBusyId by mutableStateOf<String?>(null)
|
||||
var pinTarget by mutableStateOf<MembyViewer?>(null)
|
||||
var pinSaving by mutableStateOf(false)
|
||||
var pinFailure by mutableStateOf<String?>(null)
|
||||
|
||||
// Playback launch gate + loading overlay.
|
||||
var launchingItem by mutableStateOf<BaseItem?>(null)
|
||||
var resolvingItem by mutableStateOf<BaseItem?>(null)
|
||||
|
||||
/** Stream-resolution job so a Back press out of the loading overlay can cancel it. Never read in composition. */
|
||||
var resolveJob: Job? = null
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Persists only the navigation fields across process death — the overlay flags all
|
||||
* default closed, which is the right state to restore to anyway.
|
||||
*/
|
||||
val Saver = listSaver<HomeScreenState, Any?>(
|
||||
save = { s ->
|
||||
listOf(
|
||||
s.selectedDestination.name,
|
||||
s.navigationExpanded,
|
||||
s.railFocusDestination.name,
|
||||
s.returnRowId,
|
||||
s.returnItemId,
|
||||
s.returnRowKind,
|
||||
)
|
||||
},
|
||||
restore = { v ->
|
||||
HomeScreenState().apply {
|
||||
selectedDestination = runCatching {
|
||||
BrowseDestination.valueOf(v[0] as String)
|
||||
}.getOrDefault(BrowseDestination.HOME)
|
||||
navigationExpanded = v[1] as? Boolean ?: false
|
||||
railFocusDestination = runCatching {
|
||||
BrowseDestination.valueOf(v[2] as String)
|
||||
}.getOrDefault(BrowseDestination.HOME)
|
||||
returnRowId = v[3] as? String
|
||||
returnItemId = v[4] as? String
|
||||
returnRowKind = v[5] as? String
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -60,6 +62,7 @@ import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -94,17 +97,6 @@ import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* How long a genre may sit under focus before it is asked for.
|
||||
*
|
||||
* Focus is selection in this rail, and a held D-pad travels it several items a second.
|
||||
* Without this every genre passed through would be a request, and the one the viewer
|
||||
* actually stopped on would queue behind fifteen answers nobody is waiting for. It is
|
||||
* deliberately short: at this length a deliberate press still reads as instant, and by
|
||||
* the time the wait is over a neighbour has usually been warmed anyway.
|
||||
*/
|
||||
private const val GENRE_SELECT_DEBOUNCE_MS = 130L
|
||||
|
||||
/** How many rows of placeholder cards stand in for a genre that has not answered yet. */
|
||||
private const val GENRE_PLACEHOLDER_ROWS = 3
|
||||
|
||||
@@ -149,7 +141,6 @@ fun GenreBrowseScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onContentFocused: () -> Unit = {},
|
||||
) {
|
||||
val mediaLabel = remember(itemType) { genreMediaLabel(itemType) }
|
||||
val browseViewModel: GenreBrowseViewModel = viewModel(
|
||||
key = "genre-browse-${itemType.lowercase().ifEmpty { "all" }}",
|
||||
factory = remember(itemType) {
|
||||
@@ -157,49 +148,173 @@ fun GenreBrowseScreen(
|
||||
},
|
||||
)
|
||||
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// The genre the remote is on, kept apart from the view model's selection so the rail's
|
||||
val serviceIds = remember { serviceCategoryTabs().map(GenreCategory::id) }
|
||||
val knownIds = remember(itemType) {
|
||||
(genreCategoryTabs(itemType).map(GenreCategory::id) + serviceIds).toSet()
|
||||
}
|
||||
|
||||
// The filter the remote is on, kept apart from the view model's selection so the rail's
|
||||
// own marker and the entry FocusRequester are never waiting on a request. The pane
|
||||
// follows the selection instead, because it labels the grid rather than the remote.
|
||||
//
|
||||
// It opens on the destination's own default rather than on whatever was left selected.
|
||||
// The view model outlives this screen — it is scoped to the activity, so its pages
|
||||
// survive a trip to Home and back, which is what makes returning to a genre free — but
|
||||
// arriving at Genres is arriving at the top of a catalogue, not resuming a place in
|
||||
// one, and a viewer who went Home a day ago has no reason to be put back in Crime.
|
||||
var activeCategoryId by remember(initialCategoryId) { mutableStateOf(initialCategoryId) }
|
||||
var contentHasFocus by remember { mutableStateOf(false) }
|
||||
// It is restored from [GenreBrowseFocusMemory] — arriving at Genres puts the viewer
|
||||
// back on the provider or genre they last had open, for the life of the app process. A
|
||||
// stale or unknown id falls back to the destination's own default.
|
||||
var activeCategoryId by remember(itemType) {
|
||||
mutableStateOf(GenreBrowseFocusMemory.activeId?.takeIf { it in knownIds } ?: initialCategoryId)
|
||||
}
|
||||
val restoredZone = remember(itemType) {
|
||||
GenreBrowseFocusMemory.zone
|
||||
?: if (activeCategoryId in serviceIds) GenreZone.SERVICE_FILTERS else GenreZone.GENRE_LIST
|
||||
}
|
||||
|
||||
// One requester per genre, owned here rather than by the rail, because every way back
|
||||
// into the rail has to name *which* genre it is going back to. Handing the rail a
|
||||
// single "the active one" requester that moved between items as the selection changed
|
||||
// was what put a returning viewer on the genre they had passed through rather than on
|
||||
// the one they had chosen: a requester that changes which node it is attached to has,
|
||||
// for a moment, two, and the one it answers with is not the one under the marker.
|
||||
val railFocusRequesters = rememberFocusRequesterMap(
|
||||
genreCategoryTabs(itemType).map(GenreCategory::id),
|
||||
// Mirrors of the content's zone and grid origin, kept here only so the process-scoped
|
||||
// memory can be written from one place as any of the three changes.
|
||||
var lastZone by remember(itemType) { mutableStateOf(restoredZone) }
|
||||
var lastOrigin by remember(itemType) { mutableStateOf(GenreBrowseFocusMemory.origin) }
|
||||
LaunchedEffect(lastZone, lastOrigin, activeCategoryId) {
|
||||
GenreBrowseFocusMemory.remember(lastZone, activeCategoryId, lastOrigin)
|
||||
}
|
||||
|
||||
// The id a SELECT press is waiting to enter the grid for. The grid can only be focused
|
||||
// once its first page has arrived, so the press is remembered here and acted on by the
|
||||
// effect below once the results land — and dropped if they land empty, because focus
|
||||
// must never move into an empty grid.
|
||||
var pendingGridEntry by remember(itemType) {
|
||||
mutableStateOf(if (restoredZone == GenreZone.RESULTS_GRID) activeCategoryId else null)
|
||||
}
|
||||
var enterGridRequest by remember(itemType) { mutableIntStateOf(0) }
|
||||
|
||||
// The grid follows the *committed* filter, not the D-pad. Moving the marker across the
|
||||
// rail or the services strip only changes the highlight — the results pane, its heading
|
||||
// and the rail's own accent stay on whatever was last chosen with OK (or Right into the
|
||||
// grid). This is the contract's focus-vs-selection split: browsing the rail must not
|
||||
// recompose the pane under the viewer's thumb.
|
||||
//
|
||||
// The one thing focus still does is seed the first commit: a freshly opened browser has
|
||||
// nothing selected, so the filter the screen lands on is chosen once.
|
||||
LaunchedEffect(Unit) {
|
||||
if (browseViewModel.state.value.selectedCategoryId == null) {
|
||||
browseViewModel.selectCategory(activeCategoryId)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
||||
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
|
||||
LaunchedEffect(pendingGridEntry, state.selectedCategoryId, state.items.size, state.isLoading) {
|
||||
val target = pendingGridEntry ?: return@LaunchedEffect
|
||||
if (state.selectedCategoryId != target) return@LaunchedEffect
|
||||
if (state.items.isNotEmpty()) {
|
||||
pendingGridEntry = null
|
||||
enterGridRequest++
|
||||
} else if (!state.isLoading) {
|
||||
pendingGridEntry = null
|
||||
}
|
||||
}
|
||||
|
||||
GenreBrowseContent(
|
||||
state = state,
|
||||
itemType = itemType,
|
||||
activeCategoryId = activeCategoryId,
|
||||
favouriteStates = favouriteStates,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = returnFocusItemId,
|
||||
returnFocusRequester = returnFocusRequester,
|
||||
initialZone = restoredZone,
|
||||
initialOrigin = lastOrigin,
|
||||
enterGridRequest = enterGridRequest,
|
||||
onFilterFocused = { id ->
|
||||
onContentFocused()
|
||||
activeCategoryId = id
|
||||
},
|
||||
onFilterSelected = { id ->
|
||||
onContentFocused()
|
||||
activeCategoryId = id
|
||||
browseViewModel.selectCategory(id)
|
||||
pendingGridEntry = id
|
||||
},
|
||||
onZoneChanged = { lastZone = it },
|
||||
onGridEntered = { lastOrigin = it },
|
||||
onLoadMore = browseViewModel::loadMore,
|
||||
onRetry = browseViewModel::retry,
|
||||
onItemFocused = onItemFocused,
|
||||
onItemSelected = onItemSelected,
|
||||
onClose = onClose,
|
||||
onContentFocused = onContentFocused,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
// Same shape, one per service icon. The Services strip is not itemType-specific, so
|
||||
// this is stable for the life of the screen rather than keyed to it.
|
||||
val serviceFocusRequesters = rememberFocusRequesterMap(
|
||||
serviceCategoryTabs().map(GenreCategory::id),
|
||||
)
|
||||
/**
|
||||
* The Genre Browser's rendering and focus graph, split from [GenreBrowseScreen] so it can be
|
||||
* driven by a fixture with a canned [GenreBrowseUiState] — the `SettingsPanelContent`
|
||||
* precedent. Everything about *which* remote press goes *where* between the four zones lives
|
||||
* here or in `GenreNavigation.kt`; the view model glue stays in the screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GenreBrowseContent(
|
||||
state: GenreBrowseUiState,
|
||||
itemType: String,
|
||||
activeCategoryId: String,
|
||||
favouriteStates: Map<String, Boolean>,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
initialZone: GenreZone,
|
||||
initialOrigin: ResultsOrigin?,
|
||||
enterGridRequest: Int,
|
||||
onFilterFocused: (String) -> Unit,
|
||||
onFilterSelected: (String) -> Unit,
|
||||
onZoneChanged: (GenreZone) -> Unit,
|
||||
onGridEntered: (ResultsOrigin) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onContentFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val mediaLabel = remember(itemType) { genreMediaLabel(itemType) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
/** Every requester the remote can land on across both strips, for a lookup by id. */
|
||||
val serviceIds = remember { serviceCategoryTabs().map(GenreCategory::id) }
|
||||
val genreIds = remember(itemType) { genreCategoryTabs(itemType).map(GenreCategory::id) }
|
||||
|
||||
// One requester per genre / per service, owned here rather than by the rails, because
|
||||
// every way back into a rail has to name *which* item it is going back to.
|
||||
val railFocusRequesters = rememberFocusRequesterMap(genreIds)
|
||||
val serviceFocusRequesters = rememberFocusRequesterMap(serviceIds)
|
||||
val allEntryFocusRequesters = railFocusRequesters + serviceFocusRequesters
|
||||
|
||||
// One grid state per genre, so returning to a genre returns to where it was left
|
||||
// rather than to the top of it. Bounded by the catalogue, which is a fixed list.
|
||||
var zone by remember(itemType) { mutableStateOf(initialZone) }
|
||||
fun setZone(next: GenreZone) {
|
||||
if (next != zone) {
|
||||
zone = next
|
||||
onZoneChanged(next)
|
||||
}
|
||||
}
|
||||
var resultsOrigin by remember(itemType) { mutableStateOf(initialOrigin) }
|
||||
var lastServiceFocus by remember(itemType) { mutableStateOf<String?>(null) }
|
||||
var lastGenreFocus by remember(itemType) { mutableStateOf<String?>(null) }
|
||||
|
||||
fun navContext(atFirstGenre: Boolean = false): GenreNavContext = GenreNavContext(
|
||||
serviceIds = serviceIds,
|
||||
genreIds = genreIds,
|
||||
activeId = activeCategoryId,
|
||||
origin = resultsOrigin,
|
||||
lastServiceFocus = lastServiceFocus,
|
||||
lastGenreFocus = lastGenreFocus,
|
||||
atFirstGenre = atFirstGenre,
|
||||
)
|
||||
|
||||
// One grid state / remembered card per filter, so returning to one returns to where it
|
||||
// was left rather than the top. Bounded by the fixed catalogue.
|
||||
val gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
|
||||
val focusedCardIndexes = remember(itemType) { mutableMapOf<String, Int>() }
|
||||
val gridEntryFocusRequester = remember { FocusRequester() }
|
||||
// Which card [gridEntryFocusRequester] is attached to. It is state rather than a value
|
||||
// derived from the map above, because the card a genre was left on changes as the
|
||||
// viewer travels the grid and a requester pinned to where they *entered* would send
|
||||
// the next press back to the top-left corner. Written by the press that uses it, so
|
||||
// travelling the grid costs no recomposition of it.
|
||||
var gridEntryIndex by remember(itemType) { mutableStateOf(0) }
|
||||
|
||||
val shownCategoryId = state.selectedCategoryId
|
||||
@@ -207,92 +322,103 @@ fun GenreBrowseScreen(
|
||||
(state.categories + state.services).firstOrNull { it.id == shownCategoryId }
|
||||
?: genreCategory(itemType, shownCategoryId)
|
||||
}
|
||||
val gridState = gridStates.getOrPut(shownCategoryId ?: initialCategoryId) { LazyGridState() }
|
||||
val gridState = gridStates.getOrPut(shownCategoryId ?: activeCategoryId) { LazyGridState() }
|
||||
|
||||
// Whether the rail has been travelled *on this visit*. The debounce below is about a
|
||||
// held D-pad, so it must not delay the genre the screen opens on — and the view model
|
||||
// remembering a previous visit's selection is exactly what would otherwise make the
|
||||
// opening genre look like a change.
|
||||
var railTravelled by remember(itemType) { mutableStateOf(false) }
|
||||
LaunchedEffect(activeCategoryId) {
|
||||
// See [GENRE_SELECT_DEBOUNCE_MS]: travelling past a genre must not ask for it.
|
||||
// Only a *change* is a candidate for that — the genre the page opens on is the one
|
||||
// thing somebody is definitely waiting for, so it is asked for at once.
|
||||
if (railTravelled) kotlinx.coroutines.delay(GENRE_SELECT_DEBOUNCE_MS)
|
||||
browseViewModel.selectCategory(activeCategoryId)
|
||||
railTravelled = true
|
||||
}
|
||||
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
||||
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
|
||||
/**
|
||||
* Puts the remote back on the genre the grid belongs to.
|
||||
*
|
||||
* Named rather than left to the shared entry requester: this is the press that has to
|
||||
* remember where the viewer was, and the genre they chose is the only honest answer.
|
||||
*/
|
||||
fun focusRail(): Boolean =
|
||||
requestFocusAmong(
|
||||
/** Puts the remote back on the filter the grid belongs to — its recorded origin. */
|
||||
fun focusRail(): Boolean {
|
||||
val target = resultsOrigin?.id ?: activeCategoryId
|
||||
val moved = requestFocusAmong(
|
||||
allEntryFocusRequesters[target],
|
||||
allEntryFocusRequesters[activeCategoryId],
|
||||
contentFocusRequester,
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
requestFocusWithRetries(
|
||||
allEntryFocusRequesters[activeCategoryId],
|
||||
contentFocusRequester,
|
||||
attempts = 4,
|
||||
frameDelayMillis = 32L,
|
||||
)
|
||||
}
|
||||
// Back steps out of the grid before it steps off the page — one press per level, the
|
||||
// rule the search pane and the calendar already follow.
|
||||
BackHandler {
|
||||
if (contentHasFocus) {
|
||||
focusRail()
|
||||
} else {
|
||||
onClose()
|
||||
if (moved) {
|
||||
setZone(if (target in serviceIds) GenreZone.SERVICE_FILTERS else GenreZone.GENRE_LIST)
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
/** Moves focus into the grid, at the card this genre was left on. */
|
||||
/** Moves focus into the grid, at the card this filter was left on, and records the origin. */
|
||||
fun enterGrid(): Boolean {
|
||||
val items = state.items
|
||||
if (items.isEmpty()) return false
|
||||
// Where this genre was left comes first, and the card a detail page was opened
|
||||
// from is the fallback. The other order goes stale: returnFocusItemId is not
|
||||
// cleared once its restore is done, so it would keep pulling every later press
|
||||
// back to a card the viewer has long since scrolled past.
|
||||
val origin = resultsOriginFor(activeCategoryId, serviceIds, genreIds)
|
||||
resultsOrigin = origin
|
||||
onGridEntered(origin)
|
||||
// Where this filter was left comes first, then the card a detail page was opened
|
||||
// from. The other order goes stale: returnFocusItemId is not cleared after its
|
||||
// restore, so it would keep pulling later presses back to an old card.
|
||||
val remembered = focusedCardIndexes[shownCategoryId]?.takeIf { it in items.indices }
|
||||
?: returnFocusItemId?.let { id -> items.indexOfFirst { it.id == id }.takeIf { i -> i >= 0 } }
|
||||
?: 0
|
||||
gridEntryIndex = remembered
|
||||
scope.launch {
|
||||
// A card outside the composed window cannot be focused, so the grid is put
|
||||
// back where it was before its card is asked for — and the requester has to
|
||||
// have moved to that card first, which is one recomposition away.
|
||||
runCatching { gridState.scrollToItem(remembered) }
|
||||
requestFocusWithRetries(gridEntryFocusRequester, attempts = 3)
|
||||
if (requestFocusWithRetries(gridEntryFocusRequester, attempts = 3)) {
|
||||
setZone(GenreZone.RESULTS_GRID)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (requestFocusWithRetries(
|
||||
allEntryFocusRequesters[activeCategoryId],
|
||||
contentFocusRequester,
|
||||
attempts = 4,
|
||||
frameDelayMillis = 32L,
|
||||
)
|
||||
) {
|
||||
setZone(if (activeCategoryId in serviceIds) GenreZone.SERVICE_FILTERS else GenreZone.GENRE_LIST)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(enterGridRequest) {
|
||||
if (enterGridRequest > 0) enterGrid()
|
||||
}
|
||||
// Back steps out of the grid before it steps off the page — one press per level.
|
||||
BackHandler {
|
||||
when (zone) {
|
||||
GenreZone.RESULTS_GRID -> focusRail()
|
||||
else -> onClose()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize().background(MembySurface)) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// The two ambiguous restore targets go through the focus graph: Up from the top
|
||||
// genre returns to the service the remote was last on (or the first), and Down
|
||||
// from the services returns to the last genre — never a fixed first item.
|
||||
val servicesEntryRequester = state.services.takeIf { it.isNotEmpty() }?.let {
|
||||
(
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.UP, navContext(atFirstGenre = true))
|
||||
as? GenreFocusTarget.Service
|
||||
)?.let { target -> serviceFocusRequesters[target.id] }
|
||||
}
|
||||
val genresEntryRequester = (
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.DOWN, navContext())
|
||||
as? GenreFocusTarget.Genre
|
||||
)?.let { railFocusRequesters[it.id] }
|
||||
GenreRail(
|
||||
categories = state.categories,
|
||||
activeCategoryId = activeCategoryId,
|
||||
selectedCategoryId = shownCategoryId,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
activeFocusRequester = contentFocusRequester,
|
||||
itemFocusRequesters = railFocusRequesters,
|
||||
onCategoryFocused = { id ->
|
||||
onContentFocused()
|
||||
activeCategoryId = id
|
||||
lastGenreFocus = id
|
||||
setZone(GenreZone.GENRE_LIST)
|
||||
onFilterFocused(id)
|
||||
},
|
||||
onEnterContent = ::enterGrid,
|
||||
onCategorySelected = { id -> onFilterSelected(id) },
|
||||
// Right commits the focused genre and enters its grid — the grid does not
|
||||
// follow the marker, so the press has to select first.
|
||||
onEnterContent = { onFilterSelected(activeCategoryId); true },
|
||||
// Up out of the top genre only reaches the strip above it when there is
|
||||
// one to reach — a screenshot test renders the rail with no services at
|
||||
// all, and Up from there must still stand down rather than throw.
|
||||
servicesFocusRequester = serviceFocusRequesters[state.services.firstOrNull()?.id],
|
||||
servicesFocusRequester = servicesEntryRequester,
|
||||
topContent = state.services.takeIf { it.isNotEmpty() }?.let { services ->
|
||||
{
|
||||
ServicesRail(
|
||||
@@ -304,12 +430,14 @@ fun GenreBrowseScreen(
|
||||
itemFocusRequesters = serviceFocusRequesters,
|
||||
onServiceFocused = { id ->
|
||||
onContentFocused()
|
||||
activeCategoryId = id
|
||||
lastServiceFocus = id
|
||||
setZone(GenreZone.SERVICE_FILTERS)
|
||||
onFilterFocused(id)
|
||||
},
|
||||
// Down out of the strip lands on the top genre, in whatever
|
||||
// order personalisation put it in — never a fixed one.
|
||||
downFocusRequester = railFocusRequesters[state.categories.firstOrNull()?.id],
|
||||
onEnterContent = ::enterGrid,
|
||||
onServiceSelected = { id -> onFilterSelected(id) },
|
||||
// Down out of the strip lands on the last genre the remote was
|
||||
// on, in this viewer's own order — never a fixed one.
|
||||
downFocusRequester = genresEntryRequester,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -318,7 +446,7 @@ fun GenreBrowseScreen(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.onFocusChanged { contentHasFocus = it.hasFocus },
|
||||
.onFocusChanged { if (it.hasFocus) setZone(GenreZone.RESULTS_GRID) },
|
||||
) {
|
||||
val horizontalPadding = 40.dp
|
||||
val spacing = 18.dp
|
||||
@@ -373,7 +501,7 @@ fun GenreBrowseScreen(
|
||||
)
|
||||
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
onRetry = onRetry,
|
||||
navigationFocusRequester = contentFocusRequester,
|
||||
)
|
||||
state.items.isEmpty() -> GenreMessage(
|
||||
@@ -396,7 +524,7 @@ fun GenreBrowseScreen(
|
||||
.conflate()
|
||||
.collect { last ->
|
||||
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
||||
browseViewModel.loadMore()
|
||||
onLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,11 +567,13 @@ fun GenreBrowseScreen(
|
||||
onFocused = {
|
||||
onContentFocused()
|
||||
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
||||
setZone(GenreZone.RESULTS_GRID)
|
||||
onItemFocused(displayedItem)
|
||||
},
|
||||
onClick = { onItemSelected(displayedItem) },
|
||||
onLongClick = { onItemSelected(displayedItem) },
|
||||
modifier = Modifier
|
||||
.testTag("genre-card-$index")
|
||||
.then(
|
||||
if (item.id == returnFocusItemId) {
|
||||
Modifier.focusRequester(returnFocusRequester)
|
||||
@@ -458,17 +588,17 @@ fun GenreBrowseScreen(
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
// Left out of the first column is the way back
|
||||
// to the genre this grid belongs to, named
|
||||
// directly so the press cannot land on a genre
|
||||
// Left out of the first column returns to the
|
||||
// filter that opened this grid — its recorded
|
||||
// origin, so the press cannot land on a filter
|
||||
// merely travelled through. Only the first
|
||||
// column carries the node — the rest would run
|
||||
// an empty focusProperties lambda for nothing.
|
||||
// column carries the node.
|
||||
.then(
|
||||
if (isFirstColumn) {
|
||||
Modifier.focusProperties {
|
||||
left = railFocusRequesters[activeCategoryId]
|
||||
?: contentFocusRequester
|
||||
left = allEntryFocusRequesters[
|
||||
resultsOrigin?.id ?: activeCategoryId,
|
||||
] ?: contentFocusRequester
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
@@ -484,7 +614,7 @@ fun GenreBrowseScreen(
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
onRetry = onRetry,
|
||||
navigationFocusRequester = contentFocusRequester,
|
||||
)
|
||||
}
|
||||
@@ -509,10 +639,15 @@ fun GenreBrowseScreen(
|
||||
@Composable
|
||||
internal fun GenreRail(
|
||||
categories: List<GenreCategory>,
|
||||
/** The genre the D-pad marker is on — the entry target and the bright focus plate. */
|
||||
activeCategoryId: String,
|
||||
/** The genre whose grid is showing — the quiet accent wash when focus is elsewhere. */
|
||||
selectedCategoryId: String? = activeCategoryId,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
activeFocusRequester: FocusRequester,
|
||||
onCategoryFocused: (String) -> Unit,
|
||||
/** SELECT / OK on a genre: filter the grid and move focus into it. */
|
||||
onCategorySelected: (String) -> Unit,
|
||||
onEnterContent: () -> Boolean,
|
||||
/**
|
||||
* One requester per genre, owned by the screen: every way back into the rail names the
|
||||
@@ -580,7 +715,7 @@ internal fun GenreRail(
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.6.sp,
|
||||
modifier = Modifier.padding(start = 22.dp, top = 46.dp, bottom = 18.dp),
|
||||
modifier = Modifier.padding(start = 22.dp, top = 20.dp, bottom = 14.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
state = railState,
|
||||
@@ -605,11 +740,12 @@ internal fun GenreRail(
|
||||
val requester = requesterFor(category.id)
|
||||
GenreRailItem(
|
||||
category = category,
|
||||
active = category.id == activeCategoryId,
|
||||
active = category.id == selectedCategoryId,
|
||||
focusedForCapture = category.id == focusForCapture,
|
||||
onFocused = { onCategoryFocused(category.id) },
|
||||
onClick = { onCategoryFocused(category.id) },
|
||||
onClick = { onCategorySelected(category.id) },
|
||||
modifier = Modifier
|
||||
.testTag("genre-rail-${category.id}")
|
||||
.focusRequester(requester)
|
||||
// A second requester on the same node: the screen's entry target
|
||||
// is the genre in force, and it is also what Left out of the
|
||||
@@ -766,26 +902,21 @@ internal fun ServicesRail(
|
||||
navigationFocusRequester: FocusRequester,
|
||||
activeFocusRequester: FocusRequester,
|
||||
onServiceFocused: (String) -> Unit,
|
||||
/** SELECT / OK on a service: filter the grid and move focus into it. */
|
||||
onServiceSelected: (String) -> Unit = {},
|
||||
/**
|
||||
* One requester per service, owned by the screen for the same reason the genre rail's
|
||||
* are: every way back into this strip names *which* icon it is returning to.
|
||||
*/
|
||||
itemFocusRequesters: Map<String, FocusRequester> = emptyMap(),
|
||||
/** Where Down out of the strip lands — the top genre, in this viewer's own order. */
|
||||
/** Where Down out of the strip lands — the last genre the remote was on, this viewer's order. */
|
||||
downFocusRequester: FocusRequester? = null,
|
||||
/** The service whose shelf the grid is currently showing, drawn as the quiet wash. */
|
||||
selectedServiceId: String? = null,
|
||||
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
|
||||
focusForCapture: String? = null,
|
||||
/**
|
||||
* The [GenreRail] `LazyColumn` precedent: Right belongs to this strip as a whole, and
|
||||
* has to scroll the remembered card back into composition before anything can be
|
||||
* focused, which a `focusProperties` target could not do. Without this, Right on a
|
||||
* service icon fell through to default focus search, which landed on whichever genre
|
||||
* happened to sit below it rather than entering the grid.
|
||||
*/
|
||||
onEnterContent: () -> Boolean = { false },
|
||||
) {
|
||||
val stripScope = rememberCoroutineScope()
|
||||
val ownedFocusRequesters = remember(services.map { it.id }) {
|
||||
services.associate { it.id to FocusRequester() }
|
||||
}
|
||||
@@ -818,20 +949,26 @@ internal fun ServicesRail(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusGroup()
|
||||
// Left/Right walk the strip — and nothing else. Driven here rather than by
|
||||
// each icon's `focusProperties` because more services than fit the rail is
|
||||
// only a matter of time, and an off-screen icon has no attached requester
|
||||
// for a `focusProperties` target to name: the press has to scroll it into
|
||||
// composition first. The last icon's own `right` is a predictable dead
|
||||
// edge; a service enters the grid by SELECT or by going Down to the genres.
|
||||
.onKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown || event.key != Key.DirectionRight) {
|
||||
return@onKeyEvent false
|
||||
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||
val current = services.indexOfFirst { it.id == activeServiceId }
|
||||
val target = when (event.key) {
|
||||
Key.DirectionRight -> current + 1
|
||||
Key.DirectionLeft -> current - 1
|
||||
else -> return@onKeyEvent false
|
||||
}
|
||||
// Unlike the genre rail's single column, Right means two different
|
||||
// things here: walk to the next icon, or — only once there is no
|
||||
// next icon — enter the grid. Consuming it unconditionally is what
|
||||
// made Right on Netflix or Apple TV jump straight into the grid
|
||||
// instead of reaching the icon beside it, so every other icon has
|
||||
// to let the press fall through to the item's own focusProperties
|
||||
// and the built-in directional search that reads it. Only the last
|
||||
// icon, whose own `right` is Cancel, hands the press to the grid.
|
||||
if (activeServiceId != services.lastOrNull()?.id) return@onKeyEvent false
|
||||
onEnterContent()
|
||||
if (current < 0 || target !in services.indices) return@onKeyEvent false
|
||||
stripScope.launch {
|
||||
runCatching { stripState.scrollToItem(target) }
|
||||
requestFocusWithRetries(requesterFor(services[target].id), attempts = 3)
|
||||
}
|
||||
true
|
||||
},
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -843,8 +980,9 @@ internal fun ServicesRail(
|
||||
active = service.id == selectedServiceId,
|
||||
focusedForCapture = service.id == focusForCapture,
|
||||
onFocused = { onServiceFocused(service.id) },
|
||||
onClick = { onServiceFocused(service.id) },
|
||||
onClick = { onServiceSelected(service.id) },
|
||||
modifier = Modifier
|
||||
.testTag("genre-service-${service.id}")
|
||||
.focusRequester(requester)
|
||||
.then(
|
||||
if (service.id == activeServiceId) {
|
||||
@@ -872,14 +1010,15 @@ internal fun ServicesRail(
|
||||
)
|
||||
}
|
||||
}
|
||||
// The gap that reads as "related but separate" from the genre rail beneath it.
|
||||
Spacer(Modifier.height(18.dp))
|
||||
// A tight gap and a hairline — enough to read the two strips as related but
|
||||
// separate without a slab of empty rail between them.
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 22.dp)
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.06f)),
|
||||
.background(Color.White.copy(alpha = 0.08f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -926,21 +1065,35 @@ private fun ServiceIconButton(
|
||||
radius = radius,
|
||||
center = centre,
|
||||
)
|
||||
// The ring is what says "Memby", never the brand tint underneath it —
|
||||
// white always, brighter under focus than while merely active.
|
||||
}
|
||||
// Drawn *over* the mark, not behind it: the brand artwork fills the whole
|
||||
// circle, so a ring in drawBehind was hidden under it. White always — it is
|
||||
// what says "Memby" regardless of the brand it sits on — thick and bright
|
||||
// under focus, a quieter hairline while merely the committed filter.
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
val radius = size.minDimension / 2f
|
||||
val centre = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f)
|
||||
val ringAlpha = when {
|
||||
focused -> 1f
|
||||
active -> 0.5f
|
||||
active -> 0.7f
|
||||
else -> 0f
|
||||
}
|
||||
if (ringAlpha > 0f) {
|
||||
val stroke = (if (active && !focused) 1.5f else 2.25f + 1f * emphasis.value).dp.toPx()
|
||||
// A thin dark band just outside the white keeps it legible on pale
|
||||
// artwork (the Apple TV+ and Disney+ marks are near-white at the edge).
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.35f * ringAlpha),
|
||||
radius = radius - stroke / 2f,
|
||||
center = centre,
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke + 3f),
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = ringAlpha),
|
||||
radius = radius - (1.5f + 1.5f * emphasis.value).dp.toPx() / 2f,
|
||||
radius = radius - stroke / 2f,
|
||||
center = centre,
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(
|
||||
width = (2.dp + 1.dp * emphasis.value).toPx(),
|
||||
),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
/**
|
||||
* The Genre Browser's focus graph, as a pure decision function.
|
||||
*
|
||||
* The screen has four focus zones that have to hand off to one another cleanly:
|
||||
*
|
||||
* ```
|
||||
* APP_RAIL ──▶ SERVICE_FILTERS ──▶ GENRE_LIST ──▶ RESULTS_GRID
|
||||
* │ │ │
|
||||
* └── SELECT ────────┴── SELECT ────┘ (filter + enter grid)
|
||||
* LEFT (edge) ─▶ originating filter
|
||||
* ```
|
||||
*
|
||||
* Movement *within* a zone — the next service icon, the next genre, the next poster — is
|
||||
* ordinary Compose focus travel and stays with the per-item `focusProperties`. What lives
|
||||
* here is only the zone *boundary*: the press that would otherwise fall through to a
|
||||
* nearest-neighbour search and land somewhere arbitrary. [genreFocusMove] answers "from
|
||||
* this zone, this key, where next", and returns [GenreFocusTarget.Fallthrough] for every
|
||||
* interior move so the composable knows to leave it alone.
|
||||
*
|
||||
* Kept pure (no Compose types) so the whole graph is unit-tested the way `detailTabs` and
|
||||
* `calendarMonthFocusAnchor` are — see `GenreNavigationTest`.
|
||||
*/
|
||||
enum class GenreZone { APP_RAIL, SERVICE_FILTERS, GENRE_LIST, RESULTS_GRID }
|
||||
|
||||
/**
|
||||
* How the viewer entered the results grid — a service shortcut or a genre.
|
||||
*
|
||||
* Left from the grid's first column returns to whichever filter opened it, and that is a
|
||||
* fact about the navigation the viewer performed, not about which focusable element happens
|
||||
* to sit nearest the grid's edge. Tracked explicitly for the same reason the detail page
|
||||
* remembers its band rather than inferring it from scroll position.
|
||||
*/
|
||||
sealed interface ResultsOrigin {
|
||||
val id: String
|
||||
|
||||
data class Service(override val id: String) : ResultsOrigin
|
||||
data class Genre(override val id: String) : ResultsOrigin
|
||||
}
|
||||
|
||||
enum class GenreNavKey { LEFT, RIGHT, UP, DOWN, SELECT }
|
||||
|
||||
/** Where a boundary press should send focus. */
|
||||
sealed interface GenreFocusTarget {
|
||||
/** Out to the launcher's own navigation rail. */
|
||||
data object AppRail : GenreFocusTarget
|
||||
|
||||
/** A specific service shortcut. */
|
||||
data class Service(val id: String) : GenreFocusTarget
|
||||
|
||||
/** A specific genre in the rail. */
|
||||
data class Genre(val id: String) : GenreFocusTarget
|
||||
|
||||
/** Into the poster grid, at the card this filter was last left on. */
|
||||
data object ResultsGrid : GenreFocusTarget
|
||||
|
||||
/** Eat the press: a dead edge (e.g. Up from the top service). */
|
||||
data object Consume : GenreFocusTarget
|
||||
|
||||
/** Not a boundary — let Compose's own focus search handle the interior move. */
|
||||
data object Fallthrough : GenreFocusTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything [genreFocusMove] needs that it cannot read from the zone and key alone.
|
||||
*
|
||||
* The `at*` flags are the caller's answer to "is the remote at the edge of this zone" —
|
||||
* computed from the focused index, which the composable knows and this function should not.
|
||||
*/
|
||||
data class GenreNavContext(
|
||||
/** Service ids in strip order; empty when no service shortcuts are configured. */
|
||||
val serviceIds: List<String>,
|
||||
/** Genre ids in rail order; never empty (the "All" entry is always present). */
|
||||
val genreIds: List<String>,
|
||||
/** The filter currently in force — a service id or a genre id. */
|
||||
val activeId: String,
|
||||
/** How the grid was last entered, for Left-from-grid. */
|
||||
val origin: ResultsOrigin?,
|
||||
/** The service the remote was last on, so Down/Up from the genres restores it. */
|
||||
val lastServiceFocus: String? = null,
|
||||
/** The genre the remote was last on, so Down from the services restores it. */
|
||||
val lastGenreFocus: String? = null,
|
||||
val atFirstService: Boolean = false,
|
||||
val atLastService: Boolean = false,
|
||||
val atFirstGenre: Boolean = false,
|
||||
val atLastGenre: Boolean = false,
|
||||
val atGridLeftEdge: Boolean = false,
|
||||
)
|
||||
|
||||
/** Whether an id names a service shortcut or a genre — the two id spaces are disjoint. */
|
||||
fun resultsOriginFor(
|
||||
activeId: String,
|
||||
serviceIds: List<String>,
|
||||
genreIds: List<String>,
|
||||
): ResultsOrigin =
|
||||
when {
|
||||
activeId in serviceIds -> ResultsOrigin.Service(activeId)
|
||||
else -> ResultsOrigin.Genre(activeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a zone-to-zone transition is decided.
|
||||
*
|
||||
* See the class KDoc: interior moves come back as [GenreFocusTarget.Fallthrough]; only the
|
||||
* edges produce a real target.
|
||||
*/
|
||||
fun genreFocusMove(
|
||||
zone: GenreZone,
|
||||
key: GenreNavKey,
|
||||
ctx: GenreNavContext,
|
||||
): GenreFocusTarget = when (zone) {
|
||||
GenreZone.APP_RAIL -> GenreFocusTarget.Fallthrough
|
||||
|
||||
GenreZone.SERVICE_FILTERS -> when (key) {
|
||||
GenreNavKey.LEFT ->
|
||||
if (ctx.atFirstService) GenreFocusTarget.AppRail else GenreFocusTarget.Fallthrough
|
||||
// A predictable dead edge — never a jump into the grid. The strip walks left/right;
|
||||
// SELECT is how a service enters the results.
|
||||
GenreNavKey.RIGHT ->
|
||||
if (ctx.atLastService) GenreFocusTarget.Consume else GenreFocusTarget.Fallthrough
|
||||
GenreNavKey.UP -> GenreFocusTarget.Consume
|
||||
GenreNavKey.DOWN -> GenreFocusTarget.Genre(
|
||||
ctx.lastGenreFocus?.takeIf { it in ctx.genreIds } ?: ctx.genreIds.first(),
|
||||
)
|
||||
GenreNavKey.SELECT -> GenreFocusTarget.ResultsGrid
|
||||
}
|
||||
|
||||
GenreZone.GENRE_LIST -> when (key) {
|
||||
GenreNavKey.UP -> when {
|
||||
!ctx.atFirstGenre -> GenreFocusTarget.Fallthrough
|
||||
ctx.serviceIds.isEmpty() -> GenreFocusTarget.Consume
|
||||
else -> GenreFocusTarget.Service(
|
||||
ctx.lastServiceFocus?.takeIf { it in ctx.serviceIds } ?: ctx.serviceIds.first(),
|
||||
)
|
||||
}
|
||||
GenreNavKey.DOWN ->
|
||||
if (ctx.atLastGenre) GenreFocusTarget.Consume else GenreFocusTarget.Fallthrough
|
||||
GenreNavKey.LEFT -> GenreFocusTarget.AppRail
|
||||
GenreNavKey.RIGHT -> GenreFocusTarget.ResultsGrid
|
||||
GenreNavKey.SELECT -> GenreFocusTarget.ResultsGrid
|
||||
}
|
||||
|
||||
GenreZone.RESULTS_GRID -> when (key) {
|
||||
GenreNavKey.LEFT -> if (!ctx.atGridLeftEdge) {
|
||||
GenreFocusTarget.Fallthrough
|
||||
} else when (val origin = ctx.origin) {
|
||||
is ResultsOrigin.Service -> GenreFocusTarget.Service(origin.id)
|
||||
is ResultsOrigin.Genre -> GenreFocusTarget.Genre(origin.id)
|
||||
null -> when (
|
||||
resultsOriginFor(ctx.activeId, ctx.serviceIds, ctx.genreIds)
|
||||
) {
|
||||
is ResultsOrigin.Service -> GenreFocusTarget.Service(ctx.activeId)
|
||||
is ResultsOrigin.Genre -> GenreFocusTarget.Genre(ctx.activeId)
|
||||
}
|
||||
}
|
||||
else -> GenreFocusTarget.Fallthrough
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the Genre Browser was when it was last left, for the length of the app process.
|
||||
*
|
||||
* Arriving at Genres restores the provider or genre the viewer had open and the zone they
|
||||
* were in — the `DetailPositionStore` precedent, and deliberately not persisted: a set
|
||||
* switched on the next morning opens at the top of the catalogue, not back in Crime.
|
||||
*/
|
||||
object GenreBrowseFocusMemory {
|
||||
var zone: GenreZone? = null
|
||||
var activeId: String? = null
|
||||
var origin: ResultsOrigin? = null
|
||||
|
||||
fun remember(zone: GenreZone, activeId: String, origin: ResultsOrigin?) {
|
||||
this.zone = zone
|
||||
this.activeId = activeId
|
||||
this.origin = origin
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
zone = null
|
||||
activeId = null
|
||||
origin = null
|
||||
}
|
||||
}
|
||||
@@ -786,6 +786,12 @@ internal fun SettingsPanelContent(
|
||||
// stopped responding rather than a list that has run out. Left already returns to the
|
||||
// page list; this makes Up say the same thing once the pane has no row above.
|
||||
val railSelectionFocusRequester = remember { FocusRequester() }
|
||||
// Whether focus is on the pane's first entry, reported by that entry's own focus state
|
||||
// (see pageEntryModifier). This is the test for "top of the pane", because moveFocus
|
||||
// is not: the home screen stays composed behind this panel and Up's spatial search
|
||||
// leaks onto a rail item or a covered card and reports success, so a fallback keyed on
|
||||
// moveFocus failing would never fire.
|
||||
var paneTopFocused by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -841,11 +847,11 @@ internal fun SettingsPanelContent(
|
||||
when {
|
||||
event.type != KeyEventType.KeyDown -> false
|
||||
event.key != Key.DirectionUp -> false
|
||||
focusManager.moveFocus(FocusDirection.Up) -> true
|
||||
else -> {
|
||||
paneTopFocused -> {
|
||||
scope.launch { requestFocusWithRetries(railSelectionFocusRequester) }
|
||||
true
|
||||
}
|
||||
else -> focusManager.moveFocus(FocusDirection.Up)
|
||||
}
|
||||
}
|
||||
.verticalScroll(contentScrollState)
|
||||
@@ -857,7 +863,11 @@ internal fun SettingsPanelContent(
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
|
||||
) {
|
||||
val pageEntryModifier = Modifier.focusProperties { up = railSelectionFocusRequester }
|
||||
// The first entry on every page. Its focus state is what tells the escape above
|
||||
// "the pane has no row above this one" — a redirect via focusProperties { up }
|
||||
// is unreliable here, because the first entry is often a choice row whose real
|
||||
// focus targets are nested chips that do not inherit it.
|
||||
val pageEntryModifier = Modifier.onFocusChanged { paneTopFocused = it.hasFocus }
|
||||
SettingsHeader(page = state.selectedPage)
|
||||
when (state.selectedPage) {
|
||||
SettingsPage.APPEARANCE -> SettingsGroup {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.requestFocus
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The Genre Browser focus graph, driven through real key events — the `SettingsRailFocusTest`
|
||||
* pattern. Robolectric because focus search needs a composition; not a screenshot test.
|
||||
*
|
||||
* Covers the regression scenarios in the navigation contract: a provider or genre press
|
||||
* enters the results, Left off the grid returns to the *originating* filter, and the loop
|
||||
* app rail → providers → results → providers → genres → results → genres → app rail holds up
|
||||
* with one transition per press.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
|
||||
class GenreBrowserFocusTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private val apple = "service-apple-tv-plus"
|
||||
private val netflix = "service-netflix"
|
||||
private val disney = "service-disney-plus"
|
||||
|
||||
private fun awaitFocused(tag: String) {
|
||||
compose.waitUntil(timeoutMillis = 3_000) {
|
||||
runCatching { compose.onNodeWithTag(tag).assertIsFocused() }.isSuccess
|
||||
}
|
||||
compose.onNodeWithTag(tag).assertIsFocused()
|
||||
}
|
||||
|
||||
private fun press(key: Key) {
|
||||
compose.onRoot().performKeyInput { pressKey(key) }
|
||||
compose.waitForIdle()
|
||||
}
|
||||
|
||||
// ---- Scenario A: provider -> OK -> results -------------------------------------
|
||||
|
||||
@Test
|
||||
fun `select on a provider filters and moves focus into the results`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag("genre-service-$netflix").requestFocus()
|
||||
compose.waitForIdle()
|
||||
press(Key.DirectionCenter)
|
||||
|
||||
awaitFocused("genre-card-0")
|
||||
}
|
||||
|
||||
// ---- Scenario B/C: change provider, repeatedly -------------------------------
|
||||
|
||||
@Test
|
||||
fun `left from provider results returns to that provider, then another can be chosen`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag("genre-service-$apple").requestFocus()
|
||||
compose.waitForIdle()
|
||||
press(Key.DirectionCenter)
|
||||
awaitFocused("genre-card-0")
|
||||
|
||||
// Left off the grid's first column returns to Apple TV+, not a genre.
|
||||
press(Key.DirectionLeft)
|
||||
awaitFocused("genre-service-$apple")
|
||||
|
||||
// Right to Netflix, OK, back into results, Left back to Netflix.
|
||||
press(Key.DirectionRight)
|
||||
awaitFocused("genre-service-$netflix")
|
||||
press(Key.DirectionCenter)
|
||||
awaitFocused("genre-card-0")
|
||||
press(Key.DirectionLeft)
|
||||
awaitFocused("genre-service-$netflix")
|
||||
|
||||
// And again, to Disney+.
|
||||
press(Key.DirectionRight)
|
||||
awaitFocused("genre-service-$disney")
|
||||
press(Key.DirectionCenter)
|
||||
awaitFocused("genre-card-0")
|
||||
press(Key.DirectionLeft)
|
||||
awaitFocused("genre-service-$disney")
|
||||
}
|
||||
|
||||
// ---- Scenario D: provider -> DOWN -> genres -> OK -> results ------------------
|
||||
|
||||
@Test
|
||||
fun `down from the providers reaches the genres, which stay usable`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag("genre-service-$netflix").requestFocus()
|
||||
compose.waitForIdle()
|
||||
press(Key.DirectionDown)
|
||||
awaitFocused("genre-rail-all")
|
||||
|
||||
press(Key.DirectionDown)
|
||||
awaitFocused("genre-rail-action-adventure")
|
||||
press(Key.DirectionCenter)
|
||||
awaitFocused("genre-card-0")
|
||||
}
|
||||
|
||||
// ---- Scenario E/F: genre -> results -> genre -> app rail --------------------
|
||||
|
||||
@Test
|
||||
fun `left from genre results returns to the originating genre, then out to the app rail`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag("genre-rail-comedy").requestFocus()
|
||||
compose.waitForIdle()
|
||||
press(Key.DirectionCenter)
|
||||
awaitFocused("genre-card-0")
|
||||
|
||||
press(Key.DirectionLeft)
|
||||
awaitFocused("genre-rail-comedy")
|
||||
|
||||
press(Key.DirectionLeft)
|
||||
awaitFocused("app-rail")
|
||||
}
|
||||
|
||||
// ---- Scenario H-ish: a remembered return card keeps focus -------------------
|
||||
|
||||
@Test
|
||||
fun `a returning result is focused and left still reaches its genre`() {
|
||||
compose.setContent { Fixture(returnItemId = "item-6", initialZone = GenreZone.RESULTS_GRID) }
|
||||
compose.waitForIdle()
|
||||
|
||||
awaitFocused("genre-card-6")
|
||||
press(Key.DirectionLeft)
|
||||
// item-6 is not in the first column (5 columns), so Left is an interior move.
|
||||
awaitFocused("genre-card-5")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun Fixture(
|
||||
returnItemId: String? = null,
|
||||
initialZone: GenreZone = GenreZone.GENRE_LIST,
|
||||
) {
|
||||
val categories = remember { genreCategoryTabs(ALL_MEDIA_ITEM_TYPE) }
|
||||
val services = remember { serviceCategoryTabs() }
|
||||
val items = remember {
|
||||
(0 until 12).map { i ->
|
||||
BaseItem(id = "item-$i", name = "Title $i", type = if (i % 2 == 0) "Movie" else "Series")
|
||||
}
|
||||
}
|
||||
|
||||
var activeCategoryId by remember { mutableStateOf(if (initialZone == GenreZone.SERVICE_FILTERS) ALL_SERVICES_CATEGORY_ID else ALL_MEDIA_CATEGORY_ID) }
|
||||
var selectedCategoryId by remember { mutableStateOf(activeCategoryId) }
|
||||
var pendingGridEntry by remember {
|
||||
mutableStateOf(if (initialZone == GenreZone.RESULTS_GRID) activeCategoryId else null)
|
||||
}
|
||||
var enterGridRequest by remember { mutableIntStateOf(0) }
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(pendingGridEntry, selectedCategoryId) {
|
||||
val target = pendingGridEntry ?: return@LaunchedEffect
|
||||
if (selectedCategoryId != target) return@LaunchedEffect
|
||||
pendingGridEntry = null
|
||||
enterGridRequest++
|
||||
}
|
||||
|
||||
val appRail = remember { FocusRequester() }
|
||||
val state = GenreBrowseUiState(
|
||||
categories = categories,
|
||||
services = services,
|
||||
selectedCategoryId = selectedCategoryId,
|
||||
items = items,
|
||||
)
|
||||
|
||||
PreviewSurface {
|
||||
Row {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.testTag("app-rail")
|
||||
.size(40.dp)
|
||||
.focusRequester(appRail)
|
||||
.focusable(),
|
||||
)
|
||||
GenreBrowseContent(
|
||||
state = state,
|
||||
itemType = ALL_MEDIA_ITEM_TYPE,
|
||||
activeCategoryId = activeCategoryId,
|
||||
favouriteStates = emptyMap(),
|
||||
navigationFocusRequester = appRail,
|
||||
contentFocusRequester = remember { FocusRequester() },
|
||||
returnFocusItemId = returnItemId,
|
||||
returnFocusRequester = remember { FocusRequester() },
|
||||
initialZone = initialZone,
|
||||
initialOrigin = null,
|
||||
enterGridRequest = enterGridRequest,
|
||||
onFilterFocused = { activeCategoryId = it },
|
||||
onFilterSelected = {
|
||||
activeCategoryId = it
|
||||
selectedCategoryId = it
|
||||
pendingGridEntry = it
|
||||
},
|
||||
onZoneChanged = {},
|
||||
onGridEntered = {},
|
||||
onLoadMore = {},
|
||||
onRetry = {},
|
||||
onItemFocused = {},
|
||||
onItemSelected = {},
|
||||
onClose = {},
|
||||
onContentFocused = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Genre Browser focus graph, one assertion per cell of the navigation matrix plus the
|
||||
* cross-zone hops the regression scenarios are built from.
|
||||
*/
|
||||
class GenreNavigationTest {
|
||||
|
||||
private val services = listOf("service-all", "service-apple-tv-plus", "service-netflix", "service-disney-plus")
|
||||
private val genres = listOf("all", "action-adventure", "comedy", "drama")
|
||||
|
||||
private fun ctx(
|
||||
activeId: String,
|
||||
origin: ResultsOrigin? = null,
|
||||
lastServiceFocus: String? = null,
|
||||
lastGenreFocus: String? = null,
|
||||
atFirstService: Boolean = false,
|
||||
atLastService: Boolean = false,
|
||||
atFirstGenre: Boolean = false,
|
||||
atLastGenre: Boolean = false,
|
||||
atGridLeftEdge: Boolean = false,
|
||||
serviceIds: List<String> = services,
|
||||
) = GenreNavContext(
|
||||
serviceIds = serviceIds,
|
||||
genreIds = genres,
|
||||
activeId = activeId,
|
||||
origin = origin,
|
||||
lastServiceFocus = lastServiceFocus,
|
||||
lastGenreFocus = lastGenreFocus,
|
||||
atFirstService = atFirstService,
|
||||
atLastService = atLastService,
|
||||
atFirstGenre = atFirstGenre,
|
||||
atLastGenre = atLastGenre,
|
||||
atGridLeftEdge = atGridLeftEdge,
|
||||
)
|
||||
|
||||
// ---- resultsOriginFor ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `origin is a service when the active id names one`() {
|
||||
assertEquals(
|
||||
ResultsOrigin.Service("service-netflix"),
|
||||
resultsOriginFor("service-netflix", services, genres),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `origin is a genre for anything else`() {
|
||||
assertEquals(ResultsOrigin.Genre("drama"), resultsOriginFor("drama", services, genres))
|
||||
assertEquals(ResultsOrigin.Genre("mystery"), resultsOriginFor("mystery", services, genres))
|
||||
}
|
||||
|
||||
// ---- SERVICE_FILTERS -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `left off the first service leaves for the app rail`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.AppRail,
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.LEFT, ctx("service-all", atFirstService = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left between services is an interior move`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Fallthrough,
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.LEFT, ctx("service-netflix")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right off the last service is a dead edge, never a jump into the grid`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Consume,
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.RIGHT, ctx("service-disney-plus", atLastService = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right between services is an interior move`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Fallthrough,
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.RIGHT, ctx("service-apple-tv-plus")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up from the services goes nowhere`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Consume,
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.UP, ctx("service-netflix")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `down from the services enters the genre list, restoring the last genre`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Genre("all"),
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.DOWN, ctx("service-netflix")),
|
||||
)
|
||||
assertEquals(
|
||||
GenreFocusTarget.Genre("comedy"),
|
||||
genreFocusMove(
|
||||
GenreZone.SERVICE_FILTERS,
|
||||
GenreNavKey.DOWN,
|
||||
ctx("service-netflix", lastGenreFocus = "comedy"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `select on a service filters and enters the grid`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.ResultsGrid,
|
||||
genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.SELECT, ctx("service-apple-tv-plus")),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- GENRE_LIST --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `up from the top genre reaches the services when there are any`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Service("service-all"),
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.UP, ctx("all", atFirstGenre = true)),
|
||||
)
|
||||
assertEquals(
|
||||
GenreFocusTarget.Service("service-netflix"),
|
||||
genreFocusMove(
|
||||
GenreZone.GENRE_LIST,
|
||||
GenreNavKey.UP,
|
||||
ctx("all", atFirstGenre = true, lastServiceFocus = "service-netflix"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up from the top genre with no services is a dead edge`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Consume,
|
||||
genreFocusMove(
|
||||
GenreZone.GENRE_LIST,
|
||||
GenreNavKey.UP,
|
||||
ctx("all", atFirstGenre = true, serviceIds = emptyList()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up and down between genres are interior moves`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Fallthrough,
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.UP, ctx("comedy")),
|
||||
)
|
||||
assertEquals(
|
||||
GenreFocusTarget.Fallthrough,
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.DOWN, ctx("comedy")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `down off the last genre is a dead edge`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Consume,
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.DOWN, ctx("drama", atLastGenre = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left from a genre leaves for the app rail`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.AppRail,
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.LEFT, ctx("drama")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right and select from a genre enter the grid`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.ResultsGrid,
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.RIGHT, ctx("drama")),
|
||||
)
|
||||
assertEquals(
|
||||
GenreFocusTarget.ResultsGrid,
|
||||
genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.SELECT, ctx("drama")),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- RESULTS_GRID ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `left off the grid edge returns to the originating service`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Service("service-netflix"),
|
||||
genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("service-netflix", origin = ResultsOrigin.Service("service-netflix"), atGridLeftEdge = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left off the grid edge returns to the originating genre`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Genre("comedy"),
|
||||
genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("comedy", origin = ResultsOrigin.Genre("comedy"), atGridLeftEdge = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left inside the grid is an interior move`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Fallthrough,
|
||||
genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("comedy", origin = ResultsOrigin.Genre("comedy"), atGridLeftEdge = false),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left off the grid edge with no recorded origin falls back to the active filter`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Service("service-disney-plus"),
|
||||
genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("service-disney-plus", origin = null, atGridLeftEdge = true),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
GenreFocusTarget.Genre("drama"),
|
||||
genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("drama", origin = null, atGridLeftEdge = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical movement inside the grid is left to Compose`() {
|
||||
assertEquals(
|
||||
GenreFocusTarget.Fallthrough,
|
||||
genreFocusMove(GenreZone.RESULTS_GRID, GenreNavKey.UP, ctx("drama", atGridLeftEdge = true)),
|
||||
)
|
||||
assertEquals(
|
||||
GenreFocusTarget.Fallthrough,
|
||||
genreFocusMove(GenreZone.RESULTS_GRID, GenreNavKey.DOWN, ctx("drama", atGridLeftEdge = true)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Scenario hops (the cross-zone parts of the regression scenarios) ---------
|
||||
|
||||
/** Scenario B/C: results → LEFT → provider → RIGHT → next provider → SELECT → results. */
|
||||
@Test
|
||||
fun `provider switch round trip stays deterministic`() {
|
||||
// In Apple TV+ results, Left off the edge → Apple TV+.
|
||||
var target = genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("service-apple-tv-plus", origin = ResultsOrigin.Service("service-apple-tv-plus"), atGridLeftEdge = true),
|
||||
)
|
||||
assertEquals(GenreFocusTarget.Service("service-apple-tv-plus"), target)
|
||||
|
||||
// Right walks to Netflix (interior move, Compose handles it).
|
||||
target = genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.RIGHT, ctx("service-apple-tv-plus"))
|
||||
assertEquals(GenreFocusTarget.Fallthrough, target)
|
||||
|
||||
// Select on Netflix → results.
|
||||
target = genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.SELECT, ctx("service-netflix"))
|
||||
assertEquals(GenreFocusTarget.ResultsGrid, target)
|
||||
|
||||
// Left off the Netflix results edge → Netflix, not a genre.
|
||||
target = genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("service-netflix", origin = ResultsOrigin.Service("service-netflix"), atGridLeftEdge = true),
|
||||
)
|
||||
assertEquals(GenreFocusTarget.Service("service-netflix"), target)
|
||||
}
|
||||
|
||||
/** Scenario D: provider → DOWN → genres → SELECT → genre results. */
|
||||
@Test
|
||||
fun `provider down into genres then select`() {
|
||||
var target = genreFocusMove(GenreZone.SERVICE_FILTERS, GenreNavKey.DOWN, ctx("service-netflix"))
|
||||
assertEquals(GenreFocusTarget.Genre("all"), target)
|
||||
|
||||
target = genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.DOWN, ctx("all"))
|
||||
assertEquals(GenreFocusTarget.Fallthrough, target)
|
||||
|
||||
target = genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.SELECT, ctx("drama"))
|
||||
assertEquals(GenreFocusTarget.ResultsGrid, target)
|
||||
}
|
||||
|
||||
/** Scenario F: genre results → LEFT → genre → LEFT → app rail. */
|
||||
@Test
|
||||
fun `genre results back out to the app rail`() {
|
||||
var target = genreFocusMove(
|
||||
GenreZone.RESULTS_GRID,
|
||||
GenreNavKey.LEFT,
|
||||
ctx("drama", origin = ResultsOrigin.Genre("drama"), atGridLeftEdge = true),
|
||||
)
|
||||
assertEquals(GenreFocusTarget.Genre("drama"), target)
|
||||
|
||||
target = genreFocusMove(GenreZone.GENRE_LIST, GenreNavKey.LEFT, ctx("drama"))
|
||||
assertEquals(GenreFocusTarget.AppRail, target)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ class GenreRailScreenshotTest {
|
||||
navigationFocusRequester = FocusRequester(),
|
||||
activeFocusRequester = FocusRequester(),
|
||||
onCategoryFocused = {},
|
||||
onCategorySelected = {},
|
||||
onEnterContent = { false },
|
||||
focusForCapture = focusForCapture,
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ class ServicesRailScreenshotTest {
|
||||
navigationFocusRequester = FocusRequester(),
|
||||
activeFocusRequester = FocusRequester(),
|
||||
onCategoryFocused = {},
|
||||
onCategorySelected = {},
|
||||
onEnterContent = { false },
|
||||
focusForCapture = genreActiveId.takeIf { activeId == genreActiveId && activeId != ALL_SERVICES_CATEGORY_ID && services.none { it.id == activeId } },
|
||||
topContent = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotFocused
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
@@ -56,6 +57,11 @@ class SettingsRailFocusTest {
|
||||
|
||||
compose.onNodeWithTag("settings-rail-playback").requestFocus()
|
||||
compose.waitForIdle()
|
||||
// The rail settles onto the page a beat after focus lands on it; Right is only a
|
||||
// clean move into the pane once that page is the one actually drawn.
|
||||
compose.waitUntil {
|
||||
compose.onAllNodesWithTag("settings-page-playback").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-rail-playback").assertIsNotFocused()
|
||||
|
||||
Reference in New Issue
Block a user