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
|
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
|
the strip, and an empty or failed genre hands focus back deterministically rather than
|
||||||
leaving a television with nothing focused.
|
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
|
- **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.
|
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
|
- **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,
|
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.
|
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
|
**`HomeScreen` is split three ways, and it had to be.** It was one ~2670-line `@Composable`
|
||||||
the case: it cannot reasonably collect `HomeUiState` in one place, because reading the
|
that exceeded ART's method compiler limit (20 926 instructions vs the 10 000 optimiser
|
||||||
whole object there meant an arriving update verdict, a slow-connection banner or any one
|
cutoff) — so it ran **fully interpreted** — and every rail-section switch or overlay toggle
|
||||||
of the four section loads invalidated the launcher *and* rebuilt every row with it. So
|
re-executed the whole tree, freezing a Chromecast for 1–2 s per press. It is now
|
||||||
`HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and
|
`HomeScreen` (view models, effects, the rail) → `HomeContentPane` (`HomeContentPane.kt`, the
|
||||||
their loading flags), `status` (connection health) and `appUpdate` — and the screen
|
per-destination panes and row/hero machinery) and `HomeOverlays` (`HomeOverlayHost.kt`, the
|
||||||
subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather
|
whole overlay stack: Settings, Profiles, the user picker, the detail page, My Shows,
|
||||||
than introducing a separate type, which is what lets `homeRowsFor` keep taking a
|
Requests, Alerts, the quick-actions menu, the loading overlay, the top banners), plus
|
||||||
`HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it.
|
`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:
|
**Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read:
|
||||||
`MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/
|
`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?)
|
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||||
|
|
||||||
val defaultVersionName = "0.3.35"
|
val defaultVersionName = "0.3.37"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
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.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@@ -94,17 +97,6 @@ import kotlinx.coroutines.flow.conflate
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
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. */
|
/** How many rows of placeholder cards stand in for a genre that has not answered yet. */
|
||||||
private const val GENRE_PLACEHOLDER_ROWS = 3
|
private const val GENRE_PLACEHOLDER_ROWS = 3
|
||||||
|
|
||||||
@@ -149,7 +141,6 @@ fun GenreBrowseScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onContentFocused: () -> Unit = {},
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val mediaLabel = remember(itemType) { genreMediaLabel(itemType) }
|
|
||||||
val browseViewModel: GenreBrowseViewModel = viewModel(
|
val browseViewModel: GenreBrowseViewModel = viewModel(
|
||||||
key = "genre-browse-${itemType.lowercase().ifEmpty { "all" }}",
|
key = "genre-browse-${itemType.lowercase().ifEmpty { "all" }}",
|
||||||
factory = remember(itemType) {
|
factory = remember(itemType) {
|
||||||
@@ -157,49 +148,173 @@ fun GenreBrowseScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
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
|
// 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.
|
// 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.
|
// It is restored from [GenreBrowseFocusMemory] — arriving at Genres puts the viewer
|
||||||
// The view model outlives this screen — it is scoped to the activity, so its pages
|
// back on the provider or genre they last had open, for the life of the app process. A
|
||||||
// survive a trip to Home and back, which is what makes returning to a genre free — but
|
// stale or unknown id falls back to the destination's own default.
|
||||||
// arriving at Genres is arriving at the top of a catalogue, not resuming a place in
|
var activeCategoryId by remember(itemType) {
|
||||||
// one, and a viewer who went Home a day ago has no reason to be put back in Crime.
|
mutableStateOf(GenreBrowseFocusMemory.activeId?.takeIf { it in knownIds } ?: initialCategoryId)
|
||||||
var activeCategoryId by remember(initialCategoryId) { mutableStateOf(initialCategoryId) }
|
}
|
||||||
var contentHasFocus by remember { mutableStateOf(false) }
|
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
|
// Mirrors of the content's zone and grid origin, kept here only so the process-scoped
|
||||||
// into the rail has to name *which* genre it is going back to. Handing the rail a
|
// memory can be written from one place as any of the three changes.
|
||||||
// single "the active one" requester that moved between items as the selection changed
|
var lastZone by remember(itemType) { mutableStateOf(restoredZone) }
|
||||||
// was what put a returning viewer on the genre they had passed through rather than on
|
var lastOrigin by remember(itemType) { mutableStateOf(GenreBrowseFocusMemory.origin) }
|
||||||
// the one they had chosen: a requester that changes which node it is attached to has,
|
LaunchedEffect(lastZone, lastOrigin, activeCategoryId) {
|
||||||
// for a moment, two, and the one it answers with is not the one under the marker.
|
GenreBrowseFocusMemory.remember(lastZone, activeCategoryId, lastOrigin)
|
||||||
val railFocusRequesters = rememberFocusRequesterMap(
|
}
|
||||||
genreCategoryTabs(itemType).map(GenreCategory::id),
|
|
||||||
|
// 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.
|
* The Genre Browser's rendering and focus graph, split from [GenreBrowseScreen] so it can be
|
||||||
val serviceFocusRequesters = rememberFocusRequesterMap(
|
* driven by a fixture with a canned [GenreBrowseUiState] — the `SettingsPanelContent`
|
||||||
serviceCategoryTabs().map(GenreCategory::id),
|
* 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
|
val allEntryFocusRequesters = railFocusRequesters + serviceFocusRequesters
|
||||||
|
|
||||||
// One grid state per genre, so returning to a genre returns to where it was left
|
var zone by remember(itemType) { mutableStateOf(initialZone) }
|
||||||
// rather than to the top of it. Bounded by the catalogue, which is a fixed list.
|
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 gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
|
||||||
val focusedCardIndexes = remember(itemType) { mutableMapOf<String, Int>() }
|
val focusedCardIndexes = remember(itemType) { mutableMapOf<String, Int>() }
|
||||||
val gridEntryFocusRequester = remember { FocusRequester() }
|
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) }
|
var gridEntryIndex by remember(itemType) { mutableStateOf(0) }
|
||||||
|
|
||||||
val shownCategoryId = state.selectedCategoryId
|
val shownCategoryId = state.selectedCategoryId
|
||||||
@@ -207,92 +322,103 @@ fun GenreBrowseScreen(
|
|||||||
(state.categories + state.services).firstOrNull { it.id == shownCategoryId }
|
(state.categories + state.services).firstOrNull { it.id == shownCategoryId }
|
||||||
?: genreCategory(itemType, 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
|
/** Puts the remote back on the filter the grid belongs to — its recorded origin. */
|
||||||
// held D-pad, so it must not delay the genre the screen opens on — and the view model
|
fun focusRail(): Boolean {
|
||||||
// remembering a previous visit's selection is exactly what would otherwise make the
|
val target = resultsOrigin?.id ?: activeCategoryId
|
||||||
// opening genre look like a change.
|
val moved = requestFocusAmong(
|
||||||
var railTravelled by remember(itemType) { mutableStateOf(false) }
|
allEntryFocusRequesters[target],
|
||||||
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(
|
|
||||||
allEntryFocusRequesters[activeCategoryId],
|
allEntryFocusRequesters[activeCategoryId],
|
||||||
contentFocusRequester,
|
contentFocusRequester,
|
||||||
)
|
)
|
||||||
|
if (moved) {
|
||||||
LaunchedEffect(Unit) {
|
setZone(if (target in serviceIds) GenreZone.SERVICE_FILTERS else GenreZone.GENRE_LIST)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
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 {
|
fun enterGrid(): Boolean {
|
||||||
val items = state.items
|
val items = state.items
|
||||||
if (items.isEmpty()) return false
|
if (items.isEmpty()) return false
|
||||||
// Where this genre was left comes first, and the card a detail page was opened
|
val origin = resultsOriginFor(activeCategoryId, serviceIds, genreIds)
|
||||||
// from is the fallback. The other order goes stale: returnFocusItemId is not
|
resultsOrigin = origin
|
||||||
// cleared once its restore is done, so it would keep pulling every later press
|
onGridEntered(origin)
|
||||||
// back to a card the viewer has long since scrolled past.
|
// 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 }
|
val remembered = focusedCardIndexes[shownCategoryId]?.takeIf { it in items.indices }
|
||||||
?: returnFocusItemId?.let { id -> items.indexOfFirst { it.id == id }.takeIf { i -> i >= 0 } }
|
?: returnFocusItemId?.let { id -> items.indexOfFirst { it.id == id }.takeIf { i -> i >= 0 } }
|
||||||
?: 0
|
?: 0
|
||||||
gridEntryIndex = remembered
|
gridEntryIndex = remembered
|
||||||
scope.launch {
|
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) }
|
runCatching { gridState.scrollToItem(remembered) }
|
||||||
requestFocusWithRetries(gridEntryFocusRequester, attempts = 3)
|
if (requestFocusWithRetries(gridEntryFocusRequester, attempts = 3)) {
|
||||||
|
setZone(GenreZone.RESULTS_GRID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
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)) {
|
Box(modifier.fillMaxSize().background(MembySurface)) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
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(
|
GenreRail(
|
||||||
categories = state.categories,
|
categories = state.categories,
|
||||||
activeCategoryId = activeCategoryId,
|
activeCategoryId = activeCategoryId,
|
||||||
|
selectedCategoryId = shownCategoryId,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
activeFocusRequester = contentFocusRequester,
|
activeFocusRequester = contentFocusRequester,
|
||||||
itemFocusRequesters = railFocusRequesters,
|
itemFocusRequesters = railFocusRequesters,
|
||||||
onCategoryFocused = { id ->
|
onCategoryFocused = { id ->
|
||||||
onContentFocused()
|
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
|
// 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
|
// 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.
|
// 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 ->
|
topContent = state.services.takeIf { it.isNotEmpty() }?.let { services ->
|
||||||
{
|
{
|
||||||
ServicesRail(
|
ServicesRail(
|
||||||
@@ -304,12 +430,14 @@ fun GenreBrowseScreen(
|
|||||||
itemFocusRequesters = serviceFocusRequesters,
|
itemFocusRequesters = serviceFocusRequesters,
|
||||||
onServiceFocused = { id ->
|
onServiceFocused = { id ->
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
activeCategoryId = id
|
lastServiceFocus = id
|
||||||
|
setZone(GenreZone.SERVICE_FILTERS)
|
||||||
|
onFilterFocused(id)
|
||||||
},
|
},
|
||||||
// Down out of the strip lands on the top genre, in whatever
|
onServiceSelected = { id -> onFilterSelected(id) },
|
||||||
// order personalisation put it in — never a fixed one.
|
// Down out of the strip lands on the last genre the remote was
|
||||||
downFocusRequester = railFocusRequesters[state.categories.firstOrNull()?.id],
|
// on, in this viewer's own order — never a fixed one.
|
||||||
onEnterContent = ::enterGrid,
|
downFocusRequester = genresEntryRequester,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -318,7 +446,7 @@ fun GenreBrowseScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.onFocusChanged { contentHasFocus = it.hasFocus },
|
.onFocusChanged { if (it.hasFocus) setZone(GenreZone.RESULTS_GRID) },
|
||||||
) {
|
) {
|
||||||
val horizontalPadding = 40.dp
|
val horizontalPadding = 40.dp
|
||||||
val spacing = 18.dp
|
val spacing = 18.dp
|
||||||
@@ -373,7 +501,7 @@ fun GenreBrowseScreen(
|
|||||||
)
|
)
|
||||||
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||||
message = state.errorMessage.orEmpty(),
|
message = state.errorMessage.orEmpty(),
|
||||||
onRetry = browseViewModel::retry,
|
onRetry = onRetry,
|
||||||
navigationFocusRequester = contentFocusRequester,
|
navigationFocusRequester = contentFocusRequester,
|
||||||
)
|
)
|
||||||
state.items.isEmpty() -> GenreMessage(
|
state.items.isEmpty() -> GenreMessage(
|
||||||
@@ -396,7 +524,7 @@ fun GenreBrowseScreen(
|
|||||||
.conflate()
|
.conflate()
|
||||||
.collect { last ->
|
.collect { last ->
|
||||||
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
||||||
browseViewModel.loadMore()
|
onLoadMore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,11 +567,13 @@ fun GenreBrowseScreen(
|
|||||||
onFocused = {
|
onFocused = {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
||||||
|
setZone(GenreZone.RESULTS_GRID)
|
||||||
onItemFocused(displayedItem)
|
onItemFocused(displayedItem)
|
||||||
},
|
},
|
||||||
onClick = { onItemSelected(displayedItem) },
|
onClick = { onItemSelected(displayedItem) },
|
||||||
onLongClick = { onItemSelected(displayedItem) },
|
onLongClick = { onItemSelected(displayedItem) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag("genre-card-$index")
|
||||||
.then(
|
.then(
|
||||||
if (item.id == returnFocusItemId) {
|
if (item.id == returnFocusItemId) {
|
||||||
Modifier.focusRequester(returnFocusRequester)
|
Modifier.focusRequester(returnFocusRequester)
|
||||||
@@ -458,17 +588,17 @@ fun GenreBrowseScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Left out of the first column is the way back
|
// Left out of the first column returns to the
|
||||||
// to the genre this grid belongs to, named
|
// filter that opened this grid — its recorded
|
||||||
// directly so the press cannot land on a genre
|
// origin, so the press cannot land on a filter
|
||||||
// merely travelled through. Only the first
|
// merely travelled through. Only the first
|
||||||
// column carries the node — the rest would run
|
// column carries the node.
|
||||||
// an empty focusProperties lambda for nothing.
|
|
||||||
.then(
|
.then(
|
||||||
if (isFirstColumn) {
|
if (isFirstColumn) {
|
||||||
Modifier.focusProperties {
|
Modifier.focusProperties {
|
||||||
left = railFocusRequesters[activeCategoryId]
|
left = allEntryFocusRequesters[
|
||||||
?: contentFocusRequester
|
resultsOrigin?.id ?: activeCategoryId,
|
||||||
|
] ?: contentFocusRequester
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
@@ -484,7 +614,7 @@ fun GenreBrowseScreen(
|
|||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
GenreRetry(
|
GenreRetry(
|
||||||
message = state.errorMessage.orEmpty(),
|
message = state.errorMessage.orEmpty(),
|
||||||
onRetry = browseViewModel::retry,
|
onRetry = onRetry,
|
||||||
navigationFocusRequester = contentFocusRequester,
|
navigationFocusRequester = contentFocusRequester,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -509,10 +639,15 @@ fun GenreBrowseScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun GenreRail(
|
internal fun GenreRail(
|
||||||
categories: List<GenreCategory>,
|
categories: List<GenreCategory>,
|
||||||
|
/** The genre the D-pad marker is on — the entry target and the bright focus plate. */
|
||||||
activeCategoryId: String,
|
activeCategoryId: String,
|
||||||
|
/** The genre whose grid is showing — the quiet accent wash when focus is elsewhere. */
|
||||||
|
selectedCategoryId: String? = activeCategoryId,
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
activeFocusRequester: FocusRequester,
|
activeFocusRequester: FocusRequester,
|
||||||
onCategoryFocused: (String) -> Unit,
|
onCategoryFocused: (String) -> Unit,
|
||||||
|
/** SELECT / OK on a genre: filter the grid and move focus into it. */
|
||||||
|
onCategorySelected: (String) -> Unit,
|
||||||
onEnterContent: () -> Boolean,
|
onEnterContent: () -> Boolean,
|
||||||
/**
|
/**
|
||||||
* One requester per genre, owned by the screen: every way back into the rail names the
|
* 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,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 1.6.sp,
|
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(
|
LazyColumn(
|
||||||
state = railState,
|
state = railState,
|
||||||
@@ -605,11 +740,12 @@ internal fun GenreRail(
|
|||||||
val requester = requesterFor(category.id)
|
val requester = requesterFor(category.id)
|
||||||
GenreRailItem(
|
GenreRailItem(
|
||||||
category = category,
|
category = category,
|
||||||
active = category.id == activeCategoryId,
|
active = category.id == selectedCategoryId,
|
||||||
focusedForCapture = category.id == focusForCapture,
|
focusedForCapture = category.id == focusForCapture,
|
||||||
onFocused = { onCategoryFocused(category.id) },
|
onFocused = { onCategoryFocused(category.id) },
|
||||||
onClick = { onCategoryFocused(category.id) },
|
onClick = { onCategorySelected(category.id) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag("genre-rail-${category.id}")
|
||||||
.focusRequester(requester)
|
.focusRequester(requester)
|
||||||
// A second requester on the same node: the screen's entry target
|
// 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
|
// is the genre in force, and it is also what Left out of the
|
||||||
@@ -766,26 +902,21 @@ internal fun ServicesRail(
|
|||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
activeFocusRequester: FocusRequester,
|
activeFocusRequester: FocusRequester,
|
||||||
onServiceFocused: (String) -> Unit,
|
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
|
* 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.
|
* are: every way back into this strip names *which* icon it is returning to.
|
||||||
*/
|
*/
|
||||||
itemFocusRequesters: Map<String, FocusRequester> = emptyMap(),
|
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,
|
downFocusRequester: FocusRequester? = null,
|
||||||
/** The service whose shelf the grid is currently showing, drawn as the quiet wash. */
|
/** The service whose shelf the grid is currently showing, drawn as the quiet wash. */
|
||||||
selectedServiceId: String? = null,
|
selectedServiceId: String? = null,
|
||||||
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
|
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
|
||||||
focusForCapture: String? = null,
|
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 }) {
|
val ownedFocusRequesters = remember(services.map { it.id }) {
|
||||||
services.associate { it.id to FocusRequester() }
|
services.associate { it.id to FocusRequester() }
|
||||||
}
|
}
|
||||||
@@ -818,20 +949,26 @@ internal fun ServicesRail(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusGroup()
|
.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 ->
|
.onKeyEvent { event ->
|
||||||
if (event.type != KeyEventType.KeyDown || event.key != Key.DirectionRight) {
|
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
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
|
if (current < 0 || target !in services.indices) return@onKeyEvent false
|
||||||
// things here: walk to the next icon, or — only once there is no
|
stripScope.launch {
|
||||||
// next icon — enter the grid. Consuming it unconditionally is what
|
runCatching { stripState.scrollToItem(target) }
|
||||||
// made Right on Netflix or Apple TV jump straight into the grid
|
requestFocusWithRetries(requesterFor(services[target].id), attempts = 3)
|
||||||
// instead of reaching the icon beside it, so every other icon has
|
}
|
||||||
// to let the press fall through to the item's own focusProperties
|
true
|
||||||
// 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()
|
|
||||||
},
|
},
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 14.dp),
|
contentPadding = PaddingValues(start = 16.dp, end = 14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
@@ -843,8 +980,9 @@ internal fun ServicesRail(
|
|||||||
active = service.id == selectedServiceId,
|
active = service.id == selectedServiceId,
|
||||||
focusedForCapture = service.id == focusForCapture,
|
focusedForCapture = service.id == focusForCapture,
|
||||||
onFocused = { onServiceFocused(service.id) },
|
onFocused = { onServiceFocused(service.id) },
|
||||||
onClick = { onServiceFocused(service.id) },
|
onClick = { onServiceSelected(service.id) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag("genre-service-${service.id}")
|
||||||
.focusRequester(requester)
|
.focusRequester(requester)
|
||||||
.then(
|
.then(
|
||||||
if (service.id == activeServiceId) {
|
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.
|
// A tight gap and a hairline — enough to read the two strips as related but
|
||||||
Spacer(Modifier.height(18.dp))
|
// separate without a slab of empty rail between them.
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 22.dp)
|
.padding(horizontal = 22.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(1.dp)
|
.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,
|
radius = radius,
|
||||||
center = centre,
|
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 {
|
val ringAlpha = when {
|
||||||
focused -> 1f
|
focused -> 1f
|
||||||
active -> 0.5f
|
active -> 0.7f
|
||||||
else -> 0f
|
else -> 0f
|
||||||
}
|
}
|
||||||
if (ringAlpha > 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(
|
drawCircle(
|
||||||
color = Color.White.copy(alpha = ringAlpha),
|
color = Color.White.copy(alpha = ringAlpha),
|
||||||
radius = radius - (1.5f + 1.5f * emphasis.value).dp.toPx() / 2f,
|
radius = radius - stroke / 2f,
|
||||||
center = centre,
|
center = centre,
|
||||||
style = androidx.compose.ui.graphics.drawscope.Stroke(
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
|
||||||
width = (2.dp + 1.dp * emphasis.value).toPx(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// page list; this makes Up say the same thing once the pane has no row above.
|
||||||
val railSelectionFocusRequester = remember { FocusRequester() }
|
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 focusManager = LocalFocusManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -841,11 +847,11 @@ internal fun SettingsPanelContent(
|
|||||||
when {
|
when {
|
||||||
event.type != KeyEventType.KeyDown -> false
|
event.type != KeyEventType.KeyDown -> false
|
||||||
event.key != Key.DirectionUp -> false
|
event.key != Key.DirectionUp -> false
|
||||||
focusManager.moveFocus(FocusDirection.Up) -> true
|
paneTopFocused -> {
|
||||||
else -> {
|
|
||||||
scope.launch { requestFocusWithRetries(railSelectionFocusRequester) }
|
scope.launch { requestFocusWithRetries(railSelectionFocusRequester) }
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
else -> focusManager.moveFocus(FocusDirection.Up)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.verticalScroll(contentScrollState)
|
.verticalScroll(contentScrollState)
|
||||||
@@ -857,7 +863,11 @@ internal fun SettingsPanelContent(
|
|||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
|
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)
|
SettingsHeader(page = state.selectedPage)
|
||||||
when (state.selectedPage) {
|
when (state.selectedPage) {
|
||||||
SettingsPage.APPEARANCE -> SettingsGroup {
|
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(),
|
navigationFocusRequester = FocusRequester(),
|
||||||
activeFocusRequester = FocusRequester(),
|
activeFocusRequester = FocusRequester(),
|
||||||
onCategoryFocused = {},
|
onCategoryFocused = {},
|
||||||
|
onCategorySelected = {},
|
||||||
onEnterContent = { false },
|
onEnterContent = { false },
|
||||||
focusForCapture = focusForCapture,
|
focusForCapture = focusForCapture,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class ServicesRailScreenshotTest {
|
|||||||
navigationFocusRequester = FocusRequester(),
|
navigationFocusRequester = FocusRequester(),
|
||||||
activeFocusRequester = FocusRequester(),
|
activeFocusRequester = FocusRequester(),
|
||||||
onCategoryFocused = {},
|
onCategoryFocused = {},
|
||||||
|
onCategorySelected = {},
|
||||||
onEnterContent = { false },
|
onEnterContent = { false },
|
||||||
focusForCapture = genreActiveId.takeIf { activeId == genreActiveId && activeId != ALL_SERVICES_CATEGORY_ID && services.none { it.id == activeId } },
|
focusForCapture = genreActiveId.takeIf { activeId == genreActiveId && activeId != ALL_SERVICES_CATEGORY_ID && services.none { it.id == activeId } },
|
||||||
topContent = {
|
topContent = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.input.key.Key
|
|||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.assertIsNotFocused
|
import androidx.compose.ui.test.assertIsNotFocused
|
||||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
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.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
@@ -56,6 +57,11 @@ class SettingsRailFocusTest {
|
|||||||
|
|
||||||
compose.onNodeWithTag("settings-rail-playback").requestFocus()
|
compose.onNodeWithTag("settings-rail-playback").requestFocus()
|
||||||
compose.waitForIdle()
|
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.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||||
compose.waitForIdle()
|
compose.waitForIdle()
|
||||||
compose.onNodeWithTag("settings-rail-playback").assertIsNotFocused()
|
compose.onNodeWithTag("settings-rail-playback").assertIsNotFocused()
|
||||||
|
|||||||
Reference in New Issue
Block a user