Use dependency injection and clean up code
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
@@ -22,6 +23,10 @@ import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
|
||||
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepository
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.search.SearchViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.StartupViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
|
||||
@@ -95,6 +100,7 @@ val appModule = module {
|
||||
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
|
||||
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
|
||||
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
|
||||
single<SearchRepository> { SearchRepositoryImpl(get()) }
|
||||
|
||||
viewModel { StartupViewModel(get(), get(), get(), get()) }
|
||||
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
|
||||
@@ -102,8 +108,13 @@ val appModule = module {
|
||||
viewModel { NextUpViewModel(get(), get(), get(), get()) }
|
||||
viewModel { PictureViewerViewModel(get()) }
|
||||
viewModel { ScreensaverViewModel(get()) }
|
||||
viewModel { SearchViewModel(get()) }
|
||||
|
||||
single { BackgroundService(get(), get(), get(), get()) }
|
||||
|
||||
single { MarkdownRenderer(get()) }
|
||||
|
||||
factory { (context: Context) ->
|
||||
SearchFragmentDelegate(context, get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,7 +735,8 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
retrieve(mTrailersQuery);
|
||||
break;
|
||||
case Search:
|
||||
loadSearchItems();
|
||||
loadStaticItems();
|
||||
addToParentIfResultsReceived();
|
||||
break;
|
||||
case Artists:
|
||||
retrieve(mArtistsQuery);
|
||||
@@ -812,11 +813,6 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
notifyRetrieveFinished();
|
||||
}
|
||||
|
||||
private void loadSearchItems() {
|
||||
loadStaticItems();
|
||||
addToParentIfResultsReceived();
|
||||
}
|
||||
|
||||
private void retrieveViews() {
|
||||
final UserDto user = KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue();
|
||||
apiClient.getValue().GetUserViews(user.getId().toString(), new Response<ItemsResult>() {
|
||||
|
||||
@@ -2,19 +2,24 @@ package org.jellyfin.androidtv.ui.search
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.leanback.app.SearchSupportFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
class LeanbackSearchFragment :
|
||||
SearchSupportFragment(),
|
||||
class LeanbackSearchFragment : SearchSupportFragment(),
|
||||
SearchSupportFragment.SearchResultProvider {
|
||||
|
||||
private val viewModel by viewModels<SearchViewModel>()
|
||||
private val viewModel: SearchViewModel by viewModel()
|
||||
|
||||
private val searchFragmentDelegate = SearchFragmentDelegate(this)
|
||||
private val searchFragmentDelegate: SearchFragmentDelegate by inject {
|
||||
parametersOf(
|
||||
requireContext()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -29,14 +34,9 @@ class LeanbackSearchFragment :
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
observeSearchResults()
|
||||
}
|
||||
|
||||
private fun observeSearchResults() {
|
||||
viewModel.searchResultsFlow
|
||||
.onEach {
|
||||
searchFragmentDelegate.showResults(it)
|
||||
}.launchIn(lifecycleScope)
|
||||
.onEach { searchFragmentDelegate.showResults(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun getResultsAdapter() = searchFragmentDelegate.rowsAdapter
|
||||
@@ -45,6 +45,6 @@ class LeanbackSearchFragment :
|
||||
viewModel.searchDebounced(query)
|
||||
|
||||
override fun onQueryTextSubmit(query: String): Boolean =
|
||||
viewModel.search(query)
|
||||
viewModel.searchImmediately(query)
|
||||
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import org.jellyfin.androidtv.R
|
||||
class SearchFragment : Fragment(R.layout.fragment_content_view) {
|
||||
private val isSpeechEnabled by lazy {
|
||||
SpeechRecognizer.isRecognitionAvailable(requireContext())
|
||||
&& ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_DENIED
|
||||
&& ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) != PackageManager.PERMISSION_DENIED
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.jellyfin.androidtv.ui.search
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.content.Context
|
||||
import androidx.leanback.widget.HeaderItem
|
||||
import androidx.leanback.widget.ListRow
|
||||
import androidx.leanback.widget.OnItemViewClickedListener
|
||||
@@ -14,41 +14,42 @@ import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.CustomListRowPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class SearchFragmentDelegate(
|
||||
private val fragment: Fragment
|
||||
) : KoinComponent {
|
||||
|
||||
private val backgroundService: BackgroundService by inject()
|
||||
|
||||
private val context: Context,
|
||||
private val backgroundService: BackgroundService
|
||||
) {
|
||||
val rowsAdapter = MutableObjectAdapter<Row>(CustomListRowPresenter())
|
||||
|
||||
fun showResults(searchResultGroups: List<SearchResultGroup>) {
|
||||
rowsAdapter.clear()
|
||||
val adapters = mutableListOf<ItemRowAdapter>()
|
||||
for ((labelRes, baseItems) in searchResultGroups) {
|
||||
val adapter = ItemRowAdapter(fragment.requireContext(), baseItems, CardPresenter(), rowsAdapter, QueryType.Search).apply {
|
||||
setRow(ListRow(HeaderItem(fragment.requireContext().getString(labelRes)), this))
|
||||
val adapter = ItemRowAdapter(
|
||||
context,
|
||||
baseItems.toList(),
|
||||
CardPresenter(),
|
||||
rowsAdapter,
|
||||
QueryType.Search
|
||||
).apply {
|
||||
setRow(ListRow(HeaderItem(context.getString(labelRes)), this))
|
||||
}
|
||||
adapters.add(adapter)
|
||||
}
|
||||
for (adapter in adapters) adapter.Retrieve()
|
||||
}
|
||||
|
||||
val onItemViewClickedListener =
|
||||
OnItemViewClickedListener { _, item, _, row ->
|
||||
if (item !is BaseRowItem) return@OnItemViewClickedListener
|
||||
row as ListRow
|
||||
val adapter = row.adapter as ItemRowAdapter
|
||||
ItemLauncher.launch(item as BaseRowItem?, adapter, item.index, fragment.requireActivity())
|
||||
}
|
||||
val onItemViewClickedListener = OnItemViewClickedListener { _, item, _, row ->
|
||||
if (item !is BaseRowItem) return@OnItemViewClickedListener
|
||||
row as ListRow
|
||||
val adapter = row.adapter as ItemRowAdapter
|
||||
ItemLauncher.launch(item as BaseRowItem?, adapter, item.index, context)
|
||||
}
|
||||
|
||||
val onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
|
||||
val searchHint = item?.let { (item as BaseRowItem).searchHint }
|
||||
if (searchHint != null) {
|
||||
backgroundService.setBackground(searchHint)
|
||||
val baseItem = item?.let { (item as BaseRowItem).baseItem }
|
||||
if (baseItem != null) {
|
||||
backgroundService.setBackground(baseItem)
|
||||
} else {
|
||||
backgroundService.clearBackgrounds()
|
||||
}
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
package org.jellyfin.androidtv.ui.search
|
||||
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.util.apiclient.AsyncState
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
object SearchRepository: KoinComponent {
|
||||
|
||||
private const val QUERY_LIMIT = 25
|
||||
|
||||
private val apiClient: ApiClient by inject()
|
||||
import timber.log.Timber
|
||||
|
||||
interface SearchRepository {
|
||||
suspend fun search(
|
||||
searchTerm: String,
|
||||
itemTypes: Collection<BaseItemKind>
|
||||
) = flow {
|
||||
emit(AsyncState.Loading)
|
||||
val searchHints = apiClient.itemsApi.getItemsByUserId(
|
||||
searchTerm = searchTerm,
|
||||
limit = QUERY_LIMIT,
|
||||
imageTypeLimit = 1,
|
||||
includeItemTypes = itemTypes,
|
||||
fields = listOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CAN_DELETE,
|
||||
ItemFields.BASIC_SYNC_INFO,
|
||||
ItemFields.MEDIA_SOURCE_COUNT
|
||||
),
|
||||
recursive = true,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
emit(AsyncState.Success(searchHints))
|
||||
}.catch {
|
||||
emit(AsyncState.Error(it))
|
||||
): Result<List<BaseItemDto>?>
|
||||
}
|
||||
|
||||
class SearchRepositoryImpl(
|
||||
private val apiClient: ApiClient
|
||||
) : SearchRepository {
|
||||
companion object {
|
||||
private const val QUERY_LIMIT = 25
|
||||
}
|
||||
|
||||
override suspend fun search(
|
||||
searchTerm: String,
|
||||
itemTypes: Collection<BaseItemKind>
|
||||
): Result<List<BaseItemDto>?> {
|
||||
return try {
|
||||
val result = apiClient.itemsApi.getItemsByUserId(
|
||||
searchTerm = searchTerm,
|
||||
limit = QUERY_LIMIT,
|
||||
imageTypeLimit = 1,
|
||||
includeItemTypes = itemTypes,
|
||||
fields = listOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CAN_DELETE,
|
||||
ItemFields.BASIC_SYNC_INFO,
|
||||
ItemFields.MEDIA_SOURCE_COUNT
|
||||
),
|
||||
recursive = true,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
Result.success(result.content.items)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.w("Failed to search for items: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
||||
data class SearchResultGroup(
|
||||
@StringRes val labelRes: Int,
|
||||
val items: List<BaseItemDto>
|
||||
val items: Collection<BaseItemDto>
|
||||
)
|
||||
|
||||
@@ -10,47 +10,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.util.apiclient.waitForSuccess
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
private var previousQuery: String? = null
|
||||
|
||||
private val _searchResultsFlow = MutableStateFlow<List<SearchResultGroup>>(emptyList())
|
||||
val searchResultsFlow = _searchResultsFlow.asStateFlow()
|
||||
|
||||
fun search(query: String) = searchDebounced(query, 0.milliseconds)
|
||||
|
||||
fun searchDebounced(query: String, debounceMs: Duration = debounceDuration): Boolean {
|
||||
if (query == previousQuery) return false
|
||||
previousQuery = query
|
||||
|
||||
if (query.isBlank()) {
|
||||
_searchResultsFlow.value = emptyList()
|
||||
return true
|
||||
}
|
||||
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(debounceMs)
|
||||
val deferredResults = groups.map { (stringRes, itemKinds) ->
|
||||
async {
|
||||
val result = SearchRepository.search(query, itemKinds).waitForSuccess()
|
||||
val items = result?.content?.items ?: emptyList()
|
||||
SearchResultGroup(stringRes, items)
|
||||
}
|
||||
}
|
||||
val results = deferredResults.awaitAll()
|
||||
_searchResultsFlow.value = results
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class SearchViewModel(
|
||||
private val searchRepository: SearchRepository
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private val debounceDuration = 600.milliseconds
|
||||
|
||||
@@ -67,4 +33,38 @@ class SearchViewModel : ViewModel() {
|
||||
R.string.lbl_songs to setOf(BaseItemKind.AUDIO),
|
||||
)
|
||||
}
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
private var previousQuery: String? = null
|
||||
|
||||
private val _searchResultsFlow = MutableStateFlow<List<SearchResultGroup>>(emptyList())
|
||||
val searchResultsFlow = _searchResultsFlow.asStateFlow()
|
||||
|
||||
fun searchImmediately(query: String) = searchDebounced(query, 0.milliseconds)
|
||||
|
||||
fun searchDebounced(query: String, debounceMs: Duration = debounceDuration): Boolean {
|
||||
if (query == previousQuery) return false
|
||||
previousQuery = query
|
||||
|
||||
if (query.isBlank()) {
|
||||
_searchResultsFlow.value = emptyList()
|
||||
return true
|
||||
}
|
||||
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(debounceMs)
|
||||
val deferredResults = groups.map { (stringRes, itemKinds) ->
|
||||
async {
|
||||
val result = searchRepository.search(query, itemKinds)
|
||||
val items = result.getOrNull() ?: emptyList()
|
||||
SearchResultGroup(stringRes, items)
|
||||
}
|
||||
}
|
||||
val results = deferredResults.awaitAll()
|
||||
_searchResultsFlow.value = results
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,60 +9,52 @@ import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.leanback.app.RowsSupportFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jellyfin.androidtv.databinding.FragmentSearchTextBinding
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
class TextSearchFragment : Fragment() {
|
||||
|
||||
private val viewModel by viewModels<SearchViewModel>()
|
||||
private val viewModel: SearchViewModel by viewModel()
|
||||
|
||||
private var _binding: FragmentSearchTextBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val searchFragmentDelegate = SearchFragmentDelegate(this)
|
||||
private val searchFragmentDelegate: SearchFragmentDelegate by inject {
|
||||
parametersOf(
|
||||
requireContext()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSearchTextBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupSearchBar()
|
||||
setupResultFragment()
|
||||
observeSearchResults()
|
||||
}
|
||||
|
||||
private fun setupSearchBar() {
|
||||
binding.searchBar.apply {
|
||||
onTextChanged {
|
||||
viewModel.searchDebounced(it)
|
||||
}
|
||||
onSubmit {
|
||||
viewModel.search(it)
|
||||
}
|
||||
onTextChanged { viewModel.searchDebounced(it) }
|
||||
onSubmit { viewModel.searchImmediately(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupResultFragment() {
|
||||
// Set up result fragment
|
||||
val rowsSupportFragment = binding.resultsFrame.getFragment<RowsSupportFragment?>()
|
||||
rowsSupportFragment?.let {
|
||||
it.adapter = searchFragmentDelegate.rowsAdapter
|
||||
it.onItemViewClickedListener = searchFragmentDelegate.onItemViewClickedListener
|
||||
it.onItemViewSelectedListener = searchFragmentDelegate.onItemViewSelectedListener
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSearchResults() {
|
||||
viewModel.searchResultsFlow
|
||||
.onEach {
|
||||
searchFragmentDelegate.showResults(it)
|
||||
}.launchIn(lifecycleScope)
|
||||
.onEach { searchFragmentDelegate.showResults(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -83,10 +75,13 @@ class TextSearchFragment : Fragment() {
|
||||
|
||||
private fun EditText.onTextChanged(onTextChanged: (String) -> Unit) {
|
||||
val textWatcher = object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
onTextChanged(s.toString())
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) =
|
||||
Unit
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
}
|
||||
addTextChangedListener(textWatcher)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.jellyfin.androidtv.util.apiclient
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
/**
|
||||
* A sealed class representing the state of an asynchronous operation.
|
||||
*/
|
||||
sealed class AsyncState<out T> {
|
||||
object Loading : AsyncState<Nothing>()
|
||||
data class Success<out T>(val data: T) : AsyncState<T>()
|
||||
data class Error(val throwable: Throwable) : AsyncState<Nothing>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first [AsyncState.Success] value emitted by the flow, or null if the flow was not
|
||||
* successful. Useful for quickly getting the results without having to handle the
|
||||
* Loading and Error states.
|
||||
*/
|
||||
suspend inline fun <reified T> Flow<AsyncState<T>>.waitForSuccess(): T? {
|
||||
return filterIsInstance<AsyncState.Success<T>>()
|
||||
.firstOrNull()?.data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user