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