Add button to navigate to random item (#1916)

## Description
Adds a random button on library tab/grid pages which navigates to a
random item in the list. This goes to the item's detail page.

The random item is chosen with the filters applied. So you could filter
by unplayed Action movies with 7+ stars and the random button will
select from those movies.

Pressing back restores focus on the random button so it's easy to
quickly retry.

### Related issues
Closes #838

### Testing
Emulator

## Screenshots
<img width="935" height="105" alt="image"
src="https://github.com/user-attachments/assets/170b495c-b47d-41ce-93b5-99831f2064c7"
/>
<img width="936" height="114" alt="image"
src="https://github.com/user-attachments/assets/3efa6a11-ebe8-42d7-b023-354b1e6eda37"
/>



## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-09-01 16:07:26 -04:00 • committed by GitHub
parent 5363db0b80
commit cedca9b2b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 134 additions and 39 deletions

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -22,7 +23,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
@ -288,6 +289,7 @@ data class GridClickActions(
@Composable
fun CollectionFolderHeader(
listIsNotEmpty: Boolean,
showHeader: Boolean,
showTitle: Boolean,
playEnabled: Boolean,
@ -298,12 +300,14 @@ fun CollectionFolderHeader(
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
onClickShowViewOptions: () -> Unit,
onClickPlayAll: (Boolean) -> Unit,
onClickRandom: () -> Unit,
modifier: Modifier = Modifier,
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
onShowFilterDropdown: ((Boolean) -> Unit)? = null,
filterButtonFocusRequester: FocusRequester = remember { FocusRequester() },
randomButtonFocusRequester: FocusRequester = remember { FocusRequester() },
) {
AnimatedVisibility(
showHeader,
@ -311,6 +315,7 @@ fun CollectionFolderHeader(
exit = shrinkVertically(),
modifier = modifier,
) {
val focusRequester = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
@ -320,63 +325,84 @@ fun CollectionFolderHeader(
}
val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
Row(
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = endPadding)
.focusRestorer()
.fillMaxWidth(),
) {
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusRestorer(),
) {
if (sortOptions.isNotEmpty()) {
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
}
if (filterOptions.isNotEmpty()) {
FilterByButton(
filterOptions = filterOptions,
current = currentFilter,
onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues,
modifier = Modifier.focusRequester(filterButtonFocusRequester),
onShow = onShowFilterDropdown,
)
}
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = onClickShowViewOptions,
modifier = Modifier,
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
if (sortOptions.isNotEmpty()) {
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier.focusRequester(focusRequester),
)
}
if (filterOptions.isNotEmpty()) {
FilterByButton(
filterOptions = filterOptions,
current = currentFilter,
onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues,
modifier =
Modifier
.focusRequester(filterButtonFocusRequester)
.ifElse(
sortOptions.isEmpty(),
Modifier.focusRequester(focusRequester),
),
onShow = onShowFilterDropdown,
)
}
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = onClickShowViewOptions,
modifier =
Modifier.ifElse(
sortOptions.isEmpty() && filterOptions.isEmpty(),
Modifier.focusRequester(focusRequester),
),
)
}
if (playEnabled) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusRestorer(),
) {
Spacer(Modifier.weight(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
ExpandableFaButton(
title = R.string.sort_by_random,
iconStringRes = R.string.fa_dice,
onClick = onClickRandom,
modifier = Modifier.focusRequester(randomButtonFocusRequester),
enabled = listIsNotEmpty,
)
if (playEnabled) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlayAll.invoke(false) },
enabled = listIsNotEmpty,
)
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlayAll.invoke(true) },
enabled = listIsNotEmpty,
)
}
}

View file

