diff --git a/app/build.gradle b/app/build.gradle index 76818ad..7e5ec68 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ android { minSdkVersion 23 targetSdkVersion 30 versionCode 4200 //00.04.200 - versionName "0.4.2" + versionName "0.5.0-alpha1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resValue "string", "build_time", buildTime() diff --git a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt index d68a8fb..03fa36a 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -31,7 +31,6 @@ import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.util.* import org.mosad.teapod.util.DataTypes.MediaType import java.io.IOException -import java.lang.NumberFormatException import java.util.* import kotlin.random.Random @@ -48,8 +47,10 @@ object AoDParser { private var csrfToken: String = "" private var loginSuccess = false - private val mediaList = arrayListOf() // actual media (data) - val itemMediaList = arrayListOf() // gui media + private val aodMediaList = arrayListOf() // actual media (data) + + // gui media + val guiMediaList = arrayListOf() val highlightsList = arrayListOf() val newEpisodesList = arrayListOf() val newSimulcastsList = arrayListOf() @@ -108,16 +109,19 @@ object AoDParser { /** * get a media by it's ID (int) - * @return Media + * @param aodId The AoD ID of the requested media + * @return returns a AoDMedia of type Movie or TVShow if found, else return AoDMediaNone */ - suspend fun getMediaById(mediaId: Int): Media { - val media = mediaList.first { it.id == mediaId } - - if (media.episodes.isEmpty()) { - loadStreams(media).join() + suspend fun getMediaById(aodId: Int): AoDMedia { + return aodMediaList.firstOrNull { it.aodId == aodId } ?: + try { + loadMediaAsync(aodId).await().apply { + aodMediaList.add(this) + } + } catch (exn:NullPointerException) { + Log.e(javaClass.name, "Error while loading media $aodId", exn) + AoDMediaNone } - - return media } /** @@ -140,12 +144,12 @@ object AoDParser { return baseUrl + subscriptionPath } - suspend fun markAsWatched(mediaId: Int, episodeId: Int) { - val episode = getMediaById(mediaId).getEpisodeById(episodeId) + suspend fun markAsWatched(aodId: Int, episodeId: Int) { + val episode = getMediaById(aodId).getEpisodeById(episodeId) episode.watched = true sendCallback(episode.watchedCallback) - Log.d(javaClass.name, "Marked episode ${episode.id} as watched") + Log.d(javaClass.name, "Marked episode ${episode.mediaId} as watched") } // TODO don't use jsoup here @@ -180,29 +184,22 @@ object AoDParser { val resAnimes = Jsoup.connect(baseUrl + libraryPath).get() //println(resAnimes) - itemMediaList.clear() - mediaList.clear() - resAnimes.select("div.animebox").forEach { - val type = if (it.select("p.animebox-link").select("a").text().lowercase(Locale.ROOT) == "zur serie") { - MediaType.TVSHOW - } else { - MediaType.MOVIE + guiMediaList.clear() + val animes = resAnimes.select("div.animebox") + + guiMediaList.addAll( + animes.map { + ItemMedia( + id = it.select("p.animebox-link").select("a") + .attr("href").substringAfterLast("/").toInt(), + title = it.select("h3.animebox-title").text(), + posterUrl = it.select("p.animebox-image").select("img") + .attr("src") + ) } - val mediaTitle = it.select("h3.animebox-title").text() - val mediaLink = it.select("p.animebox-link").select("a").attr("href") - val mediaImage = it.select("p.animebox-image").select("img").attr("src") - val mediaShortText = it.select("p.animebox-shorttext").text() - val mediaId = mediaLink.substringAfterLast("/").toInt() + ) - itemMediaList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) - mediaList.add(Media(mediaId, mediaLink, type).apply { - info.title = mediaTitle - info.posterUrl = mediaImage - info.shortDesc = mediaShortText - }) - } - - Log.i(javaClass.name, "Total library size is: ${mediaList.size}") + Log.i(javaClass.name, "Total library size is: ${guiMediaList.size}") } } @@ -292,87 +289,63 @@ object AoDParser { } /** - * TODO rework the media loading process, don't modify media object * TODO catch SocketTimeoutException from loading to show a waring dialog - * load streams for the media path, movies have one episode - * @param media is used as call ba reference + * Load media async. Every media has a playlist. + * @param aodId The AoD ID of the requested media */ - private suspend fun loadStreams(media: Media) = coroutineScope { - launch(Dispatchers.IO) { - if (sessionCookies.isEmpty()) login() + private suspend fun loadMediaAsync(aodId: Int): Deferred = coroutineScope { + return@coroutineScope async (Dispatchers.IO) { + if (sessionCookies.isEmpty()) login() // TODO is this needed? + // return none object, if login wasn't successful if (!loginSuccess) { - Log.w(javaClass.name, "Login, was not successful.") - return@launch + Log.w(javaClass.name, "Login was not successful") + return@async AoDMediaNone } // get the media page - val res = Jsoup.connect(baseUrl + media.link) + val res = Jsoup.connect("$baseUrl/anime/$aodId") .cookies(sessionCookies) .get() - - //println(res) + // println(res) if (csrfToken.isEmpty()) { csrfToken = res.select("meta[name=csrf-token]").attr("content") - //Log.i(javaClass.name, "New csrf token is $csrfToken") + Log.d(javaClass.name, "New csrf token is $csrfToken") } + // playlist parsing TODO can this be async to the general info parsing? val besides = res.select("div.besides").first() - val playlists = besides.select("input.streamstarter_html5").map { streamstarter -> + val aodPlaylists = besides.select("input.streamstarter_html5").map { streamstarter -> parsePlaylistAsync( streamstarter.attr("data-playlist"), streamstarter.attr("data-lang") ) - }.awaitAll() - - playlists.forEach { aod -> - // TODO improve language handling - val locale = when (aod.extLanguage) { - "ger" -> Locale.GERMAN - "jap" -> Locale.JAPANESE - else -> Locale.ROOT - } - - aod.playlist.forEach { ep -> - try { - if (media.hasEpisode(ep.mediaid)) { - media.getEpisodeById(ep.mediaid).streams.add( - Stream(ep.sources.first().file, locale) - ) - } else { - media.episodes.add(Episode( - id = ep.mediaid, - streams = mutableListOf(Stream(ep.sources.first().file, locale)), - posterUrl = ep.image, - title = ep.title, - description = ep.description, - number = getNumberFromTitle(ep.title, media.type) - )) - } - } catch (ex: Exception) { - Log.w(javaClass.name, "Could not parse episode information.", ex) - } - } } - Log.i(javaClass.name, "Loaded playlists successfully") - // additional info from the media page + /** + * generic aod media data + */ + val title = res.select("h1[itemprop=name]").text() + val description = res.select("div[itemprop=description]").text() + val posterURL = res.select("img.fullwidth-image").attr("src") + val type = when { + posterURL.contains("films") -> MediaType.MOVIE + posterURL.contains("series") -> MediaType.TVSHOW + else -> MediaType.OTHER + } + + var year = 0 + var age = 0 res.select("table.vertical-table").select("tr").forEach { row -> when (row.select("th").text().lowercase(Locale.ROOT)) { - "produktionsjahr" -> media.info.year = row.select("td").text().toInt() - "fsk" -> media.info.age = row.select("td").text().toInt() - "episodenanzahl" -> { - media.info.episodesCount = row.select("td").text() - .substringBefore("/") - .filter { it.isDigit() } - .toInt() - } + "produktionsjahr" -> year = row.select("td").text().toInt() + "fsk" -> age = row.select("td").text().toInt() } } // similar titles from media page - media.info.similar = res.select("h2:contains(Ähnliche Animes)").next().select("li").mapNotNull { + val similar = res.select("h2:contains(Ähnliche Animes)").next().select("li").mapNotNull { val mediaId = it.select("a.thumbs").attr("href") .substringAfterLast("/").toIntOrNull() val mediaImage = it.select("a.thumbs > img").attr("src") @@ -381,38 +354,78 @@ object AoDParser { if (mediaId != null) { ItemMedia(mediaId, mediaTitle, mediaImage) } else { + Log.i(javaClass.name, "MediaId for similar to $aodId was null") null } } - // additional information for tv shows the episode title (description) is loaded from the "api" - if (media.type == MediaType.TVSHOW) { - res.select("div.three-box-container > div.episodebox").forEach { episodebox -> + /** + * additional information for episodes: + * description: a short description of the episode + * watched: indicates if the episodes has been watched + * watched callback: url to set watched in aod + */ + val episodesInfo: Map = if (type == MediaType.TVSHOW) { + res.select("div.three-box-container > div.episodebox").mapNotNull { episodeBox -> // make sure the episode has a streaming link - if (episodebox.select("input.streamstarter_html5").isNotEmpty()) { - val episodeId = episodebox.select("div.flip-front").attr("id").substringAfter("-").toInt() - val episodeShortDesc = episodebox.select("p.episodebox-shorttext").text() - val episodeWatched = episodebox.select("div.episodebox-icons > div").hasClass("status-icon-orange") - val episodeWatchedCallback = episodebox.select("input.streamstarter_html5").eachAttr("data-playlist").first() + if (episodeBox.select("input.streamstarter_html5").isNotEmpty()) { + val mediaId = episodeBox.select("div.flip-front").attr("id").substringAfter("-").toInt() + val episodeShortDesc = episodeBox.select("p.episodebox-shorttext").text() + val episodeWatched = episodeBox.select("div.episodebox-icons > div").hasClass("status-icon-orange") + val episodeWatchedCallback = episodeBox.select("input.streamstarter_html5").eachAttr("data-playlist").first() - media.episodes.firstOrNull { it.id == episodeId }?.apply { - shortDesc = episodeShortDesc - watched = episodeWatched - watchedCallback = episodeWatchedCallback - } + AoDEpisodeInfo(mediaId, episodeShortDesc, episodeWatched, episodeWatchedCallback) + } else { + Log.i(javaClass.name, "Episode info for $aodId has empty streamstarter_html5 ") + null } - } + }.associateBy { it.aodMediaId } + } else { + mapOf() } - Log.i(javaClass.name, "media loaded successfully") + + // map the aod api playlist to a teapod playlist + val playlist: List = aodPlaylists.awaitAll().flatMap { aodPlaylist -> + aodPlaylist.list.mapIndexed { index, episode -> + AoDEpisode( + mediaId = episode.mediaid, + title = episode.title, + description = episode.description, + shortDesc = episodesInfo[episode.mediaid]?.shortDesc ?: "", + imageURL = episode.image, + numberStr = episode.title.substringAfter(", Ep. ", ""), // TODO move to parsePalylist + index = index, + watched = episodesInfo[episode.mediaid]?.watched ?: false, + watchedCallback = episodesInfo[episode.mediaid]?.watchedCallback ?: "", + streams = mutableListOf(Stream(episode.sources.first().file, aodPlaylist.language)) + ) + } + }.groupingBy { it.mediaId }.reduce{ _, accumulator, element -> + accumulator.copy().also { + it.streams.addAll(element.streams) + } + }.values.toList() + + return@async AoDMedia( + aodId = aodId, + type = type, + title = title, + shortText = description, + posterURL = posterURL, + year = year, + age = age, + similar = similar, + playlist = playlist + ) } } /** * don't use Gson().fromJson() as we don't have any control over the api and it may change */ - private fun parsePlaylistAsync(playlistPath: String, language: String): Deferred { + private fun parsePlaylistAsync(playlistPath: String, language: String): Deferred { if (playlistPath == "[]") { - return CompletableDeferred(AoDObject(listOf(), language)) + return CompletableDeferred(AoDPlaylist(listOf(), Locale.ROOT)) } return CoroutineScope(Dispatchers.IO).async(Dispatchers.IO) { @@ -435,7 +448,7 @@ object AoDParser { //Gson().fromJson(res.body(), AoDObject::class.java) - return@async AoDObject(JsonParser.parseString(res.body()).asJsonObject + return@async AoDPlaylist(JsonParser.parseString(res.body()).asJsonObject .get("playlist").asJsonArray.map { Playlist( sources = it.asJsonObject.get("sources").asJsonArray.map { source -> @@ -447,27 +460,14 @@ object AoDParser { mediaid = it.asJsonObject.get("mediaid").asInt ) }, - language + // TODO improve language handling (via display language etc.) + language = when (language) { + "ger" -> Locale.GERMAN + "jap" -> Locale.JAPANESE + else -> Locale.ROOT + } ) } } - /** - * get the episode number from the title - * @param title the episode title, containing a number after "Ep." - * @param type the media type, if not TVSHOW, return 0 - * @return the episode number, on NumberFormatException return 0 - */ - private fun getNumberFromTitle(title: String, type: MediaType): Int { - return if (type == MediaType.TVSHOW) { - try { - title.substringAfter(", Ep. ").toInt() - } catch (nex: NumberFormatException) { - 0 - } - } else { - 0 - } - } - } diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt index b6e1502..69905a5 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt @@ -46,6 +46,7 @@ import org.mosad.teapod.ui.activity.onboarding.OnboardingActivity import org.mosad.teapod.ui.activity.player.PlayerActivity import org.mosad.teapod.ui.components.LoginDialog import org.mosad.teapod.util.DataTypes +import org.mosad.teapod.util.MetaDBController import org.mosad.teapod.util.StorageController import org.mosad.teapod.util.exitAndRemoveTask import java.net.SocketTimeoutException @@ -137,8 +138,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen */ private fun load() { val time = measureTimeMillis { + // start the initial loading val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) - .async { AoDParser.initialLoading() } // start the initial loading + .async { + launch { AoDParser.initialLoading() } + launch { MetaDBController.list() } + } // load all saved stuff here Preferences.load(this) diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt index 1f45bcc..ae7ddd4 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt @@ -98,8 +98,8 @@ class HomeFragment : Fragment() { lifecycleScope.launch { val media = AoDParser.getMediaById(highlightMedia.id) - Log.d(javaClass.name, "Starting Player with mediaId: ${media.id}") - (activity as MainActivity).startPlayer(media.id, media.episodes.first().id) + Log.d(javaClass.name, "Starting Player with mediaId: ${media.aodId}") + (activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId) } } @@ -120,24 +120,24 @@ class HomeFragment : Fragment() { activity?.showFragment(MediaFragment(highlightMedia.id)) } - adapterMyList.onItemClick = { mediaId, _ -> - activity?.showFragment(MediaFragment(mediaId)) + adapterMyList.onItemClick = { id, _ -> + activity?.showFragment(MediaFragment(id)) } - adapterNewEpisodes.onItemClick = { mediaId, _ -> - activity?.showFragment(MediaFragment(mediaId)) + adapterNewEpisodes.onItemClick = { id, _ -> + activity?.showFragment(MediaFragment(id)) } - adapterNewSimulcasts.onItemClick = { mediaId, _ -> - activity?.showFragment(MediaFragment(mediaId)) + adapterNewSimulcasts.onItemClick = { id, _ -> + activity?.showFragment(MediaFragment(id)) } - adapterNewTitles.onItemClick = { mediaId, _ -> - activity?.showFragment(MediaFragment(mediaId)) + adapterNewTitles.onItemClick = { id, _ -> + activity?.showFragment(MediaFragment(id)) } - adapterTopTen.onItemClick = { mediaId, _ -> - activity?.showFragment(MediaFragment(mediaId)) + adapterTopTen.onItemClick = { id, _ -> + activity?.showFragment(MediaFragment(id)) } } @@ -154,7 +154,7 @@ class HomeFragment : Fragment() { private fun mapMyListToItemMedia(): List { return StorageController.myList.mapNotNull { elementId -> - AoDParser.itemMediaList.firstOrNull { it.id == elementId }.also { + AoDParser.guiMediaList.firstOrNull { it.id == elementId }.also { // it the my list entry wasn't found in itemMediaList Log it if (it == null) { Log.w(javaClass.name, "The element with the id $elementId was not found.") diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt index f757b7a..b761490 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt @@ -30,7 +30,7 @@ class LibraryFragment : Fragment() { lifecycleScope.launch { // create and set the adapter, needs context context?.let { - adapter = MediaItemAdapter(AoDParser.itemMediaList) + adapter = MediaItemAdapter(AoDParser.guiMediaList) adapter.onItemClick = { mediaId, _ -> activity?.showFragment(MediaFragment(mediaId)) } 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 a762032..fb2cc42 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 @@ -23,8 +23,9 @@ import org.mosad.teapod.databinding.FragmentMediaBinding import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.util.DataTypes.MediaType -import org.mosad.teapod.util.Episode import org.mosad.teapod.util.StorageController +import org.mosad.teapod.util.tmdb.TMDBMovie +import org.mosad.teapod.util.tmdb.TMDBApiController /** * The media detail fragment. @@ -62,7 +63,6 @@ class MediaFragment(private val mediaId: Int) : Fragment() { } }.attach() - lifecycleScope.launch { model.load(mediaId) // load the streams and tmdb for the selected media @@ -75,8 +75,8 @@ class MediaFragment(private val mediaId: Int) : Fragment() { super.onResume() // update the next ep text if there is one, since it may have changed - if (model.nextEpisode.title.isNotEmpty()) { - binding.textTitle.text = model.nextEpisode.title + if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { + binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title } } @@ -85,60 +85,65 @@ class MediaFragment(private val mediaId: Int) : Fragment() { */ private fun updateGUI() = with(model) { // generic gui - val backdropUrl = if (tmdb.backdropUrl.isNotEmpty()) tmdb.backdropUrl else media.info.posterUrl - val posterUrl = if (tmdb.posterUrl.isNotEmpty()) tmdb.posterUrl else media.info.posterUrl + val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it } + ?: media.posterURL + val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it } + ?: media.posterURL + // load poster and backdrop + Glide.with(requireContext()).load(posterUrl) + .into(binding.imagePoster) Glide.with(requireContext()).load(backdropUrl) .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) .into(binding.imageBackdrop) - Glide.with(requireContext()).load(posterUrl) - .into(binding.imagePoster) + binding.textTitle.text = media.title + binding.textYear.text = media.year.toString() + binding.textAge.text = media.age.toString() + binding.textOverview.text = media.shortText - binding.textTitle.text = media.info.title - binding.textYear.text = media.info.year.toString() - binding.textAge.text = media.info.age.toString() - binding.textOverview.text = media.info.shortDesc - if (StorageController.myList.contains(media.id)) { + // set "my list" indicator + if (StorageController.myList.contains(media.aodId)) { Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).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) + val fragmentsSize = if (fragments.lastIndex < 0) 0 else fragments.lastIndex fragments.clear() - pagerAdapter.notifyDataSetChanged() + pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize) // specific gui if (media.type == MediaType.TVSHOW) { // get next episode - nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { - media.episodes.first{ !it.watched } - } else { - media.episodes.first() - } + nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId + ?: media.playlist.first().mediaId // title is the next episodes title - binding.textTitle.text = nextEpisode.title + binding.textTitle.text = media.getEpisodeById(nextEpisodeId).title // episodes count binding.textEpisodesOrRuntime.text = resources.getQuantityString( R.plurals.text_episodes_count, - media.info.episodesCount, - media.info.episodesCount + media.playlist.size, + media.playlist.size ) // episodes - fragments.add(MediaFragmentEpisodes()) - pagerAdapter.notifyDataSetChanged() + MediaFragmentEpisodes().also { + fragments.add(it) + pagerAdapter.notifyItemInserted(fragments.indexOf(it)) + } } else if (media.type == MediaType.MOVIE) { + val tmdbMovie = (tmdbResult as TMDBMovie?) - if (tmdb.runtime > 0) { + if (tmdbMovie?.runtime != null) { binding.textEpisodesOrRuntime.text = resources.getQuantityString( R.plurals.text_runtime, - tmdb.runtime, - tmdb.runtime + tmdbMovie.runtime, + tmdbMovie.runtime ) } else { binding.textEpisodesOrRuntime.visibility = View.GONE @@ -146,9 +151,11 @@ class MediaFragment(private val mediaId: Int) : Fragment() { } // if has similar titles - if (media.info.similar.isNotEmpty()) { - fragments.add(MediaFragmentSimilar()) - pagerAdapter.notifyDataSetChanged() + if (media.similar.isNotEmpty()) { + MediaFragmentSimilar().also { + fragments.add(it) + pagerAdapter.notifyItemInserted(fragments.indexOf(it)) + } } // disable scrolling on appbar, if no tabs where added @@ -163,19 +170,19 @@ class MediaFragment(private val mediaId: Int) : Fragment() { private fun initActions() = with(model) { binding.buttonPlay.setOnClickListener { when (media.type) { - MediaType.MOVIE -> playEpisode(media.episodes.first()) - MediaType.TVSHOW -> playEpisode(nextEpisode) - else -> Log.e(javaClass.name, "Wrong Type: $media.type") + 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 binding.linearMyListAction.setOnClickListener { - if (StorageController.myList.contains(media.id)) { - StorageController.myList.remove(media.id) + if (StorageController.myList.contains(media.aodId)) { + StorageController.myList.remove(media.aodId) Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) } else { - StorageController.myList.add(media.id) + StorageController.myList.add(media.aodId) Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) } StorageController.saveMyList(requireContext()) @@ -191,11 +198,11 @@ class MediaFragment(private val mediaId: Int) : Fragment() { * play the current episode * TODO this is also used in MediaFragmentEpisode, we should only have on implementation */ - private fun playEpisode(ep: Episode) { - (activity as MainActivity).startPlayer(model.media.id, ep.id) - Log.d(javaClass.name, "Started Player with episodeId: ${ep.id}") + private fun playEpisode(episodeId: Int) { + (activity as MainActivity).startPlayer(model.media.aodId, episodeId) + Log.d(javaClass.name, "Started Player with episodeId: $episodeId") - model.updateNextEpisode(ep) // set the correct next episode + model.updateNextEpisode(episodeId) // set the correct next episode } /** 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 78f480f..f2e9f58 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 @@ -10,7 +10,6 @@ import androidx.fragment.app.activityViewModels import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.databinding.FragmentMediaEpisodesBinding -import org.mosad.teapod.util.Episode import org.mosad.teapod.util.adapter.EpisodeItemAdapter class MediaFragmentEpisodes : Fragment() { @@ -28,13 +27,13 @@ class MediaFragmentEpisodes : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapterRecEpisodes = EpisodeItemAdapter(model.media.episodes) + adapterRecEpisodes = EpisodeItemAdapter(model.media.playlist, model.tmdbTVSeason?.episodes) binding.recyclerEpisodes.adapter = adapterRecEpisodes // set onItemClick only in adapter is initialized if (this::adapterRecEpisodes.isInitialized) { adapterRecEpisodes.onImageClick = { _, position -> - playEpisode(model.media.episodes[position]) + playEpisode(model.media.playlist[position].mediaId) } } } @@ -44,18 +43,18 @@ class MediaFragmentEpisodes : Fragment() { // if adapterRecEpisodes is initialized, update the watched state for the episodes if (this::adapterRecEpisodes.isInitialized) { - model.media.episodes.forEachIndexed { index, episode -> - adapterRecEpisodes.updateWatchedState(episode.watched, index) + model.media.playlist.forEachIndexed { index, episodeInfo -> + adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index) } adapterRecEpisodes.notifyDataSetChanged() } } - private fun playEpisode(ep: Episode) { - (activity as MainActivity).startPlayer(model.media.id, ep.id) - Log.d(javaClass.name, "Started Player with episodeId: ${ep.id}") + private fun playEpisode(episodeId: Int) { + (activity as MainActivity).startPlayer(model.media.aodId, episodeId) + Log.d(javaClass.name, "Started Player with episodeId: $episodeId") - model.updateNextEpisode(ep) // set the correct next episode + model.updateNextEpisode(episodeId) // set the correct next episode } } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentSimilar.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentSimilar.kt index db6d519..87195a1 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentSimilar.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentSimilar.kt @@ -27,7 +27,7 @@ class MediaFragmentSimilar : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapterSimilar = MediaItemAdapter(model.media.info.similar) + adapterSimilar = MediaItemAdapter(model.media.similar) binding.recyclerMediaSimilar.adapter = adapterSimilar binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt index b430092..a2943a9 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt @@ -30,7 +30,7 @@ class SearchFragment : Fragment() { lifecycleScope.launch { // create and set the adapter, needs context context?.let { - adapter = MediaItemAdapter(AoDParser.itemMediaList) + adapter = MediaItemAdapter(AoDParser.guiMediaList) adapter!!.onItemClick = { mediaId, _ -> binding.searchText.clearFocus() activity?.showFragment(MediaFragment(mediaId)) 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 c2ba21d..6f855d9 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 @@ -1,48 +1,114 @@ package org.mosad.teapod.ui.activity.main.viewmodel import android.app.Application +import android.util.Log import androidx.lifecycle.AndroidViewModel import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.util.* import org.mosad.teapod.util.DataTypes.MediaType +import org.mosad.teapod.util.tmdb.TMDBApiController +import org.mosad.teapod.util.tmdb.TMDBResult +import org.mosad.teapod.util.tmdb.TMDBTVSeason /** * handle media, next ep and tmdb + * TODO this lives in activity, is this correct? */ class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { - var media = Media(-1, "", MediaType.OTHER) + var media = AoDMediaNone internal set - var nextEpisode = Episode() + var nextEpisodeId = -1 internal set - var tmdb = TMDBResponse() + + var tmdbResult: TMDBResult? = null // TODO rename + internal set + var tmdbTVSeason: TMDBTVSeason? =null + internal set + var mediaMeta: Meta? = null internal set /** * set media, tmdb and nextEpisode + * TODO run aod and tmdb load parallel */ - suspend fun load(mediaId: Int) { - media = AoDParser.getMediaById(mediaId) - tmdb = TMDBApiController().search(media.info.title, media.type) + suspend fun load(aodId: Int) { + val tmdbApiController = TMDBApiController() + media = AoDParser.getMediaById(aodId) + + // check if metaDB knows the title + val tmdbId: Int = if (MetaDBController.mediaList.media.contains(aodId)) { + // load media info from metaDB + val metaDB = MetaDBController() + mediaMeta = when (media.type) { + MediaType.MOVIE -> metaDB.getMovieMetadata(media.aodId) + MediaType.TVSHOW -> metaDB.getTVShowMetadata(media.aodId) + else -> null + } + + mediaMeta?.tmdbId ?: -1 + } else { + // use tmdb search to get media info + mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media + tmdbApiController.search(stripTitleInfo(media.title), media.type) + } + + tmdbResult = when (media.type) { + MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) + MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId) + else -> null + } + + // get season info, if metaDB knows the tv show + tmdbTVSeason = if (media.type == MediaType.TVSHOW && mediaMeta != null) { + val tvShowMeta = mediaMeta as TVShowMeta + tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) + } else { + null + } if (media.type == MediaType.TVSHOW) { - nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { - media.episodes.first{ !it.watched } - } else { - media.episodes.first() - } + //nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first() + nextEpisodeId = media.playlist.firstOrNull { !it.watched }?.mediaId + ?: media.playlist.first().mediaId } } /** - * get the next episode based on episode number (the true next episode) + * get the next episode based on episodeId * if no matching is found, use first episode */ - fun updateNextEpisode(currentEp: Episode) { + fun updateNextEpisode(episodeId: Int) { if (media.type == MediaType.MOVIE) return // return if movie - nextEpisode = media.episodes.firstOrNull{ it.number > currentEp.number } - ?: media.episodes.first() + nextEpisodeId = media.playlist.firstOrNull { it.index > media.getEpisodeById(episodeId).index }?.mediaId + ?: media.playlist.first().mediaId + } + + // remove unneeded info from the media title before searching + private fun stripTitleInfo(title: String): String { + return title.replace("(Sub)", "") + .replace(Regex("-?\\s?[0-9]+.\\s?(Staffel|Season)"), "") + .replace(Regex("(Staffel|Season)\\s?[0-9]+"), "") + .trim() + } + + /** guess Season from title + * if the title ends with a number, that could be the season + * if the title ends with Regex("-?\\s?[0-9]+.\\s?(Staffel|Season)") or + * Regex("(Staffel|Season)\\s?[0-9]+"), that is the season information + */ + private fun guessSeasonFromTitle(title: String): Int { + val helpTitle = title.replace("(Sub)", "").trim() + Log.d("test", "helpTitle: $helpTitle") + + return if (helpTitle.last().isDigit()) { + helpTitle.last().digitToInt() + } else { + Regex("([0-9]+.\\s?(Staffel|Season))|((Staffel|Season)\\s?[0-9]+)") + .find(helpTitle) + ?.value?.filter { it.isDigit() }?.toInt() ?: 1 + } } } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt index 66728f4..f3e2008 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt @@ -32,10 +32,7 @@ import org.mosad.teapod.R import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.ui.components.EpisodesListPlayer import org.mosad.teapod.ui.components.LanguageSettingsPlayer -import org.mosad.teapod.util.DataTypes -import org.mosad.teapod.util.hideBars -import org.mosad.teapod.util.isInPiPMode -import org.mosad.teapod.util.navToLauncherTask +import org.mosad.teapod.util.* import java.util.* import java.util.concurrent.TimeUnit import kotlin.concurrent.scheduleAtFixedRate @@ -121,13 +118,13 @@ class PlayerActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - // when the intent changed, lead the new media and play it + // when the intent changed, load the new media and play it intent?.let { model.loadMedia( it.getIntExtra(getString(R.string.intent_media_id), 0), it.getIntExtra(getString(R.string.intent_episode_id), 0) ) - model.playEpisode(model.currentEpisode, replace = true) + model.playEpisode(model.currentEpisode.mediaId, replace = true) } } @@ -174,7 +171,7 @@ class PlayerActivity : AppCompatActivity() { } private fun initPlayer() { - if (model.media.id < 0) { + if (model.media.aodId < 0) { Log.e(javaClass.name, "No media was set.") this.finish() } @@ -209,14 +206,14 @@ class PlayerActivity : AppCompatActivity() { else -> View.VISIBLE } - if (state == ExoPlayer.STATE_ENDED && model.nextEpisode != null && Preferences.autoplay) { + if (state == ExoPlayer.STATE_ENDED && model.nextEpisodeId != null && Preferences.autoplay) { playNextEpisode() } } }) // start playing the current episode, after all needed player components have been initialized - model.playEpisode(model.currentEpisode, true) + model.playEpisode(model.currentEpisode.mediaId, true) } @SuppressLint("ClickableViewAccessibility") @@ -226,7 +223,10 @@ class PlayerActivity : AppCompatActivity() { // when the player controls get hidden, hide the bars too video_view.setControllerVisibilityListener { when (it) { - View.GONE -> hideBars() + View.GONE -> { + hideBars() + // TODO also hide the skip op button + } View.VISIBLE -> updateControls() } } @@ -244,6 +244,7 @@ class PlayerActivity : AppCompatActivity() { rwd_10.setOnButtonClickListener { rewind() } ffwd_10.setOnButtonClickListener { fastForward() } button_next_ep.setOnClickListener { playNextEpisode() } + button_skip_op.setOnClickListener { skipOpening() } button_language.setOnClickListener { showLanguageSettings() } button_episodes.setOnClickListener { showEpisodesList() } button_next_ep_c.setOnClickListener { playNextEpisode() } @@ -262,23 +263,40 @@ class PlayerActivity : AppCompatActivity() { timerUpdates = Timer().scheduleAtFixedRate(0, 500) { lifecycleScope.launch { + val currentPosition = model.player.currentPosition val btnNextEpIsVisible = button_next_ep.isVisible val controlsVisible = controller.isVisible + // make sure remaining time is > 0 if (model.player.duration > 0) { - remainingTime = model.player.duration - model.player.currentPosition + remainingTime = model.player.duration - currentPosition remainingTime = if (remainingTime < 0) 0 else remainingTime } + // TODO add metaDB ending_start support + // if remaining time < 20 sec, a next ep is set, autoplay is enabled and not in pip: + // show next ep button if (remainingTime in 1..20000) { - // if the next ep button is not visible, make it visible. Don't show in pip mode - if (!btnNextEpIsVisible && model.nextEpisode != null && Preferences.autoplay && !isInPiPMode()) { + if (!btnNextEpIsVisible && model.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) { showButtonNextEp() } } else if (btnNextEpIsVisible) { hideButtonNextEp() } + // if meta data is present and opening_start & opening_duration are valid, show skip opening + model.currentEpisodeMeta?.let { + if (it.openingDuration > 0 && + currentPosition in it.openingStart..(it.openingStart + 10000) && + !button_skip_op.isVisible + ) { + showButtonSkipOp() + } else if (button_skip_op.isVisible && currentPosition !in it.openingStart..(it.openingStart + 10000)) { + // the button should only be visible, if currentEpisodeMeta != null + hideButtonSkipOp() + } + } + // if controls are visible, update them if (controlsVisible) { updateControls() @@ -317,7 +335,7 @@ class PlayerActivity : AppCompatActivity() { exo_text_title.text = model.getMediaTitle() // hide the next ep button, if there is none - button_next_ep_c.visibility = if (model.nextEpisode == null) { + button_next_ep_c.visibility = if (model.nextEpisodeId == null) { View.GONE } else { View.VISIBLE @@ -376,12 +394,21 @@ class PlayerActivity : AppCompatActivity() { hideButtonNextEp() } + private fun skipOpening() { + // calculate the seek time + model.currentEpisodeMeta?.let { + val seekTime = (it.openingStart + it.openingDuration) - model.player.currentPosition + model.seekToOffset(seekTime) + } + + } + /** * show the next episode button * TODO improve the show animation */ private fun showButtonNextEp() { - button_next_ep.visibility = View.VISIBLE + button_next_ep.isVisible = true button_next_ep.alpha = 0.0f button_next_ep.animate() @@ -399,7 +426,28 @@ class PlayerActivity : AppCompatActivity() { .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { super.onAnimationEnd(animation) - button_next_ep.visibility = View.GONE + button_next_ep.isVisible = false + } + }) + + } + + private fun showButtonSkipOp() { + button_skip_op.isVisible = true + button_skip_op.alpha = 0.0f + + button_skip_op.animate() + .alpha(1.0f) + .setListener(null) + } + + private fun hideButtonSkipOp() { + button_skip_op.animate() + .alpha(0.0f) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + super.onAnimationEnd(animation) + button_skip_op.isVisible = false } }) @@ -437,7 +485,7 @@ class PlayerActivity : AppCompatActivity() { */ override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { if (!isInPiPMode()) { - if (controller.isVisible) controller.hide() else controller.show() + if (controller.isVisible) controller.hide() else controller.show() } return true diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt index 5dcd69f..ca14e0f 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt @@ -19,9 +19,9 @@ import kotlinx.coroutines.runBlocking import org.mosad.teapod.R import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.preferences.Preferences -import org.mosad.teapod.util.DataTypes -import org.mosad.teapod.util.Episode -import org.mosad.teapod.util.Media +import org.mosad.teapod.util.* +import org.mosad.teapod.util.tmdb.TMDBApiController +import org.mosad.teapod.util.tmdb.TMDBTVSeason import java.util.* import kotlin.collections.ArrayList @@ -39,11 +39,17 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) val currentEpisodeChangedListener = ArrayList<() -> Unit>() private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN - var media: Media = Media(-1, "", DataTypes.MediaType.OTHER) + var media: AoDMedia = AoDMediaNone internal set - var currentEpisode = Episode() + var mediaMeta: Meta? = null internal set - var nextEpisode: Episode? = null + var tmdbTVSeason: TMDBTVSeason? =null + internal set + var currentEpisode = AoDEpisodeNone + internal set + var currentEpisodeMeta: EpisodeMeta? = null + internal set + var nextEpisodeId: Int? = null internal set var currentLanguage: Locale = Locale.ROOT internal set @@ -75,10 +81,21 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun loadMedia(mediaId: Int, episodeId: Int) { runBlocking { media = AoDParser.getMediaById(mediaId) + mediaMeta = loadMediaMeta(media.aodId) // can be done blocking, since it should be cached + } + + // run async as it should be loaded by the time the episodes a + viewModelScope.launch { + // get season info, if metaDB knows the tv show + if (media.type == DataTypes.MediaType.TVSHOW && mediaMeta != null) { + val tvShowMeta = mediaMeta as TVShowMeta + tmdbTVSeason = TMDBApiController().getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) + } } currentEpisode = media.getEpisodeById(episodeId) - nextEpisode = selectNextEpisode() + nextEpisodeId = selectNextEpisode() + currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language } @@ -105,32 +122,37 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) /** * play the next episode, if nextEpisode is not null */ - fun playNextEpisode() = nextEpisode?.let { it -> + fun playNextEpisode() = nextEpisodeId?.let { it -> playEpisode(it, replace = true) } /** - * set currentEpisode to the param episode and start playing it - * update nextEpisode to reflect the change + * Set currentEpisode and start playing it. + * Update nextEpisode to reflect the change and update + * the watched state for the now playing episode. * - * updateWatchedState for the next (now current) episode + * @param episodeId The aod media id of the episode to play. + * @param replace (default = false) + * @param seekPosition The seek position for the episode (default = 0). */ - fun playEpisode(episode: Episode, replace: Boolean = false, seekPosition: Long = 0) { - val preferredStream = episode.getPreferredStream(currentLanguage) - currentLanguage = preferredStream.language // update current language, since it may have changed - currentEpisode = episode - nextEpisode = selectNextEpisode() - currentEpisodeChangedListener.forEach { it() } // update player gui (title) + fun playEpisode(episodeId: Int, replace: Boolean = false, seekPosition: Long = 0) { + currentEpisode = media.getEpisodeById(episodeId) + currentLanguage = currentEpisode.getPreferredStream(currentLanguage).language + currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) + nextEpisodeId = selectNextEpisode() + + // update player gui (title, next ep button) after nextEpisodeId has been set + currentEpisodeChangedListener.forEach { it() } val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( - MediaItem.fromUri(Uri.parse(preferredStream.url)) + MediaItem.fromUri(Uri.parse(currentEpisode.getPreferredStream(currentLanguage).url)) ) playMedia(mediaSource, replace, seekPosition) // if episodes has not been watched, mark as watched - if (!episode.watched) { + if (!currentEpisode.watched) { viewModelScope.launch { - AoDParser.markAsWatched(media.id, episode.id) + AoDParser.markAsWatched(media.aodId, currentEpisode.mediaId) } } } @@ -151,7 +173,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) return if (media.type == DataTypes.MediaType.TVSHOW) { getApplication().getString( R.string.component_episode_title, - currentEpisode.number, + currentEpisode.numberStr, currentEpisode.description ) } else { @@ -159,17 +181,31 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } } - /** - * Based on the current episodeId, get the next episode. If there is no next - * episode, return null - */ - private fun selectNextEpisode(): Episode? { - val nextEpIndex = media.episodes.indexOfFirst { it.id == currentEpisode.id } + 1 - return if (nextEpIndex < media.episodes.size) { - media.episodes[nextEpIndex] + fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? { + val meta = mediaMeta + return if (meta is TVShowMeta) { + meta.episodes.firstOrNull { it.aodMediaId == aodMediaId } } else { null } } + private suspend fun loadMediaMeta(aodId: Int): Meta? { + return if (media.type == DataTypes.MediaType.TVSHOW) { + MetaDBController().getTVShowMetadata(aodId) + } else { + null + } + } + + /** + * Based on the current episodes index, get the next episode. + * @return The next episode or null if there is none. + */ + private fun selectNextEpisode(): Int? { + return media.playlist.firstOrNull { + it.index > media.getEpisodeById(currentEpisode.mediaId).index + }?.mediaId + } + } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt b/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt index cb51deb..13a6d40 100644 --- a/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt +++ b/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt @@ -28,16 +28,15 @@ class EpisodesListPlayer @JvmOverloads constructor( } model?.let { - adapterRecEpisodes = PlayerEpisodeItemAdapter(model.media.episodes) - + adapterRecEpisodes = PlayerEpisodeItemAdapter(model.media.playlist, model.tmdbTVSeason?.episodes) adapterRecEpisodes.onImageClick = { _, position -> (this.parent as ViewGroup).removeView(this) - model.playEpisode(model.media.episodes[position], replace = true) + model.playEpisode(model.media.playlist[position].mediaId, replace = true) } - adapterRecEpisodes.currentSelected = model.currentEpisode.number - 1 + adapterRecEpisodes.currentSelected = model.currentEpisode.index binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes - binding.recyclerEpisodesPlayer.scrollToPosition(model.currentEpisode.number - 1) // number != index + binding.recyclerEpisodesPlayer.scrollToPosition(model.currentEpisode.index) } } diff --git a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt index 56635e5..db662e5 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -1,7 +1,6 @@ package org.mosad.teapod.util -import java.util.* -import kotlin.collections.ArrayList +import java.util.Locale class DataTypes { enum class MediaType { @@ -36,61 +35,47 @@ data class ThirdPartyComponent( * it is uses in the ItemMediaAdapter (RecyclerView) */ data class ItemMedia( - val id: Int, + val id: Int, // aod path id val title: String, val posterUrl: String ) -/** - * TODO the episodes workflow could use a clean up/rework - */ -data class Media( - val id: Int, - val link: String, +// TODO replace playlist: List with a map? +data class AoDMedia( + val aodId: Int, val type: DataTypes.MediaType, - val info: Info = Info(), - val episodes: ArrayList = arrayListOf() + val title: String, + val shortText: String, + val posterURL: String, + var year: Int, + var age: Int, + val similar: List, + val playlist: List, ) { - fun hasEpisode(id: Int) = episodes.any { it.id == id } - fun getEpisodeById(id: Int) = episodes.first { it.id == id } + fun getEpisodeById(mediaId: Int) = playlist.firstOrNull { it.mediaId == mediaId } + ?: AoDEpisodeNone } -/** - * uses var, since the values are written in different steps - */ -data class Info( - var title: String = "", - var posterUrl: String = "", - var shortDesc: String = "", - var description: String = "", - var year: Int = 0, - var age: Int = 0, - var episodesCount: Int = 0, - var similar: List = listOf() -) +data class AoDEpisode( + val mediaId: Int, + val title: String, + val description: String, + val shortDesc: String, + val imageURL: String, + val numberStr: String, + val index: Int, + var watched: Boolean, + val watchedCallback: String, + val streams: MutableList, +){ + fun hasDub() = streams.any { it.language == Locale.GERMAN } -/** - * number = episode number (0..n) - */ -data class Episode( - val id: Int = -1, - val streams: MutableList = mutableListOf(), - val title: String = "", - val posterUrl: String = "", - val description: String = "", - var shortDesc: String = "", - val number: Int = 0, - var watched: Boolean = false, - var watchedCallback: String = "" -) { /** * get the preferred stream * @return the preferred stream, if not present use the first stream */ - fun getPreferredStream(language: Locale) = - streams.firstOrNull { it.language == language } ?: streams.first() - - fun hasDub() = streams.any { it.language == Locale.GERMAN } + fun getPreferredStream(language: Locale) = streams.firstOrNull { it.language == language } + ?: streams.first() } data class Stream( @@ -98,24 +83,45 @@ data class Stream( val language : Locale ) -/** - * this class is used for tmdb responses - */ -data class TMDBResponse( - val id: Int = 0, - val title: String = "", - val overview: String = "", - val posterUrl: String = "", - val backdropUrl: String = "", - val runtime: Int = 0 +// TODO will be watched info (state and callback) -> remove description and number +data class AoDEpisodeInfo( + val aodMediaId: Int, + val shortDesc: String, + var watched: Boolean, + val watchedCallback: String, +) + +val AoDMediaNone = AoDMedia( + -1, + DataTypes.MediaType.OTHER, + "", + "", + "", + -1, + -1, + listOf(), + listOf() +) + +val AoDEpisodeNone = AoDEpisode( + -1, + "", + "", + "", + "", + "", + -1, + false, + "", + mutableListOf() ) /** * this class is used to represent the aod json API? */ -data class AoDObject( - val playlist: List, - val extLanguage: String +data class AoDPlaylist( + val list: List, + val language: Locale ) data class Playlist( diff --git a/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt b/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt new file mode 100644 index 0000000..387a129 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt @@ -0,0 +1,156 @@ +/** + * Teapod + * + * Copyright 2020-2021 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + * + */ + +package org.mosad.teapod.util + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.* +import java.io.FileNotFoundException +import java.net.URL + +class MetaDBController { + + companion object { + private const val repoUrl = "https://gitlab.com/Seil0/teapodmetadb/-/raw/main/aod/" + + var mediaList = MediaList(listOf()) + private var metaCacheList = arrayListOf() + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun list() = withContext(Dispatchers.IO) { + val url = URL("$repoUrl/list.json") + val json = url.readText() + + mediaList = Gson().fromJson(json, MediaList::class.java) + } + } + + /** + * Get the meta data for a movie from MetaDB + * @param aodId The AoD id of the media + * @return A meta movie object, or null if not found + */ + suspend fun getMovieMetadata(aodId: Int): MovieMeta? { + return metaCacheList.firstOrNull { + it.aodId == aodId + } as MovieMeta? ?: getMovieMetadataFromDB(aodId) + } + + /** + * Get the meta data for a tv show from MetaDB + * @param aodId The AoD id of the media + * @return A meta tv show object, or null if not found + */ + suspend fun getTVShowMetadata(aodId: Int): TVShowMeta? { + return metaCacheList.firstOrNull { + it.aodId == aodId + } as TVShowMeta? ?: getTVShowMetadataFromDB(aodId) + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getMovieMetadataFromDB(aodId: Int): MovieMeta? = withContext(Dispatchers.IO) { + val url = URL("$repoUrl/movie/$aodId/media.json") + return@withContext try { + val json = url.readText() + val meta = Gson().fromJson(json, MovieMeta::class.java) + metaCacheList.add(meta) + + meta + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested file was not found. Requested ID: $aodId", ex) + null + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getTVShowMetadataFromDB(aodId: Int): TVShowMeta? = withContext(Dispatchers.IO) { + val url = URL("$repoUrl/tv/$aodId/media.json") + return@withContext try { + val json = url.readText() + val meta = Gson().fromJson(json, TVShowMeta::class.java) + metaCacheList.add(meta) + + meta + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested file was not found. Requested ID: $aodId", ex) + null + } + } + +} + +// class representing the media list json object +data class MediaList( + val media: List +) + +// abstract class used for meta data objects (tv, movie) +abstract class Meta { + abstract val id: Int + abstract val aodId: Int + abstract val tmdbId: Int +} + +// class representing the movie json object +data class MovieMeta( + override val id: Int, + @SerializedName("aod_id") + override val aodId: Int, + @SerializedName("tmdb_id") + override val tmdbId: Int +): Meta() + +// class representing the tv show json object +data class TVShowMeta( + override val id: Int, + @SerializedName("aod_id") + override val aodId: Int, + @SerializedName("tmdb_id") + override val tmdbId: Int, + @SerializedName("tmdb_season_id") + val tmdbSeasonId: Int, + @SerializedName("tmdb_season_number") + val tmdbSeasonNumber: Int, + @SerializedName("episodes") + val episodes: List +): Meta() + +// class used in TVShowMeta, part of the tv show json object +data class EpisodeMeta( + val id: Int, + @SerializedName("aod_media_id") + val aodMediaId: Int, + @SerializedName("tmdb_id") + val tmdbId: Int, + @SerializedName("tmdb_number") + val tmdbNumber: Int, + @SerializedName("opening_start") + val openingStart: Long, + @SerializedName("opening_duration") + val openingDuration: Long, + @SerializedName("ending_start") + val endingStart: Long, + @SerializedName("ending_duration") + val endingDuration: Long +) diff --git a/app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt b/app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt deleted file mode 100644 index 846d544..0000000 --- a/app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.mosad.teapod.util - -import android.util.Log -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import kotlinx.coroutines.* -import org.mosad.teapod.util.DataTypes.MediaType -import java.net.URL -import java.net.URLEncoder - -class TMDBApiController { - - private val apiUrl = "https://api.themoviedb.org/3" - private val searchMovieUrl = "$apiUrl/search/movie" - private val searchTVUrl = "$apiUrl/search/tv" - private val getMovieUrl = "$apiUrl/movie" - private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2" - private val language = "de" - private val preparedParameters = "?api_key=$apiKey&language=$language" - - private val imageUrl = "https://image.tmdb.org/t/p/w500" - - suspend fun search(title: String, type: MediaType): TMDBResponse { - // remove unneeded text from the media title before searching - val searchTerm = title.replace("(Sub)", "") - .replace(Regex("-?\\s?[0-9]+.\\s?(Staffel|Season)"), "") - .replace(Regex("(Staffel|Season)\\s?[0-9]+"), "") - .trim() - - return when (type) { - MediaType.MOVIE -> searchMovie(searchTerm) - MediaType.TVSHOW -> searchTVShow(searchTerm) - else -> { - Log.e(javaClass.name, "Wrong Type: $type") - TMDBResponse() - } - } - - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun searchTVShow(title: String): TMDBResponse = withContext(Dispatchers.IO) { - val url = URL("$searchTVUrl$preparedParameters&query=${URLEncoder.encode(title, "UTF-8")}") - val response = JsonParser.parseString(url.readText()).asJsonObject -// println(response) - - val sortedResults = response.get("results").asJsonArray.toList().sortedBy { - getStringNotNull(it.asJsonObject, "name") - } - - return@withContext if (sortedResults.isNotEmpty()) { - sortedResults.first().asJsonObject.let { - val id = getStringNotNull(it, "id").toInt() - val overview = getStringNotNull(it, "overview") - val posterPath = getStringNotNullPrefix(it, "poster_path", imageUrl) - val backdropPath = getStringNotNullPrefix(it, "backdrop_path", imageUrl) - - TMDBResponse(id, "", overview, posterPath, backdropPath) - } - } else { - TMDBResponse() - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun searchMovie(title: String): TMDBResponse = withContext(Dispatchers.IO) { - val url = URL("$searchMovieUrl$preparedParameters&query=${URLEncoder.encode(title, "UTF-8")}") - val response = JsonParser.parseString(url.readText()).asJsonObject -// println(response) - - val sortedResults = response.get("results").asJsonArray.toList().sortedBy { - getStringNotNull(it.asJsonObject, "title") - } - - return@withContext if (sortedResults.isNotEmpty()) { - sortedResults.first().asJsonObject.let { - val id = getStringNotNull(it,"id").toInt() - val overview = getStringNotNull(it,"overview") - val posterPath = getStringNotNullPrefix(it, "poster_path", imageUrl) - val backdropPath = getStringNotNullPrefix(it, "backdrop_path", imageUrl) - val runtime = getMovieRuntime(id) - - TMDBResponse(id, "", overview, posterPath, backdropPath, runtime) - } - } else { - TMDBResponse() - } - } - - /** - * currently only used for runtime, need a rework - */ - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun getMovieRuntime(id: Int): Int = withContext(Dispatchers.IO) { - val url = URL("$getMovieUrl/$id?api_key=$apiKey&language=$language") - - val response = JsonParser.parseString(url.readText()).asJsonObject - return@withContext getStringNotNull(response,"runtime").toInt() - } - - /** - * return memberName as string if it's not JsonNull, - * else return an empty string - */ - private fun getStringNotNull(jsonObject: JsonObject, memberName: String): String { - return getStringNotNullPrefix(jsonObject, memberName, "") - } - - /** - * return memberName as string with a prefix if it's not JsonNull, - * else return an empty string - */ - private fun getStringNotNullPrefix(jsonObject: JsonObject, memberName: String, prefix: String): String { - return if (!jsonObject.get(memberName).isJsonNull) { - prefix + jsonObject.get(memberName).asString - } else { - "" - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt index 6eb467c..3bd2df0 100644 --- a/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt +++ b/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt @@ -11,9 +11,10 @@ import com.bumptech.glide.request.RequestOptions import jp.wasabeef.glide.transformations.RoundedCornersTransformation import org.mosad.teapod.R import org.mosad.teapod.databinding.ItemEpisodeBinding -import org.mosad.teapod.util.Episode +import org.mosad.teapod.util.AoDEpisode +import org.mosad.teapod.util.tmdb.TMDBTVEpisode -class EpisodeItemAdapter(private val episodes: List) : RecyclerView.Adapter() { +class EpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { var onImageClick: ((String, Int) -> Unit)? = null @@ -26,16 +27,22 @@ class EpisodeItemAdapter(private val episodes: List) : RecyclerView.Ada val ep = episodes[position] val titleText = if (ep.hasDub()) { - context.getString(R.string.component_episode_title, ep.number, ep.description) + context.getString(R.string.component_episode_title, ep.numberStr, ep.description) } else { - context.getString(R.string.component_episode_title_sub, ep.number, ep.description) + context.getString(R.string.component_episode_title_sub, ep.numberStr, ep.description) } holder.binding.textEpisodeTitle.text = titleText - holder.binding.textEpisodeDesc.text = ep.shortDesc + holder.binding.textEpisodeDesc.text = if (ep.shortDesc.isNotEmpty()) { + ep.shortDesc + } else if (tmdbEpisodes != null && position < tmdbEpisodes.size){ + tmdbEpisodes[position].overview + } else { + "" + } - if (episodes[position].posterUrl.isNotEmpty()) { - Glide.with(context).load(ep.posterUrl) + if (ep.imageURL.isNotEmpty()) { + Glide.with(context).load(ep.imageURL) .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) .into(holder.binding.imageEpisode) diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/PlayerEpisodeItemAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/PlayerEpisodeItemAdapter.kt index 8b005a7..6cf35a0 100644 --- a/app/src/main/java/org/mosad/teapod/util/adapter/PlayerEpisodeItemAdapter.kt +++ b/app/src/main/java/org/mosad/teapod/util/adapter/PlayerEpisodeItemAdapter.kt @@ -9,9 +9,10 @@ import com.bumptech.glide.request.RequestOptions import jp.wasabeef.glide.transformations.RoundedCornersTransformation import org.mosad.teapod.R import org.mosad.teapod.databinding.ItemEpisodePlayerBinding -import org.mosad.teapod.util.Episode +import org.mosad.teapod.util.AoDEpisode +import org.mosad.teapod.util.tmdb.TMDBTVEpisode -class PlayerEpisodeItemAdapter(private val episodes: List) : RecyclerView.Adapter() { +class PlayerEpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { var onImageClick: ((String, Int) -> Unit)? = null var currentSelected: Int = -1 // -1, since position should never be < 0 @@ -25,16 +26,22 @@ class PlayerEpisodeItemAdapter(private val episodes: List) : RecyclerVi val ep = episodes[position] val titleText = if (ep.hasDub()) { - context.getString(R.string.component_episode_title, ep.number, ep.description) + context.getString(R.string.component_episode_title, ep.numberStr, ep.description) } else { - context.getString(R.string.component_episode_title_sub, ep.number, ep.description) + context.getString(R.string.component_episode_title_sub, ep.numberStr, ep.description) } holder.binding.textEpisodeTitle2.text = titleText - holder.binding.textEpisodeDesc2.text = ep.shortDesc + holder.binding.textEpisodeDesc2.text = if (ep.shortDesc.isNotEmpty()) { + ep.shortDesc + } else if (tmdbEpisodes != null && position < tmdbEpisodes.size){ + tmdbEpisodes[position].overview + } else { + "" + } - if (episodes[position].posterUrl.isNotEmpty()) { - Glide.with(context).load(ep.posterUrl) + if (ep.imageURL.isNotEmpty()) { + Glide.with(context).load(ep.imageURL) .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) .into(holder.binding.imageEpisode) } diff --git a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt new file mode 100644 index 0000000..93003c4 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt @@ -0,0 +1,137 @@ +/** + * Teapod + * + * Copyright 2020-2021 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + * + */ + +package org.mosad.teapod.util.tmdb + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.JsonParser +import kotlinx.coroutines.* +import org.mosad.teapod.util.DataTypes.MediaType +import java.io.FileNotFoundException +import java.net.URL +import java.net.URLEncoder + +/** + * Controller for tmdb api integration. + * Data types are in TMDBDataTypes. For the type definitions see: + * https://developers.themoviedb.org/3/getting-started/introduction + * + * TODO evaluate Klaxon + */ +class TMDBApiController { + + private val apiUrl = "https://api.themoviedb.org/3" + private val searchMovieUrl = "$apiUrl/search/movie" + private val searchTVUrl = "$apiUrl/search/tv" + private val detailsMovieUrl = "$apiUrl/movie" + private val detailsTVUrl = "$apiUrl/tv" + private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2" + private val language = "de" + private val preparedParameters = "?api_key=$apiKey&language=$language" + + companion object{ + const val imageUrl = "https://image.tmdb.org/t/p/w500" + } + + @Suppress("BlockingMethodInNonBlockingContext") + /** + * Search for a media(movie or tv show) in tmdb + * @param query The query text + * @param type The media type (movie or tv show) + * @return The media tmdb id, or -1 if not found + */ + suspend fun search(query: String, type: MediaType): Int = withContext(Dispatchers.IO) { + val searchUrl = when (type) { + MediaType.MOVIE -> searchMovieUrl + MediaType.TVSHOW -> searchTVUrl + else -> { + Log.e(javaClass.name, "Wrong Type: $type") + return@withContext -1 + } + } + + val url = URL("$searchUrl$preparedParameters&query=${URLEncoder.encode(query, "UTF-8")}") + val response = JsonParser.parseString(url.readText()).asJsonObject + val sortedResults = response.get("results").asJsonArray.toList().sortedBy { + it.asJsonObject.get("title")?.asString + } + + return@withContext sortedResults.firstOrNull()?.asJsonObject?.get("id")?.asInt ?: -1 + } + + @Suppress("BlockingMethodInNonBlockingContext") + /** + * Get details for a movie from tmdb + * @param movieId The tmdb ID of the movie + * @return A tmdb movie object, or null if not found + */ + suspend fun getMovieDetails(movieId: Int): TMDBMovie? = withContext(Dispatchers.IO) { + val url = URL("$detailsMovieUrl/$movieId?api_key=$apiKey&language=$language") + + return@withContext try { + val json = url.readText() + Gson().fromJson(json, TMDBMovie::class.java) + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $movieId", ex) + null + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + /** + * Get details for a tv show from tmdb + * @param tvId The tmdb ID of the tv show + * @return A tmdb tv show object, or null if not found + */ + suspend fun getTVShowDetails(tvId: Int): TMDBTVShow? = withContext(Dispatchers.IO) { + val url = URL("$detailsTVUrl/$tvId?api_key=$apiKey&language=$language") + + return@withContext try { + val json = url.readText() + Gson().fromJson(json, TMDBTVShow::class.java) + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $tvId", ex) + null + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + /** + * Get details for a tv show season from tmdb + * @param tvId The tmdb ID of the tv show + * @param seasonNumber The tmdb season number + * @return A tmdb tv season object, or null if not found + */ + suspend fun getTVSeasonDetails(tvId: Int, seasonNumber: Int): TMDBTVSeason? = withContext(Dispatchers.IO) { + val url = URL("$detailsTVUrl/$tvId/season/$seasonNumber?api_key=$apiKey&language=$language") + + return@withContext try { + val json = url.readText() + Gson().fromJson(json, TMDBTVSeason::class.java) + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $tvId, Season: $seasonNumber", ex) + null + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt new file mode 100644 index 0000000..a3f5106 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt @@ -0,0 +1,92 @@ +/** + * Teapod + * + * Copyright 2020-2021 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + * + */ + +package org.mosad.teapod.util.tmdb + +import com.google.gson.annotations.SerializedName + +/** + * These data classes represent the tmdb api json objects. + * Fields which are nullable in the tmdb api are also nullable here. + */ + +abstract class TMDBResult{ + abstract val id: Int + abstract val name: String + abstract val overview: String? // for movies tmdb return string or null + abstract val posterPath: String? + abstract val backdropPath: String? +} + +data class TMDBMovie( + override val id: Int, + override val name: String, + override val overview: String?, + @SerializedName("poster_path") + override val posterPath: String?, + @SerializedName("backdrop_path") + override val backdropPath: String?, + @SerializedName("release_date") + val releaseDate: String, + @SerializedName("runtime") + val runtime: Int?, + // TODO generes +): TMDBResult() + +data class TMDBTVShow( + override val id: Int, + override val name: String, + override val overview: String, + @SerializedName("poster_path") + override val posterPath: String?, + @SerializedName("backdrop_path") + override val backdropPath: String?, + @SerializedName("first_air_date") + val firstAirDate: String, + @SerializedName("status") + val status: String, + // TODO generes +): TMDBResult() + +data class TMDBTVSeason( + val id: Int, + val name: String, + val overview: String, + @SerializedName("poster_path") + val posterPath: String?, + @SerializedName("air_date") + val airDate: String, + @SerializedName("episodes") + val episodes: List, + @SerializedName("season_number") + val seasonNumber: Int +) + +data class TMDBTVEpisode( + val id: Int, + val name: String, + val overview: String, + @SerializedName("air_date") + val airDate: String, + @SerializedName("episode_number") + val episodeNumber: Int +) \ No newline at end of file diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 5a8f6bd..566f92f 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -89,4 +89,20 @@ app:backgroundTint="@color/exo_white" app:iconGravity="textStart" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_episode_player.xml b/app/src/main/res/layout/item_episode_player.xml index 4be2bc5..4b97df5 100644 --- a/app/src/main/res/layout/item_episode_player.xml +++ b/app/src/main/res/layout/item_episode_player.xml @@ -51,7 +51,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginTop="5dp" + android:maxLines="10" android:text="@string/text_overview_ex" - android:textColor="@color/textPrimaryDark"/> + android:textColor="@color/textPrimaryDark" /> \ No newline at end of file diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index af2731e..d7ed368 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -27,8 +27,8 @@ %d Minuten Ähnliche Titel - Flg. %1$d %2$s - Flg. %1$d %2$s (OmU) + Flg. %1$s %2$s + Flg. %1$s %2$s (OmU) Account @@ -69,6 +69,7 @@ Abspielen/Pause 10 Sekunden vorwärts Nächste Folge + Intro überspringen Sprache Folgen Folge diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7ba0b9..ce053ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,8 +34,8 @@ %d Minutes Similar titles - Ep. %1$d %2$s - Ep. %1$d %2$s (Sub) + Ep. %1$s %2$s + Ep. %1$s %2$s (Sub) episode poster already watched @@ -88,6 +88,7 @@ - 10 s + 10 s Next Episode + Skip Opening %1$02d:%2$02d %1$d:%2$02d:%3$02d Language diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 81eee9c..43da35b 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -1,6 +1,6 @@ Teapod ist eine inoffizielle App für Anime-on-Demand (AoD). -* Schau dir alle Title von AoD auf deinem Android Gerät an +* Schau dir alle Titel von AoD auf deinem Android Gerät an * Nativer Player auf Basis des ExoPayers * Bevorzuge die OmU Version über die App-Einstellungen * Speicher deine lieblings Anime in "Meine Liste"