Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Expand All @@ -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),
}
Loading
Loading