Improvements to home page customization (#1458)

## Description
Adds a few enhancements to home page customization:
- Add option to combined continue watching & next up to split it
- Add option to continue watching and next up rows to combine them
- Add option for sorting playlist or collections
- Show suggestions (recently updated) when searching for a playlist or
collection to add
- Better handling for loading vs unsupported types for suggestions

Also, the old toggle for combining continue watching & next up in
advanced settings is removed. Now by default the rows will be combined
unless changed in the home page customization settings.

### Related issues
Closes #340
Closes #1383
Closes #1350

### Testing
Just on the emulator so far

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-26 13:30:00 -04:00 committed by GitHub
parent 23856ffcf8
commit 8ea6bbbfca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 497 additions and 134 deletions

View file

@ -202,18 +202,6 @@ sealed interface AppPreference<Pref, T> {
}, },
) )
val CombineContinueNext =
AppSwitchPreference<AppPreferences>(
title = R.string.combine_continue_next,
defaultValue = false,
getter = { it.homePagePreferences.combineContinueNext },
setter = { prefs, value ->
prefs.updateHomePagePreferences { combineContinueNext = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val RewatchNextUp = val RewatchNextUp =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.rewatch_next_up, title = R.string.rewatch_next_up,
@ -1202,7 +1190,6 @@ val advancedPreferences =
AppPreference.BackdropStylePref, AppPreference.BackdropStylePref,
AppPreference.ShowLogos, AppPreference.ShowLogos,
AppPreference.ManageMedia, AppPreference.ManageMedia,
AppPreference.CombineContinueNext,
AppPreference.DisplayTogglesPref, AppPreference.DisplayTogglesPref,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus, // AppPreference.NavDrawerSwitchOnFocus,

View file

@ -85,7 +85,6 @@ class AppPreferencesSerializer
.apply { .apply {
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}.build() }.build()
interfacePreferences = interfacePreferences =

View file

@ -33,7 +33,6 @@ import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -274,9 +273,6 @@ class HomeSettingsService
navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false) navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false)
} }
val prefs =
userPreferencesService.getCurrent().appPreferences.homePagePreferences
val includedIds = val includedIds =
libraries libraries
.mapIndexed { index, it -> .mapIndexed { index, it ->
@ -296,8 +292,7 @@ class HomeSettingsService
) )
} }
} }
val continueWatchingRows = val continueWatchingRow =
if (prefs.combineContinueNext) {
listOf( listOf(
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = includedIds.size + 1, id = includedIds.size + 1,
@ -305,21 +300,7 @@ class HomeSettingsService
config = HomeRowConfig.ContinueWatchingCombined(), config = HomeRowConfig.ContinueWatchingCombined(),
), ),
) )
} else { val rowConfig = continueWatchingRow + includedIds
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(),
),
HomeRowConfigDisplay(
id = includedIds.size + 2,
title = ResStringProvider(R.string.next_up),
config = HomeRowConfig.NextUp(),
),
)
}
val rowConfig = continueWatchingRows + includedIds
return HomePageResolvedSettings(rowConfig) return HomePageResolvedSettings(rowConfig)
} }
@ -864,8 +845,24 @@ class HomeSettingsService
userId = userDto.id, userId = userDto.id,
parentId = row.parentId, parentId = row.parentId,
recursive = row.recursive, recursive = row.recursive,
sortBy = row.sort?.let { listOf(it.sort) }, sortBy =
sortOrder = row.sort?.let { listOf(it.direction) }, row.sort?.let {
buildList {
add(it.sort)
if (it.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME)
}
}
},
sortOrder =
row.sort?.let {
buildList {
add(it.direction)
if (it.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
}
},
limit = limit, limit = limit,
fields = DefaultItemFields, fields = DefaultItemFields,
) )
@ -1090,29 +1087,39 @@ class HomeSettingsService
.content .content
val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
if (itemKind != null) {
val suggestions = val suggestions =
itemKind?.let {
suggestionService suggestionService
.getSuggestionsFlow(row.parentId, itemKind) .getSuggestionsFlow(row.parentId, itemKind)
.firstOrNull() .firstOrNull()
} when (suggestions) {
if (suggestions != null && suggestions is SuggestionsResource.Success) { SuggestionsResource.Empty -> {
Success(
title,
suggestions.items,
row.viewOptions,
rowType = row,
)
} else if (suggestions is SuggestionsResource.Empty) {
Success( Success(
title, title,
listOf(), listOf(),
row.viewOptions, row.viewOptions,
rowType = row, rowType = row,
) )
} else { }
Error(
is SuggestionsResource.Success -> {
Success(
title, title,
suggestions.items,
row.viewOptions,
rowType = row,
)
}
SuggestionsResource.Loading,
null,
-> {
HomeRowLoadingState.Loading(title)
}
}
} else {
HomeRowLoadingState.Error(
title = title,
message = "Unsupported type ${library.collectionType}", message = "Unsupported type ${library.collectionType}",
) )
} }

View file

@ -8,7 +8,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
@ -16,11 +15,9 @@ import androidx.compose.ui.text.withStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.flip import com.github.damontecres.wholphin.ui.data.flip
import com.github.damontecres.wholphin.ui.data.getStringRes import com.github.damontecres.wholphin.ui.data.getStringRes
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
@ -37,10 +34,8 @@ fun SortByButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentSort = current.sort val currentSort = current.sort
val name = stringResource(getStringRes(currentSort))
val currentDirection = current.direction val currentDirection = current.direction
var sortByDropDown by remember { mutableStateOf(false) } var sortByDropDown by remember { mutableStateOf(false) }
val context = LocalContext.current
Box(modifier = modifier) { Box(modifier = modifier) {
TextButton( TextButton(
@ -50,22 +45,7 @@ fun SortByButton(
}, },
) { ) {
Text( Text(
text = text = current.toAnnotatedString(),
buildAnnotatedString {
withStyle(SpanStyle(fontFamily = FontAwesome)) {
append(
stringResource(
if (currentDirection == SortOrder.ASCENDING) {
R.string.fa_caret_up
} else {
R.string.fa_caret_down
},
),
)
}
append(" ")
append(name)
},
) )
} }
@ -117,3 +97,21 @@ fun SortByButton(
} }
} }
} }
@Composable
fun SortAndDirection.toAnnotatedString() =
buildAnnotatedString {
withStyle(SpanStyle(fontFamily = FontAwesome)) {
append(
stringResource(
if (direction == SortOrder.ASCENDING) {
R.string.fa_caret_up
} else {
R.string.fa_caret_down
},
),
)
}
append(" ")
append(stringResource(getStringRes(sort)))
}

View file

