diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt index 963598e..4fb412e 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt @@ -1,34 +1,53 @@ +/** + * Teapod + * + * Copyright 2020-2022 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + * + */ + package org.mosad.teapod.ui.activity.main.fragments import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.bumptech.glide.Glide -import kotlinx.coroutines.Job -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import org.mosad.teapod.R import org.mosad.teapod.databinding.FragmentHomeBinding -import org.mosad.teapod.parser.crunchyroll.Crunchyroll -import org.mosad.teapod.parser.crunchyroll.Item -import org.mosad.teapod.parser.crunchyroll.SortBy -import org.mosad.teapod.util.adapter.MediaItemAdapter +import org.mosad.teapod.ui.activity.main.viewmodel.HomeViewModel +import org.mosad.teapod.util.adapter.MediaItemListAdapter import org.mosad.teapod.util.decoration.MediaItemDecoration +import org.mosad.teapod.util.setDrawableTop import org.mosad.teapod.util.showFragment import org.mosad.teapod.util.toItemMediaList -import kotlin.random.Random class HomeFragment : Fragment() { + private val classTag = javaClass.name + private val model: HomeViewModel by viewModels() private lateinit var binding: FragmentHomeBinding - private lateinit var adapterUpNext: MediaItemAdapter - private lateinit var adapterWatchlist: MediaItemAdapter - private lateinit var adapterNewTitles: MediaItemAdapter - private lateinit var adapterTopTen: MediaItemAdapter - - private lateinit var highlightMedia: Item override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentHomeBinding.inflate(inflater, container, false) @@ -38,84 +57,43 @@ class HomeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - context?.let { - initHighlight() - initRecyclerViews() - initActions() - } - } - } - - private fun initHighlight() { - lifecycleScope.launch { - val newTitles = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 10) - // FIXME crashes on newTitles.items.size == 0 - highlightMedia = newTitles.items[Random.nextInt(newTitles.items.size)] - - // add media item to gui - binding.textHighlightTitle.text = highlightMedia.title - Glide.with(requireContext()).load(highlightMedia.images.poster_wide[0][3].source) - .into(binding.imageHighlight) - - // TODO watchlist indicator -// if (StorageController.myList.contains(0)) { -// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) -// } else { -// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) -// } - } - } - - /** - * Suspend, since adapters need to be initialized before we can initialize the actions. - */ - private suspend fun initRecyclerViews() { - binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) binding.recyclerNewEpisodes.addItemDecoration(MediaItemDecoration(9)) + binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9)) binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9)) - val asyncJobList = arrayListOf() + binding.recyclerNewEpisodes.adapter = MediaItemListAdapter( + MediaItemListAdapter.OnClickListener { + activity?.showFragment(MediaFragment(it.id)) + } + ) - // continue watching - val upNextJob = lifecycleScope.launch { - // TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately - adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().items - .filter { !it.fullyWatched }.toItemMediaList()) - binding.recyclerNewEpisodes.adapter = adapterUpNext + binding.recyclerWatchlist.adapter = MediaItemListAdapter( + MediaItemListAdapter.OnClickListener { + activity?.showFragment(MediaFragment(it.id)) + } + ) + + binding.recyclerNewTitles.adapter = MediaItemListAdapter( + MediaItemListAdapter.OnClickListener { + activity?.showFragment(MediaFragment(it.id)) + } + ) + + binding.recyclerTopTen.adapter = MediaItemListAdapter( + MediaItemListAdapter.OnClickListener { + activity?.showFragment(MediaFragment(it.id)) + } + ) + + binding.textHighlightMyList.setOnClickListener { + model.toggleHighlightWatchlist() + + // disable the watchlist button until the result has been loaded + binding.textHighlightMyList.isClickable = false + // TODO since this might take a few seconds show a loading animation for the watchlist button } - asyncJobList.add(upNextJob) - // watchlist - val watchlistJob = lifecycleScope.launch { - adapterWatchlist = MediaItemAdapter(Crunchyroll.watchlist(50).toItemMediaList()) - binding.recyclerWatchlist.adapter = adapterWatchlist - } - asyncJobList.add(watchlistJob) - - // new simulcasts - val simulcastsJob = lifecycleScope.launch { - // val latestSeasonTag = Crunchyroll.seasonList().items.first().id - // val newSimulcasts = Crunchyroll.browse(seasonTag = latestSeasonTag, n = 50) - val newSimulcasts = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50) - - adapterNewTitles = MediaItemAdapter(newSimulcasts.toItemMediaList()) - binding.recyclerNewTitles.adapter = adapterNewTitles - } - asyncJobList.add(simulcastsJob) - - // newly added / top ten - val newlyAddedJob = lifecycleScope.launch { - adapterTopTen = MediaItemAdapter(Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).toItemMediaList()) - binding.recyclerTopTen.adapter = adapterTopTen - } - asyncJobList.add(newlyAddedJob) - - asyncJobList.joinAll() - } - - private fun initActions() { binding.buttonPlayHighlight.setOnClickListener { // TODO implement lifecycleScope.launch { @@ -126,37 +104,57 @@ class HomeFragment : Fragment() { } } - binding.textHighlightMyList.setOnClickListener { - // TODO implement -// if (StorageController.myList.contains(0)) { -// StorageController.myList.remove(0) -// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) -// } else { -// StorageController.myList.add(0) -// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) -// } -// StorageController.saveMyList(requireContext()) - } - - binding.textHighlightInfo.setOnClickListener { - activity?.showFragment(MediaFragment(highlightMedia.id)) - } - - adapterUpNext.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) - } - - adapterWatchlist.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) - } - - adapterNewTitles.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) - } - - adapterTopTen.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) //(mediaId)) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> + when (uiState) { + is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState) + is HomeViewModel.UiState.Loading -> bindUiStateLoading() + is HomeViewModel.UiState.Error -> bindUiStateError(uiState) + } + } + } } } + private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) { + val adapterUpNext = binding.recyclerNewEpisodes.adapter as MediaItemListAdapter + adapterUpNext.submitList(uiState.upNextItems.filter { !it.fullyWatched }.toItemMediaList()) + + val adapterWatchlist = binding.recyclerWatchlist.adapter as MediaItemListAdapter + adapterWatchlist.submitList(uiState.watchlistItems.toItemMediaList()) + + val adapterNewTitles = binding.recyclerNewTitles.adapter as MediaItemListAdapter + adapterNewTitles.submitList(uiState.recentlyAddedItems.toItemMediaList()) + + val adapterTopTen = binding.recyclerTopTen.adapter as MediaItemListAdapter + adapterTopTen.submitList(uiState.topTenItems.toItemMediaList()) + + // highlight item + binding.textHighlightTitle.text = uiState.highlightItem.title + Glide.with(requireContext()).load(uiState.highlightItem.images.poster_wide[0][3].source) + .into(binding.imageHighlight) + + val iconHighlightWatchlist = if (uiState.highlightIsWatchlist) { + R.drawable.ic_baseline_check_24 + } else { + R.drawable.ic_baseline_add_24 + } + binding.textHighlightMyList.setDrawableTop(iconHighlightWatchlist) + binding.textHighlightMyList.isClickable = true + + binding.textHighlightInfo.setOnClickListener { + activity?.showFragment(MediaFragment(uiState.highlightItem.id)) + } + } + + private fun bindUiStateLoading() { + // currently not used + } + + private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) { + // currently not used + Log.e(classTag, "A error occurred while loading a UiState: ${uiState.message}") + } + } diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/HomeViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..97b2409 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/HomeViewModel.kt @@ -0,0 +1,119 @@ +/** + * Teapod + * + * Copyright 2020-2022 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + * + */ + +package org.mosad.teapod.ui.activity.main.viewmodel + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.mosad.teapod.parser.crunchyroll.* +import kotlin.random.Random + +class HomeViewModel : ViewModel() { + + private val uiState = MutableStateFlow(UiState.Loading) + + sealed class UiState { + object Loading : UiState() + data class Normal( + val upNextItems: List, + val watchlistItems: List, + val recentlyAddedItems: List, + val topTenItems: List, + val highlightItem: Item, + val highlightIsWatchlist:Boolean + ) : UiState() + data class Error(val message: String?) : UiState() + } + + init { + load() + } + + fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { + scope.launch { uiState.collect { collector(it) } } + } + + fun load() { + viewModelScope.launch { + uiState.emit(UiState.Loading) + try { + // run the loading in parallel to speed up the process + + val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().items } + val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(50).items } + val recentlyAddedJob = viewModelScope.async { + Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50).items + } + val topTenJob = viewModelScope.async { + Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).items + } + + val recentlyAddedItems = recentlyAddedJob.await() + // FIXME crashes on newTitles.items.size == 0 + val highlightItem = recentlyAddedItems[Random.nextInt(recentlyAddedItems.size)] + val highlightItemIsWatchlist = Crunchyroll.isWatchlist(highlightItem.id) + + uiState.emit(UiState.Normal( + upNextJob.await(), watchlistJob.await(), recentlyAddedJob.await(), + topTenJob.await(), highlightItem, highlightItemIsWatchlist + )) + } catch (e: Exception) { + uiState.emit(UiState.Error(e.message)) + } + } + + } + + /** + * Toggle the watchlist state of the highlight media. + */ + fun toggleHighlightWatchlist() { + viewModelScope.launch { + uiState.update { currentUiState -> + if (currentUiState is UiState.Normal) { + if (currentUiState.highlightIsWatchlist) { + Crunchyroll.deleteWatchlist(currentUiState.highlightItem.id) + } else { + Crunchyroll.postWatchlist(currentUiState.highlightItem.id) + } + + // update the watchlist after a item has been added/removed + val watchlistItems = Crunchyroll.watchlist(50).items + + currentUiState.copy( + watchlistItems = watchlistItems, + highlightIsWatchlist = !currentUiState.highlightIsWatchlist) + } else { + currentUiState + } + } + } + + + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/util/Utils.kt b/app/src/main/java/org/mosad/teapod/util/Utils.kt index 74a2ca3..e7c7d94 100644 --- a/app/src/main/java/org/mosad/teapod/util/Utils.kt +++ b/app/src/main/java/org/mosad/teapod/util/Utils.kt @@ -21,6 +21,13 @@ fun Collection.toItemMediaList(): List { } } +@JvmName("toItemMediaListItem") +fun List.toItemMediaList(): List { + return this.map { + ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source) + } +} + @JvmName("toItemMediaListContinueWatchingItem") fun Collection.toItemMediaList(): List { return items.map { diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemListAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemListAdapter.kt new file mode 100644 index 0000000..0d1edee --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemListAdapter.kt @@ -0,0 +1,55 @@ +package org.mosad.teapod.util.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import org.mosad.teapod.databinding.ItemMediaBinding +import org.mosad.teapod.util.ItemMedia + +class MediaItemListAdapter(private val onClickListener: OnClickListener) : ListAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { + return MediaViewHolder( + ItemMediaBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { + val item = getItem(position) + holder.binding.root.setOnClickListener { + onClickListener.onClick(item) + } + holder.bind(item) + } + + inner class MediaViewHolder(val binding: ItemMediaBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ItemMedia) { + binding.textTitle.text = item.title + // can we use the view instead of context here? + Glide.with(binding.root.context).load(item.posterUrl).into(binding.imagePoster) + } + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean { + return oldItem == newItem + } + } + + class OnClickListener(val clickListener: (item: ItemMedia) -> Unit) { + fun onClick(item: ItemMedia) = clickListener(item) + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180..41d9927 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 00e33ed..aa991fc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists