Add a collection or playlist to home page (#915)

## Description
Adds a basic way to add a collection or playlist as a row on the home
page.

There's a simple search dialog to find the collection or playlist.

### Related issues
Related to #399 & #803 

### Testing
Emulator testing

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-02-19 19:18:39 -05:00 committed by GitHub
parent 9daf679f7d
commit 219e4b01db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 424 additions and 8 deletions

View file

@ -0,0 +1,248 @@
package com.github.damontecres.wholphin.ui.detail.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusGroup
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
import com.github.damontecres.wholphin.ui.main.SearchResult
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun SearchForContent(
searchType: BaseItemKind,
onClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName),
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val state by viewModel.state.collectAsState()
var query by rememberSaveable { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
LifecycleResumeEffect(Unit) {
onPauseOrDispose {
viewModel.voiceInputManager.stopListening()
}
}
fun triggerImmediateSearch(searchQuery: String) {
immediateSearchQuery = searchQuery
viewModel.search(searchType, searchQuery)
}
LaunchedEffect(query) {
when {
immediateSearchQuery == query -> {
immediateSearchQuery = null
}
else -> {
delay(750L)
viewModel.search(searchType, query)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(),
) {
var isSearchActive by remember { mutableStateOf(false) }
var isTextFieldFocused by remember { mutableStateOf(false) }
val textFieldFocusRequester = remember { FocusRequester() }
BackHandler(isTextFieldFocused) {
when {
isSearchActive -> {
isSearchActive = false
keyboardController?.hide()
}
else -> {
focusManager.moveFocus(FocusDirection.Next)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.focusGroup()
.focusRestorer(textFieldFocusRequester)
.focusRequester(searchFocusRequester),
) {
VoiceSearchButton(
onSpeechResult = { spokenText ->
query = spokenText
triggerImmediateSearch(spokenText)
},
voiceInputManager = viewModel.voiceInputManager,
)
SearchEditTextBox(
value = query,
onValueChange = {
isSearchActive = true
query = it
},
onSearchClick = { triggerImmediateSearch(query) },
readOnly = !isSearchActive,
modifier =
Modifier
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
isTextFieldFocused = state.isFocused
if (!state.isFocused) isSearchActive = false
}.onPreviewKeyEvent { event ->
val isActivationKey =
event.key in listOf(Key.DirectionCenter, Key.Enter)
if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) {
isSearchActive = true
keyboardController?.show()
true
} else {
false
}
},
)
}
}
when (val st = state.results) {
is SearchResult.Error -> {
ErrorMessage("Error", st.ex)
}
SearchResult.NoQuery -> {
// no-op
}
SearchResult.Searching -> {
Text(
text = stringResource(R.string.searching),
)
}
is SearchResult.SuccessSeerr -> {
Text(
text = "Not supported",
color = MaterialTheme.colorScheme.error,
)
}
is SearchResult.Success -> {
if (st.items.isEmpty()) {
Text(
text = stringResource(R.string.no_results),
)
} else {
val titleRes =
remember {
when (searchType) {
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.PLAYLIST -> R.string.playlists
else -> null
}
}
ItemRow(
title = titleRes?.let { stringResource(it) } ?: "",
items = st.items,
onClickItem = { _, item -> onClick.invoke(item) },
onLongClickItem = { _, _ -> },
modifier = Modifier.focusRequester(focusRequester),
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier = mod,
)
},
)
}
}
}
}
}
@Composable
fun SearchForDialog(
onDismissRequest: () -> Unit,
searchType: BaseItemKind,
onClick: (BaseItem) -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
SearchForContent(
searchType = searchType,
onClick = onClick,
modifier =
Modifier
.padding(8.dp)
.fillMaxWidth(.8f)
.fillMaxHeight(.66f),
)
}
}

View file

@ -0,0 +1,71 @@
package com.github.damontecres.wholphin.ui.detail.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.SearchResult
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class SearchForViewModel
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
val navigationManager: NavigationManager,
val voiceInputManager: VoiceInputManager,
) : ViewModel() {
val state = MutableStateFlow(SearchForState())
init {
state.value = SearchForState()
}
fun search(
searchType: BaseItemKind,
query: String,
) {
viewModelScope.launchIO {
if (state.value.query != query) {
if (query.isBlank()) {
state.update { SearchForState(query, SearchResult.NoQuery) }
return@launchIO
}
state.update { SearchForState(query, SearchResult.Searching) }
try {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
searchTerm = query,
includeItemTypes = listOf(searchType),
recursive = true,
fields = SlimItemFields,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
state.update { SearchForState(query, SearchResult.Success(pager)) }
} catch (ex: Exception) {
Timber.e(ex)
state.update { SearchForState(query, SearchResult.Error(ex)) }
}
}
}
}
}
data class SearchForState(
val query: String = "",
val results: SearchResult = SearchResult.NoQuery,
)

View file

