Optional to automatically go to new page on focus instead of clicking (#127)

Closes #95 

Adds a setting (defaults to enabled) to automatically go to a page when
the navigation drawer item is focused after a brief delay.

Basically, if you open the left nav drawer, and scroll down to "Movies"
after a brief delay (600ms), the app will switch to the Movies page
without having to click.

It can be disabled in advanced settings.
This commit is contained in:
damontecres 2025-10-31 15:09:13 -04:00 committed by GitHub
parent 26cc092402
commit 9f3691fd1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 137 additions and 17 deletions

View file

@ -590,13 +590,25 @@ sealed interface AppPreference<T> {
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val NavDrawerSwitchOnFocus =
AppSwitchPreference(
title = R.string.nav_drawer_switch_on_focus,
defaultValue = true,
getter = { it.interfacePreferences.navDrawerSwitchOnFocus },
setter = { prefs, value ->
prefs.updateInterfacePreferences { navDrawerSwitchOnFocus = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.nav_drawer_switch_on_focus_summary_off,
)
} }
} }
val basicPreferences = val basicPreferences =
listOf( listOf(
PreferenceGroup( PreferenceGroup(
title = R.string.basic_interface, title = R.string.ui_interface,
preferences = preferences =
listOf( listOf(
AppPreference.HomePageItems, AppPreference.HomePageItems,
@ -648,6 +660,13 @@ val uiPreferences = listOf<PreferenceGroup>()
val advancedPreferences = val advancedPreferences =
listOf( listOf(
PreferenceGroup(
title = R.string.ui_interface,
preferences =
listOf(
AppPreference.NavDrawerSwitchOnFocus,
),
),
PreferenceGroup( PreferenceGroup(
title = R.string.playback, title = R.string.playback,
preferences = preferences =

View file

@ -70,6 +70,8 @@ class AppPreferencesSerializer
.apply { .apply {
playThemeSongs = AppPreference.PlayThemeMusic.defaultValue playThemeSongs = AppPreference.PlayThemeMusic.defaultValue
appThemeColors = AppPreference.ThemeColors.defaultValue appThemeColors = AppPreference.ThemeColors.defaultValue
navDrawerSwitchOnFocus =
AppPreference.NavDrawerSwitchOnFocus.defaultValue
}.build() }.build()
}.build() }.build()

View file

@ -23,10 +23,13 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -67,14 +70,17 @@ import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.spacedByWithFooter
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.ui.tryRequestFocus
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -96,12 +102,47 @@ class NavDrawerViewModel
val all = all ?: navDrawerItemRepository.getNavDrawerItems() val all = all ?: navDrawerItemRepository.getNavDrawerItems()
this@NavDrawerViewModel.all = all this@NavDrawerViewModel.all = all
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@NavDrawerViewModel.moreLibraries.value = this@NavDrawerViewModel.moreLibraries.value = moreLibraries
all.toMutableList().apply { removeAll(libraries) }
this@NavDrawerViewModel.libraries.value = libraries this@NavDrawerViewModel.libraries.value = libraries
} }
val asDestinations =
(libraries + listOf(NavDrawerItem.More) + moreLibraries).map {
if (it is ServerNavDrawerItem) {
it.destination
} else if (it is NavDrawerItem.Favorites) {
Destination.Favorites
} else {
null
}
}
val backstack = navigationManager.backStack.toList().reversed()
for (i in 0..<backstack.size) {
val key = backstack[i]
if (key is Destination) {
val index =
if (key is Destination.Home) {
-1
} else if (key is Destination.Search) {
-2
} else {
val idx = asDestinations.indexOf(key)
if (idx >= 0) {
idx
} else {
null
}
}
Timber.v("Found $index => $key")
if (index != null) {
selectedIndex.setValueOnMain(index)
break
}
}
}
} }
} }
@ -182,6 +223,8 @@ fun NavDrawer(
// val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries // val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries
// A negative index is a built in page, >=0 is a library // A negative index is a built in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1) val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
var focusedIndex by remember { mutableIntStateOf(Int.MIN_VALUE) }
val derivedFocusedIndex by remember { derivedStateOf { focusedIndex } }
fun setShowMore(value: Boolean) { fun setShowMore(value: Boolean) {
viewModel.setShowMore(value) viewModel.setShowMore(value)
@ -210,6 +253,31 @@ fun NavDrawer(
} }
} }
} }
if (preferences.appPreferences.interfacePreferences.navDrawerSwitchOnFocus) {
LaunchedEffect(derivedFocusedIndex) {
val index = derivedFocusedIndex
delay(600)
if (index != selectedIndex) {
if (index == -1) {
viewModel.setIndex(-1)
viewModel.navigationManager.goToHome()
} else if (index in libraries.indices) {
if (moreLibraries.isEmpty() || index != libraries.lastIndex) {
libraries.getOrNull(index)?.let {
onClick.invoke(index, it)
}
}
} else {
val newIndex = libraries.size - index + 1
if (newIndex in moreLibraries.indices) {
moreLibraries.getOrNull(newIndex)?.let {
onClick.invoke(index, it)
}
}
}
}
}
}
NavigationDrawer( NavigationDrawer(
modifier = modifier =
@ -234,11 +302,16 @@ fun NavDrawer(
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
) { ) {
item { item {
// Even though some must be clicked, focusing on it should clear other focused items
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE }
IconNavItem( IconNavItem(
text = user?.name ?: "", text = user?.name ?: "",
subtext = server?.name ?: server?.url, subtext = server?.name ?: server?.url,
icon = Icons.Default.AccountCircle, icon = Icons.Default.AccountCircle,
selected = false, selected = false,
interactionSource = interactionSource,
onClick = { onClick = {
viewModel.navigationManager.navigateTo(Destination.UserList) viewModel.navigationManager.navigateTo(Destination.UserList)
}, },
@ -246,10 +319,14 @@ fun NavDrawer(
) )
} }
item { item {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -2 }
IconNavItem( IconNavItem(
text = "Search", text = "Search",
icon = Icons.Default.Search, icon = Icons.Default.Search,
selected = selectedIndex == -2, selected = selectedIndex == -2,
interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-2) viewModel.setIndex(-2)
viewModel.navigationManager.navigateToFromDrawer(Destination.Search) viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
@ -263,10 +340,14 @@ fun NavDrawer(
) )
} }
item { item {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -1 }
IconNavItem( IconNavItem(
text = "Home", text = "Home",
icon = Icons.Default.Home, icon = Icons.Default.Home,
selected = selectedIndex == -1, selected = selectedIndex == -1,
interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-1) viewModel.setIndex(-1)
if (destination is Destination.Home) { if (destination is Destination.Home) {
@ -284,10 +365,14 @@ fun NavDrawer(
) )
} }
itemsIndexed(libraries) { index, it -> itemsIndexed(libraries) { index, it ->
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = index }
NavItem( NavItem(
library = it, library = it,
selected = selectedIndex == index, selected = selectedIndex == index,
moreExpanded = showMore, moreExpanded = showMore,
interactionSource = interactionSource,
onClick = { onClick = {
onClick.invoke(index, it) onClick.invoke(index, it)
if (it !is NavDrawerItem.More) setShowMore(false) if (it !is NavDrawerItem.More) setShowMore(false)
@ -302,26 +387,35 @@ fun NavDrawer(
} }
if (showMore) { if (showMore) {
itemsIndexed(moreLibraries) { index, it -> itemsIndexed(moreLibraries) { index, it ->
val adjustedIndex = (index + libraries.size + 1)
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = adjustedIndex }
NavItem( NavItem(
library = it, library = it,
selected = selectedIndex == (index + libraries.size), selected = selectedIndex == adjustedIndex,
moreExpanded = showMore, moreExpanded = showMore,
onClick = { onClick.invoke(index + libraries.size, it) }, onClick = { onClick.invoke(adjustedIndex, it) },
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
interactionSource = interactionSource,
modifier = modifier =
Modifier Modifier
.ifElse( .ifElse(
selectedIndex == (index + libraries.size), selectedIndex == adjustedIndex,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ).animateItem(),
) )
} }
} }
item { item {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE }
IconNavItem( IconNavItem(
text = "Settings", text = "Settings",
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
selected = false, selected = false,
interactionSource = interactionSource,
onClick = { onClick = {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Settings( Destination.Settings(
@ -364,8 +458,8 @@ fun NavigationDrawerScope.IconNavItem(
leadingContent = { leadingContent = {
val color = val color =
when { when {
isFocused -> LocalContentColor.current
selected -> MaterialTheme.colorScheme.border selected -> MaterialTheme.colorScheme.border
isFocused -> LocalContentColor.current
else -> LocalContentColor.current else -> LocalContentColor.current
} }
Icon( Icon(
@ -422,13 +516,6 @@ fun NavigationDrawerScope.NavItem(
else -> R.string.fa_film else -> R.string.fa_film
} }
} }
val isFocused = interactionSource.collectIsFocusedAsState().value
val color =
when {
isFocused -> Color.Unspecified
selected -> MaterialTheme.colorScheme.border
else -> Color.Unspecified
}
NavigationDrawerItem( NavigationDrawerItem(
modifier = modifier, modifier = modifier,
selected = false, selected = false,
@ -445,12 +532,13 @@ fun NavigationDrawerScope.NavItem(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 16.sp, fontSize = 16.sp,
fontFamily = FontAwesome, fontFamily = FontAwesome,
color = color, color = if (selected) MaterialTheme.colorScheme.border else LocalContentColor.current,
) )
} else { } else {
Icon( Icon(
painter = painterResource(icon), painter = painterResource(icon),
contentDescription = null, contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.border else LocalContentColor.current,
) )
} }
} }
@ -463,7 +551,7 @@ fun NavigationDrawerScope.NavItem(
) )
} }
}, },
interactionSource = null, interactionSource = interactionSource,
) { ) {
Text( Text(
modifier = Modifier, modifier = Modifier,

View file

@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber import timber.log.Timber
@ -81,4 +82,11 @@ suspend fun upgradeApp(
} }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
}
}
}
} }

View file

@ -81,6 +81,7 @@ message InterfacePreferences {
bool remember_selected_tab = 2; bool remember_selected_tab = 2;
map<string, int32> remembered_tabs = 3; map<string, int32> remembered_tabs = 3;
AppThemeColors app_theme_colors = 4; AppThemeColors app_theme_colors = 4;
bool nav_drawer_switch_on_focus = 5;
} }
message AppPreferences { message AppPreferences {

View file

@ -21,7 +21,7 @@
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="app_name_long">Wholphin</string> <string name="app_name_long">Wholphin</string>
<string name="basic_interface">Basic Interface</string> <string name="ui_interface">Interface</string>
<string name="playback">Playback</string> <string name="playback">Playback</string>
<string name="about">About</string> <string name="about">About</string>
<string name="advanced_settings">Advanced Settings</string> <string name="advanced_settings">Advanced Settings</string>
@ -98,5 +98,7 @@
<string name="send_app_logs_summary">Useful for debugging</string> <string name="send_app_logs_summary">Useful for debugging</string>
<string name="global_content_scale">Default content scale</string> <string name="global_content_scale">Default content scale</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> <string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>
<string name="nav_drawer_switch_on_focus">Switch nav drawer pages on focus</string>
<string name="nav_drawer_switch_on_focus_summary_off">Click to switch pages</string>
</resources> </resources>