@ -65,6 +65,7 @@ import com.github.damontecres.wholphin.ui.showToast
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.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler
@ -78,6 +79,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@ -264,6 +266,24 @@ class CollectionFolderViewModel
}
}
override fun onClickRandom() {
viewModelScope.launchIO {
try {
val random =
(state.value.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking()
Timber.d("Got random item: %s", random?.id)
random?.destination()?.let {
navigateTo(random.destination())
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error getting random")
showToast(context, "Error: ${ex.localizedMessage}}")
}
}
}
override fun onFilterChange(
newFilter: GetItemsFilter,
recursive: Boolean,
@ -767,6 +787,11 @@ interface CollectionFolderViewActions {
suspend fun positionOfLetter(letter: Char): Int?
fun saveViewOptions(viewOptions: ViewOptions)
/**
* Navigate to a random item
*/
fun onClickRandom()
}
@Composable
@ -791,11 +816,15 @@ fun CollectionFolderViewContent(
) {
var position by rememberInt(savedPosition)
// Track if leaving page due to clicking the random button so focus can be restored there
var clickedRandom by rememberSaveable { mutableStateOf(false) }
val contextMenu = rememberContextMenu(preferences, provider)
var showViewOptions by rememberSaveable { mutableStateOf(false) }
var filterDropdownShowing by remember { mutableStateOf(false) }
val headerRowFocusRequester = remember { FocusRequester() }
val filterButtonFocusRequester = remember { FocusRequester() }
val randomButtonFocusRequester = remember { FocusRequester() }
val gridActions =
remember(actions) {
@ -889,9 +918,10 @@ fun CollectionFolderViewContent(
}
}
CollectionFolderHeader(
listIsNotEmpty = state.items.successValue?.isNotEmpty() == true,
showHeader = showHeader || state.items !is DataLoadingState.Success,
showTitle = showTitle,
playEnabled = playEnabled && state.items.successValue?.isNotEmpty() == true,
playEnabled = playEnabled,
title = title,
sortAndDirection = state.sortAndDirection,
onSortChange = {
@ -908,9 +938,14 @@ fun CollectionFolderViewContent(
filterOptions = filterOptions,
onClickPlayAll = gridActions.onClickPlayAll!!,
onClickShowViewOptions = { showViewOptions = true },
onClickRandom = {
clickedRandom = true
viewActions.onClickRandom()
},
modifier = Modifier.focusRequester(headerRowFocusRequester),
onShowFilterDropdown = { filterDropdownShowing = it },
filterButtonFocusRequester = filterButtonFocusRequester,
randomButtonFocusRequester = randomButtonFocusRequester,
)
when (val pager = state.items) {
@ -933,10 +968,12 @@ fun CollectionFolderViewContent(
when {
contextMenu.isShowing -> null
filterDropdownShowing -> filterButtonFocusRequester
clickedRandom -> randomButtonFocusRequester
pager.data.isNotEmpty() -> gridFocusRequester
else -> focusRequesterOnEmpty ?: headerRowFocusRequester
}
focusRequester?.tryRequestFocus()
clickedRandom = false
}
Box(Modifier.fillMaxSize()) {
if (state.viewOptions.type == ViewOptionsType.GRID) {

View file

@ -44,6 +44,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler
@ -51,6 +52,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.successValue
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
@ -693,6 +695,26 @@ class FavoritesViewModel
override fun saveViewOptions(viewOptions: ViewOptions) {
saveViewOptions(type, viewOptions)
}
override fun onClickRandom() {
viewModelScope.launchIO {
try {
collectionStateFor(type)?.let { collectionState ->
val random =
(collectionState.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking()
Timber.d("Got random item: %s", random?.id)
random?.destination()?.let {
navigateTo(random.destination())
}
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error getting random")
showToast(context, "Error: ${ex.localizedMessage}}")
}
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.util
import java.util.function.IntFunction
import java.util.function.Predicate
import kotlin.random.Random
/**
* A [List] which has function that will wait for a result
@ -17,6 +18,11 @@ interface BlockingList<T> : List<T> {
*/
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
suspend fun randomBlocking(): T {
val index = Random.nextInt(size)
return getBlocking(index)
}
companion object {
/**
* Create a [BlockingList] over a regular [List]

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Names should be prefixed with 'fa_' and use the icon name from FontAwesome -->
<!-- Uses v6 of FontAwesome: https://fontawesome.com/v6/icons/classic/solid/<name> -->
<!-- Value is '&#x' then the unicode value, then semicolon -->
<string name="fa_tag" translatable="false">&#xf02b;</string>
<string name="fa_user" translatable="false">&#xf007;</string>
<string name="fa_location_dot" translatable="false">&#xf3c5;</string>
@ -57,4 +60,5 @@
<string name="fa_repeat" translatable="false">&#xf363;</string>
<string name="fa_compact_disc" translatable="false">&#xf51f;</string>
<string name="fa_xmark" translatable="false">&#xf00d;</string>
<string name="fa_dice" translatable="false">&#xf522;</string>
</resources>