add upNextSeries

the MediaFragment will show the next episodes title instead for the series title and play the "next up" episode when the play button is clicked
This commit is contained in:
Jannik 2022-01-09 18:41:23 +01:00
parent e98e75456e
commit a10287f747
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
6 changed files with 125 additions and 78 deletions

View File

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

View File

@ -295,6 +295,23 @@ object Crunchyroll {
} ?: NoneSeries } ?: 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 { suspend fun seasons(seriesId: String): Seasons {
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons" val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
val parameters = listOf( val parameters = listOf(
@ -404,6 +421,18 @@ object Crunchyroll {
} ?: emptyMap() } ?: 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 * Listing functions: watchlist (list), up_next_account
*/ */

View File

@ -31,6 +31,14 @@ typealias DiscSeasonList = Collection<SeasonListItem>
typealias Watchlist = Collection<Item> typealias Watchlist = Collection<Item>
typealias ContinueWatchingList = Collection<ContinueWatchingItem> 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 * panel data classes
*/ */
@ -73,17 +81,18 @@ data class SeasonListLocalization(
/** /**
* continue_watching_item data classes * continue_watching_item data classes
*/ */
@Serializable @Serializable
data class ContinueWatchingItem( data class ContinueWatchingItem(
@SerialName("panel") val panel: EpisodePanel, @SerialName("panel") val panel: EpisodePanel,
@SerialName("new") val new: Boolean, @SerialName("new") val new: Boolean,
@SerialName("new_content") val newContent: 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("is_favorite") val isFavorite: Boolean,
// @SerialName("never_watched") val neverWatched: Boolean, // @SerialName("never_watched") val neverWatched: Boolean,
// @SerialName("completion_status") val completionStatus: Boolean, // @SerialName("completion_status") val completionStatus: Boolean,
@SerialName("playhead") val playhead: Int, @SerialName("playhead") val playhead: Int,
// not present in watchlist -> continue_watching_item
// @SerialName("fully_watched") val fullyWatched: Boolean,
) )
// EpisodePanel is used in ContinueWatchingItem // EpisodePanel is used in ContinueWatchingItem
@ -94,24 +103,31 @@ data class EpisodePanel(
@SerialName("type") val type: String, @SerialName("type") val type: String,
@SerialName("channel_id") val channelId: String, @SerialName("channel_id") val channelId: String,
@SerialName("description") val description: String, @SerialName("description") val description: String,
@SerialName("images") val images: Thumbnail,
@SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata, @SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata,
@SerialName("images") val images: Thumbnail,
@SerialName("playback") val playback: String,
) )
@Serializable @Serializable
data class EpisodeMetadata( data class EpisodeMetadata(
@SerialName("duration_ms") val durationMs: Int,
@SerialName("season_id") val seasonId: String,
@SerialName("series_id") val seriesId: String, @SerialName("series_id") val seriesId: String,
@SerialName("series_title") val seriesTitle: String, @SerialName("series_title") val seriesTitle: String,
) )
val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList())) val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList()))
val NoneEpisodeMetadata = EpisodeMetadata(0, "", "", "")
val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "")
val NoneCollection = Collection<Item>(0, emptyList()) val NoneCollection = Collection<Item>(0, emptyList())
val NoneSearchResult = SearchResult(0, emptyList()) val NoneSearchResult = SearchResult(0, emptyList())
val NoneBrowseResult = BrowseResult(0, emptyList()) val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList()) val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneWatchlist = Watchlist(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel)
/** /**
* Series data type * Series data type
*/ */
@ -163,7 +179,7 @@ data class Season(
@SerialName("is_dubbed") val isDubbed: Boolean, @SerialName("is_dubbed") val isDubbed: Boolean,
) )
val NoneSeasons = Seasons(0, listOf()) val NoneSeasons = Seasons(0, emptyList())
val NoneSeason = Season("", "", "", 0, isSubbed = false, isDubbed = false) 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.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.NoneUpNextSeriesItem
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
@ -37,19 +37,23 @@ 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 model: MediaFragmentViewModel by activityViewModels()
private val fragments = arrayListOf<Fragment>() private val fragments = arrayListOf<Fragment>()
private var watchlistJobRunning = false private var watchlistJobRunning = false
private var runOnResume = false
private val model: MediaFragmentViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentMediaBinding.inflate(inflater, container, false) binding = FragmentMediaBinding.inflate(inflater, container, false)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
println("onViewCreated")
binding.frameLoading.visibility = View.VISIBLE binding.frameLoading.visibility = View.VISIBLE
// tab layout and pager // tab layout and pager
@ -77,11 +81,21 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// update the next ep text if there is one, since it may have changed if (runOnResume) {
// TODO reimplement lifecycleScope.launch {
// if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { model.updateOnResume()
// binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title
// } 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))) .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3)))
.into(binding.imageBackdrop) .into(binding.imageBackdrop)
binding.textTitle.text = seriesCrunchy.title
binding.textOverview.text = seriesCrunchy.description
binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull()
binding.textYear.text = when(tmdbResult) { binding.textYear.text = when(tmdbResult) {
is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate.substring(0, 4) is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate.substring(0, 4)
is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4) is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4)
else -> "" 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 // set "watchlist" indicator
val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 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)) pagerAdapter.notifyItemInserted(fragments.indexOf(it))
} }
// TODO reimplement via tmdb/metaDB // specific gui (via tmdb)
// specific gui
when (tmdbResult) { when (tmdbResult) {
is TMDBTVShow -> { is TMDBTVShow -> {
// episodes count // 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 // if has similar titles
// TODO reimplement // TODO reimplement
// if (media.similar.isNotEmpty()) { // if (media.similar.isNotEmpty()) {
@ -210,12 +191,9 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private fun initActions() = with(model) { private fun initActions() = with(model) {
binding.buttonPlay.setOnClickListener { binding.buttonPlay.setOnClickListener {
// TODO reimplement if (upNextSeries != NoneUpNextSeriesItem) {
// when (media.type) { playEpisode(upNextSeries.panel.episodeMetadata.seasonId, upNextSeries.panel.id)
// MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) }
// MediaType.TVSHOW -> playEpisode(nextEpisodeId)
// else -> Log.e(javaClass.name, "Wrong Type: ${media.type}")
// }
} }
// add or remove media from myList // 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 adapterRecEpisodes is initialized, update the watched state for the episodes
if (this::adapterRecEpisodes.isInitialized) { if (this::adapterRecEpisodes.isInitialized) {
// TODO reimplement, if needed // TODO reimplement, if needed
// update via playheads?
// model.media.playlist.forEachIndexed { index, episodeInfo -> // model.media.playlist.forEachIndexed { index, episodeInfo ->
// adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index) // 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) { private fun showSeasonSelection(v: View) {
// TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus // TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus
val popup = PopupMenu(requireContext(), v) val popup = PopupMenu(requireContext(), v)

View File

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