Skip to content

Commit 2a17a0f

Browse files
committed
Add a collection or playlist to home page
1 parent 8c2227b commit 2a17a0f

7 files changed

Lines changed: 434 additions & 10 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package com.github.damontecres.wholphin.ui.detail.search
2+
3+
import androidx.activity.compose.BackHandler
4+
import androidx.compose.foundation.focusGroup
5+
import androidx.compose.foundation.layout.Arrangement
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.Row
9+
import androidx.compose.foundation.layout.fillMaxHeight
10+
import androidx.compose.foundation.layout.fillMaxWidth
11+
import androidx.compose.foundation.layout.padding
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.LaunchedEffect
14+
import androidx.compose.runtime.collectAsState
15+
import androidx.compose.runtime.getValue
16+
import androidx.compose.runtime.mutableStateOf
17+
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.saveable.rememberSaveable
19+
import androidx.compose.runtime.setValue
20+
import androidx.compose.ui.Alignment
21+
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.focus.FocusDirection
23+
import androidx.compose.ui.focus.FocusRequester
24+
import androidx.compose.ui.focus.focusRequester
25+
import androidx.compose.ui.focus.focusRestorer
26+
import androidx.compose.ui.focus.onFocusChanged
27+
import androidx.compose.ui.input.key.Key
28+
import androidx.compose.ui.input.key.KeyEventType
29+
import androidx.compose.ui.input.key.key
30+
import androidx.compose.ui.input.key.onPreviewKeyEvent
31+
import androidx.compose.ui.input.key.type
32+
import androidx.compose.ui.platform.LocalFocusManager
33+
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
34+
import androidx.compose.ui.res.stringResource
35+
import androidx.compose.ui.unit.dp
36+
import androidx.compose.ui.window.DialogProperties
37+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
38+
import androidx.lifecycle.compose.LifecycleResumeEffect
39+
import androidx.tv.material3.MaterialTheme
40+
import androidx.tv.material3.Text
41+
import com.github.damontecres.wholphin.R
42+
import com.github.damontecres.wholphin.data.model.BaseItem
43+
import com.github.damontecres.wholphin.ui.Cards
44+
import com.github.damontecres.wholphin.ui.cards.ItemRow
45+
import com.github.damontecres.wholphin.ui.cards.SeasonCard
46+
import com.github.damontecres.wholphin.ui.components.BasicDialog
47+
import com.github.damontecres.wholphin.ui.components.ErrorMessage
48+
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
49+
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
50+
import com.github.damontecres.wholphin.ui.main.SearchResult
51+
import kotlinx.coroutines.delay
52+
import org.jellyfin.sdk.model.api.BaseItemKind
53+
54+
@Composable
55+
fun SearchForContent(
56+
searchType: BaseItemKind,
57+
onClick: (BaseItem) -> Unit,
58+
modifier: Modifier = Modifier,
59+
viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName),
60+
) {
61+
val focusManager = LocalFocusManager.current
62+
val keyboardController = LocalSoftwareKeyboardController.current
63+
val state by viewModel.state.collectAsState()
64+
65+
var query by rememberSaveable { mutableStateOf("") }
66+
val searchFocusRequester = remember { FocusRequester() }
67+
val focusRequester = remember { FocusRequester() }
68+
69+
var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
70+
71+
LifecycleResumeEffect(Unit) {
72+
onPauseOrDispose {
73+
viewModel.voiceInputManager.stopListening()
74+
}
75+
}
76+
77+
fun triggerImmediateSearch(searchQuery: String) {
78+
immediateSearchQuery = searchQuery
79+
viewModel.search(searchType, searchQuery)
80+
}
81+
82+
LaunchedEffect(query) {
83+
when {
84+
immediateSearchQuery == query -> {
85+
immediateSearchQuery = null
86+
}
87+
88+
else -> {
89+
delay(750L)
90+
viewModel.search(searchType, query)
91+
}
92+
}
93+
}
94+
Column(
95+
verticalArrangement = Arrangement.spacedBy(8.dp),
96+
modifier = modifier,
97+
) {
98+
Box(
99+
contentAlignment = Alignment.Center,
100+
modifier = Modifier.fillMaxWidth(),
101+
) {
102+
var isSearchActive by remember { mutableStateOf(false) }
103+
var isTextFieldFocused by remember { mutableStateOf(false) }
104+
val textFieldFocusRequester = remember { FocusRequester() }
105+
106+
BackHandler(isTextFieldFocused) {
107+
when {
108+
isSearchActive -> {
109+
isSearchActive = false
110+
keyboardController?.hide()
111+
}
112+
113+
else -> {
114+
focusManager.moveFocus(FocusDirection.Next)
115+
}
116+
}
117+
}
118+
119+
Row(
120+
horizontalArrangement = Arrangement.spacedBy(12.dp),
121+
verticalAlignment = Alignment.CenterVertically,
122+
modifier =
123+
Modifier
124+
.focusGroup()
125+
.focusRestorer(textFieldFocusRequester)
126+
.focusRequester(searchFocusRequester),
127+
) {
128+
VoiceSearchButton(
129+
onSpeechResult = { spokenText ->
130+
query = spokenText
131+
triggerImmediateSearch(spokenText)
132+
},
133+
voiceInputManager = viewModel.voiceInputManager,
134+
)
135+
136+
SearchEditTextBox(
137+
value = query,
138+
onValueChange = {
139+
isSearchActive = true
140+
query = it
141+
},
142+
onSearchClick = { triggerImmediateSearch(query) },
143+
readOnly = !isSearchActive,
144+
modifier =
145+
Modifier
146+
.focusRequester(textFieldFocusRequester)
147+
.onFocusChanged { state ->
148+
isTextFieldFocused = state.isFocused
149+
if (!state.isFocused) isSearchActive = false
150+
}.onPreviewKeyEvent { event ->
151+
val isActivationKey =
152+
event.key in listOf(Key.DirectionCenter, Key.Enter)
153+
if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) {
154+
isSearchActive = true
155+
keyboardController?.show()
156+
true
157+
} else {
158+
false
159+
}
160+
},
161+
)
162+
}
163+
}
164+
165+
when (val st = state.results) {
166+
is SearchResult.Error -> {
167+
ErrorMessage("Error", st.ex)
168+
}
169+
170+
SearchResult.NoQuery -> {
171+
// no-op
172+
}
173+
174+
SearchResult.Searching -> {
175+
Text(
176+
text = stringResource(R.string.searching),
177+
)
178+
}
179+
180+
is SearchResult.SuccessSeerr -> {
181+
Text(
182+
text = "Not supported",
183+
color = MaterialTheme.colorScheme.error,
184+
)
185+
}
186+
187+
is SearchResult.Success -> {
188+
if (st.items.isEmpty()) {
189+
Text(
190+
text = stringResource(R.string.no_results),
191+
)
192+
} else {
193+
val titleRes =
194+
remember {
195+
when (searchType) {
196+
BaseItemKind.BOX_SET -> R.string.collections
197+
BaseItemKind.PLAYLIST -> R.string.playlists
198+
else -> null
199+
}
200+
}
201+
ItemRow(
202+
title = titleRes?.let { stringResource(it) } ?: "",
203+
items = st.items,
204+
onClickItem = { _, item -> onClick.invoke(item) },
205+
onLongClickItem = { _, _ -> },
206+
modifier = Modifier.focusRequester(focusRequester),
207+
cardContent = { index, item, mod, onClick, onLongClick ->
208+
SeasonCard(
209+
item = item,
210+
onClick = {
211+
onClick.invoke()
212+
},
213+
onLongClick = onLongClick,
214+
imageHeight = Cards.height2x3,
215+
modifier = mod,
216+
)
217+
},
218+
)
219+
}
220+
}
221+
}
222+
}
223+
}
224+
225+
@Composable
226+
fun SearchForDialog(
227+
onDismissRequest: () -> Unit,
228+
searchType: BaseItemKind,
229+
onClick: (BaseItem) -> Unit,
230+
) {
231+
BasicDialog(
232+
onDismissRequest = onDismissRequest,
233+
properties =
234+
DialogProperties(
235+
usePlatformDefaultWidth = false,
236+
),
237+
) {
238+
SearchForContent(
239+
searchType = searchType,
240+
onClick = onClick,
241+
modifier =
242+
Modifier
243+
.padding(8.dp)
244+
.fillMaxWidth(.8f)
245+
.fillMaxHeight(.66f),
246+
)
247+
}
248+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.github.damontecres.wholphin.ui.detail.search
2+
3+
import androidx.lifecycle.ViewModel
4+
import androidx.lifecycle.viewModelScope
5+
import com.github.damontecres.wholphin.data.ServerRepository
6+
import com.github.damontecres.wholphin.services.NavigationManager
7+
import com.github.damontecres.wholphin.ui.SlimItemFields
8+
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
9+
import com.github.damontecres.wholphin.ui.launchIO
10+
import com.github.damontecres.wholphin.ui.main.SearchResult
11+
import com.github.damontecres.wholphin.util.ApiRequestPager
12+
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
13+
import dagger.hilt.android.lifecycle.HiltViewModel
14+
import kotlinx.coroutines.flow.MutableStateFlow
15+
import kotlinx.coroutines.flow.update
16+
import org.jellyfin.sdk.api.client.ApiClient
17+
import org.jellyfin.sdk.model.api.BaseItemKind
18+
import org.jellyfin.sdk.model.api.request.GetItemsRequest
19+
import timber.log.Timber
20+
import javax.inject.Inject
21+
22+
@HiltViewModel
23+
class SearchForViewModel
24+
@Inject
25+
constructor(
26+
private val api: ApiClient,
27+
private val serverRepository: ServerRepository,
28+
val navigationManager: NavigationManager,
29+
val voiceInputManager: VoiceInputManager,
30+
) : ViewModel() {
31+
val state = MutableStateFlow(SearchForState())
32+
33+
init {
34+
state.value = SearchForState()
35+
}
36+
37+
fun search(
38+
searchType: BaseItemKind,
39+
query: String,
40+
) {
41+
viewModelScope.launchIO {
42+
if (state.value.query != query) {
43+
if (query.isBlank()) {
44+
state.update { SearchForState(query, SearchResult.NoQuery) }
45+
return@launchIO
46+
}
47+
state.update { SearchForState(query, SearchResult.Searching) }
48+
try {
49+
val request =
50+
GetItemsRequest(
51+
userId = serverRepository.currentUser.value?.id,
52+
searchTerm = query,
53+
includeItemTypes = listOf(searchType),
54+
recursive = true,
55+
fields = SlimItemFields,
56+
)
57+
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
58+
state.update { SearchForState(query, SearchResult.Success(pager)) }
59+
} catch (ex: Exception) {
60+
Timber.e(ex)
61+
state.update { SearchForState(query, SearchResult.Error(ex)) }
62+
}
63+
}
64+
}
65+
}
66+
}
67+
68+
data class SearchForState(
69+
val query: String = "",
70+
val results: SearchResult = SearchResult.NoQuery,
71+
)

app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ fun getSupportedRowTypes(library: Library): List<LibraryRowType> {
8989
)
9090
}
9191

92+
library.collectionType == CollectionType.BOXSETS -> {
93+
listOf(
94+
LibraryRowType.RECENTLY_ADDED,
95+
LibraryRowType.RECENTLY_RELEASED,
96+
LibraryRowType.GENRES,
97+
LibraryRowType.COLLECTION,
98+
)
99+
}
100+
101+
library.collectionType == CollectionType.PLAYLISTS -> {
102+
listOf(
103+
LibraryRowType.RECENTLY_ADDED,
104+
LibraryRowType.RECENTLY_RELEASED,
105+
LibraryRowType.GENRES,
106+
LibraryRowType.PLAYLIST,
107+
)
108+
}
109+
92110
else -> {
93111
listOf(
94112
LibraryRowType.RECENTLY_ADDED,
@@ -109,4 +127,6 @@ enum class LibraryRowType(
109127
TV_CHANNELS(R.string.channels),
110128
TV_PROGRAMS(R.string.live_tv),
111129
RECENTLY_RECORDED(R.string.recently_recorded),
130+
COLLECTION(R.string.collections),
131+
PLAYLIST(R.string.playlist),
112132
}

app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,17 @@ fun HomeSettingsAddRow(
6969
TitleText(stringResource(R.string.more))
7070
HorizontalDivider()
7171
}
72-
item {
72+
itemsIndexed(
73+
listOf(
74+
MetaRowType.FAVORITES,
75+
MetaRowType.COLLECTION,
76+
MetaRowType.PLAYLIST,
77+
),
78+
) { index, type ->
7379
HomeSettingsListItem(
7480
selected = false,
75-
headlineText = stringResource(MetaRowType.FAVORITES.stringId),
76-
onClick = { onClickMeta.invoke(MetaRowType.FAVORITES) },
81+
headlineText = stringResource(type.stringId),
82+
onClick = { onClickMeta.invoke(type) },
7783
modifier = Modifier,
7884
)
7985
}
@@ -99,4 +105,6 @@ enum class MetaRowType(
99105
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
100106
FAVORITES(R.string.favorites),
101107
DISCOVER(R.string.discover),
108+
COLLECTION(R.string.collection),
109+
PLAYLIST(R.string.playlist),
102110
}

0 commit comments

Comments
 (0)