crunchyroll support #49

Merged
Seil0 merged 42 commits from feature/crunchyroll into develop 2022-03-19 20:42:54 +01:00
6 changed files with 125 additions and 78 deletions
Showing only changes of commit 4fed3ddb91 - Show all commits

View File

@ -14,7 +14,7 @@ android {
minSdkVersion 23
targetSdkVersion 30
versionCode 4200 //00.04.200
versionName "1.0.0-alpha2"
versionName "1.0.0-alpha3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime()

View File

@ -295,6 +295,23 @@ object Crunchyroll {
} ?: NoneSeries
}
/**
* TODO
*/
suspend fun upNextSeries(seriesId: String): UpNextSeriesItem {
val upNextSeriesEndpoint = "/content/v1/up_next_series"
val parameters = listOf(
"series_id" to seriesId,
"locale" to locale
)
val result = request(upNextSeriesEndpoint, parameters)
return result.component1()?.obj()?.let {
json.decodeFromString(it.toString())
} ?: NoneUpNextSeriesItem
}
suspend fun seasons(seriesId: String): Seasons {
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
val parameters = listOf(
@ -404,6 +421,18 @@ object Crunchyroll {
} ?: emptyMap()
}
suspend fun postPlayheads(episodeId: String, playhead: Int) {
val playheadsEndpoint = "/content/v1/playheads/$accountID"
val parameters = listOf("locale" to locale)
val json = buildJsonObject {
put("content_id", episodeId)
put("playhead", playhead)
}
requestPost(playheadsEndpoint, parameters, json.toString())
}
/**
* Listing functions: watchlist (list), up_next_account
*/

View File

@ -31,6 +31,14 @@ typealias DiscSeasonList = Collection<SeasonListItem>
typealias Watchlist = Collection<Item>
typealias ContinueWatchingList = Collection<ContinueWatchingItem>
@Serializable
data class UpNextSeriesItem(
val playhead: Int,
val fully_watched: Boolean,
val never_watched: Boolean,
val panel: EpisodePanel,
)
/**
* panel data classes
*/
@ -73,17 +81,18 @@ data class SeasonListLocalization(
/**
* continue_watching_item data classes
*/
@Serializable
data class ContinueWatchingItem(
@SerialName("panel") val panel: EpisodePanel,
@SerialName("new") val new: Boolean,
@SerialName("new_content") val newContent: Boolean,
// not present in up_next_account's continue_watching_item
// not present in up_next_account -> continue_watching_item
// @SerialName("is_favorite") val isFavorite: Boolean,
// @SerialName("never_watched") val neverWatched: Boolean,
// @SerialName("completion_status") val completionStatus: Boolean,
@SerialName("playhead") val playhead: Int,
// not present in watchlist -> continue_watching_item
// @SerialName("fully_watched") val fullyWatched: Boolean,
)
// EpisodePanel is used in ContinueWatchingItem
@ -94,24 +103,31 @@ data class EpisodePanel(
@SerialName("type") val type: String,
@SerialName("channel_id") val channelId: String,
@SerialName("description") val description: String,
@SerialName("images") val images: Thumbnail,
@SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata,
@SerialName("images") val images: Thumbnail,
@SerialName("playback") val playback: String,
)
@Serializable
data class EpisodeMetadata(
@SerialName("duration_ms") val durationMs: Int,
@SerialName("season_id") val seasonId: String,
@SerialName("series_id") val seriesId: String,
@SerialName("series_title") val seriesTitle: String,
)
val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList()))
val NoneEpisodeMetadata = EpisodeMetadata(0, "", "", "")
val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "")
val NoneCollection = Collection<Item>(0, emptyList())
val NoneSearchResult = SearchResult(0, emptyList())
val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneWatchlist = Watchlist(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel)
/**
* Series data type
*/
@ -163,7 +179,7 @@ data class Season(
@SerialName("is_dubbed") val isDubbed: Boolean,
)
val NoneSeasons = Seasons(0, listOf())
val NoneSeasons = Seasons(0, emptyList())
val NoneSeason = Season("", "", "", 0, isSubbed = false, isDubbed = false)

View File

@ -17,10 +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.NoneUpNextSeriesItem
import org.mosad.teapod.ui.activity.main.MainActivity
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
import org.mosad.teapod.util.tmdb.TMDBApiController
@ -37,19 +37,23 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private lateinit var binding: FragmentMediaBinding
private lateinit var pagerAdapter: FragmentStateAdapter
private val model: MediaFragmentViewModel by activityViewModels()
private val fragments = arrayListOf<Fragment>()
private var watchlistJobRunning = false
private var runOnResume = false
private val model: MediaFragmentViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentMediaBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
println("onViewCreated")
binding.frameLoading.visibility = View.VISIBLE
// tab layout and pager
@ -77,11 +81,21 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
override fun onResume() {
super.onResume()
// update the next ep text if there is one, since it may have changed
// TODO reimplement
// if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) {
// binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title
// }
if (runOnResume) {
lifecycleScope.launch {
model.updateOnResume()
if (model.upNextSeries != NoneUpNextSeriesItem) {
binding.textTitle.text = model.upNextSeries.panel.title
}
if (fragments.elementAtOrNull(0) is MediaFragmentEpisodes) {
(fragments[0] as MediaFragmentEpisodes).updateWatchedState()
}
}
} else {
runOnResume = true
}
}
/**
@ -102,15 +116,17 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
.apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3)))
.into(binding.imageBackdrop)
binding.textTitle.text = seriesCrunchy.title
binding.textOverview.text = seriesCrunchy.description
binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull()
binding.textYear.text = when(tmdbResult) {
is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate.substring(0, 4)
is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4)
else -> ""
}
binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull()
binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesItem) {
upNextSeries.panel.title
} else seriesCrunchy.title
binding.textOverview.text = seriesCrunchy.description
// set "watchlist" indicator
val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24
@ -127,8 +143,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
pagerAdapter.notifyItemInserted(fragments.indexOf(it))
}
// TODO reimplement via tmdb/metaDB
// specific gui
// specific gui (via tmdb)
when (tmdbResult) {
is TMDBTVShow -> {
// episodes count
@ -156,40 +171,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
}
}
// if (mediaCrunchy.type == MediaType.TVSHOW.str) {
// // TODO get next episode
//// nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId
//// ?: media.playlist.first().mediaId
//
// // TODO title is the next episodes title
//// binding.textTitle.text = media.getEpisodeById(nextEpisodeId).title
//
// // episodes count
// binding.textEpisodesOrRuntime.text = resources.getQuantityString(
// R.plurals.text_episodes_count,
// episodesCrunchy.total,
// episodesCrunchy.total
// )
//
// // episodes
// MediaFragmentEpisodes().also {
// fragments.add(it)
// pagerAdapter.notifyItemInserted(fragments.indexOf(it))
// }
// } else if (media.type == MediaType.MOVIE) {
// val tmdbMovie = (tmdbResult as TMDBMovie?)
//
// if (tmdbMovie?.runtime != null) {
// binding.textEpisodesOrRuntime.text = resources.getQuantityString(
// R.plurals.text_runtime,
// tmdbMovie.runtime,
// tmdbMovie.runtime
// )
// } else {
// binding.textEpisodesOrRuntime.visibility = View.GONE
// }
// }
// if has similar titles
// TODO reimplement
// if (media.similar.isNotEmpty()) {
@ -210,12 +191,9 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private fun initActions() = with(model) {
binding.buttonPlay.setOnClickListener {
// TODO reimplement
// when (media.type) {
// MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId)
// MediaType.TVSHOW -> playEpisode(nextEpisodeId)
// else -> Log.e(javaClass.name, "Wrong Type: ${media.type}")
// }
if (upNextSeries != NoneUpNextSeriesItem) {
playEpisode(upNextSeries.panel.episodeMetadata.seasonId, upNextSeries.panel.id)
}
}
// add or remove media from myList

View File

@ -60,6 +60,7 @@ class MediaFragmentEpisodes : Fragment() {
// if adapterRecEpisodes is initialized, update the watched state for the episodes
if (this::adapterRecEpisodes.isInitialized) {
// TODO reimplement, if needed
// update via playheads?
// model.media.playlist.forEachIndexed { index, episodeInfo ->
// adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index)
// }
@ -67,6 +68,11 @@ class MediaFragmentEpisodes : Fragment() {
}
}
fun updateWatchedState() {
// TODO update watched state of all episodes
// use a mutable list for playheads and notify dataset changed
}
private fun showSeasonSelection(v: View) {
// TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus
val popup = PopupMenu(requireContext(), v)

View File

@ -3,9 +3,9 @@ package org.mosad.teapod.ui.activity.main.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import org.mosad.teapod.parser.crunchyroll.*
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.DataTypes.MediaType
@ -29,9 +29,12 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
var episodesCrunchy = NoneEpisodes
internal set
val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates)
// additional media info
var currentPlayheads: PlayheadsMap = emptyMap()
var isWatchlist = false
internal set
var upNextSeries = NoneUpNextSeriesItem
// TMDB stuff
var mediaType = MediaType.OTHER
@ -52,35 +55,40 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
listOf(
viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) },
viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) },
viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) }
viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) },
viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(crunchyId) }
).joinAll()
println("series: $seriesCrunchy")
println("seasons: $seasonsCrunchy")
// TODO load episodes, metaDB and tmdb in parallel
// println("series: $seriesCrunchy")
// println("seasons: $seasonsCrunchy")
println(upNextSeries)
// load the preferred season (preferred language, language per season, not per stream)
currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal)
episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id)
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
listOf(
viewModelScope.launch { episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) },
viewModelScope.launch { mediaMeta = null }, // TODO metaDB
).joinAll()
// println("episodes: $episodesCrunchy")
currentEpisodesCrunchy.clear()
currentEpisodesCrunchy.addAll(episodesCrunchy.items)
println("episodes: $episodesCrunchy")
// get playheads (including fully watched state)
val episodeIDs = episodesCrunchy.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
// set media type
mediaType = episodesCrunchy.items.firstOrNull()?.let {
if (it.episodeNumber != null) MediaType.TVSHOW else MediaType.MOVIE
} ?: MediaType.OTHER
// TODO check if metaDB knows the title
mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media
// use tmdb search to get media info
loadTmdbInfo()
// load playheads and tmdb in parallel
listOf(
viewModelScope.launch {
// get playheads (including fully watched state)
val episodeIDs = episodesCrunchy.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
},
viewModelScope.launch { loadTmdbInfo() } // use tmdb search to get media info
).joinAll()
}
/**
@ -143,6 +151,16 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
}
}
suspend fun updateOnResume(): List<Job> {
return listOf(
viewModelScope.launch {
val episodeIDs = episodesCrunchy.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
},
viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(seriesCrunchy.id) }
)
}
/**
* get the next episode based on episodeId
* if no matching is found, use first episode