Add search
This commit is contained in:
parent
2138a4979e
commit
2ed507a278
12 changed files with 346 additions and 10 deletions
|
@ -16,7 +16,8 @@
|
|||
<activity android:name=".PlayerActivity" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main" />
|
||||
android:label="@string/title_activity_main"
|
||||
android:windowSoftInputMode="adjustPan"/>
|
||||
<activity
|
||||
android:name=".SetupActivity"
|
||||
android:exported="true"
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
package dev.jdtech.jellyfin.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
|
@ -21,6 +20,32 @@ class MediaFragment : Fragment() {
|
|||
private lateinit var binding: FragmentMediaBinding
|
||||
private val viewModel: MediaViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.media_menu, menu)
|
||||
|
||||
val search = menu.findItem(R.id.action_search)
|
||||
val searchView = search.actionView as SearchView
|
||||
searchView.queryHint = "Search movies, shows, episodes..."
|
||||
|
||||
searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(p0: String?): Boolean {
|
||||
if (p0 != null) {
|
||||
navigateToSearchResultFragment(p0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(p0: String?): Boolean {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -66,4 +91,10 @@ class MediaFragment : Fragment() {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun navigateToSearchResultFragment(query: String) {
|
||||
findNavController().navigate(
|
||||
MediaFragmentDirections.actionNavigationMediaToSearchResultFragment(query)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package dev.jdtech.jellyfin.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.jdtech.jellyfin.R
|
||||
import dev.jdtech.jellyfin.adapters.FavoritesListAdapter
|
||||
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
|
||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||
import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding
|
||||
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SearchResultFragment : Fragment() {
|
||||
|
||||
private lateinit var binding: FragmentSearchResultBinding
|
||||
private val viewModel: SearchResultViewModel by viewModels()
|
||||
|
||||
private val args: SearchResultFragmentArgs by navArgs()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentSearchResultBinding.inflate(inflater, container, false)
|
||||
|
||||
val snackbar =
|
||||
Snackbar.make(
|
||||
binding.mainLayout,
|
||||
getString(R.string.error_loading_data),
|
||||
Snackbar.LENGTH_INDEFINITE
|
||||
)
|
||||
snackbar.setAction(getString(R.string.retry)) {
|
||||
viewModel.loadData(args.query)
|
||||
}
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
|
||||
ViewItemListAdapter.OnClickListener { item ->
|
||||
navigateToMediaInfoFragment(item)
|
||||
}, HomeEpisodeListAdapter.OnClickListener { item ->
|
||||
navigateToEpisodeBottomSheetFragment(item)
|
||||
})
|
||||
|
||||
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
|
||||
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
|
||||
})
|
||||
|
||||
viewModel.error.observe(viewLifecycleOwner, { error ->
|
||||
if (error) {
|
||||
snackbar.show()
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.sections.observe(viewLifecycleOwner, { sections ->
|
||||
if (sections.isEmpty()) {
|
||||
binding.noSearchResultsText.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.noSearchResultsText.visibility = View.GONE
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.loadData(args.query)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
||||
findNavController().navigate(
|
||||
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
|
||||
item.id,
|
||||
item.name,
|
||||
item.type ?: "Unknown"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
||||
findNavController().navigate(
|
||||
FavoriteFragmentDirections.actionFavoriteFragmentToEpisodeBottomSheetFragment(
|
||||
episode.id
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -14,6 +14,8 @@ interface JellyfinRepository {
|
|||
|
||||
suspend fun getFavoriteItems(): List<BaseItemDto>
|
||||
|
||||
suspend fun getSearchItems(searchQuery: String): List<BaseItemDto>
|
||||
|
||||
suspend fun getResumeItems(): List<BaseItemDto>
|
||||
|
||||
suspend fun getLatestMedia(parentId: UUID): List<BaseItemDto>
|
||||
|
|
|
@ -49,6 +49,19 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
|||
return items
|
||||
}
|
||||
|
||||
override suspend fun getSearchItems(searchQuery: String): List<BaseItemDto> {
|
||||
val items: List<BaseItemDto>
|
||||
withContext(Dispatchers.IO) {
|
||||
items = jellyfinApi.itemsApi.getItems(
|
||||
jellyfinApi.userId!!,
|
||||
searchTerm = searchQuery,
|
||||
includeItemTypes = listOf("Movie", "Series", "Episode"),
|
||||
recursive = true
|
||||
).content.items ?: listOf()
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
override suspend fun getResumeItems(): List<BaseItemDto> {
|
||||
val items: List<BaseItemDto>
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
package dev.jdtech.jellyfin.viewmodels
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SearchResultViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val jellyfinRepository: JellyfinRepository
|
||||
) : ViewModel() {
|
||||
private val _sections = MutableLiveData<List<FavoriteSection>>()
|
||||
val sections: LiveData<List<FavoriteSection>> = _sections
|
||||
|
||||
private val _finishedLoading = MutableLiveData<Boolean>()
|
||||
val finishedLoading: LiveData<Boolean> = _finishedLoading
|
||||
|
||||
private val _error = MutableLiveData<Boolean>()
|
||||
val error: LiveData<Boolean> = _error
|
||||
|
||||
fun loadData(query: String) {
|
||||
_error.value = false
|
||||
_finishedLoading.value = false
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val items = jellyfinRepository.getSearchItems(query)
|
||||
|
||||
if (items.isEmpty()) {
|
||||
_sections.value = listOf()
|
||||
_finishedLoading.value = true
|
||||
return@launch
|
||||
}
|
||||
|
||||
val tempSections = mutableListOf<FavoriteSection>()
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
FavoriteSection(
|
||||
UUID.randomUUID(),
|
||||
"Movies",
|
||||
items.filter { it.type == "Movie" }).let {
|
||||
if (it.items.isNotEmpty()) tempSections.add(
|
||||
it
|
||||
)
|
||||
}
|
||||
FavoriteSection(
|
||||
UUID.randomUUID(),
|
||||
"Shows",
|
||||
items.filter { it.type == "Series" }).let {
|
||||
if (it.items.isNotEmpty()) tempSections.add(
|
||||
it
|
||||
)
|
||||
}
|
||||
FavoriteSection(
|
||||
UUID.randomUUID(),
|
||||
"Episodes",
|
||||
items.filter { it.type == "Episode" }).let {
|
||||
if (it.items.isNotEmpty()) tempSections.add(
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_sections.value = tempSections
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
_error.value = true
|
||||
}
|
||||
_finishedLoading.value = true
|
||||
}
|
||||
}
|
||||
}
|
21
app/src/main/res/drawable/ic_search.xml
Normal file
21
app/src/main/res/drawable/ic_search.xml
Normal file
|
@ -0,0 +1,21 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:pathData="M11,11m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M21,21L16.65,16.65"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -32,7 +32,6 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipToPadding="false"
|
||||
android:layoutAnimation="@anim/overview_media_animation"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="12dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
|
|
63
app/src/main/res/layout/fragment_search_result.xml
Normal file
63
app/src/main/res/layout/fragment_search_result.xml
Normal file
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
<variable
|
||||
name="viewModel"
|
||||
type="dev.jdtech.jellyfin.viewmodels.SearchResultViewModel" />
|
||||
</data>
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:id="@+id/main_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".fragments.FavoriteFragment">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/loading_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:trackCornerRadius="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/no_search_results_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/no_search_results"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/search_results_recycler_view"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="16dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:favoriteSections="@{viewModel.sections}"
|
||||
tools:itemCount="4"
|
||||
tools:listitem="@layout/favorite_section" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
</layout>
|
11
app/src/main/res/menu/media_menu.xml
Normal file
11
app/src/main/res/menu/media_menu.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_search"
|
||||
android:icon="@drawable/ic_search"
|
||||
android:title="@string/search"
|
||||
app:actionViewClass="androidx.appcompat.widget.SearchView"
|
||||
app:showAsAction="ifRoom|collapseActionView" />
|
||||
</menu>
|
|
@ -48,12 +48,15 @@
|
|||
app:exitAnim="@anim/nav_default_exit_anim"
|
||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||
<action
|
||||
android:id="@+id/action_navigation_media_to_searchResultFragment"
|
||||
app:destination="@id/searchResultFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/navigation_settings"
|
||||
android:name="dev.jdtech.jellyfin.fragments.SettingsFragment"
|
||||
android:label="@string/title_settings"/>
|
||||
android:label="@string/title_settings" />
|
||||
<fragment
|
||||
android:id="@+id/libraryFragment"
|
||||
android:name="dev.jdtech.jellyfin.fragments.LibraryFragment"
|
||||
|
@ -114,9 +117,9 @@
|
|||
app:argType="java.util.UUID" />
|
||||
<argument
|
||||
android:name="seriesName"
|
||||
android:defaultValue="Series"
|
||||
app:argType="string"
|
||||
app:nullable="true"
|
||||
android:defaultValue="Series" />
|
||||
app:nullable="true" />
|
||||
<argument
|
||||
android:name="seasonName"
|
||||
android:defaultValue="Season"
|
||||
|
@ -142,7 +145,7 @@
|
|||
android:id="@+id/playerActivity"
|
||||
android:name="dev.jdtech.jellyfin.PlayerActivity"
|
||||
android:label="activity_player"
|
||||
tools:layout="@layout/activity_player" >
|
||||
tools:layout="@layout/activity_player">
|
||||
<argument
|
||||
android:name="itemId"
|
||||
app:argType="java.util.UUID" />
|
||||
|
@ -165,4 +168,19 @@
|
|||
android:id="@+id/action_favoriteFragment_to_mediaInfoFragment"
|
||||
app:destination="@id/mediaInfoFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/searchResultFragment"
|
||||
android:name="dev.jdtech.jellyfin.fragments.SearchResultFragment"
|
||||
android:label="{query}"
|
||||
tools:layout="@layout/fragment_search_result" >
|
||||
<action
|
||||
android:id="@+id/action_favoriteFragment_to_episodeBottomSheetFragment"
|
||||
app:destination="@id/episodeBottomSheetFragment" />
|
||||
<action
|
||||
android:id="@+id/action_favoriteFragment_to_mediaInfoFragment"
|
||||
app:destination="@id/mediaInfoFragment" />
|
||||
<argument
|
||||
android:name="query"
|
||||
app:argType="string" />
|
||||
</fragment>
|
||||
</navigation>
|
|
@ -40,7 +40,8 @@
|
|||
<string name="latest_library">Latest %1$s</string>
|
||||
<string name="series_poster">Series poster</string>
|
||||
<string name="no_favorites">You have no favorites</string>
|
||||
|
||||
<string name="search">Search</string>
|
||||
<string name="no_search_results">No search results</string>
|
||||
<string name="settings_category_language">Language</string>
|
||||
<string name="settings_preferred_audio_language">Preferred audio language</string>
|
||||
<string name="settings_preferred_subtitle_language">Preferred subtitle language</string>
|
||||
|
|
Loading…
Reference in a new issue