Release 0.2.49

This commit is contained in:
ponzischeme89
2026-08-11 13:46:53 +12:00
parent f46d6596c5
commit 594159c0ae
13 changed files with 325 additions and 59 deletions
@@ -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<MediaRating> = 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,
@@ -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<kotlinx.coroutines.Job?>(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() }
@@ -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<EmbyProfile?>(null) }
var removalReturnIndex by remember { mutableStateOf<Int?>(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<String?>(null) }
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(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<BaseItem?>(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<List<BaseItem>>(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<String?>(null) }
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
var myShowReturnItemId by remember { mutableStateOf<String?>(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,
@@ -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,
@@ -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") }
}
}
}
@@ -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,
@@ -101,7 +101,7 @@ fun MyAlertsPage(
val listState = rememberLazyListState()
var pendingFocusIndex by remember { mutableStateOf<Int?>(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) }
@@ -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(
@@ -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,
@@ -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(
@@ -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 = ""),
)
}
}