From 844ff41dd345aed8ed2b823f9d820e3601ea5318 Mon Sep 17 00:00:00 2001 From: Jannik Date: Sat, 4 Dec 2021 19:55:26 +0100 Subject: [PATCH 01/42] 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 02/42] 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 03/42] 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 04/42] 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 05/42] 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 06/42] 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 07/42] 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 08/42] 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 09/42] 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"> + +