@ -89,6 +89,24 @@ fun getSupportedRowTypes(library: Library): List<LibraryRowType> {
)
}
library.collectionType == CollectionType.BOXSETS -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
LibraryRowType.RECENTLY_RELEASED,
LibraryRowType.GENRES,
LibraryRowType.COLLECTION,
)
}
library.collectionType == CollectionType.PLAYLISTS -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
LibraryRowType.RECENTLY_RELEASED,
LibraryRowType.GENRES,
LibraryRowType.PLAYLIST,
)
}
else -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
@ -109,4 +127,6 @@ enum class LibraryRowType(
TV_CHANNELS(R.string.channels),
TV_PROGRAMS(R.string.live_tv),
RECENTLY_RECORDED(R.string.recently_recorded),
COLLECTION(R.string.collections),
PLAYLIST(R.string.playlist),
}

View file

@ -69,11 +69,17 @@ fun HomeSettingsAddRow(
TitleText(stringResource(R.string.more))
HorizontalDivider()
}
item {
itemsIndexed(
listOf(
MetaRowType.FAVORITES,
MetaRowType.COLLECTION,
MetaRowType.PLAYLIST,
),
) { index, type ->
HomeSettingsListItem(
selected = false,
headlineText = stringResource(MetaRowType.FAVORITES.stringId),
onClick = { onClickMeta.invoke(MetaRowType.FAVORITES) },
headlineText = stringResource(type.stringId),
onClick = { onClickMeta.invoke(type) },
modifier = Modifier,
)
}
@ -99,4 +105,6 @@ enum class MetaRowType(
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
FAVORITES(R.string.favorites),
DISCOVER(R.string.discover),
COLLECTION(R.string.collection),
PLAYLIST(R.string.playlist),
}

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType
@ -43,6 +44,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
val settingsWidth = 360.dp
@ -56,6 +58,7 @@ fun HomeSettingsPage(
val listState = rememberLazyListState()
val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
val state by viewModel.state.collectAsState()
var position by rememberPosition(0, 0)
@ -149,6 +152,14 @@ fun HomeSettingsPage(
MetaRowType.DISCOVER -> {
backStack.add(HomeSettingsDestination.ChooseDiscover)
}
MetaRowType.COLLECTION -> {
searchForDialog = BaseItemKind.BOX_SET
}
MetaRowType.PLAYLIST -> {
searchForDialog = BaseItemKind.PLAYLIST
}
}
},
modifier = destModifier,
@ -159,7 +170,19 @@ fun HomeSettingsPage(
HomeLibraryRowTypeList(
library = dest.library,
onClick = { type ->
addRow { viewModel.addRow(dest.library, type) }
when (type) {
LibraryRowType.COLLECTION -> {
searchForDialog = BaseItemKind.BOX_SET
}
LibraryRowType.PLAYLIST -> {
searchForDialog = BaseItemKind.PLAYLIST
}
else -> {
addRow { viewModel.addRow(dest.library, type) }
}
}
},
modifier = destModifier,
)
@ -298,6 +321,16 @@ fun HomeSettingsPage(
},
)
}
searchForDialog?.let { searchType ->
SearchForDialog(
onDismissRequest = { searchForDialog = null },
searchType = searchType,
onClick = {
searchForDialog = null
addRow { viewModel.addRow(searchType, it) }
},
)
}
}
data class ShowConfirm(

View file

@ -18,6 +18,8 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp
import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded
import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased
import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions
import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels
import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -230,8 +232,11 @@ class HomeSettingsViewModel
)
}
MetaRowType.FAVORITES -> {
throw IllegalArgumentException("Should use addRow(BaseItemKind) instead")
MetaRowType.FAVORITES,
MetaRowType.COLLECTION,
MetaRowType.PLAYLIST,
-> {
throw IllegalArgumentException("Should use a different addRow() instead")
}
MetaRowType.DISCOVER -> {
@ -305,7 +310,7 @@ class HomeSettingsViewModel
id = id,
title = title,
config =
HomeRowConfig.TvChannels(
TvChannels(
viewOptions = HomeRowViewOptions.liveTvDefault,
),
)
@ -316,7 +321,7 @@ class HomeSettingsViewModel
HomeRowConfigDisplay(
id = id,
title = title,
config = HomeRowConfig.TvPrograms(),
config = TvPrograms(),
)
}
@ -328,6 +333,12 @@ class HomeSettingsViewModel
config = RecentlyAdded(library.itemId),
)
}
LibraryRowType.COLLECTION,
LibraryRowType.PLAYLIST,
-> {
throw IllegalArgumentException("Use different addRow")
}
}
updateState {
it.copy(
@ -357,6 +368,31 @@ class HomeSettingsViewModel
fetchRowData()
}
fun addRow(
type: BaseItemKind,
parent: BaseItem,
) = viewModelScope.launchIO {
Timber.v("Adding %s row for %s", type, parent.id)
val id = idCounter++
val newRow =
HomeRowConfigDisplay(
id = id,
title = parent.name ?: "",
config =
HomeRowConfig.ByParent(
parentId = parent.id,
recursive = true,
),
)
updateState {
it.copy(
loading = LoadingState.Loading,
rows = it.rows.toMutableList().apply { add(newRow) },
)
}
fetchRowData()
}
fun updateViewOptions(
rowId: Int,
viewOptions: HomeRowViewOptions,