Return focus to the tabs if a grid has no results (#676)

## Description
Previously, if you clicked on a tab and the resulting grid had no
results, the nav drawer would open and focus which is not good.

This PR fixes that and instead returns focus back to the tab row.

### Related issues
Fixes #543
This commit is contained in:
Ray 2026-01-11 21:36:24 -05:00 committed by GitHub
parent 197265e7f5
commit d5192abf62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 71 additions and 17 deletions

View file

@ -86,6 +86,7 @@ import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -525,6 +526,7 @@ fun CollectionFolderGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
) = CollectionFolderGrid( ) = CollectionFolderGrid(
preferences, preferences,
itemId.toServerString(), itemId.toServerString(),
@ -541,6 +543,7 @@ fun CollectionFolderGrid(
positionCallback = positionCallback, positionCallback = positionCallback,
useSeriesForPrimary = useSeriesForPrimary, useSeriesForPrimary = useSeriesForPrimary,
filterOptions = filterOptions, filterOptions = filterOptions,
focusRequesterOnEmpty = focusRequesterOnEmpty,
) )
@Composable @Composable
@ -560,6 +563,7 @@ fun CollectionFolderGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: CollectionFolderViewModel = viewModel: CollectionFolderViewModel =
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
@ -615,6 +619,7 @@ fun CollectionFolderGrid(
pager = pager, pager = pager,
sortAndDirection = sortAndDirection!!, sortAndDirection = sortAndDirection!!,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = { position, item -> onLongClickItem = { position, item ->
moreDialog.makePresent(PositionItem(position, item)) moreDialog.makePresent(PositionItem(position, item))
@ -759,6 +764,7 @@ fun CollectionFolderGridContent(
currentFilter: GetItemsFilter = GetItemsFilter(), currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(), filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {}, onFilterChange: (GetItemsFilter) -> Unit = {},
focusRequesterOnEmpty: FocusRequester? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -767,7 +773,11 @@ fun CollectionFolderGridContent(
var viewOptions by remember { mutableStateOf(viewOptions) } var viewOptions by remember { mutableStateOf(viewOptions) }
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
RequestOrRestoreFocus(gridFocusRequester) if (pager.isNotEmpty()) {
RequestOrRestoreFocus(gridFocusRequester)
} else {
focusRequesterOnEmpty?.tryRequestFocus()
}
var backdropImageUrl by remember { mutableStateOf<String?>(null) } var backdropImageUrl by remember { mutableStateOf<String?>(null) }
var position by rememberInt(initialPosition) var position by rememberInt(initialPosition)

View file

@ -29,6 +29,7 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@ -39,13 +40,16 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import timber.log.Timber
@Composable @Composable
fun TabRow( fun TabRow(
selectedTabIndex: Int, selectedTabIndex: Int,
tabs: List<String>, tabs: List<String>,
focusRequesters: List<FocusRequester>,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -55,7 +59,6 @@ fun TabRow(
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
} }
} }
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
var rowHasFocus by remember { mutableStateOf(false) } var rowHasFocus by remember { mutableStateOf(false) }
LazyRow( LazyRow(
state = state, state = state,
@ -68,6 +71,7 @@ fun TabRow(
onEnter = { onEnter = {
// If entering from left or right, use last or first tab // If entering from left or right, use last or first tab
// Otherwise use the selected tab // Otherwise use the selected tab
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex")
val focusRequester = val focusRequester =
if (requestedFocusDirection == FocusDirection.Left) { if (requestedFocusDirection == FocusDirection.Left) {
focusRequesters.lastOrNull() focusRequesters.lastOrNull()
@ -183,6 +187,7 @@ private fun TabRowPreview() {
TabRow( TabRow(
selectedTabIndex = 1, selectedTabIndex = 1,
tabs = listOf("Tab 1", "Tab 2", "Tab 3"), tabs = listOf("Tab 1", "Tab 2", "Tab 3"),
focusRequesters = listOf(),
onClick = {}, onClick = {},
) )
Tab( Tab(

View file

@ -93,14 +93,19 @@ fun CollectionFolderLiveTv(
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
val folders by viewModel.recordingFolders.observeAsState(listOf()) val folders by viewModel.recordingFolders.observeAsState(listOf())
val tvGuideStr = stringResource(R.string.tv_guide)
val tvDvrStr = stringResource(R.string.tv_dvr_schedule)
val tabs = val tabs =
listOf( remember(folders) {
TabId(stringResource(R.string.tv_guide), UUID.randomUUID()), listOf(
TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()), TabId(tvGuideStr, UUID.randomUUID()),
) + folders TabId(tvDvrStr, UUID.randomUUID()),
) + folders
}
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() } val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
@ -133,6 +138,7 @@ fun CollectionFolderLiveTv(
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs.map { it.title }, tabs = tabs.map { it.title },
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
) )
} }
when (selectedTabIndex) { when (selectedTabIndex) {
@ -150,10 +156,12 @@ fun CollectionFolderLiveTv(
1 -> { 1 -> {
DvrSchedule( DvrSchedule(
true, requestFocusAfterLoading = true,
Modifier focusRequesterOnEmpty = tabFocusRequesters[1],
.fillMaxSize() modifier =
.focusRequester(focusRequester), Modifier
.fillMaxSize()
.focusRequester(focusRequester),
) )
} }
@ -176,6 +184,7 @@ fun CollectionFolderLiveTv(
}, },
playEnabled = false, playEnabled = false,
defaultViewOptions = ViewOptions(), defaultViewOptions = ViewOptions(),
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} else { } else {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $selectedTabIndex", null)

View file

@ -59,6 +59,7 @@ fun CollectionFolderMovie(
) )
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() } val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
@ -88,6 +89,7 @@ fun CollectionFolderMovie(
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
) )
} }
when (selectedTabIndex) { when (selectedTabIndex) {
@ -136,6 +138,7 @@ fun CollectionFolderMovie(
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = true, playEnabled = true,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }
@ -168,6 +171,7 @@ fun CollectionFolderMovie(
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }

View file

@ -58,6 +58,7 @@ fun CollectionFolderTv(
) )
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() } val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
@ -91,6 +92,7 @@ fun CollectionFolderTv(
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
) )
} }
when (selectedTabIndex) { when (selectedTabIndex) {
@ -138,6 +140,7 @@ fun CollectionFolderTv(
preferencesViewModel.navigationManager.navigateTo(item.destination()) preferencesViewModel.navigationManager.navigateTo(item.destination())
}, },
playEnabled = false, playEnabled = false,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }

View file

@ -73,6 +73,7 @@ fun FavoritesPage(
stringResource(R.string.playlists), stringResource(R.string.playlists),
stringResource(R.string.people), stringResource(R.string.people),
) )
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -107,6 +108,7 @@ fun FavoritesPage(
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp), .padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
) )
} }
// TODO playEnabled = true for movies & episodes // TODO playEnabled = true for movies & episodes
@ -139,6 +141,7 @@ fun FavoritesPage(
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }
@ -170,6 +173,7 @@ fun FavoritesPage(
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }
@ -202,6 +206,7 @@ fun FavoritesPage(
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }
@ -233,6 +238,7 @@ fun FavoritesPage(
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }
@ -264,6 +270,7 @@ fun FavoritesPage(
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }
@ -300,6 +307,7 @@ fun FavoritesPage(
}, },
playEnabled = false, playEnabled = false,
filterOptions = listOf(), filterOptions = listOf(),
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
) )
} }

View file

@ -114,6 +114,7 @@ class DvrScheduleViewModel
@Composable @Composable
fun DvrSchedule( fun DvrSchedule(
requestFocusAfterLoading: Boolean, requestFocusAfterLoading: Boolean,
focusRequesterOnEmpty: FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: DvrScheduleViewModel = hiltViewModel(), viewModel: DvrScheduleViewModel = hiltViewModel(),
) { ) {
@ -138,7 +139,13 @@ fun DvrSchedule(
var showDialog by remember { mutableStateOf<BaseItem?>(null) } var showDialog by remember { mutableStateOf<BaseItem?>(null) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) { if (requestFocusAfterLoading) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) {
if (active.isNotEmpty() || recordings.isNotEmpty()) {
focusRequester.tryRequestFocus()
} else {
focusRequesterOnEmpty.tryRequestFocus()
}
}
} }
DvrScheduleContent( DvrScheduleContent(
activeRecordings = active, activeRecordings = active,

View file

@ -107,6 +107,18 @@ fun SeriesOverviewContent(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val scrollConnection = rememberDelayedNestedScroll() val scrollConnection = rememberDelayedNestedScroll()
var requestFocusAfterSeason by remember { mutableStateOf(false) } var requestFocusAfterSeason by remember { mutableStateOf(false) }
val seasonStr = stringResource(R.string.tv_season)
val tabs =
remember(seasons) {
seasons.mapNotNull {
it?.name
?: it?.data?.indexNumber?.let { "$seasonStr $it" }
?: ""
}
}
val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
Box( Box(
modifier = modifier =
modifier modifier
@ -138,17 +150,13 @@ fun SeriesOverviewContent(
} }
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
tabs = tabs = tabs,
seasons.mapNotNull {
it?.name
?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" }
?: ""
},
onClick = { onClick = {
selectedTabIndex = it selectedTabIndex = it
onChangeSeason.invoke(it) onChangeSeason.invoke(it)
requestFocusAfterSeason = true requestFocusAfterSeason = true
}, },
focusRequesters = focusRequesters,
modifier = modifier =
Modifier Modifier
.focusRequester(tabRowFocusRequester) .focusRequester(tabRowFocusRequester)