Add wrap around scrolling to settings pages (#1937)

## Description
Add scrolling from top to bottom and vice versa. This makes it quicker
to get to Advanced Settings for example.

### Related issues
N/A

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-09-05 12:11:30 -04:00 • committed by GitHub
parent 1f3a861c47
commit 39239dd444
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 112 additions and 39 deletions

View file

@ -1,5 +1,19 @@
package com.github.damontecres.wholphin.preferences
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@ -10,3 +24,48 @@ val PlaybackPreferences.skipBackOnResume: Duration?
} else {
null
}
/**
* Adds modifiers so that pressing Up on the first element scrolls to the bottom and vice versa
*/
@Composable
fun Modifier.lazyListWrapScrolling(
state: LazyListState,
focused: Boolean,
isFirst: Boolean,
isLast: Boolean,
firstFocusRequester: FocusRequester,
lastFocusRequester: FocusRequester,
): Modifier {
val scope = rememberCoroutineScope()
return this
.ifElse(
isFirst,
Modifier
.focusRequester(firstFocusRequester)
.onKeyEvent {
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
state.animateScrollToItem(state.layoutInfo.totalItemsCount - 1)
lastFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
).ifElse(
isLast,
Modifier
.focusRequester(lastFocusRequester)
.onKeyEvent {
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
state.animateScrollToItem(0)
firstFocusRequester.tryRequestFocus()
}
return@onKeyEvent true
}
false
},
)
}

View file

@ -63,15 +63,12 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -322,30 +319,13 @@ fun DialogPopupContent(
modifier =
Modifier
.focusRequester(focusRequesters[index])
.ifElse(
.lazyListWrapScrolling(
listState,
focused,
index == 0,
Modifier.onKeyEvent {
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(dialogItems.lastIndex)
focusRequesters[dialogItems.lastIndex].tryRequestFocus()
}
return@onKeyEvent true
}
false
},
).ifElse(
index == dialogItems.lastIndex,
Modifier.onKeyEvent {
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(0)
focusRequesters[0].tryRequestFocus()
}
return@onKeyEvent true
}
false
},
focusRequesters[0],
focusRequesters[dialogItems.lastIndex],
),
)
}

View file

@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.experimentalPreferences
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
import com.github.damontecres.wholphin.preferences.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.Release
@ -99,6 +100,9 @@ fun PreferencesContent(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() }
val lastFocusRequester = remember { FocusRequester() }
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
val state = rememberLazyListState()
var preferences by remember { mutableStateOf(initialPreferences) }
@ -135,7 +139,7 @@ fun PreferencesContent(
}
}
val movementSounds = true
val movementSounds = false
val installedVersion = updateVM.currentVersion
val updateAvailable =
remember(updateState.release) {
@ -185,6 +189,12 @@ fun PreferencesContent(
}
}
val showUpdate =
UpdateChecker.ACTIVE &&
preferenceScreenOption == PreferenceScreenOption.BASIC &&
preferences.autoCheckForUpdates &&
updateAvailable
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInHorizontally { it / 2 },
@ -214,11 +224,7 @@ fun PreferencesContent(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(),
) {
if (UpdateChecker.ACTIVE &&
preferenceScreenOption == PreferenceScreenOption.BASIC &&
preferences.autoCheckForUpdates &&
updateAvailable
) {
if (showUpdate) {
item {
val updateFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
@ -237,6 +243,7 @@ fun PreferencesContent(
modifier =
Modifier
.focusRequester(updateFocusRequester)
.focusRequester(firstFocusRequester)
.playSoundOnFocus(movementSounds),
)
}
@ -262,15 +269,25 @@ fun PreferencesContent(
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<AppPreferences, Any>
val isFirst = groupIndex == 0 && prefIndex == 0 && !showUpdate
val isLast =
groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex
item {
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
val focusModifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
).lazyListWrapScrolling(
state,
focused,
isFirst,
isLast,
firstFocusRequester,
lastFocusRequester,
)
val focused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) {
focusedIndex = Pair(groupIndex, prefIndex)

View file

@ -40,6 +40,7 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.SubtitlePreferences
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.preferences.ClickPreference
@ -64,6 +65,8 @@ fun SubtitlePreferencesContent(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() }
val lastFocusRequester = remember { FocusRequester() }
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
val state = rememberLazyListState()
@ -123,6 +126,9 @@ fun SubtitlePreferencesContent(
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<SubtitlePreferences, Any>
item {
val isFirst = groupIndex == 0 && prefIndex == 0
val isLast =
groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex
val interactionSource = remember { MutableInteractionSource() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val focused = interactionSource.collectIsFocusedAsState().value
@ -137,6 +143,21 @@ fun SubtitlePreferencesContent(
bringIntoViewRequester.bringIntoView()
}
}
val focusModifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
).bringIntoViewRequester(bringIntoViewRequester)
.lazyListWrapScrolling(
state,
focused,
isFirst,
isLast,
firstFocusRequester,
lastFocusRequester,
)
when (pref) {
SubtitleSettings.Reset -> {
ClickPreference(
@ -152,6 +173,7 @@ fun SubtitlePreferencesContent(
}
},
interactionSource = interactionSource,
modifier = focusModifier,
)
}
@ -187,12 +209,7 @@ fun SubtitlePreferencesContent(
}
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
).bringIntoViewRequester(bringIntoViewRequester),
modifier = focusModifier,
)
}
}