diff --git a/app/build.gradle b/app/build.gradle index 9d9704f..7d7b6de 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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() diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index 9cf0ffa..a5b6bb6 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -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 */ diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt index 8ef866e..c0335c7 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt @@ -31,6 +31,14 @@ typealias DiscSeasonList = Collection typealias Watchlist = Collection typealias ContinueWatchingList = Collection +@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(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) diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt index d3c9be7..0211d6d 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt @@ -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() 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 diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentEpisodes.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentEpisodes.kt index 4a43ded..b18914a 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentEpisodes.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentEpisodes.kt @@ -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) diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt index b6f0afd..ae43cba 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt @@ -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() // 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 { + 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