From 594159c0ae6f073e05768aa6e984010556b979de Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Tue, 11 Aug 2026 13:46:53 +1200 Subject: [PATCH] Release 0.2.49 --- CHANGELOG.md | 3 + app/build.gradle.kts | 2 +- .../memby/ui/EpisodeDetailsOverlay.kt | 14 +- .../ponzischeme89/memby/ui/HomeComponents.kt | 12 +- .../ponzischeme89/memby/ui/MainActivity.kt | 168 ++++++++++++++---- .../memby/ui/MediaDetailsOverlay.kt | 9 +- .../memby/ui/MyShowsNotifications.kt | 58 +++++- .../memby/ui/SeriesDetailsOverlay.kt | 9 +- .../memby/ui/alerts/AlertsPage.kt | 9 +- .../memby/ui/calendar/CalendarAgenda.kt | 40 ++++- .../memby/ui/settings/SettingsSheet.kt | 22 ++- .../memby/ui/UserSwitcherNavigationTest.kt | 8 + .../memby/ui/calendar/CalendarFocusTest.kt | 30 ++++ 13 files changed, 325 insertions(+), 59 deletions(-) create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarFocusTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2527d29..64cfc09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.2.49 — 2026-08-11 +- Fixed: D-pad focus now stays visible and predictable across My Shows, Settings, profile management, the TV calendar, detail pages and changing home rows. + ## 0.2.48 — 2026-08-11 - Improved: The TV calendar is now a modern weekly guide with artwork, title logos and clear season-premiere and finale labels. - Added: Home-screen metadata now shows the sound profile, including mono, stereo, 5.1 and 7.1. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2bdbec8..140e3b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.48" +val defaultVersionName = "0.2.49" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt index 4f793c7..58ab1af 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -88,6 +88,7 @@ fun EpisodeDetailsOverlay( onTogglePlayed: (BaseItem, Boolean) -> Unit, onClose: () -> Unit, onOpenItem: (BaseItem) -> Unit = {}, + restorePosition: Boolean = false, modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository @@ -125,6 +126,7 @@ fun EpisodeDetailsOverlay( onOpenItem = onOpenItem, ratings = ratings, showRatingsStrip = settings.showRatingsStrip, + restorePosition = restorePosition, modifier = modifier, ) } @@ -142,14 +144,18 @@ internal fun EpisodeDetailContent( ratings: List = emptyList(), showRatingsStrip: Boolean = true, onOpenItem: (BaseItem) -> Unit = {}, + restorePosition: Boolean = false, ) { + val remembered = remember(item.id) { detailPositions.get(item.id) } val currentSeason = item.parentIndexNumber val markers = remember(episodes, currentSeason) { seasonMarkers(episodes.orEmpty(), currentSeason) } // The scroller opens on the season this episode is in and stays wherever the viewer // moves it. Selecting a season browses; it never changes which episode the page is about. - var selectedSeason by remember(item.id) { mutableStateOf(currentSeason) } + var selectedSeason by remember(item.id, restorePosition) { + mutableStateOf(if (restorePosition) remembered.season ?: currentSeason else currentSeason) + } LaunchedEffect(markers) { if (markers.none { it.season == selectedSeason }) { selectedSeason = currentSeason ?: markers.firstOrNull()?.season @@ -167,7 +173,9 @@ internal fun EpisodeDetailContent( val firstEpisode = remember(item.id) { FocusRequester() } val emptyPane = remember(item.id) { FocusRequester() } val seasonRequesters = remember(markers) { markers.associate { it.season to FocusRequester() } } - val episodeListState = rememberLazyListState() + val episodeListState = rememberLazyListState( + if (restorePosition) remembered.episodeIndex else 0, + ) val seasonListState = rememberLazyListState() // Every focus target has to be attached on the frame the press lands. While the episode @@ -195,7 +203,7 @@ internal fun EpisodeDetailContent( RestoreDetailFocus( itemId = item.id, - zone = DetailZone.PLAY, + zone = if (restorePosition) remembered.zone else DetailZone.PLAY, play = play, tabStrip = stripEntry, related = firstEpisode, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 6b10085..3810169 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -236,6 +236,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when { @Composable fun TvNavigationRail( selected: BrowseDestination, + focusDestination: BrowseDestination = selected, expanded: Boolean, navigationFocusRequester: FocusRequester, onRailFocusChanged: (Boolean) -> Unit, @@ -361,7 +362,11 @@ fun TvNavigationRail( destination = destination, selected = destination == selected, expanded = expanded, - modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier, + modifier = if (destination == focusDestination) { + Modifier.focusRequester(navigationFocusRequester) + } else { + Modifier + }, onFocused = {}, onClick = { onDestinationSelected(destination) }, // My Alerts is one level in, behind the user picker. Without a mark out @@ -407,10 +412,12 @@ fun UserSwitcherOverlay( } val profileListState = remember(profileIds) { LazyListState() } val focusScope = rememberCoroutineScope() + var profileFocusJob by remember(profileIds) { mutableStateOf(null) } var focusedIndex by remember(profileIds) { mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId)) } LaunchedEffect(profileIds, activeProfileId) { + profileFocusJob?.cancel() focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId) if (profiles.isNotEmpty()) { profileListState.scrollToItem(focusedIndex) @@ -452,8 +459,9 @@ fun UserSwitcherOverlay( actionCount = UserSwitcherActionCount, ) focusedIndex = next + profileFocusJob?.cancel() if (next < profiles.size) { - focusScope.launch { + profileFocusJob = focusScope.launch { profileListState.scrollToItem(next) delay(16) runCatching { focusRequesters[next].requestFocus() } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 926b614..f3244bb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -1010,7 +1010,12 @@ private fun SetupScreen( kotlinx.coroutines.delay(100L) runCatching { usernameFocus.requestFocus() } } - if (onCancel != null) BackHandler(onBack = onCancel) + if (onCancel != null) { + // The visible Back button is disabled while authentication is running. Hardware + // Back must obey the same contract and, importantly, must not hide a form whose + // coroutine can still complete and switch the active viewer afterwards. + BackHandler { if (!connecting) onCancel() } + } val submit: () -> Unit = { if (username.isBlank()) { @@ -1132,6 +1137,7 @@ private fun ProfileChooser( val addFocus = remember { FocusRequester() } val backFocus = remember { FocusRequester() } var pendingRemoval by remember { mutableStateOf(null) } + var removalReturnIndex by remember { mutableStateOf(null) } val orderedProfiles = remember(profiles, currentProfileId) { profiles.sortedByDescending { it.id == currentProfileId } } @@ -1140,7 +1146,12 @@ private fun ProfileChooser( val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } } LaunchedEffect(profileIds) { kotlinx.coroutines.delay(16L) - if (orderedProfiles.isEmpty()) addFocus.requestFocus() else tileFocus.first().requestFocus() + val targetIndex = removalReturnIndex?.let { + profileFocusIndexAfterRemoval(it, orderedProfiles.size) + } ?: if (orderedProfiles.isEmpty()) null else 0 + removalReturnIndex = null + if (targetIndex == null) addFocus.requestFocus() + else tileFocus.getOrNull(targetIndex)?.requestFocus() ?: addFocus.requestFocus() } Box( modifier = Modifier @@ -1254,6 +1265,7 @@ private fun ProfileChooser( profile = profile, onCancel = { pendingRemoval = null }, onConfirm = { + removalReturnIndex = profileIds.indexOf(profile.id).takeIf { it >= 0 } pendingRemoval = null onRemove(profile) }, @@ -1262,6 +1274,9 @@ private fun ProfileChooser( } } +internal fun profileFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? = + if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1) + @Composable @OptIn(ExperimentalComposeUiApi::class) private fun RecommendationOnboardingScreen( @@ -1868,26 +1883,13 @@ private fun HomeScreen( var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var genreBrowseItemType by remember { mutableStateOf(null) } var genreBrowseInitialCategoryId by remember { mutableStateOf(null) } - // The rail entry disappears the moment the operator turns the feature off; a television - // that happened to be standing on the page has to be moved off it too, or it is left on - // a destination nothing can navigate back to. - LaunchedEffect(tvCalendarEnabled) { - if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) { - selectedDestination = BrowseDestination.HOME - } - } - LaunchedEffect(genreBrowserEnabled) { - if (!genreBrowserEnabled) { - genreBrowseItemType = null - genreBrowseInitialCategoryId = null - } - } 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) } // 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. @@ -1897,6 +1899,7 @@ private fun HomeScreen( var focusedHomeRowId by remember { mutableStateOf(null) } var myShows by remember(settings.userId) { mutableStateOf>(emptyList()) } 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 showNotifications by remember { mutableStateOf(false) } @@ -1931,14 +1934,37 @@ private fun HomeScreen( val navigationFocusRequesters = remember { BrowseDestination.entries.associateWith { FocusRequester() } } - val navigationFocusRequester = navigationFocusRequesters.getValue(selectedDestination) + var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) } + val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination) val contentFocusRequester = remember { FocusRequester() } val cardReturnFocusRequester = remember { FocusRequester() } + val myShowReturnFocusRequester = remember { FocusRequester() } // Down out of the hero, stated rather than left to Compose's spatial search. The // featured card is wide, so its centre sits nearer the second card of the shelf below // than the first, and the search obligingly skipped past the thing somebody had just // been reading about in Continue Watching. val heroRowEntryFocusRequester = remember { FocusRequester() } + // Feature flags can remove the node that currently owns focus. Move the route first, + // wait for its replacement to compose, then establish an explicit remote target. + LaunchedEffect(tvCalendarEnabled) { + if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) { + selectedDestination = BrowseDestination.HOME + railFocusDestination = BrowseDestination.HOME + kotlinx.coroutines.delay(16L) + requestFirstAvailableFocus( + contentFocusRequester, + navigationFocusRequesters.getValue(BrowseDestination.HOME), + ) + } + } + LaunchedEffect(genreBrowserEnabled) { + if (!genreBrowserEnabled && genreBrowseItemType != null) { + genreBrowseItemType = null + genreBrowseInitialCategoryId = null + kotlinx.coroutines.delay(16L) + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) + } + } var initialFocusRequested by remember { mutableStateOf(false) } // 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. @@ -1961,7 +1987,13 @@ private fun HomeScreen( runCatching { repo.getNotifications() }.onSuccess { notificationState = it } kotlinx.coroutines.delay(32L) if (returnRowId != null && returnItemId != null) { - runCatching { cardReturnFocusRequester.requestFocus() } + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } else { + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) } } } @@ -2216,6 +2248,7 @@ private fun HomeScreen( Row(Modifier.fillMaxSize()) { TvNavigationRail( selected = selectedDestination, + focusDestination = railFocusDestination, expanded = navigationExpanded, navigationFocusRequester = navigationFocusRequester, onRailFocusChanged = { @@ -2230,12 +2263,14 @@ private fun HomeScreen( genreBrowseItemType = null when (destination) { BrowseDestination.SETTINGS -> { + railFocusDestination = BrowseDestination.SETTINGS restoreRailAfterSettings = true navigationExpanded = false userSwitcherVisible = false showSettings = true } BrowseDestination.PROFILES -> { + railFocusDestination = BrowseDestination.PROFILES userSwitcherVisible = true navigationExpanded = false // The rail stays live beside Settings, so a destination @@ -2250,6 +2285,7 @@ private fun HomeScreen( showSettings = false restoreRailAfterSettings = false selectedDestination = destination + railFocusDestination = destination val savedFocus = destinationFocus[destination] savedFocus?.let { (rowId, itemId) -> returnRowId = rowId @@ -2270,9 +2306,13 @@ private fun HomeScreen( // before transferring focus out of the rail. kotlinx.coroutines.delay(16L) if (savedFocus != null) { - runCatching { cardReturnFocusRequester.requestFocus() } + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) } else { - runCatching { contentFocusRequester.requestFocus() } + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) } } } @@ -2635,12 +2675,15 @@ private fun HomeScreen( contentFocusRequester = contentFocusRequester.takeUnless { genreBrowserEnabled }, + returnFocusItemId = myShowReturnItemId, + returnFocusRequester = myShowReturnFocusRequester, onShowSelected = { homeViewModel.trackJourney( category = "content", action = "open", screen = "shows", feature = "my_shows", source = "my_shows", target = "my_show_details", itemId = it.itemId, ) + myShowReturnItemId = it.itemId selectedMyShow = it }, onContentFocused = { navigationExpanded = false }, @@ -2938,7 +2981,11 @@ private fun HomeScreen( runCatching { navigationFocusRequester.requestFocus() } restoreRailAfterSettings = false } else if (returnItemId != null) { - runCatching { cardReturnFocusRequester.requestFocus() } + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) } else { runCatching { contentFocusRequester.requestFocus() } } @@ -3031,16 +3078,23 @@ private fun HomeScreen( val previous = detailsTrail.lastOrNull() if (previous != null) { detailsTrail = detailsTrail.dropLast(1) + restoreDetailPosition = true detailsItem = previous } else { + restoreDetailPosition = false detailsItem = null detailsAiringNotice = null - runCatching { cardReturnFocusRequester.requestFocus() } + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) } } FocusedDetailsOverlay( homeViewModel = homeViewModel, selected = selected, + restorePosition = restoreDetailPosition, airingNotice = detailsAiringNotice, onOpenItem = { related -> homeViewModel.trackJourney( @@ -3049,6 +3103,7 @@ private fun HomeScreen( itemId = related.id, 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 @@ -3064,6 +3119,7 @@ private fun HomeScreen( onPlay = { detailsItem = null detailsTrail = emptyList() + restoreDetailPosition = false detailsAiringNotice = null playItem(it) }, @@ -3123,13 +3179,31 @@ private fun HomeScreen( onClose = { detailsItem = null detailsTrail = emptyList() + restoreDetailPosition = false detailsAiringNotice = null - runCatching { cardReturnFocusRequester.requestFocus() } + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) }, ) } selectedMyShow?.let { show -> - BackHandler { selectedMyShow = null } + val closeMyShow: (Boolean) -> Unit = { removed -> + selectedMyShow = null + scope.launch { + kotlinx.coroutines.delay(16L) + 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, @@ -3139,12 +3213,12 @@ private fun HomeScreen( scope.launch { runCatching { repo.removeMyShow(show.itemId) }.onSuccess { myShows = myShows.filterNot { it.itemId == show.itemId } - selectedMyShow = null + closeMyShow(true) } removingMyShow = false } }, - onClose = { selectedMyShow = null }, + onClose = { closeMyShow(false) }, ) } if (showNotifications) { @@ -3225,17 +3299,25 @@ private fun HomeScreen( ) } quickMenuItem?.let { selected -> - val closeQuickActions: () -> Unit = { + 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. kotlinx.coroutines.delay(16L) - runCatching { cardReturnFocusRequester.requestFocus() } + if (originWillDisappear) { + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) + } else { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } } } - BackHandler(onBack = closeQuickActions) + BackHandler { closeQuickActions(false) } FocusedQuickActionsOverlay( homeViewModel = homeViewModel, selected = selected, @@ -3262,9 +3344,11 @@ private fun HomeScreen( } else { contentFocusRequester } - if (runCatching { removalFocusRequester.requestFocus() }.isFailure) { - runCatching { navigationFocusRequester.requestFocus() } - } + requestFirstAvailableFocus( + removalFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) } } } else { @@ -3283,7 +3367,7 @@ private fun HomeScreen( settings.homeHiddenRows.decodeRowIds().toSet(), ) } - closeQuickActions() + closeQuickActions(false) } }, onHideRow = quickMenuRowId?.let { rowId -> @@ -3296,8 +3380,8 @@ private fun HomeScreen( settings.homePinnedRows.decodeRowIds().toSet(), hidden, ) + closeQuickActions(true) } - closeQuickActions() } }, onMoveRow = quickMenuRowId?.let { rowId -> @@ -3316,10 +3400,10 @@ private fun HomeScreen( ) } } - closeQuickActions() + closeQuickActions(false) } }, - onClose = closeQuickActions, + onClose = { closeQuickActions(false) }, ) } resolvingItem?.let { item -> @@ -3645,6 +3729,7 @@ private fun FocusedHomeMetadata( private fun FocusedDetailsOverlay( homeViewModel: HomeViewModel, selected: BaseItem, + restorePosition: Boolean, onPlay: (BaseItem) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit, isMyShow: Boolean, @@ -3665,6 +3750,7 @@ private fun FocusedDetailsOverlay( onToggleMyShow = onToggleMyShow, onClose = onClose, onOpenItem = onOpenItem, + restorePosition = restorePosition, airingNotice = airingNotice, ) } else if (item.isEpisode) { @@ -3678,6 +3764,7 @@ private fun FocusedDetailsOverlay( onTogglePlayed = onTogglePlayed, onClose = onClose, onOpenItem = onOpenItem, + restorePosition = restorePosition, ) } else { MediaDetailsOverlay( @@ -3687,6 +3774,7 @@ private fun FocusedDetailsOverlay( onTogglePlayed = onTogglePlayed, onClose = onClose, onOpenItem = onOpenItem, + restorePosition = restorePosition, ) } } @@ -4294,6 +4382,14 @@ internal fun TvTextField( } } +/** Requests the first focus target that is both attached and willing to accept focus. */ +private fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean { + requesters.forEach { requester -> + if (runCatching { requester.requestFocus() }.isSuccess) return true + } + return false +} + private fun openScreensaverSettings(context: android.content.Context) { val candidates = listOf( AndroidSettings.ACTION_DREAM_SETTINGS, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt index b507da5..f7d014f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -50,6 +50,7 @@ fun MediaDetailsOverlay( onTogglePlayed: (BaseItem, Boolean) -> Unit, onClose: () -> Unit, onOpenItem: (BaseItem) -> Unit = {}, + restorePosition: Boolean = false, modifier: Modifier = Modifier, ) { val settings by ServiceLocator.repository.settingsFlow @@ -77,6 +78,7 @@ fun MediaDetailsOverlay( ratings = ratings, showRatingsStrip = settings.showRatingsStrip, hideWatchedMovies = settings.hideWatchedMovies, + restorePosition = restorePosition, modifier = modifier, ) } @@ -98,6 +100,7 @@ internal fun MediaDetailContent( showRatingsStrip: Boolean = true, hideWatchedMovies: Boolean = false, onOpenItem: (BaseItem) -> Unit = {}, + restorePosition: Boolean = false, ) { val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) } val credits = remember(item.id, item.people, item.genres) { creditRows(item) } @@ -116,7 +119,9 @@ internal fun MediaDetailContent( val remembered = remember(item.id) { detailPositions.get(item.id) } // Every newly opened title starts as a complete hero frame. Remembering a content // tab also restored its focus and caused the page to reopen below the artwork. - var tabKey by remember(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) } + var tabKey by remember(item.id, restorePosition) { + mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key) + } val selectedTab = detailTab(tabKey, tabs) val play = remember(item.id) { FocusRequester() } @@ -142,7 +147,7 @@ internal fun MediaDetailContent( ) RestoreDetailFocus( itemId = item.id, - zone = DetailZone.PLAY, + zone = if (restorePosition) remembered.zone else DetailZone.PLAY, play = play, tabStrip = tabStrip, related = firstRelated, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index 078a04c..b79110a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -1,3 +1,5 @@ +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + package com.ponzischeme89.memby.ui import androidx.compose.foundation.background @@ -15,10 +17,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -56,6 +61,8 @@ internal fun MyShowsStrip( density: String, navigationFocusRequester: FocusRequester, contentFocusRequester: FocusRequester?, + returnFocusItemId: String? = null, + returnFocusRequester: FocusRequester? = null, onShowSelected: (MyShow) -> Unit, onContentFocused: () -> Unit, ) { @@ -121,6 +128,13 @@ internal fun MyShowsStrip( Modifier }, ) + .then( + if (show.itemId == returnFocusItemId && returnFocusRequester != null) { + Modifier.focusRequester(returnFocusRequester) + } else { + Modifier + }, + ) .focusProperties { left = navigationFocusRequester } .onFocusChanged { if (it.hasFocus) onContentFocused() }, ) @@ -236,6 +250,20 @@ internal fun MyShowDetailsOverlay( onRemove: () -> Unit, onClose: () -> Unit, ) { + val removeFocus = remember(show.itemId) { FocusRequester() } + val closeFocus = remember(show.itemId) { FocusRequester() } + LaunchedEffect(show.itemId) { + kotlinx.coroutines.delay(16L) + if (runCatching { removeFocus.requestFocus() }.isFailure) { + runCatching { closeFocus.requestFocus() } + } + } + LaunchedEffect(removing) { + if (removing) { + kotlinx.coroutines.delay(16L) + runCatching { closeFocus.requestFocus() } + } + } Box( Modifier.fillMaxSize().zIndex(8f).background(MembySurface.copy(alpha = 0.96f)), contentAlignment = Alignment.Center, @@ -264,11 +292,35 @@ internal fun MyShowDetailsOverlay( StatusLine("Sonarr", show.sonarrStatus) StatusLine("Next episode", formatMyShowDate(show.nextEpisode)) StatusLine("Series status", show.lifecycle) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = onRemove, enabled = !removing) { + Row( + modifier = Modifier.focusGroup(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = onRemove, + enabled = !removing, + modifier = Modifier + .focusRequester(removeFocus) + .focusProperties { + left = FocusRequester.Cancel + right = closeFocus + up = FocusRequester.Cancel + down = FocusRequester.Cancel + }, + ) { Text(if (removing) "Removing…" else "Remove from My Shows") } - Button(onClick = onClose) { Text("Close") } + Button( + onClick = onClose, + modifier = Modifier + .focusRequester(closeFocus) + .focusProperties { + left = if (removing) FocusRequester.Cancel else removeFocus + right = FocusRequester.Cancel + up = FocusRequester.Cancel + down = FocusRequester.Cancel + }, + ) { Text("Close") } } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index a81d8af..9a2d8ca 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -108,6 +108,7 @@ fun SeriesDetailsOverlay( onToggleMyShow: (BaseItem, Boolean) -> Unit, onClose: () -> Unit, onOpenItem: (BaseItem) -> Unit = {}, + restorePosition: Boolean = false, airingNotice: AiringNotice? = null, modifier: Modifier = Modifier, ) { @@ -153,6 +154,7 @@ fun SeriesDetailsOverlay( showRatingsStrip = settings.showRatingsStrip, hideWatchedMovies = settings.hideWatchedMovies, onOpenItem = onOpenItem, + restorePosition = restorePosition, airingNotice = airingNotice, modifier = modifier, ) @@ -175,6 +177,7 @@ internal fun SeriesDetailContent( showRatingsStrip: Boolean = true, hideWatchedMovies: Boolean = false, onOpenItem: (BaseItem) -> Unit = {}, + restorePosition: Boolean = false, airingNotice: AiringNotice? = null, ) { val remembered = remember(item.id) { detailPositions.get(item.id) } @@ -217,7 +220,9 @@ internal fun SeriesDetailContent( val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty() // A series opens on its cinematic hero and Overview just like a movie. Season and // rail positions are still remembered once the viewer enters Episodes. - var tabKey by rememberSaveable(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) } + var tabKey by rememberSaveable(item.id, restorePosition) { + mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key) + } val selectedTab = detailTab(tabKey, tabs) val play = remember(item.id) { FocusRequester() } @@ -265,7 +270,7 @@ internal fun SeriesDetailContent( ) RestoreDetailFocus( itemId = item.id, - zone = DetailZone.PLAY, + zone = if (restorePosition) remembered.zone else DetailZone.PLAY, play = play, tabStrip = tabStrip, related = firstRelated, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index d8a45e5..0076f74 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -101,7 +101,7 @@ fun MyAlertsPage( val listState = rememberLazyListState() var pendingFocusIndex by remember { mutableStateOf(null) } val hasAlerts = notifications.isNotEmpty() - LaunchedEffect(hasAlerts) { + LaunchedEffect(Unit) { // One frame for the list to place its first row; an empty page has nothing below // the actions to land on, so the chips take the remote instead. delay(16) @@ -110,13 +110,18 @@ fun MyAlertsPage( } } LaunchedEffect(notificationIds) { + if (notifications.isEmpty()) { + pendingFocusIndex = null + delay(16) + runCatching { actionsFocusRequester.requestFocus() } + return@LaunchedEffect + } val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect // Spent on this list change however it turns out. Left set, a request that could // not be honoured — an empty list, a dismissal the server refused and put back — // would be honoured against the *next* change instead, which is commonly an alert // arriving on its own: focus would jump for a press made minutes ago. pendingFocusIndex = null - if (notifications.isEmpty()) return@LaunchedEffect val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size) ?: return@LaunchedEffect runCatching { listState.scrollToItem(targetIndex) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt index 54eaab9..dfec3fa 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt @@ -98,6 +98,10 @@ internal fun CalendarContent( val selectedDay = week?.cells?.firstOrNull { it.date == state.selectedDate } ?: week?.cells?.firstOrNull { !it.isPad } val selectedItems = remember(selectedDay) { selectedDay?.items.orEmpty().distinctItems() } + val monthFocusAnchor = calendarMonthFocusAnchor( + previousMonth = state.calendar.previous, + nextMonth = state.calendar.next, + ) val monthFocusRequester = remember { FocusRequester() } val weekFocusRequester = remember { FocusRequester() } @@ -162,6 +166,7 @@ internal fun CalendarContent( weekCount = weeks.size, focusRequester = weekFocusRequester, monthFocusRequester = monthFocusRequester, + hasMonthControl = monthFocusAnchor != CalendarMonthFocusAnchor.NONE, onPrevious = { weeks.getOrNull(weekIndex - 1)?.let { onSelectDate(calendarAgendaWeekDate(it)) @@ -221,6 +226,7 @@ private fun AgendaHeader( weekFocusRequester: FocusRequester, onShowMonth: (String) -> Unit, ) { + val monthFocusAnchor = calendarMonthFocusAnchor(previousMonth, nextMonth) Row(verticalAlignment = Alignment.CenterVertically) { Box( Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape), @@ -244,7 +250,15 @@ private fun AgendaHeader( description = "Previous month", enabled = previousMonth.isNotEmpty(), onClick = { onShowMonth(previousMonth) }, - modifier = Modifier.focusProperties { down = weekFocusRequester }, + modifier = Modifier + .then( + if (monthFocusAnchor == CalendarMonthFocusAnchor.PREVIOUS) { + Modifier.focusRequester(monthFocusRequester) + } else { + Modifier + }, + ) + .focusProperties { down = weekFocusRequester }, ) Spacer(Modifier.width(8.dp)) AgendaIconButton( @@ -253,12 +267,29 @@ private fun AgendaHeader( enabled = nextMonth.isNotEmpty(), onClick = { onShowMonth(nextMonth) }, modifier = Modifier - .focusRequester(monthFocusRequester) + .then( + if (monthFocusAnchor == CalendarMonthFocusAnchor.NEXT) { + Modifier.focusRequester(monthFocusRequester) + } else { + Modifier + }, + ) .focusProperties { down = weekFocusRequester }, ) } } +internal enum class CalendarMonthFocusAnchor { PREVIOUS, NEXT, NONE } + +internal fun calendarMonthFocusAnchor( + previousMonth: String, + nextMonth: String, +): CalendarMonthFocusAnchor = when { + nextMonth.isNotEmpty() -> CalendarMonthFocusAnchor.NEXT + previousMonth.isNotEmpty() -> CalendarMonthFocusAnchor.PREVIOUS + else -> CalendarMonthFocusAnchor.NONE +} + @Composable private fun WeekSwitcher( week: CalendarAgendaWeek, @@ -266,6 +297,7 @@ private fun WeekSwitcher( weekCount: Int, focusRequester: FocusRequester, monthFocusRequester: FocusRequester, + hasMonthControl: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, ) { @@ -283,7 +315,9 @@ private fun WeekSwitcher( contentDescription = "Week ${week.label}", modifier = Modifier .focusRequester(focusRequester) - .focusProperties { up = monthFocusRequester } + .focusProperties { + up = if (hasMonthControl) monthFocusRequester else FocusRequester.Default + } .background(MembySurfaceRaised.copy(alpha = 0.62f), RoundedCornerShape(MembyChipCorner)), ) { focused -> Row( 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 6419372..f980273 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 @@ -1223,11 +1223,19 @@ private fun DeviceRenameDialog( val cancelFocus = remember { FocusRequester() } val saveFocus = remember { FocusRequester() } val valid = name.trim().isNotEmpty() && name.length <= 80 - BackHandler(enabled = !busy, onBack = onCancel) + // Keep this handler above Settings even while the request is in flight. Disabling it + // lets Back close the whole Settings destination behind the still-running rename. + BackHandler { if (!busy) onCancel() } LaunchedEffect(device.deviceId) { delay(80L) runCatching { fieldFocus.requestFocus() } } + LaunchedEffect(busy) { + if (busy) { + delay(16L) + runCatching { cancelFocus.requestFocus() } + } + } Box( modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)), contentAlignment = Alignment.Center, @@ -1273,10 +1281,14 @@ private fun DeviceRenameDialog( horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), ) { Button( - onClick = onCancel, - enabled = !busy, - modifier = Modifier.focusRequester(cancelFocus).focusProperties { right = saveFocus }, - ) { Text("Cancel") } + onClick = { if (!busy) onCancel() }, + // This remains the modal's stable focus owner while Save and the field + // are disabled. Select is deliberately a no-op until the request ends. + enabled = true, + modifier = Modifier.focusRequester(cancelFocus).focusProperties { + right = if (busy) FocusRequester.Cancel else saveFocus + }, + ) { Text(if (busy) "Please wait…" else "Cancel") } Button( onClick = { onSave(name) }, enabled = valid && !busy, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt index c9c3b3e..5543c36 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt @@ -1,9 +1,17 @@ package com.ponzischeme89.memby.ui import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class UserSwitcherNavigationTest { + @Test + fun `removed profile hands focus to its stable neighbour`() { + assertEquals(1, profileFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3)) + assertEquals(2, profileFocusIndexAfterRemoval(removedIndex = 3, remainingCount = 3)) + assertNull(profileFocusIndexAfterRemoval(removedIndex = 0, remainingCount = 0)) + } + @Test fun `active profile receives initial focus`() { assertEquals( diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarFocusTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarFocusTest.kt new file mode 100644 index 0000000..e3d0de6 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarFocusTest.kt @@ -0,0 +1,30 @@ +package com.ponzischeme89.memby.ui.calendar + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CalendarFocusTest { + @Test + fun `next month is the normal focus anchor`() { + assertEquals( + CalendarMonthFocusAnchor.NEXT, + calendarMonthFocusAnchor(previousMonth = "2026-07", nextMonth = "2026-09"), + ) + } + + @Test + fun `latest month anchors focus to previous month`() { + assertEquals( + CalendarMonthFocusAnchor.PREVIOUS, + calendarMonthFocusAnchor(previousMonth = "2026-12", nextMonth = ""), + ) + } + + @Test + fun `a calendar without travel controls uses ordinary focus search`() { + assertEquals( + CalendarMonthFocusAnchor.NONE, + calendarMonthFocusAnchor(previousMonth = "", nextMonth = ""), + ) + } +}