add search for tv shows

media items are currently not selectable, the app will crash
This commit is contained in:
Jannik 2021-12-27 22:50:29 +01:00
parent 63ce910ec5
commit 4fd6f9ca7e
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
8 changed files with 112 additions and 78 deletions

View File

@ -134,18 +134,20 @@ object Crunchyroll {
return browseResult return browseResult
} }
// // TODO locale de-DE, type /**
suspend fun search(query: String, n: Int = 10) { * TODO
*/
suspend fun search(query: String, n: Int = 10): SearchResult {
val searchEndpoint = "/content/v1/search" val searchEndpoint = "/content/v1/search"
val parameters = listOf("q" to query, "n" to n) val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series")
val result = request(searchEndpoint, parameters) val result = request(searchEndpoint, parameters)
println("${result.component1()?.obj()?.get("total")}") // TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
// to work around this, for now only tv shows are supported
val test = json.decodeFromString<BrowseResult>(result.component1()?.obj()?.toString()!!) return result.component1()?.obj()?.let {
println(test.items.size) json.decodeFromString(it.toString())
} ?: NoneSearchResult
// TODO return
} }
/** /**

View File

@ -13,9 +13,29 @@ enum class SortBy(val str: String) {
POPULARITY("popularity") POPULARITY("popularity")
} }
/**
* Search data type
*/
@Serializable
data class SearchResult(
@SerialName("total") val total: Int,
@SerialName("items") val items: List<SearchCollection>
)
@Serializable
data class SearchCollection(
@SerialName("type") val type: String,
@SerialName("items") val items: List<Item>
)
val NoneSearchResult = SearchResult(0, emptyList())
@Serializable @Serializable
data class BrowseResult(val total: Int, val items: List<Item>) data class BrowseResult(val total: Int, val items: List<Item>)
// the data class Item is used in browse and search
@Serializable @Serializable
data class Item( data class Item(
val id: String, val id: String,
@ -38,7 +58,7 @@ data class Images(val poster_tall: List<List<Poster>>, val poster_wide: List<Lis
data class Poster(val height: Int, val width: Int, val source: String, val type: String) data class Poster(val height: Int, val width: Int, val source: String, val type: String)
/** /**
* Series return type * Series data type
*/ */
@Serializable @Serializable
data class Series( data class Series(

View File

@ -135,10 +135,10 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
private fun load() { private fun load() {
val time = measureTimeMillis { val time = measureTimeMillis {
// start the initial loading // start the initial loading
val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) // val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope"))
.async { // .async {
launch { MetaDBController.list() } // launch { MetaDBController.list() }
} // }
// load all saved stuff here // load all saved stuff here
Preferences.load(this) Preferences.load(this)
@ -153,8 +153,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
runBlocking { Crunchyroll.index() } runBlocking { Crunchyroll.index() }
} }
// if (EncryptedPreferences.password.isEmpty()) { // if (EncryptedPreferences.password.isEmpty()) {
// showOnboarding() // showOnboarding()
// } else { // } else {
@ -174,7 +172,7 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
// } // }
// } // }
runBlocking { loadingJob.await() } // wait for initial loading to finish // runBlocking { loadingJob.await() } // wait for initial loading to finish
} }
Log.i(javaClass.name, "loading and login in $time ms") Log.i(javaClass.name, "loading and login in $time ms")

View File

@ -147,8 +147,8 @@ class HomeFragment : Fragment() {
* * only update actual change and not all data (performance) * * only update actual change and not all data (performance)
*/ */
fun updateMyListMedia() { fun updateMyListMedia() {
adapterMyList.updateMediaList(mapMyListToItemMedia()) //adapterMyList.updateMediaList(mapMyListToItemMedia())
adapterMyList.notifyDataSetChanged() //adapterMyList.notifyDataSetChanged()
} }
private fun mapMyListToItemMedia(): List<ItemMedia> { private fun mapMyListToItemMedia(): List<ItemMedia> {

View File

@ -45,7 +45,7 @@ class LibraryFragment : Fragment() {
adapter = MediaItemAdapter(itemList) adapter = MediaItemAdapter(itemList)
adapter.onItemClick = { mediaIdStr, _ -> adapter.onItemClick = { mediaIdStr, _ ->
activity?.showFragment(MediaFragment(mediaIdStr = mediaIdStr)) activity?.showFragment(MediaFragment(mediaIdStr))
} }
binding.recyclerMediaLibrary.adapter = adapter binding.recyclerMediaLibrary.adapter = adapter
@ -78,9 +78,7 @@ class LibraryFragment : Fragment() {
}) })
nextItemIndex += pageSize nextItemIndex += pageSize
adapter.updateMediaList(itemList)
adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize) adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize)
isLoading = false isLoading = false
} }
} }

View File

@ -7,16 +7,24 @@ import android.view.ViewGroup
import android.widget.SearchView import android.widget.SearchView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.mosad.teapod.databinding.FragmentSearchBinding import org.mosad.teapod.databinding.FragmentSearchBinding
import org.mosad.teapod.util.decoration.MediaItemDecoration import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.util.ItemMedia
import org.mosad.teapod.util.adapter.MediaItemAdapter import org.mosad.teapod.util.adapter.MediaItemAdapter
import org.mosad.teapod.util.decoration.MediaItemDecoration
import org.mosad.teapod.util.showFragment import org.mosad.teapod.util.showFragment
class SearchFragment : Fragment() { class SearchFragment : Fragment() {
private lateinit var binding: FragmentSearchBinding private lateinit var binding: FragmentSearchBinding
private var adapter : MediaItemAdapter? = null private lateinit var adapter: MediaItemAdapter
private val itemList = arrayListOf<ItemMedia>()
private var searchJob: Job? = null
private var oldSearchQuery = ""
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentSearchBinding.inflate(inflater, container, false) binding = FragmentSearchBinding.inflate(inflater, container, false)
@ -29,10 +37,10 @@ class SearchFragment : Fragment() {
lifecycleScope.launch { lifecycleScope.launch {
// create and set the adapter, needs context // create and set the adapter, needs context
context?.let { context?.let {
adapter = MediaItemAdapter(emptyList()) // TODO adapter = MediaItemAdapter(itemList)
adapter!!.onItemClick = { mediaId, _ -> adapter.onItemClick = { mediaIdStr, _ ->
binding.searchText.clearFocus() binding.searchText.clearFocus()
activity?.showFragment(MediaFragment("")) //(mediaId)) activity?.showFragment(MediaFragment(mediaIdStr))
} }
binding.recyclerMediaSearch.adapter = adapter binding.recyclerMediaSearch.adapter = adapter
@ -46,16 +54,65 @@ class SearchFragment : Fragment() {
private fun initActions() { private fun initActions() {
binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener { binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
adapter?.filter?.filter(query) query?.let { search(it) }
adapter?.notifyDataSetChanged()
return false return false
} }
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
adapter?.filter?.filter(newText) newText?.let { search(it) }
adapter?.notifyDataSetChanged()
return false return false
} }
}) })
} }
private fun search(query: String) {
// if the query hasn't changed since the last successful search, return
if (query == oldSearchQuery) return
// cancel search job if one is already running
if (searchJob?.isActive == true) searchJob?.cancel()
searchJob = lifecycleScope.async {
// TODO maybe wait a few ms (500ms?) before searching, if the user inputs any other chars
val results = Crunchyroll.search(query, 50)
itemList.clear() // TODO needs clean up
// TODO add top results first heading
itemList.addAll(results.items[0].items.map { item ->
ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source)
})
// TODO currently only tv shows are supported, hence only the first items array
// should be always present
// // TODO add tv shows heading
// if (results.items.size >= 2) {
// itemList.addAll(results.items[1].items.map { item ->
// ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source)
// })
// }
//
// // TODO add movies heading
// if (results.items.size >= 3) {
// itemList.addAll(results.items[2].items.map { item ->
// ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source)
// })
// }
//
// // TODO add episodes heading
// if (results.items.size >= 4) {
// itemList.addAll(results.items[3].items.map { item ->
// ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source)
// })
// }
adapter.notifyDataSetChanged()
//adapter.notifyItemRangeInserted(0, itemList.size)
// after successfully searching the query term, add it as old query, to make sure we
// don't search again if the query hasn't changed
oldSearchQuery = query
}
}
} }

View File

@ -41,6 +41,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
println("loading crunchyroll media $crunchyId") println("loading crunchyroll media $crunchyId")
// TODO info also in browse result item // TODO info also in browse result item
// TODO doesn't support search
mediaCrunchy = Crunchyroll.browsingCache.find { it -> mediaCrunchy = Crunchyroll.browsingCache.find { it ->
it.id == crunchyId it.id == crunchyId
} ?: NoneItem } ?: NoneItem
@ -61,7 +62,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// use tmdb search to get media info TODO media type is hardcoded, use type info from browse result once implemented // use tmdb search to get media info TODO media type is hardcoded, use type info from browse result once implemented
mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media
val tmdbId = tmdbApiController.search(stripTitleInfo(mediaCrunchy.title), MediaType.TVSHOW) val tmdbId = tmdbApiController.search(mediaCrunchy.title, MediaType.TVSHOW)
tmdbResult = when (MediaType.TVSHOW) { tmdbResult = when (MediaType.TVSHOW) {
MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId)

View File

@ -2,19 +2,14 @@ package org.mosad.teapod.util.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import org.mosad.teapod.databinding.ItemMediaBinding import org.mosad.teapod.databinding.ItemMediaBinding
import org.mosad.teapod.util.ItemMedia import org.mosad.teapod.util.ItemMedia
import java.util.*
class MediaItemAdapter(private val initMedia: List<ItemMedia>) : RecyclerView.Adapter<MediaItemAdapter.MediaViewHolder>(), Filterable { class MediaItemAdapter(private val items: List<ItemMedia>) : RecyclerView.Adapter<MediaItemAdapter.MediaViewHolder>() {
var onItemClick: ((id: String, position: Int) -> Unit)? = null var onItemClick: ((id: String, position: Int) -> Unit)? = null
private val filter = MediaFilter()
private var filteredMedia = initMedia.map { it.copy() }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaItemAdapter.MediaViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaItemAdapter.MediaViewHolder {
return MediaViewHolder(ItemMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false)) return MediaViewHolder(ItemMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false))
@ -22,21 +17,13 @@ class MediaItemAdapter(private val initMedia: List<ItemMedia>) : RecyclerView.Ad
override fun onBindViewHolder(holder: MediaItemAdapter.MediaViewHolder, position: Int) { override fun onBindViewHolder(holder: MediaItemAdapter.MediaViewHolder, position: Int) {
holder.binding.root.apply { holder.binding.root.apply {
holder.binding.textTitle.text = filteredMedia[position].title holder.binding.textTitle.text = items[position].title
Glide.with(context).load(filteredMedia[position].posterUrl).into(holder.binding.imagePoster) Glide.with(context).load(items[position].posterUrl).into(holder.binding.imagePoster)
} }
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return filteredMedia.size return items.size
}
override fun getFilter(): Filter {
return filter
}
fun updateMediaList(mediaList: List<ItemMedia>) {
filteredMedia = mediaList
} }
inner class MediaViewHolder(val binding: ItemMediaBinding) : inner class MediaViewHolder(val binding: ItemMediaBinding) :
@ -44,40 +31,11 @@ class MediaItemAdapter(private val initMedia: List<ItemMedia>) : RecyclerView.Ad
init { init {
binding.root.setOnClickListener { binding.root.setOnClickListener {
onItemClick?.invoke( onItemClick?.invoke(
filteredMedia[bindingAdapterPosition].id, items[bindingAdapterPosition].id,
bindingAdapterPosition bindingAdapterPosition
) )
} }
} }
} }
inner class MediaFilter : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterTerm = constraint.toString().lowercase(Locale.ROOT)
val results = FilterResults()
val filteredList = if (filterTerm.isEmpty()) {
initMedia
} else {
initMedia.filter {
it.title.lowercase(Locale.ROOT).contains(filterTerm)
}
}
results.values = filteredList
results.count = filteredList.size
return results
}
@Suppress("unchecked_cast")
/**
* suppressing unchecked cast is safe, since we only use Media
*/
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
filteredMedia = results?.values as List<ItemMedia>
notifyDataSetChanged()
}
}
} }