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.setValueOnMain
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.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -525,6 +526,7 @@ fun CollectionFolderGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
) = CollectionFolderGrid(
preferences,
itemId.toServerString(),
@ -541,6 +543,7 @@ fun CollectionFolderGrid(
positionCallback = positionCallback,
useSeriesForPrimary = useSeriesForPrimary,
filterOptions = filterOptions,
focusRequesterOnEmpty = focusRequesterOnEmpty,
)
@Composable
@ -560,6 +563,7 @@ fun CollectionFolderGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: CollectionFolderViewModel =
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
@ -615,6 +619,7 @@ fun CollectionFolderGrid(
pager = pager,
sortAndDirection = sortAndDirection!!,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = onClickItem,
onLongClickItem = { position, item ->
moreDialog.makePresent(PositionItem(position, item))
@ -759,6 +764,7 @@ fun CollectionFolderGridContent(
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
focusRequesterOnEmpty: FocusRequester? = null,
) {
val context = LocalContext.current
@ -767,7 +773,11 @@ fun CollectionFolderGridContent(
var viewOptions by remember { mutableStateOf(viewOptions) }
val gridFocusRequester = remember { FocusRequester() }
RequestOrRestoreFocus(gridFocusRequester)
if (pager.isNotEmpty()) {
RequestOrRestoreFocus(gridFocusRequester)
} else {
focusRequesterOnEmpty?.tryRequestFocus()
}
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
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.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
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.Text
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.tryRequestFocus
import timber.log.Timber
@Composable
fun TabRow(
selectedTabIndex: Int,
tabs: List<String>,
focusRequesters: List<FocusRequester>,
onClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
@ -55,7 +59,6 @@ fun TabRow(
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
}
}
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
var rowHasFocus by remember { mutableStateOf(false) }
LazyRow(
state = state,
@ -68,6 +71,7 @@ fun TabRow(
onEnter = {
// If entering from left or right, use last or first tab
// Otherwise use the selected tab
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex")
val focusRequester =
if (requestedFocusDirection == FocusDirection.Left) {
focusRequesters.lastOrNull()
@ -183,6 +187,7 @@ private fun TabRowPreview() {
TabRow(
selectedTabIndex = 1,
tabs = listOf("Tab 1", "Tab 2", "Tab 3"),
focusRequesters = listOf(),
onClick = {},
)
Tab(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -107,6 +107,18 @@ fun SeriesOverviewContent(
val scrollState = rememberScrollState()
val scrollConnection = rememberDelayedNestedScroll()
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(
modifier =
modifier
@ -138,17 +150,13 @@ fun SeriesOverviewContent(
}
TabRow(
selectedTabIndex = selectedTabIndex,
tabs =
seasons.mapNotNull {
it?.name
?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" }
?: ""
},
tabs = tabs,
onClick = {
selectedTabIndex = it
onChangeSeason.invoke(it)
requestFocusAfterSeason = true
},
focusRequesters = focusRequesters,
modifier =
Modifier
.focusRequester(tabRowFocusRequester)