add search for tv shows
media items are currently not selectable, the app will crash
This commit is contained in:
		| @ -134,18 +134,20 @@ object Crunchyroll { | ||||
|         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 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) | ||||
|         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()!!) | ||||
|         println(test.items.size) | ||||
|  | ||||
|         // TODO return | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneSearchResult | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|  | ||||
| @ -13,9 +13,29 @@ enum class SortBy(val str: String) { | ||||
|     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 | ||||
| data class BrowseResult(val total: Int, val items: List<Item>) | ||||
|  | ||||
| // the data class Item is used in browse and search | ||||
| @Serializable | ||||
| data class Item( | ||||
|     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) | ||||
|  | ||||
| /** | ||||
|  * Series return type | ||||
|  * Series data type | ||||
|  */ | ||||
| @Serializable | ||||
| data class Series( | ||||
|  | ||||
| @ -135,10 +135,10 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|     private fun load() { | ||||
|         val time = measureTimeMillis { | ||||
|             // start the initial loading | ||||
|             val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) | ||||
|                 .async { | ||||
|                     launch { MetaDBController.list() } | ||||
|                 } | ||||
| //            val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) | ||||
| //                .async { | ||||
| //                    launch { MetaDBController.list() } | ||||
| //                } | ||||
|  | ||||
|             // load all saved stuff here | ||||
|             Preferences.load(this) | ||||
| @ -153,8 +153,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|                 runBlocking { Crunchyroll.index() } | ||||
|             } | ||||
|  | ||||
|  | ||||
|  | ||||
| //            if (EncryptedPreferences.password.isEmpty()) { | ||||
| //                showOnboarding() | ||||
| //            } 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") | ||||
|  | ||||
|  | ||||
| @ -147,8 +147,8 @@ class HomeFragment : Fragment() { | ||||
|      *  * only update actual change and not all data (performance) | ||||
|      */ | ||||
|     fun updateMyListMedia() { | ||||
|         adapterMyList.updateMediaList(mapMyListToItemMedia()) | ||||
|         adapterMyList.notifyDataSetChanged() | ||||
|         //adapterMyList.updateMediaList(mapMyListToItemMedia()) | ||||
|         //adapterMyList.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     private fun mapMyListToItemMedia(): List<ItemMedia> { | ||||
|  | ||||
| @ -45,7 +45,7 @@ class LibraryFragment : Fragment() { | ||||
|  | ||||
|                 adapter = MediaItemAdapter(itemList) | ||||
|                 adapter.onItemClick = { mediaIdStr, _ -> | ||||
|                     activity?.showFragment(MediaFragment(mediaIdStr = mediaIdStr)) | ||||
|                     activity?.showFragment(MediaFragment(mediaIdStr)) | ||||
|                 } | ||||
|  | ||||
|                 binding.recyclerMediaLibrary.adapter = adapter | ||||
| @ -78,9 +78,7 @@ class LibraryFragment : Fragment() { | ||||
|                         }) | ||||
|                         nextItemIndex += pageSize | ||||
|  | ||||
|                         adapter.updateMediaList(itemList) | ||||
|                         adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize) | ||||
|  | ||||
|                         isLoading = false | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
| @ -7,16 +7,24 @@ import android.view.ViewGroup | ||||
| import android.widget.SearchView | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.async | ||||
| import kotlinx.coroutines.launch | ||||
| 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.decoration.MediaItemDecoration | ||||
| import org.mosad.teapod.util.showFragment | ||||
|  | ||||
| class SearchFragment : Fragment() { | ||||
|  | ||||
|     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 { | ||||
|         binding = FragmentSearchBinding.inflate(inflater, container, false) | ||||
| @ -29,10 +37,10 @@ class SearchFragment : Fragment() { | ||||
|         lifecycleScope.launch { | ||||
|             // create and set the adapter, needs context | ||||
|                 context?.let { | ||||
|                     adapter = MediaItemAdapter(emptyList()) // TODO | ||||
|                     adapter!!.onItemClick = { mediaId, _ -> | ||||
|                     adapter = MediaItemAdapter(itemList) | ||||
|                     adapter.onItemClick = { mediaIdStr, _ -> | ||||
|                         binding.searchText.clearFocus() | ||||
|                         activity?.showFragment(MediaFragment("")) //(mediaId)) | ||||
|                         activity?.showFragment(MediaFragment(mediaIdStr)) | ||||
|                     } | ||||
|  | ||||
|                     binding.recyclerMediaSearch.adapter = adapter | ||||
| @ -46,16 +54,65 @@ class SearchFragment : Fragment() { | ||||
|     private fun initActions() { | ||||
|         binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener { | ||||
|             override fun onQueryTextSubmit(query: String?): Boolean { | ||||
|                 adapter?.filter?.filter(query) | ||||
|                 adapter?.notifyDataSetChanged() | ||||
|                 query?.let { search(it) } | ||||
|                 return false | ||||
|             } | ||||
|  | ||||
|             override fun onQueryTextChange(newText: String?): Boolean { | ||||
|                 adapter?.filter?.filter(newText) | ||||
|                 adapter?.notifyDataSetChanged() | ||||
|                 newText?.let { search(it) } | ||||
|                 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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -41,6 +41,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         println("loading crunchyroll media $crunchyId") | ||||
|  | ||||
|         // TODO info also in browse result item | ||||
|         // TODO doesn't support search | ||||
|         mediaCrunchy = Crunchyroll.browsingCache.find { it -> | ||||
|             it.id == crunchyId | ||||
|         } ?: 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 | ||||
|         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) { | ||||
|             MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) | ||||
|  | ||||
| @ -2,19 +2,14 @@ package org.mosad.teapod.util.adapter | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.ViewGroup | ||||
| import android.widget.Filter | ||||
| import android.widget.Filterable | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.bumptech.glide.Glide | ||||
| import org.mosad.teapod.databinding.ItemMediaBinding | ||||
| 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 | ||||
|     private val filter = MediaFilter() | ||||
|     private var filteredMedia = initMedia.map { it.copy() } | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaItemAdapter.MediaViewHolder { | ||||
|         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) { | ||||
|         holder.binding.root.apply { | ||||
|             holder.binding.textTitle.text = filteredMedia[position].title | ||||
|             Glide.with(context).load(filteredMedia[position].posterUrl).into(holder.binding.imagePoster) | ||||
|             holder.binding.textTitle.text = items[position].title | ||||
|             Glide.with(context).load(items[position].posterUrl).into(holder.binding.imagePoster) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int { | ||||
|         return filteredMedia.size | ||||
|     } | ||||
|  | ||||
|     override fun getFilter(): Filter { | ||||
|         return filter | ||||
|     } | ||||
|  | ||||
|     fun updateMediaList(mediaList: List<ItemMedia>) { | ||||
|         filteredMedia = mediaList | ||||
|         return items.size | ||||
|     } | ||||
|  | ||||
|     inner class MediaViewHolder(val binding: ItemMediaBinding) : | ||||
| @ -44,40 +31,11 @@ class MediaItemAdapter(private val initMedia: List<ItemMedia>) : RecyclerView.Ad | ||||
|         init { | ||||
|             binding.root.setOnClickListener { | ||||
|                 onItemClick?.invoke( | ||||
|                     filteredMedia[bindingAdapterPosition].id, | ||||
|                     items[bindingAdapterPosition].id, | ||||
|                     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() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
		Reference in New Issue
	
	Block a user