@ -73,6 +73,7 @@ val VideoSortOptions =
val PlaylistSortOptions = val PlaylistSortOptions =
listOf( listOf(
ItemSortBy.DEFAULT,
ItemSortBy.SORT_NAME, ItemSortBy.SORT_NAME,
ItemSortBy.PREMIERE_DATE, ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED, ItemSortBy.DATE_CREATED,

View file

@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -43,6 +45,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -57,7 +60,13 @@ fun SearchForContent(
searchType: BaseItemKind, searchType: BaseItemKind,
onClick: (BaseItem) -> Unit, onClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName), viewModel: SearchForViewModel =
hiltViewModel<SearchForViewModel, SearchForViewModel.Factory>(
key = searchType.serialName,
creationCallback = {
it.create(searchType)
},
),
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@ -92,6 +101,7 @@ fun SearchForContent(
} }
} }
} }
val titleRes = val titleRes =
remember { remember {
when (searchType) { when (searchType) {
@ -178,18 +188,55 @@ fun SearchForContent(
} }
} }
when (val st = state.results) { LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
SearchForResultsRow(
title = stringResource(R.string.results),
results = state.results,
onClick = onClick,
modifier = Modifier.focusRequester(focusRequester),
)
}
item {
HorizontalDivider()
}
item {
SearchForResultsRow(
title = stringResource(R.string.suggestions),
results = state.recent,
onClick = onClick,
modifier = Modifier,
)
}
}
}
}
@Composable
fun SearchForResultsRow(
title: String,
results: SearchResult,
onClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
when (val st = results) {
is SearchResult.Error -> { is SearchResult.Error -> {
ErrorMessage("Error", st.ex) ErrorMessage("Error", st.ex, modifier)
} }
SearchResult.NoQuery -> { SearchResult.NoQuery -> {
// no-op ItemRowTitle(
title = stringResource(R.string.no_results),
modifier = modifier,
)
} }
SearchResult.Searching -> { SearchResult.Searching -> {
Text( ItemRowTitle(
text = stringResource(R.string.searching), title = stringResource(R.string.searching),
modifier = modifier,
) )
} }
@ -197,21 +244,23 @@ fun SearchForContent(
Text( Text(
text = "Not supported", text = "Not supported",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = modifier,
) )
} }
is SearchResult.Success -> { is SearchResult.Success -> {
if (st.items.isEmpty()) { if (st.items.isEmpty()) {
Text( ItemRowTitle(
text = stringResource(R.string.no_results), title = stringResource(R.string.no_results),
modifier = modifier,
) )
} else { } else {
ItemRow( ItemRow(
title = "", title = title,
items = st.items, items = st.items,
onClickItem = { _, item -> onClick.invoke(item) }, onClickItem = { _, item -> onClick.invoke(item) },
onLongClickItem = { _, _ -> }, onLongClickItem = { _, _ -> },
modifier = Modifier.focusRequester(focusRequester), modifier = modifier,
cardContent = { index, item, mod, onClick, onLongClick -> cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard( SeasonCard(
item = item, item = item,
@ -219,7 +268,7 @@ fun SearchForContent(
onClick.invoke() onClick.invoke()
}, },
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = Cards.height2x3, imageHeight = Cards.heightEpisode,
modifier = mod, modifier = mod,
) )
}, },
@ -227,7 +276,6 @@ fun SearchForContent(
} }
} }
} }
}
} }
@Composable @Composable
@ -242,6 +290,7 @@ fun SearchForDialog(
DialogProperties( DialogProperties(
usePlatformDefaultWidth = false, usePlatformDefaultWidth = false,
), ),
elevation = 3.dp,
) { ) {
SearchForContent( SearchForContent(
searchType = searchType, searchType = searchType,

View file

@ -8,30 +8,68 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.SearchResult import com.github.damontecres.wholphin.ui.main.SearchResult
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = SearchForViewModel.Factory::class)
class SearchForViewModel class SearchForViewModel
@Inject @AssistedInject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val voiceInputManager: VoiceInputManager, val voiceInputManager: VoiceInputManager,
@Assisted val searchType: BaseItemKind,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(searchType: BaseItemKind): SearchForViewModel
}
val state = MutableStateFlow(SearchForState()) val state = MutableStateFlow(SearchForState())
init { init {
state.value = SearchForState() state.value = SearchForState()
viewModelScope.launchIO {
try {
val request =
GetItemsRequest(
userId = serverRepository.currentUser?.id,
includeItemTypes = listOf(searchType),
recursive = true,
fields = SlimItemFields,
sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED),
sortOrder = listOf(SortOrder.DESCENDING),
limit = 25,
)
val recent = api.itemsApi.getItems(request).toBaseItems(api, false)
state.update {
it.copy(recent = SearchResult.Success(recent))
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching recent %s", searchType)
state.update {
it.copy(recent = SearchResult.Error(ex))
}
}
}
} }
fun search( fun search(
@ -41,10 +79,10 @@ class SearchForViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
if (state.value.query != query) { if (state.value.query != query) {
if (query.isBlank()) { if (query.isBlank()) {
state.update { SearchForState(query, SearchResult.NoQuery) } state.update { it.copy(query = query, results = SearchResult.NoQuery) }
return@launchIO return@launchIO
} }
state.update { SearchForState(query, SearchResult.Searching) } state.update { it.copy(query = query, results = SearchResult.NoQuery) }
try { try {
val request = val request =
GetItemsRequest( GetItemsRequest(
@ -55,10 +93,15 @@ class SearchForViewModel
fields = SlimItemFields, fields = SlimItemFields,
) )
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
state.update { SearchForState(query, SearchResult.Success(pager)) } state.update {
it.copy(
query = query,
results = SearchResult.Success(pager),
)
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex) Timber.e(ex)
state.update { SearchForState(query, SearchResult.Error(ex)) } state.update { it.copy(query = query, results = SearchResult.Error(ex)) }
} }
} }
} }
@ -67,5 +110,6 @@ class SearchForViewModel
data class SearchForState( data class SearchForState(
val query: String = "", val query: String = "",
val recent: SearchResult = SearchResult.Searching,
val results: SearchResult = SearchResult.NoQuery, val results: SearchResult = SearchResult.NoQuery,
) )

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.ui.main.settings
sealed interface HomeRowConfigAction {
data object Combine : HomeRowConfigAction
data object Split : HomeRowConfigAction
}

View file

@ -1,17 +1,32 @@
package com.github.damontecres.wholphin.ui.main.settings package com.github.damontecres.wholphin.ui.main.settings
import android.view.Gravity
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.ListItem
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppChoicePreference
import com.github.damontecres.wholphin.preferences.AppClickablePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference
@ -21,18 +36,31 @@ import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.components.toAnnotatedString
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.getStringRes
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.preferences.ClickPreference
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
@Composable @Composable
fun HomeRowSettings( fun HomeRowSettings(
title: String, title: String,
config: HomeRowConfig,
preferenceOptions: List<PreferenceGroup<HomeRowViewOptions>>, preferenceOptions: List<PreferenceGroup<HomeRowViewOptions>>,
viewOptions: HomeRowViewOptions, viewOptions: HomeRowViewOptions,
onViewOptionsChange: (HomeRowViewOptions) -> Unit, onViewOptionsChange: (HomeRowViewOptions) -> Unit,
onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
onApplyApplyAll: () -> Unit, onApplyApplyAll: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
@ -95,6 +123,135 @@ fun HomeRowSettings(
) )
} }
} }
addAdditionalConfig(
config = config,
onConfigChange = onConfigChange,
onConfigAction = onConfigAction,
)
}
}
}
private fun LazyListScope.addAdditionalConfig(
config: HomeRowConfig,
onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
) {
when (config) {
is HomeRowConfig.ContinueWatchingCombined -> {
item {
ClickPreference(
title = stringResource(R.string.split_into_separate_rows),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) },
)
}
}
is HomeRowConfig.ContinueWatching,
is HomeRowConfig.NextUp,
-> {
item {
ClickPreference(
title = stringResource(R.string.combine_continue_next),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) },
)
}
}
is HomeRowConfig.ByParent -> {
item {
val sortAndDirection =
remember(config) {
config.sort ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
)
}
var showDialog by remember { mutableStateOf(false) }
ListItem(
selected = false,
onClick = { showDialog = true },
headlineContent = {
PreferenceTitle(stringResource(R.string.sort))
},
supportingContent = {
Text(
text = sortAndDirection.toAnnotatedString(),
)
},
)
if (showDialog) {
BasicDialog(
onDismissRequest = { showDialog = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 5.dp,
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.START)
}
ByParentSortDialogContent(
current = sortAndDirection,
onClick = {
val newSort =
if (config.sort?.sort == it) {
config.sort.flip()
} else if (it == ItemSortBy.DEFAULT) {
null
} else {
SortAndDirection(it, SortOrder.ASCENDING)
}
onConfigChange.invoke(config.copy(sort = newSort))
showDialog = false
},
modifier = Modifier.width(320.dp),
)
}
}
}
}
else -> {}
}
}
@Composable
fun ByParentSortDialogContent(
current: SortAndDirection,
onClick: (ItemSortBy) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
contentPadding = PaddingValues(8.dp),
modifier = modifier,
) {
items(BoxSetSortOptions) { sort ->
ListItem(
selected = sort == current.sort,
onClick = { onClick.invoke(sort) },
leadingContent = {
Text(
fontFamily = FontAwesome,
text =
if (sort == current.sort) {
stringResource(
if (current.direction == SortOrder.ASCENDING) {
R.string.fa_caret_up
} else {
R.string.fa_caret_down
},
)
} else {
""
},
)
},
headlineContent = {
Text(
text = stringResource(getStringRes(sort)),
)
},
)
} }
} }
} }

View file

@ -232,6 +232,22 @@ fun HomeSettingsPage(
viewModel.updateViewOptionsForAll(row.config.viewOptions) viewModel.updateViewOptionsForAll(row.config.viewOptions)
}, },
modifier = destModifier, modifier = destModifier,
config = row.config,
onConfigChange = {
viewModel.onConfigChange(row, it)
},
onConfigAction = {
viewModel.onConfigAction(row, it)
when (it) {
HomeRowConfigAction.Combine,
HomeRowConfigAction.Split,
-> {
backStack.removeAt(
backStack.lastIndex,
)
}
}
},
) )
} }

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
@ -782,6 +783,101 @@ class HomeSettingsViewModel
} }
} }
} }
fun onConfigAction(
row: HomeRowConfigDisplay,
action: HomeRowConfigAction,
) {
viewModelScope.launchDefault {
when (action) {
HomeRowConfigAction.Combine -> {
val index =
state.value.rows
.indexOf(row)
.coerceAtLeast(0)
val rowsToRemove =
state.value.rows
.filter { it.config is ContinueWatching || it.config is NextUp }
updateState {
it.copy(
loading = LoadingState.Loading,
rows =
it.rows.toMutableList().apply {
set(
index,
HomeRowConfigDisplay(
id = it.rows[index].id,
title = ResStringProvider(R.string.combine_continue_next),
config = ContinueWatchingCombined(row.config.viewOptions),
),
)
removeAll(rowsToRemove)
},
)
}
fetchRowData()
}
HomeRowConfigAction.Split -> {
val index =
state.value.rows
.indexOf(row)
.coerceAtLeast(0)
updateState {
it.copy(
loading = LoadingState.Loading,
rows =
it.rows.toMutableList().apply {
set(
index,
HomeRowConfigDisplay(
id = it.rows[index].id,
title = ResStringProvider(R.string.continue_watching),
config = ContinueWatching(row.config.viewOptions),
),
)
add(
index + 1,
HomeRowConfigDisplay(
id = idCounter++,
title = ResStringProvider(R.string.next_up),
config = NextUp(row.config.viewOptions),
),
)
},
)
}
fetchRowData()
}
}
}
}
fun onConfigChange(
row: HomeRowConfigDisplay,
config: HomeRowConfig,
) {
viewModelScope.launchDefault {
val index =
state.value.rows
.indexOf(row)
.coerceAtLeast(0)
updateState {
it.copy(
loading = LoadingState.Loading,
rows =
it.rows.toMutableList().apply {
set(
index,
row.copy(config = config),
)
},
)
}
fetchRowData()
}
}
} }
data class HomePageSettingsState( data class HomePageSettingsState(

View file

@ -92,7 +92,7 @@ message PlaybackPreferences {
message HomePagePreferences{ message HomePagePreferences{
int32 max_items_per_row = 1; int32 max_items_per_row = 1;
bool enable_rewatching_next_up = 2; bool enable_rewatching_next_up = 2;
bool combine_continue_next = 3; bool combine_continue_next = 3 [deprecated = true];
int32 max_days_next_up = 4; int32 max_days_next_up = 4;
bool click_to_play = 5; bool click_to_play = 5;
} }

View file

@ -820,4 +820,6 @@
</array> </array>
<string name="stars">Stars</string> <string name="stars">Stars</string>
<string name="tomatoes">Tomatoes</string> <string name="tomatoes">Tomatoes</string>
<string name="sort">Sort</string>
<string name="split_into_separate_rows">Split into separate rows</string>
</resources> </resources>