add watchlist support for media fragment
This commit is contained in:
		| @ -4,12 +4,15 @@ import android.util.Log | ||||
| import com.github.kittinunf.fuel.Fuel | ||||
| import com.github.kittinunf.fuel.core.FuelError | ||||
| import com.github.kittinunf.fuel.core.Parameters | ||||
| import com.github.kittinunf.fuel.core.extensions.jsonBody | ||||
| import com.github.kittinunf.fuel.json.FuelJson | ||||
| import com.github.kittinunf.fuel.json.responseJson | ||||
| import com.github.kittinunf.result.Result | ||||
| import kotlinx.coroutines.* | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import kotlinx.serialization.json.buildJsonObject | ||||
| import kotlinx.serialization.json.put | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| import java.util.* | ||||
|  | ||||
| @ -22,6 +25,8 @@ object Crunchyroll { | ||||
|     private var accessToken = "" | ||||
|     private var tokenType = "" | ||||
|  | ||||
|     private var accountID = "" | ||||
|  | ||||
|     private var policy = "" | ||||
|     private var signature = "" | ||||
|     private var keyPairID = "" | ||||
| @ -91,6 +96,42 @@ object Crunchyroll { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun requestPost( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         body: String | ||||
|     ) = coroutineScope { | ||||
|         val path = "$baseUrl$endpoint" | ||||
|  | ||||
|         // TODO before sending a request, make sure the accessToken is not expired | ||||
|         withContext(Dispatchers.IO) { | ||||
|             Fuel.post(path, params) | ||||
|                 .header("Authorization", "$tokenType $accessToken") | ||||
|                 .jsonBody(body) | ||||
|                 .response() // without a response, crunchy doesn't accept the request | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun requestDelete( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         url: String = "" | ||||
|     ) = coroutineScope { | ||||
|         val path = if (url.isEmpty()) "$baseUrl$endpoint" else url | ||||
|  | ||||
|         // TODO before sending a request, make sure the accessToken is not expired | ||||
|         withContext(Dispatchers.IO) { | ||||
|             Fuel.delete(path, params) | ||||
|                 .header("Authorization", "$tokenType $accessToken") | ||||
|                 .response() // without a response, crunchy doesn't accept the request | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Basic functions: index, account | ||||
|      * Needed for other functions to work properly! | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
|      * Retrieve the identifiers necessary for streaming. If the identifiers are | ||||
|      * retrieved, set the corresponding global var. The identifiers are valid for 24h. | ||||
| @ -110,6 +151,24 @@ object Crunchyroll { | ||||
|         println("keyPairID: $keyPairID") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Retrieve the account id and set the corresponding global var. | ||||
|      * The account id is needed for other calls. | ||||
|      * | ||||
|      * This must be execute on every start for teapod to work properly! | ||||
|      */ | ||||
|     suspend fun account() { | ||||
|         val indexEndpoint = "/accounts/v1/me" | ||||
|         val result = request(indexEndpoint) | ||||
|  | ||||
|         result.component1()?.obj()?.let { | ||||
|             accountID = it.get("account_id").toString() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Main media functions: browse, search, series, season, episodes, playback | ||||
|      */ | ||||
|  | ||||
|     // TODO locale de-DE, categories | ||||
|     /** | ||||
| @ -213,4 +272,59 @@ object Crunchyroll { | ||||
|         } ?: NonePlayback | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Additional media functions: watchlist, playhead | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
|      * Check if a media is in the user's watchlist. | ||||
|      * | ||||
|      * @param seriesId The crunchyroll series id of the media to check | ||||
|      * @return Boolean: ture if it was found, else false | ||||
|      */ | ||||
|     suspend fun isWatchlist(seriesId: String): Boolean { | ||||
|         val watchlistEndpoint = "/content/v1/watchlist/$accountID/$seriesId" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|  | ||||
|         val result = request(watchlistEndpoint, parameters) | ||||
|         // if needed implement parsing | ||||
|  | ||||
|         return result.component1()?.obj()?.has(seriesId) ?: false | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add a media to the user's watchlist. | ||||
|      * | ||||
|      * @param seriesId The crunchyroll series id of the media to check | ||||
|      */ | ||||
|     suspend fun postWatchlist(seriesId: String) { | ||||
|         val watchlistEndpoint = "/content/v1/watchlist/$accountID" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|  | ||||
|         val json = buildJsonObject { | ||||
|             put("content_id", seriesId) | ||||
|         } | ||||
|  | ||||
|         requestPost(watchlistEndpoint, parameters, json.toString()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Remove a media from the user's watchlist. | ||||
|      * | ||||
|      * @param seriesId The crunchyroll series id of the media to check | ||||
|      */ | ||||
|     suspend fun deleteWatchlist(seriesId: String) { | ||||
|         val watchlistEndpoint = "/content/v1/watchlist/$accountID/$seriesId" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|  | ||||
|         requestDelete(watchlistEndpoint, parameters) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * TODO | ||||
|      */ | ||||
|     suspend fun playhead() { | ||||
|         // implement | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -135,10 +135,6 @@ 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() } | ||||
| //                } | ||||
|  | ||||
|             // load all saved stuff here | ||||
|             Preferences.load(this) | ||||
| @ -150,7 +146,7 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|                 showOnboarding() | ||||
|             } else { | ||||
|                 Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password) | ||||
|                 runBlocking { Crunchyroll.index() } | ||||
|                 runBlocking { initCrunchyroll().joinAll() } | ||||
|             } | ||||
|  | ||||
| //            if (EncryptedPreferences.password.isEmpty()) { | ||||
| @ -179,6 +175,14 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|         wasInitialized = true | ||||
|     } | ||||
|  | ||||
|     private fun initCrunchyroll(): List<Job> { | ||||
|         val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) | ||||
|         return listOf( | ||||
|             scope.launch { Crunchyroll.index() }, | ||||
|             scope.launch { Crunchyroll.account() } | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private fun showLoginDialog() { | ||||
|         LoginDialog(this, false).positiveButton { | ||||
|             EncryptedPreferences.saveCredentials(login, password, context) | ||||
|  | ||||
| @ -17,11 +17,10 @@ import com.bumptech.glide.request.RequestOptions | ||||
| import com.google.android.material.appbar.AppBarLayout | ||||
| import com.google.android.material.tabs.TabLayoutMediator | ||||
| import jp.wasabeef.glide.transformations.BlurTransformation | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.FragmentMediaBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.Item | ||||
| import org.mosad.teapod.parser.crunchyroll.NoneItem | ||||
| import org.mosad.teapod.ui.activity.main.MainActivity | ||||
| import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel | ||||
| import org.mosad.teapod.util.tmdb.TMDBApiController | ||||
| @ -33,12 +32,13 @@ import org.mosad.teapod.util.tmdb.TMDBTVShow | ||||
|  * Note: the fragment is created only once, when selecting a similar title etc. | ||||
|  * therefore fragments may be not empty and model may be the old one | ||||
|  */ | ||||
| class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : Fragment() { | ||||
| class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|  | ||||
|     private lateinit var binding: FragmentMediaBinding | ||||
|     private lateinit var pagerAdapter: FragmentStateAdapter | ||||
|  | ||||
|     private val fragments = arrayListOf<Fragment>() | ||||
|     private var watchlistJobRunning = false | ||||
|  | ||||
|     private val model: MediaFragmentViewModel by activityViewModels() | ||||
|  | ||||
| @ -112,12 +112,9 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : | ||||
|             else -> "" | ||||
|         } | ||||
|  | ||||
|         // TODO set "my list" indicator | ||||
| //        if (StorageController.myList.contains(media.aodId)) { | ||||
| //            Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) | ||||
| //        } else { | ||||
| //            Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) | ||||
| //        } | ||||
|         // set "watchlist" indicator | ||||
|         val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 | ||||
|         Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction) | ||||
|  | ||||
|         // clear fragments, since it lives in onCreate scope (don't do this in onPause/onStop -> FragmentManager transaction) | ||||
|         val fragmentsSize = if (fragments.lastIndex < 0) 0 else fragments.lastIndex | ||||
| @ -223,20 +220,18 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : | ||||
|  | ||||
|         // add or remove media from myList | ||||
|         binding.linearMyListAction.setOnClickListener { | ||||
|             // TODO reimplement | ||||
| //            if (StorageController.myList.contains(media.aodId)) { | ||||
| //                StorageController.myList.remove(media.aodId) | ||||
| //                Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) | ||||
| //            } else { | ||||
| //                StorageController.myList.add(media.aodId) | ||||
| //                Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) | ||||
| //            } | ||||
| //            StorageController.saveMyList(requireContext()) | ||||
| // | ||||
| //            // notify home fragment on change | ||||
| //            parentFragmentManager.findFragmentByTag("HomeFragment")?.let { | ||||
| //                (it as HomeFragment).updateMyListMedia() | ||||
| //            } | ||||
|             // don't allow parallel execution | ||||
|             if (!watchlistJobRunning) { | ||||
|                 watchlistJobRunning = true | ||||
|                 lifecycleScope.launch { | ||||
|                     setWatchlist() | ||||
|  | ||||
|                     // update "watchlist" indicator | ||||
|                     val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 | ||||
|                     Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction) | ||||
|                     watchlistJobRunning = false | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -29,7 +29,10 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|     var episodesCrunchy = NoneEpisodes | ||||
|         internal set | ||||
|     val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates) | ||||
|     var isWatchlist = false | ||||
|         internal set | ||||
|  | ||||
|     // TMDB stuff | ||||
|     var mediaType = MediaType.OTHER | ||||
|         internal set | ||||
|     var tmdbResult: TMDBResult = NoneTMDB // TODO rename | ||||
| @ -47,7 +50,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         // load series and seasons info in parallel | ||||
|         listOf( | ||||
|             viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, | ||||
|             viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) } | ||||
|             viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) }, | ||||
|             viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) } | ||||
|         ).joinAll() | ||||
|  | ||||
|         println("series: $seriesCrunchy") | ||||
| @ -74,31 +78,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         loadTmdbInfo() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Set currentSeasonCrunchy based on the season id. Also set the new seasons episodes. | ||||
|      * | ||||
|      * @param seasonId the id of the season to set | ||||
|      */ | ||||
|     suspend fun setCurrentSeason(seasonId: String) { | ||||
|         // return if the id hasn't changed (performance) | ||||
|         if (currentSeasonCrunchy.id == seasonId) return | ||||
|  | ||||
|         // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, | ||||
|         // don't change the current season (this should/can never happen) | ||||
|         currentSeasonCrunchy = seasonsCrunchy.items.firstOrNull { | ||||
|             it.id == seasonId | ||||
|         } ?: currentSeasonCrunchy | ||||
|  | ||||
|         episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) | ||||
|         currentEpisodesCrunchy.clear() | ||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.items) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Load the tmdb info for the selected media. | ||||
|      * The TMDB search return a media type, use this to get the details (movie/tv show and season) | ||||
|      */ | ||||
|     suspend fun loadTmdbInfo() { | ||||
|     private suspend fun loadTmdbInfo() { | ||||
|         val tmdbApiController = TMDBApiController() | ||||
|  | ||||
|         val tmdbSearchResult = when(mediaType) { | ||||
| @ -124,6 +108,36 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
| //        } else NoneTMDBTVSeason | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Set currentSeasonCrunchy based on the season id. Also set the new seasons episodes. | ||||
|      * | ||||
|      * @param seasonId the id of the season to set | ||||
|      */ | ||||
|     suspend fun setCurrentSeason(seasonId: String) { | ||||
|         // return if the id hasn't changed (performance) | ||||
|         if (currentSeasonCrunchy.id == seasonId) return | ||||
|  | ||||
|         // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, | ||||
|         // don't change the current season (this should/can never happen) | ||||
|         currentSeasonCrunchy = seasonsCrunchy.items.firstOrNull { | ||||
|             it.id == seasonId | ||||
|         } ?: currentSeasonCrunchy | ||||
|  | ||||
|         episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) | ||||
|         currentEpisodesCrunchy.clear() | ||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.items) | ||||
|     } | ||||
|  | ||||
|     suspend fun setWatchlist() { | ||||
|         isWatchlist = if (isWatchlist) { | ||||
|             Crunchyroll.deleteWatchlist(seriesCrunchy.id) | ||||
|             false | ||||
|         } else { | ||||
|             Crunchyroll.postWatchlist(seriesCrunchy.id) | ||||
|             true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * get the next episode based on episodeId | ||||
|      * if no matching is found, use first episode | ||||
|  | ||||
		Reference in New Issue
	
	Block a user