implement preferred season/languag choosing in MediaFragment

This commit is contained in:
Jannik 2021-12-28 20:32:44 +01:00
parent 4fd6f9ca7e
commit ecbbc5db7b
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
9 changed files with 171 additions and 124 deletions

View File

@ -8,9 +8,9 @@ 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.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.mosad.teapod.preferences.Preferences
import java.util.* import java.util.*
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@ -27,10 +27,10 @@ object Crunchyroll {
private var keyPairID = "" private var keyPairID = ""
// TODO temp helper vary // TODO temp helper vary
var locale = "${Locale.GERMANY.language}-${Locale.GERMANY.country}" private var locale: String = Preferences.preferredLocal.toLanguageTag()
var country = Locale.GERMANY.country private var country: String = Preferences.preferredLocal.country
val browsingCache = arrayListOf<Item>() private val browsingCache = arrayListOf<Item>()
fun login(username: String, password: String): Boolean = runBlocking { fun login(username: String, password: String): Boolean = runBlocking {
val tokenEndpoint = "/auth/v1/token" val tokenEndpoint = "/auth/v1/token"

View File

@ -2,6 +2,7 @@ package org.mosad.teapod.parser.crunchyroll
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.*
/** /**
* data classes for browse * data classes for browse
@ -36,6 +37,7 @@ val NoneSearchResult = SearchResult(0, emptyList())
data class BrowseResult(val total: Int, val items: List<Item>) data class BrowseResult(val total: Int, val items: List<Item>)
// the data class Item is used in browse and search // the data class Item is used in browse and search
// TODO rename to MediaPanel
@Serializable @Serializable
data class Item( data class Item(
val id: String, val id: String,
@ -74,14 +76,38 @@ val NoneSeries = Series("", "", "", Images(listOf(), listOf()))
* Seasons data type * Seasons data type
*/ */
@Serializable @Serializable
data class Seasons(val total: Int, val items: List<Season>) data class Seasons(
val total: Int,
val items: List<Season>
) {
fun getPreferredSeasonId(local: Locale): String {
// try to get the the first seasons which matches the preferred local
items.forEach { season ->
if (season.title.startsWith("(${local.language})", true)) {
return season.id
}
}
// if there is no season with the preferred local, try to find a subbed season
items.forEach { season ->
if (season.isSubbed) {
return season.id
}
}
// if there is no preferred language season and no sub, use the first season
return items.first().id
}
}
@Serializable @Serializable
data class Season( data class Season(
val id: String, @SerialName("id") val id: String,
val title: String, @SerialName("title") val title: String,
val series_id: String, @SerialName("series_id") val seriesId: String,
val season_number: Int @SerialName("season_number") val seasonNumber: Int,
@SerialName("is_subbed") val isSubbed: Boolean,
@SerialName("is_dubbed") val isDubbed: Boolean,
) )
val NoneSeasons = Seasons(0, listOf()) val NoneSeasons = Seasons(0, listOf())
@ -101,7 +127,7 @@ data class Episode(
@SerialName("season_id") val seasonId: String, @SerialName("season_id") val seasonId: String,
@SerialName("season_number") val seasonNumber: Int, @SerialName("season_number") val seasonNumber: Int,
@SerialName("episode") val episode: String, @SerialName("episode") val episode: String,
@SerialName("episode_number") val episodeNumber: Int, @SerialName("episode_number") val episodeNumber: Int? = null,
@SerialName("description") val description: String, @SerialName("description") val description: String,
@SerialName("next_episode_id") val nextEpisodeId: String? = null, // default/nullable value since optional @SerialName("next_episode_id") val nextEpisodeId: String? = null, // default/nullable value since optional
@SerialName("next_episode_title") val nextEpisodeTitle: String? = null, // default/nullable value since optional @SerialName("next_episode_title") val nextEpisodeTitle: String? = null, // default/nullable value since optional

View File

@ -4,11 +4,14 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.util.DataTypes import org.mosad.teapod.util.DataTypes
import java.util.*
object Preferences { object Preferences {
var preferSecondary = false var preferSecondary = false
internal set internal set
var preferredLocal = Locale.GERMANY
internal set
var autoplay = true var autoplay = true
internal set internal set
var devSettings = false var devSettings = false

View File

@ -15,6 +15,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions 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 jp.wasabeef.glide.transformations.BlurTransformation import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.mosad.teapod.R import org.mosad.teapod.R
@ -56,14 +57,14 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
// fix material components issue #1878, if more tabs are added increase // fix material components issue #1878, if more tabs are added increase
binding.pagerEpisodesSimilar.offscreenPageLimit = 2 binding.pagerEpisodesSimilar.offscreenPageLimit = 2
binding.pagerEpisodesSimilar.adapter = pagerAdapter binding.pagerEpisodesSimilar.adapter = pagerAdapter
// TODO implement for cr media items // TODO is position 0 always episodes? (and 1 always similar titles)
// TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position ->
// tab.text = if (model.media.type == MediaType.TVSHOW && position == 0) { tab.text = when(position) {
// getString(R.string.episodes) 0 -> getString(R.string.episodes)
// } else { 1 -> getString(R.string.similar_titles)
// getString(R.string.similar_titles) else -> ""
// } }
// }.attach() }.attach()
lifecycleScope.launch { lifecycleScope.launch {
model.loadCrunchy(mediaIdStr) model.loadCrunchy(mediaIdStr)
@ -77,9 +78,10 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
super.onResume() super.onResume()
// update the next ep text if there is one, since it may have changed // update the next ep text if there is one, since it may have changed
if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { // TODO reimplement
binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title // if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) {
} // binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title
// }
} }
/** /**
@ -88,9 +90,9 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
private fun updateGUI() = with(model) { private fun updateGUI() = with(model) {
// generic gui // generic gui
val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it } val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it }
?: mediaCrunchy.images.poster_wide[0][2].source ?: seriesCrunchy.images.poster_wide[0][2].source
val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it } val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it }
?: mediaCrunchy.images.poster_tall[0][2].source ?: seriesCrunchy.images.poster_tall[0][2].source
// load poster and backdrop // load poster and backdrop
Glide.with(requireContext()).load(posterUrl) Glide.with(requireContext()).load(posterUrl)
@ -100,65 +102,74 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
.apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3)))
.into(binding.imageBackdrop) .into(binding.imageBackdrop)
binding.textTitle.text = mediaCrunchy.title binding.textTitle.text = seriesCrunchy.title
//binding.textYear.text = media.year.toString() // TODO //binding.textYear.text = media.year.toString() // TODO get from tmdb
//binding.textAge.text = media.age.toString() // TODO //binding.textAge.text = media.age.toString() // TODO get from tmdb
binding.textOverview.text = mediaCrunchy.description binding.textOverview.text = seriesCrunchy.description
// TODO set "my list" indicator // TODO set "my list" indicator
if (StorageController.myList.contains(media.aodId)) { // if (StorageController.myList.contains(media.aodId)) {
Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) // Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction)
} else { // } else {
Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) // 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
fragments.clear() fragments.clear()
pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize) pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize)
// specific gui
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 // add the episodes fragment (as tab)
// binding.textTitle.text = media.getEpisodeById(nextEpisodeId).title MediaFragmentEpisodes().also {
fragments.add(it)
// episodes count pagerAdapter.notifyItemInserted(fragments.indexOf(it))
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
}
} }
// TODO reimplement via tmdb/metaDB
// specific gui
// 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
if (media.similar.isNotEmpty()) { // TODO reimplement
MediaFragmentSimilar().also { // if (media.similar.isNotEmpty()) {
fragments.add(it) // MediaFragmentSimilar().also {
pagerAdapter.notifyItemInserted(fragments.indexOf(it)) // fragments.add(it)
} // pagerAdapter.notifyItemInserted(fragments.indexOf(it))
} // }
// }
// disable scrolling on appbar, if no tabs where added // disable scrolling on appbar, if no tabs where added
if(fragments.isEmpty()) { if(fragments.isEmpty()) {
@ -171,28 +182,30 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
private fun initActions() = with(model) { private fun initActions() = with(model) {
binding.buttonPlay.setOnClickListener { binding.buttonPlay.setOnClickListener {
when (media.type) { // TODO reimplement
//MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) // TODO // when (media.type) {
//MediaType.TVSHOW -> playEpisode(nextEpisodeId) // TODO // MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId)
else -> Log.e(javaClass.name, "Wrong Type: ${media.type}") // 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
binding.linearMyListAction.setOnClickListener { binding.linearMyListAction.setOnClickListener {
if (StorageController.myList.contains(media.aodId)) { // TODO reimplement
StorageController.myList.remove(media.aodId) // if (StorageController.myList.contains(media.aodId)) {
Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) // StorageController.myList.remove(media.aodId)
} else { // Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction)
StorageController.myList.add(media.aodId) // } else {
Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) // StorageController.myList.add(media.aodId)
} // Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction)
StorageController.saveMyList(requireContext()) // }
// StorageController.saveMyList(requireContext())
// notify home fragment on change //
parentFragmentManager.findFragmentByTag("HomeFragment")?.let { // // notify home fragment on change
(it as HomeFragment).updateMyListMedia() // parentFragmentManager.findFragmentByTag("HomeFragment")?.let {
} // (it as HomeFragment).updateMyListMedia()
// }
} }
} }

View File

@ -44,10 +44,11 @@ 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) {
model.media.playlist.forEachIndexed { index, episodeInfo -> // TODO reimplement, if needed
adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index) // model.media.playlist.forEachIndexed { index, episodeInfo ->
} // adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index)
adapterRecEpisodes.notifyDataSetChanged() // }
// adapterRecEpisodes.notifyDataSetChanged()
} }
} }

View File

@ -27,7 +27,7 @@ class MediaFragmentSimilar : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapterSimilar = MediaItemAdapter(model.media.similar) adapterSimilar = MediaItemAdapter(emptyList()) //(model.media.similar)
binding.recyclerMediaSimilar.adapter = adapterSimilar binding.recyclerMediaSimilar.adapter = adapterSimilar
binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9))

View File

@ -3,9 +3,16 @@ package org.mosad.teapod.ui.activity.main.viewmodel
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import org.mosad.teapod.parser.crunchyroll.* import androidx.lifecycle.viewModelScope
import org.mosad.teapod.util.* import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.parser.crunchyroll.NoneEpisodes
import org.mosad.teapod.parser.crunchyroll.NoneSeasons
import org.mosad.teapod.parser.crunchyroll.NoneSeries
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.DataTypes.MediaType import org.mosad.teapod.util.DataTypes.MediaType
import org.mosad.teapod.util.Meta
import org.mosad.teapod.util.tmdb.TMDBApiController import org.mosad.teapod.util.tmdb.TMDBApiController
import org.mosad.teapod.util.tmdb.TMDBResult import org.mosad.teapod.util.tmdb.TMDBResult
import org.mosad.teapod.util.tmdb.TMDBTVSeason import org.mosad.teapod.util.tmdb.TMDBTVSeason
@ -16,12 +23,9 @@ import org.mosad.teapod.util.tmdb.TMDBTVSeason
*/ */
class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) {
var media = AoDMediaNone // var mediaCrunchy = NoneItem
internal set // internal set
var nextEpisodeId = -1 var seriesCrunchy = NoneSeries // TODO it seems movies also series?
internal set
var mediaCrunchy = NoneItem
internal set internal set
var seasonsCrunchy = NoneSeasons var seasonsCrunchy = NoneSeasons
internal set internal set
@ -35,34 +39,31 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
var mediaMeta: Meta? = null var mediaMeta: Meta? = null
internal set internal set
/**
* @param crunchyId the crunchyroll series id
*/
suspend fun loadCrunchy(crunchyId: String) { suspend fun loadCrunchy(crunchyId: String) {
val tmdbApiController = TMDBApiController() val tmdbApiController = TMDBApiController()
println("loading crunchyroll media $crunchyId") // load series and seasons info in parallel
listOf(
viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) },
viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) }
).joinAll()
// TODO info also in browse result item println("series: $seriesCrunchy")
// TODO doesn't support search
mediaCrunchy = Crunchyroll.browsingCache.find { it ->
it.id == crunchyId
} ?: NoneItem
println("media: $mediaCrunchy")
// load seasons
seasonsCrunchy = Crunchyroll.seasons(crunchyId)
println("seasons: $seasonsCrunchy") println("seasons: $seasonsCrunchy")
// load first season // load the preferred season (preferred language, language per season, not per stream)
// TODO make sure to load the preferred season (language), language is set per season, not per stream val preferredSeasonId = seasonsCrunchy.getPreferredSeasonId(Preferences.preferredLocal)
episodesCrunchy = Crunchyroll.episodes(seasonsCrunchy.items.first().id) episodesCrunchy = Crunchyroll.episodes(preferredSeasonId)
println("episodes: $episodesCrunchy") println("episodes: $episodesCrunchy")
// TODO check if metaDB knows the title // TODO check if metaDB knows the title
// use tmdb search to get media info TODO media type is hardcoded, use type info from browse result once implemented // use tmdb search to get media info TODO media type is hardcoded, use episodeNumber? (if null it should be a movie)
mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media
val tmdbId = tmdbApiController.search(mediaCrunchy.title, MediaType.TVSHOW) val tmdbId = tmdbApiController.search(seriesCrunchy.title, MediaType.TVSHOW)
tmdbResult = when (MediaType.TVSHOW) { tmdbResult = when (MediaType.TVSHOW) {
MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId)
@ -122,10 +123,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
* if no matching is found, use first episode * if no matching is found, use first episode
*/ */
fun updateNextEpisode(episodeId: Int) { fun updateNextEpisode(episodeId: Int) {
if (media.type == MediaType.MOVIE) return // return if movie // TODO reimplement if needed
// if (media.type == MediaType.MOVIE) return // return if movie
nextEpisodeId = media.playlist.firstOrNull { it.index > media.getEpisodeById(episodeId).index }?.mediaId //
?: media.playlist.first().mediaId // nextEpisodeId = media.playlist.firstOrNull { it.index > media.getEpisodeById(episodeId).index }?.mediaId
// ?: media.playlist.first().mediaId
} }
// remove unneeded info from the media title before searching // remove unneeded info from the media title before searching

View File

@ -168,7 +168,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
currentEpisodeChangedListener.forEach { it() } currentEpisodeChangedListener.forEach { it() }
// get preferred stream url TODO implement // get preferred stream url TODO implement
val url = currentPlayback.streams.adaptive_hls["en-US"]?.url ?: "" val localeKey = Preferences.preferredLocal.toLanguageTag()
val url = currentPlayback.streams.adaptive_hls[localeKey]?.url
?: currentPlayback.streams.adaptive_hls[""]?.url ?: ""
println("stream url: $url") println("stream url: $url")
// create the media source object // create the media source object

View File

@ -34,7 +34,7 @@ class EpisodesListPlayer @JvmOverloads constructor(
model.setCurrentEpisode(episodeId, startPlayback = true) model.setCurrentEpisode(episodeId, startPlayback = true)
} }
// episodeNumber starts at 1, we need the episode index -> - 1 // episodeNumber starts at 1, we need the episode index -> - 1
adapterRecEpisodes.currentSelected = (model.currentEpisode.episodeNumber - 1) adapterRecEpisodes.currentSelected = model.currentEpisode.episodeNumber?.minus(1) ?: 0
binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes
binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected) binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected)