Use dependency injection and clean up code

This commit is contained in:
polson
2023-05-15 18:25:24 -05:00
committed by Niels van Velzen
parent 26fb739e39
commit 4104612977
10 changed files with 150 additions and 162 deletions

View File

@@ -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())
}
}

View File

@@ -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>() {

View File

@@ -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)
}

View File

@@ -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?) {

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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>
)

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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
}