mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
9daf679f7d
commit
219e4b01db
6 changed files with 424 additions and 8 deletions
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue