From cf3b1802d59765dbd512d40e73da7f733c6f7d87 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sat, 10 Jul 2021 20:37:02 +0200 Subject: [PATCH 001/133] update kotlin coroutines 1.5.0 -> 1.5.1 --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index c194ff7..a195924 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,7 +41,7 @@ android { dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1' implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.3.0' From 9df5be003b9ddb14215c381d534052ae3e5a5267 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 15 Aug 2021 00:39:17 +0200 Subject: [PATCH 002/133] update agp, kotlin, appcompat and exoplayer * agp 4.2.2 -> 7.0.0 * kotlin 1.5.20 -> 1.5.21 * appcompat 1.3.0 -> 1.2.1 * exoplayer 1.14.1 -> 1.14.2 --- app/build.gradle | 13 +++++++------ build.gradle | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a195924..409378f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,6 +29,7 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -44,7 +45,7 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1' implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.appcompat:appcompat:1.3.0' + implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' @@ -55,11 +56,11 @@ dependencies { implementation 'com.google.android.material:material:1.4.0' implementation 'com.google.code.gson:gson:2.8.7' - implementation 'com.google.android.exoplayer:exoplayer-core:2.14.1' - implementation 'com.google.android.exoplayer:exoplayer-hls:2.14.1' - implementation 'com.google.android.exoplayer:exoplayer-dash:2.14.1' - implementation 'com.google.android.exoplayer:exoplayer-ui:2.14.1' - implementation 'com.google.android.exoplayer:extension-mediasession:2.14.1' + implementation 'com.google.android.exoplayer:exoplayer-core:2.14.2' + implementation 'com.google.android.exoplayer:exoplayer-hls:2.14.2' + implementation 'com.google.android.exoplayer:exoplayer-dash:2.14.2' + implementation 'com.google.android.exoplayer:exoplayer-ui:2.14.2' + implementation 'com.google.android.exoplayer:extension-mediasession:2.14.2' implementation 'org.jsoup:jsoup:1.13.1' implementation 'com.github.bumptech.glide:glide:4.12.0' diff --git a/build.gradle b/build.gradle index 0492e3b..e6817d5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.5.20" + ext.kotlin_version = "1.5.21" repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.2.2' + classpath 'com.android.tools.build:gradle:7.0.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong From d417181b707d9964e7b3d07af5b73ee5cecdcc0e Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 29 Aug 2021 15:02:40 +0200 Subject: [PATCH 003/133] update kotlin, gradle & libraries * kotlin 1.5.21 -> 1.5.30 * gradle wrapper 7.0.2 -> 7.2 * gradle agp 7.0.0 -> 7.0.1 * constraintlayout 2.0.4 -> 2.1.0 --- app/build.gradle | 2 +- app/src/main/res/drawable/shape_rounded_corner.xml | 2 +- app/src/main/res/values/attrs.xml | 2 +- app/src/main/res/values/styles.xml | 4 ++-- build.gradle | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 409378f..76818ad 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -46,7 +46,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.3.1' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.constraintlayout:constraintlayout:2.1.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' implementation 'androidx.security:security-crypto:1.1.0-alpha03' diff --git a/app/src/main/res/drawable/shape_rounded_corner.xml b/app/src/main/res/drawable/shape_rounded_corner.xml index 13bb244..eae1adb 100644 --- a/app/src/main/res/drawable/shape_rounded_corner.xml +++ b/app/src/main/res/drawable/shape_rounded_corner.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 567bf9f..4de55db 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index bc39363..820686c 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -14,7 +14,7 @@ @color/textPrimaryLight @color/textPrimaryLight @color/textSecondaryLight - @color/textBackgroundLight + @color/textBackgroundLight @color/iconColorLight @color/buttonBackgroundLight @color/themeSecondaryLight @@ -32,7 +32,7 @@ @color/textPrimaryDark @color/textPrimaryDark @color/textSecondaryDark - @color/textBackgroundDark + @color/textBackgroundDark @color/iconColorDark @color/buttonBackgroundDark @color/themeSecondaryDark diff --git a/build.gradle b/build.gradle index e6817d5..691b235 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.5.21" + ext.kotlin_version = "1.5.30" repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.0' + classpath 'com.android.tools.build:gradle:7.0.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0f80bbf..ffed3a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 44f99295e9addf657e06ac5d392cebfa9f00b615 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sat, 10 Jul 2021 23:37:16 +0200 Subject: [PATCH 004/133] rework the tmdb controller the tmdb interation now provides additional information: * tv seasons & episodes * movie & tv show (air date, status) --- .../activity/main/fragments/MediaFragment.kt | 23 ++- .../main/viewmodel/MediaFragmentViewModel.kt | 62 +++++++- .../java/org/mosad/teapod/util/DataTypes.kt | 12 -- .../mosad/teapod/util/TMDBApiController.kt | 121 --------------- .../teapod/util/tmdb/TMDBApiController.kt | 139 ++++++++++++++++++ .../mosad/teapod/util/tmdb/TMDBDataTypes.kt | 50 +++++++ 6 files changed, 259 insertions(+), 148 deletions(-) delete mode 100644 app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt create mode 100644 app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt create mode 100644 app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt 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..3a0010f 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 @@ -25,6 +25,8 @@ 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.Movie +import org.mosad.teapod.util.tmdb.TMDBApiController /** * The media detail fragment. @@ -85,21 +87,25 @@ 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.info.posterUrl + val posterUrl = tmdbResult.posterPath?.let { TMDBApiController.imageUrl + it } + ?: media.info.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.info.title binding.textYear.text = media.info.year.toString() binding.textAge.text = media.info.age.toString() binding.textOverview.text = media.info.shortDesc + + // set "my list" indicator if (StorageController.myList.contains(media.id)) { Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) } else { @@ -133,12 +139,13 @@ class MediaFragment(private val mediaId: Int) : Fragment() { fragments.add(MediaFragmentEpisodes()) pagerAdapter.notifyDataSetChanged() } else if (media.type == MediaType.MOVIE) { + val tmdbMovie = (tmdbResult as Movie) - 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 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..d155f31 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,10 +1,14 @@ 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.Movie +import org.mosad.teapod.util.tmdb.TMDBApiController +import org.mosad.teapod.util.tmdb.TMDBResult /** * handle media, next ep and tmdb @@ -15,7 +19,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic internal set var nextEpisode = Episode() internal set - var tmdb = TMDBResponse() + lateinit var tmdbResult: TMDBResult // TODO rename internal set /** @@ -23,14 +27,32 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic */ suspend fun load(mediaId: Int) { media = AoDParser.getMediaById(mediaId) - tmdb = TMDBApiController().search(media.info.title, media.type) + + val tmdbApiController = TMDBApiController() + val searchTitle = stripTitleInfo(media.info.title) + val tmdbId = tmdbApiController.search(searchTitle, media.type) + + tmdbResult = when (media.type) { + MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) + MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId) + else -> Movie(-1) + } + println(tmdbResult) // TODO + + // TESTING + if (media.type == MediaType.TVSHOW) { + val seasonNumber = guessSeasonFromTitle(media.info.title) + Log.d("test", "season number: $seasonNumber") + + // TODO Important: only use tmdb info if media title and episode number match exactly + val tmdbTVSeason = tmdbApiController.getTVSeasonDetails(tmdbId, seasonNumber) + Log.d("test", "Season Info: $tmdbTVSeason.") + } + + // TESTING END 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() } } @@ -45,4 +67,30 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic ?: media.episodes.first() } + // 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/util/DataTypes.kt b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt index 56635e5..8ea34dd 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -98,18 +98,6 @@ 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 -) - /** * this class is used to represent the aod json API? */ 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/tmdb/TMDBApiController.kt b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt new file mode 100644 index 0000000..e68250e --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt @@ -0,0 +1,139 @@ +package org.mosad.teapod.util.tmdb + +import android.util.Log +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 + +// TODO use 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") + 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.first().asJsonObject?.get("id")?.asInt ?: -1 + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun getMovieDetails(movieId: Int): Movie = withContext(Dispatchers.IO) { + val url = URL("$detailsMovieUrl/$movieId?api_key=$apiKey&language=$language") + + val response = try { + JsonParser.parseString(url.readText()).asJsonObject + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "The resource you requested could not be found") + return@withContext Movie(-1) + } + + return@withContext try { + Movie( + id = response.get("id").asInt, + name = response.get("title")?.asString, + overview = response.get("overview")?.asString, + posterPath = response.get("poster_path")?.asString, + backdropPath = response.get("backdrop_path")?.asString, + releaseDate = response.get("release_date")?.asString, + runtime = response.get("runtime")?.asInt + ) + } catch (ex: Exception) { + Log.w(javaClass.name, "Error", ex) + Movie(-1) + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun getTVShowDetails(tvId: Int): TVShow = withContext(Dispatchers.IO) { + val url = URL("$detailsTVUrl/$tvId?api_key=$apiKey&language=$language") + + val response = try { + JsonParser.parseString(url.readText()).asJsonObject + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "The resource you requested could not be found") + return@withContext TVShow(-1) + } + + return@withContext try { + TVShow( + id = response.get("id").asInt, + name = response.get("name")?.asString, + overview = response.get("overview")?.asString, + posterPath = response.get("poster_path")?.asString, + backdropPath = response.get("backdrop_path")?.asString, + firstAirDate = response.get("first_air_date")?.asString, + status = response.get("status")?.asString + ) + } catch (ex: Exception) { + Log.w(javaClass.name, "Error", ex) + TVShow(-1) + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun getTVSeasonDetails(tvId: Int, seasonNumber: Int): TVSeason = withContext(Dispatchers.IO) { + val url = URL("$detailsTVUrl/$tvId/season/$seasonNumber?api_key=$apiKey&language=$language") + + val response = try { + JsonParser.parseString(url.readText()).asJsonObject + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "The resource you requested could not be found") + return@withContext TVSeason(-1) + } + // println(response) + + return@withContext try { + val episodes = response.get("episodes").asJsonArray.map { + TVEpisode( + id = it.asJsonObject.get("id").asInt, + name = it.asJsonObject.get("name")?.asString, + overview = it.asJsonObject.get("overview")?.asString, + airDate = it.asJsonObject.get("air_date")?.asString, + episodeNumber = it.asJsonObject.get("episode_number")?.asInt + ) + } + + TVSeason( + id = response.get("id").asInt, + name = response.asJsonObject.get("name")?.asString, + overview = response.asJsonObject.get("overview")?.asString, + posterPath = response.asJsonObject.get("poster_path")?.asString, + airDate = response.asJsonObject.get("air_date")?.asString, + episodes = episodes, + seasonNumber = response.get("season_number")?.asInt + ) + } catch (ex: Exception) { + Log.w(javaClass.name, "Error", ex) + TVSeason(-1) + } + } + +} \ 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..608831a --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt @@ -0,0 +1,50 @@ +package org.mosad.teapod.util.tmdb + +abstract class TMDBResult{ + abstract val id: Int + abstract val name: String? + abstract val overview: String? + abstract val posterPath: String? + abstract val backdropPath: String? +} + +data class Movie( + override val id: Int, + override val name: String? = null, + override val overview: String? = null, + override val posterPath: String? = null, + override val backdropPath: String? = null, + val releaseDate: String? = null, + val runtime: Int? = null + // TODO generes +): TMDBResult() + +data class TVShow( + override val id: Int, + override val name: String? = null, + override val overview: String? = null, + override val posterPath: String? = null, + override val backdropPath: String? = null, + val firstAirDate: String? = null, + val status: String? = null, + // TODO generes +): TMDBResult() + +data class TVSeason( + val id: Int, + val name: String? = null, + val overview: String? = null, + val posterPath: String? = null, + val airDate: String? = null, + val episodes: List? = null, + val seasonNumber: Int? = null +) + +// TODO decide whether to use nullable or not +data class TVEpisode( + val id: Int, + val name: String? = null, + val overview: String? = null, + val airDate: String? = null, + val episodeNumber: Int? = null +) \ No newline at end of file From c66c725ee342856b7f7b012b4fbd8ddeeaed96db Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 11 Jul 2021 12:56:21 +0200 Subject: [PATCH 005/133] use tmdb data if missing on aod * episode description --- .../teapod/ui/activity/main/MainActivity.kt | 7 +- .../activity/main/fragments/MediaFragment.kt | 1 - .../main/fragments/MediaFragmentEpisodes.kt | 2 +- .../main/viewmodel/MediaFragmentViewModel.kt | 57 +++++++-- .../ui/activity/player/PlayerViewModel.kt | 15 ++- .../org/mosad/teapod/util/MetaDBController.kt | 119 ++++++++++++++++++ .../teapod/util/adapter/EpisodeItemAdapter.kt | 11 +- .../teapod/util/tmdb/TMDBApiController.kt | 24 ++-- .../mosad/teapod/util/tmdb/TMDBDataTypes.kt | 20 +-- 9 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/org/mosad/teapod/util/MetaDBController.kt 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/MediaFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt index 3a0010f..3d9e13e 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 @@ -64,7 +64,6 @@ class MediaFragment(private val mediaId: Int) : Fragment() { } }.attach() - lifecycleScope.launch { model.load(mediaId) // load the streams and tmdb for the selected media 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..2a8b0fa 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 @@ -28,7 +28,7 @@ class MediaFragmentEpisodes : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapterRecEpisodes = EpisodeItemAdapter(model.media.episodes) + adapterRecEpisodes = EpisodeItemAdapter(model.media.episodes, model.tmdbTVSeason?.episodes) binding.recyclerEpisodes.adapter = adapterRecEpisodes // set onItemClick only in adapter is initialized 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 d155f31..e897f68 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 @@ -9,9 +9,11 @@ import org.mosad.teapod.util.DataTypes.MediaType import org.mosad.teapod.util.tmdb.Movie import org.mosad.teapod.util.tmdb.TMDBApiController import org.mosad.teapod.util.tmdb.TMDBResult +import org.mosad.teapod.util.tmdb.TVSeason /** * handle media, next ep and tmdb + * TODO this lives in activity, is this correct? */ class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { @@ -21,16 +23,35 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic internal set lateinit var tmdbResult: TMDBResult // TODO rename internal set + var tmdbTVSeason: TVSeason? =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) { + val tmdbApiController = TMDBApiController() media = AoDParser.getMediaById(mediaId) - val tmdbApiController = TMDBApiController() - val searchTitle = stripTitleInfo(media.info.title) - val tmdbId = tmdbApiController.search(searchTitle, media.type) + // check if metaDB knows the title + val tmdbId: Int = if (MetaDBController.mediaList.media.contains(media.id)) { + // load media info from metaDB + val metaDB = MetaDBController() + mediaMeta = when (media.type) { + MediaType.MOVIE -> metaDB.getMovieMetadata(media.id) + MediaType.TVSHOW -> metaDB.getTVShowMetadata(media.id) + 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.info.title), media.type) + } tmdbResult = when (media.type) { MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) @@ -39,16 +60,30 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic } println(tmdbResult) // TODO - // TESTING - if (media.type == MediaType.TVSHOW) { - val seasonNumber = guessSeasonFromTitle(media.info.title) - Log.d("test", "season number: $seasonNumber") - - // TODO Important: only use tmdb info if media title and episode number match exactly - val tmdbTVSeason = tmdbApiController.getTVSeasonDetails(tmdbId, seasonNumber) - Log.d("test", "Season Info: $tmdbTVSeason.") + // 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 } + // TESTING +// if (media.type == MediaType.TVSHOW) { +// if (mediaMeta != null) { +// val tvShowMeta = mediaMeta as TVShowMeta +// val tmdbTVSeason = tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) +// } else { +// // for tv shows not in metaDB, try to guess/search +// +// val seasonNumber = guessSeasonFromTitle(media.info.title) +// Log.d("test", "season number: $seasonNumber") +// +// val tmdbTVSeason = tmdbApiController.getTVSeasonDetails(tmdbId, seasonNumber) +// Log.d("test", "Season Info: $tmdbTVSeason.") +// } +// } + // TESTING END if (media.type == MediaType.TVSHOW) { 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..c34d321 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,7 @@ 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 java.util.* import kotlin.collections.ArrayList @@ -45,6 +43,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) internal set var nextEpisode: Episode? = null internal set + var mediaMeta: Meta? = null + internal set var currentLanguage: Locale = Locale.ROOT internal set @@ -75,6 +75,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun loadMedia(mediaId: Int, episodeId: Int) { runBlocking { media = AoDParser.getMediaById(mediaId) + mediaMeta = loadMediaMeta(media.id) } currentEpisode = media.getEpisodeById(episodeId) @@ -159,6 +160,14 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } } + private suspend fun loadMediaMeta(aodId: Int): Meta? { + return if (media.type == DataTypes.MediaType.TVSHOW) { + MetaDBController().getTVShowMetadata(aodId) + } else { + null + } + } + /** * Based on the current episodeId, get the next episode. If there is no next * episode, return null 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..84564e0 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt @@ -0,0 +1,119 @@ +package org.mosad.teapod.util + +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() + + Thread.sleep(5000) + + mediaList = Gson().fromJson(json, MediaList::class.java) + } + } + + suspend fun getMovieMetadata(aodId: Int): MovieMeta? { + return metaCacheList.firstOrNull { + it.aodId == aodId + } as MovieMeta? ?: getMovieMetadata2(aodId) + } + + suspend fun getTVShowMetadata(aodId: Int): TVShowMeta? { + return metaCacheList.firstOrNull { + it.aodId == aodId + } as TVShowMeta? ?: getTVShowMetadata2(aodId) + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getMovieMetadata2(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) { + null + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getTVShowMetadata2(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) { + null + } + } + +} + +// TODO move data classes +data class MediaList( + val media: List +) + +abstract class Meta { + abstract val id: Int + abstract val aodId: Int + abstract val tmdbId: Int +} + +data class MovieMeta( + override val id: Int, + @SerializedName("aod_id") + override val aodId: Int, + @SerializedName("tmdb_id") + override val tmdbId: Int +): Meta() + +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() + +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: Int, + @SerializedName("opening_duration") + val openingDuration: Int, + @SerializedName("ending_start") + val endingStart: Int, + @SerializedName("ending_duration") + val endingDuration: Int +) 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..a131a3b 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 @@ -12,8 +12,9 @@ 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.tmdb.TVEpisode -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 @@ -32,7 +33,13 @@ class EpisodeItemAdapter(private val episodes: List) : RecyclerView.Ada } 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) 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 index e68250e..e5c4348 100644 --- a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt @@ -99,14 +99,14 @@ class TMDBApiController { } @Suppress("BlockingMethodInNonBlockingContext") - suspend fun getTVSeasonDetails(tvId: Int, seasonNumber: Int): TVSeason = withContext(Dispatchers.IO) { + suspend fun getTVSeasonDetails(tvId: Int, seasonNumber: Int): TVSeason? = withContext(Dispatchers.IO) { val url = URL("$detailsTVUrl/$tvId/season/$seasonNumber?api_key=$apiKey&language=$language") val response = try { JsonParser.parseString(url.readText()).asJsonObject } catch (ex: FileNotFoundException) { Log.w(javaClass.name, "The resource you requested could not be found") - return@withContext TVSeason(-1) + return@withContext null } // println(response) @@ -114,25 +114,25 @@ class TMDBApiController { val episodes = response.get("episodes").asJsonArray.map { TVEpisode( id = it.asJsonObject.get("id").asInt, - name = it.asJsonObject.get("name")?.asString, - overview = it.asJsonObject.get("overview")?.asString, - airDate = it.asJsonObject.get("air_date")?.asString, - episodeNumber = it.asJsonObject.get("episode_number")?.asInt + name = it.asJsonObject.get("name")?.asString ?: "", + overview = it.asJsonObject.get("overview")?.asString ?: "", + airDate = it.asJsonObject.get("air_date")?.asString ?: "", + episodeNumber = it.asJsonObject.get("episode_number")?.asInt ?: -1 ) } TVSeason( id = response.get("id").asInt, - name = response.asJsonObject.get("name")?.asString, - overview = response.asJsonObject.get("overview")?.asString, - posterPath = response.asJsonObject.get("poster_path")?.asString, - airDate = response.asJsonObject.get("air_date")?.asString, + name = response.asJsonObject.get("name")?.asString ?: "", + overview = response.asJsonObject.get("overview")?.asString ?: "", + posterPath = response.asJsonObject.get("poster_path")?.asString ?: "", + airDate = response.asJsonObject.get("air_date")?.asString ?: "", episodes = episodes, - seasonNumber = response.get("season_number")?.asInt + seasonNumber = response.get("season_number")?.asInt ?: -1 ) } catch (ex: Exception) { Log.w(javaClass.name, "Error", ex) - TVSeason(-1) + null } } 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 index 608831a..08fdc70 100644 --- a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt @@ -32,19 +32,19 @@ data class TVShow( data class TVSeason( val id: Int, - val name: String? = null, - val overview: String? = null, - val posterPath: String? = null, - val airDate: String? = null, - val episodes: List? = null, - val seasonNumber: Int? = null + val name: String, + val overview: String, + val posterPath: String, + val airDate: String, + val episodes: List, + val seasonNumber: Int ) // TODO decide whether to use nullable or not data class TVEpisode( val id: Int, - val name: String? = null, - val overview: String? = null, - val airDate: String? = null, - val episodeNumber: Int? = null + val name: String, + val overview: String, + val airDate: String, + val episodeNumber: Int ) \ No newline at end of file From 26d2da923b445b35fd8d0e4d53a7251915223e2b Mon Sep 17 00:00:00 2001 From: Jannik Date: Sat, 17 Jul 2021 13:15:14 +0200 Subject: [PATCH 006/133] use Gson in TMDBApiController, adapt tmdb types to api documentation * use gson fromJson() to parse tmdb response * adapt tmd types to documentation (nullable/non nullable) --- .../activity/main/fragments/MediaFragment.kt | 8 +- .../main/viewmodel/MediaFragmentViewModel.kt | 5 +- .../org/mosad/teapod/util/MetaDBController.kt | 49 ++++++- .../teapod/util/tmdb/TMDBApiController.kt | 138 +++++++++--------- .../mosad/teapod/util/tmdb/TMDBDataTypes.kt | 74 ++++++++-- 5 files changed, 176 insertions(+), 98 deletions(-) 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 3d9e13e..6aa1742 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 @@ -86,9 +86,9 @@ class MediaFragment(private val mediaId: Int) : Fragment() { */ private fun updateGUI() = with(model) { // generic gui - val backdropUrl = tmdbResult.backdropPath?.let { TMDBApiController.imageUrl + it } + val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it } ?: media.info.posterUrl - val posterUrl = tmdbResult.posterPath?.let { TMDBApiController.imageUrl + it } + val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it } ?: media.info.posterUrl // load poster and backdrop @@ -138,9 +138,9 @@ class MediaFragment(private val mediaId: Int) : Fragment() { fragments.add(MediaFragmentEpisodes()) pagerAdapter.notifyDataSetChanged() } else if (media.type == MediaType.MOVIE) { - val tmdbMovie = (tmdbResult as Movie) + val tmdbMovie = (tmdbResult as Movie?) - if (tmdbMovie.runtime != null) { + if (tmdbMovie?.runtime != null) { binding.textEpisodesOrRuntime.text = resources.getQuantityString( R.plurals.text_runtime, tmdbMovie.runtime, 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 e897f68..207218b 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 @@ -6,7 +6,6 @@ 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.Movie import org.mosad.teapod.util.tmdb.TMDBApiController import org.mosad.teapod.util.tmdb.TMDBResult import org.mosad.teapod.util.tmdb.TVSeason @@ -21,7 +20,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic internal set var nextEpisode = Episode() internal set - lateinit var tmdbResult: TMDBResult // TODO rename + var tmdbResult: TMDBResult? = null // TODO rename internal set var tmdbTVSeason: TVSeason? =null internal set @@ -56,7 +55,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic tmdbResult = when (media.type) { MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId) - else -> Movie(-1) + else -> null } println(tmdbResult) // TODO diff --git a/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt b/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt index 84564e0..9a13e43 100644 --- a/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt +++ b/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt @@ -1,5 +1,28 @@ +/** + * 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.* @@ -25,20 +48,30 @@ class MetaDBController { } } + /** + * 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? ?: getMovieMetadata2(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? ?: getTVShowMetadata2(aodId) + } as TVShowMeta? ?: getTVShowMetadataFromDB(aodId) } @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun getMovieMetadata2(aodId: Int): MovieMeta? = withContext(Dispatchers.IO) { + 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() @@ -47,12 +80,13 @@ class MetaDBController { 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 getTVShowMetadata2(aodId: Int): TVShowMeta? = withContext(Dispatchers.IO) { + 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() @@ -61,23 +95,26 @@ class MetaDBController { meta } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested file was not found. Requested ID: $aodId", ex) null } } } -// TODO move data classes +// 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") @@ -86,6 +123,7 @@ data class MovieMeta( override val tmdbId: Int ): Meta() +// class representing the tv show json object data class TVShowMeta( override val id: Int, @SerializedName("aod_id") @@ -100,6 +138,7 @@ data class TVShowMeta( 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") 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 index e5c4348..067a496 100644 --- a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt @@ -1,6 +1,29 @@ +/** + * 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 @@ -8,7 +31,13 @@ import java.io.FileNotFoundException import java.net.URL import java.net.URLEncoder -// TODO use Klaxon? +/** + * 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" @@ -25,6 +54,12 @@ class TMDBApiController { } @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 @@ -45,93 +80,56 @@ class TMDBApiController { } @Suppress("BlockingMethodInNonBlockingContext") - suspend fun getMovieDetails(movieId: Int): Movie = withContext(Dispatchers.IO) { + /** + * 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): Movie? = withContext(Dispatchers.IO) { val url = URL("$detailsMovieUrl/$movieId?api_key=$apiKey&language=$language") - val response = try { - JsonParser.parseString(url.readText()).asJsonObject - } catch (ex: FileNotFoundException) { - Log.w(javaClass.name, "The resource you requested could not be found") - return@withContext Movie(-1) - } - return@withContext try { - Movie( - id = response.get("id").asInt, - name = response.get("title")?.asString, - overview = response.get("overview")?.asString, - posterPath = response.get("poster_path")?.asString, - backdropPath = response.get("backdrop_path")?.asString, - releaseDate = response.get("release_date")?.asString, - runtime = response.get("runtime")?.asInt - ) - } catch (ex: Exception) { - Log.w(javaClass.name, "Error", ex) - Movie(-1) + val json = url.readText() + Gson().fromJson(json, Movie::class.java) + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $movieId", ex) + null } } @Suppress("BlockingMethodInNonBlockingContext") - suspend fun getTVShowDetails(tvId: Int): TVShow = withContext(Dispatchers.IO) { + /** + * 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): TVShow? = withContext(Dispatchers.IO) { val url = URL("$detailsTVUrl/$tvId?api_key=$apiKey&language=$language") - val response = try { - JsonParser.parseString(url.readText()).asJsonObject - } catch (ex: FileNotFoundException) { - Log.w(javaClass.name, "The resource you requested could not be found") - return@withContext TVShow(-1) - } - return@withContext try { - TVShow( - id = response.get("id").asInt, - name = response.get("name")?.asString, - overview = response.get("overview")?.asString, - posterPath = response.get("poster_path")?.asString, - backdropPath = response.get("backdrop_path")?.asString, - firstAirDate = response.get("first_air_date")?.asString, - status = response.get("status")?.asString - ) - } catch (ex: Exception) { - Log.w(javaClass.name, "Error", ex) - TVShow(-1) + val json = url.readText() + Gson().fromJson(json, TVShow::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): TVSeason? = withContext(Dispatchers.IO) { val url = URL("$detailsTVUrl/$tvId/season/$seasonNumber?api_key=$apiKey&language=$language") - val response = try { - JsonParser.parseString(url.readText()).asJsonObject - } catch (ex: FileNotFoundException) { - Log.w(javaClass.name, "The resource you requested could not be found") - return@withContext null - } - // println(response) - return@withContext try { - val episodes = response.get("episodes").asJsonArray.map { - TVEpisode( - id = it.asJsonObject.get("id").asInt, - name = it.asJsonObject.get("name")?.asString ?: "", - overview = it.asJsonObject.get("overview")?.asString ?: "", - airDate = it.asJsonObject.get("air_date")?.asString ?: "", - episodeNumber = it.asJsonObject.get("episode_number")?.asInt ?: -1 - ) - } - - TVSeason( - id = response.get("id").asInt, - name = response.asJsonObject.get("name")?.asString ?: "", - overview = response.asJsonObject.get("overview")?.asString ?: "", - posterPath = response.asJsonObject.get("poster_path")?.asString ?: "", - airDate = response.asJsonObject.get("air_date")?.asString ?: "", - episodes = episodes, - seasonNumber = response.get("season_number")?.asInt ?: -1 - ) - } catch (ex: Exception) { - Log.w(javaClass.name, "Error", ex) + val json = url.readText() + Gson().fromJson(json, TVSeason::class.java) + } catch (ex: FileNotFoundException) { + Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $tvId, Season: $seasonNumber", ex) null } } 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 index 08fdc70..5a474c7 100644 --- a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt @@ -1,32 +1,69 @@ +/** + * 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? + 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 Movie( override val id: Int, - override val name: String? = null, - override val overview: String? = null, - override val posterPath: String? = null, - override val backdropPath: String? = null, - val releaseDate: String? = null, - val runtime: Int? = null + 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 TVShow( override val id: Int, - override val name: String? = null, - override val overview: String? = null, - override val posterPath: String? = null, - override val backdropPath: String? = null, - val firstAirDate: String? = null, - val status: String? = null, + 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() @@ -34,17 +71,22 @@ data class TVSeason( val id: Int, val name: String, val overview: String, - val posterPath: String, + @SerializedName("poster_path") + val posterPath: String?, + @SerializedName("air_date") val airDate: String, + @SerializedName("episodes") val episodes: List, + @SerializedName("season_number") val seasonNumber: Int ) -// TODO decide whether to use nullable or not data class TVEpisode( 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 From 9dfd2cf70b9072f98e729cfc77f26e5ec684d76f Mon Sep 17 00:00:00 2001 From: Jannik Date: Sat, 17 Jul 2021 19:40:16 +0200 Subject: [PATCH 007/133] added skip opening for tv shows * available for tv shows, where metaDB has the needed information --- .../ui/activity/player/PlayerActivity.kt | 68 ++++++++++++++++--- .../ui/activity/player/PlayerViewModel.kt | 18 ++++- .../org/mosad/teapod/util/MetaDBController.kt | 10 ++- app/src/main/res/layout/activity_player.xml | 16 +++++ app/src/main/res/values-de-rDE/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 97 insertions(+), 17 deletions(-) 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..24682f5 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 @@ -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,16 +263,20 @@ 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()) { showButtonNextEp() } @@ -279,6 +284,19 @@ class PlayerActivity : AppCompatActivity() { 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() @@ -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 c34d321..2894e21 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 @@ -27,6 +27,9 @@ import kotlin.collections.ArrayList * PlayerViewModel handles all stuff related to media/episodes. * When currentEpisode is changed the player will start playing it (not initial media), * the next episode will be update and the callback is handled. + * + * TODO rework don't use episodes for everything, use media instead + * this is a major rework of the AoDParser/Player/Media architecture */ class PlayerViewModel(application: Application) : AndroidViewModel(application) { @@ -45,6 +48,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) internal set var mediaMeta: Meta? = null internal set + var currentEpisodeMeta: EpisodeMeta? = null + internal set var currentLanguage: Locale = Locale.ROOT internal set @@ -75,11 +80,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun loadMedia(mediaId: Int, episodeId: Int) { runBlocking { media = AoDParser.getMediaById(mediaId) - mediaMeta = loadMediaMeta(media.id) + mediaMeta = loadMediaMeta(media.id) // can be done blocking, since it should be cached } currentEpisode = media.getEpisodeById(episodeId) nextEpisode = selectNextEpisode() + currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.id) currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language } @@ -121,6 +127,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) currentLanguage = preferredStream.language // update current language, since it may have changed currentEpisode = episode nextEpisode = selectNextEpisode() + currentEpisodeMeta = getEpisodeMetaByAoDMediaId(episode.id) currentEpisodeChangedListener.forEach { it() } // update player gui (title) val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( @@ -160,6 +167,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } } + 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) diff --git a/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt b/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt index 9a13e43..387a129 100644 --- a/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt +++ b/app/src/main/java/org/mosad/teapod/util/MetaDBController.kt @@ -42,8 +42,6 @@ class MetaDBController { val url = URL("$repoUrl/list.json") val json = url.readText() - Thread.sleep(5000) - mediaList = Gson().fromJson(json, MediaList::class.java) } } @@ -148,11 +146,11 @@ data class EpisodeMeta( @SerializedName("tmdb_number") val tmdbNumber: Int, @SerializedName("opening_start") - val openingStart: Int, + val openingStart: Long, @SerializedName("opening_duration") - val openingDuration: Int, + val openingDuration: Long, @SerializedName("ending_start") - val endingStart: Int, + val endingStart: Long, @SerializedName("ending_duration") - val endingDuration: Int + val endingDuration: Long ) 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/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index af2731e..a957051 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -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..a58f3a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,6 +88,7 @@ - 10 s + 10 s Next Episode + Skip Opening %1$02d:%2$02d %1$d:%2$02d:%3$02d Language From 0340c83b47a46bb4fc5ab703a38cdc9cfe101a61 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 25 Jul 2021 19:15:31 +0200 Subject: [PATCH 008/133] clean up some AoDParser related code --- app/build.gradle | 2 +- .../java/org/mosad/teapod/parser/AoDParser.kt | 62 ++++++++++++------- .../activity/main/fragments/HomeFragment.kt | 22 +++---- .../main/fragments/LibraryFragment.kt | 2 +- .../activity/main/fragments/MediaFragment.kt | 6 +- .../activity/main/fragments/SearchFragment.kt | 2 +- .../main/viewmodel/MediaFragmentViewModel.kt | 22 +------ .../java/org/mosad/teapod/util/DataTypes.kt | 8 +-- .../teapod/util/adapter/EpisodeItemAdapter.kt | 4 +- .../teapod/util/tmdb/TMDBApiController.kt | 12 ++-- .../mosad/teapod/util/tmdb/TMDBDataTypes.kt | 10 +-- 11 files changed, 76 insertions(+), 76 deletions(-) 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..4481800 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -49,7 +49,10 @@ object AoDParser { private var loginSuccess = false private val mediaList = arrayListOf() // actual media (data) - val itemMediaList = arrayListOf() // gui media + private val aodMediaList = arrayListOf() + + // gui media + val guiMediaList = arrayListOf() val highlightsList = arrayListOf() val newEpisodesList = arrayListOf() val newSimulcastsList = arrayListOf() @@ -110,8 +113,8 @@ object AoDParser { * get a media by it's ID (int) * @return Media */ - suspend fun getMediaById(mediaId: Int): Media { - val media = mediaList.first { it.id == mediaId } + suspend fun getMediaById(aodId: Int): Media { + val media = mediaList.first { it.id == aodId } if (media.episodes.isEmpty()) { loadStreams(media).join() @@ -180,24 +183,39 @@ object AoDParser { val resAnimes = Jsoup.connect(baseUrl + libraryPath).get() //println(resAnimes) - itemMediaList.clear() + guiMediaList.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 - } - 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() + val animes = resAnimes.select("div.animebox") - itemMediaList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) - mediaList.add(Media(mediaId, mediaLink, type).apply { - info.title = mediaTitle - info.posterUrl = mediaImage + 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") + ) + } + ) + + // TODO legacy + resAnimes.select("div.animebox").forEach { + val id = it.select("p.animebox-link").select("a").attr("href") + .substringAfterLast("/").toInt() + val title = it.select("h3.animebox-title").text() + val image = it.select("p.animebox-image").select("img").attr("src") + val link = it.select("p.animebox-link").select("a").attr("href") + val type = when (it.select("p.animebox-link").select("a").text().lowercase(Locale.ROOT)) { + "zur serie" -> MediaType.TVSHOW + "zum film" -> MediaType.MOVIE + else -> MediaType.OTHER + } + val mediaShortText = it.select("p.animebox-shorttext").text() + + mediaList.add(Media(id, link, type).apply { + info.title = title + info.posterUrl = image info.shortDesc = mediaShortText }) } @@ -410,9 +428,9 @@ object AoDParser { /** * 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(), language)) } return CoroutineScope(Dispatchers.IO).async(Dispatchers.IO) { @@ -435,7 +453,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 -> 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..29fed64 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 @@ -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 6aa1742..026306f 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 @@ -25,7 +25,7 @@ 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.Movie +import org.mosad.teapod.util.tmdb.TMDBMovie import org.mosad.teapod.util.tmdb.TMDBApiController /** @@ -138,7 +138,7 @@ class MediaFragment(private val mediaId: Int) : Fragment() { fragments.add(MediaFragmentEpisodes()) pagerAdapter.notifyDataSetChanged() } else if (media.type == MediaType.MOVIE) { - val tmdbMovie = (tmdbResult as Movie?) + val tmdbMovie = (tmdbResult as TMDBMovie?) if (tmdbMovie?.runtime != null) { binding.textEpisodesOrRuntime.text = resources.getQuantityString( @@ -171,7 +171,7 @@ class MediaFragment(private val mediaId: Int) : Fragment() { when (media.type) { MediaType.MOVIE -> playEpisode(media.episodes.first()) MediaType.TVSHOW -> playEpisode(nextEpisode) - else -> Log.e(javaClass.name, "Wrong Type: $media.type") + else -> Log.e(javaClass.name, "Wrong Type: ${media.type}") } } 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 207218b..6e4c724 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 @@ -8,7 +8,7 @@ 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.TVSeason +import org.mosad.teapod.util.tmdb.TMDBTVSeason /** * handle media, next ep and tmdb @@ -22,7 +22,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic internal set var tmdbResult: TMDBResult? = null // TODO rename internal set - var tmdbTVSeason: TVSeason? =null + var tmdbTVSeason: TMDBTVSeason? =null internal set var mediaMeta: Meta? = null internal set @@ -67,24 +67,6 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic null } - // TESTING -// if (media.type == MediaType.TVSHOW) { -// if (mediaMeta != null) { -// val tvShowMeta = mediaMeta as TVShowMeta -// val tmdbTVSeason = tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) -// } else { -// // for tv shows not in metaDB, try to guess/search -// -// val seasonNumber = guessSeasonFromTitle(media.info.title) -// Log.d("test", "season number: $seasonNumber") -// -// val tmdbTVSeason = tmdbApiController.getTVSeasonDetails(tmdbId, seasonNumber) -// Log.d("test", "Season Info: $tmdbTVSeason.") -// } -// } - - // TESTING END - if (media.type == MediaType.TVSHOW) { nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first() } 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 8ea34dd..397e448 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -36,7 +36,7 @@ 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 ) @@ -101,9 +101,9 @@ data class Stream( /** * 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: String ) data class Playlist( 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 a131a3b..89b0bf5 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 @@ -12,9 +12,9 @@ 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.tmdb.TVEpisode +import org.mosad.teapod.util.tmdb.TMDBTVEpisode -class EpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { +class EpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { var onImageClick: ((String, Int) -> Unit)? = null 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 index 067a496..db74e8b 100644 --- a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt @@ -85,12 +85,12 @@ class TMDBApiController { * @param movieId The tmdb ID of the movie * @return A tmdb movie object, or null if not found */ - suspend fun getMovieDetails(movieId: Int): Movie? = withContext(Dispatchers.IO) { + 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, Movie::class.java) + 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 @@ -103,12 +103,12 @@ class TMDBApiController { * @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): TVShow? = withContext(Dispatchers.IO) { + 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, TVShow::class.java) + 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 @@ -122,12 +122,12 @@ class TMDBApiController { * @param seasonNumber The tmdb season number * @return A tmdb tv season object, or null if not found */ - suspend fun getTVSeasonDetails(tvId: Int, seasonNumber: Int): TVSeason? = withContext(Dispatchers.IO) { + 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, TVSeason::class.java) + 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 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 index 5a474c7..a3f5106 100644 --- a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBDataTypes.kt @@ -37,7 +37,7 @@ abstract class TMDBResult{ abstract val backdropPath: String? } -data class Movie( +data class TMDBMovie( override val id: Int, override val name: String, override val overview: String?, @@ -52,7 +52,7 @@ data class Movie( // TODO generes ): TMDBResult() -data class TVShow( +data class TMDBTVShow( override val id: Int, override val name: String, override val overview: String, @@ -67,7 +67,7 @@ data class TVShow( // TODO generes ): TMDBResult() -data class TVSeason( +data class TMDBTVSeason( val id: Int, val name: String, val overview: String, @@ -76,12 +76,12 @@ data class TVSeason( @SerializedName("air_date") val airDate: String, @SerializedName("episodes") - val episodes: List, + val episodes: List, @SerializedName("season_number") val seasonNumber: Int ) -data class TVEpisode( +data class TMDBTVEpisode( val id: Int, val name: String, val overview: String, From 309a9910075cdf7406096c5535ef6400f76d267c Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 25 Jul 2021 19:17:37 +0200 Subject: [PATCH 009/133] fix for AoDParser related code clean up --- app/src/main/java/org/mosad/teapod/parser/AoDParser.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4481800..b33291d 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -346,13 +346,13 @@ object AoDParser { playlists.forEach { aod -> // TODO improve language handling - val locale = when (aod.extLanguage) { + val locale = when (aod.language) { "ger" -> Locale.GERMAN "jap" -> Locale.JAPANESE else -> Locale.ROOT } - aod.playlist.forEach { ep -> + aod.list.forEach { ep -> try { if (media.hasEpisode(ep.mediaid)) { media.getEpisodeById(ep.mediaid).streams.add( From d76538cf28b123f194adc1d5f48ab8dcaf89c0b9 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 25 Jul 2021 19:30:25 +0200 Subject: [PATCH 010/133] use locale instead of string for language in AoDPlaylist --- .../java/org/mosad/teapod/parser/AoDParser.kt | 21 ++++++++----------- .../java/org/mosad/teapod/util/DataTypes.kt | 2 +- 2 files changed, 10 insertions(+), 13 deletions(-) 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 b33291d..c87e0c1 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -49,7 +49,6 @@ object AoDParser { private var loginSuccess = false private val mediaList = arrayListOf() // actual media (data) - private val aodMediaList = arrayListOf() // gui media val guiMediaList = arrayListOf() @@ -345,23 +344,16 @@ object AoDParser { }.awaitAll() playlists.forEach { aod -> - // TODO improve language handling - val locale = when (aod.language) { - "ger" -> Locale.GERMAN - "jap" -> Locale.JAPANESE - else -> Locale.ROOT - } - aod.list.forEach { ep -> try { if (media.hasEpisode(ep.mediaid)) { media.getEpisodeById(ep.mediaid).streams.add( - Stream(ep.sources.first().file, locale) + Stream(ep.sources.first().file, aod.language) ) } else { media.episodes.add(Episode( id = ep.mediaid, - streams = mutableListOf(Stream(ep.sources.first().file, locale)), + streams = mutableListOf(Stream(ep.sources.first().file, aod.language)), posterUrl = ep.image, title = ep.title, description = ep.description, @@ -430,7 +422,7 @@ object AoDParser { */ private fun parsePlaylistAsync(playlistPath: String, language: String): Deferred { if (playlistPath == "[]") { - return CompletableDeferred(AoDPlaylist(listOf(), language)) + return CompletableDeferred(AoDPlaylist(listOf(), Locale.ROOT)) } return CoroutineScope(Dispatchers.IO).async(Dispatchers.IO) { @@ -465,7 +457,12 @@ 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 + } ) } } 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 397e448..449919d 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -103,7 +103,7 @@ data class Stream( */ data class AoDPlaylist( val list: List, - val language: String + val language: Locale ) data class Playlist( From a505315781615d43b89414725a1faad720cd86ae Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 15 Aug 2021 00:11:42 +0200 Subject: [PATCH 011/133] fix crash if media is not found in tmdb --- .../main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index db74e8b..93003c4 100644 --- a/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt +++ b/app/src/main/java/org/mosad/teapod/util/tmdb/TMDBApiController.kt @@ -76,7 +76,7 @@ class TMDBApiController { it.asJsonObject.get("title")?.asString } - return@withContext sortedResults.first().asJsonObject?.get("id")?.asInt ?: -1 + return@withContext sortedResults.firstOrNull()?.asJsonObject?.get("id")?.asInt ?: -1 } @Suppress("BlockingMethodInNonBlockingContext") From c2a5f768b89aeba5976b1c7f18bb7a4745c5f44d Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 31 Aug 2021 19:47:18 +0200 Subject: [PATCH 012/133] AoDParser Media handling rework [Part 1/2] --- .../java/org/mosad/teapod/parser/AoDParser.kt | 150 +++++++++++++++++- .../activity/main/fragments/HomeFragment.kt | 6 +- .../activity/main/fragments/MediaFragment.kt | 63 ++++---- .../main/fragments/MediaFragmentEpisodes.kt | 17 +- .../main/fragments/MediaFragmentSimilar.kt | 2 +- .../main/viewmodel/MediaFragmentViewModel.kt | 47 ++++-- .../java/org/mosad/teapod/util/DataTypes.kt | 72 +++++++++ .../teapod/util/adapter/EpisodeItemAdapter.kt | 8 +- .../metadata/android/de/full_description.txt | 2 +- 9 files changed, 298 insertions(+), 69 deletions(-) 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 c87e0c1..95ea3a4 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -48,7 +48,8 @@ object AoDParser { private var csrfToken: String = "" private var loginSuccess = false - private val mediaList = arrayListOf() // actual media (data) + private val mediaList = arrayListOf() // actual media (data) TODO remove + private val aodMediaList = arrayListOf() // actual media (data) // gui media val guiMediaList = arrayListOf() @@ -112,16 +113,38 @@ object AoDParser { * get a media by it's ID (int) * @return Media */ + @Deprecated(message = "Use getMediaById2() instead") suspend fun getMediaById(aodId: Int): Media { val media = mediaList.first { it.id == aodId } if (media.episodes.isEmpty()) { loadStreams(media).join() + + loadMediaAsync(media.id).await() } return media } + /** + * get a media by it's ID (int) + * @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 getMediaById2(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 + } + + + } + /** * get subscription info from aod website, remove "Anime-Abo" Prefix and trim */ @@ -417,6 +440,131 @@ object AoDParser { } } + 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@async AoDMediaNone + } + + // get the media page + val res = Jsoup.connect("$baseUrl/anime/$aodId") + .cookies(sessionCookies) + .get() + // println(res) + + if (csrfToken.isEmpty()) { + csrfToken = res.select("meta[name=csrf-token]").attr("content") + Log.d(javaClass.name, "New csrf token is $csrfToken") + } + + // playlist parsing TODO can this be async to the genral info marsing? + val besides = res.select("div.besides").first() + val aodPlaylists = besides.select("input.streamstarter_html5").map { streamstarter -> + parsePlaylistAsync( + streamstarter.attr("data-playlist"), + streamstarter.attr("data-lang") + ) + } + + /** + * 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" -> year = row.select("td").text().toInt() + "fsk" -> age = row.select("td").text().toInt() + } + } + + // similar titles from media page + 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") + val mediaTitle = it.select("a").text() + + if (mediaId != null) { + ItemMedia(mediaId, mediaTitle, mediaImage) + } else { + null + } + } + + /** + * 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 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() + + AoDEpisodeInfo(mediaId, episodeShortDesc, episodeWatched, episodeWatchedCallback) + } else { + null + } + }.associateBy { it.aodMediaId } + } else { + mapOf() + } + + // TODO make AoDPlaylist to 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, + number = 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() + println("new playlist object: $playlist") + + 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 */ 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 29fed64..e9422f1 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 @@ -96,10 +96,10 @@ class HomeFragment : Fragment() { binding.buttonPlayHighlight.setOnClickListener { // TODO get next episode lifecycleScope.launch { - val media = AoDParser.getMediaById(highlightMedia.id) + val media = AoDParser.getMediaById2(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) } } 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 026306f..2d8cef3 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,7 +23,6 @@ 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 @@ -57,7 +56,7 @@ class MediaFragment(private val mediaId: Int) : Fragment() { binding.pagerEpisodesSimilar.offscreenPageLimit = 2 binding.pagerEpisodesSimilar.adapter = pagerAdapter TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> - tab.text = if (model.media.type == MediaType.TVSHOW && position == 0) { + tab.text = if (model.media2.type == MediaType.TVSHOW && position == 0) { getString(R.string.episodes) } else { getString(R.string.similar_titles) @@ -76,8 +75,9 @@ 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 + println(model.nextEpisodeId) + if (model.media2.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { + binding.textTitle.text = model.media2.getEpisodeById(model.nextEpisodeId).title } } @@ -87,9 +87,9 @@ class MediaFragment(private val mediaId: Int) : Fragment() { private fun updateGUI() = with(model) { // generic gui val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it } - ?: media.info.posterUrl + ?: media2.posterURL val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it } - ?: media.info.posterUrl + ?: media2.posterURL // load poster and backdrop Glide.with(requireContext()).load(posterUrl) @@ -99,13 +99,13 @@ class MediaFragment(private val mediaId: Int) : Fragment() { .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) .into(binding.imageBackdrop) - 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 + binding.textTitle.text = media2.title + binding.textYear.text = media2.year.toString() + binding.textAge.text = media2.age.toString() + binding.textOverview.text = media2.shortText // set "my list" indicator - if (StorageController.myList.contains(media.id)) { + if (StorageController.myList.contains(media2.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) @@ -116,28 +116,25 @@ class MediaFragment(private val mediaId: Int) : Fragment() { pagerAdapter.notifyDataSetChanged() // specific gui - if (media.type == MediaType.TVSHOW) { + if (media2.type == MediaType.TVSHOW) { // get next episode - nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { - media.episodes.first{ !it.watched } - } else { - media.episodes.first() - } + nextEpisodeId = media2.playlist.firstOrNull{ !it.watched }?.mediaId + ?: media2.playlist.first().mediaId // title is the next episodes title - binding.textTitle.text = nextEpisode.title + binding.textTitle.text = media2.getEpisodeById(nextEpisodeId).title // episodes count binding.textEpisodesOrRuntime.text = resources.getQuantityString( R.plurals.text_episodes_count, - media.info.episodesCount, - media.info.episodesCount + media2.playlist.size, + media2.playlist.size ) // episodes fragments.add(MediaFragmentEpisodes()) pagerAdapter.notifyDataSetChanged() - } else if (media.type == MediaType.MOVIE) { + } else if (media2.type == MediaType.MOVIE) { val tmdbMovie = (tmdbResult as TMDBMovie?) if (tmdbMovie?.runtime != null) { @@ -152,7 +149,7 @@ class MediaFragment(private val mediaId: Int) : Fragment() { } // if has similar titles - if (media.info.similar.isNotEmpty()) { + if (media2.similar.isNotEmpty()) { fragments.add(MediaFragmentSimilar()) pagerAdapter.notifyDataSetChanged() } @@ -168,20 +165,20 @@ 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}") + when (media2.type) { + MediaType.MOVIE -> playEpisode(media2.playlist.first().mediaId) + MediaType.TVSHOW -> playEpisode(nextEpisodeId) + else -> Log.e(javaClass.name, "Wrong Type: ${media2.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(media2.aodId)) { + StorageController.myList.remove(media2.aodId) Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) } else { - StorageController.myList.add(media.id) + StorageController.myList.add(media2.aodId) Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) } StorageController.saveMyList(requireContext()) @@ -197,11 +194,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.media2.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 2a8b0fa..7a0eff9 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, model.tmdbTVSeason?.episodes) + adapterRecEpisodes = EpisodeItemAdapter(model.media2.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.media2.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.media2.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.media2.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..dba70c3 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.media2.similar) binding.recyclerMediaSimilar.adapter = adapterSimilar binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) 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 6e4c724..fd1f4b6 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 @@ -16,10 +16,17 @@ import org.mosad.teapod.util.tmdb.TMDBTVSeason */ class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { - var media = Media(-1, "", MediaType.OTHER) +// var media = Media(-1, "", MediaType.OTHER) +// internal set +// var nextEpisode = Episode() +// internal set + + var media2 = AoDMediaNone internal set - var nextEpisode = Episode() + var nextEpisodeId = -1 internal set + + var tmdbResult: TMDBResult? = null // TODO rename internal set var tmdbTVSeason: TMDBTVSeason? =null @@ -33,15 +40,16 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic */ suspend fun load(mediaId: Int) { val tmdbApiController = TMDBApiController() - media = AoDParser.getMediaById(mediaId) + //media = AoDParser.getMediaById(mediaId) + media2 = AoDParser.getMediaById2(mediaId) // check if metaDB knows the title - val tmdbId: Int = if (MetaDBController.mediaList.media.contains(media.id)) { + val tmdbId: Int = if (MetaDBController.mediaList.media.contains(media2.aodId)) { // load media info from metaDB val metaDB = MetaDBController() - mediaMeta = when (media.type) { - MediaType.MOVIE -> metaDB.getMovieMetadata(media.id) - MediaType.TVSHOW -> metaDB.getTVShowMetadata(media.id) + mediaMeta = when (media2.type) { + MediaType.MOVIE -> metaDB.getMovieMetadata(media2.aodId) + MediaType.TVSHOW -> metaDB.getTVShowMetadata(media2.aodId) else -> null } @@ -49,10 +57,10 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic } 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.info.title), media.type) + tmdbApiController.search(stripTitleInfo(media2.title), media2.type) } - tmdbResult = when (media.type) { + tmdbResult = when (media2.type) { MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId) else -> null @@ -60,27 +68,32 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic println(tmdbResult) // TODO // get season info, if metaDB knows the tv show - tmdbTVSeason = if (media.type == MediaType.TVSHOW && mediaMeta != null) { + tmdbTVSeason = if (media2.type == MediaType.TVSHOW && mediaMeta != null) { val tvShowMeta = mediaMeta as TVShowMeta tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) } else { null } - if (media.type == MediaType.TVSHOW) { - nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first() + if (media2.type == MediaType.TVSHOW) { + //nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first() + nextEpisodeId = media2.playlist.firstOrNull { !it.watched }?.mediaId + ?: media2.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) { - if (media.type == MediaType.MOVIE) return // return if movie + fun updateNextEpisode(episodeId: Int) { + if (media2.type == MediaType.MOVIE) return // return if movie - nextEpisode = media.episodes.firstOrNull{ it.number > currentEp.number } - ?: media.episodes.first() +// nextEpisode = media.episodes.firstOrNull{ it.number > currentEp.number } +// ?: media.episodes.first() + + nextEpisodeId = media2.playlist.firstOrNull { it.number > media2.getEpisodeById(episodeId).number }?.mediaId + ?: media2.playlist.first().mediaId } // remove unneeded info from the media title before searching 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 449919d..909e766 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -44,6 +44,78 @@ data class ItemMedia( /** * TODO the episodes workflow could use a clean up/rework */ +// TODO replace playlist: List with a map? +data class AoDMedia( + val aodId: Int, + val type: DataTypes.MediaType, + val title: String, + val shortText: String, + val posterURL: String, + var year: Int, + var age: Int, + val similar: List, + val playlist: List, +) { + fun hasEpisode(mediaId: Int) = playlist.any { it.mediaId == mediaId } + fun getEpisodeById(mediaId: Int) = playlist.firstOrNull { it.mediaId == mediaId } + ?: AoDEpisodeNone +} + +data class AoDEpisode( + val mediaId: Int, + val title: String, + val description: String, + val shortDesc: String, + val imageURL: String, + val number: Int, + var watched: Boolean, + val watchedCallback: String, + val streams: MutableList, +){ + fun hasDub() = streams.any { it.language == Locale.GERMAN } + + /** + * 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() +} + +// 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() +) + +// LEGACY + data class Media( val id: Int, val link: String, 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 89b0bf5..62416d9 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,10 +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, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { +class EpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { var onImageClick: ((String, Int) -> Unit)? = null @@ -41,8 +41,8 @@ class EpisodeItemAdapter(private val episodes: List, private val tmdbEp "" } - 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/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" From ed9eff433b918b23a2f92d777686eca9551830aa Mon Sep 17 00:00:00 2001 From: Jannik Date: Sat, 4 Sep 2021 13:33:46 +0200 Subject: [PATCH 013/133] AoDParser Media handling rework [Part 2/2] * move Player to new AoD media Implementation * remove old AoD media Implementation from AoDParser --- .../java/org/mosad/teapod/parser/AoDParser.kt | 188 ++---------------- .../activity/main/fragments/HomeFragment.kt | 2 +- .../activity/main/fragments/MediaFragment.kt | 51 +++-- .../main/fragments/MediaFragmentEpisodes.kt | 8 +- .../main/fragments/MediaFragmentSimilar.kt | 2 +- .../main/viewmodel/MediaFragmentViewModel.kt | 43 ++-- .../ui/activity/player/PlayerActivity.kt | 2 +- .../ui/activity/player/PlayerViewModel.kt | 25 +-- .../ui/components/EpisodesListPlayer.kt | 4 +- .../java/org/mosad/teapod/util/DataTypes.kt | 61 +----- .../util/adapter/PlayerEpisodeItemAdapter.kt | 8 +- 11 files changed, 81 insertions(+), 313 deletions(-) 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 95ea3a4..11627ba 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,7 +47,6 @@ object AoDParser { private var csrfToken: String = "" private var loginSuccess = false - private val mediaList = arrayListOf() // actual media (data) TODO remove private val aodMediaList = arrayListOf() // actual media (data) // gui media @@ -109,29 +107,12 @@ object AoDParser { } } - /** - * get a media by it's ID (int) - * @return Media - */ - @Deprecated(message = "Use getMediaById2() instead") - suspend fun getMediaById(aodId: Int): Media { - val media = mediaList.first { it.id == aodId } - - if (media.episodes.isEmpty()) { - loadStreams(media).join() - - loadMediaAsync(media.id).await() - } - - return media - } - /** * get a media by it's ID (int) * @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 getMediaById2(aodId: Int): AoDMedia { + suspend fun getMediaById(aodId: Int): AoDMedia { return aodMediaList.firstOrNull { it.aodId == aodId } ?: try { loadMediaAsync(aodId).await().apply { @@ -141,8 +122,6 @@ object AoDParser { Log.e(javaClass.name, "Error while loading media $aodId", exn) AoDMediaNone } - - } /** @@ -165,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 @@ -206,7 +185,6 @@ object AoDParser { //println(resAnimes) guiMediaList.clear() - mediaList.clear() val animes = resAnimes.select("div.animebox") guiMediaList.addAll( @@ -221,28 +199,7 @@ object AoDParser { } ) - // TODO legacy - resAnimes.select("div.animebox").forEach { - val id = it.select("p.animebox-link").select("a").attr("href") - .substringAfterLast("/").toInt() - val title = it.select("h3.animebox-title").text() - val image = it.select("p.animebox-image").select("img").attr("src") - val link = it.select("p.animebox-link").select("a").attr("href") - val type = when (it.select("p.animebox-link").select("a").text().lowercase(Locale.ROOT)) { - "zur serie" -> MediaType.TVSHOW - "zum film" -> MediaType.MOVIE - else -> MediaType.OTHER - } - val mediaShortText = it.select("p.animebox-shorttext").text() - - mediaList.add(Media(id, link, type).apply { - info.title = title - info.posterUrl = image - info.shortDesc = mediaShortText - }) - } - - Log.i(javaClass.name, "Total library size is: ${mediaList.size}") + Log.i(javaClass.name, "Total library size is: ${guiMediaList.size}") } } @@ -332,121 +289,17 @@ 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() - - if (!loginSuccess) { - Log.w(javaClass.name, "Login, was not successful.") - return@launch - } - - // get the media page - val res = Jsoup.connect(baseUrl + media.link) - .cookies(sessionCookies) - .get() - - //println(res) - - if (csrfToken.isEmpty()) { - csrfToken = res.select("meta[name=csrf-token]").attr("content") - //Log.i(javaClass.name, "New csrf token is $csrfToken") - } - - val besides = res.select("div.besides").first() - val playlists = besides.select("input.streamstarter_html5").map { streamstarter -> - parsePlaylistAsync( - streamstarter.attr("data-playlist"), - streamstarter.attr("data-lang") - ) - }.awaitAll() - - playlists.forEach { aod -> - aod.list.forEach { ep -> - try { - if (media.hasEpisode(ep.mediaid)) { - media.getEpisodeById(ep.mediaid).streams.add( - Stream(ep.sources.first().file, aod.language) - ) - } else { - media.episodes.add(Episode( - id = ep.mediaid, - streams = mutableListOf(Stream(ep.sources.first().file, aod.language)), - 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 - 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() - } - } - } - - // similar titles from media page - media.info.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") - val mediaTitle = it.select("a").text() - - if (mediaId != null) { - ItemMedia(mediaId, mediaTitle, mediaImage) - } else { - 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 -> - // 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() - - media.episodes.firstOrNull { it.id == episodeId }?.apply { - shortDesc = episodeShortDesc - watched = episodeWatched - watchedCallback = episodeWatchedCallback - } - } - } - } - Log.i(javaClass.name, "media loaded successfully") - } - } - 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.") + Log.w(javaClass.name, "Login was not successful") return@async AoDMediaNone } @@ -461,7 +314,7 @@ object AoDParser { Log.d(javaClass.name, "New csrf token is $csrfToken") } - // playlist parsing TODO can this be async to the genral info marsing? + // playlist parsing TODO can this be async to the general info parsing? val besides = res.select("div.besides").first() val aodPlaylists = besides.select("input.streamstarter_html5").map { streamstarter -> parsePlaylistAsync( @@ -501,6 +354,7 @@ object AoDParser { if (mediaId != null) { ItemMedia(mediaId, mediaTitle, mediaImage) } else { + Log.i(javaClass.name, "MediaId for similar to $aodId was null") null } } @@ -522,6 +376,7 @@ object AoDParser { AoDEpisodeInfo(mediaId, episodeShortDesc, episodeWatched, episodeWatchedCallback) } else { + Log.i(javaClass.name, "Episode info for $aodId has empty streamstarter_html5 ") null } }.associateBy { it.aodMediaId } @@ -529,7 +384,7 @@ object AoDParser { mapOf() } - // TODO make AoDPlaylist to teapod playlist + // map the aod api playlist to a teapod playlist val playlist: List = aodPlaylists.awaitAll().flatMap { aodPlaylist -> aodPlaylist.list.mapIndexed { index, episode -> AoDEpisode( @@ -549,7 +404,6 @@ object AoDParser { it.streams.addAll(element.streams) } }.values.toList() - println("new playlist object: $playlist") return@async AoDMedia( aodId = aodId, @@ -615,22 +469,4 @@ object AoDParser { } } - /** - * 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/fragments/HomeFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt index e9422f1..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 @@ -96,7 +96,7 @@ class HomeFragment : Fragment() { binding.buttonPlayHighlight.setOnClickListener { // TODO get next episode lifecycleScope.launch { - val media = AoDParser.getMediaById2(highlightMedia.id) + val media = AoDParser.getMediaById(highlightMedia.id) Log.d(javaClass.name, "Starting Player with mediaId: ${media.aodId}") (activity as MainActivity).startPlayer(media.aodId, media.playlist.first().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 2d8cef3..082a100 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 @@ -56,7 +56,7 @@ class MediaFragment(private val mediaId: Int) : Fragment() { binding.pagerEpisodesSimilar.offscreenPageLimit = 2 binding.pagerEpisodesSimilar.adapter = pagerAdapter TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> - tab.text = if (model.media2.type == MediaType.TVSHOW && position == 0) { + tab.text = if (model.media.type == MediaType.TVSHOW && position == 0) { getString(R.string.episodes) } else { getString(R.string.similar_titles) @@ -75,9 +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 - println(model.nextEpisodeId) - if (model.media2.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { - binding.textTitle.text = model.media2.getEpisodeById(model.nextEpisodeId).title + if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { + binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title } } @@ -87,9 +86,9 @@ class MediaFragment(private val mediaId: Int) : Fragment() { private fun updateGUI() = with(model) { // generic gui val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it } - ?: media2.posterURL + ?: media.posterURL val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it } - ?: media2.posterURL + ?: media.posterURL // load poster and backdrop Glide.with(requireContext()).load(posterUrl) @@ -99,13 +98,13 @@ class MediaFragment(private val mediaId: Int) : Fragment() { .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) .into(binding.imageBackdrop) - binding.textTitle.text = media2.title - binding.textYear.text = media2.year.toString() - binding.textAge.text = media2.age.toString() - binding.textOverview.text = media2.shortText + binding.textTitle.text = media.title + binding.textYear.text = media.year.toString() + binding.textAge.text = media.age.toString() + binding.textOverview.text = media.shortText // set "my list" indicator - if (StorageController.myList.contains(media2.aodId)) { + 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) @@ -116,25 +115,25 @@ class MediaFragment(private val mediaId: Int) : Fragment() { pagerAdapter.notifyDataSetChanged() // specific gui - if (media2.type == MediaType.TVSHOW) { + if (media.type == MediaType.TVSHOW) { // get next episode - nextEpisodeId = media2.playlist.firstOrNull{ !it.watched }?.mediaId - ?: media2.playlist.first().mediaId + nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId + ?: media.playlist.first().mediaId // title is the next episodes title - binding.textTitle.text = media2.getEpisodeById(nextEpisodeId).title + binding.textTitle.text = media.getEpisodeById(nextEpisodeId).title // episodes count binding.textEpisodesOrRuntime.text = resources.getQuantityString( R.plurals.text_episodes_count, - media2.playlist.size, - media2.playlist.size + media.playlist.size, + media.playlist.size ) // episodes fragments.add(MediaFragmentEpisodes()) pagerAdapter.notifyDataSetChanged() - } else if (media2.type == MediaType.MOVIE) { + } else if (media.type == MediaType.MOVIE) { val tmdbMovie = (tmdbResult as TMDBMovie?) if (tmdbMovie?.runtime != null) { @@ -149,7 +148,7 @@ class MediaFragment(private val mediaId: Int) : Fragment() { } // if has similar titles - if (media2.similar.isNotEmpty()) { + if (media.similar.isNotEmpty()) { fragments.add(MediaFragmentSimilar()) pagerAdapter.notifyDataSetChanged() } @@ -165,20 +164,20 @@ class MediaFragment(private val mediaId: Int) : Fragment() { private fun initActions() = with(model) { binding.buttonPlay.setOnClickListener { - when (media2.type) { - MediaType.MOVIE -> playEpisode(media2.playlist.first().mediaId) + when (media.type) { + MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) MediaType.TVSHOW -> playEpisode(nextEpisodeId) - else -> Log.e(javaClass.name, "Wrong Type: ${media2.type}") + else -> Log.e(javaClass.name, "Wrong Type: ${media.type}") } } // add or remove media from myList binding.linearMyListAction.setOnClickListener { - if (StorageController.myList.contains(media2.aodId)) { - StorageController.myList.remove(media2.aodId) + 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(media2.aodId) + StorageController.myList.add(media.aodId) Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) } StorageController.saveMyList(requireContext()) @@ -195,7 +194,7 @@ class MediaFragment(private val mediaId: Int) : Fragment() { * TODO this is also used in MediaFragmentEpisode, we should only have on implementation */ private fun playEpisode(episodeId: Int) { - (activity as MainActivity).startPlayer(model.media2.aodId, episodeId) + (activity as MainActivity).startPlayer(model.media.aodId, episodeId) Log.d(javaClass.name, "Started Player with episodeId: $episodeId") 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 7a0eff9..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 @@ -27,13 +27,13 @@ class MediaFragmentEpisodes : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapterRecEpisodes = EpisodeItemAdapter(model.media2.playlist, model.tmdbTVSeason?.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.media2.playlist[position].mediaId) + playEpisode(model.media.playlist[position].mediaId) } } } @@ -43,7 +43,7 @@ class MediaFragmentEpisodes : Fragment() { // if adapterRecEpisodes is initialized, update the watched state for the episodes if (this::adapterRecEpisodes.isInitialized) { - model.media2.playlist.forEachIndexed { index, episodeInfo -> + model.media.playlist.forEachIndexed { index, episodeInfo -> adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index) } adapterRecEpisodes.notifyDataSetChanged() @@ -51,7 +51,7 @@ class MediaFragmentEpisodes : Fragment() { } private fun playEpisode(episodeId: Int) { - (activity as MainActivity).startPlayer(model.media2.aodId, episodeId) + (activity as MainActivity).startPlayer(model.media.aodId, episodeId) Log.d(javaClass.name, "Started Player with episodeId: $episodeId") model.updateNextEpisode(episodeId) // set the correct next episode 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 dba70c3..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.media2.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/viewmodel/MediaFragmentViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt index fd1f4b6..95d9887 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 @@ -16,17 +16,11 @@ import org.mosad.teapod.util.tmdb.TMDBTVSeason */ class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { -// var media = Media(-1, "", MediaType.OTHER) -// internal set -// var nextEpisode = Episode() -// internal set - - var media2 = AoDMediaNone + var media = AoDMediaNone internal set var nextEpisodeId = -1 internal set - var tmdbResult: TMDBResult? = null // TODO rename internal set var tmdbTVSeason: TMDBTVSeason? =null @@ -38,18 +32,17 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic * set media, tmdb and nextEpisode * TODO run aod and tmdb load parallel */ - suspend fun load(mediaId: Int) { + suspend fun load(aodId: Int) { val tmdbApiController = TMDBApiController() - //media = AoDParser.getMediaById(mediaId) - media2 = AoDParser.getMediaById2(mediaId) + media = AoDParser.getMediaById(aodId) // check if metaDB knows the title - val tmdbId: Int = if (MetaDBController.mediaList.media.contains(media2.aodId)) { + val tmdbId: Int = if (MetaDBController.mediaList.media.contains(aodId)) { // load media info from metaDB val metaDB = MetaDBController() - mediaMeta = when (media2.type) { - MediaType.MOVIE -> metaDB.getMovieMetadata(media2.aodId) - MediaType.TVSHOW -> metaDB.getTVShowMetadata(media2.aodId) + mediaMeta = when (media.type) { + MediaType.MOVIE -> metaDB.getMovieMetadata(media.aodId) + MediaType.TVSHOW -> metaDB.getTVShowMetadata(media.aodId) else -> null } @@ -57,28 +50,27 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic } else { // use tmdb search to get media info mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media - tmdbApiController.search(stripTitleInfo(media2.title), media2.type) + tmdbApiController.search(stripTitleInfo(media.title), media.type) } - tmdbResult = when (media2.type) { + tmdbResult = when (media.type) { MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId) else -> null } - println(tmdbResult) // TODO // get season info, if metaDB knows the tv show - tmdbTVSeason = if (media2.type == MediaType.TVSHOW && mediaMeta != null) { + tmdbTVSeason = if (media.type == MediaType.TVSHOW && mediaMeta != null) { val tvShowMeta = mediaMeta as TVShowMeta tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) } else { null } - if (media2.type == MediaType.TVSHOW) { + if (media.type == MediaType.TVSHOW) { //nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first() - nextEpisodeId = media2.playlist.firstOrNull { !it.watched }?.mediaId - ?: media2.playlist.first().mediaId + nextEpisodeId = media.playlist.firstOrNull { !it.watched }?.mediaId + ?: media.playlist.first().mediaId } } @@ -87,13 +79,10 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic * if no matching is found, use first episode */ fun updateNextEpisode(episodeId: Int) { - if (media2.type == MediaType.MOVIE) return // return if movie + if (media.type == MediaType.MOVIE) return // return if movie -// nextEpisode = media.episodes.firstOrNull{ it.number > currentEp.number } -// ?: media.episodes.first() - - nextEpisodeId = media2.playlist.firstOrNull { it.number > media2.getEpisodeById(episodeId).number }?.mediaId - ?: media2.playlist.first().mediaId + nextEpisodeId = media.playlist.firstOrNull { it.number > media.getEpisodeById(episodeId).number }?.mediaId + ?: media.playlist.first().mediaId } // remove unneeded info from the media title before searching 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 24682f5..a1307d8 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 @@ -171,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() } 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 2894e21..9dffdd1 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 @@ -40,11 +40,11 @@ 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 currentEpisode = AoDEpisodeNone internal set - var nextEpisode: Episode? = null + var nextEpisode: AoDEpisode? = null internal set var mediaMeta: Meta? = null internal set @@ -80,12 +80,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun loadMedia(mediaId: Int, episodeId: Int) { runBlocking { media = AoDParser.getMediaById(mediaId) - mediaMeta = loadMediaMeta(media.id) // can be done blocking, since it should be cached + mediaMeta = loadMediaMeta(media.aodId) // can be done blocking, since it should be cached } currentEpisode = media.getEpisodeById(episodeId) nextEpisode = selectNextEpisode() - currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.id) + currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language } @@ -122,12 +122,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) * * updateWatchedState for the next (now current) episode */ - fun playEpisode(episode: Episode, replace: Boolean = false, seekPosition: Long = 0) { + fun playEpisode(episode: AoDEpisode, 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() - currentEpisodeMeta = getEpisodeMetaByAoDMediaId(episode.id) + currentEpisodeMeta = getEpisodeMetaByAoDMediaId(episode.mediaId) currentEpisodeChangedListener.forEach { it() } // update player gui (title) val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( @@ -138,7 +138,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) // if episodes has not been watched, mark as watched if (!episode.watched) { viewModelScope.launch { - AoDParser.markAsWatched(media.id, episode.id) + AoDParser.markAsWatched(media.aodId, episode.mediaId) } } } @@ -188,13 +188,8 @@ 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] - } else { - null - } + private fun selectNextEpisode(): AoDEpisode? { + return media.playlist.firstOrNull { it.number > media.getEpisodeById(currentEpisode.mediaId).number } } } \ 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..f7fe649 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,11 +28,11 @@ class EpisodesListPlayer @JvmOverloads constructor( } model?.let { - adapterRecEpisodes = PlayerEpisodeItemAdapter(model.media.episodes) + adapterRecEpisodes = PlayerEpisodeItemAdapter(model.media.playlist) adapterRecEpisodes.onImageClick = { _, position -> (this.parent as ViewGroup).removeView(this) - model.playEpisode(model.media.episodes[position], replace = true) + model.playEpisode(model.media.playlist[position], replace = true) } adapterRecEpisodes.currentSelected = model.currentEpisode.number - 1 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 909e766..dea2d20 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -82,6 +82,11 @@ data class AoDEpisode( ?: streams.first() } +data class Stream( + val url: String, + val language : Locale +) + // TODO will be watched info (state and callback) -> remove description and number data class AoDEpisodeInfo( val aodMediaId: Int, @@ -114,62 +119,6 @@ val AoDEpisodeNone = AoDEpisode( mutableListOf() ) -// LEGACY - -data class Media( - val id: Int, - val link: String, - val type: DataTypes.MediaType, - val info: Info = Info(), - val episodes: ArrayList = arrayListOf() -) { - fun hasEpisode(id: Int) = episodes.any { it.id == id } - fun getEpisodeById(id: Int) = episodes.first { it.id == id } -} - -/** - * 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() -) - -/** - * 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 } -} - -data class Stream( - val url: String, - val language : Locale -) - /** * this class is used to represent the aod json API? */ 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..e1d37d5 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,9 @@ 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 -class PlayerEpisodeItemAdapter(private val episodes: List) : RecyclerView.Adapter() { +class PlayerEpisodeItemAdapter(private val episodes: List) : RecyclerView.Adapter() { var onImageClick: ((String, Int) -> Unit)? = null var currentSelected: Int = -1 // -1, since position should never be < 0 @@ -33,8 +33,8 @@ class PlayerEpisodeItemAdapter(private val episodes: List) : RecyclerVi holder.binding.textEpisodeTitle2.text = titleText holder.binding.textEpisodeDesc2.text = ep.shortDesc - 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) } From 062013489d0c8781a17ca51d26940207eef1ab21 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 5 Sep 2021 00:04:59 +0200 Subject: [PATCH 014/133] use notifyItem...() instead of notifyDataSetChanged() in MediaFragment --- .../ui/activity/main/fragments/MediaFragment.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 082a100..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 @@ -111,8 +111,9 @@ class MediaFragment(private val mediaId: Int) : Fragment() { } // 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) { @@ -131,8 +132,10 @@ class MediaFragment(private val mediaId: Int) : Fragment() { ) // 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?) @@ -149,8 +152,10 @@ class MediaFragment(private val mediaId: Int) : Fragment() { // if has similar titles if (media.similar.isNotEmpty()) { - fragments.add(MediaFragmentSimilar()) - pagerAdapter.notifyDataSetChanged() + MediaFragmentSimilar().also { + fragments.add(it) + pagerAdapter.notifyItemInserted(fragments.indexOf(it)) + } } // disable scrolling on appbar, if no tabs where added From 5ea94b7ded9e77a10207326aefe0a704418799af Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 5 Sep 2021 00:08:03 +0200 Subject: [PATCH 015/133] add numberStr to AoDEpisode type & show tmdb episode info in player * use numberStr instead of index to display the correct episode number, allowing for number such as "12.5" * show tmdb episode description in player if found and aod description is missing --- .../java/org/mosad/teapod/parser/AoDParser.kt | 3 ++- .../main/viewmodel/MediaFragmentViewModel.kt | 2 +- .../ui/activity/player/PlayerViewModel.kt | 17 +++++++++++++++-- .../teapod/ui/components/EpisodesListPlayer.kt | 7 +++---- .../java/org/mosad/teapod/util/DataTypes.kt | 11 ++++------- .../teapod/util/adapter/EpisodeItemAdapter.kt | 4 ++-- .../util/adapter/PlayerEpisodeItemAdapter.kt | 15 +++++++++++---- app/src/main/res/layout/item_episode_player.xml | 3 ++- app/src/main/res/values-de-rDE/strings.xml | 4 ++-- app/src/main/res/values/strings.xml | 4 ++-- 10 files changed, 44 insertions(+), 26 deletions(-) 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 11627ba..03fa36a 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -393,7 +393,8 @@ object AoDParser { description = episode.description, shortDesc = episodesInfo[episode.mediaid]?.shortDesc ?: "", imageURL = episode.image, - number = index, + 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)) 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 95d9887..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 @@ -81,7 +81,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic fun updateNextEpisode(episodeId: Int) { if (media.type == MediaType.MOVIE) return // return if movie - nextEpisodeId = media.playlist.firstOrNull { it.number > media.getEpisodeById(episodeId).number }?.mediaId + nextEpisodeId = media.playlist.firstOrNull { it.index > media.getEpisodeById(episodeId).index }?.mediaId ?: media.playlist.first().mediaId } 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 9dffdd1..e63225a 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 @@ -20,6 +20,8 @@ import org.mosad.teapod.R import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.preferences.Preferences 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 @@ -46,6 +48,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) internal set var nextEpisode: AoDEpisode? = null internal set + var tmdbTVSeason: TMDBTVSeason? =null + internal set var mediaMeta: Meta? = null internal set var currentEpisodeMeta: EpisodeMeta? = null @@ -83,6 +87,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) 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() currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) @@ -159,7 +172,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 { @@ -189,7 +202,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) * episode, return null */ private fun selectNextEpisode(): AoDEpisode? { - return media.playlist.firstOrNull { it.number > media.getEpisodeById(currentEpisode.mediaId).number } + return media.playlist.firstOrNull { it.index > media.getEpisodeById(currentEpisode.mediaId).index } } } \ 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 f7fe649..9a7874d 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.playlist) - + adapterRecEpisodes = PlayerEpisodeItemAdapter(model.media.playlist, model.tmdbTVSeason?.episodes) adapterRecEpisodes.onImageClick = { _, position -> (this.parent as ViewGroup).removeView(this) model.playEpisode(model.media.playlist[position], 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 dea2d20..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 { @@ -41,9 +40,6 @@ data class ItemMedia( val posterUrl: String ) -/** - * TODO the episodes workflow could use a clean up/rework - */ // TODO replace playlist: List with a map? data class AoDMedia( val aodId: Int, @@ -56,7 +52,6 @@ data class AoDMedia( val similar: List, val playlist: List, ) { - fun hasEpisode(mediaId: Int) = playlist.any { it.mediaId == mediaId } fun getEpisodeById(mediaId: Int) = playlist.firstOrNull { it.mediaId == mediaId } ?: AoDEpisodeNone } @@ -67,7 +62,8 @@ data class AoDEpisode( val description: String, val shortDesc: String, val imageURL: String, - val number: Int, + val numberStr: String, + val index: Int, var watched: Boolean, val watchedCallback: String, val streams: MutableList, @@ -113,6 +109,7 @@ val AoDEpisodeNone = AoDEpisode( "", "", "", + "", -1, false, "", 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 62416d9..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 @@ -27,9 +27,9 @@ class EpisodeItemAdapter(private val episodes: List, private val tmd 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 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 e1d37d5..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 @@ -10,8 +10,9 @@ import jp.wasabeef.glide.transformations.RoundedCornersTransformation import org.mosad.teapod.R import org.mosad.teapod.databinding.ItemEpisodePlayerBinding 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,13 +26,19 @@ class PlayerEpisodeItemAdapter(private val episodes: List) : Recycle 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 (ep.imageURL.isNotEmpty()) { Glide.with(context).load(ep.imageURL) 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 a957051..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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a58f3a2..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 From 8753d4f36f69edbf4d8e8b219c252881a231e903 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 5 Sep 2021 00:08:53 +0200 Subject: [PATCH 016/133] fix tmdb episode description in player --- .../java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e63225a..194110d 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 @@ -92,7 +92,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) // 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) + tmdbTVSeason = TMDBApiController().getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) } } From eeb1c33e432868e8ebcae47475b21649908f86fa Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 5 Sep 2021 11:54:55 +0200 Subject: [PATCH 017/133] use the epsidoeId for the next epsiode in PlayerViewModel --- .../ui/activity/player/PlayerActivity.kt | 12 ++-- .../ui/activity/player/PlayerViewModel.kt | 55 ++++++++++--------- .../ui/components/EpisodesListPlayer.kt | 2 +- 3 files changed, 36 insertions(+), 33 deletions(-) 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 a1307d8..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 @@ -118,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) } } @@ -206,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") @@ -277,7 +277,7 @@ class PlayerActivity : AppCompatActivity() { // 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 (!btnNextEpIsVisible && model.nextEpisode != null && Preferences.autoplay && !isInPiPMode()) { + if (!btnNextEpIsVisible && model.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) { showButtonNextEp() } } else if (btnNextEpIsVisible) { @@ -335,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 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 194110d..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 @@ -29,9 +29,6 @@ import kotlin.collections.ArrayList * PlayerViewModel handles all stuff related to media/episodes. * When currentEpisode is changed the player will start playing it (not initial media), * the next episode will be update and the callback is handled. - * - * TODO rework don't use episodes for everything, use media instead - * this is a major rework of the AoDParser/Player/Media architecture */ class PlayerViewModel(application: Application) : AndroidViewModel(application) { @@ -44,16 +41,16 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) var media: AoDMedia = AoDMediaNone internal set - var currentEpisode = AoDEpisodeNone - internal set - var nextEpisode: AoDEpisode? = null + var mediaMeta: Meta? = null internal set var tmdbTVSeason: TMDBTVSeason? =null internal set - var mediaMeta: Meta? = null + var currentEpisode = AoDEpisodeNone internal set var currentEpisodeMeta: EpisodeMeta? = null internal set + var nextEpisodeId: Int? = null + internal set var currentLanguage: Locale = Locale.ROOT internal set @@ -97,7 +94,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } currentEpisode = media.getEpisodeById(episodeId) - nextEpisode = selectNextEpisode() + nextEpisodeId = selectNextEpisode() currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language } @@ -125,33 +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: AoDEpisode, 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() - currentEpisodeMeta = getEpisodeMetaByAoDMediaId(episode.mediaId) - 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.aodId, episode.mediaId) + AoDParser.markAsWatched(media.aodId, currentEpisode.mediaId) } } } @@ -198,11 +199,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } /** - * Based on the current episodeId, get the next episode. If there is no next - * episode, return null + * Based on the current episodes index, get the next episode. + * @return The next episode or null if there is none. */ - private fun selectNextEpisode(): AoDEpisode? { - return media.playlist.firstOrNull { it.index > media.getEpisodeById(currentEpisode.mediaId).index } + 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 9a7874d..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 @@ -31,7 +31,7 @@ class EpisodesListPlayer @JvmOverloads constructor( adapterRecEpisodes = PlayerEpisodeItemAdapter(model.media.playlist, model.tmdbTVSeason?.episodes) adapterRecEpisodes.onImageClick = { _, position -> (this.parent as ViewGroup).removeView(this) - model.playEpisode(model.media.playlist[position], replace = true) + model.playEpisode(model.media.playlist[position].mediaId, replace = true) } adapterRecEpisodes.currentSelected = model.currentEpisode.index From 3935f37267ca2c9b218f918e01773fae00643896 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 5 Sep 2021 13:43:27 +0200 Subject: [PATCH 018/133] update libraries * kotlinx-coroutines-android 1.5.1 -> 1.5.2 * exoplayer 2.14.2 -> 2.15.0 * jsoup 1.13.1 -> 1.14.2 * gradle agp 7.0.1 -> 7.0.2 --- app/build.gradle | 20 +++---- .../java/org/mosad/teapod/parser/AoDParser.kt | 55 +++++++++---------- app/src/main/res/layout/activity_player.xml | 4 +- build.gradle | 2 +- 4 files changed, 39 insertions(+), 42 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7e5ec68..06a5261 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.5.0-alpha1" + versionName "0.5.0-alpha2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resValue "string", "build_time", buildTime() @@ -41,8 +41,8 @@ android { dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1' + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.3.1' @@ -55,14 +55,14 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' implementation 'com.google.android.material:material:1.4.0' - implementation 'com.google.code.gson:gson:2.8.7' - implementation 'com.google.android.exoplayer:exoplayer-core:2.14.2' - implementation 'com.google.android.exoplayer:exoplayer-hls:2.14.2' - implementation 'com.google.android.exoplayer:exoplayer-dash:2.14.2' - implementation 'com.google.android.exoplayer:exoplayer-ui:2.14.2' - implementation 'com.google.android.exoplayer:extension-mediasession:2.14.2' + implementation 'com.google.code.gson:gson:2.8.8' + implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0' + implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0' + implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0' + implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0' + implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0' - implementation 'org.jsoup:jsoup:1.13.1' + implementation 'org.jsoup:jsoup:1.14.2' implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'com.afollestad.material-dialogs:core:3.3.0' 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 03fa36a..e352731 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -31,8 +31,10 @@ import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.util.* import org.mosad.teapod.util.DataTypes.MediaType import java.io.IOException +import java.net.CookieStore import java.util.* import kotlin.random.Random +import kotlin.reflect.jvm.jvmName object AoDParser { @@ -43,7 +45,7 @@ object AoDParser { private const val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0" - private var sessionCookies = mutableMapOf() + private lateinit var cookieStore: CookieStore private var csrfToken: String = "" private var loginSuccess = false @@ -60,23 +62,22 @@ object AoDParser { fun login(): Boolean = runBlocking { withContext(Dispatchers.IO) { - // get the authenticity token - val resAuth = Jsoup.connect(baseUrl + loginPath) + // get the authenticity token and cookies + val conAuth = Jsoup.connect(baseUrl + loginPath) .header("User-Agent", userAgent) - .execute() - val authenticityToken = resAuth.parse().select("meta[name=csrf-token]").attr("content") - val authCookies = resAuth.cookies() + cookieStore = conAuth.cookieStore() + csrfToken = conAuth.execute().parse().select("meta[name=csrf-token]").attr("content") - //Log.d(javaClass.name, "Received authenticity token: $authenticityToken") - //Log.d(javaClass.name, "Received authenticity cookies: $authCookies") + Log.d(AoDParser::class.jvmName, "Received authenticity token: $csrfToken") + Log.d(AoDParser::class.jvmName, "Received authenticity cookies: $cookieStore") val data = mapOf( Pair("user[login]", EncryptedPreferences.login), Pair("user[password]", EncryptedPreferences.password), Pair("user[remember_me]", "1"), Pair("commit", "Einloggen"), - Pair("authenticity_token", authenticityToken) + Pair("authenticity_token", csrfToken) ) val resLogin = Jsoup.connect(baseUrl + loginPath) @@ -84,14 +85,12 @@ object AoDParser { .timeout(60000) // login can take some time default is 60000 (60 sec) .data(data) .postDataCharset("UTF-8") - .cookies(authCookies) + .cookieStore(cookieStore) .execute() - //println(resLogin.body()) - sessionCookies = resLogin.cookies() loginSuccess = resLogin.body().contains("Hallo, du bist jetzt angemeldet.") - Log.i(javaClass.name, "Status: ${resLogin.statusCode()} (${resLogin.statusMessage()}), login successful: $loginSuccess") + Log.i(AoDParser::class.jvmName, "Status: ${resLogin.statusCode()} (${resLogin.statusMessage()}), login successful: $loginSuccess") loginSuccess } @@ -119,7 +118,7 @@ object AoDParser { aodMediaList.add(this) } } catch (exn:NullPointerException) { - Log.e(javaClass.name, "Error while loading media $aodId", exn) + Log.e(AoDParser::class.jvmName, "Error while loading media $aodId", exn) AoDMediaNone } } @@ -131,7 +130,7 @@ object AoDParser { return coroutineScope { async(Dispatchers.IO) { val res = Jsoup.connect(baseUrl + subscriptionPath) - .cookies(sessionCookies) + .cookieStore(cookieStore) .get() return@async res.select("a:contains(Anime-Abo)").text() @@ -149,7 +148,7 @@ object AoDParser { episode.watched = true sendCallback(episode.watchedCallback) - Log.d(javaClass.name, "Marked episode ${episode.mediaId} as watched") + Log.d(AoDParser::class.jvmName, "Marked episode ${episode.mediaId} as watched") } // TODO don't use jsoup here @@ -166,11 +165,11 @@ object AoDParser { try { Jsoup.connect(baseUrl + callbackPath) .ignoreContentType(true) - .cookies(sessionCookies) + .cookieStore(cookieStore) .headers(headers) .execute() } catch (ex: IOException) { - Log.e(javaClass.name, "Callback for $callbackPath failed.", ex) + Log.e(AoDParser::class.jvmName, "Callback for $callbackPath failed.", ex) } } } @@ -199,7 +198,7 @@ object AoDParser { } ) - Log.i(javaClass.name, "Total library size is: ${guiMediaList.size}") + Log.i(AoDParser::class.jvmName, "Total library size is: ${guiMediaList.size}") } } @@ -284,7 +283,7 @@ object AoDParser { } } - Log.i(javaClass.name, "loaded home") + Log.i(AoDParser::class.jvmName, "loaded home") } } @@ -295,27 +294,27 @@ object AoDParser { */ private suspend fun loadMediaAsync(aodId: Int): Deferred = coroutineScope { return@coroutineScope async (Dispatchers.IO) { - if (sessionCookies.isEmpty()) login() // TODO is this needed? + if (cookieStore.cookies.isEmpty()) login() // TODO is this needed? // return none object, if login wasn't successful if (!loginSuccess) { - Log.w(javaClass.name, "Login was not successful") + Log.w(AoDParser::class.jvmName, "Login was not successful") return@async AoDMediaNone } // get the media page val res = Jsoup.connect("$baseUrl/anime/$aodId") - .cookies(sessionCookies) + .cookieStore(cookieStore) .get() // println(res) if (csrfToken.isEmpty()) { csrfToken = res.select("meta[name=csrf-token]").attr("content") - Log.d(javaClass.name, "New csrf token is $csrfToken") + Log.d(AoDParser::class.jvmName, "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 besides = res.select("div.besides").first()!! val aodPlaylists = besides.select("input.streamstarter_html5").map { streamstarter -> parsePlaylistAsync( streamstarter.attr("data-playlist"), @@ -354,7 +353,7 @@ object AoDParser { if (mediaId != null) { ItemMedia(mediaId, mediaTitle, mediaImage) } else { - Log.i(javaClass.name, "MediaId for similar to $aodId was null") + Log.i(AoDParser::class.jvmName, "MediaId for similar to $aodId was null") null } } @@ -376,7 +375,7 @@ object AoDParser { AoDEpisodeInfo(mediaId, episodeShortDesc, episodeWatched, episodeWatchedCallback) } else { - Log.i(javaClass.name, "Episode info for $aodId has empty streamstarter_html5 ") + Log.i(AoDParser::class.jvmName, "Episode info for $aodId has empty streamstarter_html5 ") null } }.associateBy { it.aodMediaId } @@ -441,7 +440,7 @@ object AoDParser { val res = Jsoup.connect(baseUrl + playlistPath) .ignoreContentType(true) - .cookies(sessionCookies) + .cookieStore(cookieStore) .headers(headers) .timeout(120000) // loading the playlist can take some time .execute() diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 566f92f..92b9a22 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -16,9 +16,7 @@ android:layout_gravity="center" android:animateLayoutChanges="true" android:foreground="@drawable/ripple_background" - app:controller_layout_id="@layout/player_controls" - app:fastforward_increment="10000" - app:rewind_increment="10000" /> + app:controller_layout_id="@layout/player_controls" /> Date: Wed, 1 Dec 2021 20:46:19 +0100 Subject: [PATCH 019/133] update kotlin and agp --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index e2aa019..cfe8c91 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.5.30" + ext.kotlin_version = "1.6.0" repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.2' + classpath 'com.android.tools.build:gradle:7.0.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong From 487c0c3c39be0479c2a047924ecf327317506e2c Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 1 Feb 2022 17:20:58 +0100 Subject: [PATCH 020/133] update gradle wrapper, kotlin and agp * gradle wrapper 7.2 ->7.3.3 * kotlin 1.6.0 -> 1.6.10 * agp 7.0.3 -> 7.1.0 --- build.gradle | 4 +- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 59536 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 269 ++++++++++++++--------- 4 files changed, 162 insertions(+), 113 deletions(-) diff --git a/build.gradle b/build.gradle index cfe8c91..f1d3eb9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.6.0" + ext.kotlin_version = "1.6.10" repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.3' + classpath 'com.android.tools.build:gradle:7.1.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..7454180f2ae8848c63b8b4dea2cb829da983f2fa 100644 GIT binary patch delta 18435 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 B8dv}T delta 17845 zcmV)CK*GO}(F4QI1F(Jx4W$DjNjn4p0N4ir06~)x5+0MO2`GQvQyWzj|J`gh3(E#l zNGO!HfVMRRN~%`0q^)g%XlN*vP!O#;m*h5VyX@j-1N|HN;8S1vqEAj=eCdn`)tUB9 zXZjcT^`bL6qvL}gvXj%9vrOD+x!Gc_0{$Zg+6lTXG$bmoEBV z*%y^c-mV0~Rjzv%e6eVI)yl>h;TMG)Ft8lqpR`>&IL&`>KDi5l$AavcVh9g;CF0tY zw_S0eIzKD?Nj~e4raA8wxiiImTRzv6;b6|LFmw)!E4=CiJ4I%&axSey4zE-MIh@*! z*P;K2Mx{xVYPLeagKA}Hj=N=1VrWU`ukuBnc14iBG?B}Uj>?=2UMk4|42=()8KOnc zrJzAxxaEIfjw(CKV6F$35u=1qyf(%cY8fXaS9iS?yetY{mQ#Xyat*7sSoM9fJlZqq zyasQ3>D>6p^`ck^Y|kYYZB*G})uAbQ#7)Jeb~glGz@2rPu}zBWDzo5K$tP<|meKV% z{Swf^eq6NBioF)v&~9NLIxHMTKe6gJ@QQ^A6fA!n#u1C&n`aG7TDXKM1Jly-DwTB` z+6?=Y)}hj;C#r5>&x;MCM4U13nuXVK*}@yRY~W3X%>U>*CB2C^K6_OZsXD!nG2RSX zQg*0)$G3%Es$otA@p_1N!hIPT(iSE=8OPZG+t)oFyD~{nevj0gZen$p>U<7}uRE`t5Mk1f4M0K*5 zbn@3IG5I2mk;8K>*RZ zPV6iL006)S001s%0eYj)9hu1 z9o)iQT9(v*sAuZ|ot){RrZ0Qw4{E0A+!Yx_M~#Pj&OPUM&i$RU=Uxu}e*6Sr2ror= z&?lmvFCO$)BY+^+21E>ENWe`I0{02H<-lz&?})gIVFyMWxX0B|0b?S6?qghp3lDgz z2?0|ALJU=7s-~Lb3>9AA5`#UYCl!Xeh^i@bxs5f&SdiD!WN}CIgq&WI4VCW;M!UJL zX2};d^sVj5oVl)OrkapV-C&SrG)*x=X*ru!2s04TjZ`pY$jP)4+%)7&MlpiZ`lgoF zo_p>^4qGz^(Y*uB10dY2kcIbt=$FIdYNqk;~47wf@)6|nJp z1cocL3zDR9N2Pxkw)dpi&_rvMW&Dh0@T*_}(1JFSc0S~Ph2Sr=vy)u*=TY$i_IHSo zR+&dtWFNxHE*!miRJ%o5@~GK^G~4$LzEYR-(B-b(L*3jyTq}M3d0g6sdx!X3-m&O% zK5g`P179KHJKXpIAAX`A2MFUA;`nXx^b?mboVbQgigIHTU8FI>`q53AjWaD&aowtj z{XyIX>c)*nLO~-WZG~>I)4S1d2q@&?nwL)CVSWqWi&m1&#K1!gt`g%O4s$u^->Dwq ziKc&0O9KQ7000OG0000%03-m(e&Y`S09YWC4iYDSty&3q8^?8ij|8zxaCt!zCFq1@ z9TX4Hl68`nY>}cQNW4Ullqp$~SHO~l1!CdFLKK}ij_t^a?I?C^CvlvnZkwiVn>dl2 z2$V(JN{`5`-8ShF_ek6HNRPBlPuIPYu>TAeAV5O2)35r3*_k(Q-h1+h5pb(Zu%oJ__pBsW0n5ILw`!&QR&YV`g0Fe z(qDM!FX_7;`U3rxX#QHT{f%h;)Eursw=*#qvV)~y%^Uo^% zi-%sMe^uz;#Pe;@{JUu05zT*i=u7mU9{MkT`ft(vPdQZoK&2mg=tnf8FsaNQ+QcPg zB>vP8Rd6Z0JoH5_Q`zldg;hx4azQCq*rRZThqlqTRMzn1O3_rQTrHk8LQ<{5UYN~` zM6*~lOGHyAnx&#yCK{i@%N1Us@=6cw=UQxpSE;<(LnnES%6^q^QhBYQ-VCSmIu8wh z@_LmwcFDfAhIn>`%h7L{)iGBzu`Md4dj-m3C8mA9+BL*<>q z#$7^ttIBOE-=^|zmG`K8yUKT{yjLu2SGYsreN0*~9yhFxn4U};Nv1XXj1fH*v-g=3 z@tCPc`YdzQGLp%zXwo*o$m9j-+~nSWls#s|?PyrHO%SUGdk**X9_=|b)Y%^j_V$3S z>mL2A-V)Q}qb(uZipEFVm?}HWc+%G6_K+S+87g-&RkRQ8-{0APDil115eG|&>WQhU zufO*|e`hFks^cJJmx_qNx{ltSp3aT|XgD5-VxGGXb7gkiOG$w^qMVBDjR8%!Sbh72niHRDV* ziFy8LE+*$j?t^6aZP9qt-ow;hzkmhvy*Hn-X^6?yVMbtNbyqZQ^rXg58`gk+I%Wv} zn_)dRq+3xjc8D%}EQ%nnTF7L7m}o9&*^jf`_qvUhVKY7w9Zgxr-0YHWFRd3$l_6UX zpXt^U&TiC*qZWx#pOG6k?3Tg)pra*fw(O6_45>lUBN1U5Qmc>^DHt)5b~Ntjsw!NI z1n4{$HWFeIi)*qvgK^ui;(81VQc1(wJ8C#tjR>Dkjf{xYC^_B^#qrdCc)uZxtgua6 zk98UGQF|;;k`c+0_z)tQ&9DwLB~&12@D1!*mTz_!3Mp=cg;B7Oq4cKN>5v&dW7q@H zal=g6Ipe`siZN4NZiBrkJCU*x216gmbV(FymgHuG@%%|8sgD?gR&0*{y4n=pukZnd z4=Nl~_>jVfbIehu)pG)WvuUpLR}~OKlW|)=S738Wh^a&L+Vx~KJU25o6%G7+Cy5mB zgmYsgkBC|@K4Jm_PwPoz`_|5QSk}^p`XV`649#jr4Lh^Q>Ne~#6Cqxn$7dNMF=%Va z%z9Ef6QmfoXAlQ3)PF8#3Y% zadcE<1`fd1&Q9fMZZnyI;&L;YPuy#TQ8b>AnXr*SGY&xUb>2678A+Y z8K%HOdgq_4LRFu_M>Ou|kj4W%sPPaV)#zDzN~25klE!!PFz_>5wCxglj7WZI13U5| zEq_YLKPH;v8sEhyG`dV_jozR);a6dBvkauhC;1dk%mr+J*Z6MMH9jqxFk@)&h{mHl zrf^i_d-#mTF=6-T8Rk?(1+rPGgl$9=j%#dkf@x6>czSc`jk7$f!9SrV{do%m!t8{? z_iAi$Qe&GDR#Nz^#uJ>-_?(E$ns)(3)X3cYY)?gFvU+N>nnCoBSmwB2<4L|xH19+4 z`$u#*Gt%mRw=*&|em}h_Y`Pzno?k^8e*hEwfM`A_yz-#vJtUfkGb=s>-!6cHfR$Mz z`*A8jVcz7T{n8M>ZTb_sl{EZ9Ctau4naX7TX?&g^VLE?wZ+}m)=YW4ODRy*lV4%-0 zG1XrPs($mVVfpnqoSihnIFkLdxG9um&n-U|`47l{bnr(|8dmglO7H~yeK7-wDwZXq zaHT($Qy2=MMuj@lir(iyxI1HnMlaJwpX86je}e=2n|Esb6hB?SmtDH3 z2qH6o`33b{;M{mDa5@@~1or8+Zcio*97pi1Jkx6v5MXCaYsb~Ynq)eWpKnF{n)FXZ z?Xd;o7ESu&rtMFr5(yJ(B7V>&0gnDdL*4MZH&eO+r*t!TR98ssbMRaw`7;`SLI8mT z=)hSAt~F=mz;JbDI6g~J%w!;QI(X14AnOu;uve^4wyaP3>(?jSLp+LQ7uU(iib%IyB(d&g@+hg;78M>h7yAeq$ALRoHGkKXA+E z$Sk-hd$Fs2nL4w9p@O*Y$c;U)W#d~)&8Js;i^Dp^* z0*7*zEGj~VehF4sRqSGny*K_CxeF=T^8;^lb}HF125G{kMRV?+hYktZWfNA^Mp7y8 zK~Q?ycf%rr+wgLaHQ|_<6z^eTG7izr@99SG9Q{$PCjJabSz`6L_QJJe7{LzTc$P&pwTy<&3RRUlSHmK;?}=QAhQaDW3#VWcNAH3 zeBPRTDf3?3mfdI$&WOg(nr9Gyzg`&u^o!f2rKJ57D_>p z6|?Vg?h(@(*X=o071{g^le>*>qSbVam`o}sAK8>b|11%e&;%`~b2OP7--q%0^2YDS z`2M`{2QYr1VC)sIW9WOu8<~7Q>^$*Og{KF+kI;wFegvaIDkB%3*%PWtWKSq7l`1YcDxQQ2@nv{J!xWV?G+w6C zhUUxUYVf%(Q(40_xrZB@rbxL=Dj3RV^{*yHd>4n-TOoHVRnazDOxxkS9kiZyN}IN3 zB^5N=* zRSTO+rA<{*P8-$GZdyUNOB=MzddG$*@q>mM;pUIiQ_z)hbE#Ze-IS)9G}Rt$5PSB{ zZZ;#h9nS7Rf1ecW&n(Gpu9}{vXQZ-f`UHIvD?cTbF`YvH*{rgE(zE22pLAQfhg-`U zuh612EpByB(~{w7svCylrBk%5$LCIyuhrGi=yOfca`=8ltKxHcSNfDRt@62QH^R_0 z&eQL6rRk>Dvf6rjMQv5ZXzg}S`HqV69hJT^pPHtdhqsrPJWs|IT9>BvpQa@*(FX6v zG}TYjreQCnH(slMt5{NgUf)qsS1F&Bb(M>$X}tWI&yt2I&-rJbqveuj?5J$`Dyfa2 z)m6Mq0XH@K)Y2v8X=-_4=4niodT&Y7W?$KLQhjA<+R}WTdYjX9>kD+SRS^oOY1{A= zZTId-(@wF^UEWso($wZtrs%e7t<}YaC_;#@`r0LUzKY&|qPJz*y~RHG`E6bypP5AX zN!p0^AUu8uDR>xM-ALFzBxXM~Q3z=}fHWCIG>0&I6x2Iu7&U)49j7qeMI&?qb$=4I zdMmhAJrO%@0f%YW! z^gLByEGSk+R0v4*d4w*N$Ju6z#j%HBI}6y$2en=-@S3=6+yZX94m&1j@s- z7T6|#0$c~dYq9IkA!P)AGkp~S$zYJ1SXZ#RM0|E~Q0PSm?DsT4N3f^)b#h(u9%_V5 zX*&EIX|gD~P!vtx?ra71pl%v)F!W~X2hcE!h8cu@6uKURdmo1-7icN4)ej4H1N~-C zjXgOK+mi#aJv4;`DZ%QUbVVZclkx;9`2kgbAhL^d{@etnm+5N8pB#fyH)bxtZGCAv z(%t0kPgBS{Q2HtjrfI0B$$M0c?{r~2T=zeXo7V&&aprCzww=i*}Atu7g^(*ivauMz~kkB%Vt{Wydlz%%2c26%>0PAbZO zVHx%tK(uzDl#ZZK`cW8TD2)eD77wB@gum{B2bO_jnqGl~01EF_^jx4Uqu1yfA~*&g zXJ`-N?D-n~5_QNF_5+Un-4&l$1b zVlHFqtluoN85b^C{A==lp#hS9J(npJ#6P4aY41r) zzCmv~c77X5L}H%sj>5t&@0heUDy;S1gSOS>JtH1v-k5l}z2h~i3^4NF6&iMb;ZYVE zMw*0%-9GdbpF1?HHim|4+)Zed=Fk<2Uz~GKc^P(Ig@x0&XuX0<-K(gA*KkN&lY2Xu zG054Q8wbK~$jE32#Ba*Id2vkqmfV{U$Nx9vJ;jeI`X+j1kh7hB8$CBTe@ANmT^tI8 z%U>zrTKuECin-M|B*gy(SPd`(_xvxjUL?s137KOyH>U{z01cBcFFt=Fp%d+BK4U;9 zQG_W5i)JASNpK)Q0wQpL<+Ml#cei41kCHe&P9?>p+KJN>I~`I^vK1h`IKB7k^xi`f z$H_mtr_+@M>C5+_xt%v}{#WO{86J83;VS@Ei3JLtp<*+hsY1oGzo z0?$?OJO$79;{|@aP!fO6t9TJ!?8i&|c&UPWRMbkwT3nEeFH`Yyyh6b%Rm^nBuTt@9 z+$&-4lf!G|@LCo3<8=yN@5dYbc%uq|Hz|0tiiLQKiUoM9g14zyECKGv0}3AWv2WJ zUAXGUhvkNk`0-H%ACsRSmy4fJ@kxBD3ZKSj6g(n1KPw?g{v19phcBr3BEF>J%lL|d zud3LNuL;cR*xS+;X+N^Br+x2{&hDMhb-$6_fKU(Pt0FQUXgNrZvzsVCnsFqv?#L z4-FYsQ-?D>;LdjHu_TT1CHN~aGkmDjWJkJg4G^!+V_APd%_48tErDv6BW5;ji^UDD zRu5Sw7wwplk`w{OGEKWJM&61c-AWn!SeUP8G#+beH4_Ov*)NUV?eGw&GHNDI6G(1Y zTfCv?T*@{QyK|!Q09wbk5koPD>=@(cA<~i4pSO?f(^5sSbdhUc+K$DW#_7^d7i%At z?KBg#vm$?P4h%?T=XymU;w*AsO_tJr)`+HUll+Uk_zx6vNw>G3jT){w3ck+Z=>7f0 zZVkM*!k^Z_E@_pZK6uH#|vzoL{-j1VFlUHP&5~q?j=UvJJNQG ztQdiCF$8_EaN_Pu8+afN6n8?m5UeR_p_6Log$5V(n9^W)-_vS~Ws`RJhQNPb1$C?| zd9D_ePe*`aI9AZ~Ltbg)DZ;JUo@-tu*O7CJ=T)ZI1&tn%#cisS85EaSvpS~c#CN9B z#Bx$vw|E@gm{;cJOuDi3F1#fxWZ9+5JCqVRCz5o`EDW890NUfNCuBn)3!&vFQE{E$L`Cf7FMSSX%ppLH+Z}#=p zSow$)$z3IL7frW#M>Z4|^9T!=Z8}B0h*MrWXXiVschEA=$a|yX9T~o!=%C?T+l^Cc zJx&MB$me(a*@lLLWZ=>PhKs!}#!ICa0! zq%jNgnF$>zrBZ3z%)Y*yOqHbKzEe_P=@<5$u^!~9G2OAzi#}oP&UL9JljG!zf{JIK z++G*8j)K=$#57N)hj_gSA8golO7xZP|KM?elUq)qLS)i(?&lk{oGMJh{^*FgklBY@Xfl<_Q zXP~(}ST6V01$~VfOmD6j!Hi}lsE}GQikW1YmBH)`f_+)KI!t#~B7=V;{F*`umxy#2Wt8(EbQ~ks9wZS(KV5#5Tn3Ia90r{}fI%pfbqBAG zhZ)E7)ZzqA672%@izC5sBpo>dCcpXi$VNFztSQnmI&u`@zQ#bqFd9d&ls?RomgbSh z9a2rjfNiKl2bR!$Y1B*?3Ko@s^L5lQN|i6ZtiZL|w5oq%{Fb@@E*2%%j=bcma{K~9 z*g1%nEZ;0g;S84ZZ$+Rfurh;Nhq0;{t~(EIRt}D@(Jb7fbe+_@H=t&)I)gPCtj*xI z9S>k?WEAWBmJZ|gs}#{3*pR`-`!HJ)1Dkx8vAM6Tv1bHZhH=MLI;iC#Y!$c|$*R>h zjP{ETat(izXB{@tTOAC4nWNhh1_%7AVaf!kVI5D=Jf5I1!?}stbx_Yv23hLf$iUTb z-)WrTtd2X+;vBW_q*Z6}B!10fs=2FA=3gy*dljsE43!G*3Uw(Is>(-a*5E!T4}b-Y zfvOC)-HYjNfcpi`=kG%(X3XcP?;p&=pz+F^6LKqRom~pA}O* zitR+Np{QZ(D2~p_Jh-k|dL!LPmexLM?tEqI^qRDq9Mg z5XBftj3z}dFir4oScbB&{m5>s{v&U=&_trq#7i&yQN}Z~OIu0}G)>RU*`4<}@7bB% zKYxGx0#L#u199YKSWZwV$nZd>D>{mDTs4qDNyi$4QT6z~D_%Bgf?>3L#NTtvX;?2D zS3IT*2i$Snp4fjDzR#<)A``4|dA(}wv^=L?rB!;kiotwU_gma`w+@AUtkSyhwp{M} z!e`jbUR3AG4XvnBVcyIZht6Vi~?pCC!$XF2 z*V~)DBVm8H7$*OZQJYl3482hadhsI2NCz~_NINtpC?|KI6H3`SG@1d%PsDdw{u}hq zN;OU~F7L1jT&KAitilb&Fl3X12zfSuFm;X)xQWOHL&7d)Q5wgn{78QJ6k5J;is+XP zCPO8_rlGMJB-kuQ*_=Yo1TswG4xnZd&eTjc8=-$6J^8TAa~kEnRQ@Zp-_W&B(4r@F zA==}0vBzsF1mB~743XqBmL9=0RSkGn$cvHf*hyc{<2{@hW+jKjbC|y%CNupHY_NC% zivz^btBLP-cDyV8j>u)=loBs>HoI5ME)xg)oK-Q0wAy|8WD$fm>K{-`0|W{H00;;G z000j`0OWQ8aHA9e04^;603eeQIvtaXMG=2tcr1y8Fl-J;AS+=<0%DU8Bp3oEEDhA^ zOY)M8%o5+cF$rC?trfMcty*f)R;^v=f~}||Xe!#;T3eTDZELN&-50xk+J1heP5AQ>h5O#S_uO;O@;~REd*_G$x$hVeE#bchX)otXQy|S5(oB)2a2%Sc(iDHm z=d>V|a!BLp9^#)o7^EQ2kg=K4%nI^sK2w@-kmvB+ARXYdq?xC2age6)e4$^UaY=wn zgLD^{X0A+{ySY+&7RpldwpC6=E zSPq?y(rl8ZN%(A*sapd4PU+dIakIwT0=zxIJEUW0kZSo|(zFEWdETY*ZjIk9uNMUA ze11=mHu8lUUlgRx!hItf0dAF#HfdIB+#aOuY--#QN9Ry zbx|XkG?PrBb@l6Owl{9Oa9w{x^R}%GwcEEfY;L-6OU8|9RXvu`-ECS`jcO1x1MP{P zcr;Bw##*Dod9K@pEx9z9G~MiNi>8v1OU-}vk*HbI)@CM? zn~b=jWUF%HP=CS+VCP>GiAU_UOz$aq3%%Z2laq^Gx`WAEmuNScCN)OlW>YHGYFgV2 z42lO5ZANs5VMXLS-RZTvBJkWy*OeV#L;7HwWg51*E|RpFR=H}h(|N+79g)tIW!RBK ze08bg^hlygY$C2`%N>7bDm`UZ(5M~DTanh3d~dg+OcNdUanr8azO?})g}EfnUB;5- zE1FX=ru?X=zAk4_6@__o1fE+ml1r&u^f1Kb24Jf-)zKla%-dbd>UZ1 zrj3!RR!Jg`ZnllKJ)4Yfg)@z>(fFepeOcp=F-^VHv?3jSxfa}-NB~*qkJ5Uq(yn+( z<8)qbZh{C!xnO@-XC~XMNVnr-Z+paowv!$H7>`ypMwA(X4(knx7z{UcWWe-wXM!d? zYT}xaVy|7T@yCbNOoy)$D=E%hUNTm(lPZqL)?$v+-~^-1P8m@Jm2t^L%4#!JK#Vtg zyUjM+Y*!$);1<)0MUqL00L0*EZcsE&usAK-?|{l|-)b7|PBKl}?TM6~#j9F+eZq25_L&oSl}DOMv^-tacpDI)l*Ws3u+~jO@;t(T)P=HCEZ#s_5q=m zOsVY!QsOJn)&+Ge6Tm)Ww_Bd@0PY(78ZJ)7_eP-cnXYk`>j9q`x2?Xc6O@55wF+6R zUPdIX!2{VGA;FSivN@+;GNZ7H2(pTDnAOKqF*ARg+C54vZ@Ve`i?%nDDvQRh?m&`1 zq46gH)wV=;UrwfCT3F(m!Q5qYpa!#f6qr0wF=5b9rk%HF(ITc!*R3wIFaCcftGwPt z(kzx{$*>g5L<;u}HzS4XD%ml zmdStbJcY@pn`!fUmkzJ8N>*8Y+DOO^r}1f4ix-`?x|khoRvF%jiA)8)P{?$8j2_qN zcl3Lm9-s$xdYN9)>3j6BPFK)Jbovl|Sf_p((CHe!4hx@F)hd&&*Xb&{TBj>%pT;-n z{3+hA^QZYnjXxtF2XwxPZ`S#J8h>5qLwtwM-{5abbEnRS z`9_`Zq8FJiI#0syE_V_3M&trw$P=ezkHosV$8&I5c0(*-9KBE5DJOC-Xv zw}1bq~AD0_Xerm`%ryiG9_$S z5G|btfiAUNdV09SO2l9v+e#(H6HYOdQs=^ z@xwZQU)~;p1L*~ciC}9ao{nQ-@B>rpUzKBxv=cUusOP5Trs3QnvHxGh9e>s7AM{V1|HfYe z3QwH;nHHR49fYzuGc3W3l5xrDAI392SFXx>lWE3V9Ds9il3PyZaN5>oC3>9W-^7vC z3~KZ-@iD?tIkhg+6t{m;RGk2%>@I0&kf)o$+-^ls0(YABNbM(=l#ad@nKp_j=b~Xs ziR;xu_+)lxy6|+af!@}gO2H_x)p;nZ-tYxW5Omq=l`GzMp*GTLr>vZN1?e}^C$t*Z zvzEdIc2|HA2RFN_4#EkzMqKnbbw!?!?%B@M0^^5Z;K?x-%lg?Z>}wMV8zEqHZ$cr~Y#Wv>9+)KMUZatUqbRU8 z8t9qrek(H^C0Tuzq|cP2$WL7tzj+Dj5y^2SF1D154CnsB$xbz`$wV||n-cG%rsT$p z+3RHdadK(3-noj(2L#8c5lODg)V8pv(GEnNb@F>dEHQr>!qge@L>#qg)RAUtiOYqF ziiV_ETExwD)bQ<))?-9$)E(FiRBYyC@}issHS!j9n)~I1tarxnQ2LfjdIJ)*jp{0E z&1oTd%!Qbw$W58s!6ms>F z=p0!~_Mv~8jyaicOS*t(ntw`5uFi0Bc4*mH8kSkk$>!f0;FM zX_t14I55!ZVsg0O$D2iuEDb7(J>5|NKW^Z~kzm@dax z9(|As$U7^}LF%#`6r&UPB*6`!Rf74h~*C=ami6xUxYCwiJxdr$+`z zKSC4A%8!s%R&j*2si(OEc*fy!q)?%=TjDZJ2}O zxT6o>jlKXz_7_Y$N})}IG`*#KfMzs#R(SI#)3*ZEzCv%_tu(VTZ5J| zw2$5kK)xTa>xGFgS0?X(NecjzFVKG%VVn?neu=&eQ+DJ1APlY1E?Q1s!Kk=yf7Uho z>8mg_!U{cKqpvI3ucSkC2V`!d^XMDk;>GG~>6>&X_z75-kv0UjevS5ORHV^e8r{tr z-9z*y&0eq3k-&c_AKw~<`8dtjsP0XgFv6AnG?0eo5P14T{xW#b*Hn2gEnt5-KvN1z zy!TUSi>IRbD3u+h@;fn7fy{F&hAKx7dG4i!c?5_GnvYV|_d&F16p;)pzEjB{zL-zr z(0&AZUkQ!(A>ghC5U-)t7(EXb-3)tNgb=z`>8m8n+N?vtl-1i&*ftMbE~0zsKG^I$ zSbh+rUiucsb!Ax@yB}j>yGeiKIZk1Xj!i#K^I*LZW_bWQIA-}FmJ~^}>p=K$bX9F{}z{s^KWc~OK(zl_X57aB^J9v}yQ5h#BE$+C)WOglV)nd0WWtaF{7`_Ur`my>4*NleQG#xae4fIo(b zW(&|g*#YHZNvDtE|6}yHvu(hDekJ-t*f!2RK;FZHRMb*l@Qwkh*~CqQRNLaepXypX z1?%ATf_nHIu3z6gK<7Dmd;{`0a!|toT0ck|TL$U;7Wr-*piO@R)KrbUz8SXO0vr1K z>76arfrqImq!ny+VkH!4?x*IR$d6*;ZA}Mhro(mzUa?agrFZpHi*)P~4~4N;XoIvH z9N%4VK|j4mV2DRQUD!_-9fmfA2(YVYyL#S$B;vqu7fnTbAFMqH``wS7^B5=|1O&fL z)qq(oV6_u4x(I(**#mD}MnAy(C&B4a1n6V%$&=vrIDq^F_KhE5Uw8_@{V`_#M0vCu zaNUXB=n0HT@D+ppDXi8-vp{tj)?7+k>1j}VvEKRgQ~DWva}8*pp`W8~KRo*kJ*&X} zP!~2fxQr@dM*q0dI|)Fux=pZWBk==RI7i{^BQf`kWlD2%|@R9!JA7& zLbM$uJ12y}_62$|T|{)@OJZtzfpL^t@1nMTYHutrF#D+^?~CN~9`YQ@#&&@c_Zf)( zbC~y8!2LO8jHwQXv>G~1q?c68ipT*%dY&c{8wd_!Y#~tMJ7yk!F8| zt?m_CLVw6cU@@p(#h4cY&Qsfz2Xp3w^4Cg%m03Tmq~9n%hyoMH^KY7{(QkRyn_!YB zzZa!Tgr~5$MAG$x)Fs71#6j}Kvcv3=9VUX8CH< zbP3|fY8f#$K*<5JQ7whM(v=GN2k26Xsh)#0!HKS(koLgAp-;)8z0w&_Z=nG4v6n8u z&Tm0Fi){4_!Y5Kp?!zv$FKfUifQ{%c82uYfrvE{%ejUd72aNYmI*0z3-a-EYr+bB->oH3#t(AY3 zV{Z=(SJr;D#0(`u*dc*~9T7D8Pudw894%!>c4wU&V1m<~0InidR6fbi?yPl(z+sKa zdF*kS>_4^1UO>y4T%Ar>epSr5&vp`$KdY7B(F%P0@VyHk@1fJ=6X0=aGjD-)BrOJD zW}IU@hg~^2r>a1fQvjTtvL*mKJ7q;pfP*U2=URL`VB_Y_JojbZ+MS=vaVN0C6L_MV zG1#5=35-E`KsD%r>-Q_ndvJ2tOYcMMP9f*t0iJ`(Z`^+YP)h>@lR(@Wvrt-`0tHG+ zuP2R@@mx=T@fPoQ1s`e^1I0H*kQPBGDky@!ZQG@8jY-+2ihreG5q$6i{3vmDTg0j$ zzRb*-nKN@{_wD`V6+i*YS)?$XfrA-sW?js?SYU8#vXxxQCc|*K!EbpWfu)3~jwq6_@KC0m;3A%jH^18_a0;ksC2DEwa@2{9@{ z9@T??<4QwR69zk{UvcHHX;`ICOwrF;@U;etd@YE)4MzI1WCsadP=`%^B>xPS-{`=~ zZ+2im8meb#4p~XIL9}ZOBg7D8R=PC8V}ObDcxEEK(4yGKcyCQWUe{9jCs+@k!_y|I z%s{W(&>P4w@hjQ>PQL$zY+=&aDU6cWr#hG)BVCyfP)h>@3IG5I2mk;8K>)Ppba*!h z005B=001VF5fT=Y4_ytCUk`sv8hJckqSy&Gc2Jx^WJ$J~08N{il-M$fz_ML$)Cpil z(nOv_nlZB^c4s&&O3h=OLiCz&(|f0 zxWU_-JZy>hxP*gvR>CLnNeQ1~g;6{g#-}AbkIzWR;j=8=6!AHpKQCbjFYxf9h%bov zVi;eNa1>t-<14KERUW>^KwoF+8zNo`Y*WiQwq}3m0_2RYtL9Wmu`JaRaQMQ)`Si^6+VbM`!rH~T?DX2=(n4nT zf`G`(Rpq*pDk*v~wMYPZ@vMNZDMPnxMYmU!lA{Xfo?n=Ibb4y3eyY1@Dut4|Y^ml& zqs$r}jAo=B(Ml>ogeEjyv(E`=kBzPf2uv9TQtO$~bamD#=Tv`lNy(K|w$J2O6jS51 zzZtOCHDWz7W0=L1XDW5WR5mtLGc~W+>*vX5{e~U@rE~?7e>vKU-v8bj;F4#abtcV(3ZtwXo9ia93HiETyQXwW4a-0){;$OU*l` zW^bjkyZTJ6_DL^0}`*)#EZ|2nvKRzMLH9-~@Z6$v#t8Dm%(qpP+DgzNe6d)1q zBqhyF$jJTyYFvl_=a>#I8jhJ)d6SBNPg#xg2^kZ3NX8kQ74ah(Y5Z8mlXyzTD&}Q8 ziY(pj-N-V2f>&hZQJ`Di%wp2fN(I%F@l)3M8GcSdNy+#HuO{$I8NXubRlFkL)cY@b z#`v{}-^hRXEq*8B_cG=%PZvI$eo(|8Wc(2o8L#0_GX9L$1@yV>%7mGk)QTD1R*OvS z4OW;ym1)%k9Bfem0tOqq3yyAUWp&q|LsN!RDnxa|j;>R|Mm2rIv7=tej5GFaa+`#| z;7u9Z_^XV+vD@2hF8Xe63+Qd`oig6S9jX(*DbjzPb*K-H7c^7E-(~!R6E%TrgW;RvG;WS{Ziv*W*a*`9Bb;$Er3?MyF~5GcXv`k>U)n}lwv$Sp+H@IKA5$mKk0g*4Ln{!tfvITeY zzr%8JJ5BdcEYsR9eGzJ4B&$}4FMmbRU6{8{_w7Kl77@PNe7|Bc#c?5(C5&Z=kJ#(oM90D4`rh2S!|^L!P#e#1hkD5@~-- z`63GV0~*rOZSqw7k^#-Y$Q4z3Oa2SPRURqEahB1B^h{7~+p03SwzqL9QU#$3-X zdYtQ?-K5xDAdfomEd6(yPtZ!yY_<35bMedeq`z2JWorljz5-f9<^93HM-$#+acw%9r!JOM%O<|BR`W& zd-%j_?b^q7Kl6{q^N{cg2u;11rFB5EP+oqG9&pHD#_Mo@aNMj;LUvsl&nK(ca(hT( zzFc2oHC6WQv8g7jo+3ZSwK+9G$cvfRnql)?g=XeQ3+LTh3)79nhEle8OqS3T$qn(> z(=5Bg?EWq-ldEywgzXW965%H(9^ik*rH(8dNdkbcS9|ow&_r`X~R^R?B+(oTiMzzlx8KnHqUi z8Rh-)VAnS-CO+3}yxqm8)X+N+uzieFVm-F#syP#M1p5&$wX3MJ8 z+R@grZ*5G^Uh4I@VT=>C4RJNc^~3mx$kS1F{L?3)BzdduD2MZKdu#jNno&f2&d{?` zW(>$oktzY@GO{|Ln~Bt^A4)(%?l-&(Dm!iL#$K_xOyhwAf=K2<+Bom zw7|hl6E5}B$d%n0sfZvfQRy9Fyz2~ z83#=#LaHnf1th^k*p|ux8!!8pfHE!)x*%=_hAddl)P%4h4%&8!5-W#xqqb}c=H(i|wqcIS&oDQ{ zhI7N-$f$ra3=RjPmMh?-IEkJYQ<}R9Z!}wmp$#~Uc%u1oh#TP}wF*kJJmQX2#27kL z_dz(yKufo<=m71bZfLp^Ll#t3(IHkrgMcvx@~om%Ib(h(<$Da7urTI`x|%`wD--sN zJEEa>4DGSEG?0ulkosfj8IMNN4)B=ZtvGG{|4Fp=Xhg!wPNgYzS>{Bp%%Qa+624X@ X49Luk)baa85H9$5YCsTPT`SVRWMtMW diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..2e6e589 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..1b6c787 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" From 844ff41dd345aed8ed2b823f9d820e3601ea5318 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sat, 4 Dec 2021 19:55:26 +0100 Subject: [PATCH 021/133] add crunchyroll login and browse (no parsing for now) --- app/build.gradle | 14 ++- .../teapod/parser/crunchyroll/Cruncyroll.kt | 105 ++++++++++++++++++ .../teapod/ui/activity/main/MainActivity.kt | 41 ++++--- 3 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/mosad/teapod/parser/crunchyroll/Cruncyroll.kt diff --git a/app/build.gradle b/app/build.gradle index 06a5261..fc83ede 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,9 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-android-extensions' + id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version" +} android { compileSdkVersion 30 @@ -43,6 +46,7 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1") implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.3.1' @@ -68,6 +72,10 @@ dependencies { implementation 'com.afollestad.material-dialogs:core:3.3.0' implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' + implementation 'com.github.kittinunf.fuel:fuel:2.3.1' + implementation 'com.github.kittinunf.fuel:fuel-android:2.3.1' + implementation 'com.github.kittinunf.fuel:fuel-json:2.3.1' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Cruncyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Cruncyroll.kt new file mode 100644 index 0000000..cddec0d --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Cruncyroll.kt @@ -0,0 +1,105 @@ +package org.mosad.teapod.parser.crunchyroll + +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.core.FuelError +import com.github.kittinunf.fuel.json.FuelJson +import com.github.kittinunf.fuel.json.responseJson +import com.github.kittinunf.result.Result +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +private val json = Json { ignoreUnknownKeys = true } + +class Cruncyroll { + + private val baseUrl = "https://beta-api.crunchyroll.com" + + private var accessToken = "" + private var tokenType = "" + + fun login(username: String, password: String): Boolean = runBlocking { + val tokenEndpoint = "/auth/v1/token" + + val formData = listOf( + "username" to username, + "password" to password, + "grant_type" to "password", + "scope" to "offline_access" + ) + + withContext(Dispatchers.IO) { + val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData) + .header("Content-Type", "application/x-www-form-urlencoded") + .appendHeader( + "Authorization", + "Basic " + ) + .responseJson() + + result.component1()?.obj()?.let { + accessToken = it.get("access_token").toString() + tokenType = it.get("token_type").toString() + } + +// println("request: $request") +// println("response: $response") +// println("response: $result") + + println("login complete with code ${response.statusCode}") + + return@withContext response.statusCode == 200 + } + + return@runBlocking false + } + + // TODO get/post difference + private suspend fun requestA(endpoint: String): Result = coroutineScope { + return@coroutineScope (Dispatchers.IO) { + val (request, response, result) = Fuel.get("$baseUrl$endpoint") + .header("Authorization", "$tokenType $accessToken") + .responseJson() + +// println("request request: $request") +// println("request response: $response") +// println("request result: $result") + + result + } + } + + // TESTING + @Serializable + data class Test(val total: Int, val items: List) + + @Serializable + data class Item(val channel_id: String, val description: String) + + // TODO sort_by, default alphabetical, n, locale de-DE + suspend fun browse() { + val browseEndpoint = "/content/v1/browse" + + val result = requestA(browseEndpoint) + + println("${result.component1()?.obj()?.get("total")}") + + val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) + println(test) + + } + + suspend fun search() { + val searchEndpoint = "/content/v1/search" + + val result = requestA(searchEndpoint) + + println("${result.component1()?.obj()?.get("total")}") + + val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) + println(test) + + } + +} \ No newline at end of file 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 69905a5..39104b4 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 @@ -36,6 +36,7 @@ import kotlinx.coroutines.* import org.mosad.teapod.R import org.mosad.teapod.databinding.ActivityMainBinding import org.mosad.teapod.parser.AoDParser +import org.mosad.teapod.parser.crunchyroll.Cruncyroll import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.ui.activity.main.fragments.AccountFragment @@ -150,26 +151,38 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen EncryptedPreferences.readCredentials(this) StorageController.load(this) - // show onboarding + // show onboarding TODO rework if (EncryptedPreferences.password.isEmpty()) { showOnboarding() } else { - try { - if (!AoDParser.login()) { - showLoginDialog() - } - } catch (ex: SocketTimeoutException) { - Log.w(javaClass.name, "Timeout during login!") + val crunchy = Cruncyroll() + crunchy.login(EncryptedPreferences.login, EncryptedPreferences.password) + println("after login") - // show waring dialog before finishing - MaterialDialog(this).show { - title(R.string.dialog_timeout_head) - message(R.string.dialog_timeout_desc) - onDismiss { exitAndRemoveTask() } - } - } + runBlocking { crunchy.browse() } } + + +// if (EncryptedPreferences.password.isEmpty()) { +// showOnboarding() +// } else { +// try { +// if (!AoDParser.login()) { +// showLoginDialog() +// } +// } catch (ex: SocketTimeoutException) { +// Log.w(javaClass.name, "Timeout during login!") +// +// // show waring dialog before finishing +// MaterialDialog(this).show { +// title(R.string.dialog_timeout_head) +// message(R.string.dialog_timeout_desc) +// onDismiss { exitAndRemoveTask() } +// } +// } +// } + runBlocking { loadingJob.await() } // wait for initial loading to finish } Log.i(javaClass.name, "loading and login in $time ms") From c4bc3c7ea2156cf2269dcbb64597e5b9736c04e0 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 5 Dec 2021 00:42:56 +0100 Subject: [PATCH 022/133] add rudimentary parsing for browsing results --- app/build.gradle | 2 +- .../{Cruncyroll.kt => Crunchyroll.kt} | 36 +++++++++---------- .../teapod/parser/crunchyroll/DataTypes.kt | 34 ++++++++++++++++++ .../teapod/ui/activity/main/MainActivity.kt | 13 ++----- .../main/fragments/LibraryFragment.kt | 11 +++++- 5 files changed, 65 insertions(+), 31 deletions(-) rename app/src/main/java/org/mosad/teapod/parser/crunchyroll/{Cruncyroll.kt => Crunchyroll.kt} (69%) create mode 100644 app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt diff --git a/app/build.gradle b/app/build.gradle index fc83ede..8a96898 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion 23 targetSdkVersion 30 versionCode 4200 //00.04.200 - versionName "0.5.0-alpha2" + versionName "1.0.0-alpha1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resValue "string", "build_time", buildTime() diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Cruncyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt similarity index 69% rename from app/src/main/java/org/mosad/teapod/parser/crunchyroll/Cruncyroll.kt rename to app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index cddec0d..1b9b274 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Cruncyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -1,7 +1,9 @@ package org.mosad.teapod.parser.crunchyroll +import android.util.Log import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.core.FuelError +import com.github.kittinunf.fuel.core.Parameters import com.github.kittinunf.fuel.json.FuelJson import com.github.kittinunf.fuel.json.responseJson import com.github.kittinunf.result.Result @@ -12,7 +14,7 @@ import kotlinx.serialization.json.Json private val json = Json { ignoreUnknownKeys = true } -class Cruncyroll { +object Crunchyroll { private val baseUrl = "https://beta-api.crunchyroll.com" @@ -47,7 +49,7 @@ class Cruncyroll { // println("response: $response") // println("response: $result") - println("login complete with code ${response.statusCode}") + Log.i(javaClass.name, "login complete with code ${response.statusCode}") return@withContext response.statusCode == 200 } @@ -56,9 +58,9 @@ class Cruncyroll { } // TODO get/post difference - private suspend fun requestA(endpoint: String): Result = coroutineScope { + private suspend fun request(endpoint: String, params: Parameters = listOf()): Result = coroutineScope { return@coroutineScope (Dispatchers.IO) { - val (request, response, result) = Fuel.get("$baseUrl$endpoint") + val (request, response, result) = Fuel.get("$baseUrl$endpoint", params) .header("Authorization", "$tokenType $accessToken") .responseJson() @@ -71,34 +73,30 @@ class Cruncyroll { } // TESTING - @Serializable - data class Test(val total: Int, val items: List) - @Serializable - data class Item(val channel_id: String, val description: String) - // TODO sort_by, default alphabetical, n, locale de-DE - suspend fun browse() { + // TODO sort_by, default alphabetical, n, locale de-DE, categories + suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, n: Int = 10): BrowseResult { val browseEndpoint = "/content/v1/browse" + val parameters = listOf("sort_by" to sortBy.str, "n" to n) - val result = requestA(browseEndpoint) + val result = request(browseEndpoint, parameters) - println("${result.component1()?.obj()?.get("total")}") - - val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) - println(test) +// val browseResult = json.decodeFromString(result.component1()?.obj()?.toString()!!) +// println(browseResult.items.size) + return json.decodeFromString(result.component1()?.obj()?.toString()!!) } + // TODO suspend fun search() { val searchEndpoint = "/content/v1/search" - - val result = requestA(searchEndpoint) + val result = request(searchEndpoint) println("${result.component1()?.obj()?.get("total")}") - val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) - println(test) + val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) + println(test.items.size) } diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt new file mode 100644 index 0000000..1ca5236 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt @@ -0,0 +1,34 @@ +package org.mosad.teapod.parser.crunchyroll + +import kotlinx.serialization.Serializable + +/** + * data classes for browse + * TODO make class names more clear/possibly overlapping for now + */ +enum class SortBy(val str: String) { + ALPHABETICAL("alphabetical"), + NEWLY_ADDED("newly_added"), + POPULARITY("popularity") +} + +@Serializable +data class BrowseResult(val total: Int, val items: List) + +@Serializable +data class Item( + val id: String, + val title: String, + val type: String, + val channel_id: String, + val description: String, + val images: Images + // TODO metadata etc. +) + +@Serializable +data class Images(val poster_tall: List>, val poster_wide: List>) +// crunchyroll why? + +@Serializable +data class Poster(val height: Int, val width: Int, val source: String, val type: String) \ No newline at end of file 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 39104b4..741a0c1 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 @@ -29,14 +29,12 @@ import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.commit -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.callbacks.onDismiss import com.google.android.material.navigation.NavigationBarView import kotlinx.coroutines.* import org.mosad.teapod.R import org.mosad.teapod.databinding.ActivityMainBinding import org.mosad.teapod.parser.AoDParser -import org.mosad.teapod.parser.crunchyroll.Cruncyroll +import org.mosad.teapod.parser.crunchyroll.Crunchyroll import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.ui.activity.main.fragments.AccountFragment @@ -49,8 +47,6 @@ 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 import kotlin.system.measureTimeMillis class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener { @@ -155,11 +151,8 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen if (EncryptedPreferences.password.isEmpty()) { showOnboarding() } else { - val crunchy = Cruncyroll() - crunchy.login(EncryptedPreferences.login, EncryptedPreferences.password) - println("after login") - - runBlocking { crunchy.browse() } + Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password) + //runBlocking { Crunchyroll.browse() } } 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 b761490..03a31ce 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 @@ -9,6 +9,8 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.mosad.teapod.databinding.FragmentLibraryBinding import org.mosad.teapod.parser.AoDParser +import org.mosad.teapod.parser.crunchyroll.Crunchyroll +import org.mosad.teapod.util.ItemMedia import org.mosad.teapod.util.adapter.MediaItemAdapter import org.mosad.teapod.util.decoration.MediaItemDecoration import org.mosad.teapod.util.showFragment @@ -30,7 +32,14 @@ class LibraryFragment : Fragment() { lifecycleScope.launch { // create and set the adapter, needs context context?.let { - adapter = MediaItemAdapter(AoDParser.guiMediaList) + // crunchy testing TODO implement lazy loading + val results = Crunchyroll.browse(n = 50) + val list = results.items.mapIndexed { index, item -> + ItemMedia(index, item.title, item.images.poster_wide[0][0].source) + } + + + adapter = MediaItemAdapter(list) adapter.onItemClick = { mediaId, _ -> activity?.showFragment(MediaFragment(mediaId)) } From a46fd4c6d279e73c699c849a019d9b10307cd655 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 5 Dec 2021 01:34:06 +0100 Subject: [PATCH 023/133] implement index call index is needed to retrieve identifiers necessary for streaming --- .../teapod/parser/crunchyroll/Crunchyroll.kt | 34 ++++++++++++++++++- .../teapod/ui/activity/main/MainActivity.kt | 3 +- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index 1b9b274..204e2c1 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -21,6 +21,10 @@ object Crunchyroll { private var accessToken = "" private var tokenType = "" + private var policy = "" + private var signature = "" + private var keyPairID = "" + fun login(username: String, password: String): Boolean = runBlocking { val tokenEndpoint = "/auth/v1/token" @@ -40,6 +44,7 @@ object Crunchyroll { ) .responseJson() + // TODO fix JSONException: No value for result.component1()?.obj()?.let { accessToken = it.get("access_token").toString() tokenType = it.get("token_type").toString() @@ -76,6 +81,14 @@ object Crunchyroll { // TODO sort_by, default alphabetical, n, locale de-DE, categories + /** + * Browse the media available on crunchyroll. + * + * @param sortBy + * @param n Number of items to return, defaults to 10 + * + * @return A **[BrowseResult]** object is returned. + */ suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, n: Int = 10): BrowseResult { val browseEndpoint = "/content/v1/browse" val parameters = listOf("sort_by" to sortBy.str, "n" to n) @@ -85,7 +98,7 @@ object Crunchyroll { // val browseResult = json.decodeFromString(result.component1()?.obj()?.toString()!!) // println(browseResult.items.size) - return json.decodeFromString(result.component1()?.obj()?.toString()!!) + return json.decodeFromString(result.component1()?.obj()?.toString()!!) } // TODO @@ -100,4 +113,23 @@ object Crunchyroll { } + /** + * Retrieve the identifiers necessary for streaming. If the identifiers are + * retrieved, set the corresponding global var. The identifiers are valid for 24h. + */ + suspend fun index() { + val indexEndpoint = "/index/v2" + val result = request(indexEndpoint) + + result.component1()?.obj()?.getJSONObject("cms")?.let { + policy = it.get("policy").toString() + signature = it.get("signature").toString() + keyPairID = it.get("key_pair_id").toString() + } + + println("policy: $policy") + println("signature: $signature") + println("keyPairID: $keyPairID") + } + } \ No newline at end of file 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 741a0c1..9386c17 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 @@ -152,7 +152,8 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen showOnboarding() } else { Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password) - //runBlocking { Crunchyroll.browse() } + runBlocking { Crunchyroll.browse() } + runBlocking { Crunchyroll.index() } } From 236ca9a6c9106df41ebfaadbcf15252465719642 Mon Sep 17 00:00:00 2001 From: Jannik Date: Mon, 20 Dec 2021 22:14:58 +0100 Subject: [PATCH 024/133] Implement media fragment for tv shows --- .../teapod/parser/crunchyroll/Crunchyroll.kt | 160 +++++++++++++----- .../teapod/parser/crunchyroll/DataTypes.kt | 135 ++++++++++++++- .../teapod/ui/activity/main/MainActivity.kt | 6 +- .../activity/main/fragments/HomeFragment.kt | 14 +- .../main/fragments/LibraryFragment.kt | 6 +- .../activity/main/fragments/MediaFragment.kt | 62 +++---- .../main/fragments/MediaFragmentEpisodes.kt | 13 +- .../main/fragments/MediaFragmentSimilar.kt | 2 +- .../activity/main/fragments/SearchFragment.kt | 2 +- .../main/viewmodel/MediaFragmentViewModel.kt | 45 ++++- .../ui/activity/player/PlayerActivity.kt | 8 +- .../ui/activity/player/PlayerViewModel.kt | 26 ++- .../java/org/mosad/teapod/util/DataTypes.kt | 11 +- .../teapod/util/adapter/EpisodeItemAdapter.kt | 56 +++--- .../teapod/util/adapter/MediaItemAdapter.kt | 4 +- app/src/main/res/values/strings.xml | 1 + 16 files changed, 419 insertions(+), 132 deletions(-) diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index 204e2c1..91386cc 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -11,12 +11,13 @@ import kotlinx.coroutines.* import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import java.util.* private val json = Json { ignoreUnknownKeys = true } object Crunchyroll { - private val baseUrl = "https://beta-api.crunchyroll.com" + private const val baseUrl = "https://beta-api.crunchyroll.com" private var accessToken = "" private var tokenType = "" @@ -25,9 +26,14 @@ object Crunchyroll { private var signature = "" private var keyPairID = "" + // TODO temp helper vary + var locale = "${Locale.GERMANY.language}-${Locale.GERMANY.country}" + var country = Locale.GERMANY.country + + val browsingCache = arrayListOf() + fun login(username: String, password: String): Boolean = runBlocking { val tokenEndpoint = "/auth/v1/token" - val formData = listOf( "username" to username, "password" to password, @@ -63,9 +69,15 @@ object Crunchyroll { } // TODO get/post difference - private suspend fun request(endpoint: String, params: Parameters = listOf()): Result = coroutineScope { + private suspend fun request( + endpoint: String, + params: Parameters = listOf(), + url: String = "" + ): Result = coroutineScope { + val path = if (url.isEmpty()) "$baseUrl$endpoint" else url + return@coroutineScope (Dispatchers.IO) { - val (request, response, result) = Fuel.get("$baseUrl$endpoint", params) + val (request, response, result) = Fuel.get(path, params) .header("Authorization", "$tokenType $accessToken") .responseJson() @@ -77,42 +89,6 @@ object Crunchyroll { } } - // TESTING - - - // TODO sort_by, default alphabetical, n, locale de-DE, categories - /** - * Browse the media available on crunchyroll. - * - * @param sortBy - * @param n Number of items to return, defaults to 10 - * - * @return A **[BrowseResult]** object is returned. - */ - suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, n: Int = 10): BrowseResult { - val browseEndpoint = "/content/v1/browse" - val parameters = listOf("sort_by" to sortBy.str, "n" to n) - - val result = request(browseEndpoint, parameters) - -// val browseResult = json.decodeFromString(result.component1()?.obj()?.toString()!!) -// println(browseResult.items.size) - - return json.decodeFromString(result.component1()?.obj()?.toString()!!) - } - - // TODO - suspend fun search() { - val searchEndpoint = "/content/v1/search" - val result = request(searchEndpoint) - - println("${result.component1()?.obj()?.get("total")}") - - val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) - println(test.items.size) - - } - /** * Retrieve the identifiers necessary for streaming. If the identifiers are * retrieved, set the corresponding global var. The identifiers are valid for 24h. @@ -132,4 +108,108 @@ object Crunchyroll { println("keyPairID: $keyPairID") } + + // TODO locale de-DE, categories + /** + * Browse the media available on crunchyroll. + * + * @param sortBy + * @param n Number of items to return, defaults to 10 + * + * @return A **[BrowseResult]** object is returned. + */ + suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, n: Int = 10): BrowseResult { + val browseEndpoint = "/content/v1/browse" + val parameters = listOf("sort_by" to sortBy.str, "n" to n) + + val result = request(browseEndpoint, parameters) + val browseResult = result.component1()?.obj()?.let { + json.decodeFromString(it.toString()) + } ?: NoneBrowseResult + + // add results to cache TODO improve + browsingCache.clear() + browsingCache.addAll(browseResult.items) + + return browseResult + } + + // // TODO locale de-DE, type + suspend fun search(query: String, n: Int = 10) { + val searchEndpoint = "/content/v1/search" + val parameters = listOf("q" to query, "n" to n) + + val result = request(searchEndpoint, parameters) + println("${result.component1()?.obj()?.get("total")}") + + val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) + println(test.items.size) + + // TODO return + } + + /** + * series id == crunchyroll id? + */ + suspend fun series(seriesId: String): Series { + val seriesEndpoint = "/cms/v2/$country/M3/crunchyroll/series/$seriesId" + val parameters = listOf( + "locale" to locale, + "Signature" to signature, + "Policy" to policy, + "Key-Pair-Id" to keyPairID + ) + + val result = request(seriesEndpoint, parameters) + + return result.component1()?.obj()?.let { + json.decodeFromString(it.toString()) + } ?: NoneSeries + } + + suspend fun seasons(seriesId: String): Seasons { + val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons" + val parameters = listOf( + "series_id" to seriesId, + "locale" to locale, + "Signature" to signature, + "Policy" to policy, + "Key-Pair-Id" to keyPairID + ) + + val result = request(episodesEndpoint, parameters) + + return result.component1()?.obj()?.let { + println(it) + json.decodeFromString(it.toString()) + } ?: NoneSeasons + } + + suspend fun episodes(seasonId: String): Episodes { + val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/episodes" + val parameters = listOf( + "season_id" to seasonId, + "locale" to locale, + "Signature" to signature, + "Policy" to policy, + "Key-Pair-Id" to keyPairID + ) + + val result = request(episodesEndpoint, parameters) + + return result.component1()?.obj()?.let { + println(it) + json.decodeFromString(it.toString()) + } ?: NoneEpisodes + } + + suspend fun playback(url: String): Playback { + val result = request("", url = url) + + return result.component1()?.obj()?.let { + println(it) + json.decodeFromString(it.toString()) + } ?: NonePlayback + } + } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt index 1ca5236..fb12c8b 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt @@ -1,5 +1,6 @@ package org.mosad.teapod.parser.crunchyroll +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -26,9 +27,141 @@ data class Item( // TODO metadata etc. ) +val NoneItem = Item("", "", "", "", "", Images(listOf(), listOf())) +val NoneBrowseResult = BrowseResult(0, listOf()) + @Serializable data class Images(val poster_tall: List>, val poster_wide: List>) // crunchyroll why? @Serializable -data class Poster(val height: Int, val width: Int, val source: String, val type: String) \ No newline at end of file +data class Poster(val height: Int, val width: Int, val source: String, val type: String) + +/** + * Series return type + */ +@Serializable +data class Series( + val id: String, + val title: String, + val description: String, + val images: Images +) +val NoneSeries = Series("", "", "", Images(listOf(), listOf())) + + +/** + * Seasons data type + */ +@Serializable +data class Seasons(val total: Int, val items: List) + +@Serializable +data class Season( + val id: String, + val title: String, + val series_id: String, + val season_number: Int +) + +val NoneSeasons = Seasons(0, listOf()) + +/** + * Episodes data type + */ +@Serializable +data class Episodes(val total: Int, val items: List) + +@Serializable +data class Episode( + @SerialName("id") val id: String, + @SerialName("title") val title: String, + @SerialName("series_id") val seriesId: String, + @SerialName("season_title") val seasonTitle: String, + @SerialName("season_id") val seasonId: String, + @SerialName("season_number") val seasonNumber: Int, + @SerialName("episode") val episode: String, + @SerialName("episode_number") val episodeNumber: Int, + @SerialName("description") val description: String, + @SerialName("next_episode_id") val nextEpisodeId: String = "", // use default value since the field is optional + @SerialName("next_episode_title") val nextEpisodeTitle: String = "", // use default value since the field is optional + @SerialName("is_subbed") val isSubbed: Boolean, + @SerialName("is_dubbed") val isDubbed: Boolean, + @SerialName("images") val images: Thumbnail, + @SerialName("duration_ms") val durationMs: Int, + @SerialName("playback") val playback: String, +) + +@Serializable +data class Thumbnail( + @SerialName("thumbnail") val thumbnail: List> +) + +val NoneEpisodes = Episodes(0, listOf()) +val NoneEpisode = Episode( + id = "", + title = "", + seriesId = "", + seasonId = "", + seasonTitle = "", + seasonNumber = 0, + episode = "", + episodeNumber = 0, + description = "", + nextEpisodeId = "", + nextEpisodeTitle = "", + isSubbed = false, + isDubbed = false, + images = Thumbnail(listOf()), + durationMs = 0, + playback = "" +) + +/** + * Playback/stream data type + */ +@Serializable +data class Playback( + @SerialName("audio_locale") val audioLocale: String, + @SerialName("subtitles") val subtitles: Map, + @SerialName("streams") val streams: Streams, +) + +@Serializable +data class Subtitle( + @SerialName("locale") val locale: String, + @SerialName("url") val url: String, + @SerialName("format") val format: String, +) + +@Serializable +data class Streams( + @SerialName("adaptive_dash") val adaptive_dash: Map, + @SerialName("adaptive_hls") val adaptive_hls: Map, + @SerialName("download_hls") val download_hls: Map, + @SerialName("drm_adaptive_dash") val drm_adaptive_dash: Map, + @SerialName("drm_adaptive_hls") val drm_adaptive_hls: Map, + @SerialName("drm_download_hls") val drm_download_hls: Map, + @SerialName("trailer_dash") val trailer_dash: Map, + @SerialName("trailer_hls") val trailer_hls: Map, + @SerialName("vo_adaptive_dash") val vo_adaptive_dash: Map, + @SerialName("vo_adaptive_hls") val vo_adaptive_hls: Map, + @SerialName("vo_drm_adaptive_dash") val vo_drm_adaptive_dash: Map, + @SerialName("vo_drm_adaptive_hls") val vo_drm_adaptive_hls: Map, +) + +@Serializable +data class Stream( + @SerialName("hardsub_locale") val hardsubLocale: String, + @SerialName("url") val url: String, + @SerialName("vcodec") val vcodec: String, +) + +val NonePlayback = Playback( + "", + mapOf(), + Streams( + mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), + mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), + ) +) 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 9386c17..48326d6 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 @@ -47,6 +47,7 @@ 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 java.util.* import kotlin.system.measureTimeMillis class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener { @@ -138,7 +139,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen // start the initial loading val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) .async { - launch { AoDParser.initialLoading() } launch { MetaDBController.list() } } @@ -209,9 +209,9 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen /** * start the player as new activity */ - fun startPlayer(mediaId: Int, episodeId: Int) { + fun startPlayer(seasonId: String, episodeId: String) { val intent = Intent(this, PlayerActivity::class.java).apply { - putExtra(getString(R.string.intent_media_id), mediaId) + putExtra(getString(R.string.intent_season_id), seasonId) putExtra(getString(R.string.intent_episode_id), episodeId) } startActivity(intent) 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 ae7ddd4..25f12f7 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 @@ -99,7 +99,7 @@ class HomeFragment : Fragment() { val media = AoDParser.getMediaById(highlightMedia.id) Log.d(javaClass.name, "Starting Player with mediaId: ${media.aodId}") - (activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId) + //(activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId) // TODO } } @@ -117,27 +117,27 @@ class HomeFragment : Fragment() { } binding.textHighlightInfo.setOnClickListener { - activity?.showFragment(MediaFragment(highlightMedia.id)) + activity?.showFragment(MediaFragment("")) } adapterMyList.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) + activity?.showFragment(MediaFragment("")) //(mediaId)) } adapterNewEpisodes.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) + activity?.showFragment(MediaFragment("")) //(mediaId)) } adapterNewSimulcasts.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) + activity?.showFragment(MediaFragment("")) //(mediaId)) } adapterNewTitles.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) + activity?.showFragment(MediaFragment("")) //(mediaId)) } adapterTopTen.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment(id)) + activity?.showFragment(MediaFragment("")) //(mediaId)) } } 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 03a31ce..cabe2b8 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 @@ -35,13 +35,13 @@ class LibraryFragment : Fragment() { // crunchy testing TODO implement lazy loading val results = Crunchyroll.browse(n = 50) val list = results.items.mapIndexed { index, item -> - ItemMedia(index, item.title, item.images.poster_wide[0][0].source) + ItemMedia(index, item.title, item.images.poster_wide[0][0].source, idStr = item.id) } adapter = MediaItemAdapter(list) - adapter.onItemClick = { mediaId, _ -> - activity?.showFragment(MediaFragment(mediaId)) + adapter.onItemClick = { mediaIdStr, _ -> + activity?.showFragment(MediaFragment(mediaIdStr = mediaIdStr)) } binding.recyclerMediaLibrary.adapter = adapter 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 fb2cc42..87408e7 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 @@ -15,11 +15,12 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import com.bumptech.glide.Glide import com.bumptech.glide.request.RequestOptions import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.tabs.TabLayoutMediator import jp.wasabeef.glide.transformations.BlurTransformation import kotlinx.coroutines.launch import org.mosad.teapod.R import org.mosad.teapod.databinding.FragmentMediaBinding +import org.mosad.teapod.parser.crunchyroll.Item +import org.mosad.teapod.parser.crunchyroll.NoneItem import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.util.DataTypes.MediaType @@ -32,7 +33,7 @@ import org.mosad.teapod.util.tmdb.TMDBApiController * Note: the fragment is created only once, when selecting a similar title etc. * therefore fragments may be not empty and model may be the old one */ -class MediaFragment(private val mediaId: Int) : Fragment() { +class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : Fragment() { private lateinit var binding: FragmentMediaBinding private lateinit var pagerAdapter: FragmentStateAdapter @@ -55,16 +56,17 @@ class MediaFragment(private val mediaId: Int) : Fragment() { // fix material components issue #1878, if more tabs are added increase binding.pagerEpisodesSimilar.offscreenPageLimit = 2 binding.pagerEpisodesSimilar.adapter = pagerAdapter - TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> - tab.text = if (model.media.type == MediaType.TVSHOW && position == 0) { - getString(R.string.episodes) - } else { - getString(R.string.similar_titles) - } - }.attach() + // TODO implement for cr media items +// TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> +// tab.text = if (model.media.type == MediaType.TVSHOW && position == 0) { +// getString(R.string.episodes) +// } else { +// getString(R.string.similar_titles) +// } +// }.attach() lifecycleScope.launch { - model.load(mediaId) // load the streams and tmdb for the selected media + model.loadCrunchy(mediaIdStr) updateGUI() initActions() @@ -86,9 +88,9 @@ class MediaFragment(private val mediaId: Int) : Fragment() { private fun updateGUI() = with(model) { // generic gui val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it } - ?: media.posterURL + ?: mediaCrunchy.images.poster_wide[0][2].source val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it } - ?: media.posterURL + ?: mediaCrunchy.images.poster_tall[0][2].source // load poster and backdrop Glide.with(requireContext()).load(posterUrl) @@ -98,12 +100,12 @@ class MediaFragment(private val mediaId: Int) : Fragment() { .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) .into(binding.imageBackdrop) - 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 = mediaCrunchy.title + //binding.textYear.text = media.year.toString() // TODO + //binding.textAge.text = media.age.toString() // TODO + binding.textOverview.text = mediaCrunchy.description - // set "my list" indicator + // TODO set "my list" indicator if (StorageController.myList.contains(media.aodId)) { Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) } else { @@ -116,19 +118,19 @@ class MediaFragment(private val mediaId: Int) : Fragment() { pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize) // specific gui - if (media.type == MediaType.TVSHOW) { - // get next episode - nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId - ?: media.playlist.first().mediaId + if (mediaCrunchy.type == MediaType.TVSHOW.str) { + // TODO get next episode +// nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId +// ?: media.playlist.first().mediaId - // title is the next episodes title - binding.textTitle.text = media.getEpisodeById(nextEpisodeId).title + // 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, - media.playlist.size, - media.playlist.size + episodesCrunchy.total, + episodesCrunchy.total ) // episodes @@ -170,8 +172,8 @@ class MediaFragment(private val mediaId: Int) : Fragment() { private fun initActions() = with(model) { binding.buttonPlay.setOnClickListener { when (media.type) { - MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) - MediaType.TVSHOW -> playEpisode(nextEpisodeId) + //MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) // TODO + //MediaType.TVSHOW -> playEpisode(nextEpisodeId) // TODO else -> Log.e(javaClass.name, "Wrong Type: ${media.type}") } } @@ -198,11 +200,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(episodeId: Int) { - (activity as MainActivity).startPlayer(model.media.aodId, episodeId) + private fun playEpisode(seasonId: String, episodeId: String) { + (activity as MainActivity).startPlayer(seasonId, episodeId) Log.d(javaClass.name, "Started Player with episodeId: $episodeId") - model.updateNextEpisode(episodeId) // 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 f2e9f58..a0985ce 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 @@ -27,13 +27,14 @@ class MediaFragmentEpisodes : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapterRecEpisodes = EpisodeItemAdapter(model.media.playlist, model.tmdbTVSeason?.episodes) + adapterRecEpisodes = EpisodeItemAdapter(model.episodesCrunchy, model.tmdbTVSeason?.episodes) binding.recyclerEpisodes.adapter = adapterRecEpisodes // set onItemClick only in adapter is initialized if (this::adapterRecEpisodes.isInitialized) { - adapterRecEpisodes.onImageClick = { _, position -> - playEpisode(model.media.playlist[position].mediaId) + adapterRecEpisodes.onImageClick = { seasonId, episodeId -> + println("TODO playback episode $episodeId (season: $seasonId)") + playEpisode(seasonId, episodeId) } } } @@ -50,11 +51,11 @@ class MediaFragmentEpisodes : Fragment() { } } - private fun playEpisode(episodeId: Int) { - (activity as MainActivity).startPlayer(model.media.aodId, episodeId) + private fun playEpisode(seasonId: String, episodeId: String) { + (activity as MainActivity).startPlayer(seasonId, episodeId) Log.d(javaClass.name, "Started Player with episodeId: $episodeId") - model.updateNextEpisode(episodeId) // 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 87195a1..c57770b 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 @@ -34,7 +34,7 @@ class MediaFragmentSimilar : Fragment() { // set onItemClick only in adapter is initialized if (this::adapterSimilar.isInitialized) { adapterSimilar.onItemClick = { mediaId, _ -> - activity?.showFragment(MediaFragment(mediaId)) + activity?.showFragment(MediaFragment("")) //(mediaId)) } } } 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 a2943a9..08ea2ac 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 @@ -33,7 +33,7 @@ class SearchFragment : Fragment() { adapter = MediaItemAdapter(AoDParser.guiMediaList) adapter!!.onItemClick = { mediaId, _ -> binding.searchText.clearFocus() - activity?.showFragment(MediaFragment(mediaId)) + activity?.showFragment(MediaFragment("")) //(mediaId)) } binding.recyclerMediaSearch.adapter = adapter 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 6f855d9..f6695b1 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 @@ -4,6 +4,8 @@ import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel import org.mosad.teapod.parser.AoDParser +import org.mosad.teapod.parser.crunchyroll.* +import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.util.* import org.mosad.teapod.util.DataTypes.MediaType import org.mosad.teapod.util.tmdb.TMDBApiController @@ -21,6 +23,13 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic var nextEpisodeId = -1 internal set + var mediaCrunchy = NoneItem + internal set + var seasonsCrunchy = NoneSeasons + internal set + var episodesCrunchy = NoneEpisodes + internal set + var tmdbResult: TMDBResult? = null // TODO rename internal set var tmdbTVSeason: TMDBTVSeason? =null @@ -28,11 +37,45 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic var mediaMeta: Meta? = null internal set + suspend fun loadCrunchy(crunchyId: String) { + val tmdbApiController = TMDBApiController() + + println("loading crunchyroll media $crunchyId") + + // TODO info also in browse result item + mediaCrunchy = Crunchyroll.browsingCache.find { it -> + it.id == crunchyId + } ?: NoneItem + println("media: $mediaCrunchy") + + // load seasons + seasonsCrunchy = Crunchyroll.seasons(crunchyId) + println("media: $seasonsCrunchy") + + // load first season + episodesCrunchy = Crunchyroll.episodes(seasonsCrunchy.items.first().id) + println("media: $episodesCrunchy") + + + + // 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 + mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media + val tmdbId = tmdbApiController.search(stripTitleInfo(mediaCrunchy.title), MediaType.TVSHOW) + + tmdbResult = when (MediaType.TVSHOW) { + MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) + MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId) + else -> null + } + } + /** * set media, tmdb and nextEpisode * TODO run aod and tmdb load parallel */ - suspend fun load(aodId: Int) { + suspend fun loadAoD(aodId: Int) { val tmdbApiController = TMDBApiController() media = AoDParser.getMediaById(aodId) 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 f3e2008..82519e3 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 @@ -58,8 +58,8 @@ class PlayerActivity : AppCompatActivity() { hideBars() // Initial hide the bars model.loadMedia( - intent.getIntExtra(getString(R.string.intent_media_id), 0), - intent.getIntExtra(getString(R.string.intent_episode_id), 0) + intent.getStringExtra(getString(R.string.intent_season_id)) ?: "", + intent.getStringExtra(getString(R.string.intent_episode_id)) ?: "" ) model.currentEpisodeChangedListener.add { onMediaChanged() } gestureDetector = GestureDetectorCompat(this, PlayerGestureListener()) @@ -121,8 +121,8 @@ class PlayerActivity : AppCompatActivity() { // 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) + it.getStringExtra(getString(R.string.intent_season_id)) ?: "", + it.getStringExtra(getString(R.string.intent_episode_id)) ?: "" ) model.playEpisode(model.currentEpisode.mediaId, replace = 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 ca14e0f..c81c5d1 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 @@ -18,6 +18,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.mosad.teapod.R import org.mosad.teapod.parser.AoDParser +import org.mosad.teapod.parser.crunchyroll.Crunchyroll +import org.mosad.teapod.parser.crunchyroll.NoneEpisode +import org.mosad.teapod.parser.crunchyroll.NoneEpisodes +import org.mosad.teapod.parser.crunchyroll.NonePlayback import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.util.* import org.mosad.teapod.util.tmdb.TMDBApiController @@ -54,6 +58,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) var currentLanguage: Locale = Locale.ROOT internal set + var episodesCrunchy = NoneEpisodes + internal set + var currentEpisodeCr = NoneEpisode + internal set + var currentPlaybackCr = NonePlayback + internal set + init { initMediaSession() } @@ -78,10 +89,17 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) mediaSession.isActive = true } - fun loadMedia(mediaId: Int, episodeId: Int) { + fun loadMedia(seasonId: String, episodeId: String) { runBlocking { - media = AoDParser.getMediaById(mediaId) - mediaMeta = loadMediaMeta(media.aodId) // can be done blocking, since it should be cached + episodesCrunchy = Crunchyroll.episodes(seasonId) + //mediaMeta = loadMediaMeta(media.aodId) // can be done blocking, since it should be cached + + currentEpisodeCr = episodesCrunchy.items.find { episode -> + episode.id == episodeId + } ?: NoneEpisode + println("loading playback ${currentEpisodeCr.playback}") + + currentPlaybackCr = Crunchyroll.playback(currentEpisodeCr.playback) } // run async as it should be loaded by the time the episodes a @@ -93,8 +111,6 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } } - currentEpisode = media.getEpisodeById(episodeId) - nextEpisodeId = selectNextEpisode() currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language } 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 db662e5..83467fd 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -3,10 +3,10 @@ package org.mosad.teapod.util import java.util.Locale class DataTypes { - enum class MediaType { - OTHER, - MOVIE, - TVSHOW + enum class MediaType(val str: String) { + OTHER("other"), + MOVIE("movie"), // TODO + TVSHOW("series") } enum class Theme(val str: String) { @@ -37,7 +37,8 @@ data class ThirdPartyComponent( data class ItemMedia( val id: Int, // aod path id val title: String, - val posterUrl: String + val posterUrl: String, + val idStr: String = "" // crunchyroll id ) // TODO replace playlist: List with a map? 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 3bd2df0..8baa776 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 @@ -4,19 +4,18 @@ import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide 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.AoDEpisode +import org.mosad.teapod.parser.crunchyroll.Episodes import org.mosad.teapod.util.tmdb.TMDBTVEpisode -class EpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { +class EpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { - var onImageClick: ((String, Int) -> Unit)? = null + var onImageClick: ((seasonId: String, episodeId: String) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { return EpisodeViewHolder(ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false)) @@ -24,52 +23,63 @@ class EpisodeItemAdapter(private val episodes: List, private val tmd override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { val context = holder.binding.root.context - val ep = episodes[position] + val ep = episodes.items[position] - val titleText = if (ep.hasDub()) { - context.getString(R.string.component_episode_title, ep.numberStr, ep.description) + val titleText = if (ep.isDubbed) { + context.getString(R.string.component_episode_title, ep.episode, ep.title) } else { - context.getString(R.string.component_episode_title_sub, ep.numberStr, ep.description) + context.getString(R.string.component_episode_title_sub, ep.episode, ep.title) } holder.binding.textEpisodeTitle.text = titleText - holder.binding.textEpisodeDesc.text = if (ep.shortDesc.isNotEmpty()) { - ep.shortDesc + holder.binding.textEpisodeDesc.text = if (ep.description.isNotEmpty()) { + ep.description } else if (tmdbEpisodes != null && position < tmdbEpisodes.size){ tmdbEpisodes[position].overview } else { "" } - if (ep.imageURL.isNotEmpty()) { - Glide.with(context).load(ep.imageURL) + // TODO is isNotEmpty() needed? + if (ep.images.thumbnail[0][0].source.isNotEmpty()) { + Glide.with(context).load(ep.images.thumbnail[0][0].source) .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) .into(holder.binding.imageEpisode) } - if (ep.watched) { - holder.binding.imageWatched.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_baseline_check_circle_24) - ) - } else { - holder.binding.imageWatched.setImageDrawable(null) - } + // TODO +// if (ep.watched) { +// holder.binding.imageWatched.setImageDrawable( +// ContextCompat.getDrawable(context, R.drawable.ic_baseline_check_circle_24) +// ) +// } else { +// holder.binding.imageWatched.setImageDrawable(null) +// } + // disable watched icon until implemented + holder.binding.imageWatched.setImageDrawable(null) } override fun getItemCount(): Int { - return episodes.size + return episodes.items.size } fun updateWatchedState(watched: Boolean, position: Int) { // use getOrNull as there could be a index out of bound when running this in onResume() - episodes.getOrNull(position)?.watched = watched + + // TODO + //episodes.getOrNull(position)?.watched = watched } - inner class EpisodeViewHolder(val binding: ItemEpisodeBinding) : RecyclerView.ViewHolder(binding.root) { + inner class EpisodeViewHolder(val binding: ItemEpisodeBinding) : + RecyclerView.ViewHolder(binding.root) { init { + // on image click return the episode id and index (within the adapter) binding.imageEpisode.setOnClickListener { - onImageClick?.invoke(episodes[adapterPosition].title, adapterPosition) + onImageClick?.invoke( + episodes.items[bindingAdapterPosition].seasonId, + episodes.items[bindingAdapterPosition].id + ) } } } diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt index 2c23bcf..f5b862c 100644 --- a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt +++ b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt @@ -12,7 +12,7 @@ import java.util.* class MediaItemAdapter(private val initMedia: List) : RecyclerView.Adapter(), Filterable { - var onItemClick: ((Int, Int) -> Unit)? = null + var onItemClick: ((String, Int) -> Unit)? = null private val filter = MediaFilter() private var filteredMedia = initMedia.map { it.copy() } @@ -42,7 +42,7 @@ class MediaItemAdapter(private val initMedia: List) : RecyclerView.Ad inner class MediaViewHolder(val binding: ItemMediaBinding) : RecyclerView.ViewHolder(binding.root) { init { binding.root.setOnClickListener { - onItemClick?.invoke(filteredMedia[adapterPosition].id, adapterPosition) + onItemClick?.invoke(filteredMedia[adapterPosition].idStr, adapterPosition) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce053ae..50246e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,5 +131,6 @@ intent_media_id + intent_season_id intent_episode_id \ No newline at end of file From 7dc41da13c07b141824871fa1c0304798441d280 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sun, 26 Dec 2021 20:22:00 +0100 Subject: [PATCH 025/133] add support for crunchyroll media playback in player --- .../teapod/parser/crunchyroll/DataTypes.kt | 4 +- .../ui/activity/player/PlayerActivity.kt | 31 ++-- .../ui/activity/player/PlayerViewModel.kt | 150 ++++++++++-------- .../ui/components/EpisodesListPlayer.kt | 9 +- .../teapod/ui/components/FastForwardButton.kt | 26 +-- .../teapod/ui/components/RewindButton.kt | 26 +-- .../java/org/mosad/teapod/util/DataTypes.kt | 4 +- .../teapod/util/adapter/EpisodeItemAdapter.kt | 2 +- .../util/adapter/PlayerEpisodeItemAdapter.kt | 31 ++-- 9 files changed, 153 insertions(+), 130 deletions(-) diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt index fb12c8b..6086b10 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt @@ -83,8 +83,8 @@ data class Episode( @SerialName("episode") val episode: String, @SerialName("episode_number") val episodeNumber: Int, @SerialName("description") val description: String, - @SerialName("next_episode_id") val nextEpisodeId: String = "", // use default value since the field is optional - @SerialName("next_episode_title") val nextEpisodeTitle: String = "", // use default value since the field is 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("is_subbed") val isSubbed: Boolean, @SerialName("is_dubbed") val isDubbed: Boolean, @SerialName("images") val images: Thumbnail, 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 82519e3..bcc63b2 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 @@ -29,6 +29,7 @@ import kotlinx.android.synthetic.main.activity_player.* import kotlinx.android.synthetic.main.player_controls.* import kotlinx.coroutines.launch import org.mosad.teapod.R +import org.mosad.teapod.parser.crunchyroll.NoneEpisode import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.ui.components.EpisodesListPlayer import org.mosad.teapod.ui.components.LanguageSettingsPlayer @@ -124,7 +125,7 @@ class PlayerActivity : AppCompatActivity() { it.getStringExtra(getString(R.string.intent_season_id)) ?: "", it.getStringExtra(getString(R.string.intent_episode_id)) ?: "" ) - model.playEpisode(model.currentEpisode.mediaId, replace = true) + model.playCurrentMedia() } } @@ -171,7 +172,7 @@ class PlayerActivity : AppCompatActivity() { } private fun initPlayer() { - if (model.media.aodId < 0) { + if (model.currentEpisode.equals(NoneEpisode)) { Log.e(javaClass.name, "No media was set.") this.finish() } @@ -206,14 +207,14 @@ class PlayerActivity : AppCompatActivity() { else -> View.VISIBLE } - if (state == ExoPlayer.STATE_ENDED && model.nextEpisodeId != null && Preferences.autoplay) { + if (state == ExoPlayer.STATE_ENDED && model.currentEpisodeCr.nextEpisodeId != null && Preferences.autoplay) { playNextEpisode() } } }) // start playing the current episode, after all needed player components have been initialized - model.playEpisode(model.currentEpisode.mediaId, true) + model.playCurrentMedia() } @SuppressLint("ClickableViewAccessibility") @@ -251,9 +252,10 @@ class PlayerActivity : AppCompatActivity() { } private fun initGUI() { - if (model.media.type == DataTypes.MediaType.MOVIE) { - button_episodes.visibility = View.GONE - } + // TODO reimplement for cr +// if (model.media.type == DataTypes.MediaType.MOVIE) { +// button_episodes.visibility = View.GONE +// } } private fun initTimeUpdates() { @@ -277,7 +279,7 @@ class PlayerActivity : AppCompatActivity() { // 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 (!btnNextEpIsVisible && model.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) { + if (!btnNextEpIsVisible && model.currentEpisodeCr.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) { showButtonNextEp() } } else if (btnNextEpIsVisible) { @@ -335,18 +337,19 @@ 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.nextEpisodeId == null) { + button_next_ep_c.visibility = if (model.currentEpisodeCr.nextEpisodeId == null) { View.GONE } else { View.VISIBLE } + // TODO reimplement for cr // hide the episodes button, if the media type changed - button_episodes.visibility = if (model.media.type == DataTypes.MediaType.MOVIE) { - View.GONE - } else { - View.VISIBLE - } +// button_episodes.visibility = if (model.media.type == DataTypes.MediaType.MOVIE) { +// View.GONE +// } else { +// View.VISIBLE +// } } /** 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 c81c5d1..795707b 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 @@ -5,26 +5,23 @@ import android.net.Uri import android.support.v4.media.session.MediaSessionCompat import android.util.Log import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.google.android.exoplayer2.C import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory import com.google.android.exoplayer2.util.Util -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.mosad.teapod.R -import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.parser.crunchyroll.Crunchyroll import org.mosad.teapod.parser.crunchyroll.NoneEpisode import org.mosad.teapod.parser.crunchyroll.NoneEpisodes import org.mosad.teapod.parser.crunchyroll.NonePlayback import org.mosad.teapod.preferences.Preferences -import org.mosad.teapod.util.* -import org.mosad.teapod.util.tmdb.TMDBApiController +import org.mosad.teapod.util.AoDEpisodeNone +import org.mosad.teapod.util.EpisodeMeta +import org.mosad.teapod.util.Meta +import org.mosad.teapod.util.TVShowMeta import org.mosad.teapod.util.tmdb.TMDBTVSeason import java.util.* import kotlin.collections.ArrayList @@ -43,8 +40,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) val currentEpisodeChangedListener = ArrayList<() -> Unit>() private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN - var media: AoDMedia = AoDMediaNone - internal set +// var media: AoDMedia = AoDMediaNone +// internal set var mediaMeta: Meta? = null internal set var tmdbTVSeason: TMDBTVSeason? =null @@ -53,8 +50,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) internal set var currentEpisodeMeta: EpisodeMeta? = null internal set - var nextEpisodeId: Int? = null - internal set +// var nextEpisodeId: Int? = null +// internal set var currentLanguage: Locale = Locale.ROOT internal set @@ -62,8 +59,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) internal set var currentEpisodeCr = NoneEpisode internal set - var currentPlaybackCr = NonePlayback - internal set + private var currentPlaybackCr = NonePlayback init { initMediaSession() @@ -94,6 +90,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) episodesCrunchy = Crunchyroll.episodes(seasonId) //mediaMeta = loadMediaMeta(media.aodId) // can be done blocking, since it should be cached + // TODO replace this with setCurrentEpisode currentEpisodeCr = episodesCrunchy.items.find { episode -> episode.id == episodeId } ?: NoneEpisode @@ -102,14 +99,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) currentPlaybackCr = Crunchyroll.playback(currentEpisodeCr.playback) } + // TODO reimplement for cr // 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) - } - } +// viewModelScope.launch { +// // get tmdb 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) +// } +// } currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language @@ -117,12 +115,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun setLanguage(language: Locale) { currentLanguage = language + playCurrentMedia(player.currentPosition) - val seekTime = player.currentPosition - val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( - MediaItem.fromUri(Uri.parse(currentEpisode.getPreferredStream(language).url)) - ) - playMedia(mediaSource, true, seekTime) +// val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( +// MediaItem.fromUri(Uri.parse(currentEpisode.getPreferredStream(language).url)) +// ) +// playMedia(mediaSource, seekTime) } // player actions @@ -138,62 +136,70 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) /** * play the next episode, if nextEpisode is not null */ - fun playNextEpisode() = nextEpisodeId?.let { it -> - playEpisode(it, replace = true) + fun playNextEpisode() = currentEpisodeCr.nextEpisodeId?.let { nextEpisodeId -> + setCurrentEpisode(nextEpisodeId, startPlayback = true) } /** - * Set currentEpisode and start playing it. - * Update nextEpisode to reflect the change and update - * the watched state for the now playing episode. + * Set currentEpisodeCr to the episode of the given ID + * @param episodeId The ID of the episode you want to set currentEpisodeCr to + */ + fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) { + currentEpisodeCr = episodesCrunchy.items.find { episode -> + episode.id == episodeId + } ?: NoneEpisode + + // TODO don't run blocking + runBlocking { + currentPlaybackCr = Crunchyroll.playback(currentEpisodeCr.playback) + } + + // TODO update metadata and language (it should not be needed to update the language here!) + + if (startPlayback) { + playCurrentMedia() + } + } + + /** + * Play the current media from currentPlaybackCr. * - * @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(episodeId: Int, replace: Boolean = false, seekPosition: Long = 0) { - currentEpisode = media.getEpisodeById(episodeId) - currentLanguage = currentEpisode.getPreferredStream(currentLanguage).language - currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) - nextEpisodeId = selectNextEpisode() - + fun playCurrentMedia(seekPosition: Long = 0) { // update player gui (title, next ep button) after nextEpisodeId has been set currentEpisodeChangedListener.forEach { it() } + // get preferred stream url TODO implement + val url = currentPlaybackCr.streams.adaptive_hls["en-US"]?.url ?: "" + println("stream url: $url") + + // create the media source object val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( - MediaItem.fromUri(Uri.parse(currentEpisode.getPreferredStream(currentLanguage).url)) + MediaItem.fromUri(Uri.parse(url)) ) - playMedia(mediaSource, replace, seekPosition) - // if episodes has not been watched, mark as watched - if (!currentEpisode.watched) { - viewModelScope.launch { - AoDParser.markAsWatched(media.aodId, currentEpisode.mediaId) - } - } - } + // the actual player playback code + player.setMediaSource(mediaSource) + player.prepare() + if (seekPosition > 0) player.seekTo(seekPosition) + player.playWhenReady = true - /** - * change the players media source and start playback - */ - fun playMedia(source: MediaSource, replace: Boolean = false, seekPosition: Long = 0) { - if (replace || player.contentDuration == C.TIME_UNSET) { - player.setMediaSource(source) - player.prepare() - if (seekPosition > 0) player.seekTo(seekPosition) - player.playWhenReady = true - } + // TODO reimplement mark as watched for cr, if needed } fun getMediaTitle(): String { - return if (media.type == DataTypes.MediaType.TVSHOW) { + // TODO add tvshow/movie diff + val isTVShow = true + return if(isTVShow) { getApplication().getString( R.string.component_episode_title, - currentEpisode.numberStr, - currentEpisode.description + currentEpisodeCr.episode, + currentEpisodeCr.title ) } else { - currentEpisode.title + // TODO movie + currentEpisodeCr.title } } @@ -206,22 +212,28 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } } + // TODO reimplement for cr private suspend fun loadMediaMeta(aodId: Int): Meta? { - return if (media.type == DataTypes.MediaType.TVSHOW) { - MetaDBController().getTVShowMetadata(aodId) - } else { - null - } +// return if (media.type == DataTypes.MediaType.TVSHOW) { +// MetaDBController().getTVShowMetadata(aodId) +// } else { +// null +// } + + return null } /** + * TODO reimplement for cr * 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 +// return media.playlist.firstOrNull { +// it.index > media.getEpisodeById(currentEpisode.mediaId).index +// }?.mediaId + + return null } } \ 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 13a6d40..cb654e9 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,12 +28,13 @@ class EpisodesListPlayer @JvmOverloads constructor( } model?.let { - adapterRecEpisodes = PlayerEpisodeItemAdapter(model.media.playlist, model.tmdbTVSeason?.episodes) - adapterRecEpisodes.onImageClick = { _, position -> + adapterRecEpisodes = PlayerEpisodeItemAdapter(model.episodesCrunchy, model.tmdbTVSeason?.episodes) + adapterRecEpisodes.onImageClick = {_, episodeId -> (this.parent as ViewGroup).removeView(this) - model.playEpisode(model.media.playlist[position].mediaId, replace = true) + model.setCurrentEpisode(episodeId, startPlayback = true) } - adapterRecEpisodes.currentSelected = model.currentEpisode.index + // episodeNumber starts at 1, we need the episode index -> - 1 + adapterRecEpisodes.currentSelected = (model.currentEpisodeCr.episodeNumber - 1) binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes binding.recyclerEpisodesPlayer.scrollToPosition(model.currentEpisode.index) diff --git a/app/src/main/java/org/mosad/teapod/ui/components/FastForwardButton.kt b/app/src/main/java/org/mosad/teapod/ui/components/FastForwardButton.kt index cabe34a..e9207d7 100644 --- a/app/src/main/java/org/mosad/teapod/ui/components/FastForwardButton.kt +++ b/app/src/main/java/org/mosad/teapod/ui/components/FastForwardButton.kt @@ -5,13 +5,15 @@ import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.content.Context import android.util.AttributeSet +import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout -import kotlinx.android.synthetic.main.button_fast_forward.view.* import org.mosad.teapod.R +import org.mosad.teapod.databinding.ButtonFastForwardBinding class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(context, attrs) { + private val binding = ButtonFastForwardBinding.inflate(LayoutInflater.from(context)) private val animationDuration: Long = 800 private val buttonAnimation: ObjectAnimator private val labelAnimation: ObjectAnimator @@ -19,30 +21,30 @@ class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(con var onAnimationEndCallback: (() -> Unit)? = null init { - inflate(context, R.layout.button_fast_forward, this) + addView(binding.root) - buttonAnimation = ObjectAnimator.ofFloat(imageButton, View.ROTATION, 0f, 50f).apply { + buttonAnimation = ObjectAnimator.ofFloat(binding.imageButton, View.ROTATION, 0f, 50f).apply { duration = animationDuration / 4 repeatCount = 1 repeatMode = ObjectAnimator.REVERSE addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator?) { - imageButton.isEnabled = false // disable button - imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_24) + binding.imageButton.isEnabled = false // disable button + binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_24) } }) } - labelAnimation = ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, 35f).apply { + labelAnimation = ObjectAnimator.ofFloat(binding.textView, View.TRANSLATION_X, 35f).apply { duration = animationDuration addListener(object : AnimatorListenerAdapter() { // the label animation takes longer then the button animation, reset stuff in here override fun onAnimationEnd(animation: Animator?) { - imageButton.isEnabled = true // enable button - imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_10_24) + binding.imageButton.isEnabled = true // enable button + binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_10_24) - textView.visibility = View.GONE - textView.animate().translationX(0f) + binding.textView.visibility = View.GONE + binding.textView.animate().translationX(0f) onAnimationEndCallback?.invoke() } @@ -51,7 +53,7 @@ class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(con } fun setOnButtonClickListener(func: FastForwardButton.() -> Unit) { - imageButton.setOnClickListener { + binding.imageButton.setOnClickListener { func() } } @@ -61,7 +63,7 @@ class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(con buttonAnimation.start() // run lbl animation - textView.visibility = View.VISIBLE + binding.textView.visibility = View.VISIBLE labelAnimation.start() } diff --git a/app/src/main/java/org/mosad/teapod/ui/components/RewindButton.kt b/app/src/main/java/org/mosad/teapod/ui/components/RewindButton.kt index a72ec74..c1fa4d0 100644 --- a/app/src/main/java/org/mosad/teapod/ui/components/RewindButton.kt +++ b/app/src/main/java/org/mosad/teapod/ui/components/RewindButton.kt @@ -5,13 +5,15 @@ import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.content.Context import android.util.AttributeSet +import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout -import kotlinx.android.synthetic.main.button_rewind.view.* import org.mosad.teapod.R +import org.mosad.teapod.databinding.ButtonRewindBinding class RewindButton(context: Context, attrs: AttributeSet): FrameLayout(context, attrs) { + private val binding = ButtonRewindBinding.inflate(LayoutInflater.from(context)) private val animationDuration: Long = 800 private val buttonAnimation: ObjectAnimator private val labelAnimation: ObjectAnimator @@ -19,29 +21,29 @@ class RewindButton(context: Context, attrs: AttributeSet): FrameLayout(context, var onAnimationEndCallback: (() -> Unit)? = null init { - inflate(context, R.layout.button_rewind, this) + addView(binding.root) - buttonAnimation = ObjectAnimator.ofFloat(imageButton, View.ROTATION, 0f, -50f).apply { + buttonAnimation = ObjectAnimator.ofFloat(binding.imageButton, View.ROTATION, 0f, -50f).apply { duration = animationDuration / 4 repeatCount = 1 repeatMode = ObjectAnimator.REVERSE addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator?) { - imageButton.isEnabled = false // disable button - imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_24) + binding.imageButton.isEnabled = false // disable button + binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_24) } }) } - labelAnimation = ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -35f).apply { + labelAnimation = ObjectAnimator.ofFloat(binding.textView, View.TRANSLATION_X, -35f).apply { duration = animationDuration addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { - imageButton.isEnabled = true // enable button - imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_10_24) + binding.imageButton.isEnabled = true // enable button + binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_10_24) - textView.visibility = View.GONE - textView.animate().translationX(0f) + binding.textView.visibility = View.GONE + binding.textView.animate().translationX(0f) onAnimationEndCallback?.invoke() } @@ -50,7 +52,7 @@ class RewindButton(context: Context, attrs: AttributeSet): FrameLayout(context, } fun setOnButtonClickListener(func: RewindButton.() -> Unit) { - imageButton.setOnClickListener { + binding.imageButton.setOnClickListener { func() } } @@ -60,7 +62,7 @@ class RewindButton(context: Context, attrs: AttributeSet): FrameLayout(context, buttonAnimation.start() // run lbl animation - textView.visibility = View.VISIBLE + binding.textView.visibility = View.VISIBLE labelAnimation.start() } 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 83467fd..7e93be0 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -76,7 +76,7 @@ data class AoDEpisode( * @return the preferred stream, if not present use the first stream */ fun getPreferredStream(language: Locale) = streams.firstOrNull { it.language == language } - ?: streams.first() + ?: Stream("", Locale.ROOT) } data class Stream( @@ -112,7 +112,7 @@ val AoDEpisodeNone = AoDEpisode( "", "", -1, - false, + true, "", mutableListOf() ) 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 8baa776..7576d71 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 @@ -40,7 +40,7 @@ class EpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisode "" } - // TODO is isNotEmpty() needed? + // TODO is isNotEmpty() needed? also in PlayerEpisodeItemAdapter if (ep.images.thumbnail[0][0].source.isNotEmpty()) { Glide.with(context).load(ep.images.thumbnail[0][0].source) .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) 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 6cf35a0..4efecaa 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,12 +9,12 @@ 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.AoDEpisode +import org.mosad.teapod.parser.crunchyroll.Episodes import org.mosad.teapod.util.tmdb.TMDBTVEpisode -class PlayerEpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { +class PlayerEpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { - var onImageClick: ((String, Int) -> Unit)? = null + var onImageClick: ((seasonId: String, episodeId: String) -> Unit)? = null var currentSelected: Int = -1 // -1, since position should never be < 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { @@ -23,25 +23,25 @@ class PlayerEpisodeItemAdapter(private val episodes: List, private v override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { val context = holder.binding.root.context - val ep = episodes[position] + val ep = episodes.items[position] - val titleText = if (ep.hasDub()) { - context.getString(R.string.component_episode_title, ep.numberStr, ep.description) + val titleText = if (ep.isDubbed) { + context.getString(R.string.component_episode_title, ep.episode, ep.title) } else { - context.getString(R.string.component_episode_title_sub, ep.numberStr, ep.description) + context.getString(R.string.component_episode_title_sub, ep.episode, ep.title) } holder.binding.textEpisodeTitle2.text = titleText - holder.binding.textEpisodeDesc2.text = if (ep.shortDesc.isNotEmpty()) { - ep.shortDesc + holder.binding.textEpisodeDesc2.text = if (ep.description.isNotEmpty()) { + ep.description } else if (tmdbEpisodes != null && position < tmdbEpisodes.size){ tmdbEpisodes[position].overview } else { "" } - if (ep.imageURL.isNotEmpty()) { - Glide.with(context).load(ep.imageURL) + if (ep.images.thumbnail[0][0].source.isNotEmpty()) { + Glide.with(context).load(ep.images.thumbnail[0][0].source) .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) .into(holder.binding.imageEpisode) } @@ -55,15 +55,18 @@ class PlayerEpisodeItemAdapter(private val episodes: List, private v } override fun getItemCount(): Int { - return episodes.size + return episodes.items.size } inner class EpisodeViewHolder(val binding: ItemEpisodePlayerBinding) : RecyclerView.ViewHolder(binding.root) { init { binding.imageEpisode.setOnClickListener { // don't execute, if it's the current episode - if (currentSelected != adapterPosition) { - onImageClick?.invoke(episodes[adapterPosition].title, adapterPosition) + if (currentSelected != bindingAdapterPosition) { + onImageClick?.invoke( + episodes.items[bindingAdapterPosition].seasonId, + episodes.items[bindingAdapterPosition].id + ) } } } From 63ce910ec507039f95848264c41f6b387db54b07 Mon Sep 17 00:00:00 2001 From: Jannik Date: Mon, 27 Dec 2021 21:14:35 +0100 Subject: [PATCH 026/133] implement lazy loading for LibraryFragment & code cleanup --- app/proguard-rules.pro | 28 ++ .../java/org/mosad/teapod/parser/AoDParser.kt | 472 ------------------ .../teapod/parser/crunchyroll/Crunchyroll.kt | 4 +- .../teapod/ui/activity/main/MainActivity.kt | 12 +- .../main/fragments/AccountFragment.kt | 18 +- .../activity/main/fragments/HomeFragment.kt | 108 ++-- .../main/fragments/LibraryFragment.kt | 54 +- .../activity/main/fragments/SearchFragment.kt | 3 +- .../main/viewmodel/MediaFragmentViewModel.kt | 89 ++-- .../ui/activity/onboarding/OnLoginFragment.kt | 25 +- .../ui/activity/player/PlayerActivity.kt | 8 +- .../ui/activity/player/PlayerViewModel.kt | 68 +-- .../ui/components/EpisodesListPlayer.kt | 6 +- .../ui/components/LanguageSettingsPlayer.kt | 13 +- .../java/org/mosad/teapod/util/DataTypes.kt | 3 +- .../teapod/util/adapter/MediaItemAdapter.kt | 10 +- 16 files changed, 249 insertions(+), 672 deletions(-) delete mode 100644 app/src/main/java/org/mosad/teapod/parser/AoDParser.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 13a7bfd..73e6e44 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -26,6 +26,34 @@ -keepattributes Signature -dontwarn sun.misc.** +# kotlinx.serialization +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <1>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + #misc -dontwarn java.lang.instrument.ClassFileTransformer -dontwarn java.lang.ClassValue diff --git a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt deleted file mode 100644 index e352731..0000000 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ /dev/null @@ -1,472 +0,0 @@ -/** - * 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.parser - -import android.util.Log -import com.google.gson.JsonParser -import kotlinx.coroutines.* -import org.jsoup.Connection -import org.jsoup.Jsoup -import org.mosad.teapod.preferences.EncryptedPreferences -import org.mosad.teapod.util.* -import org.mosad.teapod.util.DataTypes.MediaType -import java.io.IOException -import java.net.CookieStore -import java.util.* -import kotlin.random.Random -import kotlin.reflect.jvm.jvmName - -object AoDParser { - - private const val baseUrl = "https://www.anime-on-demand.de" - private const val loginPath = "/users/sign_in" - private const val libraryPath = "/animes" - private const val subscriptionPath = "/mypools" - - private const val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0" - - private lateinit var cookieStore: CookieStore - private var csrfToken: String = "" - private var loginSuccess = false - - private val aodMediaList = arrayListOf() // actual media (data) - - // gui media - val guiMediaList = arrayListOf() - val highlightsList = arrayListOf() - val newEpisodesList = arrayListOf() - val newSimulcastsList = arrayListOf() - val newTitlesList = arrayListOf() - val topTenList = arrayListOf() - - fun login(): Boolean = runBlocking { - - withContext(Dispatchers.IO) { - // get the authenticity token and cookies - val conAuth = Jsoup.connect(baseUrl + loginPath) - .header("User-Agent", userAgent) - - cookieStore = conAuth.cookieStore() - csrfToken = conAuth.execute().parse().select("meta[name=csrf-token]").attr("content") - - Log.d(AoDParser::class.jvmName, "Received authenticity token: $csrfToken") - Log.d(AoDParser::class.jvmName, "Received authenticity cookies: $cookieStore") - - val data = mapOf( - Pair("user[login]", EncryptedPreferences.login), - Pair("user[password]", EncryptedPreferences.password), - Pair("user[remember_me]", "1"), - Pair("commit", "Einloggen"), - Pair("authenticity_token", csrfToken) - ) - - val resLogin = Jsoup.connect(baseUrl + loginPath) - .method(Connection.Method.POST) - .timeout(60000) // login can take some time default is 60000 (60 sec) - .data(data) - .postDataCharset("UTF-8") - .cookieStore(cookieStore) - .execute() - //println(resLogin.body()) - - loginSuccess = resLogin.body().contains("Hallo, du bist jetzt angemeldet.") - Log.i(AoDParser::class.jvmName, "Status: ${resLogin.statusCode()} (${resLogin.statusMessage()}), login successful: $loginSuccess") - - loginSuccess - } - } - - /** - * initially load all media and home screen data - */ - suspend fun initialLoading() { - coroutineScope { - launch { loadHome() } - launch { listAnimes() } - } - } - - /** - * get a media by it's ID (int) - * @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(aodId: Int): AoDMedia { - return aodMediaList.firstOrNull { it.aodId == aodId } ?: - try { - loadMediaAsync(aodId).await().apply { - aodMediaList.add(this) - } - } catch (exn:NullPointerException) { - Log.e(AoDParser::class.jvmName, "Error while loading media $aodId", exn) - AoDMediaNone - } - } - - /** - * get subscription info from aod website, remove "Anime-Abo" Prefix and trim - */ - suspend fun getSubscriptionInfoAsync(): Deferred { - return coroutineScope { - async(Dispatchers.IO) { - val res = Jsoup.connect(baseUrl + subscriptionPath) - .cookieStore(cookieStore) - .get() - - return@async res.select("a:contains(Anime-Abo)").text() - .removePrefix("Anime-Abo").trim() - } - } - } - - fun getSubscriptionUrl(): String { - return baseUrl + subscriptionPath - } - - suspend fun markAsWatched(aodId: Int, episodeId: Int) { - val episode = getMediaById(aodId).getEpisodeById(episodeId) - episode.watched = true - sendCallback(episode.watchedCallback) - - Log.d(AoDParser::class.jvmName, "Marked episode ${episode.mediaId} as watched") - } - - // TODO don't use jsoup here - private suspend fun sendCallback(callbackPath: String) = coroutineScope { - launch(Dispatchers.IO) { - val headers = mutableMapOf( - Pair("Accept", "application/json, text/javascript, */*; q=0.01"), - Pair("Accept-Language", "de,en-US;q=0.7,en;q=0.3"), - Pair("Accept-Encoding", "gzip, deflate, br"), - Pair("X-CSRF-Token", csrfToken), - Pair("X-Requested-With", "XMLHttpRequest"), - ) - - try { - Jsoup.connect(baseUrl + callbackPath) - .ignoreContentType(true) - .cookieStore(cookieStore) - .headers(headers) - .execute() - } catch (ex: IOException) { - Log.e(AoDParser::class.jvmName, "Callback for $callbackPath failed.", ex) - } - } - } - - /** - * load all media from aod into itemMediaList and mediaList - * TODO private suspend fun listAnimes() = withContext(Dispatchers.IO) should also work, maybe a bug in android studio? - */ - private suspend fun listAnimes() = withContext(Dispatchers.IO) { - launch(Dispatchers.IO) { - val resAnimes = Jsoup.connect(baseUrl + libraryPath).get() - //println(resAnimes) - - 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") - ) - } - ) - - Log.i(AoDParser::class.jvmName, "Total library size is: ${guiMediaList.size}") - } - } - - /** - * load new episodes, titles and highlights - */ - private suspend fun loadHome() = withContext(Dispatchers.IO) { - launch(Dispatchers.IO) { - val resHome = Jsoup.connect(baseUrl).get() - - // get highlights from AoD - highlightsList.clear() - resHome.select("#aod-highlights").select("div.news-item").forEach { - val mediaId = it.select("div.news-item-text").select("a.serienlink") - .attr("href").substringAfterLast("/").toIntOrNull() - val mediaTitle = it.select("div.news-title").select("h2").text() - val mediaImage = it.select("img").attr("src") - - if (mediaId != null) { - highlightsList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) - } - } - - // get all new episodes from AoD - newEpisodesList.clear() - resHome.select("h2:contains(Neue Episoden)").next().select("li").forEach { - val mediaId = it.select("a.thumbs").attr("href") - .substringAfterLast("/").toIntOrNull() - val mediaImage = it.select("a.thumbs > img").attr("src") - val mediaTitle = "${it.select("a").text()} - ${it.select("span.neweps").text()}" - - if (mediaId != null) { - newEpisodesList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) - } - } - - // get new simulcasts from AoD - newSimulcastsList.clear() - resHome.select("h2:contains(Neue Simulcasts)").next().select("li").forEach { - val mediaId = it.select("a.thumbs").attr("href") - .substringAfterLast("/").toIntOrNull() - val mediaImage = it.select("a.thumbs > img").attr("src") - val mediaTitle = it.select("a").text() - - if (mediaId != null) { - newSimulcastsList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) - } - } - - // get new titles from AoD - newTitlesList.clear() - resHome.select("h2:contains(Neue Anime-Titel)").next().select("li").forEach { - val mediaId = it.select("a.thumbs").attr("href") - .substringAfterLast("/").toIntOrNull() - val mediaImage = it.select("a.thumbs > img").attr("src") - val mediaTitle = it.select("a").text() - - if (mediaId != null) { - newTitlesList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) - } - } - - // get top ten from AoD - topTenList.clear() - resHome.select("h2:contains(Anime Top 10)").next().select("li").forEach { - val mediaId = it.select("a.thumbs").attr("href") - .substringAfterLast("/").toIntOrNull() - val mediaImage = it.select("a.thumbs > img").attr("src") - val mediaTitle = it.select("a").text() - - if (mediaId != null) { - topTenList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) - } - } - - // if highlights is empty, add a random new title - if (highlightsList.isEmpty()) { - if (newTitlesList.isNotEmpty()) { - highlightsList.add(newTitlesList[Random.nextInt(0, newTitlesList.size)]) - } else { - highlightsList.add(ItemMedia(0,"", "")) - } - } - - Log.i(AoDParser::class.jvmName, "loaded home") - } - } - - /** - * TODO catch SocketTimeoutException from loading to show a waring dialog - * Load media async. Every media has a playlist. - * @param aodId The AoD ID of the requested media - */ - private suspend fun loadMediaAsync(aodId: Int): Deferred = coroutineScope { - return@coroutineScope async (Dispatchers.IO) { - if (cookieStore.cookies.isEmpty()) login() // TODO is this needed? - - // return none object, if login wasn't successful - if (!loginSuccess) { - Log.w(AoDParser::class.jvmName, "Login was not successful") - return@async AoDMediaNone - } - - // get the media page - val res = Jsoup.connect("$baseUrl/anime/$aodId") - .cookieStore(cookieStore) - .get() - // println(res) - - if (csrfToken.isEmpty()) { - csrfToken = res.select("meta[name=csrf-token]").attr("content") - Log.d(AoDParser::class.jvmName, "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 aodPlaylists = besides.select("input.streamstarter_html5").map { streamstarter -> - parsePlaylistAsync( - streamstarter.attr("data-playlist"), - streamstarter.attr("data-lang") - ) - } - - /** - * 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" -> year = row.select("td").text().toInt() - "fsk" -> age = row.select("td").text().toInt() - } - } - - // similar titles from media page - 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") - val mediaTitle = it.select("a").text() - - if (mediaId != null) { - ItemMedia(mediaId, mediaTitle, mediaImage) - } else { - Log.i(AoDParser::class.jvmName, "MediaId for similar to $aodId was null") - null - } - } - - /** - * 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 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() - - AoDEpisodeInfo(mediaId, episodeShortDesc, episodeWatched, episodeWatchedCallback) - } else { - Log.i(AoDParser::class.jvmName, "Episode info for $aodId has empty streamstarter_html5 ") - null - } - }.associateBy { it.aodMediaId } - } else { - mapOf() - } - - // 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 { - if (playlistPath == "[]") { - return CompletableDeferred(AoDPlaylist(listOf(), Locale.ROOT)) - } - - return CoroutineScope(Dispatchers.IO).async(Dispatchers.IO) { - val headers = mutableMapOf( - Pair("Accept", "application/json, text/javascript, */*; q=0.01"), - Pair("Accept-Language", "de,en-US;q=0.7,en;q=0.3"), - Pair("Accept-Encoding", "gzip, deflate, br"), - Pair("X-CSRF-Token", csrfToken), - Pair("X-Requested-With", "XMLHttpRequest"), - ) - - //println("loading streaminfo with cstf: $csrfToken") - - val res = Jsoup.connect(baseUrl + playlistPath) - .ignoreContentType(true) - .cookieStore(cookieStore) - .headers(headers) - .timeout(120000) // loading the playlist can take some time - .execute() - - //Gson().fromJson(res.body(), AoDObject::class.java) - - return@async AoDPlaylist(JsonParser.parseString(res.body()).asJsonObject - .get("playlist").asJsonArray.map { - Playlist( - sources = it.asJsonObject.get("sources").asJsonArray.map { source -> - Source(source.asJsonObject.get("file").asString) - }, - image = it.asJsonObject.get("image").asString, - title = it.asJsonObject.get("title").asString, - description = it.asJsonObject.get("description").asString, - mediaid = it.asJsonObject.get("mediaid").asInt - ) - }, - // TODO improve language handling (via display language etc.) - language = when (language) { - "ger" -> Locale.GERMAN - "jap" -> Locale.JAPANESE - else -> Locale.ROOT - } - ) - } - } - -} diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index 91386cc..f1d734a 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -118,9 +118,9 @@ object Crunchyroll { * * @return A **[BrowseResult]** object is returned. */ - suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, n: Int = 10): BrowseResult { + suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, start: Int = 0, n: Int = 10): BrowseResult { val browseEndpoint = "/content/v1/browse" - val parameters = listOf("sort_by" to sortBy.str, "n" to n) + val parameters = listOf("sort_by" to sortBy.str, "start" to start, "n" to n) val result = request(browseEndpoint, parameters) val browseResult = result.component1()?.obj()?.let { 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 48326d6..7bb5f15 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 @@ -33,7 +33,6 @@ import com.google.android.material.navigation.NavigationBarView import kotlinx.coroutines.* import org.mosad.teapod.R import org.mosad.teapod.databinding.ActivityMainBinding -import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.parser.crunchyroll.Crunchyroll import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.Preferences @@ -47,7 +46,6 @@ 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 java.util.* import kotlin.system.measureTimeMillis class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener { @@ -152,7 +150,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen showOnboarding() } else { Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password) - runBlocking { Crunchyroll.browse() } runBlocking { Crunchyroll.index() } } @@ -188,10 +185,11 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen LoginDialog(this, false).positiveButton { EncryptedPreferences.saveCredentials(login, password, context) - if (!AoDParser.login()) { - showLoginDialog() - Log.w(javaClass.name, "Login failed, please try again.") - } + // TODO +// if (!AoDParser.login()) { +// showLoginDialog() +// Log.w(javaClass.name, "Login failed, please try again.") +// } }.negativeButton { Log.i(javaClass.name, "Login canceled, exiting.") finish() diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt index 64c7b89..4829f3b 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt @@ -2,9 +2,7 @@ package org.mosad.teapod.ui.activity.main.fragments import android.app.Activity import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -19,7 +17,6 @@ import kotlinx.coroutines.launch import org.mosad.teapod.BuildConfig import org.mosad.teapod.R import org.mosad.teapod.databinding.FragmentAccountBinding -import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.ui.activity.main.MainActivity @@ -62,12 +59,13 @@ class AccountFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // TODO reimplement for ct, if possible (maybe account status would be better? (premium)) // load subscription (async) info before anything else binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading)) lifecycleScope.launch { binding.textAccountSubscription.text = getString( R.string.account_subscription, - AoDParser.getSubscriptionInfoAsync().await() + "TODO" ) } @@ -92,7 +90,8 @@ class AccountFragment : Fragment() { } binding.linearAccountSubscription.setOnClickListener { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl()))) + // TODO + //startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl()))) } binding.linearTheme.setOnClickListener { @@ -133,10 +132,11 @@ class AccountFragment : Fragment() { LoginDialog(requireContext(), firstTry).positiveButton { EncryptedPreferences.saveCredentials(login, password, context) - if (!AoDParser.login()) { - showLoginDialog(false) - Log.w(javaClass.name, "Login failed, please try again.") - } + // TODO +// if (!AoDParser.login()) { +// showLoginDialog(false) +// Log.w(javaClass.name, "Login failed, please try again.") +// } }.show { login = EncryptedPreferences.login password = "" 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 25f12f7..9592c9f 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 @@ -1,18 +1,14 @@ package org.mosad.teapod.ui.activity.main.fragments import android.os.Bundle -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import com.bumptech.glide.Glide import kotlinx.coroutines.launch import org.mosad.teapod.R import org.mosad.teapod.databinding.FragmentHomeBinding -import org.mosad.teapod.parser.AoDParser -import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.util.ItemMedia import org.mosad.teapod.util.StorageController import org.mosad.teapod.util.adapter.MediaItemAdapter @@ -49,19 +45,20 @@ class HomeFragment : Fragment() { } private fun initHighlight() { - if (AoDParser.highlightsList.isNotEmpty()) { - highlightMedia = AoDParser.highlightsList[0] - - binding.textHighlightTitle.text = highlightMedia.title - Glide.with(requireContext()).load(highlightMedia.posterUrl) - .into(binding.imageHighlight) - - if (StorageController.myList.contains(highlightMedia.id)) { - binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) - } else { - binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) - } - } + // TODO +// if (AoDParser.highlightsList.isNotEmpty()) { +// highlightMedia = AoDParser.highlightsList[0] +// +// binding.textHighlightTitle.text = highlightMedia.title +// Glide.with(requireContext()).load(highlightMedia.posterUrl) +// .into(binding.imageHighlight) +// +// if (StorageController.myList.contains(0)) { +// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) +// } else { +// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) +// } +// } } private fun initRecyclerViews() { @@ -75,40 +72,42 @@ class HomeFragment : Fragment() { adapterMyList = MediaItemAdapter(mapMyListToItemMedia()) binding.recyclerMyList.adapter = adapterMyList + // TODO // new episodes - adapterNewEpisodes = MediaItemAdapter(AoDParser.newEpisodesList) - binding.recyclerNewEpisodes.adapter = adapterNewEpisodes +// adapterNewEpisodes = MediaItemAdapter(AoDParser.newEpisodesList) +// binding.recyclerNewEpisodes.adapter = adapterNewEpisodes // new simulcasts - adapterNewSimulcasts = MediaItemAdapter(AoDParser.newSimulcastsList) - binding.recyclerNewSimulcasts.adapter = adapterNewSimulcasts +// adapterNewSimulcasts = MediaItemAdapter(AoDParser.newSimulcastsList) +// binding.recyclerNewSimulcasts.adapter = adapterNewSimulcasts // new titles - adapterNewTitles = MediaItemAdapter(AoDParser.newTitlesList) - binding.recyclerNewTitles.adapter = adapterNewTitles +// adapterNewTitles = MediaItemAdapter(AoDParser.newTitlesList) +// binding.recyclerNewTitles.adapter = adapterNewTitles // top ten - adapterTopTen = MediaItemAdapter(AoDParser.topTenList) - binding.recyclerTopTen.adapter = adapterTopTen +// adapterTopTen = MediaItemAdapter(AoDParser.topTenList) +// binding.recyclerTopTen.adapter = adapterTopTen } private fun initActions() { binding.buttonPlayHighlight.setOnClickListener { // TODO get next episode lifecycleScope.launch { - val media = AoDParser.getMediaById(highlightMedia.id) + // TODO + //val media = AoDParser.getMediaById(0) - Log.d(javaClass.name, "Starting Player with mediaId: ${media.aodId}") - //(activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId) // TODO + // Log.d(javaClass.name, "Starting Player with mediaId: ${media.aodId}") + //(activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId) } } binding.textHighlightMyList.setOnClickListener { - if (StorageController.myList.contains(highlightMedia.id)) { - StorageController.myList.remove(highlightMedia.id) + if (StorageController.myList.contains(0)) { + StorageController.myList.remove(0) binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) } else { - StorageController.myList.add(highlightMedia.id) + StorageController.myList.add(0) binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) } StorageController.saveMyList(requireContext()) @@ -124,21 +123,21 @@ class HomeFragment : Fragment() { activity?.showFragment(MediaFragment("")) //(mediaId)) } - adapterNewEpisodes.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment("")) //(mediaId)) - } - - adapterNewSimulcasts.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment("")) //(mediaId)) - } - - adapterNewTitles.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment("")) //(mediaId)) - } - - adapterTopTen.onItemClick = { id, _ -> - activity?.showFragment(MediaFragment("")) //(mediaId)) - } +// adapterNewEpisodes.onItemClick = { id, _ -> +// activity?.showFragment(MediaFragment("")) //(mediaId)) +// } +// +// adapterNewSimulcasts.onItemClick = { id, _ -> +// activity?.showFragment(MediaFragment("")) //(mediaId)) +// } +// +// adapterNewTitles.onItemClick = { id, _ -> +// activity?.showFragment(MediaFragment("")) //(mediaId)) +// } +// +// adapterTopTen.onItemClick = { id, _ -> +// activity?.showFragment(MediaFragment("")) //(mediaId)) +// } } /** @@ -153,14 +152,15 @@ class HomeFragment : Fragment() { } private fun mapMyListToItemMedia(): List { - return StorageController.myList.mapNotNull { elementId -> - 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.") - } - } - } + return emptyList() +// return StorageController.myList.mapNotNull { elementId -> +// AoDParser.guiMediaList.firstOrNull { it.id == elementId.toString() }.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.") +// } +// } +// } } } \ No newline at end of file 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 cabe2b8..00f992c 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 @@ -6,9 +6,10 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.launch import org.mosad.teapod.databinding.FragmentLibraryBinding -import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.parser.crunchyroll.Crunchyroll import org.mosad.teapod.util.ItemMedia import org.mosad.teapod.util.adapter.MediaItemAdapter @@ -20,6 +21,10 @@ class LibraryFragment : Fragment() { private lateinit var binding: FragmentLibraryBinding private lateinit var adapter: MediaItemAdapter + private val itemList = arrayListOf() + private val pageSize = 30 + private var nextItemIndex = 0 + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentLibraryBinding.inflate(inflater, container, false) return binding.root @@ -32,22 +37,55 @@ class LibraryFragment : Fragment() { lifecycleScope.launch { // create and set the adapter, needs context context?.let { - // crunchy testing TODO implement lazy loading - val results = Crunchyroll.browse(n = 50) - val list = results.items.mapIndexed { index, item -> - ItemMedia(index, item.title, item.images.poster_wide[0][0].source, idStr = item.id) - } + val initialResults = Crunchyroll.browse(n = pageSize) + itemList.addAll(initialResults.items.map { item -> + ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) + }) + nextItemIndex += pageSize - - adapter = MediaItemAdapter(list) + adapter = MediaItemAdapter(itemList) adapter.onItemClick = { mediaIdStr, _ -> activity?.showFragment(MediaFragment(mediaIdStr = mediaIdStr)) } binding.recyclerMediaLibrary.adapter = adapter binding.recyclerMediaLibrary.addItemDecoration(MediaItemDecoration(9)) + // TODO replace with pagination3 + // https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797 + binding.recyclerMediaLibrary.addOnScrollListener(PaginationScrollListener()) } } } + + inner class PaginationScrollListener: RecyclerView.OnScrollListener() { + private var isLoading = false + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + val layoutManager = recyclerView.layoutManager as GridLayoutManager? + + if (!isLoading) layoutManager?.let { + // itemList.size - 5 to start loading a bit earlier than the actual end + if (layoutManager.findLastCompletelyVisibleItemPosition() >= (itemList.size - 5)) { + // load new browse results async + isLoading = true + lifecycleScope.launch { + val firstNewItemIndex = itemList.lastIndex + 1 + val results = Crunchyroll.browse(start = nextItemIndex, n = pageSize) + itemList.addAll(results.items.map { item -> + ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) + }) + nextItemIndex += pageSize + + adapter.updateMediaList(itemList) + adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize) + + isLoading = false + } + } + } + } + } + } \ No newline at end of file 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 08ea2ac..29d9bcd 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 @@ -9,7 +9,6 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.mosad.teapod.databinding.FragmentSearchBinding -import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.util.decoration.MediaItemDecoration import org.mosad.teapod.util.adapter.MediaItemAdapter import org.mosad.teapod.util.showFragment @@ -30,7 +29,7 @@ class SearchFragment : Fragment() { lifecycleScope.launch { // create and set the adapter, needs context context?.let { - adapter = MediaItemAdapter(AoDParser.guiMediaList) + adapter = MediaItemAdapter(emptyList()) // TODO 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 f6695b1..d422ca1 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt @@ -3,9 +3,7 @@ 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.parser.crunchyroll.* -import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.util.* import org.mosad.teapod.util.DataTypes.MediaType import org.mosad.teapod.util.tmdb.TMDBApiController @@ -50,11 +48,12 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic // load seasons seasonsCrunchy = Crunchyroll.seasons(crunchyId) - println("media: $seasonsCrunchy") + println("seasons: $seasonsCrunchy") // load first season + // TODO make sure to load the preferred season (language), language is set per season, not per stream episodesCrunchy = Crunchyroll.episodes(seasonsCrunchy.items.first().id) - println("media: $episodesCrunchy") + println("episodes: $episodesCrunchy") @@ -75,47 +74,47 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic * set media, tmdb and nextEpisode * TODO run aod and tmdb load parallel */ - suspend fun loadAoD(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 = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first() - nextEpisodeId = media.playlist.firstOrNull { !it.watched }?.mediaId - ?: media.playlist.first().mediaId - } - } +// suspend fun loadAoD(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 = 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 episodeId diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt index 6a329be..9f7a060 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt @@ -7,9 +7,7 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.* -import org.mosad.teapod.R import org.mosad.teapod.databinding.FragmentOnLoginBinding -import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.preferences.EncryptedPreferences class OnLoginFragment: Fragment() { @@ -37,17 +35,18 @@ class OnLoginFragment: Fragment() { binding.buttonLogin.isClickable = false loginJob = lifecycleScope.launch { - if (AoDParser.login()) { - // if login was successful, switch to main - if (activity is OnboardingActivity) { - (activity as OnboardingActivity).launchMainActivity() - } - } else { - withContext(Dispatchers.Main) { - binding.textLoginDesc.text = getString(R.string.on_login_failed) - binding.buttonLogin.isClickable = true - } - } + // TODO +// if (AoDParser.login()) { +// // if login was successful, switch to main +// if (activity is OnboardingActivity) { +// (activity as OnboardingActivity).launchMainActivity() +// } +// } else { +// withContext(Dispatchers.Main) { +// binding.textLoginDesc.text = getString(R.string.on_login_failed) +// binding.buttonLogin.isClickable = true +// } +// } } } } 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 bcc63b2..cf6082d 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 @@ -172,7 +172,7 @@ class PlayerActivity : AppCompatActivity() { } private fun initPlayer() { - if (model.currentEpisode.equals(NoneEpisode)) { + if (model.currentEpisode == NoneEpisode) { Log.e(javaClass.name, "No media was set.") this.finish() } @@ -207,7 +207,7 @@ class PlayerActivity : AppCompatActivity() { else -> View.VISIBLE } - if (state == ExoPlayer.STATE_ENDED && model.currentEpisodeCr.nextEpisodeId != null && Preferences.autoplay) { + if (state == ExoPlayer.STATE_ENDED && model.currentEpisode.nextEpisodeId != null && Preferences.autoplay) { playNextEpisode() } } @@ -279,7 +279,7 @@ class PlayerActivity : AppCompatActivity() { // 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 (!btnNextEpIsVisible && model.currentEpisodeCr.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) { + if (!btnNextEpIsVisible && model.currentEpisode.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) { showButtonNextEp() } } else if (btnNextEpIsVisible) { @@ -337,7 +337,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.currentEpisodeCr.nextEpisodeId == null) { + button_next_ep_c.visibility = if (model.currentEpisode.nextEpisodeId == null) { View.GONE } else { View.VISIBLE 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 795707b..c812342 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 @@ -18,7 +18,6 @@ import org.mosad.teapod.parser.crunchyroll.NoneEpisode import org.mosad.teapod.parser.crunchyroll.NoneEpisodes import org.mosad.teapod.parser.crunchyroll.NonePlayback import org.mosad.teapod.preferences.Preferences -import org.mosad.teapod.util.AoDEpisodeNone import org.mosad.teapod.util.EpisodeMeta import org.mosad.teapod.util.Meta import org.mosad.teapod.util.TVShowMeta @@ -40,27 +39,25 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) val currentEpisodeChangedListener = ArrayList<() -> Unit>() private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN -// var media: AoDMedia = AoDMediaNone -// internal set + // tmdb/meta data TODO currently not implemented for cr var mediaMeta: Meta? = null internal set var tmdbTVSeason: TMDBTVSeason? =null internal set - var currentEpisode = AoDEpisodeNone - internal set var currentEpisodeMeta: EpisodeMeta? = null internal set -// var nextEpisodeId: Int? = null -// internal set + + // crunchyroll episodes/playback + var episodes = NoneEpisodes + internal set + var currentEpisode = NoneEpisode + internal set + private var currentPlayback = NonePlayback + + // current playback settings var currentLanguage: Locale = Locale.ROOT internal set - var episodesCrunchy = NoneEpisodes - internal set - var currentEpisodeCr = NoneEpisode - internal set - private var currentPlaybackCr = NonePlayback - init { initMediaSession() } @@ -87,16 +84,16 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun loadMedia(seasonId: String, episodeId: String) { runBlocking { - episodesCrunchy = Crunchyroll.episodes(seasonId) + episodes = Crunchyroll.episodes(seasonId) //mediaMeta = loadMediaMeta(media.aodId) // can be done blocking, since it should be cached // TODO replace this with setCurrentEpisode - currentEpisodeCr = episodesCrunchy.items.find { episode -> + currentEpisode = episodes.items.find { episode -> episode.id == episodeId } ?: NoneEpisode - println("loading playback ${currentEpisodeCr.playback}") + println("loading playback ${currentEpisode.playback}") - currentPlaybackCr = Crunchyroll.playback(currentEpisodeCr.playback) + currentPlayback = Crunchyroll.playback(currentEpisode.playback) } // TODO reimplement for cr @@ -108,9 +105,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) // tmdbTVSeason = TMDBApiController().getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) // } // } - - currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId) - currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language +// +// currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisodeAoD.mediaId) +// currentLanguage = currentEpisodeAoD.getPreferredStream(preferredLanguage).language } fun setLanguage(language: Locale) { @@ -118,7 +115,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) playCurrentMedia(player.currentPosition) // val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( -// MediaItem.fromUri(Uri.parse(currentEpisode.getPreferredStream(language).url)) +// MediaItem.fromUri(Uri.parse(currentEpisodeAoD.getPreferredStream(language).url)) // ) // playMedia(mediaSource, seekTime) } @@ -134,9 +131,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } /** - * play the next episode, if nextEpisode is not null + * play the next episode, if nextEpisodeId is not null */ - fun playNextEpisode() = currentEpisodeCr.nextEpisodeId?.let { nextEpisodeId -> + fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId -> setCurrentEpisode(nextEpisodeId, startPlayback = true) } @@ -145,13 +142,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) * @param episodeId The ID of the episode you want to set currentEpisodeCr to */ fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) { - currentEpisodeCr = episodesCrunchy.items.find { episode -> + currentEpisode = episodes.items.find { episode -> episode.id == episodeId } ?: NoneEpisode // TODO don't run blocking runBlocking { - currentPlaybackCr = Crunchyroll.playback(currentEpisodeCr.playback) + currentPlayback = Crunchyroll.playback(currentEpisode.playback) } // TODO update metadata and language (it should not be needed to update the language here!) @@ -171,7 +168,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) currentEpisodeChangedListener.forEach { it() } // get preferred stream url TODO implement - val url = currentPlaybackCr.streams.adaptive_hls["en-US"]?.url ?: "" + val url = currentPlayback.streams.adaptive_hls["en-US"]?.url ?: "" println("stream url: $url") // create the media source object @@ -194,12 +191,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) return if(isTVShow) { getApplication().getString( R.string.component_episode_title, - currentEpisodeCr.episode, - currentEpisodeCr.title + currentEpisode.episode, + currentEpisode.title ) } else { // TODO movie - currentEpisodeCr.title + currentEpisode.title } } @@ -223,17 +220,4 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) return null } - /** - * TODO reimplement for cr - * 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 - - return null - } - } \ 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 cb654e9..fd139aa 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,16 @@ class EpisodesListPlayer @JvmOverloads constructor( } model?.let { - adapterRecEpisodes = PlayerEpisodeItemAdapter(model.episodesCrunchy, model.tmdbTVSeason?.episodes) + adapterRecEpisodes = PlayerEpisodeItemAdapter(model.episodes, model.tmdbTVSeason?.episodes) adapterRecEpisodes.onImageClick = {_, episodeId -> (this.parent as ViewGroup).removeView(this) model.setCurrentEpisode(episodeId, startPlayback = true) } // episodeNumber starts at 1, we need the episode index -> - 1 - adapterRecEpisodes.currentSelected = (model.currentEpisodeCr.episodeNumber - 1) + adapterRecEpisodes.currentSelected = (model.currentEpisode.episodeNumber - 1) binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes - binding.recyclerEpisodesPlayer.scrollToPosition(model.currentEpisode.index) + binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected) } } diff --git a/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt b/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt index 404ba7e..8c90188 100644 --- a/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt +++ b/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt @@ -30,12 +30,13 @@ class LanguageSettingsPlayer @JvmOverloads constructor( init { model?.let { - model.currentEpisode.streams.forEach { stream -> - addLanguage(stream.language.displayName, stream.language == currentLanguage) { - currentLanguage = stream.language - updateSelectedLanguage(it as TextView) - } - } + // TODO reimplement for cr +// it.currentEpisode.streams.forEach { stream -> +// addLanguage(stream.language.displayName, stream.language == currentLanguage) { +// currentLanguage = stream.language +// updateSelectedLanguage(it as TextView) +// } +// } } binding.buttonCloseLanguageSettings.setOnClickListener { close() } 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 7e93be0..280cf1d 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -35,10 +35,9 @@ data class ThirdPartyComponent( * it is uses in the ItemMediaAdapter (RecyclerView) */ data class ItemMedia( - val id: Int, // aod path id + val id: String, val title: String, val posterUrl: String, - val idStr: String = "" // crunchyroll id ) // TODO replace playlist: List with a map? diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt index f5b862c..63747d4 100644 --- a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt +++ b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt @@ -12,7 +12,7 @@ import java.util.* class MediaItemAdapter(private val initMedia: List) : RecyclerView.Adapter(), Filterable { - var onItemClick: ((String, Int) -> Unit)? = null + var onItemClick: ((id: String, position: Int) -> Unit)? = null private val filter = MediaFilter() private var filteredMedia = initMedia.map { it.copy() } @@ -39,10 +39,14 @@ class MediaItemAdapter(private val initMedia: List) : RecyclerView.Ad filteredMedia = mediaList } - inner class MediaViewHolder(val binding: ItemMediaBinding) : RecyclerView.ViewHolder(binding.root) { + inner class MediaViewHolder(val binding: ItemMediaBinding) : + RecyclerView.ViewHolder(binding.root) { init { binding.root.setOnClickListener { - onItemClick?.invoke(filteredMedia[adapterPosition].idStr, adapterPosition) + onItemClick?.invoke( + filteredMedia[bindingAdapterPosition].id, + bindingAdapterPosition + ) } } } From 4fd6f9ca7e9f43bafb86e44dce3371b7d4f748ac Mon Sep 17 00:00:00 2001 From: Jannik Date: Mon, 27 Dec 2021 22:50:29 +0100 Subject: [PATCH 027/133] add search for tv shows media items are currently not selectable, the app will crash --- .../teapod/parser/crunchyroll/Crunchyroll.kt | 18 +++-- .../teapod/parser/crunchyroll/DataTypes.kt | 22 +++++- .../teapod/ui/activity/main/MainActivity.kt | 12 ++- .../activity/main/fragments/HomeFragment.kt | 4 +- .../main/fragments/LibraryFragment.kt | 4 +- .../activity/main/fragments/SearchFragment.kt | 75 ++++++++++++++++--- .../main/viewmodel/MediaFragmentViewModel.kt | 3 +- .../teapod/util/adapter/MediaItemAdapter.kt | 52 ++----------- 8 files changed, 112 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index f1d734a..21f9e64 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -134,18 +134,20 @@ object Crunchyroll { return browseResult } - // // TODO locale de-DE, type - suspend fun search(query: String, n: Int = 10) { + /** + * TODO + */ + suspend fun search(query: String, n: Int = 10): SearchResult { val searchEndpoint = "/content/v1/search" - val parameters = listOf("q" to query, "n" to n) + val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series") val result = request(searchEndpoint, parameters) - println("${result.component1()?.obj()?.get("total")}") + // TODO episodes have thumbnails as image, and not poster_tall/poster_tall, + // to work around this, for now only tv shows are supported - val test = json.decodeFromString(result.component1()?.obj()?.toString()!!) - println(test.items.size) - - // TODO return + return result.component1()?.obj()?.let { + json.decodeFromString(it.toString()) + } ?: NoneSearchResult } /** diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt index 6086b10..1f9edba 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt @@ -13,9 +13,29 @@ enum class SortBy(val str: String) { POPULARITY("popularity") } +/** + * Search data type + */ +@Serializable +data class SearchResult( + @SerialName("total") val total: Int, + @SerialName("items") val items: List +) + +@Serializable +data class SearchCollection( + @SerialName("type") val type: String, + @SerialName("items") val items: List +) + +val NoneSearchResult = SearchResult(0, emptyList()) + + + @Serializable data class BrowseResult(val total: Int, val items: List) +// the data class Item is used in browse and search @Serializable data class Item( val id: String, @@ -38,7 +58,7 @@ data class Images(val poster_tall: List>, val poster_wide: List { 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 00f992c..58f5ec0 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 @@ -45,7 +45,7 @@ class LibraryFragment : Fragment() { adapter = MediaItemAdapter(itemList) adapter.onItemClick = { mediaIdStr, _ -> - activity?.showFragment(MediaFragment(mediaIdStr = mediaIdStr)) + activity?.showFragment(MediaFragment(mediaIdStr)) } binding.recyclerMediaLibrary.adapter = adapter @@ -78,9 +78,7 @@ class LibraryFragment : Fragment() { }) nextItemIndex += pageSize - adapter.updateMediaList(itemList) adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize) - isLoading = false } } 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 29d9bcd..ca924d4 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 @@ -7,16 +7,24 @@ import android.view.ViewGroup import android.widget.SearchView import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.launch import org.mosad.teapod.databinding.FragmentSearchBinding -import org.mosad.teapod.util.decoration.MediaItemDecoration +import org.mosad.teapod.parser.crunchyroll.Crunchyroll +import org.mosad.teapod.util.ItemMedia import org.mosad.teapod.util.adapter.MediaItemAdapter +import org.mosad.teapod.util.decoration.MediaItemDecoration import org.mosad.teapod.util.showFragment class SearchFragment : Fragment() { private lateinit var binding: FragmentSearchBinding - private var adapter : MediaItemAdapter? = null + private lateinit var adapter: MediaItemAdapter + + private val itemList = arrayListOf() + private var searchJob: Job? = null + private var oldSearchQuery = "" override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentSearchBinding.inflate(inflater, container, false) @@ -29,10 +37,10 @@ class SearchFragment : Fragment() { lifecycleScope.launch { // create and set the adapter, needs context context?.let { - adapter = MediaItemAdapter(emptyList()) // TODO - adapter!!.onItemClick = { mediaId, _ -> + adapter = MediaItemAdapter(itemList) + adapter.onItemClick = { mediaIdStr, _ -> binding.searchText.clearFocus() - activity?.showFragment(MediaFragment("")) //(mediaId)) + activity?.showFragment(MediaFragment(mediaIdStr)) } binding.recyclerMediaSearch.adapter = adapter @@ -46,16 +54,65 @@ class SearchFragment : Fragment() { private fun initActions() { binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { - adapter?.filter?.filter(query) - adapter?.notifyDataSetChanged() + query?.let { search(it) } return false } override fun onQueryTextChange(newText: String?): Boolean { - adapter?.filter?.filter(newText) - adapter?.notifyDataSetChanged() + newText?.let { search(it) } return false } }) } + + private fun search(query: String) { + // if the query hasn't changed since the last successful search, return + if (query == oldSearchQuery) return + + // cancel search job if one is already running + if (searchJob?.isActive == true) searchJob?.cancel() + + searchJob = lifecycleScope.async { + // TODO maybe wait a few ms (500ms?) before searching, if the user inputs any other chars + val results = Crunchyroll.search(query, 50) + + itemList.clear() // TODO needs clean up + + // TODO add top results first heading + itemList.addAll(results.items[0].items.map { item -> + ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) + }) + + // TODO currently only tv shows are supported, hence only the first items array + // should be always present + +// // TODO add tv shows heading +// if (results.items.size >= 2) { +// itemList.addAll(results.items[1].items.map { item -> +// ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) +// }) +// } +// +// // TODO add movies heading +// if (results.items.size >= 3) { +// itemList.addAll(results.items[2].items.map { item -> +// ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) +// }) +// } +// +// // TODO add episodes heading +// if (results.items.size >= 4) { +// itemList.addAll(results.items[3].items.map { item -> +// ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) +// }) +// } + + adapter.notifyDataSetChanged() + //adapter.notifyItemRangeInserted(0, itemList.size) + + // after successfully searching the query term, add it as old query, to make sure we + // don't search again if the query hasn't changed + oldSearchQuery = query + } + } } \ No newline at end of file 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 d422ca1..ac73a6e 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 @@ -41,6 +41,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic println("loading crunchyroll media $crunchyId") // TODO info also in browse result item + // TODO doesn't support search mediaCrunchy = Crunchyroll.browsingCache.find { it -> it.id == crunchyId } ?: NoneItem @@ -61,7 +62,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic // use tmdb search to get media info TODO media type is hardcoded, use type info from browse result once implemented mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media - val tmdbId = tmdbApiController.search(stripTitleInfo(mediaCrunchy.title), MediaType.TVSHOW) + val tmdbId = tmdbApiController.search(mediaCrunchy.title, MediaType.TVSHOW) tmdbResult = when (MediaType.TVSHOW) { MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt index 63747d4..1097426 100644 --- a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt +++ b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt @@ -2,19 +2,14 @@ package org.mosad.teapod.util.adapter import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.Filter -import android.widget.Filterable import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import org.mosad.teapod.databinding.ItemMediaBinding import org.mosad.teapod.util.ItemMedia -import java.util.* -class MediaItemAdapter(private val initMedia: List) : RecyclerView.Adapter(), Filterable { +class MediaItemAdapter(private val items: List) : RecyclerView.Adapter() { var onItemClick: ((id: String, position: Int) -> Unit)? = null - private val filter = MediaFilter() - private var filteredMedia = initMedia.map { it.copy() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaItemAdapter.MediaViewHolder { return MediaViewHolder(ItemMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false)) @@ -22,21 +17,13 @@ class MediaItemAdapter(private val initMedia: List) : RecyclerView.Ad override fun onBindViewHolder(holder: MediaItemAdapter.MediaViewHolder, position: Int) { holder.binding.root.apply { - holder.binding.textTitle.text = filteredMedia[position].title - Glide.with(context).load(filteredMedia[position].posterUrl).into(holder.binding.imagePoster) + holder.binding.textTitle.text = items[position].title + Glide.with(context).load(items[position].posterUrl).into(holder.binding.imagePoster) } } override fun getItemCount(): Int { - return filteredMedia.size - } - - override fun getFilter(): Filter { - return filter - } - - fun updateMediaList(mediaList: List) { - filteredMedia = mediaList + return items.size } inner class MediaViewHolder(val binding: ItemMediaBinding) : @@ -44,40 +31,11 @@ class MediaItemAdapter(private val initMedia: List) : RecyclerView.Ad init { binding.root.setOnClickListener { onItemClick?.invoke( - filteredMedia[bindingAdapterPosition].id, + items[bindingAdapterPosition].id, bindingAdapterPosition ) } } } - inner class MediaFilter : Filter() { - override fun performFiltering(constraint: CharSequence?): FilterResults { - val filterTerm = constraint.toString().lowercase(Locale.ROOT) - val results = FilterResults() - - val filteredList = if (filterTerm.isEmpty()) { - initMedia - } else { - initMedia.filter { - it.title.lowercase(Locale.ROOT).contains(filterTerm) - } - } - - results.values = filteredList - results.count = filteredList.size - - return results - } - - @Suppress("unchecked_cast") - /** - * suppressing unchecked cast is safe, since we only use Media - */ - override fun publishResults(constraint: CharSequence?, results: FilterResults?) { - filteredMedia = results?.values as List - notifyDataSetChanged() - } - } - } \ No newline at end of file From ecbbc5db7bbb698596296ed3ae61fec7fd4499ae Mon Sep 17 00:00:00 2001 From: Jannik Date: Tue, 28 Dec 2021 20:32:44 +0100 Subject: [PATCH 028/133] implement preferred season/languag choosing in MediaFragment --- .../teapod/parser/crunchyroll/Crunchyroll.kt | 8 +- .../teapod/parser/crunchyroll/DataTypes.kt | 38 +++- .../mosad/teapod/preferences/Preferences.kt | 3 + .../activity/main/fragments/MediaFragment.kt | 169 ++++++++++-------- .../main/fragments/MediaFragmentEpisodes.kt | 9 +- .../main/fragments/MediaFragmentSimilar.kt | 2 +- .../main/viewmodel/MediaFragmentViewModel.kt | 60 ++++--- .../ui/activity/player/PlayerViewModel.kt | 4 +- .../ui/components/EpisodesListPlayer.kt | 2 +- 9 files changed, 171 insertions(+), 124 deletions(-) diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index 21f9e64..1fc542f 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -8,9 +8,9 @@ import com.github.kittinunf.fuel.json.FuelJson import com.github.kittinunf.fuel.json.responseJson import com.github.kittinunf.result.Result import kotlinx.coroutines.* -import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import org.mosad.teapod.preferences.Preferences import java.util.* private val json = Json { ignoreUnknownKeys = true } @@ -27,10 +27,10 @@ object Crunchyroll { private var keyPairID = "" // TODO temp helper vary - var locale = "${Locale.GERMANY.language}-${Locale.GERMANY.country}" - var country = Locale.GERMANY.country + private var locale: String = Preferences.preferredLocal.toLanguageTag() + private var country: String = Preferences.preferredLocal.country - val browsingCache = arrayListOf() + private val browsingCache = arrayListOf() fun login(username: String, password: String): Boolean = runBlocking { val tokenEndpoint = "/auth/v1/token" diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt index 1f9edba..4ea22dd 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt @@ -2,6 +2,7 @@ package org.mosad.teapod.parser.crunchyroll import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.util.* /** * data classes for browse @@ -36,6 +37,7 @@ val NoneSearchResult = SearchResult(0, emptyList()) data class BrowseResult(val total: Int, val items: List) // the data class Item is used in browse and search +// TODO rename to MediaPanel @Serializable data class Item( val id: String, @@ -74,14 +76,38 @@ val NoneSeries = Series("", "", "", Images(listOf(), listOf())) * Seasons data type */ @Serializable -data class Seasons(val total: Int, val items: List) +data class Seasons( + val total: Int, + val items: List +) { + 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 data class Season( - val id: String, - val title: String, - val series_id: String, - val season_number: Int + @SerialName("id") val id: String, + @SerialName("title") val title: String, + @SerialName("series_id") val seriesId: String, + @SerialName("season_number") val seasonNumber: Int, + @SerialName("is_subbed") val isSubbed: Boolean, + @SerialName("is_dubbed") val isDubbed: Boolean, ) val NoneSeasons = Seasons(0, listOf()) @@ -101,7 +127,7 @@ data class Episode( @SerialName("season_id") val seasonId: String, @SerialName("season_number") val seasonNumber: Int, @SerialName("episode") val episode: String, - @SerialName("episode_number") val episodeNumber: Int, + @SerialName("episode_number") val episodeNumber: Int? = null, @SerialName("description") val description: String, @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 diff --git a/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt b/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt index b5c1d60..96440ca 100644 --- a/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt +++ b/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt @@ -4,11 +4,14 @@ import android.content.Context import android.content.SharedPreferences import org.mosad.teapod.R import org.mosad.teapod.util.DataTypes +import java.util.* object Preferences { var preferSecondary = false internal set + var preferredLocal = Locale.GERMANY + internal set var autoplay = true internal set var devSettings = false diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt index 87408e7..f9caed4 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 @@ -15,6 +15,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import com.bumptech.glide.Glide import com.bumptech.glide.request.RequestOptions import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.tabs.TabLayoutMediator import jp.wasabeef.glide.transformations.BlurTransformation import kotlinx.coroutines.launch 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 binding.pagerEpisodesSimilar.offscreenPageLimit = 2 binding.pagerEpisodesSimilar.adapter = pagerAdapter - // TODO implement for cr media items -// TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> -// tab.text = if (model.media.type == MediaType.TVSHOW && position == 0) { -// getString(R.string.episodes) -// } else { -// getString(R.string.similar_titles) -// } -// }.attach() + // TODO is position 0 always episodes? (and 1 always similar titles) + TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> + tab.text = when(position) { + 0 -> getString(R.string.episodes) + 1 -> getString(R.string.similar_titles) + else -> "" + } + }.attach() lifecycleScope.launch { model.loadCrunchy(mediaIdStr) @@ -77,9 +78,10 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : super.onResume() // update the next ep text if there is one, since it may have changed - if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { - binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title - } + // TODO reimplement +// 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) { // generic gui 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 } - ?: mediaCrunchy.images.poster_tall[0][2].source + ?: seriesCrunchy.images.poster_tall[0][2].source // load poster and backdrop Glide.with(requireContext()).load(posterUrl) @@ -100,65 +102,74 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) .into(binding.imageBackdrop) - binding.textTitle.text = mediaCrunchy.title - //binding.textYear.text = media.year.toString() // TODO - //binding.textAge.text = media.age.toString() // TODO - binding.textOverview.text = mediaCrunchy.description + binding.textTitle.text = seriesCrunchy.title + //binding.textYear.text = media.year.toString() // TODO get from tmdb + //binding.textAge.text = media.age.toString() // TODO get from tmdb + binding.textOverview.text = seriesCrunchy.description // TODO 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) - } +// 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.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 -// 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 - } + // add the episodes fragment (as tab) + MediaFragmentEpisodes().also { + fragments.add(it) + pagerAdapter.notifyItemInserted(fragments.indexOf(it)) } + // 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 (media.similar.isNotEmpty()) { - MediaFragmentSimilar().also { - fragments.add(it) - pagerAdapter.notifyItemInserted(fragments.indexOf(it)) - } - } + // TODO reimplement +// if (media.similar.isNotEmpty()) { +// MediaFragmentSimilar().also { +// fragments.add(it) +// pagerAdapter.notifyItemInserted(fragments.indexOf(it)) +// } +// } // disable scrolling on appbar, if no tabs where added if(fragments.isEmpty()) { @@ -171,28 +182,30 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : private fun initActions() = with(model) { binding.buttonPlay.setOnClickListener { - when (media.type) { - //MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) // TODO - //MediaType.TVSHOW -> playEpisode(nextEpisodeId) // TODO - else -> Log.e(javaClass.name, "Wrong Type: ${media.type}") - } + // TODO reimplement +// when (media.type) { +// MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) +// MediaType.TVSHOW -> playEpisode(nextEpisodeId) +// else -> Log.e(javaClass.name, "Wrong Type: ${media.type}") +// } } // add or remove media from myList binding.linearMyListAction.setOnClickListener { - 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.aodId) - Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) - } - StorageController.saveMyList(requireContext()) - - // notify home fragment on change - parentFragmentManager.findFragmentByTag("HomeFragment")?.let { - (it as HomeFragment).updateMyListMedia() - } + // TODO reimplement +// 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.aodId) +// Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) +// } +// StorageController.saveMyList(requireContext()) +// +// // notify home fragment on change +// parentFragmentManager.findFragmentByTag("HomeFragment")?.let { +// (it as HomeFragment).updateMyListMedia() +// } } } 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 a0985ce..0d47b65 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 @@ -44,10 +44,11 @@ class MediaFragmentEpisodes : Fragment() { // if adapterRecEpisodes is initialized, update the watched state for the episodes if (this::adapterRecEpisodes.isInitialized) { - model.media.playlist.forEachIndexed { index, episodeInfo -> - adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index) - } - adapterRecEpisodes.notifyDataSetChanged() + // TODO reimplement, if needed +// model.media.playlist.forEachIndexed { index, episodeInfo -> +// adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index) +// } +// adapterRecEpisodes.notifyDataSetChanged() } } 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 c57770b..052ec89 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.similar) + adapterSimilar = MediaItemAdapter(emptyList()) //(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/viewmodel/MediaFragmentViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt index ac73a6e..b3a26fb 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt @@ -3,9 +3,16 @@ 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.crunchyroll.* -import org.mosad.teapod.util.* +import androidx.lifecycle.viewModelScope +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.Meta import org.mosad.teapod.util.tmdb.TMDBApiController import org.mosad.teapod.util.tmdb.TMDBResult import org.mosad.teapod.util.tmdb.TMDBTVSeason @@ -16,12 +23,9 @@ import org.mosad.teapod.util.tmdb.TMDBTVSeason */ class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { - var media = AoDMediaNone - internal set - var nextEpisodeId = -1 - internal set - - var mediaCrunchy = NoneItem +// var mediaCrunchy = NoneItem +// internal set + var seriesCrunchy = NoneSeries // TODO it seems movies also series? internal set var seasonsCrunchy = NoneSeasons internal set @@ -35,34 +39,31 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic var mediaMeta: Meta? = null internal set + /** + * @param crunchyId the crunchyroll series id + */ suspend fun loadCrunchy(crunchyId: String) { 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 - // TODO doesn't support search - mediaCrunchy = Crunchyroll.browsingCache.find { it -> - it.id == crunchyId - } ?: NoneItem - println("media: $mediaCrunchy") - - // load seasons - seasonsCrunchy = Crunchyroll.seasons(crunchyId) + println("series: $seriesCrunchy") println("seasons: $seasonsCrunchy") - // load first season - // TODO make sure to load the preferred season (language), language is set per season, not per stream - episodesCrunchy = Crunchyroll.episodes(seasonsCrunchy.items.first().id) + // load the preferred season (preferred language, language per season, not per stream) + val preferredSeasonId = seasonsCrunchy.getPreferredSeasonId(Preferences.preferredLocal) + episodesCrunchy = Crunchyroll.episodes(preferredSeasonId) println("episodes: $episodesCrunchy") - - // 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 - val tmdbId = tmdbApiController.search(mediaCrunchy.title, MediaType.TVSHOW) + val tmdbId = tmdbApiController.search(seriesCrunchy.title, MediaType.TVSHOW) tmdbResult = when (MediaType.TVSHOW) { MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) @@ -122,10 +123,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic * if no matching is found, use first episode */ fun updateNextEpisode(episodeId: Int) { - if (media.type == MediaType.MOVIE) return // return if movie - - nextEpisodeId = media.playlist.firstOrNull { it.index > media.getEpisodeById(episodeId).index }?.mediaId - ?: media.playlist.first().mediaId + // 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 } // remove unneeded info from the media title before searching 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 c812342..615bea1 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 @@ -168,7 +168,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) currentEpisodeChangedListener.forEach { it() } // 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") // create the media source object 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 fd139aa..ce182f2 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 @@ -34,7 +34,7 @@ class EpisodesListPlayer @JvmOverloads constructor( model.setCurrentEpisode(episodeId, startPlayback = true) } // 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.scrollToPosition(adapterRecEpisodes.currentSelected) From f97d07c2b8e6e3f6146595b1c35a8717fcf87585 Mon Sep 17 00:00:00 2001 From: Jannik Date: Wed, 29 Dec 2021 19:36:33 +0100 Subject: [PATCH 029/133] implement season selection in MediaFragment --- app/build.gradle | 2 +- .../teapod/parser/crunchyroll/DataTypes.kt | 10 ++-- .../main/fragments/MediaFragmentEpisodes.kt | 53 ++++++++++++++++--- .../main/viewmodel/MediaFragmentViewModel.kt | 28 +++++++--- .../teapod/util/adapter/EpisodeItemAdapter.kt | 12 ++--- .../ic_baseline_arrow_drop_down_24.xml | 5 ++ .../res/layout/fragment_media_episodes.xml | 20 +++++-- app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 6 +++ 9 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml diff --git a/app/build.gradle b/app/build.gradle index 8a96898..0233747 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion 23 targetSdkVersion 30 versionCode 4200 //00.04.200 - versionName "1.0.0-alpha1" + versionName "1.0.0-alpha2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resValue "string", "build_time", buildTime() diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt index 4ea22dd..4d8720a 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/DataTypes.kt @@ -80,23 +80,23 @@ data class Seasons( val total: Int, val items: List ) { - fun getPreferredSeasonId(local: Locale): String { + fun getPreferredSeason(local: Locale): Season { // 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 + return season } } // if there is no season with the preferred local, try to find a subbed season items.forEach { season -> if (season.isSubbed) { - return season.id + return season } } // if there is no preferred language season and no sub, use the first season - return items.first().id + return items.first() } } @@ -111,6 +111,8 @@ data class Season( ) val NoneSeasons = Seasons(0, listOf()) +val NoneSeason = Season("", "", "", 0, false, false) + /** * Episodes data type 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 0d47b65..301385e 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 @@ -1,15 +1,19 @@ package org.mosad.teapod.ui.activity.main.fragments +import android.annotation.SuppressLint import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.mosad.teapod.databinding.FragmentMediaEpisodesBinding 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.adapter.EpisodeItemAdapter class MediaFragmentEpisodes : Fragment() { @@ -27,15 +31,17 @@ class MediaFragmentEpisodes : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapterRecEpisodes = EpisodeItemAdapter(model.episodesCrunchy, model.tmdbTVSeason?.episodes) + adapterRecEpisodes = EpisodeItemAdapter(model.currentEpisodesCrunchy, model.tmdbTVSeason?.episodes) binding.recyclerEpisodes.adapter = adapterRecEpisodes - // set onItemClick only in adapter is initialized - if (this::adapterRecEpisodes.isInitialized) { - adapterRecEpisodes.onImageClick = { seasonId, episodeId -> - println("TODO playback episode $episodeId (season: $seasonId)") - playEpisode(seasonId, episodeId) - } + // set onItemClick, adapter is initialized + adapterRecEpisodes.onImageClick = { seasonId, episodeId -> + playEpisode(seasonId, episodeId) + } + + binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title + binding.buttonSeasonSelection.setOnClickListener { v -> + showSeasonSelection(v) } } @@ -52,6 +58,37 @@ class MediaFragmentEpisodes : Fragment() { } } + private fun showSeasonSelection(v: View) { + // TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus + val popup = PopupMenu(requireContext(), v) + model.seasonsCrunchy.items.forEach { season -> + popup.menu.add(season.title).also { + it.setOnMenuItemClickListener { + onSeasonSelected(season.id) + false + } + } + } + + popup.show() + } + + /** + * Call model to load a new season. + * Once loaded update buttonSeasonSelection text and adapterRecEpisodes. + * + * Suppress waring since invalid. + */ + @SuppressLint("NotifyDataSetChanged") + private fun onSeasonSelected(seasonId: String) { + // load the new season + lifecycleScope.launch { + model.setCurrentSeason(seasonId) + binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title + adapterRecEpisodes.notifyDataSetChanged() + } + } + private fun playEpisode(seasonId: String, episodeId: String) { (activity as MainActivity).startPlayer(seasonId, episodeId) Log.d(javaClass.name, "Started Player with episodeId: $episodeId") 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 b3a26fb..112a71e 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 @@ -6,10 +6,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope 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.parser.crunchyroll.* import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.util.DataTypes.MediaType import org.mosad.teapod.util.Meta @@ -29,8 +26,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic internal set var seasonsCrunchy = NoneSeasons internal set + var currentSeasonCrunchy = NoneSeason + internal set var episodesCrunchy = NoneEpisodes internal set + val currentEpisodesCrunchy = arrayListOf() var tmdbResult: TMDBResult? = null // TODO rename internal set @@ -55,8 +55,9 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic println("seasons: $seasonsCrunchy") // load the preferred season (preferred language, language per season, not per stream) - val preferredSeasonId = seasonsCrunchy.getPreferredSeasonId(Preferences.preferredLocal) - episodesCrunchy = Crunchyroll.episodes(preferredSeasonId) + currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal) + episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) + currentEpisodesCrunchy.addAll(episodesCrunchy.items) println("episodes: $episodesCrunchy") // TODO check if metaDB knows the title @@ -72,6 +73,21 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic } } + suspend fun setCurrentSeason(seasonId: String) { + // return if the id hasn't changed (performance) + if (currentSeasonCrunchy.id == seasonId) return + + // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, + // don't change the current season (this should/can never happen) + currentSeasonCrunchy = seasonsCrunchy.items.firstOrNull { + it.id == seasonId + } ?: currentSeasonCrunchy + + episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) + currentEpisodesCrunchy.clear() + currentEpisodesCrunchy.addAll(episodesCrunchy.items) + } + /** * set media, tmdb and nextEpisode * TODO run aod and tmdb load parallel 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 7576d71..f90347c 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 @@ -10,10 +10,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.parser.crunchyroll.Episodes +import org.mosad.teapod.parser.crunchyroll.Episode import org.mosad.teapod.util.tmdb.TMDBTVEpisode -class EpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { +class EpisodeItemAdapter(private val episodes: List, private val tmdbEpisodes: List?) : RecyclerView.Adapter() { var onImageClick: ((seasonId: String, episodeId: String) -> Unit)? = null @@ -23,7 +23,7 @@ class EpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisode override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { val context = holder.binding.root.context - val ep = episodes.items[position] + val ep = episodes[position] val titleText = if (ep.isDubbed) { context.getString(R.string.component_episode_title, ep.episode, ep.title) @@ -61,7 +61,7 @@ class EpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisode } override fun getItemCount(): Int { - return episodes.items.size + return episodes.size } fun updateWatchedState(watched: Boolean, position: Int) { @@ -77,8 +77,8 @@ class EpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisode // on image click return the episode id and index (within the adapter) binding.imageEpisode.setOnClickListener { onImageClick?.invoke( - episodes.items[bindingAdapterPosition].seasonId, - episodes.items[bindingAdapterPosition].id + episodes[bindingAdapterPosition].seasonId, + episodes[bindingAdapterPosition].id ) } } diff --git a/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml new file mode 100644 index 0000000..3dbfedb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_media_episodes.xml b/app/src/main/res/layout/fragment_media_episodes.xml index eb4485d..67ca94e 100644 --- a/app/src/main/res/layout/fragment_media_episodes.xml +++ b/app/src/main/res/layout/fragment_media_episodes.xml @@ -1,10 +1,24 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> + +