parent
8e397e13d2
commit
e113a9c795
|
@ -40,8 +40,8 @@ import org.mosad.teapod.preferences.EncryptedPreferences
|
||||||
import org.mosad.teapod.preferences.Preferences
|
import org.mosad.teapod.preferences.Preferences
|
||||||
import org.mosad.teapod.ui.activity.main.fragments.AccountFragment
|
import org.mosad.teapod.ui.activity.main.fragments.AccountFragment
|
||||||
import org.mosad.teapod.ui.activity.main.fragments.HomeFragment
|
import org.mosad.teapod.ui.activity.main.fragments.HomeFragment
|
||||||
|
import org.mosad.teapod.ui.activity.main.fragments.MyListsFragment
|
||||||
import org.mosad.teapod.ui.activity.main.fragments.LibraryFragment
|
import org.mosad.teapod.ui.activity.main.fragments.LibraryFragment
|
||||||
import org.mosad.teapod.ui.activity.main.fragments.SearchFragment
|
|
||||||
import org.mosad.teapod.ui.activity.onboarding.OnboardingActivity
|
import org.mosad.teapod.ui.activity.onboarding.OnboardingActivity
|
||||||
import org.mosad.teapod.util.DataTypes
|
import org.mosad.teapod.util.DataTypes
|
||||||
import org.mosad.teapod.util.metadb.MetaDBController
|
import org.mosad.teapod.util.metadb.MetaDBController
|
||||||
|
@ -100,12 +100,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
||||||
activeBaseFragment = HomeFragment()
|
activeBaseFragment = HomeFragment()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.navigation_library -> {
|
R.id.navigation_my_lists -> {
|
||||||
activeBaseFragment = LibraryFragment()
|
activeBaseFragment = MyListsFragment()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.navigation_search -> {
|
R.id.navigation_library -> {
|
||||||
activeBaseFragment = SearchFragment()
|
activeBaseFragment = LibraryFragment()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.navigation_account -> {
|
R.id.navigation_account -> {
|
||||||
|
|
|
@ -1,30 +1,30 @@
|
||||||
package org.mosad.teapod.ui.activity.main.fragments
|
package org.mosad.teapod.ui.activity.main.fragments
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.mosad.teapod.databinding.FragmentLibraryBinding
|
import org.mosad.teapod.databinding.FragmentLibraryBinding
|
||||||
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
|
import org.mosad.teapod.ui.activity.main.viewmodel.LibraryFragmentViewModel
|
||||||
import org.mosad.teapod.util.ItemMedia
|
|
||||||
import org.mosad.teapod.util.adapter.MediaItemListAdapter
|
import org.mosad.teapod.util.adapter.MediaItemListAdapter
|
||||||
import org.mosad.teapod.util.decoration.MediaItemDecoration
|
|
||||||
import org.mosad.teapod.util.showFragment
|
import org.mosad.teapod.util.showFragment
|
||||||
import org.mosad.teapod.util.toItemMediaList
|
|
||||||
|
|
||||||
class LibraryFragment : Fragment() {
|
class LibraryFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentLibraryBinding
|
private lateinit var binding: FragmentLibraryBinding
|
||||||
private lateinit var adapter: MediaItemListAdapter
|
private lateinit var adapter: MediaItemListAdapter
|
||||||
|
private val model: LibraryFragmentViewModel by viewModels()
|
||||||
private val itemList = arrayListOf<ItemMedia>()
|
|
||||||
private val pageSize = 30
|
|
||||||
private var nextItemIndex = 0
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
binding = FragmentLibraryBinding.inflate(inflater, container, false)
|
binding = FragmentLibraryBinding.inflate(inflater, container, false)
|
||||||
|
@ -36,48 +36,79 @@ class LibraryFragment : Fragment() {
|
||||||
|
|
||||||
// TODO replace with pagination3
|
// TODO replace with pagination3
|
||||||
// https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797
|
// https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797
|
||||||
binding.recyclerMediaLibrary.addOnScrollListener(PaginationScrollListener())
|
binding.recyclerMediaSearch.addOnScrollListener(PaginationScrollListener())
|
||||||
|
|
||||||
// init async
|
adapter = MediaItemListAdapter(MediaItemListAdapter.OnClickListener {
|
||||||
lifecycleScope.launch {
|
binding.searchText.clearFocus()
|
||||||
// create and set the adapter, needs context
|
activity?.showFragment(MediaFragment(it.id))
|
||||||
context?.let {
|
})
|
||||||
itemList.addAll(Crunchyroll.browse(n = pageSize).toItemMediaList())
|
binding.recyclerMediaSearch.adapter = adapter
|
||||||
nextItemIndex += pageSize
|
|
||||||
|
|
||||||
adapter = MediaItemListAdapter(MediaItemListAdapter.OnClickListener {
|
binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
activity?.showFragment(MediaFragment(it.id))
|
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||||
})
|
println("onQueryTextSubmit: $query")
|
||||||
binding.recyclerMediaLibrary.adapter = adapter
|
query?.let { model.search(it) }
|
||||||
adapter.submitList(itemList)
|
return false // return false to dismiss the keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
override fun onQueryTextChange(newText: String?): Boolean {
|
||||||
}
|
println("onQueryTextChange: $newText")
|
||||||
|
newText?.let { model.search(it) }
|
||||||
|
return false // return false to dismiss the keyboard
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
inner class PaginationScrollListener: RecyclerView.OnScrollListener() {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
private var isLoading = false
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
when (uiState) {
|
||||||
super.onScrolled(recyclerView, dx, dy)
|
is LibraryFragmentViewModel.UiState.Browse -> bindUiStateBrowse(uiState)
|
||||||
val layoutManager = recyclerView.layoutManager as GridLayoutManager?
|
is LibraryFragmentViewModel.UiState.Search -> bindUiStateSearch(uiState)
|
||||||
|
is LibraryFragmentViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
if (!isLoading) layoutManager?.let {
|
is LibraryFragmentViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
// itemList.size - 5 to start loading a bit earlier than the actual end
|
|
||||||
if (layoutManager.findLastCompletelyVisibleItemPosition() >= (itemList.size - 5)) {
|
|
||||||
// load new browse results async
|
|
||||||
isLoading = true
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val firstNewItemIndex = itemList.lastIndex + 1
|
|
||||||
itemList.addAll(Crunchyroll.browse(start = nextItemIndex, n = pageSize).toItemMediaList())
|
|
||||||
nextItemIndex += pageSize
|
|
||||||
|
|
||||||
adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize)
|
|
||||||
isLoading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
private fun bindUiStateBrowse(uiState: LibraryFragmentViewModel.UiState.Browse) {
|
||||||
|
adapter.submitList(uiState.itemList)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
private fun bindUiStateSearch(uiState: LibraryFragmentViewModel.UiState.Search) {
|
||||||
|
adapter.submitList(uiState.itemList)
|
||||||
|
adapter.notifyDataSetChanged() // this is needed, else the adapter will not update
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateLoading() {
|
||||||
|
// currently not used
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateError(uiState: LibraryFragmentViewModel.UiState.Error) {
|
||||||
|
// currently not used
|
||||||
|
Log.e(javaClass.name, "A error occurred while loading a UiState: ${uiState.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class PaginationScrollListener: RecyclerView.OnScrollListener() {
|
||||||
|
|
||||||
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
super.onScrolled(recyclerView, dx, dy)
|
||||||
|
|
||||||
|
if (!model.isLazyLoading) {
|
||||||
|
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
|
||||||
|
layoutManager?.let {
|
||||||
|
// adapter.itemCount - 10 to start loading a bit earlier than the actual end
|
||||||
|
if (layoutManager.findLastCompletelyVisibleItemPosition() >= (adapter.itemCount - 10)) {
|
||||||
|
model.onLazyLoad().invokeOnCompletion {
|
||||||
|
adapter.notifyItemRangeInserted(adapter.itemCount, model.PAGESIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -47,7 +47,7 @@ class MediaFragmentSimilar : Fragment() {
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
binding.recyclerMediaSimilar.adapter = MediaItemListAdapter(
|
binding.recyclerMediaSimilar.adapter = MediaItemListAdapter(
|
||||||
MediaItemListAdapter.OnClickListener {
|
MediaItemListAdapter.OnClickListener {
|
||||||
activity?.showFragment(MediaFragment(it.id))
|
activity?.showFragment(MediaFragment(it.id))
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
package org.mosad.teapod.ui.activity.main.fragments
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.mosad.teapod.databinding.FragmentMyListsBinding
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
|
||||||
|
import org.mosad.teapod.util.ItemMedia
|
||||||
|
import org.mosad.teapod.util.adapter.MediaItemListAdapter
|
||||||
|
import org.mosad.teapod.util.showFragment
|
||||||
|
import org.mosad.teapod.util.toItemMediaList
|
||||||
|
|
||||||
|
class MyListsFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var binding: FragmentMyListsBinding
|
||||||
|
private lateinit var adapter: MediaItemListAdapter
|
||||||
|
|
||||||
|
private val itemList = arrayListOf<ItemMedia>()
|
||||||
|
private val pageSize = 30
|
||||||
|
private var nextItemIndex = 0
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
binding = FragmentMyListsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
// TODO replace with pagination3
|
||||||
|
// https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797
|
||||||
|
binding.recyclerMediaLibrary.addOnScrollListener(PaginationScrollListener())
|
||||||
|
|
||||||
|
// init async
|
||||||
|
lifecycleScope.launch {
|
||||||
|
// create and set the adapter, needs context
|
||||||
|
context?.let {
|
||||||
|
itemList.addAll(Crunchyroll.browse(n = pageSize).toItemMediaList())
|
||||||
|
nextItemIndex += pageSize
|
||||||
|
|
||||||
|
adapter = MediaItemListAdapter(MediaItemListAdapter.OnClickListener {
|
||||||
|
activity?.showFragment(MediaFragment(it.id))
|
||||||
|
})
|
||||||
|
binding.recyclerMediaLibrary.adapter = adapter
|
||||||
|
adapter.submitList(itemList)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class PaginationScrollListener: RecyclerView.OnScrollListener() {
|
||||||
|
private var isLoading = false
|
||||||
|
|
||||||
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
super.onScrolled(recyclerView, dx, dy)
|
||||||
|
val layoutManager = recyclerView.layoutManager as GridLayoutManager?
|
||||||
|
|
||||||
|
if (!isLoading) layoutManager?.let {
|
||||||
|
// itemList.size - 5 to start loading a bit earlier than the actual end
|
||||||
|
if (layoutManager.findLastCompletelyVisibleItemPosition() >= (itemList.size - 5)) {
|
||||||
|
// load new browse results async
|
||||||
|
isLoading = true
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val firstNewItemIndex = itemList.lastIndex + 1
|
||||||
|
itemList.addAll(Crunchyroll.browse(start = nextItemIndex, n = pageSize).toItemMediaList())
|
||||||
|
nextItemIndex += pageSize
|
||||||
|
|
||||||
|
adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,112 +0,0 @@
|
||||||
package org.mosad.teapod.ui.activity.main.fragments
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
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 org.mosad.teapod.databinding.FragmentSearchBinding
|
|
||||||
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
|
|
||||||
import org.mosad.teapod.util.ItemMedia
|
|
||||||
import org.mosad.teapod.util.adapter.MediaItemListAdapter
|
|
||||||
import org.mosad.teapod.util.decoration.MediaItemDecoration
|
|
||||||
import org.mosad.teapod.util.showFragment
|
|
||||||
import org.mosad.teapod.util.toItemMediaList
|
|
||||||
|
|
||||||
class SearchFragment : Fragment() {
|
|
||||||
|
|
||||||
private lateinit var binding: FragmentSearchBinding
|
|
||||||
private lateinit var adapter: MediaItemListAdapter
|
|
||||||
|
|
||||||
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)
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
|
|
||||||
binding.recyclerMediaSearch.addItemDecoration(MediaItemDecoration(9))
|
|
||||||
|
|
||||||
adapter = MediaItemListAdapter(MediaItemListAdapter.OnClickListener {
|
|
||||||
binding.searchText.clearFocus()
|
|
||||||
activity?.showFragment(MediaFragment(it.id))
|
|
||||||
})
|
|
||||||
binding.recyclerMediaSearch.adapter = adapter
|
|
||||||
|
|
||||||
initActions()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initActions() {
|
|
||||||
binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
|
||||||
query?.let { search(it) }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onQueryTextChange(newText: String?): Boolean {
|
|
||||||
newText?.let { search(it) }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for a query string at Crunchyroll and submit the results to the adapter.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
adapter.submitList(
|
|
||||||
results.items.firstOrNull()?.items?.toItemMediaList() ?: listOf<ItemMedia>()
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
package org.mosad.teapod.ui.activity.main.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
|
||||||
|
import org.mosad.teapod.util.ItemMedia
|
||||||
|
import org.mosad.teapod.util.toItemMediaList
|
||||||
|
|
||||||
|
class LibraryFragmentViewModel : ViewModel() {
|
||||||
|
|
||||||
|
val PAGESIZE = 50
|
||||||
|
|
||||||
|
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
|
private var oldSearchQuery = ""
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
var isLazyLoading = false
|
||||||
|
internal set
|
||||||
|
|
||||||
|
sealed class UiState {
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Browse(
|
||||||
|
val itemList: MutableList<ItemMedia>
|
||||||
|
) : UiState()
|
||||||
|
data class Search(
|
||||||
|
val itemList: List<ItemMedia>
|
||||||
|
) : UiState()
|
||||||
|
data class Error(val message: String?) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* initially load the first n browsing items
|
||||||
|
*/
|
||||||
|
private fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
|
|
||||||
|
try {
|
||||||
|
initBrowse()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
uiState.emit(UiState.Error(ex.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a query string at Crunchyroll and emit the new ui state.
|
||||||
|
*/
|
||||||
|
fun search(query: String) {
|
||||||
|
// return if nothing has changed
|
||||||
|
if (query == oldSearchQuery) return
|
||||||
|
|
||||||
|
// update the old query since it has changed
|
||||||
|
oldSearchQuery = query
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
|
||||||
|
// always cancel a running search job
|
||||||
|
if (searchJob?.isActive == true) searchJob?.cancel()
|
||||||
|
|
||||||
|
// handle state change: browse <-> search
|
||||||
|
if (query.isEmpty()) {
|
||||||
|
// if the query is empty change back to browse state
|
||||||
|
initBrowse()
|
||||||
|
} else {
|
||||||
|
// TODO handle errors
|
||||||
|
|
||||||
|
// if the current ui state is not search, clear the recyclerview
|
||||||
|
if (uiState.value !is UiState.Search) {
|
||||||
|
uiState.emit(UiState.Search(emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a new search job
|
||||||
|
searchJob = viewModelScope.async {
|
||||||
|
// wait for a few ms: if the user is typing the task will get canceled
|
||||||
|
delay(250)
|
||||||
|
|
||||||
|
val results = Crunchyroll.search(query, 50)
|
||||||
|
.items.firstOrNull()?.items?.toItemMediaList()
|
||||||
|
?: listOf()
|
||||||
|
uiState.emit(UiState.Search(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLazyLoad() = viewModelScope.launch {
|
||||||
|
isLazyLoading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
uiState.update { currentUiState ->
|
||||||
|
if (currentUiState is UiState.Browse) {
|
||||||
|
val newBrowseItems = Crunchyroll.browse(start = currentUiState.itemList.size, n = PAGESIZE)
|
||||||
|
.toItemMediaList()
|
||||||
|
currentUiState.itemList.addAll(newBrowseItems)
|
||||||
|
}
|
||||||
|
currentUiState
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
uiState.emit(UiState.Error(ex.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
isLazyLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initBrowse() {
|
||||||
|
try {
|
||||||
|
val initialBrowseItems = Crunchyroll.browse(n = PAGESIZE)
|
||||||
|
.toItemMediaList()
|
||||||
|
.toMutableList()
|
||||||
|
uiState.emit(UiState.Browse(initialBrowseItems))
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
uiState.emit(UiState.Error(ex.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
package org.mosad.teapod.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.R
|
||||||
|
import androidx.appcompat.widget.SearchView
|
||||||
|
|
||||||
|
// see https://stackoverflow.com/questions/30046201/android-searchview-empty-query-doesnt-work
|
||||||
|
class EmptySubmitSearchView : SearchView {
|
||||||
|
|
||||||
|
constructor(context: Context) : super(context)
|
||||||
|
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||||
|
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||||
|
|
||||||
|
override fun setOnQueryTextListener(listener: OnQueryTextListener?) {
|
||||||
|
super.setOnQueryTextListener(listener)
|
||||||
|
|
||||||
|
findViewById<SearchAutoComplete?>(R.id.search_src_text).setOnEditorActionListener { _: TextView?, _: Int, event: KeyEvent? ->
|
||||||
|
if (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||||
|
listener?.onQueryTextSubmit(query.toString())
|
||||||
|
} else {
|
||||||
|
listener?.onQueryTextSubmit(query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M17,3L7,3c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3L19,5c0,-1.1 -0.9,-2 -2,-2zM17,18l-5,-2.18L7,18L7,5h10v13z"/>
|
||||||
|
</vector>
|
|
@ -7,10 +7,23 @@
|
||||||
android:background="?themePrimary"
|
android:background="?themePrimary"
|
||||||
tools:context=".ui.activity.main.fragments.LibraryFragment">
|
tools:context=".ui.activity.main.fragments.LibraryFragment">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<org.mosad.teapod.ui.components.EmptySubmitSearchView
|
||||||
android:id="@+id/recycler_media_library"
|
android:id="@+id/search_text"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="0dp"
|
||||||
|
android:background="?themeSecondary"
|
||||||
|
android:elevation="8dp"
|
||||||
|
android:iconifiedByDefault="false"
|
||||||
|
android:paddingBottom="5dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
</org.mosad.teapod.ui.components.EmptySubmitSearchView>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recycler_media_search"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="3dp"
|
android:padding="3dp"
|
||||||
|
@ -18,8 +31,10 @@
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toBottomOf="@+id/search_text"
|
||||||
app:spanCount="@integer/item_media_columns"
|
app:spanCount="@integer/item_media_columns"
|
||||||
tools:listitem="@layout/item_media" />
|
tools:listitem="@layout/item_media">
|
||||||
|
|
||||||
|
</androidx.recyclerview.widget.RecyclerView>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -5,28 +5,12 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="?themePrimary"
|
android:background="?themePrimary"
|
||||||
tools:context=".ui.activity.main.fragments.SearchFragment">
|
tools:context=".ui.activity.main.fragments.MyListsFragment">
|
||||||
|
|
||||||
<SearchView
|
|
||||||
android:id="@+id/search_text"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:background="?themeSecondary"
|
|
||||||
android:elevation="8dp"
|
|
||||||
android:iconifiedByDefault="false"
|
|
||||||
android:paddingBottom="5dp"
|
|
||||||
android:queryHint="@string/search_hint"
|
|
||||||
android:searchIcon="@drawable/ic_baseline_search_24"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
|
||||||
|
|
||||||
</SearchView>
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recycler_media_search"
|
android:id="@+id/recycler_media_library"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="match_parent"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="3dp"
|
android:padding="3dp"
|
||||||
|
@ -34,10 +18,8 @@
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/search_text"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:spanCount="@integer/item_media_columns"
|
app:spanCount="@integer/item_media_columns"
|
||||||
tools:listitem="@layout/item_media">
|
tools:listitem="@layout/item_media" />
|
||||||
|
|
||||||
</androidx.recyclerview.widget.RecyclerView>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -6,15 +6,15 @@
|
||||||
android:icon="@drawable/ic_home_black_24dp"
|
android:icon="@drawable/ic_home_black_24dp"
|
||||||
android:title="@string/title_home" />
|
android:title="@string/title_home" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_my_lists"
|
||||||
|
android:icon="@drawable/ic_baseline_bookmark_border_24"
|
||||||
|
android:title="@string/title_my_lists" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/navigation_library"
|
android:id="@+id/navigation_library"
|
||||||
android:icon="@drawable/ic_baseline_video_library_24"
|
android:icon="@drawable/ic_baseline_video_library_24"
|
||||||
android:title="@string/title_library" />
|
android:title="@string/title_library" />
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/navigation_search"
|
|
||||||
android:icon="@drawable/ic_baseline_search_24"
|
|
||||||
android:title="@string/title_search" />
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/navigation_account"
|
android:id="@+id/navigation_account"
|
||||||
android:icon="@drawable/ic_baseline_account_box_24"
|
android:icon="@drawable/ic_baseline_account_box_24"
|
||||||
|
|
|
@ -11,18 +11,18 @@
|
||||||
android:label="@string/title_home"
|
android:label="@string/title_home"
|
||||||
tools:layout="@layout/fragment_home" />
|
tools:layout="@layout/fragment_home" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_my_lists"
|
||||||
|
android:name="org.mosad.teapod.ui.activity.main.fragments.MyListsFragment"
|
||||||
|
android:label="@string/title_my_lists"
|
||||||
|
tools:layout="@layout/fragment_my_lists" />
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/navigation_library"
|
android:id="@+id/navigation_library"
|
||||||
android:name="org.mosad.teapod.ui.activity.main.fragments.LibraryFragment"
|
android:name="org.mosad.teapod.ui.activity.main.fragments.LibraryFragment"
|
||||||
android:label="@string/title_library"
|
android:label="@string/title_library"
|
||||||
tools:layout="@layout/fragment_library" />
|
tools:layout="@layout/fragment_library" />
|
||||||
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/navigation_search"
|
|
||||||
android:name="org.mosad.teapod.ui.activity.main.fragments.SearchFragment"
|
|
||||||
android:label="@string/title_search"
|
|
||||||
tools:layout="@layout/fragment_search" />
|
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/navigation_account"
|
android:id="@+id/navigation_account"
|
||||||
android:name="org.mosad.teapod.ui.activity.main.fragments.AccountFragment"
|
android:name="org.mosad.teapod.ui.activity.main.fragments.AccountFragment"
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="title_home">Startseite</string>
|
<string name="title_home">Startseite</string>
|
||||||
|
<string name="title_my_lists">Meine Listen</string>
|
||||||
<string name="title_library">Übersicht</string>
|
<string name="title_library">Übersicht</string>
|
||||||
<string name="title_search">Suche</string>
|
|
||||||
<string name="title_account">Account</string>
|
<string name="title_account">Account</string>
|
||||||
|
|
||||||
<!-- home fragment -->
|
<!-- home fragment -->
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Teapod</string>
|
<string name="app_name" translatable="false">Teapod</string>
|
||||||
<string name="title_home">Home</string>
|
<string name="title_home">Home</string>
|
||||||
|
<string name="title_my_lists">My Lists</string>
|
||||||
<string name="title_library">Library</string>
|
<string name="title_library">Library</string>
|
||||||
<string name="title_search">Search</string>
|
|
||||||
<string name="title_account">Account</string>
|
<string name="title_account">Account</string>
|
||||||
|
|
||||||
<!-- home fragment -->
|
<!-- home fragment -->
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
<item name="popupMenuStyle">@style/Widget.App.PopupMenu</item>
|
<item name="popupMenuStyle">@style/Widget.App.PopupMenu</item>
|
||||||
|
<item name="searchViewStyle">@style/SearchViewStyle</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.Light" parent="AppTheme">
|
<style name="AppTheme.Light" parent="AppTheme">
|
||||||
|
@ -50,6 +51,13 @@
|
||||||
<item name="android:textColor">?textPrimary</item>
|
<item name="android:textColor">?textPrimary</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- search view theme -->
|
||||||
|
<style name="SearchViewStyle" parent="Widget.AppCompat.SearchView.ActionBar">
|
||||||
|
<item name="iconifiedByDefault">false</item>
|
||||||
|
<item name="searchIcon">@drawable/ic_baseline_search_24</item>
|
||||||
|
<item name="queryHint">@string/search_hint</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- player theme -->
|
<!-- player theme -->
|
||||||
<style name="PlayerTheme" parent="AppTheme">
|
<style name="PlayerTheme" parent="AppTheme">
|
||||||
<item name="android:windowNoTitle">true</item>
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
@ -74,7 +82,6 @@
|
||||||
<item name="postSplashScreenTheme">@style/AppTheme.Dark</item> # Required.
|
<item name="postSplashScreenTheme">@style/AppTheme.Dark</item> # Required.
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<!-- shapes -->
|
<!-- shapes -->
|
||||||
<style name="ShapeAppearance.Teapod.RoundedPoster" parent="ShapeAppearance.MaterialComponents.LargeComponent">
|
<style name="ShapeAppearance.Teapod.RoundedPoster" parent="ShapeAppearance.MaterialComponents.LargeComponent">
|
||||||
<item name="cornerFamily">rounded</item>
|
<item name="cornerFamily">rounded</item>
|
||||||
|
@ -95,5 +102,4 @@
|
||||||
<item name="android:windowTranslucentNavigation">true</item>
|
<item name="android:windowTranslucentNavigation">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
</resources>
|
</resources>
|
Loading…
Reference in New Issue