add watchlist support for media fragment

This commit is contained in:
Jannik 2022-01-02 22:39:31 +01:00
parent d427691f6e
commit f2a798d4f7
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
4 changed files with 177 additions and 50 deletions

View File

@ -4,12 +4,15 @@ import android.util.Log
import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.Parameters 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.FuelJson
import com.github.kittinunf.fuel.json.responseJson import com.github.kittinunf.fuel.json.responseJson
import com.github.kittinunf.result.Result import com.github.kittinunf.result.Result
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import java.util.* import java.util.*
@ -22,6 +25,8 @@ object Crunchyroll {
private var accessToken = "" private var accessToken = ""
private var tokenType = "" private var tokenType = ""
private var accountID = ""
private var policy = "" private var policy = ""
private var signature = "" private var signature = ""
private var keyPairID = "" 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 * Retrieve the identifiers necessary for streaming. If the identifiers are
* retrieved, set the corresponding global var. The identifiers are valid for 24h. * retrieved, set the corresponding global var. The identifiers are valid for 24h.
@ -110,6 +151,24 @@ object Crunchyroll {
println("keyPairID: $keyPairID") 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 // TODO locale de-DE, categories
/** /**
@ -213,4 +272,59 @@ object Crunchyroll {
} ?: NonePlayback } ?: 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
}
} }

View File

@ -135,10 +135,6 @@ 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"))
// .async {
// launch { MetaDBController.list() }
// }
// load all saved stuff here // load all saved stuff here
Preferences.load(this) Preferences.load(this)
@ -150,7 +146,7 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
showOnboarding() showOnboarding()
} else { } else {
Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password) Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password)
runBlocking { Crunchyroll.index() } runBlocking { initCrunchyroll().joinAll() }
} }
// if (EncryptedPreferences.password.isEmpty()) { // if (EncryptedPreferences.password.isEmpty()) {
@ -179,6 +175,14 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
wasInitialized = true 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() { private fun showLoginDialog() {
LoginDialog(this, false).positiveButton { LoginDialog(this, false).positiveButton {
EncryptedPreferences.saveCredentials(login, password, context) EncryptedPreferences.saveCredentials(login, password, context)

View File

@ -17,11 +17,10 @@ import com.bumptech.glide.request.RequestOptions
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import jp.wasabeef.glide.transformations.BlurTransformation import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentMediaBinding 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.MainActivity
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
import org.mosad.teapod.util.tmdb.TMDBApiController 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. * 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 * 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 binding: FragmentMediaBinding
private lateinit var pagerAdapter: FragmentStateAdapter private lateinit var pagerAdapter: FragmentStateAdapter
private val fragments = arrayListOf<Fragment>() private val fragments = arrayListOf<Fragment>()
private var watchlistJobRunning = false
private val model: MediaFragmentViewModel by activityViewModels() private val model: MediaFragmentViewModel by activityViewModels()
@ -112,12 +112,9 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
else -> "" else -> ""
} }
// TODO set "my list" indicator // set "watchlist" indicator
// if (StorageController.myList.contains(media.aodId)) { val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24
// Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction)
// } else {
// Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction)
// }
// clear fragments, since it lives in onCreate scope (don't do this in onPause/onStop -> FragmentManager transaction) // 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 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 // add or remove media from myList
binding.linearMyListAction.setOnClickListener { binding.linearMyListAction.setOnClickListener {
// TODO reimplement // don't allow parallel execution
// if (StorageController.myList.contains(media.aodId)) { if (!watchlistJobRunning) {
// StorageController.myList.remove(media.aodId) watchlistJobRunning = true
// Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) lifecycleScope.launch {
// } else { setWatchlist()
// StorageController.myList.add(media.aodId)
// Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) // update "watchlist" indicator
// } val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24
// StorageController.saveMyList(requireContext()) Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction)
// watchlistJobRunning = false
// // notify home fragment on change }
// parentFragmentManager.findFragmentByTag("HomeFragment")?.let { }
// (it as HomeFragment).updateMyListMedia()
// }
} }
} }

View File

@ -29,7 +29,10 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
var episodesCrunchy = NoneEpisodes var episodesCrunchy = NoneEpisodes
internal set internal set
val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates) val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates)
var isWatchlist = false
internal set
// TMDB stuff
var mediaType = MediaType.OTHER var mediaType = MediaType.OTHER
internal set internal set
var tmdbResult: TMDBResult = NoneTMDB // TODO rename var tmdbResult: TMDBResult = NoneTMDB // TODO rename
@ -47,7 +50,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// load series and seasons info in parallel // load series and seasons info in parallel
listOf( listOf(
viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, 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() ).joinAll()
println("series: $seriesCrunchy") println("series: $seriesCrunchy")
@ -74,31 +78,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
loadTmdbInfo() 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. * 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) * 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 tmdbApiController = TMDBApiController()
val tmdbSearchResult = when(mediaType) { val tmdbSearchResult = when(mediaType) {
@ -124,6 +108,36 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// } else NoneTMDBTVSeason // } 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 * get the next episode based on episodeId
* if no matching is found, use first episode * if no matching is found, use first episode