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 |         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 |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -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( | ||||||
|  | |||||||
| @ -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") | ||||||
|  |  | ||||||
|  | |||||||
| @ -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> { | ||||||
|  | |||||||
| @ -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 | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  | |||||||
| @ -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 | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
| @ -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) | ||||||
|  | |||||||
| @ -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() |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } | } | ||||||
		Reference in New Issue
	
	Block a user