diff --git a/CLAUDE.md b/CLAUDE.md index 08587e4..c2f1314 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -855,6 +855,41 @@ at a time. Things to preserve: results; a genre result claims its own requester when the first page lands, Back restores the strip, and an empty or failed genre hands focus back deterministically rather than leaving a television with nothing focused. +- **The four focus zones are one graph, and it is a pure function.** `ui/genre/GenreNavigation.kt` + — `GenreZone` (`APP_RAIL` / `SERVICE_FILTERS` / `GENRE_LIST` / `RESULTS_GRID`), `ResultsOrigin`, + and `genreFocusMove(zone, key, ctx)` returning a `GenreFocusTarget` — is the whole of the + zone-boundary behaviour, unit-tested exhaustively (`GenreNavigationTest`) the way `detailTabs` + and `calendarMonthFocusAnchor` are. Interior travel (the next icon, genre, poster) stays with + Compose's per-item `focusProperties` and comes back as `Fallthrough`. `GenreBrowseScreen` was + split into `GenreBrowseScreen` (view-model glue) and `GenreBrowseContent` (rendering + the + graph), the `SettingsPanelContent` precedent, so `GenreBrowserFocusTest` drives the regression + scenarios through real key events with a canned `GenreBrowseUiState`. Things to preserve: + - **The grid follows the committed filter, not the D-pad.** Moving the marker across the + rail or the services strip only changes the highlight (`activeCategoryId`); the results + pane, its heading and the rail's own accent wash stay on `state.selectedCategoryId`, + which changes only on OK or Right-into-grid. This is the contract's focus-vs-selection + split, and it is the opposite of the old rule (which recomposed the pane on every focus + move). `GenreRailItem`/`ServiceIconButton` draw the bright plate/ring from `hasFocus` + and the quiet wash from the committed id. The first commit is seeded once from the + filter the screen opens on, since a fresh browser has nothing selected. + - **OK on a service or genre filters *and* enters the grid.** The grid can only be + focused once its first page has arrived, so the press is held in `pendingGridEntry` + and acted on when the results land — and dropped if they land empty, because focus must + never move into an empty grid. Right out of a genre commits it and enters its grid; + Right off the services strip's last icon is a predictable dead edge, never a jump. + - **Left off the grid's first column returns to the *originating* filter**, tracked as + `ResultsOrigin` rather than inferred from which focusable sits nearest the edge. Enter a + Netflix-filtered grid and Left comes back to Netflix; enter it from Drama and Left comes + back to Drama. + - **The Services strip walks with an `onKeyEvent` on the `LazyRow`, not per-icon + `focusProperties`.** More services than fit the 214dp rail is a matter of time, and an + off-screen icon has no attached requester for a `focusProperties` target to name — the + press scrolls it into composition first, the `GenreRail` Right-into-grid precedent. Right + off the last icon is a predictable dead edge; a service never enters the grid by a Right + press. + - **Re-entry restores the last provider/genre and zone** via `GenreBrowseFocusMemory` + (process-scoped, the `DetailPositionStore` precedent; deliberately not persisted — a set + switched on the next morning opens at the top of the catalogue). - **Episodes are excluded on both paths.** An episode inherits its series' genres, so including them fills a page with twenty entries of one comedy and buries the rest. - **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful @@ -2778,15 +2813,33 @@ recompositions of one number over ten seconds instead of ~600 of the whole bar. rule applies to collecting flows — collect in the smallest composable that needs the value, not at the top of `MainActivity`, or every emission recomposes the launcher. -**Where a composable is too large to split, narrow the state instead.** `HomeScreen` is -the case: it cannot reasonably collect `HomeUiState` in one place, because reading the -whole object there meant an arriving update verdict, a slow-connection banner or any one -of the four section loads invalidated the launcher *and* rebuilt every row with it. So -`HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and -their loading flags), `status` (connection health) and `appUpdate` — and the screen -subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather -than introducing a separate type, which is what lets `homeRowsFor` keep taking a -`HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it. +**`HomeScreen` is split three ways, and it had to be.** It was one ~2670-line `@Composable` +that exceeded ART's method compiler limit (20 926 instructions vs the 10 000 optimiser +cutoff) — so it ran **fully interpreted** — and every rail-section switch or overlay toggle +re-executed the whole tree, freezing a Chromecast for 1–2 s per press. It is now +`HomeScreen` (view models, effects, the rail) → `HomeContentPane` (`HomeContentPane.kt`, the +per-destination panes and row/hero machinery) and `HomeOverlays` (`HomeOverlayHost.kt`, the +whole overlay stack: Settings, Profiles, the user picker, the detail page, My Shows, +Requests, Alerts, the quick-actions menu, the loading overlay, the top banners), plus +`rememberHomePlayback` (`HomePlayback.kt`, the `ActivityResultLauncher` and stream +resolution). Things to preserve: +- **Shared launcher state is `HomeScreenState`** (`HomeScreenState.kt`), a single `@Stable` + holder `rememberSaveable`d keyed on `settings.userId` (matching the `remember(userId)` + reset semantics it replaces; a `Saver` persists only the navigation fields). Passing it as + one stable param is what keeps `HomeContentPane` and `HomeOverlays` independently + skippable — each recomposes only for the properties it reads. Do not go back to threading + 40 value/setter pairs, and do not read the whole holder anywhere it isn't needed. +- **The content area is not wrapped in `BoxWithConstraints`.** That was a `SubcomposeLayout` + around the entire pane, so a section switch re-composed it *during the layout pass* + (measure → recompose → measure) — 1.4 s of one frame on the Chromecast. Screen size comes + from `LocalConfiguration` instead (`screenWidthDp - TvRailCollapsedWidth`); the window is + the screen on a TV. +- **Narrow the projection, not the split.** `HomeViewModel` still exposes + `distinctUntilChanged` projections — `content` (rows + loading flags), `status` + (connection health), `forYou`, `favoriteChanges`, `playedChanges` — collected where they + are rendered so an arriving update verdict or a slow-connection banner does not rebuild + every row. `contentSlice()` blanks the non-row fields rather than adding a type, so + `homeRowsFor` keeps taking a `HomeUiState` and its tests keep working. **Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read: `MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7f9ff7..d04745d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeContentPane.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeContentPane.kt new file mode 100644 index 0000000..ff7b7d3 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeContentPane.kt @@ -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, + playedChanges: Map, + liveMaintenance: com.ponzischeme89.memby.data.MaintenanceNotice?, + rows: List, + contextualHeroItems: List, + metadataHeroContentOrder: List, + metadataHeroTimeRemainingColour: String, + navigationFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + cardReturnFocusRequester: FocusRequester, + heroRowEntryFocusRequester: FocusRequester, + myShowsEntryFocusRequester: FocusRequester, + myShowReturnFocusRequester: FocusRequester, + verticalStates: MutableMap, + horizontalStates: MutableMap, + rememberedRowFocus: MutableMap>, + destinationFocus: MutableMap>, + 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(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() }, + ) + } + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlayHost.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlayHost.kt new file mode 100644 index 0000000..b14c045 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlayHost.kt @@ -0,0 +1,1219 @@ +@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) + +package com.ponzischeme89.memby.ui + +import android.content.Context +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.tv.material3.Text +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.EmbyRepository +import com.ponzischeme89.memby.data.NotificationSurface +import com.ponzischeme89.memby.data.ServerConfig +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.friendlyEmbyError +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.UserNotification +import com.ponzischeme89.memby.performance.StartupTrace +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig +import com.ponzischeme89.memby.ui.alerts.MyAlertsPage +import com.ponzischeme89.memby.ui.profiles.ProfileChooser +import com.ponzischeme89.memby.ui.requests.RequestsScreen +import com.ponzischeme89.memby.ui.requests.RequestsViewModel +import com.ponzischeme89.memby.ui.requests.RequestsViewModelFactory +import com.ponzischeme89.memby.ui.settings.SettingsSheet +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.viewers.MAX_SHADOW_VIEWERS +import com.ponzischeme89.memby.ui.viewers.ViewerManageScreen +import com.ponzischeme89.memby.ui.viewers.ViewerNameEntry +import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget +import com.ponzischeme89.memby.ui.viewers.ViewerPicker +import com.ponzischeme89.memby.ui.viewers.ViewerPinEntry +import com.ponzischeme89.memby.ui.viewers.shouldOfferViewerPicker +import com.ponzischeme89.memby.ui.viewers.viewerNameFor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * The launcher's overlay stack — every full-screen page and dialog that layers over + * [HomeScreen]: the user picker, viewer management, Settings, Profiles, the detail page, My + * Shows, My Requests, My Alerts, the long-press quick-actions menu, the playback-launch + * loading screen, and the two top banners. + * + * Extracted from `HomeScreen` (one ~2670-line @Composable, over ART's method compiler limit + * and therefore interpreted) so opening any of these recomposes only this subtree rather + * than the rail and the row panes. Shared state is [HomeScreenState]; see its KDoc. + */ +@Composable +internal fun BoxScope.HomeOverlayHost( + homeState: HomeScreenState, + settings: Settings, + homeViewModel: HomeViewModel, + repo: EmbyRepository, + scope: CoroutineScope, + context: Context, + remoteConfig: MembyRemoteConfig, + remoteMarkPath: String?, + profileViewModelOwner: androidx.lifecycle.ViewModelStoreOwner, + journeyScreen: String, + showHomeGreeting: Boolean, + compatibilityNotice: com.ponzischeme89.memby.data.CompatibilityNotice?, + liveMaintenance: com.ponzischeme89.memby.data.MaintenanceNotice?, + embyOutage: com.ponzischeme89.memby.data.EmbyOutage?, + seriesStatusRevision: Long, + detailExperience: String, + requestsAllowed: Boolean, + viewersEnabled: Boolean, + tvCalendarEnabled: Boolean, + hasContextualHero: Boolean, + rows: List, + navigationFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + cardReturnFocusRequester: FocusRequester, + requestsRailFocusRequester: FocusRequester, + myShowReturnFocusRequester: FocusRequester, + heroRowEntryFocusRequester: FocusRequester, + openViewerName: (ViewerNameTarget) -> Unit, + refreshViewers: suspend () -> Unit, + abandonLaunch: () -> Unit, + playItem: (BaseItem) -> Unit, + playTrailer: (BaseItem) -> Unit, +) { + val displayedNotifications = homeState.notificationState.notifications + AnimatedVisibility( + visible = compatibilityNotice != null, + modifier = Modifier.align(Alignment.TopCenter), + enter = fadeIn(tween(180)), + exit = fadeOut(tween(120)), + ) { + Text( + text = compatibilityNotice?.message.orEmpty(), + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFD6403A)) + .padding(horizontal = 36.dp, vertical = 12.dp), + ) + } + HomeClock( + showGreeting = showHomeGreeting, + username = settings.username, + shortName = settings.shortName, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 18.dp), + ) + if (homeState.userSwitcherVisible) { + BackHandler { + homeState.userSwitcherVisible = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + UserSwitcherOverlay( + profiles = settings.profiles, + activeProfileId = settings.activeProfileId, + onProfileSelected = { profile -> + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + if (profile.id != settings.activeProfileId) { + homeState.switchingProfileId = profile.id + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + runCatching { repo.switchProfile(profile) } + .onFailure { homeState.switchingProfileId = null } + } + } else { + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + }, + onManageProfiles = { + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + homeState.showProfiles = true + }, + alertCount = displayedNotifications.size, + onOpenSettings = { + homeViewModel.trackJourney( + category = "navigation", action = "select", + screen = journeyScreen, feature = "settings", + source = journeyScreen, target = "settings", + ) + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + homeState.railFocusDestination = BrowseDestination.PROFILES + homeState.restoreRailAfterSettings = true + homeState.showSettings = true + }, + onOpenAlerts = { + homeViewModel.trackJourney( + category = "notifications", action = "open", screen = journeyScreen, + feature = "notifications", target = "notifications", + ) + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + homeState.showNotifications = true + scope.launch { + homeState.notificationsLoading = true + homeState.notificationsError = null + runCatching { repo.getNotifications() } + .onSuccess { homeState.notificationState = it } + .onFailure { homeState.notificationsError = friendlyEmbyError(it) } + homeState.notificationsLoading = false + } + }, + showViewers = shouldOfferViewerPicker( + gatewayMode = ServerConfig.isGateway, + enabled = viewersEnabled, + viewerCount = homeState.viewers.size, + ), + viewers = homeState.viewers, + activeViewerId = settings.activeViewerId, + // Selecting a person is the panel's own press now rather than a screen + // reached from it, which is the whole point: this is the thing a household + // changes nightly. Everything on the launcher belongs to the outgoing + // viewer, so the journey is closed and the rows replaced rather than left + // standing under a different person's name. Changing the viewer rebuilds + // the shared launcher lifecycle, including its ViewModel and focus graph. + onViewerSelected = { viewer -> + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + repo.switchViewer(viewer) + } + }, + onAddViewer = { + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + openViewerName(ViewerNameTarget.Add) + }, + canAddViewer = homeState.viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, + onManageViewers = { + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + homeState.showViewerPicker = true + }, + showRequests = requestsAllowed, + onOpenRequests = { + homeViewModel.trackJourney( + category = "requests", action = "open", screen = journeyScreen, + feature = "requests", target = "requests", + ) + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false + homeState.showRequests = true + }, + onDismiss = { + homeState.userSwitcherVisible = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + }, + ) + } + if (homeState.showViewerPicker) { + val closeViewerPicker: (restoreFocus: Boolean) -> Unit = { restoreFocus -> + homeState.showViewerPicker = false + if (restoreFocus) { + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + } + BackHandler { closeViewerPicker(true) } + Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) { + ViewerPicker( + viewers = homeState.viewers, + activeViewerId = settings.activeViewerId, + onViewerSelected = { viewer -> + // Do not aim focus back into the outgoing viewer's graph while its + // user-keyed launcher is being disposed. + closeViewerPicker(false) + scope.launch { + // Everything on the launcher belongs to the outgoing viewer, so + // the journey is closed before the viewer-keyed launcher is rebuilt. + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + repo.switchViewer(viewer) + } + }, + // Both open over the picker rather than instead of it, so Back is one + // step out of each and the row of faces is still underneath. + onAddViewer = { openViewerName(ViewerNameTarget.Add) }, + onManageViewers = { homeState.showViewerManage = true }, + canAddViewer = homeState.viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, + ) + } + } + if (homeState.showViewerManage) { + BackHandler(onBack = { homeState.showViewerManage = false }) + Box(Modifier.fillMaxSize().zIndex(21f).background(MembySurface)) { + ViewerManageScreen( + viewers = homeState.viewers, + onRename = { openViewerName(ViewerNameTarget.Rename(it)) }, + onRemove = { viewer -> + homeState.viewerBusyId = viewer.id + scope.launch { + // Removing whoever is watching returns this set to the account, + // which the repository does; the launcher has to be told, or it + // goes on drawing the removed person's rows. + val watching = viewer.id == settings.activeViewerId + val removed = repo.removeViewer(viewer) + if (removed && watching) { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + } + refreshViewers() + homeState.viewerBusyId = null + } + }, + onAdd = { openViewerName(ViewerNameTarget.Add) }, + onClose = { homeState.showViewerManage = false }, + canAddViewer = homeState.viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, + busyViewerId = homeState.viewerBusyId, + onSetPin = { homeState.pinTarget = it; homeState.pinFailure = null; homeState.showViewerManage = false }, + ) + } + } + homeState.viewerNameTarget?.let { target -> + val closeViewerName = { homeState.viewerNameTarget = null } + BackHandler(onBack = closeViewerName) + Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) { + ViewerNameEntry( + target = target, + name = homeState.viewerName, + existing = homeState.viewers, + onNameChanged = { homeState.viewerName = it; homeState.viewerNameFailure = null }, + onCancel = closeViewerName, + onConfirm = { + if (!homeState.viewerSaving) { + homeState.viewerSaving = true + scope.launch { + val saved = when (target) { + ViewerNameTarget.Add -> repo.createViewer(homeState.viewerName) + is ViewerNameTarget.Rename -> + repo.renameViewer(target.viewer, homeState.viewerName) + } + homeState.viewerSaving = false + if (saved == null) { + // The one thing the television can say about a refusal + // it has no wording for. The screen stays up holding + // what was typed, because retyping a name somebody has + // just entered is the worst possible answer to a + // request that failed for a reason nothing here knows. + homeState.viewerNameFailure = "That could not be saved. Try again." + } else { + homeState.viewerNameTarget = null + refreshViewers() + } + } + } + }, + failure = homeState.viewerNameFailure, + saving = homeState.viewerSaving, + ) + } + } + homeState.pinTarget?.let { viewer -> + BackHandler(onBack = { homeState.pinTarget = null }) + Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) { + ViewerPinEntry( + viewer = viewer, + saving = homeState.pinSaving, + failure = homeState.pinFailure, + onCancel = { homeState.pinTarget = null }, + onConfirm = { pin -> + if (!homeState.pinSaving) { + homeState.pinSaving = true + scope.launch { + runCatching { repo.setViewerPIN(viewer, pin) } + .onSuccess { homeState.pinTarget = null; refreshViewers() } + .onFailure { homeState.pinFailure = "That PIN could not be saved." } + homeState.pinSaving = false + } + } + }, + ) + } + } + if (homeState.userQuickActionsVisible) { + val closeUserQuickActions: () -> Unit = { + homeState.userQuickActionsVisible = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + BackHandler(onBack = closeUserQuickActions) + UserQuickActionsOverlay( + username = settings.username.orEmpty(), + alertCount = displayedNotifications.size, + busy = homeState.notificationsMutationBusy, + // Clearing from here never takes the viewer anywhere: the menu closes, the + // badge on the rail behind it empties, and a toast says what happened. The + // list is emptied optimistically for the reason the alerts page already is — + // a badge that lingered while the request was in flight is one somebody + // clears twice — and put back untouched if the gateway refuses. + onClearNotifications = { + if (!homeState.notificationsMutationBusy) { + homeState.notificationsMutationBusy = true + val previous = homeState.notificationState + val offered = displayedNotifications.size + homeState.notificationState = homeState.notificationState.copy(notifications = emptyList()) + closeUserQuickActions() + scope.launch { + runCatching { repo.clearNotifications() } + .onSuccess { cleared -> + Toast.makeText( + context, + clearedNotificationsMessage(cleared), + Toast.LENGTH_SHORT, + ).show() + homeViewModel.trackJourney( + category = "notifications", action = "dismiss", + screen = journeyScreen, + feature = "user_switcher_clear_notifications", + source = "user_switcher", target = "notifications", + // The one free-text field, and the count is the only + // thing that separates one use of this shortcut from + // the next. + itemName = "$cleared cleared", + outcome = "success", + ) + } + .onFailure { + // Put the list back and say so here rather than writing + // notificationsError: this menu never opens the page, and + // an error banner waiting on a page nobody has opened is + // one they meet later with no idea what it is about. + homeState.notificationState = previous + Toast.makeText( + context, + "Couldn’t clear notifications", + Toast.LENGTH_SHORT, + ).show() + homeViewModel.trackJourney( + category = "notifications", action = "dismiss", + screen = journeyScreen, + feature = "user_switcher_clear_notifications", + source = "user_switcher", target = "notifications", + itemName = "$offered offered", + outcome = "failure", + ) + } + homeState.notificationsMutationBusy = false + } + } + }, + onDismiss = closeUserQuickActions, + ) + } + if (homeState.showSettings) { + val closeSettings: () -> Unit = { + homeState.showSettings = false + scope.launch { + delay(16.milliseconds) + if (homeState.restoreRailAfterSettings) { + homeState.navigationExpanded = true + runCatching { navigationFocusRequester.requestFocus() } + homeState.restoreRailAfterSettings = false + } else if (homeState.returnItemId != null) { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } else { + runCatching { contentFocusRequester.requestFocus() } + } + } + } + BackHandler(onBack = closeSettings) + // Settings is a destination, not a takeover: it leaves the main rail visible + // and reachable so Home, Search and the active user are one Left press away, the + // same as from every other page. The rail below is the live one — this Row + // only reserves its collapsed footprint and takes the same expand shift the + // content area does, so an expanded rail slides Settings aside rather than + // covering its own page list. + // The full expansion, not the home screen's TvRailContentShift: the rows + // tolerate the rail drawing over their left edge, but this panel is painted + // above the rail, so anything short of the whole width would clip the + // labels off the very rail the viewer is moving through. + val settingsShift by animateDpAsState( + targetValue = if (homeState.navigationExpanded) { + TvRailExpandedWidth - TvRailCollapsedWidth + } else { + 0.dp + }, + animationSpec = tween(150), + label = "settings-content-shift", + ) + Row(Modifier.fillMaxSize()) { + Spacer(Modifier.width(TvRailCollapsedWidth)) + SettingsSheet( + onClose = closeSettings, + overlay = false, + navigationFocusRequester = navigationFocusRequester, + onAnalyticsEvent = { feature, action -> + homeViewModel.trackJourney( + category = "settings", action = action, screen = "settings", + feature = feature, outcome = "success", + ) + }, + modifier = Modifier + .weight(1f) + .offset { IntOffset(x = settingsShift.roundToPx(), y = 0) }, + ) + } + } + if (homeState.showProfiles) { + BackHandler(onBack = { homeState.showProfiles = false }) + ProfileChooser( + profiles = settings.profiles, + currentProfileId = settings.activeProfileId, + switchingProfileId = homeState.switchingProfileId, + removingProfileId = homeState.removingProfileId, + onSelect = { profile -> + if (profile.id == settings.activeProfileId) { + homeState.showProfiles = false + } else { + homeState.switchingProfileId = profile.id + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + runCatching { repo.switchProfile(profile) } + .onFailure { homeState.switchingProfileId = null } + } + } + }, + onRemove = { profile -> + homeState.removingProfileId = profile.id + scope.launch { + runCatching { repo.removeProfile(profile) } + homeState.removingProfileId = null + } + }, + onAddProfile = { + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + homeState.addingProfile = true + } + }, + onClose = { homeState.showProfiles = false }, + ) + } + // Over the manage-users page rather than instead of it: cancelling comes straight + // back to the list the "+" was pressed from. A successful sign-in makes the new + // viewer active, which re-keys HomeScreen and takes both flags with it. + if (homeState.addingProfile) { + SetupScreen( + onCancel = { homeState.addingProfile = false }, + onSignedIn = { homeState.addingProfile = false }, + ) + } + // Coming back from the player. The page is reopened rather than restored from + // under the player — see [detailsResumeTarget] — and the item is re-requested on + // the way in, so what appears has this playback's progress on it rather than the + // record the row handed over before the film started. + LifecycleResumeEffect(Unit) { + homeState.detailsResumeTarget?.let { target -> + homeState.detailsResumeTarget = null + if (homeState.detailsItem == null) { + homeViewModel.focusItem(target.item) + homeState.detailsTrail = target.trail + homeState.detailsAiringNotice = target.airingNotice + homeState.restoreDetailPosition = true + homeState.detailsItem = target.item + } + } + onPauseOrDispose { } + } + homeState.detailsItem?.let { selected -> + // A detail page is opened by a press that sets `detailsItem`, so the composition + // this runs in *is* the frame that press caused — which makes it the earliest + // point that can honestly stand for "the viewer asked for this page". Keyed on + // the item, so walking a "More like this" trail times each page separately. + // Debug-only: on a release build both calls return before allocating. + remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) } + LaunchedEffect(selected.id) { StartupTrace.endSpan(StartupTrace.DETAIL) } + // Closing the page with nothing left in the trail — the hardware Back key and the + // "Back to Search Results" action both mean exactly this, so both call it rather + // than keeping two copies of what "leave the page" does. + val closeDetails: () -> Unit = { + homeState.restoreDetailPosition = false + homeState.detailsItem = null + homeState.detailsAiringNotice = null + homeState.detailsFromSearch = false + // Same settle delay every other "an overlay just closed, hand focus back + // to what is underneath" path in this file uses (closeSettings, + // onDestinationSelected): the overlay's own focused node is being torn + // down this same frame, and asking a FocusRequester to take focus before + // Compose has processed that removal can silently do nothing — requestFocus() + // does not throw when its target is not currently reachable, so + // requestFirstAvailableFocus reported success while nothing had actually + // moved. Search paid for that the worst: with no live focus owner, the + // D-pad's directional search had nothing to search relative to and the + // remote surfaced back near the keyboard pane, unable to reach any card in + // the results list it had just returned to. + scope.launch { + delay(16.milliseconds) + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } + Unit + } + BackHandler { + val previous = homeState.detailsTrail.lastOrNull() + if (previous != null) { + homeState.detailsTrail = homeState.detailsTrail.dropLast(1) + homeState.restoreDetailPosition = true + homeState.detailsItem = previous + } else { + closeDetails() + } + } + FocusedDetailsOverlay( + homeViewModel = homeViewModel, + selected = selected, + seriesStatusRevision = seriesStatusRevision, + detailExperience = detailExperience, + restorePosition = homeState.restoreDetailPosition, + airingNotice = homeState.detailsAiringNotice, + // Contextual to the exact page Search opened: still true after walking back + // out of a "More like this" trail to it, gone the moment that trail is not + // empty — a deep-link shortcut back to a specific entry point rather than a + // permanent control every detail page carries. + onBackToSearch = closeDetails.takeIf { homeState.detailsFromSearch && homeState.detailsTrail.isEmpty() }, + onOpenItem = { related -> + homeViewModel.trackJourney( + category = "recommendations", action = "open", screen = "details", + feature = "related", source = "related", target = "details", + itemName = related.name, itemType = related.type, + ) + homeState.detailsTrail = homeState.detailsTrail + selected + homeState.restoreDetailPosition = false + // Ask for the full record the same way a focused card does. Some + // routes here hand over a stub rather than a row item — the episode + // page's "Show" button knows only the series' id and name — and + // without this the page opens with no logo, no backdrop and no facts, + // because nothing else on this path ever fetches the item. + homeViewModel.focusItem(related) + homeState.detailsItem = related + // The notice was about the show that was pressed in the row, not about + // whatever "More like this" leads to. Walking Back does not restore it, + // and should not: it was news, and it has been read. + homeState.detailsAiringNotice = null + }, + onOpenEmbyItem = { embyItem -> + // Not a step in the trail: the Radarr page and the Emby page are two + // answers about one title, so Back from here still belongs where the + // card was pressed rather than on the page it replaced. + homeViewModel.focusItem(embyItem) + homeState.detailsAiringNotice = null + homeState.detailsItem = embyItem + }, + onPlay = { + // Kept, not discarded: this is what the viewer comes back to when the + // film ends or they press Back out of the player. + homeState.detailsResumeTarget = DetailsReturn(selected, homeState.detailsTrail, homeState.detailsAiringNotice) + homeState.detailsItem = null + homeState.detailsTrail = emptyList() + homeState.restoreDetailPosition = false + homeState.detailsAiringNotice = null + playItem(it) + }, + onPlayTrailer = playTrailer, + onToggleFavorite = { item, saved -> + homeViewModel.trackJourney( + category = "library", action = if (saved) "favourite" else "unfavourite", + screen = "details", feature = "favorites", itemName = item.name, itemType = item.type, + outcome = "success", + ) + homeViewModel.setFavorite(item, saved) + }, + isMyShow = homeState.myShows.any { it.itemId == selected.id }, + onToggleMyShow = { item, saved -> + homeViewModel.trackJourney( + category = "library", action = if (saved) "follow" else "unfollow", + screen = "details", feature = "my_shows", itemName = item.name, itemType = item.type, + outcome = "success", + ) + // Optimistic, the way a favourite already is. Following a show is a + // press with an obvious outcome, and the server's answer to it is the + // *whole* list decorated with Sonarr's lifecycle for every show on it + // — a request the viewer has no reason to sit through watching an + // unchanged button. The row that lands a moment later replaces this + // placeholder; a failure puts the button back where it was. + if (homeState.myShowsMutationBusy) return@FocusedDetailsOverlay + homeState.myShowsMutationBusy = true + val previous = homeState.myShows + homeState.myShows = if (saved) { + homeState.myShows.filterNot { it.itemId == item.id } + myShowStub(item) + } else { + homeState.myShows.filterNot { it.itemId == item.id } + } + if (saved) { + Toast.makeText( + context, + "${item.name} added to My Shows", + Toast.LENGTH_SHORT, + ).show() + } + scope.launch { + if (saved) { + runCatching { repo.saveMyShow(item) } + .onSuccess { homeState.myShows = it } + .onFailure { homeState.myShows = previous } + } else { + runCatching { repo.removeMyShow(item.id) } + .onFailure { homeState.myShows = previous } + } + homeState.myShowsMutationBusy = false + } + }, + onTogglePlayed = { item, played -> + homeViewModel.trackJourney( + category = "library", action = if (played) "mark_played" else "mark_unplayed", + screen = "details", feature = "played_status", itemName = item.name, itemType = item.type, + outcome = "success", + ) + homeViewModel.setPlayed(item, played) + }, + onClose = { + homeState.detailsItem = null + homeState.detailsTrail = emptyList() + homeState.restoreDetailPosition = false + homeState.detailsAiringNotice = null + homeState.detailsFromSearch = false + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + }, + ) + } + homeState.selectedMyShow?.let { show -> + val closeMyShow: (Boolean) -> Unit = { removed -> + homeState.selectedMyShow = null + scope.launch { + delay(16.milliseconds) + val exactStillExists = !removed && homeState.myShows.any { it.itemId == homeState.myShowReturnItemId } + val restored = exactStillExists && runCatching { + myShowReturnFocusRequester.requestFocus() + }.isSuccess + if (!restored && runCatching { contentFocusRequester.requestFocus() }.isFailure) { + runCatching { navigationFocusRequester.requestFocus() } + } + } + } + BackHandler { closeMyShow(false) } + MyShowDetailsOverlay( + show = show, + repository = repo, + removing = homeState.removingMyShow, + onRemove = { + homeState.removingMyShow = true + scope.launch { + runCatching { repo.removeMyShow(show.itemId) }.onSuccess { + homeState.myShows = homeState.myShows.filterNot { it.itemId == show.itemId } + closeMyShow(true) + } + homeState.removingMyShow = false + } + }, + onClose = { closeMyShow(false) }, + ) + } + if (homeState.showRequests) { + // Reached from the user picker, so leaving it returns to the rail rather than to + // whatever card held focus on the launcher behind it — the alerts page's rule. + val closeRequests: () -> Unit = { + homeState.showRequests = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + // Keyed on the profile like every other page here, so switching viewer cannot + // leave one person's requests on screen under somebody else's name. + val requestsViewModel: RequestsViewModel = viewModel( + viewModelStoreOwner = profileViewModelOwner, + factory = RequestsViewModelFactory(repo), + ) + val requestsState by requestsViewModel.state.collectAsStateWithLifecycle() + // The page keeps itself current while it is on screen — it is the one screen + // whose cards move without anybody touching anything. The loop is hung off the + // composition rather than off the view model, which is keyed on the profile and + // outlives this block, and off STARTED rather than run unconditionally, so a + // television left on the launcher or switched to another app stops asking + // entirely. Whether there is anything worth asking about is the view model's + // own judgement — see pollWhileVisible. + val requestsLifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(requestsViewModel, requestsLifecycleOwner) { + requestsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + requestsViewModel.pollWhileVisible() + } + } + // The page covers the launcher, rail and all, so it carries its own rather than + // leaving Left pointing at one nobody can see. Its own FocusRequester, because + // the launcher's is still attached to the rail underneath and one requester on + // two live nodes lands on whichever Compose reaches first. + // + // Selecting a destination from here leaves the page — this rail is how you get + // *out* of Requests, not a second way to browse behind it. + Row( + Modifier + .fillMaxSize() + .zIndex(6f) + // The rail's own surface is 95% opaque, so without this the launcher + // shows faintly through the one column of the page it does not paint. + .background(MembySurface), + ) { + TvNavigationRail( + config = remoteConfig.navigation, + markPath = remoteMarkPath, + selected = if (homeState.userSwitcherVisible) { + BrowseDestination.PROFILES + } else { + homeState.selectedDestination + }, + expanded = homeState.requestsRailExpanded, + navigationFocusRequester = requestsRailFocusRequester, + onRailFocusChanged = { homeState.requestsRailExpanded = it }, + onDestinationSelected = { destination -> + homeState.requestsRailExpanded = false + homeState.showRequests = false + when (destination) { + BrowseDestination.SETTINGS -> { + homeState.railFocusDestination = BrowseDestination.SETTINGS + homeState.restoreRailAfterSettings = true + homeState.navigationExpanded = false + homeState.userSwitcherVisible = false + homeState.showSettings = true + } + BrowseDestination.PROFILES -> { + homeState.railFocusDestination = BrowseDestination.PROFILES + homeState.userSwitcherVisible = true + // The picker and the shortcut menu are two answers to one control; only + // one of them may ever be on screen. + homeState.userQuickActionsVisible = false + homeState.navigationExpanded = false + homeState.showSettings = false + homeState.restoreRailAfterSettings = false + } + else -> { + homeState.selectedDestination = destination + homeState.railFocusDestination = destination + homeState.navigationExpanded = false + homeState.userSwitcherVisible = false + homeState.showSettings = false + homeState.restoreRailAfterSettings = false + scope.launch { + // Let the destination compose and attach its entry + // target before focus is transferred to it. + delay(16.milliseconds) + requestFirstAvailableFocus( + contentFocusRequester, + navigationFocusRequester, + ) + } + } + } + }, + alertCount = displayedNotifications.size, + activeUsername = settings.username.orEmpty(), + activeProfileInitials = settings.profileInitials, + calendarEnabled = tvCalendarEnabled, + ) + RequestsScreen( + state = requestsState, + navigationFocusRequester = requestsRailFocusRequester, + contentFocusRequester = contentFocusRequester, + onSelectTab = requestsViewModel::selectTab, + onQueryChanged = requestsViewModel::onQueryChanged, + onAppendToQuery = requestsViewModel::appendToQuery, + onBackspace = requestsViewModel::backspace, + onClearQuery = requestsViewModel::clearQuery, + onSubmitSearch = requestsViewModel::submitSearch, + onRequest = requestsViewModel::request, + onRemove = requestsViewModel::remove, + onOpenItem = { itemId -> + // A request that has arrived opens the thing it became. The stub is + // filled in by focusItem the way a schedule card's is, so the page + // appears at once instead of waiting on an item request. + scope.launch { + runCatching { repo.getItemDetails(itemId) } + .onSuccess { item -> + closeRequests() + homeState.detailsAiringNotice = null + homeState.detailsFromSearch = false + homeState.detailsTrail = emptyList() + homeState.detailsItem = item + } + } + }, + onRetry = requestsViewModel::refresh, + onExit = closeRequests, + posterUrlFor = { it.takeIf(String::isNotBlank) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + if (homeState.showNotifications) { + // Reached from the user picker, so leaving it goes back to the rail rather than + // to whatever card happened to hold focus on the launcher behind it. + val closeAlerts: () -> Unit = { + homeState.showNotifications = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + BackHandler(onBack = closeAlerts) + MyAlertsPage( + notifications = displayedNotifications, + preferences = homeState.notificationState.preferences, + loading = homeState.notificationsLoading, + errorMessage = homeState.notificationsError, + onRetry = { + if (!homeState.notificationsLoading) scope.launch { + homeState.notificationsLoading = true + homeState.notificationsError = null + runCatching { repo.getNotifications() } + .onSuccess { homeState.notificationState = it } + .onFailure { homeState.notificationsError = friendlyEmbyError(it) } + homeState.notificationsLoading = false + } + }, + onNotificationAction = { notification, action -> + if (homeState.notificationsMutationBusy) return@MyAlertsPage + homeState.notificationsMutationBusy = true + val previous = homeState.notificationState + homeState.notificationState = homeState.notificationState.copy( + notifications = homeState.notificationState.notifications.map { + if (it.id == notification.id) { + it.copy( + itemId = "", + action = action.copy( + label = action.completedLabel.ifBlank { action.label }, + enabled = false, + ), + ) + } else { + it + } + }, + ) + scope.launch { + runCatching { repo.runNotificationAction(notification.id, action.kind) } + .onFailure { failure -> + homeState.notificationState = previous + homeState.notificationsError = friendlyEmbyError(failure) + } + homeState.notificationsMutationBusy = false + } + }, + // The seen toggle, and the only thing that moves a row between Inbox and + // Seen — nothing is marked read merely by being looked at any more, because + // with the two halves split that would empty the Inbox under the remote. + // + // Optimistic and reversed on failure, like the dismissal below: the flag is + // the only thing that changed, so a row that sat unmoved while its request + // was in flight is one pressed a second time. + onToggleSeen = onToggleSeen@{ notification -> + val markingSeen = notification.unread + val previousReadAt = notification.readAt + homeState.notificationState = homeState.notificationState.copy( + notifications = homeState.notificationState.notifications.map { + if (it.id == notification.id) { + it.copy(readAt = if (markingSeen) "now" else null) + } else { + it + } + }, + ) + scope.launch { + runCatching { + if (markingSeen) { + repo.markNotificationRead(notification.id) + } else { + repo.markNotificationUnread(notification.id) + } + }.onFailure { failure -> + homeState.notificationState = homeState.notificationState.copy( + notifications = homeState.notificationState.notifications.map { + if (it.id == notification.id) { + it.copy(readAt = previousReadAt) + } else { + it + } + }, + ) + homeState.notificationsError = friendlyEmbyError(failure) + } + } + }, + // Optimistic, for the reason "Dismiss all" beneath it already is: this + // page is judged entirely on emptying itself, and a row that stayed put + // while its request was in flight is a row pressed again — which on this + // page also re-aims where focus lands afterwards. A failure puts the row + // back where it was rather than quietly losing somebody's alert. + onDismiss = onDismiss@{ notification -> + if (homeState.notificationsMutationBusy) return@onDismiss + homeState.notificationsMutationBusy = true + val previous = homeState.notificationState + homeState.notificationState = homeState.notificationState.copy( + notifications = homeState.notificationState.notifications.filterNot { + it.id == notification.id + }, + ) + scope.launch { + runCatching { repo.dismissNotification(notification.id) } + .onFailure { failure -> + homeState.notificationState = previous + homeState.notificationsError = friendlyEmbyError(failure) + } + homeState.notificationsMutationBusy = false + } + }, + // The gateway has no bulk route, so this is the same call per alert. The + // list is emptied optimistically: the page is judged on emptying itself, + // and a row that lingered while its request was in flight would be pressed + // a second time. + onDismissAll = { pending -> + if (homeState.notificationsMutationBusy) return@MyAlertsPage + homeState.notificationsMutationBusy = true + val previous = homeState.notificationState + // Only the half on screen. The page dismisses what it is showing, so + // emptying Seen must not also throw away an Inbox the viewer has not + // read — a bulk action nobody can see the extent of is one nobody presses. + val pendingIds = pending.map(UserNotification::id).toSet() + homeState.notificationState = homeState.notificationState.copy( + notifications = homeState.notificationState.notifications.filterNot { + it.id in pendingIds + }, + ) + scope.launch { + val failed = pendingIds.filter { id -> + runCatching { repo.dismissNotification(id) }.isFailure + } + runCatching { repo.getNotifications() } + .onSuccess { homeState.notificationState = it } + .onFailure { failure -> + homeState.notificationState = previous + homeState.notificationsError = friendlyEmbyError(failure) + } + if (failed.isNotEmpty()) { + homeState.notificationsError = "Some alerts couldn’t be dismissed. Try again." + } + homeState.notificationsMutationBusy = false + } + }, + onClose = closeAlerts, + ) + } + homeState.quickMenuItem?.let { selected -> + LaunchedEffect(selected.id) { + homeState.quickMenuTrailerAvailable = selected.isRadarrOnly && + repo.getRadarrMovie(selected.id)?.trailerAvailable == true + } + val closeQuickActions: (Boolean) -> Unit = { originWillDisappear -> + homeState.quickMenuItem = null + homeState.quickMenuRowId = null + scope.launch { + // The overlay owns focus until it leaves composition. Restore the + // exact originating card after its focus node is available again. + delay(16.milliseconds) + if (originWillDisappear) { + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) + } else { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } + } + } + BackHandler { closeQuickActions(false) } + FocusedQuickActionsOverlay( + homeViewModel = homeViewModel, + selected = selected, + onOpenDetails = { + homeState.quickMenuItem = null + homeState.detailsAiringNotice = null + homeState.detailsFromSearch = false + homeState.detailsItem = it + }, + onSetFavorite = homeViewModel::setFavorite, + onSetPlayed = homeViewModel::setPlayed, + // Only for a film with no page to play from, and only when the gateway has + // a candidate to resolve — the same answer the detail page's button waits + // for, so the two can never disagree about whether there is a trailer. + onPlayTrailer = if (selected.isRadarrOnly && homeState.quickMenuTrailerAvailable) { + { + homeState.quickMenuItem = null + homeState.quickMenuRowId = null + playTrailer(selected) + } + } else { + null + }, + onRemoveFromContinueWatching = if ( + rows.firstOrNull { it.id == homeState.quickMenuRowId }?.kind == MediaRowKind.CONTINUE + ) { + { + homeState.quickMenuItem = null + homeState.quickMenuRowId = null + homeViewModel.removeFromContinueWatching(selected) + scope.launch { + delay(16.milliseconds) + val removalFocusRequester = if (hasContextualHero) { + heroRowEntryFocusRequester + } else { + contentFocusRequester + } + requestFirstAvailableFocus( + removalFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } + } + } else { + null + }, + rowTitle = rows.firstOrNull { it.id == homeState.quickMenuRowId }?.title, + rowPinned = homeState.quickMenuRowId in settings.homePinnedRows.decodeRowIds(), + onToggleRowPinned = homeState.quickMenuRowId?.let { rowId -> + { + val pinned = settings.homePinnedRows.decodeRowIds().toMutableSet() + if (!pinned.add(rowId)) pinned.remove(rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + settings.homeRowOrder.decodeRowIds(), + pinned, + settings.homeHiddenRows.decodeRowIds().toSet(), + ) + } + closeQuickActions(false) + } + }, + onHideRow = homeState.quickMenuRowId?.let { rowId -> + { + val hidden = settings.homeHiddenRows.decodeRowIds().toMutableSet() + hidden.add(rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + settings.homeRowOrder.decodeRowIds(), + settings.homePinnedRows.decodeRowIds().toSet(), + hidden, + ) + closeQuickActions(true) + } + } + }, + onMoveRow = homeState.quickMenuRowId?.let { rowId -> + { direction -> + val currentIds = rows.map(HomeBrowseRow::id).toMutableList() + val index = currentIds.indexOf(rowId) + val target = (index + direction).coerceIn(0, currentIds.lastIndex) + if (index >= 0 && target != index) { + currentIds.removeAt(index) + currentIds.add(target, rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + currentIds, + settings.homePinnedRows.decodeRowIds().toSet(), + settings.homeHiddenRows.decodeRowIds().toSet(), + ) + } + } + closeQuickActions(false) + } + }, + onClose = { closeQuickActions(false) }, + ) + } + homeState.resolvingItem?.let { item -> + // Back gets out of the wait. This overlay covers the whole screen while the + // gateway is asked for a stream, and against a server that has stopped + // answering that is the length of an HTTP timeout — long enough that a viewer + // reaches for the remote, and until now long enough that nothing answered + // them. Cancelling is safe: nothing has been launched and nothing reported. + BackHandler(onBack = abandonLaunch) + PlaybackLaunchOverlay( + item = item, + modifier = Modifier.fillMaxSize().zIndex(9f), + ) + } + // Emby has stopped answering. Persistent, unlike the news bar below it, because + // it describes a state rather than an event: a set switched on midway through an + // outage was never told, and this is the only thing that says why nothing plays. + // Suppressed under maintenance, which owns the screen and is its own explanation. + EmbyOutageBanner( + suppressed = liveMaintenance != null, + modifier = Modifier.align(Alignment.TopCenter), + ) + // News about the library, not about the app: it sits above the rows but is + // suppressed whenever something more important owns the screen. + ServiceAlertBanner( + surface = NotificationSurface.BROWSING, + // The alert itself is collected inside the banner, so an arriving one does + // not recompose this whole function. Only the suppression conditions — both + // already read here for other reasons — cross the boundary. + // The outage bar takes the same strip of screen and outranks any + // announcement, including the one announcing this very outage. + suppressed = liveMaintenance != null || embyOutage != null, + // Flush to the top edge and spanning the rail: for its few seconds this is + // the top layer of the screen, the way a broadcast notice is. + modifier = Modifier.align(Alignment.TopCenter), + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomePlayback.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomePlayback.kt new file mode 100644 index 0000000..127cd83 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomePlayback.kt @@ -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, + contentFocusRequester: FocusRequester, + cardReturnFocusRequester: FocusRequester, +): HomePlaybackController { + val context = LocalContext.current +val playbackLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), +) { result -> + if (PlayerActivity.trailerUnavailable(result.resultCode, result.data)) { + Toast.makeText(context, "No playable trailer is available", Toast.LENGTH_SHORT).show() + } + homeViewModel.trackJourney( + category = "playback", action = "stop", screen = "player", + feature = "playback", target = homeState.selectedDestination.name.lowercase(), + ) + homeViewModel.resumeAnalyticsAfterPlayback() + // PlayerActivity has finished and this activity owns the window again. Compose + // needs one frame to reattach the saved card's focus node before it can receive + // focus, especially when playback progress refreshed the row behind the player. + // Clearing the launch gate here rather than when the intent was fired covers both + // routes into the player, including the one that hands over without waiting. + homeState.launchingItem = null + scope.launch { + homeState.myShowsLoading = true + homeState.notificationsLoading = true + coroutineScope { + launch { + runCatching { repo.getMyShows() } + .onSuccess { homeState.myShows = it; homeState.myShowsError = null } + .onFailure { + if (it is CancellationException) throw it + homeState.myShowsError = friendlyEmbyError(it) + } + homeState.myShowsLoading = false + } + launch { + runCatching { repo.getNotifications() } + .onSuccess { homeState.notificationState = it; homeState.notificationsError = null } + .onFailure { + if (it is CancellationException) throw it + homeState.notificationsError = friendlyEmbyError(it) + } + homeState.notificationsLoading = false + } + } + delay(32.milliseconds) + // Trailer playback leaves its detail page composed. Let Compose restore the + // exact hero action instead of moving focus to the home card behind it. + if (homeState.detailsItem != null) return@launch + if (homeState.returnRowId != null && homeState.returnItemId != null) { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequesters.getValue(homeState.railFocusDestination), + ) + } else { + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequesters.getValue(homeState.railFocusDestination)) + } + } +} + +val abandonLaunch: () -> Unit = { + homeState.resolveJob?.cancel() + homeState.resolveJob = null + homeState.resolvingItem = null + homeState.launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() +} +val playItem: (BaseItem) -> Unit = playItem@{ item -> + if (homeState.launchingItem != null || !item.membyPlayable) return@playItem + // The shelf this press traces back to, resolved once and then carried all the way + // into the player, so the step the launcher records and the step the player records + // agree about where the viewer came from. `source` used to be the raw row id, which + // the console could not group on: Continue Watching arrived as `continue` from the + // gateway, as `nextup` from a cached row, and as a recommendation id nobody had seen + // before every other day. + val entryPoint = playbackEntryPointFor(homeState.returnRowId, homeState.returnRowKind) + PlaybackJourney.requested( + sink = homeViewModel.journeySink, + entryPoint = entryPoint, + screen = homeState.selectedDestination.name.lowercase(), + itemId = item.id, + itemName = item.name, + itemType = item.type, + ) + // Nothing here uploads: the buffer is drained once the player hands the window back. + homeViewModel.pauseAnalyticsForPlayback() + // Every way a launch can die before the player exists. The player records its own + // failures once it has one; these are the ones it would never hear about, and + // without them a journey ends on a request that appears simply to have gone nowhere. + val playbackScreen = homeState.selectedDestination.name.lowercase() + val recordPlaybackFailed = { + PlaybackJourney.failed( + sink = homeViewModel.journeySink, + entryPoint = entryPoint, + screen = playbackScreen, + itemId = item.id, + itemName = item.name, + itemType = item.type, + ) + } + homeState.launchingItem = item + val playbackRequestedAtMs = SystemClock.elapsedRealtime() + // Resuming: open the player now and let it resolve the stream while it starts. + // Waiting here would spend the negotiation on a still launcher and only then begin + // the activity, the layout and the decoder, none of which needed the answer. A cold + // start still resolves first — the pre-roll it opens with needs a stream to run + // behind it, and whether there is one to show is part of the same answer. + // Everything up to the hand-over runs inside a catch that reopens the gate. The + // gate is otherwise cleared only by the player coming back, so anything that + // throws before one is started — a malformed cached item, an activity result + // registry that has already been torn down — would leave Play dead for the rest + // of the session with nothing on screen to say why. + // Logo enrichment may need the associated series record. Keep it in the same + // cancellable launch job as stream resolution so Back abandons the whole hand-off. + homeState.resolveJob = scope.launch { + try { + val prepared = runCatching { + val request = repo.playbackRequestForLaunch(item) + request to repo.readyPlayableForLaunch(request) + }.getOrElse { + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + homeState.launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + return@launch + } + val request = prepared.first + val ready = prepared.second + if (ready == null && request.resumePositionMs > 0L) { + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.intent( + context = context, + request = request, + posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH), + backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH) + ?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH), + requestStartedAtMs = playbackRequestedAtMs, + journeySource = entryPoint.id, + ), + ) + } + if (launched.isFailure) { + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + homeState.launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + } + return@launch + } + // Only the route that waits on the server shows the launcher's own loading screen. + // The route that hands over immediately would only be showing it behind the player. + homeState.resolvingItem = item + runCatching { + val playable = ready ?: run { + var timeout: TimeoutCancellationException? = null + repeat(2) { + try { + // The request, not the card. `playbackRequestForLaunch` + // above is what resolves an episode's title treatment from + // its series, and re-deriving the request from the item + // here threw that away — so a cold start (no resume + // position, which is every unwatched pick) opened the + // player with no logo while a resume opened with one. + return@run withTimeout(PLAYBACK_SOURCE_RESOLUTION_TIMEOUT) { + repo.resolvePlayableForLaunch(request) + } + } catch (error: TimeoutCancellationException) { + timeout = error + } + } + throw requireNotNull(timeout) + } + // A resolution that came back without a stream is a failure with a + // success's shape. Handed on, PlayerActivity finds no URL and no + // request to resolve one from, closes itself in onCreate, and the + // viewer sees the screen flicker and nothing else — the one playback + // failure that arrives with no explanation at all. + check(playable.url.isNotBlank()) { "resolved playable has no stream" } + playable + } + .onSuccess { playable -> + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.intent( + context = context, + playable = playable, + backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH) + ?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH), + posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH), + requestStartedAtMs = playbackRequestedAtMs, + journeySource = entryPoint.id, + ), + ) + } + if (launched.isFailure) { + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + homeState.launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + } + } + .onFailure { error -> + // A cancellation is the viewer having pressed Back out of the + // wait, which has already reopened the gate and said so on screen. + if (error is CancellationException) throw error + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + // Nothing was launched, so nothing will come back to reopen the gate. + homeState.launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + } + } finally { + homeState.resolvingItem = null + homeState.resolveJob = null + } + } +} +val playTrailer: (BaseItem) -> Unit = playTrailer@{ item -> + if (homeState.launchingItem != null) return@playTrailer + homeState.launchingItem = item + homeViewModel.trackJourney( + category = "playback", action = "trailer", screen = "details", + feature = "trailer", source = "details", target = "player", + itemName = item.name, itemType = item.type, + ) + homeViewModel.pauseAnalyticsForPlayback() + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.trailerIntent( + context, + TrailerPlaybackRequest( + subjectId = item.id, + title = item.name, + posterUrl = repo.primaryUrl(item, maxWidth = 500), + logoUrl = repo.logoUrl(item), + ), + ), + ) + } + if (launched.isFailure) { + homeState.launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + Toast.makeText(context, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show() + } +} + return remember(playbackLauncher) { + HomePlaybackController(playItem = playItem, playTrailer = playTrailer, abandonLaunch = abandonLaunch) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 31b38a7..75b472e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -128,10 +128,10 @@ import java.util.Calendar import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -private val PLAYBACK_SOURCE_RESOLUTION_TIMEOUT = 15.seconds +internal val PLAYBACK_SOURCE_RESOLUTION_TIMEOUT = 15.seconds /** The maximum time one row-to-row focus move may hold the D-pad. */ -private val ROW_FOCUS_MOVE_TIMEOUT = 1_200.milliseconds +internal val ROW_FOCUS_MOVE_TIMEOUT = 1_200.milliseconds /** * The followed show a press stands for, as much of it as the card already knows. @@ -142,7 +142,7 @@ private val ROW_FOCUS_MOVE_TIMEOUT = 1_200.milliseconds * what this television knows for the moment, and the real row replaces it as soon as the * save returns. The same promise `scheduleSeriesStub` makes about a detail page. */ -private fun myShowStub(item: BaseItem): MyShow = +internal fun myShowStub(item: BaseItem): MyShow = MyShow( itemId = item.id, title = item.name, @@ -162,7 +162,7 @@ internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp = } @OptIn(ExperimentalFoundationApi::class) -private val HomeLazyListPrefetchStrategy = LazyListPrefetchStrategy( +internal val HomeLazyListPrefetchStrategy = LazyListPrefetchStrategy( // A shelf is expensive enough to compose that preparing more than the next one makes // rapid vertical movement compete with the frame that is currently being navigated. nestedPrefetchItemCount = 1, @@ -223,6 +223,9 @@ internal fun HomeScreen( val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle() val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle() + // See HomeScreenState: launcher state hoisted out so HomeScreen, HomeContentPane and + // HomeOverlays are each small enough to be compiled rather than interpreted. + val homeState = rememberSaveable(settings.userId, saver = HomeScreenState.Saver) { HomeScreenState() } val startupPosterIdentity = remember( settings.serverUrl, settings.userId, @@ -286,27 +289,14 @@ internal fun HomeScreen( // which of the two top bars gets the strip. val embyOutage by ServiceLocator.maintenance.embyOutage.collectAsStateWithLifecycle() - var showSettings by remember { mutableStateOf(false) } - var showProfiles by remember { mutableStateOf(false) } // Reached only from the manage-users page, which is itself two presses in behind the // user picker — so the "+" is where somebody looking to add a viewer is already // standing, and it is nowhere near the launcher. - var addingProfile by remember { mutableStateOf(false) } - var userSwitcherVisible by remember { mutableStateOf(false) } // The shortcut menu a hold on the rail's user item raises. Kept beside the picker rather // than inside it: the two are alternative answers to the same control, and only one of // them may ever be on screen. - var userQuickActionsVisible by remember { mutableStateOf(false) } - var switchingProfileId by remember { mutableStateOf(null) } - var removingProfileId by remember { mutableStateOf(null) } - var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } - var navigationExpanded by rememberSaveable { mutableStateOf(false) } - var restoreRailAfterSettings by remember { mutableStateOf(false) } - var detailsItem by remember { mutableStateOf(null) } // Detail pages can now open one another through the related rail. Back walks the trail // home one page at a time; without this it would drop three levels to the launcher. - var detailsTrail by remember { mutableStateOf>(emptyList()) } - var restoreDetailPosition by remember { mutableStateOf(false) } // The page playback was started from, so returning from the player comes back to it // rather than to the launcher. // @@ -318,87 +308,48 @@ internal fun HomeScreen( // tab, the season, the grid offset and the band that held focus all come back with it — // and focus is re-requested by that path rather than depending on Compose having held // it across another activity's window. - var detailsResumeTarget by remember { mutableStateOf(null) } // Belongs to the *route*, not to the show: set only when a page is opened from the // "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the // same series reached from Favourites or a search never claims a schedule. - var detailsAiringNotice by remember { mutableStateOf(null) } // Belongs to the *route*, the [detailsAiringNotice] arrangement: true only for the page // Search opened directly, false the moment the trail moves anywhere else (a "More like // this" step, a fresh press from Home, Genres, Calendar or a row). It is what gates the // contextual "Back to Search Results" action — a deep link back to a specific entry // point, not a permanent detail-page control. - var detailsFromSearch by remember { mutableStateOf(false) } - var quickMenuItem by remember { mutableStateOf(null) } // Whether the long-press menu may offer a trailer for the Radarr card it is open on. // Asked once, when the menu opens, and false until answered: a row entry that appears // and then fails is worse than one that arrives a moment late. - var quickMenuTrailerAvailable by remember { mutableStateOf(false) } - var quickMenuRowId by remember { mutableStateOf(null) } - var focusedHomeRowId by remember { mutableStateOf(null) } - var sectionHeroRows by remember(settings.userId) { - mutableStateOf>>(emptyMap()) - } - var myShows by remember(settings.userId) { mutableStateOf>(emptyList()) } - var myShowsLoading by remember(settings.userId) { mutableStateOf(true) } - var myShowsError by remember(settings.userId) { mutableStateOf(null) } - var myShowsMutationBusy by remember(settings.userId) { mutableStateOf(false) } - var selectedMyShow by remember { mutableStateOf(null) } - var myShowReturnItemId by remember { mutableStateOf(null) } - var removingMyShow by remember { mutableStateOf(false) } - var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) } - var notificationsLoading by remember(settings.userId) { mutableStateOf(true) } - var notificationsError by remember(settings.userId) { mutableStateOf(null) } - var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) } - val displayedNotifications = notificationState.notifications - var showNotifications by remember { mutableStateOf(false) } - var showRequests by remember { mutableStateOf(false) } + val displayedNotifications = homeState.notificationState.notifications // The people under this account. Fetched once the launcher is up rather than on the // critical path: nothing on the signed-in path may block on a request, and until the // answer lands this television watches as whoever it watched as last — which is the // right answer far more often than not. - var viewers by remember { mutableStateOf>(emptyList()) } - var showViewerPicker by remember { mutableStateOf(false) } // Managing viewers is a stack over the picker rather than a replacement for it — the // arrangement the "add another user" sign-in already takes over the manage-users page: // cancelling a name comes straight back to the list the button was pressed from, with // nothing to restore because nothing was ever unmounted. - var showViewerManage by remember { mutableStateOf(false) } - var viewerNameTarget by remember { mutableStateOf(null) } - var viewerName by remember { mutableStateOf("") } - var viewerNameFailure by remember { mutableStateOf(null) } - var viewerSaving by remember { mutableStateOf(false) } - var viewerBusyId by remember { mutableStateOf(null) } - var pinTarget by remember { mutableStateOf(null) } - var pinSaving by remember { mutableStateOf(false) } - var pinFailure by remember { mutableStateOf(null) } LaunchedEffect(settings.userId, settings.serverUrl) { - viewers = if (settings.isSignedIn) repo.viewers() else emptyList() + homeState.viewers = if (settings.isSignedIn) repo.viewers() else emptyList() } // One place the list is re-read, so every mutation ends the same way and none of them // has to work out what the answer should now be. The gateway is the thing that knows. val refreshViewers: suspend () -> Unit = { - viewers = runCatching { repo.viewers() }.getOrDefault(viewers) + homeState.viewers = runCatching { repo.viewers() }.getOrDefault(homeState.viewers) } val openViewerName: (ViewerNameTarget) -> Unit = { target -> - viewerName = viewerNameFor(target) - viewerNameFailure = null - viewerNameTarget = target + homeState.viewerName = viewerNameFor(target) + homeState.viewerNameFailure = null + homeState.viewerNameTarget = target } // Two different things: [launchingItem] is the gate that stops a second Play press // stacking a second player, and stays shut until one comes back. [resolvingItem] is // the loading screen, and belongs only to a launch that is waiting on the server. - var launchingItem by remember { mutableStateOf(null) } - var resolvingItem by remember { mutableStateOf(null) } - var returnRowId by rememberSaveable { mutableStateOf(null) } - var returnItemId by rememberSaveable { mutableStateOf(null) } // The shelf a Play press can be traced back to, as an entry point rather than as a row // id. Kept beside [returnRowId] and written wherever it is, because the row's *kind* is // the reliable half — a row id is the gateway's, differs on the direct path, and a // recommendation strip invents a new one daily — and because by the time `playItem` runs // the row list is out of scope. Saved with the rest so a recreate mid-browse does not // file the next resume under nothing. - var returnRowKind by rememberSaveable { mutableStateOf(null) } // Destination and row list states live above the conditional content branches. // Opening Search/Settings/details therefore never creates a new list at position 0. val verticalStates = rememberSaveable(saver = LazyListStateMapSaver) { @@ -418,13 +369,11 @@ internal fun HomeScreen( val navigationFocusRequesters = remember { BrowseDestination.entries.associateWith { FocusRequester() } } - var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) } - val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination) + val navigationFocusRequester = navigationFocusRequesters.getValue(homeState.railFocusDestination) // The Requests page covers the launcher, so it mounts a rail of its own. Kept apart // from the launcher's because both are composed at once while that page is open, and a // requester attached to two live nodes focuses whichever Compose reaches first. val requestsRailFocusRequester = remember { FocusRequester() } - var requestsRailExpanded by remember { mutableStateOf(false) } val contentFocusRequester = remember { FocusRequester() } val cardReturnFocusRequester = remember { FocusRequester() } val myShowReturnFocusRequester = remember { FocusRequester() } @@ -444,15 +393,15 @@ internal fun HomeScreen( // off it, the same rule the calendar destination follows: a page nothing behind it will // answer must not be somewhere a remote is left sitting. LaunchedEffect(requestsAllowed) { - if (!requestsAllowed) showRequests = false + if (!requestsAllowed) homeState.showRequests = false } LaunchedEffect(continueWatchingEnabled) { homeViewModel.setContinueWatchingEnabled(continueWatchingEnabled) } LaunchedEffect(tvCalendarEnabled) { - if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) { - selectedDestination = BrowseDestination.HOME - railFocusDestination = BrowseDestination.HOME + if (!tvCalendarEnabled && homeState.selectedDestination == BrowseDestination.CALENDAR) { + homeState.selectedDestination = BrowseDestination.HOME + homeState.railFocusDestination = BrowseDestination.HOME delay(16.milliseconds) requestFirstAvailableFocus( contentFocusRequester, @@ -466,9 +415,9 @@ internal fun HomeScreen( // moved to Home, the stance the calendar takes: the rail entry goes with the // feature, so leaving it there would strand the viewer on a page nothing can // navigate back to. - if (selectedDestination == BrowseDestination.GENRES) { - selectedDestination = BrowseDestination.HOME - railFocusDestination = BrowseDestination.HOME + if (homeState.selectedDestination == BrowseDestination.GENRES) { + homeState.selectedDestination = BrowseDestination.HOME + homeState.railFocusDestination = BrowseDestination.HOME delay(16.milliseconds) requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) } @@ -476,87 +425,42 @@ internal fun HomeScreen( // Initial focus belongs to each destination, not to the launcher as a whole. Home can // settle before Shows or Movies have returned their rows; sharing one latch meant the // later page's only request happened while its first card was still absent. - var initialFocusDestination by remember { mutableStateOf(null) } // Incremented when a rail selection needs to restore a card in the lazy row list. // The list owns the scroll state, so it also owns the actual restoration below. - var rowListFocusRestoreRequest by remember { mutableStateOf(0) } - 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 = 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. - launchingItem = null - scope.launch { - myShowsLoading = true - notificationsLoading = true - coroutineScope { - launch { - runCatching { repo.getMyShows() } - .onSuccess { myShows = it; myShowsError = null } - .onFailure { - if (it is CancellationException) throw it - myShowsError = friendlyEmbyError(it) - } - myShowsLoading = false - } - launch { - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it; notificationsError = null } - .onFailure { - if (it is CancellationException) throw it - notificationsError = friendlyEmbyError(it) - } - 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 (detailsItem != null) return@launch - if (returnRowId != null && returnItemId != null) { - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } else { - requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) - } - } - } + val playback = rememberHomePlayback( + homeState = homeState, + homeViewModel = homeViewModel, + repo = repo, + scope = scope, + navigationFocusRequesters = navigationFocusRequesters, + contentFocusRequester = contentFocusRequester, + cardReturnFocusRequester = cardReturnFocusRequester, + ) + val abandonLaunch = playback.abandonLaunch + val playItem = playback.playItem + val playTrailer = playback.playTrailer LaunchedEffect(settings.userId) { - myShowsLoading = true - notificationsLoading = true + homeState.myShowsLoading = true + homeState.notificationsLoading = true coroutineScope { launch { runCatching { repo.getMyShows() } - .onSuccess { myShows = it; myShowsError = null } + .onSuccess { homeState.myShows = it; homeState.myShowsError = null } .onFailure { if (it is CancellationException) throw it - myShowsError = friendlyEmbyError(it) + homeState.myShowsError = friendlyEmbyError(it) } - myShowsLoading = false + homeState.myShowsLoading = false } launch { runCatching { repo.getNotifications() } - .onSuccess { notificationState = it; notificationsError = null } + .onSuccess { homeState.notificationState = it; homeState.notificationsError = null } .onFailure { if (it is CancellationException) throw it - notificationsError = friendlyEmbyError(it) + homeState.notificationsError = friendlyEmbyError(it) } - notificationsLoading = false + homeState.notificationsLoading = false } } } @@ -564,16 +468,16 @@ internal fun HomeScreen( LaunchedEffect(liveMaintenance) { if (liveMaintenance != null) { // Maintenance is an app-wide interruption, not another overlay in the stack. - showSettings = false - showProfiles = false - userSwitcherVisible = false - userQuickActionsVisible = false - showNotifications = false - showRequests = false - detailsItem = null - detailsAiringNotice = null - quickMenuItem = null - quickMenuRowId = null + homeState.showSettings = false + homeState.showProfiles = false + homeState.userSwitcherVisible = false + homeState.userQuickActionsVisible = false + homeState.showNotifications = false + homeState.showRequests = false + homeState.detailsItem = null + homeState.detailsAiringNotice = null + homeState.quickMenuItem = null + homeState.quickMenuRowId = null } else if (homeStatus.maintenanceMessage != null) { // A successful live status is authoritative; refresh content immediately // instead of waiting for the maintenance screen's slower retry timer. @@ -583,205 +487,18 @@ internal fun HomeScreen( // The job resolving a stream, so the overlay it raises can be called off. Without it, // a Play press against a server that has stopped answering holds a full-screen loading // overlay for the length of the HTTP timeout with no way to leave it. - var resolveJob by remember { mutableStateOf(null) } - val abandonLaunch: () -> Unit = { - resolveJob?.cancel() - resolveJob = null - resolvingItem = null - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - } - val playItem: (BaseItem) -> Unit = playItem@{ item -> - if (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(returnRowId, returnRowKind) - PlaybackJourney.requested( - sink = homeViewModel.journeySink, - entryPoint = entryPoint, - screen = 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 = selectedDestination.name.lowercase() - val recordPlaybackFailed = { - PlaybackJourney.failed( - sink = homeViewModel.journeySink, - entryPoint = entryPoint, - screen = playbackScreen, - itemId = item.id, - itemName = item.name, - itemType = item.type, - ) - } - 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. - resolveJob = scope.launch { - try { - val prepared = runCatching { - val request = repo.playbackRequestForLaunch(item) - request to repo.readyPlayableForLaunch(request) - }.getOrElse { - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - return@launch - } - val request = prepared.first - val ready = prepared.second - if (ready == null && request.resumePositionMs > 0L) { - val launched = runCatching { - playbackLauncher.launch( - PlayerActivity.intent( - context = context, - request = request, - posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH), - backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH) - ?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH), - requestStartedAtMs = playbackRequestedAtMs, - journeySource = entryPoint.id, - ), - ) - } - if (launched.isFailure) { - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - 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. - resolvingItem = item - runCatching { - val playable = ready ?: run { - var timeout: TimeoutCancellationException? = null - repeat(2) { - try { - // The request, not the card. `playbackRequestForLaunch` - // above is what resolves an episode's title treatment from - // its series, and re-deriving the request from the item - // here threw that away — so a cold start (no resume - // position, which is every unwatched pick) opened the - // player with no logo while a resume opened with one. - return@run withTimeout(PLAYBACK_SOURCE_RESOLUTION_TIMEOUT) { - repo.resolvePlayableForLaunch(request) - } - } catch (error: TimeoutCancellationException) { - timeout = error - } - } - throw requireNotNull(timeout) - } - // A resolution that came back without a stream is a failure with a - // success's shape. Handed on, PlayerActivity finds no URL and no - // request to resolve one from, closes itself in onCreate, and the - // viewer sees the screen flicker and nothing else — the one playback - // failure that arrives with no explanation at all. - check(playable.url.isNotBlank()) { "resolved playable has no stream" } - playable - } - .onSuccess { playable -> - val launched = runCatching { - playbackLauncher.launch( - PlayerActivity.intent( - context = context, - playable = playable, - backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH) - ?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH), - posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH), - requestStartedAtMs = playbackRequestedAtMs, - journeySource = entryPoint.id, - ), - ) - } - if (launched.isFailure) { - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - } - } - .onFailure { error -> - // A cancellation is the viewer having pressed Back out of the - // wait, which has already reopened the gate and said so on screen. - if (error is CancellationException) throw error - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - // Nothing was launched, so nothing will come back to reopen the gate. - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - } - } finally { - resolvingItem = null - resolveJob = null - } - } - } - val playTrailer: (BaseItem) -> Unit = playTrailer@{ item -> - if (launchingItem != null) return@playTrailer - 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) { - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - Toast.makeText(context, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show() - } - } val rows = remember( homeContent, forYouState, - selectedDestination, + homeState.selectedDestination, settings.homeSections, settings.hideWatchedMovies, ) { - val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) { + val destinationRows = if (homeState.selectedDestination == BrowseDestination.FOR_YOU) { forYouBrowseRows(forYouState) } else { - homeRowsFor(selectedDestination, homeContent, settings, remoteConfig) + homeRowsFor(homeState.selectedDestination, homeContent, settings, remoteConfig) } applyWatchedVisibility(destinationRows, settings.hideWatchedMovies) } @@ -831,76 +548,76 @@ internal fun HomeScreen( BrowseDestination.SHOWS to "tv_shows", (BrowseDestination.HOME to "home").takeIf { moved }, ).forEach { (destination, placement) -> - if (!moved && sectionHeroRows.containsKey(destination)) return@forEach + if (!moved && homeState.sectionHeroRows.containsKey(destination)) return@forEach launch { runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> // Replaced one placement at a time, never cleared first: the // cards that did not change keep the artwork already decoded // for them and the row never blanks between the two states. - sectionHeroRows = sectionHeroRows + (destination to resolved) + homeState.sectionHeroRows = homeState.sectionHeroRows + (destination to resolved) } } } } } } - LaunchedEffect(selectedDestination, settings.userId) { - val placement = when (selectedDestination) { + LaunchedEffect(homeState.selectedDestination, settings.userId) { + val placement = when (homeState.selectedDestination) { BrowseDestination.MOVIES -> "movies" BrowseDestination.SHOWS -> "tv_shows" else -> null } ?: return@LaunchedEffect - if (sectionHeroRows.containsKey(selectedDestination)) return@LaunchedEffect + if (homeState.sectionHeroRows.containsKey(homeState.selectedDestination)) return@LaunchedEffect runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> - sectionHeroRows = sectionHeroRows + (selectedDestination to resolved) + homeState.sectionHeroRows = homeState.sectionHeroRows + (homeState.selectedDestination to resolved) } } - val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) { - when (selectedDestination) { + val contextualHeroItems = remember(rows, homeContent.rows, homeState.sectionHeroRows, homeState.selectedDestination, heroDay) { + when (homeState.selectedDestination) { // The separately fetched home hero wins where there is one, which is only // after the configuration moved under a set sitting on this screen; otherwise // the hero row that came with the home payload is still the right answer. BrowseDestination.HOME -> selectHomeHeroMovies( rows, heroDay, - sectionHeroRows[BrowseDestination.HOME]?.takeIf(List::isNotEmpty) + homeState.sectionHeroRows[BrowseDestination.HOME]?.takeIf(List::isNotEmpty) ?: homeContent.rows, ) BrowseDestination.MOVIES, BrowseDestination.SHOWS -> - serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty()) + serverHeroPicks(homeState.sectionHeroRows[homeState.selectedDestination].orEmpty()) else -> emptyList() } } - val showHomeGreeting = selectedDestination == BrowseDestination.HOME && + val showHomeGreeting = homeState.selectedDestination == BrowseDestination.HOME && shouldShowHomeGreeting( hasHero = contextualHeroItems.isNotEmpty(), - focusedRowId = focusedHomeRowId, + focusedRowId = homeState.focusedHomeRowId, rowIds = rows.map(HomeBrowseRow::id), ) - LaunchedEffect(selectedDestination) { - focusedHomeRowId = null + LaunchedEffect(homeState.selectedDestination) { + homeState.focusedHomeRowId = null } - LaunchedEffect(selectedDestination, settings.forYouMinutes) { + LaunchedEffect(homeState.selectedDestination, settings.forYouMinutes) { if ( - selectedDestination == BrowseDestination.FOR_YOU && + homeState.selectedDestination == BrowseDestination.FOR_YOU && (forYouState.rows.isEmpty() || forYouState.availableMinutes != settings.forYouMinutes) ) { homeViewModel.loadForYou(settings.forYouMinutes) } } - LaunchedEffect(selectedDestination, settings.hasOpenedForYou) { - if (selectedDestination == BrowseDestination.FOR_YOU && !settings.hasOpenedForYou) { + LaunchedEffect(homeState.selectedDestination, settings.hasOpenedForYou) { + if (homeState.selectedDestination == BrowseDestination.FOR_YOU && !settings.hasOpenedForYou) { repo.markForYouOpened() } } val journeyScreen = when { - showSettings -> "settings" - showRequests -> "requests" - showNotifications -> "notifications" - userSwitcherVisible || showProfiles -> "profiles" - detailsItem != null -> "details" - selectedMyShow != null -> "my_show_details" - else -> selectedDestination.name.lowercase() + homeState.showSettings -> "settings" + homeState.showRequests -> "requests" + homeState.showNotifications -> "notifications" + homeState.userSwitcherVisible || homeState.showProfiles -> "profiles" + homeState.detailsItem != null -> "details" + homeState.selectedMyShow != null -> "my_show_details" + else -> homeState.selectedDestination.name.lowercase() } LaunchedEffect(journeyScreen) { homeViewModel.trackJourney( @@ -910,21 +627,21 @@ internal fun HomeScreen( } val latestJourneyScreen by rememberUpdatedState(journeyScreen) LaunchedEffect( - selectedDestination, + homeState.selectedDestination, contextualHeroItems.firstOrNull()?.item?.id, rows.firstOrNull()?.items?.firstOrNull()?.id, ) { val firstItem = contextualHeroItems.firstOrNull()?.item ?: rows.firstNotNullOfOrNull { it.items.firstOrNull() } firstItem?.let(homeViewModel::focusItem) - if (firstItem != null && initialFocusDestination != selectedDestination) { + if (firstItem != null && homeState.initialFocusDestination != homeState.selectedDestination) { // A lazy row may still need one or two frames to attach its first card after the // data arrives. Retry briefly rather than falling back to the rail and leaving the // viewer unable to reach the page's rows with the remote. repeat(8) { delay(16.milliseconds) if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) { - initialFocusDestination = selectedDestination + homeState.initialFocusDestination = homeState.selectedDestination return@LaunchedEffect } } @@ -947,7 +664,7 @@ internal fun HomeScreen( } val contentShift by animateDpAsState( - targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp, + targetValue = if (homeState.navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp, animationSpec = tween(150), label = "navigation-content-shift", ) @@ -956,16 +673,16 @@ internal fun HomeScreen( TvNavigationRail( config = remoteConfig.navigation, markPath = remoteMarkPath, - selected = if (userSwitcherVisible) { + selected = if (homeState.userSwitcherVisible) { BrowseDestination.PROFILES } else { - selectedDestination + homeState.selectedDestination }, - focusDestination = railFocusDestination, - expanded = navigationExpanded, + focusDestination = homeState.railFocusDestination, + expanded = homeState.navigationExpanded, navigationFocusRequester = navigationFocusRequester, onRailFocusChanged = { - navigationExpanded = it + homeState.navigationExpanded = it }, onDestinationSelected = { destination -> homeViewModel.trackJourney( @@ -975,41 +692,41 @@ internal fun HomeScreen( ) when (destination) { BrowseDestination.SETTINGS -> { - railFocusDestination = BrowseDestination.SETTINGS - restoreRailAfterSettings = true - navigationExpanded = false - userSwitcherVisible = false - showSettings = true + homeState.railFocusDestination = BrowseDestination.SETTINGS + homeState.restoreRailAfterSettings = true + homeState.navigationExpanded = false + homeState.userSwitcherVisible = false + homeState.showSettings = true } BrowseDestination.PROFILES -> { - railFocusDestination = BrowseDestination.PROFILES - userSwitcherVisible = true + homeState.railFocusDestination = BrowseDestination.PROFILES + homeState.userSwitcherVisible = true // The picker and the shortcut menu are two answers to one control; only // one of them may ever be on screen. - userQuickActionsVisible = false - navigationExpanded = false + homeState.userQuickActionsVisible = false + homeState.navigationExpanded = false // The rail stays live beside Settings, so a destination // chosen from there has to close it or it would sit over // whatever was just asked for. - showSettings = false - restoreRailAfterSettings = false + homeState.showSettings = false + homeState.restoreRailAfterSettings = false } else -> { - navigationExpanded = false - userSwitcherVisible = false - showSettings = false - restoreRailAfterSettings = false - selectedDestination = destination - railFocusDestination = destination + homeState.navigationExpanded = false + homeState.userSwitcherVisible = false + homeState.showSettings = false + homeState.restoreRailAfterSettings = false + homeState.selectedDestination = destination + homeState.railFocusDestination = destination val savedFocus = destinationFocus[destination] savedFocus?.let { (rowId, itemId) -> - returnRowId = rowId - returnItemId = itemId + homeState.returnRowId = rowId + homeState.returnItemId = itemId // Only the id was remembered, so the kind is cleared rather // than left carrying whichever row was focused on the // destination being left; the id alone still names the // shelves that matter. - returnRowKind = null + homeState.returnRowKind = null } if ( savedFocus != null && @@ -1019,7 +736,7 @@ internal fun HomeScreen( // A low shelf may have fallen out of LazyColumn composition // while the rail owned focus. Let the list scroll it back in // before asking its card for focus. - rowListFocusRestoreRequest += 1 + homeState.rowListFocusRestoreRequest += 1 } else { scope.launch { // Let the destination compose and attach its entry target @@ -1052,1814 +769,85 @@ internal fun HomeScreen( feature = "user_switcher_shortcuts", source = journeyScreen, target = "user_shortcuts", ) - userSwitcherVisible = false - navigationExpanded = false + homeState.userSwitcherVisible = false + homeState.navigationExpanded = false // So closing the menu hands focus back to the item that was held, rather // than to whichever destination the rail was last aimed at. - railFocusDestination = BrowseDestination.PROFILES - userQuickActionsVisible = true + homeState.railFocusDestination = BrowseDestination.PROFILES + homeState.userQuickActionsVisible = true }, ) - BoxWithConstraints( + Box( modifier = Modifier .weight(1f) .fillMaxSize() .offset { IntOffset(x = contentShift.roundToPx(), y = 0) }, ) { - 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@BoxWithConstraints - } - - if (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 = returnItemId.takeIf { 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 -> - returnRowId = SEARCH_ROW_ID - returnRowKind = null - 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, - ) - detailsAiringNotice = null - detailsFromSearch = true - detailsItem = item - } - }, - onContentFocused = { navigationExpanded = false }, - onExit = { - selectedDestination = BrowseDestination.HOME - scope.launch { - delay(16.milliseconds) - runCatching { contentFocusRequester.requestFocus() } - } - }, - ) - return@BoxWithConstraints - } - - if (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) - detailsFromSearch = false - if (seriesStub != null) { - detailsAiringNotice = airingNoticeFor(item) - homeViewModel.focusItem(seriesStub) - detailsItem = seriesStub - } else if (item.membyPlayable) { - detailsAiringNotice = null - homeViewModel.focusItem(item) - detailsItem = item - } - }, - onExit = { - selectedDestination = BrowseDestination.HOME - scope.launch { - delay(16.milliseconds) - runCatching { contentFocusRequester.requestFocus() } - } - }, - ) - return@BoxWithConstraints - } - - // 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 (selectedDestination == BrowseDestination.GENRES) { - GenreBrowseScreen( - itemType = ALL_MEDIA_ITEM_TYPE, - initialCategoryId = ALL_MEDIA_CATEGORY_ID, - favouriteStates = favoriteChanges, - playedStates = playedChanges, - navigationFocusRequester = navigationFocusRequester, - contentFocusRequester = contentFocusRequester, - returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID }, - returnFocusRequester = cardReturnFocusRequester, - onItemFocused = homeViewModel::focusItem, - onItemSelected = { item -> - returnRowId = GENRE_BROWSER_ROW_ID - returnRowKind = null - 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, - ) - detailsAiringNotice = null - detailsFromSearch = false - detailsItem = item - }, - onContentFocused = { navigationExpanded = false }, - onClose = { - selectedDestination = BrowseDestination.HOME - railFocusDestination = BrowseDestination.HOME - scope.launch { - delay(16.milliseconds) - runCatching { contentFocusRequester.requestFocus() } - } - }, - ) - return@BoxWithConstraints - } - - FocusedHomeBackdrop(homeViewModel) - val verticalState = verticalStates.getOrPut(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(selectedDestination) { mutableStateOf(false) } - val showHomeHero = shouldShowHomeMovieHero( - hasMovies = hasContextualHero, - listAtTop = homeListAtTop, - rowFocused = rowFocusedBelowHero, - ) - val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero) - val contentWidth = maxWidth - HomeArtworkPreloader(rows = rows, availableWidth = contentWidth) - val rowFocusPositions = rememberedRowFocus.getOrPut(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(selectedDestination) { - mutableStateOf(null) - } - var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) } - var rowFocusRequestId by remember(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 == 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 = - selectedDestination == BrowseDestination.SHOWS && 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 (selectedDestination == BrowseDestination.SHOWS) 1 else 0) + - (if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0) - LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) { - if (rowListFocusRestoreRequest == 0) return@LaunchedEffect - val rowId = returnRowId ?: run { - rowListFocusRestoreRequest = 0 - return@LaunchedEffect - } - val rowIndex = rows.indexOfFirst { it.id == rowId } - if (rowIndex < 0) { - 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) { - rowListFocusRestoreRequest = 0 - return@LaunchedEffect - } - } - 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 = returnItemId.takeIf { 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 -> - navigationExpanded = false - rowFocusedBelowHero = false - focusedHomeRowId = null - destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id - returnRowId = HOME_HERO_ROW_ID - returnRowKind = null - returnItemId = item.id - homeViewModel.focusItem(item) - }, - onItemSelected = { item -> - returnRowId = HOME_HERO_ROW_ID - returnRowKind = null - returnItemId = item.id - homeViewModel.focusItem(item) - homeViewModel.trackJourney( - category = "content", action = "open", screen = selectedDestination.name.lowercase(), - feature = "hero", source = HOME_HERO_ROW_ID, target = "details", - itemId = item.id, itemName = item.name, itemType = item.type, - ) - detailsAiringNotice = null - detailsFromSearch = false - 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.... slow. Please wait.", - color = MembyMutedText, - fontSize = 14.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 (selectedDestination == BrowseDestination.SHOWS) { - item(key = "my-shows", contentType = "my-shows") { - MyShowsStrip( - shows = myShows, - loading = myShowsLoading, - errorMessage = myShowsError, - onRetry = { - if (!myShowsLoading) scope.launch { - myShowsLoading = true - myShowsError = null - runCatching { repo.getMyShows() } - .onSuccess { myShows = it } - .onFailure { myShowsError = friendlyEmbyError(it) } - 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 = 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, - ) - myShowReturnItemId = it.itemId - selectedMyShow = it - }, - onContentFocused = { navigationExpanded = false }, - ) - } - } - if (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 { - returnItemId.takeIf { returnRowId == row.id } - } - } - MediaRow( - modifier = Modifier, - row = row, - availableWidth = contentWidth, - contentEntryFocusRequester = contentFocusRequester.takeIf { - !hasContextualHero && - (selectedDestination != BrowseDestination.SHOWS || - 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 = { navigationExpanded = false }, - onItemFocused = { item, itemIndex -> - rowFocusPositions[row.id] = itemIndex - rowFocusedBelowHero = true - if (selectedDestination == BrowseDestination.HOME) { - focusedHomeRowId = row.id - } - destinationFocus[selectedDestination] = row.id to item.id - returnRowId = row.id - returnRowKind = row.kind.name - returnItemId = item.id - homeViewModel.focusItem(item) - homeViewModel.trackRowFocused(row.id, row.kind.name, item.id) - }, - onItemSelected = { item -> - returnRowId = row.id - returnRowKind = row.kind.name - returnItemId = item.id - homeViewModel.trackRowSelected(row.id, row.kind.name, item.id) - homeViewModel.trackJourney( - category = "content", action = "open", - screen = 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) - detailsFromSearch = false - if (seriesStub != null) { - detailsAiringNotice = airingNoticeFor(item) - homeViewModel.focusItem(seriesStub) - detailsItem = seriesStub - } else if (movieStub != null) { - detailsAiringNotice = null - homeViewModel.focusItem(movieStub) - detailsItem = movieStub - } else { - homeViewModel.focusItem(item) - if (item.membyPlayable || item.isRadarrOnly) { - detailsAiringNotice = null - detailsItem = item - } - } - }, - onItemLongPressed = { item -> - returnRowId = row.id - returnRowKind = row.kind.name - 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. - quickMenuTrailerAvailable = false - quickMenuRowId = row.id - quickMenuItem = item - } - }, - density = settings.homeCardDensity, - artworkStyle = settings.homeArtworkStyle, - horizontalState = horizontalStates.getOrPut( - "${selectedDestination.name}:${row.id}", - ) { LazyListState() }, - ) - } - } - } - } - } - AnimatedVisibility( - visible = compatibilityNotice != null, - modifier = Modifier.align(Alignment.TopCenter), - enter = fadeIn(tween(180)), - exit = fadeOut(tween(120)), - ) { - Text( - text = compatibilityNotice?.message.orEmpty(), - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFFD6403A)) - .padding(horizontal = 36.dp, vertical = 12.dp), - ) - } - HomeClock( - showGreeting = showHomeGreeting, - username = settings.username, - shortName = settings.shortName, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 24.dp, bottom = 18.dp), - ) - if (userSwitcherVisible) { - BackHandler { - userSwitcherVisible = false - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } - } - UserSwitcherOverlay( - profiles = settings.profiles, - activeProfileId = settings.activeProfileId, - onProfileSelected = { profile -> - userSwitcherVisible = false - navigationExpanded = false - if (profile.id != settings.activeProfileId) { - switchingProfileId = profile.id - scope.launch { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - runCatching { repo.switchProfile(profile) } - .onFailure { switchingProfileId = null } - } - } else { - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } - } - }, - onManageProfiles = { - userSwitcherVisible = false - navigationExpanded = false - showProfiles = true - }, - alertCount = displayedNotifications.size, - onOpenSettings = { - homeViewModel.trackJourney( - category = "navigation", action = "select", - screen = journeyScreen, feature = "settings", - source = journeyScreen, target = "settings", - ) - userSwitcherVisible = false - navigationExpanded = false - railFocusDestination = BrowseDestination.PROFILES - restoreRailAfterSettings = true - showSettings = true - }, - onOpenAlerts = { - homeViewModel.trackJourney( - category = "notifications", action = "open", screen = journeyScreen, - feature = "notifications", target = "notifications", - ) - userSwitcherVisible = false - navigationExpanded = false - showNotifications = true - scope.launch { - notificationsLoading = true - notificationsError = null - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it } - .onFailure { notificationsError = friendlyEmbyError(it) } - notificationsLoading = false - } - }, - showViewers = shouldOfferViewerPicker( - gatewayMode = ServerConfig.isGateway, - enabled = viewersEnabled, - viewerCount = viewers.size, - ), - viewers = viewers, - activeViewerId = settings.activeViewerId, - // Selecting a person is the panel's own press now rather than a screen - // reached from it, which is the whole point: this is the thing a household - // changes nightly. Everything on the launcher belongs to the outgoing - // viewer, so the journey is closed and the rows replaced rather than left - // standing under a different person's name. Changing the viewer rebuilds - // the shared launcher lifecycle, including its ViewModel and focus graph. - onViewerSelected = { viewer -> - userSwitcherVisible = false - navigationExpanded = false - scope.launch { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - repo.switchViewer(viewer) - } - }, - onAddViewer = { - userSwitcherVisible = false - navigationExpanded = false - openViewerName(ViewerNameTarget.Add) - }, - canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, - onManageViewers = { - userSwitcherVisible = false - navigationExpanded = false - showViewerPicker = true - }, - showRequests = requestsAllowed, - onOpenRequests = { - homeViewModel.trackJourney( - category = "requests", action = "open", screen = journeyScreen, - feature = "requests", target = "requests", - ) - userSwitcherVisible = false - navigationExpanded = false - showRequests = true - }, - onDismiss = { - userSwitcherVisible = false - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } - }, - ) - } - if (showViewerPicker) { - val closeViewerPicker: (restoreFocus: Boolean) -> Unit = { restoreFocus -> - showViewerPicker = false - if (restoreFocus) { - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } - } - } - BackHandler { closeViewerPicker(true) } - Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) { - ViewerPicker( - viewers = viewers, - activeViewerId = settings.activeViewerId, - onViewerSelected = { viewer -> - // Do not aim focus back into the outgoing viewer's graph while its - // user-keyed launcher is being disposed. - closeViewerPicker(false) - scope.launch { - // Everything on the launcher belongs to the outgoing viewer, so - // the journey is closed before the viewer-keyed launcher is rebuilt. - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - repo.switchViewer(viewer) - } - }, - // Both open over the picker rather than instead of it, so Back is one - // step out of each and the row of faces is still underneath. - onAddViewer = { openViewerName(ViewerNameTarget.Add) }, - onManageViewers = { showViewerManage = true }, - canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, - ) - } - } - if (showViewerManage) { - BackHandler(onBack = { showViewerManage = false }) - Box(Modifier.fillMaxSize().zIndex(21f).background(MembySurface)) { - ViewerManageScreen( - viewers = viewers, - onRename = { openViewerName(ViewerNameTarget.Rename(it)) }, - onRemove = { viewer -> - viewerBusyId = viewer.id - scope.launch { - // Removing whoever is watching returns this set to the account, - // which the repository does; the launcher has to be told, or it - // goes on drawing the removed person's rows. - val watching = viewer.id == settings.activeViewerId - val removed = repo.removeViewer(viewer) - if (removed && watching) { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - } - refreshViewers() - viewerBusyId = null - } - }, - onAdd = { openViewerName(ViewerNameTarget.Add) }, - onClose = { showViewerManage = false }, - canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, - busyViewerId = viewerBusyId, - onSetPin = { pinTarget = it; pinFailure = null; showViewerManage = false }, - ) - } - } - viewerNameTarget?.let { target -> - val closeViewerName = { viewerNameTarget = null } - BackHandler(onBack = closeViewerName) - Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) { - ViewerNameEntry( - target = target, - name = viewerName, - existing = viewers, - onNameChanged = { viewerName = it; viewerNameFailure = null }, - onCancel = closeViewerName, - onConfirm = { - if (!viewerSaving) { - viewerSaving = true - scope.launch { - val saved = when (target) { - ViewerNameTarget.Add -> repo.createViewer(viewerName) - is ViewerNameTarget.Rename -> - repo.renameViewer(target.viewer, viewerName) - } - viewerSaving = false - if (saved == null) { - // The one thing the television can say about a refusal - // it has no wording for. The screen stays up holding - // what was typed, because retyping a name somebody has - // just entered is the worst possible answer to a - // request that failed for a reason nothing here knows. - viewerNameFailure = "That could not be saved. Try again." - } else { - viewerNameTarget = null - refreshViewers() - } - } - } - }, - failure = viewerNameFailure, - saving = viewerSaving, - ) - } - } - pinTarget?.let { viewer -> - BackHandler(onBack = { pinTarget = null }) - Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) { - ViewerPinEntry( - viewer = viewer, - saving = pinSaving, - failure = pinFailure, - onCancel = { pinTarget = null }, - onConfirm = { pin -> - if (!pinSaving) { - pinSaving = true - scope.launch { - runCatching { repo.setViewerPIN(viewer, pin) } - .onSuccess { pinTarget = null; refreshViewers() } - .onFailure { pinFailure = "That PIN could not be saved." } - pinSaving = false - } - } - }, - ) - } - } - if (userQuickActionsVisible) { - val closeUserQuickActions: () -> Unit = { - userQuickActionsVisible = false - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } - } - BackHandler(onBack = closeUserQuickActions) - UserQuickActionsOverlay( - username = settings.username.orEmpty(), - alertCount = displayedNotifications.size, - busy = notificationsMutationBusy, - // Clearing from here never takes the viewer anywhere: the menu closes, the - // badge on the rail behind it empties, and a toast says what happened. The - // list is emptied optimistically for the reason the alerts page already is — - // a badge that lingered while the request was in flight is one somebody - // clears twice — and put back untouched if the gateway refuses. - onClearNotifications = { - if (!notificationsMutationBusy) { - notificationsMutationBusy = true - val previous = notificationState - val offered = displayedNotifications.size - notificationState = notificationState.copy(notifications = emptyList()) - closeUserQuickActions() - scope.launch { - runCatching { repo.clearNotifications() } - .onSuccess { cleared -> - Toast.makeText( - context, - clearedNotificationsMessage(cleared), - Toast.LENGTH_SHORT, - ).show() - homeViewModel.trackJourney( - category = "notifications", action = "dismiss", - screen = journeyScreen, - feature = "user_switcher_clear_notifications", - source = "user_switcher", target = "notifications", - // The one free-text field, and the count is the only - // thing that separates one use of this shortcut from - // the next. - itemName = "$cleared cleared", - outcome = "success", - ) - } - .onFailure { - // Put the list back and say so here rather than writing - // notificationsError: this menu never opens the page, and - // an error banner waiting on a page nobody has opened is - // one they meet later with no idea what it is about. - notificationState = previous - Toast.makeText( - context, - "Couldn’t clear notifications", - Toast.LENGTH_SHORT, - ).show() - homeViewModel.trackJourney( - category = "notifications", action = "dismiss", - screen = journeyScreen, - feature = "user_switcher_clear_notifications", - source = "user_switcher", target = "notifications", - itemName = "$offered offered", - outcome = "failure", - ) - } - notificationsMutationBusy = false - } - } - }, - onDismiss = closeUserQuickActions, - ) - } - if (showSettings) { - val closeSettings: () -> Unit = { - showSettings = false - scope.launch { - delay(16.milliseconds) - if (restoreRailAfterSettings) { - navigationExpanded = true - runCatching { navigationFocusRequester.requestFocus() } - restoreRailAfterSettings = false - } else if (returnItemId != null) { - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } else { - runCatching { contentFocusRequester.requestFocus() } - } - } - } - BackHandler(onBack = closeSettings) - // Settings is a destination, not a takeover: it leaves the main rail visible - // and reachable so Home, Search and the active user are one Left press away, the - // same as from every other page. The rail below is the live one — this Row - // only reserves its collapsed footprint and takes the same expand shift the - // content area does, so an expanded rail slides Settings aside rather than - // covering its own page list. - // The full expansion, not the home screen's TvRailContentShift: the rows - // tolerate the rail drawing over their left edge, but this panel is painted - // above the rail, so anything short of the whole width would clip the - // labels off the very rail the viewer is moving through. - val settingsShift by animateDpAsState( - targetValue = if (navigationExpanded) { - TvRailExpandedWidth - TvRailCollapsedWidth - } else { - 0.dp - }, - animationSpec = tween(150), - label = "settings-content-shift", - ) - Row(Modifier.fillMaxSize()) { - Spacer(Modifier.width(TvRailCollapsedWidth)) - SettingsSheet( - onClose = closeSettings, - overlay = false, + HomeContentPane( + homeState = homeState, + settings = settings, + remoteConfig = remoteConfig, + homeViewModel = homeViewModel, + repo = repo, + scope = scope, + profileViewModelOwner = profileViewModelOwner, + homeContent = homeContent, + homeStatus = homeStatus, + forYouState = forYouState, + favoriteChanges = favoriteChanges, + playedChanges = playedChanges, + liveMaintenance = liveMaintenance, + rows = rows, + contextualHeroItems = contextualHeroItems, + metadataHeroContentOrder = metadataHeroContentOrder, + metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour, navigationFocusRequester = navigationFocusRequester, - onAnalyticsEvent = { feature, action -> - homeViewModel.trackJourney( - category = "settings", action = action, screen = "settings", - feature = feature, outcome = "success", - ) - }, - modifier = Modifier - .weight(1f) - .offset { IntOffset(x = settingsShift.roundToPx(), y = 0) }, - ) - } - } - if (showProfiles) { - BackHandler(onBack = { showProfiles = false }) - ProfileChooser( - profiles = settings.profiles, - currentProfileId = settings.activeProfileId, - switchingProfileId = switchingProfileId, - removingProfileId = removingProfileId, - onSelect = { profile -> - if (profile.id == settings.activeProfileId) { - showProfiles = false - } else { - switchingProfileId = profile.id - scope.launch { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - runCatching { repo.switchProfile(profile) } - .onFailure { switchingProfileId = null } - } - } - }, - onRemove = { profile -> - removingProfileId = profile.id - scope.launch { - runCatching { repo.removeProfile(profile) } - removingProfileId = null - } - }, - onAddProfile = { - scope.launch { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - addingProfile = true - } - }, - onClose = { showProfiles = false }, - ) - } - // Over the manage-users page rather than instead of it: cancelling comes straight - // back to the list the "+" was pressed from. A successful sign-in makes the new - // viewer active, which re-keys HomeScreen and takes both flags with it. - if (addingProfile) { - SetupScreen( - onCancel = { addingProfile = false }, - onSignedIn = { addingProfile = false }, - ) - } - // Coming back from the player. The page is reopened rather than restored from - // under the player — see [detailsResumeTarget] — and the item is re-requested on - // the way in, so what appears has this playback's progress on it rather than the - // record the row handed over before the film started. - LifecycleResumeEffect(Unit) { - detailsResumeTarget?.let { target -> - detailsResumeTarget = null - if (detailsItem == null) { - homeViewModel.focusItem(target.item) - detailsTrail = target.trail - detailsAiringNotice = target.airingNotice - restoreDetailPosition = true - detailsItem = target.item - } - } - onPauseOrDispose { } - } - detailsItem?.let { selected -> - // A detail page is opened by a press that sets `detailsItem`, so the composition - // this runs in *is* the frame that press caused — which makes it the earliest - // point that can honestly stand for "the viewer asked for this page". Keyed on - // the item, so walking a "More like this" trail times each page separately. - // Debug-only: on a release build both calls return before allocating. - remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) } - LaunchedEffect(selected.id) { StartupTrace.endSpan(StartupTrace.DETAIL) } - // Closing the page with nothing left in the trail — the hardware Back key and the - // "Back to Search Results" action both mean exactly this, so both call it rather - // than keeping two copies of what "leave the page" does. - val closeDetails: () -> Unit = { - restoreDetailPosition = false - detailsItem = null - detailsAiringNotice = null - detailsFromSearch = false - // Same settle delay every other "an overlay just closed, hand focus back - // to what is underneath" path in this file uses (closeSettings, - // onDestinationSelected): the overlay's own focused node is being torn - // down this same frame, and asking a FocusRequester to take focus before - // Compose has processed that removal can silently do nothing — requestFocus() - // does not throw when its target is not currently reachable, so - // requestFirstAvailableFocus reported success while nothing had actually - // moved. Search paid for that the worst: with no live focus owner, the - // D-pad's directional search had nothing to search relative to and the - // remote surfaced back near the keyboard pane, unable to reach any card in - // the results list it had just returned to. - scope.launch { - delay(16.milliseconds) - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } - Unit - } - BackHandler { - val previous = detailsTrail.lastOrNull() - if (previous != null) { - detailsTrail = detailsTrail.dropLast(1) - restoreDetailPosition = true - detailsItem = previous - } else { - closeDetails() - } - } - FocusedDetailsOverlay( - homeViewModel = homeViewModel, - selected = selected, - seriesStatusRevision = seriesStatusRevision, - detailExperience = detailExperience, - restorePosition = restoreDetailPosition, - airingNotice = detailsAiringNotice, - // Contextual to the exact page Search opened: still true after walking back - // out of a "More like this" trail to it, gone the moment that trail is not - // empty — a deep-link shortcut back to a specific entry point rather than a - // permanent control every detail page carries. - onBackToSearch = closeDetails.takeIf { detailsFromSearch && detailsTrail.isEmpty() }, - onOpenItem = { related -> - homeViewModel.trackJourney( - category = "recommendations", action = "open", screen = "details", - feature = "related", source = "related", target = "details", - itemName = related.name, itemType = related.type, - ) - detailsTrail = detailsTrail + selected - restoreDetailPosition = false - // Ask for the full record the same way a focused card does. Some - // routes here hand over a stub rather than a row item — the episode - // page's "Show" button knows only the series' id and name — and - // without this the page opens with no logo, no backdrop and no facts, - // because nothing else on this path ever fetches the item. - homeViewModel.focusItem(related) - detailsItem = related - // The notice was about the show that was pressed in the row, not about - // whatever "More like this" leads to. Walking Back does not restore it, - // and should not: it was news, and it has been read. - detailsAiringNotice = null - }, - onOpenEmbyItem = { embyItem -> - // Not a step in the trail: the Radarr page and the Emby page are two - // answers about one title, so Back from here still belongs where the - // card was pressed rather than on the page it replaced. - homeViewModel.focusItem(embyItem) - detailsAiringNotice = null - detailsItem = embyItem - }, - onPlay = { - // Kept, not discarded: this is what the viewer comes back to when the - // film ends or they press Back out of the player. - detailsResumeTarget = DetailsReturn(selected, detailsTrail, detailsAiringNotice) - detailsItem = null - detailsTrail = emptyList() - restoreDetailPosition = false - detailsAiringNotice = null - playItem(it) - }, - onPlayTrailer = playTrailer, - onToggleFavorite = { item, saved -> - homeViewModel.trackJourney( - category = "library", action = if (saved) "favourite" else "unfavourite", - screen = "details", feature = "favorites", itemName = item.name, itemType = item.type, - outcome = "success", - ) - homeViewModel.setFavorite(item, saved) - }, - isMyShow = myShows.any { it.itemId == selected.id }, - onToggleMyShow = { item, saved -> - homeViewModel.trackJourney( - category = "library", action = if (saved) "follow" else "unfollow", - screen = "details", feature = "my_shows", itemName = item.name, itemType = item.type, - outcome = "success", - ) - // Optimistic, the way a favourite already is. Following a show is a - // press with an obvious outcome, and the server's answer to it is the - // *whole* list decorated with Sonarr's lifecycle for every show on it - // — a request the viewer has no reason to sit through watching an - // unchanged button. The row that lands a moment later replaces this - // placeholder; a failure puts the button back where it was. - if (myShowsMutationBusy) return@FocusedDetailsOverlay - myShowsMutationBusy = true - val previous = myShows - myShows = if (saved) { - myShows.filterNot { it.itemId == item.id } + myShowStub(item) - } else { - myShows.filterNot { it.itemId == item.id } - } - if (saved) { - Toast.makeText( - context, - "${item.name} added to My Shows", - Toast.LENGTH_SHORT, - ).show() - } - scope.launch { - if (saved) { - runCatching { repo.saveMyShow(item) } - .onSuccess { myShows = it } - .onFailure { myShows = previous } - } else { - runCatching { repo.removeMyShow(item.id) } - .onFailure { myShows = previous } - } - myShowsMutationBusy = false - } - }, - onTogglePlayed = { item, played -> - homeViewModel.trackJourney( - category = "library", action = if (played) "mark_played" else "mark_unplayed", - screen = "details", feature = "played_status", itemName = item.name, itemType = item.type, - outcome = "success", - ) - homeViewModel.setPlayed(item, played) - }, - onClose = { - detailsItem = null - detailsTrail = emptyList() - restoreDetailPosition = false - detailsAiringNotice = null - detailsFromSearch = false - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - }, - ) - } - selectedMyShow?.let { show -> - val closeMyShow: (Boolean) -> Unit = { removed -> - selectedMyShow = null - scope.launch { - delay(16.milliseconds) - val exactStillExists = !removed && myShows.any { it.itemId == myShowReturnItemId } - val restored = exactStillExists && runCatching { - myShowReturnFocusRequester.requestFocus() - }.isSuccess - if (!restored && runCatching { contentFocusRequester.requestFocus() }.isFailure) { - runCatching { navigationFocusRequester.requestFocus() } - } - } - } - BackHandler { closeMyShow(false) } - MyShowDetailsOverlay( - show = show, - repository = repo, - removing = removingMyShow, - onRemove = { - removingMyShow = true - scope.launch { - runCatching { repo.removeMyShow(show.itemId) }.onSuccess { - myShows = myShows.filterNot { it.itemId == show.itemId } - closeMyShow(true) - } - removingMyShow = false - } - }, - onClose = { closeMyShow(false) }, - ) - } - if (showRequests) { - // Reached from the user picker, so leaving it returns to the rail rather than to - // whatever card held focus on the launcher behind it — the alerts page's rule. - val closeRequests: () -> Unit = { - showRequests = false - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } - } - // Keyed on the profile like every other page here, so switching viewer cannot - // leave one person's requests on screen under somebody else's name. - val requestsViewModel: RequestsViewModel = viewModel( - viewModelStoreOwner = profileViewModelOwner, - factory = RequestsViewModelFactory(repo), - ) - val requestsState by requestsViewModel.state.collectAsStateWithLifecycle() - // The page keeps itself current while it is on screen — it is the one screen - // whose cards move without anybody touching anything. The loop is hung off the - // composition rather than off the view model, which is keyed on the profile and - // outlives this block, and off STARTED rather than run unconditionally, so a - // television left on the launcher or switched to another app stops asking - // entirely. Whether there is anything worth asking about is the view model's - // own judgement — see pollWhileVisible. - val requestsLifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(requestsViewModel, requestsLifecycleOwner) { - requestsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - requestsViewModel.pollWhileVisible() - } - } - // The page covers the launcher, rail and all, so it carries its own rather than - // leaving Left pointing at one nobody can see. Its own FocusRequester, because - // the launcher's is still attached to the rail underneath and one requester on - // two live nodes lands on whichever Compose reaches first. - // - // Selecting a destination from here leaves the page — this rail is how you get - // *out* of Requests, not a second way to browse behind it. - Row( - Modifier - .fillMaxSize() - .zIndex(6f) - // The rail's own surface is 95% opaque, so without this the launcher - // shows faintly through the one column of the page it does not paint. - .background(MembySurface), - ) { - TvNavigationRail( - config = remoteConfig.navigation, - markPath = remoteMarkPath, - selected = if (userSwitcherVisible) { - BrowseDestination.PROFILES - } else { - selectedDestination - }, - expanded = requestsRailExpanded, - navigationFocusRequester = requestsRailFocusRequester, - onRailFocusChanged = { requestsRailExpanded = it }, - onDestinationSelected = { destination -> - requestsRailExpanded = false - showRequests = false - when (destination) { - BrowseDestination.SETTINGS -> { - railFocusDestination = BrowseDestination.SETTINGS - restoreRailAfterSettings = true - navigationExpanded = false - userSwitcherVisible = false - showSettings = true - } - BrowseDestination.PROFILES -> { - railFocusDestination = BrowseDestination.PROFILES - userSwitcherVisible = true - // The picker and the shortcut menu are two answers to one control; only - // one of them may ever be on screen. - userQuickActionsVisible = false - navigationExpanded = false - showSettings = false - restoreRailAfterSettings = false - } - else -> { - selectedDestination = destination - railFocusDestination = destination - navigationExpanded = false - userSwitcherVisible = false - showSettings = false - restoreRailAfterSettings = false - scope.launch { - // Let the destination compose and attach its entry - // target before focus is transferred to it. - delay(16.milliseconds) - requestFirstAvailableFocus( - contentFocusRequester, - navigationFocusRequester, - ) - } - } - } - }, - alertCount = displayedNotifications.size, - activeUsername = settings.username.orEmpty(), - activeProfileInitials = settings.profileInitials, - calendarEnabled = tvCalendarEnabled, - ) - RequestsScreen( - state = requestsState, - navigationFocusRequester = requestsRailFocusRequester, contentFocusRequester = contentFocusRequester, - onSelectTab = requestsViewModel::selectTab, - onQueryChanged = requestsViewModel::onQueryChanged, - onAppendToQuery = requestsViewModel::appendToQuery, - onBackspace = requestsViewModel::backspace, - onClearQuery = requestsViewModel::clearQuery, - onSubmitSearch = requestsViewModel::submitSearch, - onRequest = requestsViewModel::request, - onRemove = requestsViewModel::remove, - onOpenItem = { itemId -> - // A request that has arrived opens the thing it became. The stub is - // filled in by focusItem the way a schedule card's is, so the page - // appears at once instead of waiting on an item request. - scope.launch { - runCatching { repo.getItemDetails(itemId) } - .onSuccess { item -> - closeRequests() - detailsAiringNotice = null - detailsFromSearch = false - detailsTrail = emptyList() - detailsItem = item - } - } - }, - onRetry = requestsViewModel::refresh, - onExit = closeRequests, - posterUrlFor = { it.takeIf(String::isNotBlank) }, - modifier = Modifier.weight(1f).fillMaxHeight(), + cardReturnFocusRequester = cardReturnFocusRequester, + heroRowEntryFocusRequester = heroRowEntryFocusRequester, + myShowsEntryFocusRequester = myShowsEntryFocusRequester, + myShowReturnFocusRequester = myShowReturnFocusRequester, + verticalStates = verticalStates, + horizontalStates = horizontalStates, + rememberedRowFocus = rememberedRowFocus, + destinationFocus = destinationFocus, + playItem = playItem, ) } } - if (showNotifications) { - // Reached from the user picker, so leaving it goes back to the rail rather than - // to whatever card happened to hold focus on the launcher behind it. - val closeAlerts: () -> Unit = { - showNotifications = false - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } - } - BackHandler(onBack = closeAlerts) - MyAlertsPage( - notifications = displayedNotifications, - preferences = notificationState.preferences, - loading = notificationsLoading, - errorMessage = notificationsError, - onRetry = { - if (!notificationsLoading) scope.launch { - notificationsLoading = true - notificationsError = null - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it } - .onFailure { notificationsError = friendlyEmbyError(it) } - notificationsLoading = false - } - }, - onNotificationAction = { notification, action -> - if (notificationsMutationBusy) return@MyAlertsPage - notificationsMutationBusy = true - val previous = notificationState - notificationState = notificationState.copy( - notifications = notificationState.notifications.map { - if (it.id == notification.id) { - it.copy( - itemId = "", - action = action.copy( - label = action.completedLabel.ifBlank { action.label }, - enabled = false, - ), - ) - } else { - it - } - }, - ) - scope.launch { - runCatching { repo.runNotificationAction(notification.id, action.kind) } - .onFailure { failure -> - notificationState = previous - notificationsError = friendlyEmbyError(failure) - } - notificationsMutationBusy = false - } - }, - // The seen toggle, and the only thing that moves a row between Inbox and - // Seen — nothing is marked read merely by being looked at any more, because - // with the two halves split that would empty the Inbox under the remote. - // - // Optimistic and reversed on failure, like the dismissal below: the flag is - // the only thing that changed, so a row that sat unmoved while its request - // was in flight is one pressed a second time. - onToggleSeen = onToggleSeen@{ notification -> - val markingSeen = notification.unread - val previousReadAt = notification.readAt - notificationState = notificationState.copy( - notifications = notificationState.notifications.map { - if (it.id == notification.id) { - it.copy(readAt = if (markingSeen) "now" else null) - } else { - it - } - }, - ) - scope.launch { - runCatching { - if (markingSeen) { - repo.markNotificationRead(notification.id) - } else { - repo.markNotificationUnread(notification.id) - } - }.onFailure { failure -> - notificationState = notificationState.copy( - notifications = notificationState.notifications.map { - if (it.id == notification.id) { - it.copy(readAt = previousReadAt) - } else { - it - } - }, - ) - notificationsError = friendlyEmbyError(failure) - } - } - }, - // Optimistic, for the reason "Dismiss all" beneath it already is: this - // page is judged entirely on emptying itself, and a row that stayed put - // while its request was in flight is a row pressed again — which on this - // page also re-aims where focus lands afterwards. A failure puts the row - // back where it was rather than quietly losing somebody's alert. - onDismiss = onDismiss@{ notification -> - if (notificationsMutationBusy) return@onDismiss - notificationsMutationBusy = true - val previous = notificationState - notificationState = notificationState.copy( - notifications = notificationState.notifications.filterNot { - it.id == notification.id - }, - ) - scope.launch { - runCatching { repo.dismissNotification(notification.id) } - .onFailure { failure -> - notificationState = previous - notificationsError = friendlyEmbyError(failure) - } - notificationsMutationBusy = false - } - }, - // The gateway has no bulk route, so this is the same call per alert. The - // list is emptied optimistically: the page is judged on emptying itself, - // and a row that lingered while its request was in flight would be pressed - // a second time. - onDismissAll = { pending -> - if (notificationsMutationBusy) return@MyAlertsPage - notificationsMutationBusy = true - val previous = notificationState - // Only the half on screen. The page dismisses what it is showing, so - // emptying Seen must not also throw away an Inbox the viewer has not - // read — a bulk action nobody can see the extent of is one nobody presses. - val pendingIds = pending.map(UserNotification::id).toSet() - notificationState = notificationState.copy( - notifications = notificationState.notifications.filterNot { - it.id in pendingIds - }, - ) - scope.launch { - val failed = pendingIds.filter { id -> - runCatching { repo.dismissNotification(id) }.isFailure - } - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it } - .onFailure { failure -> - notificationState = previous - notificationsError = friendlyEmbyError(failure) - } - if (failed.isNotEmpty()) { - notificationsError = "Some alerts couldn’t be dismissed. Try again." - } - notificationsMutationBusy = false - } - }, - onClose = closeAlerts, - ) - } - quickMenuItem?.let { selected -> - LaunchedEffect(selected.id) { - quickMenuTrailerAvailable = selected.isRadarrOnly && - repo.getRadarrMovie(selected.id)?.trailerAvailable == true - } - val closeQuickActions: (Boolean) -> Unit = { originWillDisappear -> - quickMenuItem = null - quickMenuRowId = null - scope.launch { - // The overlay owns focus until it leaves composition. Restore the - // exact originating card after its focus node is available again. - delay(16.milliseconds) - if (originWillDisappear) { - requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) - } else { - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } - } - } - BackHandler { closeQuickActions(false) } - FocusedQuickActionsOverlay( - homeViewModel = homeViewModel, - selected = selected, - onOpenDetails = { - quickMenuItem = null - detailsAiringNotice = null - detailsFromSearch = false - detailsItem = it - }, - onSetFavorite = homeViewModel::setFavorite, - onSetPlayed = homeViewModel::setPlayed, - // Only for a film with no page to play from, and only when the gateway has - // a candidate to resolve — the same answer the detail page's button waits - // for, so the two can never disagree about whether there is a trailer. - onPlayTrailer = if (selected.isRadarrOnly && quickMenuTrailerAvailable) { - { - quickMenuItem = null - quickMenuRowId = null - playTrailer(selected) - } - } else { - null - }, - onRemoveFromContinueWatching = if ( - rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE - ) { - { - quickMenuItem = null - quickMenuRowId = null - homeViewModel.removeFromContinueWatching(selected) - scope.launch { - delay(16.milliseconds) - val removalFocusRequester = if ( - contextualHeroItems.isNotEmpty() - ) { - heroRowEntryFocusRequester - } else { - contentFocusRequester - } - requestFirstAvailableFocus( - removalFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } - } - } else { - null - }, - rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title, - rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(), - onToggleRowPinned = quickMenuRowId?.let { rowId -> - { - val pinned = settings.homePinnedRows.decodeRowIds().toMutableSet() - if (!pinned.add(rowId)) pinned.remove(rowId) - scope.launch { - ServiceLocator.settings.setHomeRowPreferences( - settings.homeRowOrder.decodeRowIds(), - pinned, - settings.homeHiddenRows.decodeRowIds().toSet(), - ) - } - closeQuickActions(false) - } - }, - onHideRow = quickMenuRowId?.let { rowId -> - { - val hidden = settings.homeHiddenRows.decodeRowIds().toMutableSet() - hidden.add(rowId) - scope.launch { - ServiceLocator.settings.setHomeRowPreferences( - settings.homeRowOrder.decodeRowIds(), - settings.homePinnedRows.decodeRowIds().toSet(), - hidden, - ) - closeQuickActions(true) - } - } - }, - onMoveRow = quickMenuRowId?.let { rowId -> - { direction -> - val currentIds = rows.map(HomeBrowseRow::id).toMutableList() - val index = currentIds.indexOf(rowId) - val target = (index + direction).coerceIn(0, currentIds.lastIndex) - if (index >= 0 && target != index) { - currentIds.removeAt(index) - currentIds.add(target, rowId) - scope.launch { - ServiceLocator.settings.setHomeRowPreferences( - currentIds, - settings.homePinnedRows.decodeRowIds().toSet(), - settings.homeHiddenRows.decodeRowIds().toSet(), - ) - } - } - closeQuickActions(false) - } - }, - onClose = { closeQuickActions(false) }, - ) - } - resolvingItem?.let { item -> - // Back gets out of the wait. This overlay covers the whole screen while the - // gateway is asked for a stream, and against a server that has stopped - // answering that is the length of an HTTP timeout — long enough that a viewer - // reaches for the remote, and until now long enough that nothing answered - // them. Cancelling is safe: nothing has been launched and nothing reported. - BackHandler(onBack = abandonLaunch) - PlaybackLaunchOverlay( - item = item, - modifier = Modifier.fillMaxSize().zIndex(9f), - ) - } - // Emby has stopped answering. Persistent, unlike the news bar below it, because - // it describes a state rather than an event: a set switched on midway through an - // outage was never told, and this is the only thing that says why nothing plays. - // Suppressed under maintenance, which owns the screen and is its own explanation. - EmbyOutageBanner( - suppressed = liveMaintenance != null, - modifier = Modifier.align(Alignment.TopCenter), - ) - // News about the library, not about the app: it sits above the rows but is - // suppressed whenever something more important owns the screen. - ServiceAlertBanner( - surface = NotificationSurface.BROWSING, - // The alert itself is collected inside the banner, so an arriving one does - // not recompose this whole function. Only the suppression conditions — both - // already read here for other reasons — cross the boundary. - // The outage bar takes the same strip of screen and outranks any - // announcement, including the one announcing this very outage. - suppressed = liveMaintenance != null || embyOutage != null, - // Flush to the top edge and spanning the rail: for its few seconds this is - // the top layer of the screen, the way a broadcast notice is. - modifier = Modifier.align(Alignment.TopCenter), + HomeOverlayHost( + homeState = homeState, + settings = settings, + homeViewModel = homeViewModel, + repo = repo, + scope = scope, + context = context, + remoteConfig = remoteConfig, + remoteMarkPath = remoteMarkPath, + profileViewModelOwner = profileViewModelOwner, + journeyScreen = journeyScreen, + showHomeGreeting = showHomeGreeting, + compatibilityNotice = compatibilityNotice, + liveMaintenance = liveMaintenance, + embyOutage = embyOutage, + seriesStatusRevision = seriesStatusRevision, + detailExperience = detailExperience, + requestsAllowed = requestsAllowed, + viewersEnabled = viewersEnabled, + tvCalendarEnabled = tvCalendarEnabled, + hasContextualHero = contextualHeroItems.isNotEmpty(), + rows = rows, + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + cardReturnFocusRequester = cardReturnFocusRequester, + requestsRailFocusRequester = requestsRailFocusRequester, + myShowReturnFocusRequester = myShowReturnFocusRequester, + heroRowEntryFocusRequester = heroRowEntryFocusRequester, + openViewerName = openViewerName, + refreshViewers = refreshViewers, + abandonLaunch = abandonLaunch, + playItem = playItem, + playTrailer = playTrailer, ) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreenState.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreenState.kt new file mode 100644 index 0000000..4183b8e --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreenState.kt @@ -0,0 +1,144 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.setValue +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.HomeRow +import com.ponzischeme89.memby.data.model.MembyViewer +import com.ponzischeme89.memby.data.model.MyShow +import com.ponzischeme89.memby.data.model.NotificationsResponse +import com.ponzischeme89.memby.ui.detail.AiringNotice +import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget +import kotlinx.coroutines.Job + +/** + * Mutable launcher state hoisted out of the [HomeScreen] composable body. + * + * `HomeScreen` was a single ~2670-line `@Composable` that exceeded ART's method compiler + * limit — so it ran fully interpreted — and recomposed wholesale whenever any of its ~40 + * state flags changed, freezing the UI for 1–2 s on every rail-section switch. It is now + * split into `HomeScreen` (state + effects + rail) → `HomeContentPane` (the per-destination + * panes) and `HomeOverlays` (the overlay stack). That split needs one shared, stable state + * object rather than threading ~70 value/setter parameters: a `@Stable` holder whose + * identity never changes means each child recomposes only for the properties it reads. + * + * `remember`ed keyed on the viewer id in `HomeScreen`, which reproduces the reset semantics + * the `remember(settings.userId) { … }` blocks it replaces already had — a viewer switch + * opens a fresh launcher. + */ +@Stable +internal class HomeScreenState { + // Navigation — the rail selection and focus target. Persisted across process death by + // [Saver], like the `rememberSaveable` fields this replaces. + var selectedDestination by mutableStateOf(BrowseDestination.HOME) + var navigationExpanded by mutableStateOf(false) + var railFocusDestination by mutableStateOf(BrowseDestination.HOME) + var returnRowId by mutableStateOf(null) + var returnItemId by mutableStateOf(null) + var returnRowKind by mutableStateOf(null) + + var requestsRailExpanded by mutableStateOf(false) + // Initial focus belongs to each destination, not the launcher as a whole. + var initialFocusDestination by mutableStateOf(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(null) + var removingProfileId by mutableStateOf(null) + var restoreRailAfterSettings by mutableStateOf(false) + + // Detail page (opened from any pane; walks its own Back trail). + var detailsItem by mutableStateOf(null) + var detailsTrail by mutableStateOf>(emptyList()) + var restoreDetailPosition by mutableStateOf(false) + var detailsResumeTarget by mutableStateOf(null) + var detailsAiringNotice by mutableStateOf(null) + var detailsFromSearch by mutableStateOf(false) + + // Long-press quick-actions menu. + var quickMenuItem by mutableStateOf(null) + var quickMenuTrailerAvailable by mutableStateOf(false) + var quickMenuRowId by mutableStateOf(null) + + var focusedHomeRowId by mutableStateOf(null) + var sectionHeroRows by mutableStateOf>>(emptyMap()) + + // My Shows. + var myShows by mutableStateOf>(emptyList()) + var myShowsLoading by mutableStateOf(true) + var myShowsError by mutableStateOf(null) + var myShowsMutationBusy by mutableStateOf(false) + var selectedMyShow by mutableStateOf(null) + var myShowReturnItemId by mutableStateOf(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(null) + var notificationsMutationBusy by mutableStateOf(false) + var showNotifications by mutableStateOf(false) + var showRequests by mutableStateOf(false) + + // Viewer management. + var viewers by mutableStateOf>(emptyList()) + var showViewerPicker by mutableStateOf(false) + var showViewerManage by mutableStateOf(false) + var viewerNameTarget by mutableStateOf(null) + var viewerName by mutableStateOf("") + var viewerNameFailure by mutableStateOf(null) + var viewerSaving by mutableStateOf(false) + var viewerBusyId by mutableStateOf(null) + var pinTarget by mutableStateOf(null) + var pinSaving by mutableStateOf(false) + var pinFailure by mutableStateOf(null) + + // Playback launch gate + loading overlay. + var launchingItem by mutableStateOf(null) + var resolvingItem by mutableStateOf(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( + 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 + } + }, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index a79779d..6646da6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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, + 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(null) } + var lastGenreFocus by remember(itemType) { mutableStateOf(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() } val focusedCardIndexes = remember(itemType) { mutableMapOf() } 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, + /** 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 = 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), ) } }, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreNavigation.kt new file mode 100644 index 0000000..1071bf6 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreNavigation.kt @@ -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, + /** Genre ids in rail order; never empty (the "All" entry is always present). */ + val genreIds: List, + /** 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, + genreIds: List, +): 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 + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 313d174..72f5e7e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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 { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowserFocusTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowserFocusTest.kt new file mode 100644 index 0000000..0faf666 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowserFocusTest.kt @@ -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 = {}, + ) + } + } + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreNavigationTest.kt new file mode 100644 index 0000000..11806b5 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreNavigationTest.kt @@ -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 = 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) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt index 4daf9c1..448ba92 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt @@ -122,6 +122,7 @@ class GenreRailScreenshotTest { navigationFocusRequester = FocusRequester(), activeFocusRequester = FocusRequester(), onCategoryFocused = {}, + onCategorySelected = {}, onEnterContent = { false }, focusForCapture = focusForCapture, ) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/ServicesRailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/ServicesRailScreenshotTest.kt index 33f6b82..c0e5289 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/genre/ServicesRailScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/ServicesRailScreenshotTest.kt @@ -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 = { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt index 7d77ed9..5dca4e7 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt @@ -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()