Compare commits
17 Commits
9bf0ae2f63
...
1.0.0-beta
Author | SHA1 | Date | |
---|---|---|---|
4a5a6c04ca
|
|||
554c66e11f
|
|||
0aece1d8fa | |||
f820d2aac0 | |||
0ea2e5ee97
|
|||
a092c5b8be
|
|||
ab660d0ae7
|
|||
be1c001942
|
|||
30a5331bbc
|
|||
0797e9fa3d
|
|||
75204e522d
|
|||
2016e03e56
|
|||
4505f95309
|
|||
e8bf63a666
|
|||
a51001ec2e
|
|||
0b5a8e69fb
|
|||
61c96f5ce2
|
@ -1,14 +1,13 @@
|
|||||||
# Teapod
|
# Teapod
|
||||||
|
|
||||||
Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all your favourite animes from AoD on your android device. To use Teapod you need to have a subscription to AoD.
|
Teapod is a unofficial App for Crunchyroll. It allows you to watch all your favourite animes from Crunchyroll on your android device. To use Teapod you need to have a account at Crunchyroll.
|
||||||
|
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">](https://f-droid.org/de/packages/org.mosad.teapod/)
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">](https://f-droid.org/de/packages/org.mosad.teapod/)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
* Watch all animes from AoD on your Android device
|
* Watch all animes from Crunchyroll on your Android device
|
||||||
* Native Player based on ExoPayer
|
* Native Player based on ExoPayer
|
||||||
* Prefer the OmU version via the app settings
|
* Prefer the OmU version via the app settings
|
||||||
* Save your favorite animes to "My List"
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Home.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Home.webp)
|
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Home.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Home.webp)
|
||||||
@ -17,10 +16,10 @@ Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all
|
|||||||
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp)
|
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp)
|
||||||
|
|
||||||
### License
|
### License
|
||||||
Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Anime on Demand in any way. But they allow open source apps for their service.
|
Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Crunchyroll in any way.
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
Currently you need to have an AoD account to contribute to Teapod. Contributing without on is kind of impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write me an email.
|
Currently you need to have an Crunchyroll account to contribute to Teapod. Contributing without one is impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write an email.
|
||||||
|
|
||||||
### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe)
|
### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe)
|
||||||
|
|
||||||
|
@ -13,8 +13,8 @@ android {
|
|||||||
applicationId "org.mosad.teapod"
|
applicationId "org.mosad.teapod"
|
||||||
minSdkVersion 23
|
minSdkVersion 23
|
||||||
targetSdkVersion 30
|
targetSdkVersion 30
|
||||||
versionCode 4200 //00.04.200
|
versionCode 9000 //00.09.000
|
||||||
versionName "1.0.0-alpha3"
|
versionName "1.0.0-beta1"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
resValue "string", "build_time", buildTime()
|
resValue "string", "build_time", buildTime()
|
||||||
@ -59,22 +59,21 @@ dependencies {
|
|||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
|
||||||
|
|
||||||
implementation 'com.google.android.material:material:1.4.0'
|
implementation 'com.google.android.material:material:1.4.0'
|
||||||
implementation 'com.google.code.gson:gson:2.8.8'
|
implementation 'com.google.code.gson:gson:2.8.8' // TODO remove, still used by metadb
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
|
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0'
|
implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0'
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0'
|
implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0'
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
|
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
|
||||||
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
|
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
|
||||||
|
|
||||||
implementation 'org.jsoup:jsoup:1.14.2'
|
|
||||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||||
implementation 'jp.wasabeef:glide-transformations:4.3.0'
|
implementation 'jp.wasabeef:glide-transformations:4.3.0'
|
||||||
implementation 'com.afollestad.material-dialogs:core:3.3.0'
|
implementation 'com.afollestad.material-dialogs:core:3.3.0' // TODO remove once unused
|
||||||
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0'
|
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' // TODO remove once unused
|
||||||
|
|
||||||
implementation 'com.github.kittinunf.fuel:fuel:2.3.1'
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||||
implementation 'com.github.kittinunf.fuel:fuel-android:2.3.1'
|
implementation "io.ktor:ktor-client-android:$ktor_version"
|
||||||
implementation 'com.github.kittinunf.fuel:fuel-json:2.3.1'
|
implementation "io.ktor:ktor-client-serialization:$ktor_version"
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
|
@ -1,16 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Teapod
|
||||||
|
*
|
||||||
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
||||||
|
*
|
||||||
|
* 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.crunchyroll
|
package org.mosad.teapod.parser.crunchyroll
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import io.ktor.client.*
|
||||||
import com.github.kittinunf.fuel.core.FuelError
|
import io.ktor.client.call.*
|
||||||
import com.github.kittinunf.fuel.core.Parameters
|
import io.ktor.client.features.json.*
|
||||||
import com.github.kittinunf.fuel.core.extensions.jsonBody
|
import io.ktor.client.features.json.serializer.*
|
||||||
import com.github.kittinunf.fuel.json.FuelJson
|
import io.ktor.client.request.*
|
||||||
import com.github.kittinunf.fuel.json.responseJson
|
import io.ktor.client.request.forms.*
|
||||||
import com.github.kittinunf.result.Result
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.mosad.teapod.preferences.EncryptedPreferences
|
import org.mosad.teapod.preferences.EncryptedPreferences
|
||||||
@ -20,11 +44,18 @@ import org.mosad.teapod.util.concatenate
|
|||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
object Crunchyroll {
|
object Crunchyroll {
|
||||||
|
private val TAG = javaClass.name
|
||||||
|
|
||||||
|
private val client = HttpClient {
|
||||||
|
install(JsonFeature) {
|
||||||
|
serializer = KotlinxSerializer(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
private const val baseUrl = "https://beta-api.crunchyroll.com"
|
private const val baseUrl = "https://beta-api.crunchyroll.com"
|
||||||
|
private const val basicApiTokenUrl = "https://gitlab.com/-/snippets/2274956/raw/main/snippetfile1.txt"
|
||||||
|
private var basicApiToken: String = ""
|
||||||
|
|
||||||
private var accessToken = ""
|
private lateinit var token: Token
|
||||||
private var tokenType = ""
|
|
||||||
private var tokenValidUntil: Long = 0
|
private var tokenValidUntil: Long = 0
|
||||||
|
|
||||||
private var accountID = ""
|
private var accountID = ""
|
||||||
@ -33,12 +64,21 @@ object Crunchyroll {
|
|||||||
private var signature = ""
|
private var signature = ""
|
||||||
private var keyPairID = ""
|
private var keyPairID = ""
|
||||||
|
|
||||||
// TODO temp helper vary
|
|
||||||
private var locale: String = Preferences.preferredLocal.toLanguageTag()
|
|
||||||
private var country: String = Preferences.preferredLocal.country
|
|
||||||
|
|
||||||
private val browsingCache = arrayListOf<Item>()
|
private val browsingCache = arrayListOf<Item>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the pai token, see:
|
||||||
|
* https://git.mosad.xyz/mosad/NonePublicIssues/issues/1
|
||||||
|
*
|
||||||
|
* TODO handle empty file
|
||||||
|
*/
|
||||||
|
fun initBasicApiToken() = runBlocking {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
basicApiToken = (client.get(basicApiTokenUrl) as HttpResponse).readText()
|
||||||
|
Log.i(TAG, "basic auth token: $basicApiToken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login to the crunchyroll API.
|
* Login to the crunchyroll API.
|
||||||
*
|
*
|
||||||
@ -49,39 +89,24 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
fun login(username: String, password: String): Boolean = runBlocking {
|
fun login(username: String, password: String): Boolean = runBlocking {
|
||||||
val tokenEndpoint = "/auth/v1/token"
|
val tokenEndpoint = "/auth/v1/token"
|
||||||
val formData = listOf(
|
val formData = Parameters.build {
|
||||||
"username" to username,
|
append("username", username)
|
||||||
"password" to password,
|
append("password", password)
|
||||||
"grant_type" to "password",
|
append("grant_type", "password")
|
||||||
"scope" to "offline_access"
|
append("scope", "offline_access")
|
||||||
)
|
}
|
||||||
|
|
||||||
var success: Boolean // is false
|
var success = false// is false
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData)
|
// TODO handle exceptions
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) {
|
||||||
.appendHeader(
|
header("Authorization", "Basic $basicApiToken")
|
||||||
"Authorization",
|
|
||||||
"Basic "
|
|
||||||
)
|
|
||||||
.responseJson()
|
|
||||||
|
|
||||||
// TODO fix JSONException: No value for
|
|
||||||
result.component1()?.obj()?.let {
|
|
||||||
accessToken = it.get("access_token").toString()
|
|
||||||
tokenType = it.get("token_type").toString()
|
|
||||||
|
|
||||||
// token will be invalid 1 sec
|
|
||||||
val expiresIn = (it.get("expires_in").toString().toLong() - 1)
|
|
||||||
tokenValidUntil = System.currentTimeMillis() + (expiresIn * 1000)
|
|
||||||
}
|
}
|
||||||
|
token = response.receive()
|
||||||
|
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000)
|
||||||
|
|
||||||
// println("request: $request")
|
Log.i(TAG, "login complete with code ${response.status}")
|
||||||
// println("response: $response")
|
success = (response.status == HttpStatusCode.OK)
|
||||||
// println("response: $result")
|
|
||||||
|
|
||||||
Log.i(javaClass.name, "login complete with code ${response.statusCode}")
|
|
||||||
success = (response.statusCode == 200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return@runBlocking success
|
return@runBlocking success
|
||||||
@ -95,56 +120,74 @@ object Crunchyroll {
|
|||||||
* Requests: get, post, delete
|
* Requests: get, post, delete
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private suspend fun request(
|
private suspend inline fun <reified T> request(
|
||||||
endpoint: String,
|
url: String,
|
||||||
params: Parameters = listOf(),
|
httpMethod: HttpMethod,
|
||||||
url: String = ""
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
): Result<FuelJson, FuelError> = coroutineScope {
|
bodyObject: Any = Any()
|
||||||
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
): T = coroutineScope {
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
||||||
|
|
||||||
return@coroutineScope (Dispatchers.IO) {
|
return@coroutineScope (Dispatchers.IO) {
|
||||||
val (request, response, result) = Fuel.get(path, params)
|
val response: T = client.request(url) {
|
||||||
.header("Authorization", "$tokenType $accessToken")
|
method = httpMethod
|
||||||
.responseJson()
|
header("Authorization", "${token.tokenType} ${token.accessToken}")
|
||||||
|
params.forEach {
|
||||||
|
parameter(it.first, it.second)
|
||||||
|
}
|
||||||
|
|
||||||
// println("request request: $request")
|
// for json set body and content type
|
||||||
// println("request response: $response")
|
if (bodyObject is JsonObject) {
|
||||||
// println("request result: $result")
|
body = bodyObject
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> requestGet(
|
||||||
|
endpoint: String,
|
||||||
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
|
url: String = ""
|
||||||
|
): T {
|
||||||
|
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
||||||
|
|
||||||
|
return request(path, HttpMethod.Get, params)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun requestPost(
|
private suspend fun requestPost(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
body: String
|
bodyObject: JsonObject
|
||||||
) = coroutineScope {
|
) {
|
||||||
val path = "$baseUrl$endpoint"
|
val path = "$baseUrl$endpoint"
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject)
|
||||||
Fuel.post(path, params)
|
Log.i(TAG, "Response: $response")
|
||||||
.header("Authorization", "$tokenType $accessToken")
|
}
|
||||||
.jsonBody(body)
|
|
||||||
.response() // without a response, crunchy doesn't accept the request
|
private suspend fun requestPatch(
|
||||||
}
|
endpoint: String,
|
||||||
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
|
bodyObject: JsonObject
|
||||||
|
) {
|
||||||
|
val path = "$baseUrl$endpoint"
|
||||||
|
|
||||||
|
val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject)
|
||||||
|
Log.i(TAG, "Response: $response")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestDelete(
|
private suspend fun requestDelete(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
url: String = ""
|
url: String = ""
|
||||||
) = coroutineScope {
|
) = coroutineScope {
|
||||||
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val response: HttpResponse = request(path, HttpMethod.Delete, params)
|
||||||
Fuel.delete(path, params)
|
Log.i(TAG, "Response: $response")
|
||||||
.header("Authorization", "$tokenType $accessToken")
|
|
||||||
.response() // without a response, crunchy doesn't accept the request
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -158,17 +201,15 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun index() {
|
suspend fun index() {
|
||||||
val indexEndpoint = "/index/v2"
|
val indexEndpoint = "/index/v2"
|
||||||
val result = request(indexEndpoint)
|
|
||||||
|
|
||||||
result.component1()?.obj()?.getJSONObject("cms")?.let {
|
val index: Index = requestGet(indexEndpoint)
|
||||||
policy = it.get("policy").toString()
|
policy = index.cms.policy
|
||||||
signature = it.get("signature").toString()
|
signature = index.cms.signature
|
||||||
keyPairID = it.get("key_pair_id").toString()
|
keyPairID = index.cms.keyPairId
|
||||||
}
|
|
||||||
|
|
||||||
println("policy: $policy")
|
Log.i(TAG, "Policy : $policy")
|
||||||
println("signature: $signature")
|
Log.i(TAG, "Signature : $signature")
|
||||||
println("keyPairID: $keyPairID")
|
Log.i(TAG, "Key Pair ID : $keyPairID")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -179,18 +220,22 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun account() {
|
suspend fun account() {
|
||||||
val indexEndpoint = "/accounts/v1/me"
|
val indexEndpoint = "/accounts/v1/me"
|
||||||
val result = request(indexEndpoint)
|
|
||||||
|
|
||||||
result.component1()?.obj()?.let {
|
val account: Account = try {
|
||||||
accountID = it.get("account_id").toString()
|
requestGet(indexEndpoint)
|
||||||
|
} catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in account(). This is bad!", ex)
|
||||||
|
NoneAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountID = account.accountId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General element/media functions: browse, search, objects, season_list
|
* General element/media functions: browse, search, objects, season_list
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// TODO locale de-DE, categories
|
// TODO categories
|
||||||
/**
|
/**
|
||||||
* Browse the media available on crunchyroll.
|
* Browse the media available on crunchyroll.
|
||||||
*
|
*
|
||||||
@ -206,7 +251,12 @@ object Crunchyroll {
|
|||||||
n: Int = 10
|
n: Int = 10
|
||||||
): BrowseResult {
|
): BrowseResult {
|
||||||
val browseEndpoint = "/content/v1/browse"
|
val browseEndpoint = "/content/v1/browse"
|
||||||
val noneOptParams = listOf("sort_by" to sortBy.str, "start" to start, "n" to n)
|
val noneOptParams = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"sort_by" to sortBy.str,
|
||||||
|
"start" to start,
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
// if a season tag is present add it to the parameters
|
// if a season tag is present add it to the parameters
|
||||||
val parameters = if (seasonTag.isNotEmpty()) {
|
val parameters = if (seasonTag.isNotEmpty()) {
|
||||||
@ -215,10 +265,12 @@ object Crunchyroll {
|
|||||||
noneOptParams
|
noneOptParams
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = request(browseEndpoint, parameters)
|
val browseResult: BrowseResult = try {
|
||||||
val browseResult = result.component1()?.obj()?.let {
|
requestGet(browseEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneBrowseResult
|
Log.e(TAG, "SerializationException in browse().", ex)
|
||||||
|
NoneBrowseResult
|
||||||
|
}
|
||||||
|
|
||||||
// add results to cache TODO improve
|
// add results to cache TODO improve
|
||||||
browsingCache.clear()
|
browsingCache.clear()
|
||||||
@ -232,15 +284,22 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun search(query: String, n: Int = 10): SearchResult {
|
suspend fun search(query: String, n: Int = 10): SearchResult {
|
||||||
val searchEndpoint = "/content/v1/search"
|
val searchEndpoint = "/content/v1/search"
|
||||||
val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series")
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"q" to query,
|
||||||
|
"n" to n,
|
||||||
|
"type" to "series"
|
||||||
|
)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
|
||||||
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
|
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
|
||||||
// to work around this, for now only tv shows are supported
|
// to work around this, for now only tv shows are supported
|
||||||
|
|
||||||
return result.component1()?.obj()?.let {
|
return try {
|
||||||
json.decodeFromString(it.toString())
|
requestGet(searchEndpoint, parameters)
|
||||||
} ?: NoneSearchResult
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex)
|
||||||
|
NoneSearchResult
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -253,17 +312,18 @@ object Crunchyroll {
|
|||||||
suspend fun objects(objects: List<String>): Collection<Item> {
|
suspend fun objects(objects: List<String>): Collection<Item> {
|
||||||
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
|
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(episodesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(episodesEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in objects().", ex)
|
||||||
} ?: NoneCollection
|
NoneCollection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -272,13 +332,14 @@ object Crunchyroll {
|
|||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
suspend fun seasonList(): DiscSeasonList {
|
suspend fun seasonList(): DiscSeasonList {
|
||||||
val seasonListEndpoint = "/content/v1/season_list"
|
val seasonListEndpoint = "/content/v1/season_list"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val result = request(seasonListEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(seasonListEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in seasonList().", ex)
|
||||||
} ?: NoneDiscSeasonList
|
NoneDiscSeasonList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -289,19 +350,20 @@ object Crunchyroll {
|
|||||||
* series id == crunchyroll id?
|
* series id == crunchyroll id?
|
||||||
*/
|
*/
|
||||||
suspend fun series(seriesId: String): Series {
|
suspend fun series(seriesId: String): Series {
|
||||||
val seriesEndpoint = "/cms/v2/$country/M3/crunchyroll/series/$seriesId"
|
val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(seriesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(seriesEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in series().", ex)
|
||||||
} ?: NoneSeries
|
NoneSeries
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -311,56 +373,60 @@ object Crunchyroll {
|
|||||||
val upNextSeriesEndpoint = "/content/v1/up_next_series"
|
val upNextSeriesEndpoint = "/content/v1/up_next_series"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"series_id" to seriesId,
|
"series_id" to seriesId,
|
||||||
"locale" to locale
|
"locale" to Preferences.preferredLocale.toLanguageTag()
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(upNextSeriesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(upNextSeriesEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in upNextSeries().", ex)
|
||||||
} ?: NoneUpNextSeriesItem
|
NoneUpNextSeriesItem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun seasons(seriesId: String): Seasons {
|
suspend fun seasons(seriesId: String): Seasons {
|
||||||
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
|
val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"series_id" to seriesId,
|
"series_id" to seriesId,
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(episodesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(seasonsEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in seasons().", ex)
|
||||||
} ?: NoneSeasons
|
NoneSeasons
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun episodes(seasonId: String): Episodes {
|
suspend fun episodes(seasonId: String): Episodes {
|
||||||
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/episodes"
|
val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"season_id" to seasonId,
|
"season_id" to seasonId,
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(episodesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(episodesEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in episodes().", ex)
|
||||||
} ?: NoneEpisodes
|
NoneEpisodes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playback(url: String): Playback {
|
suspend fun playback(url: String): Playback {
|
||||||
val result = request("", url = url)
|
return try {
|
||||||
|
requestGet("", url = url)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in playback(), with url = $url.", ex)
|
||||||
} ?: NonePlayback
|
NonePlayback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -375,12 +441,15 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun isWatchlist(seriesId: String): Boolean {
|
suspend fun isWatchlist(seriesId: String): Boolean {
|
||||||
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val result = request(watchlistSeriesEndpoint, parameters)
|
return try {
|
||||||
// if needed implement parsing
|
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
|
||||||
|
.containsKey(seriesId)
|
||||||
return result.component1()?.obj()?.has(seriesId) ?: false
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex)
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -390,13 +459,13 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun postWatchlist(seriesId: String) {
|
suspend fun postWatchlist(seriesId: String) {
|
||||||
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
|
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val json = buildJsonObject {
|
val json = buildJsonObject {
|
||||||
put("content_id", seriesId)
|
put("content_id", seriesId)
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPost(watchlistPostEndpoint, parameters, json.toString())
|
requestPost(watchlistPostEndpoint, parameters, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -406,7 +475,7 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun deleteWatchlist(seriesId: String) {
|
suspend fun deleteWatchlist(seriesId: String) {
|
||||||
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
requestDelete(watchlistDeleteEndpoint, parameters)
|
requestDelete(watchlistDeleteEndpoint, parameters)
|
||||||
}
|
}
|
||||||
@ -421,25 +490,26 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
|
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
|
||||||
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
|
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val result = request(playheadsEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(playheadsEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in upNextSeries().", ex)
|
||||||
} ?: emptyMap()
|
emptyMap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun postPlayheads(episodeId: String, playhead: Int) {
|
suspend fun postPlayheads(episodeId: String, playhead: Int) {
|
||||||
val playheadsEndpoint = "/content/v1/playheads/$accountID"
|
val playheadsEndpoint = "/content/v1/playheads/$accountID"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val json = buildJsonObject {
|
val json = buildJsonObject {
|
||||||
put("content_id", episodeId)
|
put("content_id", episodeId)
|
||||||
put("playhead", playhead)
|
put("playhead", playhead)
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPost(playheadsEndpoint, parameters, json.toString())
|
requestPost(playheadsEndpoint, parameters, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -454,12 +524,17 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun watchlist(n: Int = 20): Watchlist {
|
suspend fun watchlist(n: Int = 20): Watchlist {
|
||||||
val watchlistEndpoint = "/content/v1/$accountID/watchlist"
|
val watchlistEndpoint = "/content/v1/$accountID/watchlist"
|
||||||
val parameters = listOf("locale" to locale, "n" to n)
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
val watchlistResult = request(watchlistEndpoint, parameters)
|
val list: ContinueWatchingList = try {
|
||||||
val list: ContinueWatchingList = watchlistResult.component1()?.obj()?.let {
|
requestGet(watchlistEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneContinueWatchingList
|
Log.e(TAG, "SerializationException in watchlist().", ex)
|
||||||
|
NoneContinueWatchingList
|
||||||
|
}
|
||||||
|
|
||||||
val objects = list.items.map{ it.panel.episodeMetadata.seriesId }
|
val objects = list.items.map{ it.panel.episodeMetadata.seriesId }
|
||||||
return objects(objects)
|
return objects(objects)
|
||||||
@ -473,12 +548,41 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
|
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
|
||||||
val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
|
val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
|
||||||
val parameters = listOf("locale" to locale, "n" to n)
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
val resultUpNextAccount = request(watchlistEndpoint, parameters)
|
return try {
|
||||||
return resultUpNextAccount.component1()?.obj()?.let {
|
requestGet(watchlistEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneContinueWatchingList
|
Log.e(TAG, "SerializationException in upNextAccount().", ex)
|
||||||
|
NoneContinueWatchingList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account/Profile functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
suspend fun profile(): Profile {
|
||||||
|
val profileEndpoint = "/accounts/v1/me/profile"
|
||||||
|
|
||||||
|
return try {
|
||||||
|
requestGet(profileEndpoint)
|
||||||
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in profile().", ex)
|
||||||
|
NoneProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun postPrefSubLanguage(languageTag: String) {
|
||||||
|
val profileEndpoint = "/accounts/v1/me/profile"
|
||||||
|
val json = buildJsonObject {
|
||||||
|
put("preferred_content_subtitle_language", languageTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPatch(profileEndpoint, bodyObject = json)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Teapod
|
||||||
|
*
|
||||||
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
||||||
|
*
|
||||||
|
* 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.crunchyroll
|
package org.mosad.teapod.parser.crunchyroll
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
val supportedLocals = listOf(
|
||||||
|
Locale.forLanguageTag("ar-SA"),
|
||||||
|
Locale.forLanguageTag("de-DE"),
|
||||||
|
Locale.forLanguageTag("en-US"),
|
||||||
|
Locale.forLanguageTag("es-419"),
|
||||||
|
Locale.forLanguageTag("es-ES"),
|
||||||
|
Locale.forLanguageTag("fr-FR"),
|
||||||
|
Locale.forLanguageTag("it-IT"),
|
||||||
|
Locale.forLanguageTag("pt-BR"),
|
||||||
|
Locale.forLanguageTag("pt-PT"),
|
||||||
|
Locale.forLanguageTag("ru-RU"),
|
||||||
|
Locale.ROOT
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* data classes for browse
|
* data classes for browse
|
||||||
* TODO make class names more clear/possibly overlapping for now
|
* TODO make class names more clear/possibly overlapping for now
|
||||||
@ -14,6 +50,44 @@ enum class SortBy(val str: String) {
|
|||||||
POPULARITY("popularity")
|
POPULARITY("popularity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* token, index, account. This must pe present for the app to work!
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Token(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("refresh_token") val refreshToken: String,
|
||||||
|
@SerialName("expires_in") val expiresIn: Int,
|
||||||
|
@SerialName("token_type") val tokenType: String,
|
||||||
|
@SerialName("scope") val scope: String,
|
||||||
|
@SerialName("country") val country: String,
|
||||||
|
@SerialName("account_id") val accountId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Index(
|
||||||
|
@SerialName("cms") val cms: CMS,
|
||||||
|
@SerialName("service_available") val serviceAvailable: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CMS(
|
||||||
|
@SerialName("bucket") val bucket: String,
|
||||||
|
@SerialName("policy") val policy: String,
|
||||||
|
@SerialName("signature") val signature: String,
|
||||||
|
@SerialName("key_pair_id") val keyPairId: String,
|
||||||
|
@SerialName("expires") val expires: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Account(
|
||||||
|
@SerialName("account_id") val accountId: String,
|
||||||
|
@SerialName("external_id") val externalId: String,
|
||||||
|
@SerialName("email_verified") val emailVerified: Boolean,
|
||||||
|
@SerialName("created") val created: String,
|
||||||
|
)
|
||||||
|
val NoneAccount = Account("", "", false, "")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* search, browse, DiscSeasonList, Watchlist, ContinueWatchingList data types all use Collection
|
* search, browse, DiscSeasonList, Watchlist, ContinueWatchingList data types all use Collection
|
||||||
*/
|
*/
|
||||||
@ -33,10 +107,10 @@ typealias ContinueWatchingList = Collection<ContinueWatchingItem>
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UpNextSeriesItem(
|
data class UpNextSeriesItem(
|
||||||
val playhead: Int,
|
@SerialName("playhead") val playhead: Int,
|
||||||
val fully_watched: Boolean,
|
@SerialName("fully_watched") val fullyWatched: Boolean,
|
||||||
val never_watched: Boolean,
|
@SerialName("never_watched") val neverWatched: Boolean,
|
||||||
val panel: EpisodePanel,
|
@SerialName("panel") val panel: EpisodePanel,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,7 +166,7 @@ data class ContinueWatchingItem(
|
|||||||
// @SerialName("completion_status") val completionStatus: Boolean,
|
// @SerialName("completion_status") val completionStatus: Boolean,
|
||||||
@SerialName("playhead") val playhead: Int,
|
@SerialName("playhead") val playhead: Int,
|
||||||
// not present in watchlist -> continue_watching_item
|
// not present in watchlist -> continue_watching_item
|
||||||
// @SerialName("fully_watched") val fullyWatched: Boolean,
|
@SerialName("fully_watched") val fullyWatched: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
// EpisodePanel is used in ContinueWatchingItem
|
// EpisodePanel is used in ContinueWatchingItem
|
||||||
@ -126,7 +200,7 @@ val NoneBrowseResult = BrowseResult(0, emptyList())
|
|||||||
val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
|
val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
|
||||||
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
|
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
|
||||||
|
|
||||||
val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel)
|
val NoneUpNextSeriesItem = UpNextSeriesItem(0, false, false, NoneEpisodePanel)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Series data type
|
* Series data type
|
||||||
@ -150,22 +224,13 @@ data class Seasons(
|
|||||||
@SerialName("items") val items: List<Season>
|
@SerialName("items") val items: List<Season>
|
||||||
) {
|
) {
|
||||||
fun getPreferredSeason(local: Locale): Season {
|
fun getPreferredSeason(local: Locale): Season {
|
||||||
// try to get the the first seasons which matches the preferred local
|
return items.firstOrNull { season ->
|
||||||
items.forEach { season ->
|
// try to get the the first seasons which matches the preferred local
|
||||||
if (season.title.startsWith("(${local.language})", true)) {
|
season.slugTitle.endsWith("${local.getDisplayLanguage(Locale.ENGLISH)}-dub", true)
|
||||||
return season
|
} ?: items.firstOrNull { season ->
|
||||||
}
|
// if there is no season with the preferred local, try to find a subbed season
|
||||||
}
|
season.isSubbed
|
||||||
|
} ?: items.first() // if no preferred language and no sub, use the first season
|
||||||
// if there is no season with the preferred local, try to find a subbed season
|
|
||||||
items.forEach { season ->
|
|
||||||
if (season.isSubbed) {
|
|
||||||
return season
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there is no preferred language season and no sub, use the first season
|
|
||||||
return items.first()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,6 +238,7 @@ data class Seasons(
|
|||||||
data class Season(
|
data class Season(
|
||||||
@SerialName("id") val id: String,
|
@SerialName("id") val id: String,
|
||||||
@SerialName("title") val title: String,
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("slug_title") val slugTitle: String,
|
||||||
@SerialName("series_id") val seriesId: String,
|
@SerialName("series_id") val seriesId: String,
|
||||||
@SerialName("season_number") val seasonNumber: Int,
|
@SerialName("season_number") val seasonNumber: Int,
|
||||||
@SerialName("is_subbed") val isSubbed: Boolean,
|
@SerialName("is_subbed") val isSubbed: Boolean,
|
||||||
@ -180,7 +246,7 @@ data class Season(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val NoneSeasons = Seasons(0, emptyList())
|
val NoneSeasons = Seasons(0, emptyList())
|
||||||
val NoneSeason = Season("", "", "", 0, isSubbed = false, isDubbed = false)
|
val NoneSeason = Season("", "", "", "", 0, isSubbed = false, isDubbed = false)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -295,3 +361,19 @@ val NonePlayback = Playback(
|
|||||||
mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(),
|
mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Profile(
|
||||||
|
@SerialName("avatar") val avatar: String,
|
||||||
|
@SerialName("email") val email: String,
|
||||||
|
@SerialName("maturity_rating") val maturityRating: String,
|
||||||
|
@SerialName("preferred_content_subtitle_language") val preferredContentSubtitleLanguage: String,
|
||||||
|
@SerialName("username") val username: String,
|
||||||
|
)
|
||||||
|
val NoneProfile = Profile(
|
||||||
|
avatar = "",
|
||||||
|
email = "",
|
||||||
|
maturityRating = "",
|
||||||
|
preferredContentSubtitleLanguage = "",
|
||||||
|
username = ""
|
||||||
|
)
|
||||||
|
@ -8,9 +8,9 @@ import java.util.*
|
|||||||
|
|
||||||
object Preferences {
|
object Preferences {
|
||||||
|
|
||||||
var preferSecondary = false
|
var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start
|
||||||
internal set
|
internal set
|
||||||
var preferredLocal = Locale.GERMANY
|
var preferSubbed = false
|
||||||
internal set
|
internal set
|
||||||
var autoplay = true
|
var autoplay = true
|
||||||
internal set
|
internal set
|
||||||
@ -26,13 +26,22 @@ object Preferences {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun savePreferSecondary(context: Context, preferSecondary: Boolean) {
|
fun savePreferredLocal(context: Context, preferredLocale: Locale) {
|
||||||
with(getSharedPref(context).edit()) {
|
with(getSharedPref(context).edit()) {
|
||||||
putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSecondary)
|
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.preferSecondary = preferSecondary
|
this.preferredLocale = preferredLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePreferSecondary(context: Context, preferSubbed: Boolean) {
|
||||||
|
with(getSharedPref(context).edit()) {
|
||||||
|
putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSubbed)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preferSubbed = preferSubbed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveAutoplay(context: Context, autoplay: Boolean) {
|
fun saveAutoplay(context: Context, autoplay: Boolean) {
|
||||||
@ -68,7 +77,12 @@ object Preferences {
|
|||||||
fun load(context: Context) {
|
fun load(context: Context) {
|
||||||
val sharedPref = getSharedPref(context)
|
val sharedPref = getSharedPref(context)
|
||||||
|
|
||||||
preferSecondary = sharedPref.getBoolean(
|
preferredLocale = Locale.forLanguageTag(
|
||||||
|
sharedPref.getString(
|
||||||
|
context.getString(R.string.save_key_preferred_local), "en-US"
|
||||||
|
) ?: "en-US"
|
||||||
|
)
|
||||||
|
preferSubbed = sharedPref.getBoolean(
|
||||||
context.getString(R.string.save_key_prefer_secondary), false
|
context.getString(R.string.save_key_prefer_secondary), false
|
||||||
)
|
)
|
||||||
autoplay = sharedPref.getBoolean(
|
autoplay = sharedPref.getBoolean(
|
||||||
|
@ -44,9 +44,11 @@ import org.mosad.teapod.ui.activity.onboarding.OnboardingActivity
|
|||||||
import org.mosad.teapod.ui.activity.player.PlayerActivity
|
import org.mosad.teapod.ui.activity.player.PlayerActivity
|
||||||
import org.mosad.teapod.ui.components.LoginDialog
|
import org.mosad.teapod.ui.components.LoginDialog
|
||||||
import org.mosad.teapod.util.DataTypes
|
import org.mosad.teapod.util.DataTypes
|
||||||
|
import java.util.*
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
|
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
|
||||||
|
private val classTag = javaClass.name
|
||||||
|
|
||||||
private lateinit var binding: ActivityMainBinding
|
private lateinit var binding: ActivityMainBinding
|
||||||
private var activeBaseFragment: Fragment = HomeFragment() // the currently active fragment, home at the start
|
private var activeBaseFragment: Fragment = HomeFragment() // the currently active fragment, home at the start
|
||||||
@ -135,6 +137,9 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
Preferences.load(this)
|
Preferences.load(this)
|
||||||
EncryptedPreferences.readCredentials(this)
|
EncryptedPreferences.readCredentials(this)
|
||||||
|
|
||||||
|
// always initialize the api token
|
||||||
|
Crunchyroll.initBasicApiToken()
|
||||||
|
|
||||||
// show onboarding if no password is set, or login fails
|
// show onboarding if no password is set, or login fails
|
||||||
if (EncryptedPreferences.password.isEmpty() || !Crunchyroll.login(
|
if (EncryptedPreferences.password.isEmpty() || !Crunchyroll.login(
|
||||||
EncryptedPreferences.login,
|
EncryptedPreferences.login,
|
||||||
@ -146,16 +151,20 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
runBlocking { initCrunchyroll().joinAll() }
|
runBlocking { initCrunchyroll().joinAll() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.i(javaClass.name, "loading in $time ms")
|
Log.i(classTag, "loading in $time ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initCrunchyroll(): List<Job> {
|
private fun initCrunchyroll(): List<Job> {
|
||||||
println("init")
|
|
||||||
|
|
||||||
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope"))
|
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope"))
|
||||||
return listOf(
|
return listOf(
|
||||||
scope.launch { Crunchyroll.index() },
|
scope.launch { Crunchyroll.index() },
|
||||||
scope.launch { Crunchyroll.account() }
|
scope.launch { Crunchyroll.account() },
|
||||||
|
scope.launch {
|
||||||
|
// update the local preferred content language, since it may have changed
|
||||||
|
val locale = Locale.forLanguageTag(Crunchyroll.profile().preferredContentSubtitleLanguage)
|
||||||
|
Preferences.savePreferredLocal(this@MainActivity, locale)
|
||||||
|
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,7 +178,7 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
// Log.w(javaClass.name, "Login failed, please try again.")
|
// Log.w(javaClass.name, "Login failed, please try again.")
|
||||||
// }
|
// }
|
||||||
}.negativeButton {
|
}.negativeButton {
|
||||||
Log.i(javaClass.name, "Login canceled, exiting.")
|
Log.i(classTag, "Login canceled, exiting.")
|
||||||
finish()
|
finish()
|
||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.RawRes
|
import androidx.annotation.RawRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.afollestad.materialdialogs.MaterialDialog
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import org.mosad.teapod.BuildConfig
|
import org.mosad.teapod.BuildConfig
|
||||||
import org.mosad.teapod.R
|
import org.mosad.teapod.R
|
||||||
import org.mosad.teapod.databinding.FragmentAboutBinding
|
import org.mosad.teapod.databinding.FragmentAboutBinding
|
||||||
@ -68,9 +68,9 @@ class AboutFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.linearLicense.setOnClickListener {
|
binding.linearLicense.setOnClickListener {
|
||||||
MaterialDialog(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.title(text = License.GPL3.long)
|
.setTitle(License.GPL3.long)
|
||||||
.message(text = parseLicense(R.raw.gpl_3_full))
|
.setMessage(parseLicense(R.raw.gpl_3_full))
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,9 +113,9 @@ class AboutFragment : Fragment() {
|
|||||||
"https://github.com/google/material-design-icons", License.APACHE2),
|
"https://github.com/google/material-design-icons", License.APACHE2),
|
||||||
ThirdPartyComponent("Material Dialogs", "", "Aidan Follestad",
|
ThirdPartyComponent("Material Dialogs", "", "Aidan Follestad",
|
||||||
"https://github.com/afollestad/material-dialogs", License.APACHE2),
|
"https://github.com/afollestad/material-dialogs", License.APACHE2),
|
||||||
ThirdPartyComponent("Jsoup", "2009 - 2020", "Jonathan Hedley",
|
ThirdPartyComponent("Ktor", "2014-2021", "JetBrains s.r.o and contributors",
|
||||||
"https://jsoup.org/", License.MIT),
|
"https://ktor.io/", License.APACHE2),
|
||||||
ThirdPartyComponent("kotlinx.coroutines", "2016 - 2019", "JetBrains",
|
ThirdPartyComponent("kotlinx.coroutines", "2016-2021", "JetBrains s.r.o",
|
||||||
"https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2),
|
"https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2),
|
||||||
ThirdPartyComponent("Glide", "2014", "Google Inc.",
|
ThirdPartyComponent("Glide", "2014", "Google Inc.",
|
||||||
"https://github.com/bumptech/glide", License.BSD2),
|
"https://github.com/bumptech/glide", License.BSD2),
|
||||||
@ -132,9 +132,9 @@ class AboutFragment : Fragment() {
|
|||||||
License.MIT -> parseLicense(R.raw.mit_full)
|
License.MIT -> parseLicense(R.raw.mit_full)
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialDialog(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.title(text = license.long)
|
.setTitle(license.long)
|
||||||
.message(text = licenseText)
|
.setMessage(licenseText)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,27 +6,36 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.afollestad.materialdialogs.MaterialDialog
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.mosad.teapod.BuildConfig
|
import org.mosad.teapod.BuildConfig
|
||||||
import org.mosad.teapod.R
|
import org.mosad.teapod.R
|
||||||
import org.mosad.teapod.databinding.FragmentAccountBinding
|
import org.mosad.teapod.databinding.FragmentAccountBinding
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.Profile
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.supportedLocals
|
||||||
import org.mosad.teapod.preferences.EncryptedPreferences
|
import org.mosad.teapod.preferences.EncryptedPreferences
|
||||||
import org.mosad.teapod.preferences.Preferences
|
import org.mosad.teapod.preferences.Preferences
|
||||||
import org.mosad.teapod.ui.activity.main.MainActivity
|
import org.mosad.teapod.ui.activity.main.MainActivity
|
||||||
import org.mosad.teapod.ui.components.LoginDialog
|
import org.mosad.teapod.ui.components.LoginDialog
|
||||||
import org.mosad.teapod.util.DataTypes.Theme
|
import org.mosad.teapod.util.DataTypes.Theme
|
||||||
import org.mosad.teapod.util.showFragment
|
import org.mosad.teapod.util.showFragment
|
||||||
|
import org.mosad.teapod.util.toDisplayString
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class AccountFragment : Fragment() {
|
class AccountFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentAccountBinding
|
private lateinit var binding: FragmentAccountBinding
|
||||||
|
private var profile: Deferred<Profile> = lifecycleScope.async {
|
||||||
|
Crunchyroll.profile()
|
||||||
|
}
|
||||||
|
|
||||||
private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
@ -58,7 +67,9 @@ class AccountFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
// TODO reimplement for ct, if possible (maybe account status would be better? (premium))
|
binding.textAccountLogin.text = EncryptedPreferences.login
|
||||||
|
|
||||||
|
// TODO reimplement for cr, if possible (maybe account status would be better? (premium))
|
||||||
// load subscription (async) info before anything else
|
// load subscription (async) info before anything else
|
||||||
binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading))
|
binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading))
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@ -68,18 +79,23 @@ class AccountFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.textAccountLogin.text = EncryptedPreferences.login
|
// add preferred subtitles
|
||||||
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time))
|
lifecycleScope.launch {
|
||||||
|
binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag(
|
||||||
|
profile.await().preferredContentSubtitleLanguage
|
||||||
|
).displayLanguage
|
||||||
|
}
|
||||||
|
binding.switchSecondary.isChecked = Preferences.preferSubbed
|
||||||
|
binding.switchAutoplay.isChecked = Preferences.autoplay
|
||||||
binding.textThemeSelected.text = when (Preferences.theme) {
|
binding.textThemeSelected.text = when (Preferences.theme) {
|
||||||
Theme.DARK -> getString(R.string.theme_dark)
|
Theme.DARK -> getString(R.string.theme_dark)
|
||||||
else -> getString(R.string.theme_light)
|
else -> getString(R.string.theme_light)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.switchSecondary.isChecked = Preferences.preferSecondary
|
|
||||||
binding.switchAutoplay.isChecked = Preferences.autoplay
|
|
||||||
|
|
||||||
binding.linearDevSettings.isVisible = Preferences.devSettings
|
binding.linearDevSettings.isVisible = Preferences.devSettings
|
||||||
|
|
||||||
|
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time))
|
||||||
|
|
||||||
initActions()
|
initActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,12 +109,9 @@ class AccountFragment : Fragment() {
|
|||||||
//startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
|
//startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.linearTheme.setOnClickListener {
|
|
||||||
showThemeDialog()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.linearInfo.setOnClickListener {
|
binding.linearSettingsContentLanguage.setOnClickListener {
|
||||||
activity?.showFragment(AboutFragment())
|
showContentLanguageSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.switchSecondary.setOnClickListener {
|
binding.switchSecondary.setOnClickListener {
|
||||||
@ -109,6 +122,14 @@ class AccountFragment : Fragment() {
|
|||||||
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
|
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.linearTheme.setOnClickListener {
|
||||||
|
showThemeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.linearInfo.setOnClickListener {
|
||||||
|
activity?.showFragment(AboutFragment())
|
||||||
|
}
|
||||||
|
|
||||||
binding.linearExportData.setOnClickListener {
|
binding.linearExportData.setOnClickListener {
|
||||||
val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
@ -142,24 +163,67 @@ class AccountFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showThemeDialog() {
|
private fun showContentLanguageSelection() {
|
||||||
val themes = listOf(
|
// we should be able to use the index of supportedLocals for language selection, items is GUI only
|
||||||
resources.getString(R.string.theme_light),
|
val items = supportedLocals.map {
|
||||||
resources.getString(R.string.theme_dark)
|
it.toDisplayString(getString(R.string.settings_content_language_none))
|
||||||
)
|
}.toTypedArray()
|
||||||
|
|
||||||
MaterialDialog(requireContext()).show {
|
var initialSelection: Int
|
||||||
title(R.string.theme)
|
// profile should be completed here, therefore blocking
|
||||||
listItemsSingleChoice(items = themes, initialSelection = Preferences.theme.ordinal) { _, index, _ ->
|
runBlocking {
|
||||||
when(index) {
|
initialSelection = supportedLocals.indexOf(Locale.forLanguageTag(
|
||||||
0 -> Preferences.saveTheme(context, Theme.LIGHT)
|
profile.await().preferredContentSubtitleLanguage))
|
||||||
1 -> Preferences.saveTheme(context, Theme.DARK)
|
if (initialSelection < 0) initialSelection = supportedLocals.lastIndex
|
||||||
else -> Preferences.saveTheme(context, Theme.DARK)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
(activity as MainActivity).restart()
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.settings_content_language)
|
||||||
|
.setSingleChoiceItems(items, initialSelection){ dialog, which ->
|
||||||
|
updatePrefContentLanguage(supportedLocals[which])
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
private fun updatePrefContentLanguage(preferredLocale: Locale) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
Crunchyroll.postPrefSubLanguage(preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
|
}.invokeOnCompletion {
|
||||||
|
// update the local preferred content language
|
||||||
|
Preferences.savePreferredLocal(requireContext(), preferredLocale)
|
||||||
|
|
||||||
|
// update profile since the language selection might have changed
|
||||||
|
profile = lifecycleScope.async { Crunchyroll.profile() }
|
||||||
|
profile.invokeOnCompletion {
|
||||||
|
// update language once loading profile is completed
|
||||||
|
binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag(
|
||||||
|
profile.getCompleted().preferredContentSubtitleLanguage
|
||||||
|
).displayLanguage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showThemeDialog() {
|
||||||
|
val items = arrayOf(
|
||||||
|
resources.getString(R.string.theme_light),
|
||||||
|
resources.getString(R.string.theme_dark)
|
||||||
|
)
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.settings_content_language)
|
||||||
|
.setSingleChoiceItems(items, Preferences.theme.ordinal){ _, which ->
|
||||||
|
when(which) {
|
||||||
|
0 -> Preferences.saveTheme(requireContext(), Theme.LIGHT)
|
||||||
|
1 -> Preferences.saveTheme(requireContext(), Theme.DARK)
|
||||||
|
else -> Preferences.saveTheme(requireContext(), Theme.DARK)
|
||||||
|
}
|
||||||
|
|
||||||
|
(activity as MainActivity).restart()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -81,7 +81,8 @@ class HomeFragment : Fragment() {
|
|||||||
// continue watching
|
// continue watching
|
||||||
val upNextJob = lifecycleScope.launch {
|
val upNextJob = lifecycleScope.launch {
|
||||||
// TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately
|
// TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately
|
||||||
adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().toItemMediaList())
|
adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().items
|
||||||
|
.filter { !it.fullyWatched }.toItemMediaList())
|
||||||
binding.recyclerNewEpisodes.adapter = adapterUpNext
|
binding.recyclerNewEpisodes.adapter = adapterUpNext
|
||||||
}
|
}
|
||||||
asyncJobList.add(upNextJob)
|
asyncJobList.add(upNextJob)
|
||||||
|
@ -51,9 +51,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
println("onViewCreated")
|
|
||||||
|
|
||||||
binding.frameLoading.visibility = View.VISIBLE
|
binding.frameLoading.visibility = View.VISIBLE
|
||||||
|
|
||||||
// tab layout and pager
|
// tab layout and pager
|
||||||
|
@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment
|
|||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.mosad.teapod.R
|
||||||
import org.mosad.teapod.databinding.FragmentMediaEpisodesBinding
|
import org.mosad.teapod.databinding.FragmentMediaEpisodesBinding
|
||||||
import org.mosad.teapod.ui.activity.main.MainActivity
|
import org.mosad.teapod.ui.activity.main.MainActivity
|
||||||
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
|
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
|
||||||
@ -47,7 +48,11 @@ class MediaFragmentEpisodes : Fragment() {
|
|||||||
if (model.seasonsCrunchy.total < 2) {
|
if (model.seasonsCrunchy.total < 2) {
|
||||||
binding.buttonSeasonSelection.visibility = View.GONE
|
binding.buttonSeasonSelection.visibility = View.GONE
|
||||||
} else {
|
} else {
|
||||||
binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title
|
binding.buttonSeasonSelection.text = getString(
|
||||||
|
R.string.season_number_title,
|
||||||
|
model.currentSeasonCrunchy.seasonNumber,
|
||||||
|
model.currentSeasonCrunchy.title
|
||||||
|
)
|
||||||
binding.buttonSeasonSelection.setOnClickListener { v ->
|
binding.buttonSeasonSelection.setOnClickListener { v ->
|
||||||
showSeasonSelection(v)
|
showSeasonSelection(v)
|
||||||
}
|
}
|
||||||
@ -64,7 +69,12 @@ class MediaFragmentEpisodes : Fragment() {
|
|||||||
// TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus
|
// TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus
|
||||||
val popup = PopupMenu(requireContext(), v)
|
val popup = PopupMenu(requireContext(), v)
|
||||||
model.seasonsCrunchy.items.forEach { season ->
|
model.seasonsCrunchy.items.forEach { season ->
|
||||||
popup.menu.add(season.title).also {
|
popup.menu.add(getString(
|
||||||
|
R.string.season_number_title,
|
||||||
|
season.seasonNumber,
|
||||||
|
season.title
|
||||||
|
)
|
||||||
|
).also {
|
||||||
it.setOnMenuItemClickListener {
|
it.setOnMenuItemClickListener {
|
||||||
onSeasonSelected(season.id)
|
onSeasonSelected(season.id)
|
||||||
false
|
false
|
||||||
@ -86,7 +96,11 @@ class MediaFragmentEpisodes : Fragment() {
|
|||||||
// load the new season
|
// load the new season
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
model.setCurrentSeason(seasonId)
|
model.setCurrentSeason(seasonId)
|
||||||
binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title
|
binding.buttonSeasonSelection.text = getString(
|
||||||
|
R.string.season_number_title,
|
||||||
|
model.currentSeasonCrunchy.seasonNumber,
|
||||||
|
model.currentSeasonCrunchy.title
|
||||||
|
)
|
||||||
adapterRecEpisodes.notifyDataSetChanged()
|
adapterRecEpisodes.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
println(upNextSeries)
|
println(upNextSeries)
|
||||||
|
|
||||||
// load the preferred season (preferred language, language per season, not per stream)
|
// load the preferred season (preferred language, language per season, not per stream)
|
||||||
currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal)
|
currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocale)
|
||||||
|
|
||||||
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
|
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
|
||||||
listOf(
|
listOf(
|
||||||
|
@ -47,8 +47,6 @@ import org.mosad.teapod.parser.crunchyroll.NoneEpisodes
|
|||||||
import org.mosad.teapod.parser.crunchyroll.NonePlayback
|
import org.mosad.teapod.parser.crunchyroll.NonePlayback
|
||||||
import org.mosad.teapod.preferences.Preferences
|
import org.mosad.teapod.preferences.Preferences
|
||||||
import org.mosad.teapod.util.EpisodeMeta
|
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 org.mosad.teapod.util.tmdb.TMDBTVSeason
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@ -64,12 +62,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION")
|
private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION")
|
||||||
|
|
||||||
val currentEpisodeChangedListener = ArrayList<() -> Unit>()
|
val currentEpisodeChangedListener = ArrayList<() -> Unit>()
|
||||||
private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN
|
|
||||||
private var currentPlayhead: Long = 0
|
private var currentPlayhead: Long = 0
|
||||||
|
|
||||||
// tmdb/meta data TODO currently not implemented for cr
|
// tmdb/meta data
|
||||||
var mediaMeta: Meta? = null
|
// TODO meta data currently not implemented for cr
|
||||||
internal set
|
// var mediaMeta: Meta? = null
|
||||||
|
// internal set
|
||||||
var tmdbTVSeason: TMDBTVSeason? =null
|
var tmdbTVSeason: TMDBTVSeason? =null
|
||||||
internal set
|
internal set
|
||||||
var currentEpisodeMeta: EpisodeMeta? = null
|
var currentEpisodeMeta: EpisodeMeta? = null
|
||||||
@ -83,7 +81,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
var currentPlayback = NonePlayback
|
var currentPlayback = NonePlayback
|
||||||
|
|
||||||
// current playback settings
|
// current playback settings
|
||||||
var currentLanguage: Locale = Preferences.preferredLocal
|
var currentLanguage: Locale = Preferences.preferredLocale
|
||||||
internal set
|
internal set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -102,8 +100,6 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
if (!isPlaying) updatePlayhead()
|
if (!isPlaying) updatePlayhead()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
@ -130,7 +126,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
episodes = Crunchyroll.episodes(seasonId)
|
episodes = Crunchyroll.episodes(seasonId)
|
||||||
|
|
||||||
setCurrentEpisode(episodeId)
|
setCurrentEpisode(episodeId)
|
||||||
playCurrentMedia(currentPlayhead) // TODO, if fully watched, start from 0
|
playCurrentMedia(currentPlayhead)
|
||||||
|
|
||||||
// TODO reimplement for cr
|
// TODO reimplement for cr
|
||||||
// run async as it should be loaded by the time the episodes a
|
// run async as it should be loaded by the time the episodes a
|
||||||
@ -165,6 +161,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
* play the next episode, if nextEpisodeId is not null
|
* play the next episode, if nextEpisodeId is not null
|
||||||
*/
|
*/
|
||||||
fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
|
fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
|
||||||
|
updatePlayhead() // update playhead before switching to new episode
|
||||||
setCurrentEpisode(nextEpisodeId, startPlayback = true)
|
setCurrentEpisode(nextEpisodeId, startPlayback = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,7 +185,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
},
|
},
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
|
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
|
||||||
currentPlayhead = (it.playhead.times(1000)).toLong()
|
// if the episode was fully watched, start at the beginning
|
||||||
|
currentPlayhead = if (it.fullyWatched) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(it.playhead.times(1000)).toLong()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -220,8 +222,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url
|
currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// if no language tag is present use the first entry
|
||||||
currentLanguage = Locale.ROOT
|
currentLanguage = Locale.ROOT
|
||||||
currentPlayback.streams.adaptive_hls[Locale.ROOT.toLanguageTag()]?.url ?: ""
|
currentPlayback.streams.adaptive_hls.entries.first().value.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println("stream url: $url")
|
println("stream url: $url")
|
||||||
@ -263,25 +266,25 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
return episodes.items.lastOrNull()?.id == currentEpisode.id
|
return episodes.items.lastOrNull()?.id == currentEpisode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? {
|
|
||||||
val meta = mediaMeta
|
|
||||||
return if (meta is TVShowMeta) {
|
|
||||||
meta.episodes.firstOrNull { it.aodMediaId == aodMediaId }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO reimplement for cr
|
// TODO reimplement for cr
|
||||||
private suspend fun loadMediaMeta(aodId: Int): Meta? {
|
// fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? {
|
||||||
|
// val meta = mediaMeta
|
||||||
|
// return if (meta is TVShowMeta) {
|
||||||
|
// meta.episodes.firstOrNull { it.aodMediaId == aodMediaId }
|
||||||
|
// } else {
|
||||||
|
// null
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private suspend fun loadMediaMeta(aodId: Int): Meta? {
|
||||||
// return if (media.type == DataTypes.MediaType.TVSHOW) {
|
// return if (media.type == DataTypes.MediaType.TVSHOW) {
|
||||||
// MetaDBController().getTVShowMetadata(aodId)
|
// MetaDBController().getTVShowMetadata(aodId)
|
||||||
// } else {
|
// } else {
|
||||||
// null
|
// null
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
return null
|
// return null
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the playhead of the current episode, if currentPosition > 1000ms.
|
* Update the playhead of the current episode, if currentPosition > 1000ms.
|
||||||
|
@ -31,6 +31,7 @@ import com.afollestad.materialdialogs.customview.customView
|
|||||||
import com.afollestad.materialdialogs.customview.getCustomView
|
import com.afollestad.materialdialogs.customview.getCustomView
|
||||||
import org.mosad.teapod.R
|
import org.mosad.teapod.R
|
||||||
|
|
||||||
|
// TODO rework and port away from MaterialDialog
|
||||||
class LoginDialog(val context: Context, firstTry: Boolean) {
|
class LoginDialog(val context: Context, firstTry: Boolean) {
|
||||||
|
|
||||||
private val dialog = MaterialDialog(context, BottomSheet())
|
private val dialog = MaterialDialog(context, BottomSheet())
|
||||||
|
@ -3,8 +3,8 @@ package org.mosad.teapod.util
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import org.mosad.teapod.parser.crunchyroll.Collection
|
import org.mosad.teapod.parser.crunchyroll.Collection
|
||||||
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem
|
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem
|
||||||
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingList
|
|
||||||
import org.mosad.teapod.parser.crunchyroll.Item
|
import org.mosad.teapod.parser.crunchyroll.Item
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
fun TextView.setDrawableTop(drawable: Int) {
|
fun TextView.setDrawableTop(drawable: Int) {
|
||||||
this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0)
|
this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0)
|
||||||
@ -23,7 +23,23 @@ fun Collection<Item>.toItemMediaList(): List<ItemMedia> {
|
|||||||
|
|
||||||
@JvmName("toItemMediaListContinueWatchingItem")
|
@JvmName("toItemMediaListContinueWatchingItem")
|
||||||
fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
|
fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
|
||||||
return this.items.map {
|
return items.map {
|
||||||
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
|
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun List<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
|
||||||
|
return this.map {
|
||||||
|
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Locale.toDisplayString(fallback: String): String {
|
||||||
|
return if (this.displayLanguage.isNotEmpty() && this.displayCountry.isNotEmpty()) {
|
||||||
|
"${this.displayLanguage} (${this.displayCountry})"
|
||||||
|
} else if (this.displayCountry.isNotEmpty()) {
|
||||||
|
this.displayLanguage
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -22,16 +22,19 @@
|
|||||||
|
|
||||||
package org.mosad.teapod.util.tmdb
|
package org.mosad.teapod.util.tmdb
|
||||||
|
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import android.util.Log
|
||||||
import com.github.kittinunf.fuel.core.FuelError
|
import io.ktor.client.*
|
||||||
import com.github.kittinunf.fuel.core.Parameters
|
import io.ktor.client.call.*
|
||||||
import com.github.kittinunf.fuel.json.FuelJson
|
import io.ktor.client.features.json.*
|
||||||
import com.github.kittinunf.fuel.json.responseJson
|
import io.ktor.client.features.json.serializer.*
|
||||||
import com.github.kittinunf.result.Result
|
import io.ktor.client.request.*
|
||||||
import kotlinx.coroutines.*
|
import io.ktor.client.statement.*
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.invoke
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.mosad.teapod.preferences.Preferences
|
||||||
import org.mosad.teapod.util.concatenate
|
import org.mosad.teapod.util.concatenate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,30 +44,41 @@ import org.mosad.teapod.util.concatenate
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
class TMDBApiController {
|
class TMDBApiController {
|
||||||
|
private val classTag = javaClass.name
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val client = HttpClient {
|
||||||
|
install(JsonFeature) {
|
||||||
|
serializer = KotlinxSerializer(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val apiUrl = "https://api.themoviedb.org/3"
|
private val apiUrl = "https://api.themoviedb.org/3"
|
||||||
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
|
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
|
||||||
private val language = "de"
|
|
||||||
|
|
||||||
companion object{
|
companion object{
|
||||||
const val imageUrl = "https://image.tmdb.org/t/p/w500"
|
const val imageUrl = "https://image.tmdb.org/t/p/w500"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun request(
|
private suspend inline fun <reified T> request(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
parameters: Parameters = emptyList()
|
parameters: List<Pair<String, Any?>> = emptyList()
|
||||||
): Result<FuelJson, FuelError> = coroutineScope {
|
): T = coroutineScope {
|
||||||
val path = "$apiUrl$endpoint"
|
val path = "$apiUrl$endpoint"
|
||||||
val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters)
|
val params = concatenate(
|
||||||
|
listOf("api_key" to apiKey, "language" to Preferences.preferredLocale.language),
|
||||||
|
parameters
|
||||||
|
)
|
||||||
|
|
||||||
// TODO handle FileNotFoundException
|
// TODO handle FileNotFoundException
|
||||||
return@coroutineScope (Dispatchers.IO) {
|
return@coroutineScope (Dispatchers.IO) {
|
||||||
val (_, _, result) = Fuel.get(path, params)
|
val response: HttpResponse = client.get(path) {
|
||||||
.responseJson()
|
params.forEach {
|
||||||
|
parameter(it.first, it.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
response.receive<T>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +92,12 @@ class TMDBApiController {
|
|||||||
val searchEndpoint = "/search/multi"
|
val searchEndpoint = "/search/multi"
|
||||||
val parameters = listOf("query" to query, "include_adult" to false)
|
val parameters = listOf("query" to query, "include_adult" to false)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(searchEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBSearchMovie
|
Log.e(classTag, "SerializationException in searchMovie(), with query = $query.", ex)
|
||||||
|
NoneTMDBSearchMovie
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,10 +110,12 @@ class TMDBApiController {
|
|||||||
val searchEndpoint = "/search/tv"
|
val searchEndpoint = "/search/tv"
|
||||||
val parameters = listOf("query" to query, "include_adult" to false)
|
val parameters = listOf("query" to query, "include_adult" to false)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(searchEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBSearchTVShow
|
Log.e(classTag, "SerializationException in searchTVShow(), with query = $query.", ex)
|
||||||
|
NoneTMDBSearchTVShow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -109,10 +127,12 @@ class TMDBApiController {
|
|||||||
val movieEndpoint = "/movie/$movieId"
|
val movieEndpoint = "/movie/$movieId"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(movieEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(movieEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBMovie
|
Log.e(classTag, "SerializationException in getMovieDetails(), with movieId = $movieId.", ex)
|
||||||
|
NoneTMDBMovie
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -124,10 +144,12 @@ class TMDBApiController {
|
|||||||
val tvShowEndpoint = "/tv/$tvId"
|
val tvShowEndpoint = "/tv/$tvId"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(tvShowEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(tvShowEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBTVShow
|
Log.e(classTag, "SerializationException in getTVShowDetails(), with tvId = $tvId.", ex)
|
||||||
|
NoneTMDBTVShow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
@ -141,10 +163,12 @@ class TMDBApiController {
|
|||||||
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
|
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(tvShowSeasonEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(tvShowSeasonEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBTVSeason
|
Log.e(classTag, "SerializationException in getTVSeasonDetails(), with tvId = $tvId, seasonNumber = $seasonNumber.", ex)
|
||||||
|
NoneTMDBTVSeason
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -110,8 +110,8 @@ data class TMDBTVShow(
|
|||||||
|
|
||||||
// use null for nullable types, the gui needs to handle/implement a fallback for null values
|
// use null for nullable types, the gui needs to handle/implement a fallback for null values
|
||||||
val NoneTMDB = TMDBBase(0, "", "", null, null)
|
val NoneTMDB = TMDBBase(0, "", "", null, null)
|
||||||
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "")
|
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "1970-01-01", null, "")
|
||||||
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "")
|
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "1970-01-01", "1970-01-01", "")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TMDBTVSeason(
|
data class TMDBTVSeason(
|
||||||
|
5
app/src/main/res/drawable/ic_baseline_language_24.xml
Normal file
5
app/src/main/res/drawable/ic_baseline_language_24.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z"/>
|
||||||
|
</vector>
|
@ -146,6 +146,46 @@
|
|||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/linear_settings_content_language"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="7dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imageView4"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="@string/settings_content_language"
|
||||||
|
android:minWidth="48dp"
|
||||||
|
android:minHeight="48dp"
|
||||||
|
android:padding="9dp"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/ic_baseline_language_24"
|
||||||
|
app:tint="?iconColor" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_settings_content_language"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_content_language"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_settings_content_language_desc"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_content_language_desc"
|
||||||
|
android:textColor="?textSecondary" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/linear_settings_secondary"
|
android:id="@+id/linear_settings_secondary"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@ -158,7 +198,7 @@
|
|||||||
android:id="@+id/imageView3"
|
android:id="@+id/imageView3"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:contentDescription="@string/settings_secondary"
|
android:contentDescription="@string/settings_prefer_subbed"
|
||||||
android:minWidth="48dp"
|
android:minWidth="48dp"
|
||||||
android:minHeight="48dp"
|
android:minHeight="48dp"
|
||||||
android:padding="9dp"
|
android:padding="9dp"
|
||||||
@ -185,7 +225,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="@string/settings_secondary"
|
android:text="@string/settings_prefer_subbed"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@ -194,7 +234,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:maxLines="2"
|
android:maxLines="2"
|
||||||
android:text="@string/settings_secondary_desc"
|
android:text="@string/settings_prefer_subbed_desc"
|
||||||
android:textColor="?textSecondary" />
|
android:textColor="?textSecondary" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
@ -39,8 +39,11 @@
|
|||||||
<string name="info">Info</string>
|
<string name="info">Info</string>
|
||||||
<string name="info_about_desc">Version %1$s (%2$s)</string>
|
<string name="info_about_desc">Version %1$s (%2$s)</string>
|
||||||
<string name="settings">Einstellungen</string>
|
<string name="settings">Einstellungen</string>
|
||||||
<string name="settings_secondary">Bevorzuge Japanisch (OmU)</string>
|
<string name="settings_content_language">Bevorzuge Inhaltssprache</string>
|
||||||
<string name="settings_secondary_desc">Japanisch verwenden, sofern vorhanden</string>
|
<string name="settings_content_language_desc">Englisch</string>
|
||||||
|
<string name="settings_content_language_none">Keine</string>
|
||||||
|
<string name="settings_prefer_subbed">Bevorzuge OmU</string>
|
||||||
|
<string name="settings_prefer_subbed_desc">Original Sprache verwenden, sofern vorhanden</string>
|
||||||
<string name="settings_autoplay">Autoplay</string>
|
<string name="settings_autoplay">Autoplay</string>
|
||||||
<string name="settings_autoplay_desc">Nächste Episode automatisch abspielen</string>
|
<string name="settings_autoplay_desc">Nächste Episode automatisch abspielen</string>
|
||||||
<string name="theme">Design</string>
|
<string name="theme">Design</string>
|
||||||
|
@ -34,24 +34,25 @@
|
|||||||
<item quantity="one">%d Minute</item>
|
<item quantity="one">%d Minute</item>
|
||||||
<item quantity="other">%d Minutes</item>
|
<item quantity="other">%d Minutes</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="season_number_title" translatable="false">S%1$d - %2$s</string>
|
||||||
<string name="similar_titles">Similar titles</string>
|
<string name="similar_titles">Similar titles</string>
|
||||||
<string name="component_episode_title">Ep. %1$s %2$s</string>
|
<string name="component_episode_title">Ep. %1$s %2$s</string>
|
||||||
<string name="component_episode_title_sub">Ep. %1$s %2$s (Sub)</string>
|
<string name="component_episode_title_sub">Ep. %1$s %2$s (Sub)</string>
|
||||||
<string name="component_poster_desc" translatable="false">episode poster</string>
|
<string name="component_poster_desc" translatable="false">episode poster</string>
|
||||||
<string name="component_watched_desc" translatable="false">already watched</string>
|
<string name="component_watched_desc" translatable="false">already watched</string>
|
||||||
|
|
||||||
<!-- settings fragment -->
|
<!-- account fragment -->
|
||||||
<string name="account">Account</string>
|
<string name="account">Account</string>
|
||||||
<string name="account_login_ex" translatable="false">user@example.com</string>
|
<string name="account_login_ex" translatable="false">user@example.com</string>
|
||||||
<string name="account_login_desc">Tap to edit</string>
|
<string name="account_login_desc">Tap to edit</string>
|
||||||
<string name="account_subscription">Subscription %1$s</string>
|
<string name="account_subscription">Subscription %1$s</string>
|
||||||
<string name="account_subscription_desc">Tap to extend</string>
|
<string name="account_subscription_desc">Tap to extend</string>
|
||||||
<string name="info">Info</string>
|
|
||||||
<string name="info_about" translatable="false">Teapod by @Seil0</string>
|
|
||||||
<string name="info_about_desc">Version %1$s (%2$s)</string>
|
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="settings_secondary">Prefer japanese (sub)</string>
|
<string name="settings_content_language">Preferred content language</string>
|
||||||
<string name="settings_secondary_desc">Use the japanese, if present</string>
|
<string name="settings_content_language_desc">English</string>
|
||||||
|
<string name="settings_content_language_none">None</string>
|
||||||
|
<string name="settings_prefer_subbed">Prefer subbed</string>
|
||||||
|
<string name="settings_prefer_subbed_desc">Use original language, if present</string>
|
||||||
<string name="settings_autoplay">Autoplay</string>
|
<string name="settings_autoplay">Autoplay</string>
|
||||||
<string name="settings_autoplay_desc">Play next episode automatically</string>
|
<string name="settings_autoplay_desc">Play next episode automatically</string>
|
||||||
<string name="theme">Theme</string>
|
<string name="theme">Theme</string>
|
||||||
@ -63,7 +64,9 @@
|
|||||||
<string name="import_data">import data</string>
|
<string name="import_data">import data</string>
|
||||||
<string name="import_data_desc">import "My list" from a file</string>
|
<string name="import_data_desc">import "My list" from a file</string>
|
||||||
<string name="import_data_success">imported "My list" successfully</string>
|
<string name="import_data_success">imported "My list" successfully</string>
|
||||||
|
<string name="info">Info</string>
|
||||||
|
<string name="info_about" translatable="false">Teapod by @Seil0</string>
|
||||||
|
<string name="info_about_desc">Version %1$s (%2$s)</string>
|
||||||
|
|
||||||
<!-- about fragment -->
|
<!-- about fragment -->
|
||||||
<string name="version">Version</string>
|
<string name="version">Version</string>
|
||||||
@ -128,7 +131,9 @@
|
|||||||
<string name="preference_file_key" translatable="false">org.mosad.teapod.preferences</string>
|
<string name="preference_file_key" translatable="false">org.mosad.teapod.preferences</string>
|
||||||
<string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string>
|
<string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string>
|
||||||
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
|
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
|
||||||
|
<!-- for legacy reasons the prefer subbed key is called prefer_secondary-->
|
||||||
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
|
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
|
||||||
|
<string name="save_key_preferred_local" translatable="false">org.mosad.teapod.preferred_local</string>
|
||||||
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
|
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
|
||||||
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>
|
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>
|
||||||
<string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string>
|
<string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string>
|
||||||
|
@ -36,15 +36,30 @@
|
|||||||
<item name="shapeTextBackground">@color/textBackgroundDark</item>
|
<item name="shapeTextBackground">@color/textBackgroundDark</item>
|
||||||
<item name="iconColor">@color/iconColorDark</item>
|
<item name="iconColor">@color/iconColorDark</item>
|
||||||
<item name="buttonBackground">@color/buttonBackgroundDark</item>
|
<item name="buttonBackground">@color/buttonBackgroundDark</item>
|
||||||
|
|
||||||
<item name="md_background_color">@color/themeSecondaryDark</item>
|
<item name="md_background_color">@color/themeSecondaryDark</item>
|
||||||
<item name="md_color_content">@color/textSecondaryDark</item>
|
<item name="md_color_content">@color/textSecondaryDark</item>
|
||||||
|
|
||||||
<!-- without this, the unchecked single choice buttons while be black -->
|
<!-- without this, the unchecked single choice buttons while be black -->
|
||||||
<item name="md_color_widget_unchecked">@color/textSecondaryDark</item>
|
<item name="md_color_widget_unchecked">@color/textSecondaryDark</item>
|
||||||
|
|
||||||
|
<item name="materialAlertDialogTheme">@style/ThemeOverlay.App.MaterialAlertDialog.Dark</item>
|
||||||
<!-- change on click indicator color for manually set components -->
|
<!-- change on click indicator color for manually set components -->
|
||||||
<item name="colorControlHighlight">@color/controlHighlightDark</item>
|
<item name="colorControlHighlight">@color/controlHighlightDark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- dialog themes -->
|
||||||
|
<style name="ThemeOverlay.App.MaterialAlertDialog.Dark" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog">
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorSurface">@color/themeSecondaryDark</item>
|
||||||
|
<item name="colorOnSurface">@color/textPrimaryDark</item>
|
||||||
|
<item name="android:colorControlNormal">@color/textSecondaryDark</item> <!-- Radio button unchecked-->
|
||||||
|
<item name="materialAlertDialogTitleTextStyle">@style/MaterialAlertDialog.App.Title.Text</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="MaterialAlertDialog.App.Title.Text" parent="MaterialAlertDialog.MaterialComponents.Title.Text">
|
||||||
|
<item name="android:textColor">?textPrimary</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- player theme -->
|
<!-- player theme -->
|
||||||
<style name="PlayerTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
|
<style name="PlayerTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
|
||||||
<item name="android:windowNoTitle">true</item>
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = "1.6.10"
|
ext.kotlin_version = "1.6.10"
|
||||||
|
ext.ktor_version = "1.6.7"
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:7.1.0'
|
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
Teapod ist eine inoffizielle App für Anime-on-Demand (AoD).
|
Teapod ist eine inoffizielle App für Crunchyroll.
|
||||||
|
|
||||||
* Schau dir alle Titel von AoD auf deinem Android Gerät an
|
* Schau dir alle Titel von Crunchyroll auf deinem Android Gerät an
|
||||||
* Nativer Player auf Basis des ExoPayers
|
* Nativer Player auf Basis des ExoPayers
|
||||||
* Bevorzuge die OmU Version über die App-Einstellungen
|
* Bevorzuge die OmU Version über die App-Einstellungen
|
||||||
* Speicher deine lieblings Anime in "Meine Liste"
|
|
||||||
|
|
||||||
Um Teapod zu verwenden musst du dich mit deinem AoD Account anmelden.
|
Um Teapod zu verwenden musst du dich mit deinem Crunchyroll Account anmelden.
|
||||||
Dieses Projekt ist in keiner Weise mit Anime-on-Demand verbunden.
|
Dieses Projekt ist in keiner Weise mit Crunchyroll verbunden.
|
||||||
|
|
||||||
Bitte melde Fehler und Probleme an support@mosad.xyz
|
Bitte melde Fehler und Probleme an support@mosad.xyz
|
||||||
|
@ -1 +1 @@
|
|||||||
Android App für AoD
|
Android App für Crunchyroll
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
Teapod is a unofficial App for Anime-on-Demand (AoD).
|
Teapod is a unofficial App for Crunchyroll.
|
||||||
|
|
||||||
* Watch all animes from AoD on your Android device
|
* Watch all animes from Crunchyroll on your Android device
|
||||||
* Native Player based on ExoPayer
|
* Native Player based on ExoPayer
|
||||||
* Prefer the OmU version via the app settings
|
* Prefer the OmU version via the app settings
|
||||||
* Save your favorite animes to "My List"
|
|
||||||
|
|
||||||
To use Teapod you have to login with your AoD account.
|
To use Teapod you have to login with your Crunchyroll account.
|
||||||
This Project is not associated with Anime-on-Demand in any way.
|
This Project is not associated with Crunchyroll in any way.
|
||||||
|
|
||||||
Please report bugs and issues to support@mosad.xyz
|
Please report bugs and issues to support@mosad.xyz
|
||||||
|
@ -1 +1 @@
|
|||||||
Android App for AoD
|
Android App for Crunchyroll
|
||||||
|
Reference in New Issue
Block a user