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:
parent
5363db0b80
commit
cedca9b2b1
5 changed files with 134 additions and 39 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"></string>
|
||||
<string name="fa_user" translatable="false"></string>
|
||||
<string name="fa_location_dot" translatable="false"></string>
|
||||
|
|
@ -57,4 +60,5 @@
|
|||
<string name="fa_repeat" translatable="false"></string>
|
||||
<string name="fa_compact_disc" translatable="false"></string>
|
||||
<string name="fa_xmark" translatable="false"></string>
|
||||
<string name="fa_dice" translatable="false"></string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue