28 Commits

Author SHA1 Message Date
35563eeefe refresh access token, if it is expired, before doing a request 2022-01-30 12:22:25 +01:00
281d4b5625 MediaFragment: update playhead progress/fully watched on resume 2022-01-29 20:32:45 +01:00
f65497e06a Player: load media async and use playhead for initial episode 2022-01-29 19:56:39 +01:00
1e54bc7983 don't show next ep button or autoplay if the current ep is the last ep
next_episode_id can be non null, even if it's the last episode
2022-01-18 18:03:56 +01:00
87cc4c5ec1 the media type should not change while playing a media (tv show/movie) 2022-01-09 19:24:21 +01:00
b79d962b8d implement playhead reporting to crunchyroll 2022-01-09 19:23:33 +01:00
a10287f747 add upNextSeries
the MediaFragment will show the next episodes title instead for the series title and play the "next up" episode when the play button is clicked
2022-01-09 18:41:23 +01:00
e98e75456e Update the onboarding process to support crunchyroll
* only save credentials during onboarding, if login was successful
* show onboarding, if login failed
2022-01-08 19:20:21 +01:00
349a0e451a add highlight (random of newly added (n=10)) 2022-01-06 18:57:49 +01:00
22d2d777c8 add newly added title to HomeFragment
* add support for season_list to crunchyroll parser
2022-01-06 18:39:23 +01:00
04b1ac5a53 add playheads to crunchyroll parser
* show watched icon, if episode has been fully watched
* add seasonTag to browse()
2022-01-05 01:28:39 +01:00
2fa5a0aacd add up next to home screen
for now up next will show the series and not play the actual episode
2022-01-05 00:28:49 +01:00
9062474180 add watchlist to home fragment 2022-01-03 14:49:15 +01:00
450fd259c6 fix proguard for changes in 7491e7fd93056569a823b292483a114300ca86fb 2022-01-03 14:49:09 +01:00
6dcc50c12a add watchlist support for media fragment 2022-01-03 14:49:04 +01:00
90069e2518 update copyright/license notice 2022-01-03 14:48:58 +01:00
0866ce5917 replace tmdb multi search with type search (movie/tv)
multi search often retuns a wrong result, therfore use movie or tv show search
2022-01-03 14:48:50 +01:00
9f47304b55 move TMDBApiCOntroller to Fuel and kotlinx.serialization
* add year and maturityRatings to MediaFragment
* don't show season selection if only one season is present
2022-01-03 14:48:42 +01:00
206a00fed5 add subtitle selection to player 2022-01-03 14:48:37 +01:00
a14db062ed implement season selection in MediaFragment 2022-01-03 14:48:34 +01:00
b21e9c7abd implement preferred season/languag choosing in MediaFragment 2022-01-03 14:48:29 +01:00
51e214d3c1 add search for tv shows
media items are currently not selectable, the app will crash
2022-01-03 14:48:24 +01:00
2d2c7b2580 implement lazy loading for LibraryFragment & code cleanup 2022-01-03 14:48:15 +01:00
6dac929550 add support for crunchyroll media playback in player 2022-01-03 14:48:11 +01:00
919bce65e9 Implement media fragment for tv shows 2022-01-03 14:48:04 +01:00
4f5f111afe implement index call
index is needed to retrieve identifiers necessary for streaming
2022-01-03 14:46:45 +01:00
e6fd5d6952 add rudimentary parsing for browsing results 2022-01-03 14:46:07 +01:00
e7d057bfb8 add crunchyroll login and browse (no parsing for now) 2022-01-03 14:45:46 +01:00
73 changed files with 1473 additions and 2572 deletions

View File

@ -1,13 +1,14 @@
# Teapod # Teapod
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. 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.
[<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 Crunchyroll on your Android device * Watch all animes from AoD 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)
@ -16,10 +17,10 @@ Teapod is a unofficial App for Crunchyroll. It allows you to watch all your favo
[<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 Crunchyroll in any way. 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.
### Contributing ### Contributing
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. 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.
### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe) ### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe)

View File

@ -1,19 +1,20 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
id 'kotlin-android-extensions'
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version" id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
} }
android { android {
compileSdkVersion 31 compileSdkVersion 30
buildToolsVersion "30.0.3" buildToolsVersion "30.0.3"
defaultConfig { defaultConfig {
applicationId "org.mosad.teapod" applicationId "org.mosad.teapod"
minSdkVersion 23 minSdkVersion 23
targetSdkVersion 31 targetSdkVersion 30
versionCode 9010 //00.09.010 versionCode 4200 //00.04.200
versionName "1.0.0-beta2" versionName "1.0.0-alpha3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime() resValue "string", "build_time", buildTime()
@ -38,43 +39,42 @@ android {
} }
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '1.8'
kotlin.sourceSets.all {
languageSettings.optIn("kotlin.RequiresOptIn")
} }
} }
namespace 'org.mosad.teapod'
}
dependencies { dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"]) implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1'
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.core:core-splashscreen:1.0.0-rc01' implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-fragment-ktx:2.4.2' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.4.2'
implementation 'androidx.security:security-crypto:1.1.0-alpha03' implementation 'androidx.security:security-crypto:1.1.0-alpha03'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'com.google.android.material:material:1.5.0' implementation 'com.google.android.material:material:1.4.0'
implementation "com.google.android.exoplayer:exoplayer-core:$exo_version" implementation 'com.google.code.gson:gson:2.8.8'
implementation "com.google.android.exoplayer:exoplayer-hls:$exo_version" implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
implementation "com.google.android.exoplayer:exoplayer-dash:$exo_version" implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0'
implementation "com.google.android.exoplayer:exoplayer-ui:$exo_version" implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0'
implementation "com.google.android.exoplayer:extension-mediasession:$exo_version" implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
implementation 'com.github.bumptech.glide:glide:4.13.1' implementation 'org.jsoup:jsoup:1.14.2'
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'jp.wasabeef:glide-transformations:4.3.0'
implementation 'com.afollestad.material-dialogs:core:3.3.0'
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0'
implementation "io.ktor:ktor-client-core:$ktor_version" implementation 'com.github.kittinunf.fuel:fuel:2.3.1'
implementation "io.ktor:ktor-client-android:$ktor_version" implementation 'com.github.kittinunf.fuel:fuel-android:2.3.1'
implementation "io.ktor:ktor-client-serialization:$ktor_version" implementation 'com.github.kittinunf.fuel:fuel-json:2.3.1'
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'

View File

@ -24,6 +24,10 @@
-keep class org.json.** { *; } -keep class org.json.** { *; }
#Gson
-keepattributes Signature
-dontwarn sun.misc.**
# kotlinx.serialization # kotlinx.serialization
# Keep `Companion` object fields of serializable classes. # Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
package="org.mosad.teapod">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@ -12,27 +13,32 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme.Dark"> android:theme="@style/AppTheme.Dark">
<activity <activity
android:exported="true" android:name="org.mosad.teapod.ui.activity.SplashActivity"
android:name="org.mosad.teapod.ui.activity.main.MainActivity" android:label="@string/app_name"
android:screenOrientation="portrait" android:theme="@style/SplashTheme"
android:theme="@style/Theme.App.Starting"> android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:exported="false"
android:name="org.mosad.teapod.ui.activity.onboarding.OnboardingActivity" android:name="org.mosad.teapod.ui.activity.onboarding.OnboardingActivity"
android:label="@string/app_name"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:launchMode="singleTop" android:launchMode="singleTop"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustPan">
</activity> </activity>
<activity <activity
android:exported="false" android:name="org.mosad.teapod.ui.activity.main.MainActivity"
android:label="@string/app_name"
android:screenOrientation="portrait">
</activity>
<activity
android:name="org.mosad.teapod.ui.activity.player.PlayerActivity" android:name="org.mosad.teapod.ui.activity.player.PlayerActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|layoutDirection" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|layoutDirection"
android:autoRemoveFromRecents="true" android:autoRemoveFromRecents="true"
android:label="@string/app_name"
android:launchMode="singleTask" android:launchMode="singleTask"
android:parentActivityName="org.mosad.teapod.ui.activity.main.MainActivity" android:parentActivityName="org.mosad.teapod.ui.activity.main.MainActivity"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"

View File

@ -1,64 +1,31 @@
/**
* 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 io.ktor.client.* import com.github.kittinunf.fuel.Fuel
import io.ktor.client.call.* import com.github.kittinunf.fuel.core.FuelError
import io.ktor.client.features.* 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.SerializationException import kotlinx.serialization.decodeFromString
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
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
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 lateinit var token: Token private var accessToken = ""
private var tokenType = ""
private var tokenValidUntil: Long = 0 private var tokenValidUntil: Long = 0
@OptIn(DelicateCoroutinesApi::class)
private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext")
private var accountID = "" private var accountID = ""
@ -66,20 +33,11 @@ object Crunchyroll {
private var signature = "" private var signature = ""
private var keyPairID = "" private var keyPairID = ""
private val browsingCache = hashMapOf<String, BrowseResult>() // TODO temp helper vary
private var locale: String = Preferences.preferredLocal.toLanguageTag()
private var country: String = Preferences.preferredLocal.country
/** 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.
@ -91,36 +49,39 @@ 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 = Parameters.build { val formData = listOf(
append("username", username) "username" to username,
append("password", password) "password" to password,
append("grant_type", "password") "grant_type" to "password",
append("scope", "offline_access") "scope" to "offline_access"
} )
var success = false// is false var success: Boolean // is false
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Log.i(TAG, "getting token ...") val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData)
.header("Content-Type", "application/x-www-form-urlencoded")
.appendHeader(
"Authorization",
"Basic "
)
.responseJson()
val status = try { // TODO fix JSONException: No value for
val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { result.component1()?.obj()?.let {
header("Authorization", "Basic $basicApiToken") accessToken = it.get("access_token").toString()
} tokenType = it.get("token_type").toString()
token = response.receive()
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000) // token will be invalid 1 sec
response.status val expiresIn = (it.get("expires_in").toString().toLong() - 1)
} catch (ex: ClientRequestException) { tokenValidUntil = System.currentTimeMillis() + (expiresIn * 1000)
val status = ex.response.status
if (status == HttpStatusCode.Unauthorized) {
Log.e(TAG, "Could not complete login: " +
"${status.value} ${status.description}. " +
"Probably wrong username or password")
} }
status // println("request: $request")
} // println("response: $response")
Log.i(TAG, "Login complete with code $status") // println("response: $result")
success = (status == HttpStatusCode.OK)
Log.i(javaClass.name, "login complete with code ${response.statusCode}")
success = (response.statusCode == 200)
} }
return@runBlocking success return@runBlocking success
@ -134,76 +95,56 @@ object Crunchyroll {
* Requests: get, post, delete * Requests: get, post, delete
*/ */
private suspend inline fun <reified T> request( private suspend fun request(
url: String, endpoint: String,
httpMethod: HttpMethod, params: Parameters = listOf(),
params: List<Pair<String, Any?>> = listOf(), url: String = ""
bodyObject: Any = Any() ): Result<FuelJson, FuelError> = coroutineScope {
): T = coroutineScope { val path = url.ifEmpty { "$baseUrl$endpoint" }
withContext(tokenRefreshContext) {
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
}
return@coroutineScope (Dispatchers.IO) { return@coroutineScope (Dispatchers.IO) {
val response: T = client.request(url) { val (request, response, result) = Fuel.get(path, params)
method = httpMethod .header("Authorization", "$tokenType $accessToken")
header("Authorization", "${token.tokenType} ${token.accessToken}") .responseJson()
params.forEach {
parameter(it.first, it.second)
}
// for json set body and content type // println("request request: $request")
if (bodyObject is JsonObject) { // println("request response: $response")
body = bodyObject // println("request result: $result")
contentType(ContentType.Application.Json)
}
}
response result
} }
} }
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: List<Pair<String, Any?>> = listOf(), params: Parameters = listOf(),
bodyObject: JsonObject body: String
) { ) = coroutineScope {
val path = "$baseUrl$endpoint" val path = "$baseUrl$endpoint"
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject) withContext(Dispatchers.IO) {
Log.i(TAG, "Response: $response") Fuel.post(path, params)
.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: List<Pair<String, Any?>> = listOf(), params: Parameters = listOf(),
url: String = "" url: String = ""
) = coroutineScope { ) = coroutineScope {
val path = url.ifEmpty { "$baseUrl$endpoint" } val path = url.ifEmpty { "$baseUrl$endpoint" }
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
val response: HttpResponse = request(path, HttpMethod.Delete, params) withContext(Dispatchers.IO) {
Log.i(TAG, "Response: $response") Fuel.delete(path, params)
.header("Authorization", "$tokenType $accessToken")
.response() // without a response, crunchy doesn't accept the request
}
} }
/** /**
@ -217,15 +158,17 @@ object Crunchyroll {
*/ */
suspend fun index() { suspend fun index() {
val indexEndpoint = "/index/v2" val indexEndpoint = "/index/v2"
val result = request(indexEndpoint)
val index: Index = requestGet(indexEndpoint) result.component1()?.obj()?.getJSONObject("cms")?.let {
policy = index.cms.policy policy = it.get("policy").toString()
signature = index.cms.signature signature = it.get("signature").toString()
keyPairID = index.cms.keyPairId keyPairID = it.get("key_pair_id").toString()
}
Log.i(TAG, "Policy : $policy") println("policy: $policy")
Log.i(TAG, "Signature : $signature") println("signature: $signature")
Log.i(TAG, "Key Pair ID : $keyPairID") println("keyPairID: $keyPairID")
} }
/** /**
@ -236,21 +179,18 @@ object Crunchyroll {
*/ */
suspend fun account() { suspend fun account() {
val indexEndpoint = "/accounts/v1/me" val indexEndpoint = "/accounts/v1/me"
val result = request(indexEndpoint)
val account: Account = try { result.component1()?.obj()?.let {
requestGet(indexEndpoint) accountID = it.get("account_id").toString()
} 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
/** /**
* Browse the media available on crunchyroll. * Browse the media available on crunchyroll.
* *
@ -260,81 +200,47 @@ object Crunchyroll {
* @return A **[BrowseResult]** object is returned. * @return A **[BrowseResult]** object is returned.
*/ */
suspend fun browse( suspend fun browse(
categories: List<Categories> = emptyList(),
sortBy: SortBy = SortBy.ALPHABETICAL, sortBy: SortBy = SortBy.ALPHABETICAL,
seasonTag: String = "", seasonTag: String = "",
start: Int = 0, start: Int = 0,
n: Int = 10 n: Int = 10
): BrowseResult { ): BrowseResult {
val browseEndpoint = "/content/v1/browse" val browseEndpoint = "/content/v1/browse"
val parameters = mutableListOf( val noneOptParams = listOf("sort_by" to sortBy.str, "start" to start, "n" to n)
"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
if (seasonTag.isNotEmpty()) { val parameters = if (seasonTag.isNotEmpty()) {
parameters.add("season_tag" to seasonTag) concatenate(noneOptParams, listOf("season_tag" to seasonTag))
}
// if a season tag is present add it to the parameters
if (categories.isNotEmpty()) {
parameters.add("categories" to categories.joinToString(",") { it.str })
}
// fetch result if not already cached
if (browsingCache.contains(parameters.toString())) {
Log.d(TAG, "browse result cached: $parameters")
} else { } else {
Log.d(TAG, "browse result not cached, fetching: $parameters") noneOptParams
val browseResult: BrowseResult = try {
requestGet(browseEndpoint, parameters)
}catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in browse().", ex)
NoneBrowseResult
} }
// if the cache has more than 100 entries clear it, so it doesn't become a memory problem val result = request(browseEndpoint, parameters)
// Note: this value is totally guessed and should be replaced by a properly researched value val browseResult = result.component1()?.obj()?.let {
if (browsingCache.size > 100) { json.decodeFromString(it.toString())
} ?: NoneBrowseResult
// add results to cache TODO improve
browsingCache.clear() browsingCache.clear()
} browsingCache.addAll(browseResult.items)
// add results to cache return browseResult
browsingCache[parameters.toString()] = browseResult
}
return browsingCache[parameters.toString()] ?: NoneBrowseResult
} }
/** /**
* Search fo a query term. * TODO
* Note: currently this function only supports series/tv shows.
*
* @param query The query term as String
* @param n The maximum number of results to return, default = 10
* @return A **[SearchResult]** object
*/ */
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( val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series")
"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 try { return result.component1()?.obj()?.let {
requestGet(searchEndpoint, parameters) json.decodeFromString(it.toString())
}catch (ex: SerializationException) { } ?: NoneSearchResult
Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex)
NoneSearchResult
}
} }
/** /**
@ -347,18 +253,17 @@ 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 Preferences.preferredLocale.toLanguageTag(), "locale" to locale,
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
return try { val result = request(episodesEndpoint, parameters)
requestGet(episodesEndpoint, parameters)
}catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in objects().", ex) json.decodeFromString(it.toString())
NoneCollection } ?: NoneCollection
}
} }
/** /**
@ -367,14 +272,13 @@ 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 Preferences.preferredLocale.toLanguageTag()) val parameters = listOf("locale" to locale)
return try { val result = request(seasonListEndpoint, parameters)
requestGet(seasonListEndpoint, parameters)
}catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in seasonList().", ex) json.decodeFromString(it.toString())
NoneDiscSeasonList } ?: NoneDiscSeasonList
}
} }
/** /**
@ -385,108 +289,82 @@ 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/${token.country}/M3/crunchyroll/series/$seriesId" val seriesEndpoint = "/cms/v2/$country/M3/crunchyroll/series/$seriesId"
val parameters = listOf( val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(), "locale" to locale,
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
return try { val result = request(seriesEndpoint, parameters)
requestGet(seriesEndpoint, parameters)
}catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in series().", ex) json.decodeFromString(it.toString())
NoneSeries } ?: NoneSeries
}
} }
/** /**
* Get the next episode for a series. * TODO
*
* @param seriesId The series id for which to call up next
* @return A **[UpNextSeriesItem]** with a Panel representing the up next episode
*/ */
suspend fun upNextSeries(seriesId: String): UpNextSeriesItem { suspend fun upNextSeries(seriesId: String): UpNextSeriesItem {
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 Preferences.preferredLocale.toLanguageTag() "locale" to locale
) )
return try { val result = request(upNextSeriesEndpoint, parameters)
requestGet(upNextSeriesEndpoint, parameters)
}catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in upNextSeries().", ex) json.decodeFromString(it.toString())
NoneUpNextSeriesItem } ?: NoneUpNextSeriesItem
}
} }
/**
* Get all available seasons for a series.
*
* @param seriesId The series id for which to get the seasons
* @return A **[Seasons]** object with a list of **[Season]**
*/
suspend fun seasons(seriesId: String): Seasons { suspend fun seasons(seriesId: String): Seasons {
val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons" val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
val parameters = listOf( val parameters = listOf(
"series_id" to seriesId, "series_id" to seriesId,
"locale" to Preferences.preferredLocale.toLanguageTag(), "locale" to locale,
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
return try { val result = request(episodesEndpoint, parameters)
requestGet(seasonsEndpoint, parameters)
}catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in seasons().", ex) json.decodeFromString(it.toString())
NoneSeasons } ?: NoneSeasons
}
} }
/**
* Get all available episodes for a season.
*
* @param seasonId The season id for which to get the episodes
* @return A **[Episodes]** object with a list of **[Episode]**
*/
suspend fun episodes(seasonId: String): Episodes { suspend fun episodes(seasonId: String): Episodes {
val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes" val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/episodes"
val parameters = listOf( val parameters = listOf(
"season_id" to seasonId, "season_id" to seasonId,
"locale" to Preferences.preferredLocale.toLanguageTag(), "locale" to locale,
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
return try { val result = request(episodesEndpoint, parameters)
requestGet(episodesEndpoint, parameters)
}catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in episodes().", ex) json.decodeFromString(it.toString())
NoneEpisodes } ?: NoneEpisodes
}
} }
/**
* Get all available subtitles and streams of a episode.
*
* @param url The playback url of a episode
* @return A **[Playback]** object
*/
suspend fun playback(url: String): Playback { suspend fun playback(url: String): Playback {
return try { val result = request("", url = url)
requestGet("", url = url)
}catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in playback(), with url = $url.", ex) json.decodeFromString(it.toString())
NonePlayback } ?: NonePlayback
}
} }
/** /**
* Additional media functions: watchlist (series), playhead, similar to * Additional media functions: watchlist (series), playhead
*/ */
/** /**
@ -497,15 +375,12 @@ 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 Preferences.preferredLocale.toLanguageTag()) val parameters = listOf("locale" to locale)
return try { val result = request(watchlistSeriesEndpoint, parameters)
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) // if needed implement parsing
.containsKey(seriesId)
}catch (ex: SerializationException) { return result.component1()?.obj()?.has(seriesId) ?: false
Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex)
false
}
} }
/** /**
@ -515,13 +390,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 Preferences.preferredLocale.toLanguageTag()) val parameters = listOf("locale" to locale)
val json = buildJsonObject { val json = buildJsonObject {
put("content_id", seriesId) put("content_id", seriesId)
} }
requestPost(watchlistPostEndpoint, parameters, json) requestPost(watchlistPostEndpoint, parameters, json.toString())
} }
/** /**
@ -531,7 +406,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 Preferences.preferredLocale.toLanguageTag()) val parameters = listOf("locale" to locale)
requestDelete(watchlistDeleteEndpoint, parameters) requestDelete(watchlistDeleteEndpoint, parameters)
} }
@ -546,62 +421,25 @@ 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 Preferences.preferredLocale.toLanguageTag()) val parameters = listOf("locale" to locale)
return try { val result = request(playheadsEndpoint, parameters)
requestGet(playheadsEndpoint, parameters)
} catch (ex: SerializationException) { return result.component1()?.obj()?.let {
Log.e(TAG, "SerializationException in playheads().", ex) json.decodeFromString(it.toString())
emptyMap() } ?: emptyMap()
} catch (ex: Throwable) {
Log.e(TAG, "Exception in playheads().", ex.cause)
emptyMap()
}
} }
/**
* Post the playhead to crunchy (playhead position,watched state)
*
* @param episodeId A episode ID as strings.
* @param playhead The episodes playhead in seconds.
*/
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 Preferences.preferredLocale.toLanguageTag()) val parameters = listOf("locale" to locale)
val json = buildJsonObject { val json = buildJsonObject {
put("content_id", episodeId) put("content_id", episodeId)
put("playhead", playhead) put("playhead", playhead)
} }
try { requestPost(playheadsEndpoint, parameters, json.toString())
requestPost(playheadsEndpoint, parameters, json)
} catch (ex: Throwable) {
Log.e(TAG, "Exception in postPlayheads()", ex.cause)
}
}
/**
* Get similar media for a show/movie.
*
* @param seriesId The crunchyroll series id of the media
* @param n The maximum number of results to return, default = 10
* @return A **[SimilarToResult]** object
*/
suspend fun similarTo(seriesId: String, n: Int = 10): SimilarToResult {
val similarToEndpoint = "/content/v1/$accountID/similar_to"
val parameters = listOf(
"guid" to seriesId,
"locale" to Preferences.preferredLocale.toLanguageTag(),
"n" to n
)
return try {
requestGet(similarToEndpoint, parameters)
}catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in similarTo().", ex)
NoneSimilarToResult
}
} }
/** /**
@ -616,17 +454,12 @@ 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( val parameters = listOf("locale" to locale, "n" to n)
"locale" to Preferences.preferredLocale.toLanguageTag(),
"n" to n
)
val list: ContinueWatchingList = try { val watchlistResult = request(watchlistEndpoint, parameters)
requestGet(watchlistEndpoint, parameters) val list: ContinueWatchingList = watchlistResult.component1()?.obj()?.let {
}catch (ex: SerializationException) { json.decodeFromString(it.toString())
Log.e(TAG, "SerializationException in watchlist().", ex) } ?: NoneContinueWatchingList
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)
@ -640,68 +473,12 @@ 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( val parameters = listOf("locale" to locale, "n" to n)
"locale" to Preferences.preferredLocale.toLanguageTag(),
"n" to n
)
return try { val resultUpNextAccount = request(watchlistEndpoint, parameters)
requestGet(watchlistEndpoint, parameters) return resultUpNextAccount.component1()?.obj()?.let {
}catch (ex: SerializationException) { json.decodeFromString(it.toString())
Log.e(TAG, "SerializationException in upNextAccount().", ex) } ?: NoneContinueWatchingList
NoneContinueWatchingList
}
}
suspend fun recommendations(n: Int = 20, start: Int = 0): RecommendationsList {
val recommendationsEndpoint = "/content/v1/$accountID/recommendations"
val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"n" to n,
"start" to start,
"variant_id" to 0
)
return try {
requestGet(recommendationsEndpoint, parameters)
}catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in recommendations().", ex)
NoneRecommendationsList
}
}
/**
* Account/Profile functions
*/
/**
* Get profile information for the currently logged in account.
*
* @return A **[Profile]** object
*/
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
}
}
/**
* Post the preferred content subtitle language.
*
* @param languageTag the preferred language as language tag
*/
suspend fun postPrefSubLanguage(languageTag: String) {
val profileEndpoint = "/accounts/v1/me/profile"
val json = buildJsonObject {
put("preferred_content_subtitle_language", languageTag)
}
requestPatch(profileEndpoint, bodyObject = json)
} }
} }

View File

@ -1,45 +1,9 @@
/**
* 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
@ -50,63 +14,6 @@ enum class SortBy(val str: String) {
POPULARITY("popularity") POPULARITY("popularity")
} }
@Suppress("unused")
enum class Categories(val str: String) {
ACTION("action"),
ADVENTURE("adventure"),
COMEDY("comedy"),
DRAMA("drama"),
FANTASY("fantasy"),
MUSIC("music"),
ROMANCE("romance"),
SCI_FI("sci-fi"),
SEINEN("seinen"),
SHOJO("shojo"),
SHONEN("shonen"),
SLICE_OF_LIFE("slice+of+life"),
SPORTS("sports"),
SUPERNATURAL("supernatural"),
THRILLER("thriller")
}
/**
* 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
*/ */
@ -120,25 +27,23 @@ data class Collection<T>(
typealias SearchResult = Collection<SearchCollection> typealias SearchResult = Collection<SearchCollection>
typealias SearchCollection = Collection<Item> typealias SearchCollection = Collection<Item>
typealias BrowseResult = Collection<Item> typealias BrowseResult = Collection<Item>
typealias SimilarToResult = Collection<Item>
typealias DiscSeasonList = Collection<SeasonListItem> typealias DiscSeasonList = Collection<SeasonListItem>
typealias Watchlist = Collection<Item> typealias Watchlist = Collection<Item>
typealias ContinueWatchingList = Collection<ContinueWatchingItem> typealias ContinueWatchingList = Collection<ContinueWatchingItem>
typealias RecommendationsList = Collection<Item>
@Serializable @Serializable
data class UpNextSeriesItem( data class UpNextSeriesItem(
@SerialName("playhead") val playhead: Int, val playhead: Int,
@SerialName("fully_watched") val fullyWatched: Boolean, val fully_watched: Boolean,
@SerialName("never_watched") val neverWatched: Boolean, val never_watched: Boolean,
@SerialName("panel") val panel: EpisodePanel, val panel: EpisodePanel,
) )
/** /**
* panel data classes * panel data classes
*/ */
// the data class Item is used in browse, search, watchlist and similar to // the data class Item is used in browse and search
// TODO rename to MediaPanel // TODO rename to MediaPanel
@Serializable @Serializable
data class Item( data class Item(
@ -149,7 +54,6 @@ data class Item(
val description: String, val description: String,
val images: Images val images: Images
// TODO series_metadata etc. // TODO series_metadata etc.
// TODO add slug_title if present in search, browse, similar to
) )
@Serializable @Serializable
@ -188,10 +92,10 @@ 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 = false, // @SerialName("fully_watched") val fullyWatched: Boolean,
) )
// EpisodePanel is used in ContinueWatchingItem and UpNextSeriesItem // EpisodePanel is used in ContinueWatchingItem
@Serializable @Serializable
data class EpisodePanel( data class EpisodePanel(
@SerialName("id") val id: String, @SerialName("id") val id: String,
@ -207,35 +111,25 @@ data class EpisodePanel(
@Serializable @Serializable
data class EpisodeMetadata( data class EpisodeMetadata(
@SerialName("duration_ms") val durationMs: Int, @SerialName("duration_ms") val durationMs: Int,
@SerialName("episode_number") val episodeNumber: Int? = null, // default/nullable value since optional
@SerialName("season_id") val seasonId: String, @SerialName("season_id") val seasonId: String,
@SerialName("season_number") val seasonNumber: Int,
@SerialName("season_title") val seasonTitle: String,
@SerialName("series_id") val seriesId: String, @SerialName("series_id") val seriesId: String,
@SerialName("series_title") val seriesTitle: String, @SerialName("series_title") val seriesTitle: String,
) )
val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList())) val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList()))
val NoneEpisodeMetadata = EpisodeMetadata(0, 0, "", 0, "", "", "") val NoneEpisodeMetadata = EpisodeMetadata(0, "", "", "")
val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "") val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "")
val NoneCollection = Collection<Item>(0, emptyList()) val NoneCollection = Collection<Item>(0, emptyList())
val NoneSearchResult = SearchResult(0, emptyList()) val NoneSearchResult = SearchResult(0, emptyList())
val NoneBrowseResult = BrowseResult(0, emptyList()) val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneSimilarToResult = SimilarToResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList()) val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneRecommendationsList = RecommendationsList(0, emptyList())
val NoneUpNextSeriesItem = UpNextSeriesItem( val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel)
playhead = 0,
fullyWatched = false,
neverWatched = false,
panel = NoneEpisodePanel
)
/** /**
* series data class * Series data type
*/ */
@Serializable @Serializable
data class Series( data class Series(
@ -248,7 +142,7 @@ data class Series(
val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList()) val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList())
/** /**
* Seasons data classes * Seasons data type
*/ */
@Serializable @Serializable
data class Seasons( data class Seasons(
@ -256,13 +150,22 @@ 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 {
return items.firstOrNull { season ->
// try to get the the first seasons which matches the preferred local // try to get the the first seasons which matches the preferred local
season.slugTitle.endsWith("${local.getDisplayLanguage(Locale.ENGLISH)}-dub", true) items.forEach { season ->
} ?: items.firstOrNull { season -> if (season.title.startsWith("(${local.language})", true)) {
return season
}
}
// if there is no season with the preferred local, try to find a subbed season // if there is no season with the preferred local, try to find a subbed season
season.isSubbed items.forEach { season ->
} ?: items.first() // if no preferred language and no sub, use the first season if (season.isSubbed) {
return season
}
}
// if there is no preferred language season and no sub, use the first season
return items.first()
} }
} }
@ -270,7 +173,6 @@ 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,
@ -278,11 +180,11 @@ 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)
/** /**
* Episodes data classes * Episodes data type
*/ */
@Serializable @Serializable
data class Episodes( data class Episodes(
@ -346,7 +248,7 @@ data class PlayheadObject(
) )
/** /**
* playback/stream data classes * Playback/stream data type
*/ */
@Serializable @Serializable
data class Playback( data class Playback(
@ -393,22 +295,3 @@ val NonePlayback = Playback(
mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(),
) )
) )
/**
* profile data class
*/
@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 = ""
)

View File

@ -8,9 +8,9 @@ import java.util.*
object Preferences { object Preferences {
var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start var preferSecondary = false
internal set internal set
var preferSubbed = false var preferredLocal = Locale.GERMANY
internal set internal set
var autoplay = true var autoplay = true
internal set internal set
@ -19,10 +19,6 @@ object Preferences {
var theme = DataTypes.Theme.DARK var theme = DataTypes.Theme.DARK
internal set internal set
// dev settings
var updatePlayhead = true
internal set
private fun getSharedPref(context: Context): SharedPreferences { private fun getSharedPref(context: Context): SharedPreferences {
return context.getSharedPreferences( return context.getSharedPreferences(
context.getString(R.string.preference_file_key), context.getString(R.string.preference_file_key),
@ -30,22 +26,13 @@ object Preferences {
) )
} }
fun savePreferredLocal(context: Context, preferredLocale: Locale) { fun savePreferSecondary(context: Context, preferSecondary: Boolean) {
with(getSharedPref(context).edit()) { with(getSharedPref(context).edit()) {
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag()) putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSecondary)
apply() apply()
} }
this.preferredLocale = preferredLocale this.preferSecondary = preferSecondary
}
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) {
@ -75,27 +62,13 @@ object Preferences {
this.theme = theme this.theme = theme
} }
fun saveUpdatePlayhead(context: Context, updatePlayhead: Boolean) {
with(getSharedPref(context).edit()) {
putBoolean(context.getString(R.string.save_key_update_playhead), updatePlayhead)
apply()
}
this.updatePlayhead = updatePlayhead
}
/** /**
* initially load the stored values * initially load the stored values
*/ */
fun load(context: Context) { fun load(context: Context) {
val sharedPref = getSharedPref(context) val sharedPref = getSharedPref(context)
preferredLocale = Locale.forLanguageTag( preferSecondary = sharedPref.getBoolean(
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(
@ -109,11 +82,6 @@ object Preferences {
context.getString(R.string.save_key_theme), DataTypes.Theme.DARK.toString() context.getString(R.string.save_key_theme), DataTypes.Theme.DARK.toString()
) ?: DataTypes.Theme.DARK.toString() ) ?: DataTypes.Theme.DARK.toString()
) )
// dev settings
updatePlayhead = sharedPref.getBoolean(
context.getString(R.string.save_key_update_playhead), true
)
} }

View File

@ -0,0 +1,18 @@
package org.mosad.teapod.ui.activity
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.mosad.teapod.ui.activity.main.MainActivity
class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
}

View File

@ -27,7 +27,6 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.commit import androidx.fragment.app.commit
import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigation.NavigationBarView
@ -43,13 +42,11 @@ import org.mosad.teapod.ui.activity.main.fragments.LibraryFragment
import org.mosad.teapod.ui.activity.main.fragments.SearchFragment import org.mosad.teapod.ui.activity.main.fragments.SearchFragment
import org.mosad.teapod.ui.activity.onboarding.OnboardingActivity 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.util.DataTypes import org.mosad.teapod.util.DataTypes
import org.mosad.teapod.util.metadb.MetaDBController
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
@ -63,9 +60,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Handle the splash screen transition.
installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
load() // start the initial loading load() // start the initial loading
@ -141,12 +135,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
Preferences.load(this) Preferences.load(this)
EncryptedPreferences.readCredentials(this) EncryptedPreferences.readCredentials(this)
// load meta db at the start, it doesn't depend on any third party
val metaJob = initMetaDB()
// 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,
@ -155,32 +143,35 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
) { ) {
showOnboarding() showOnboarding()
} else { } else {
runBlocking { runBlocking { initCrunchyroll().joinAll() }
initCrunchyroll().joinAll()
metaJob.join() // meta loading should be done here
} }
} }
} 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> {
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialCrunchyLoading")) println("init")
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)
}
) )
} }
private fun initMetaDB(): Job { private fun showLoginDialog() {
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialMetaDBLoading")) LoginDialog(this, false).positiveButton {
return scope.launch { MetaDBController.list() } EncryptedPreferences.saveCredentials(login, password, context)
// TODO
// if (!AoDParser.login()) {
// showLoginDialog()
// Log.w(javaClass.name, "Login failed, please try again.")
// }
}.negativeButton {
Log.i(javaClass.name, "Login canceled, exiting.")
finish()
}.show()
} }
/** /**

View File

@ -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.google.android.material.dialog.MaterialAlertDialogBuilder import com.afollestad.materialdialogs.MaterialDialog
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 {
MaterialAlertDialogBuilder(requireContext()) MaterialDialog(requireContext())
.setTitle(License.GPL3.long) .title(text = License.GPL3.long)
.setMessage(parseLicense(R.raw.gpl_3_full)) .message(text = parseLicense(R.raw.gpl_3_full))
.show() .show()
} }
} }
@ -107,14 +107,16 @@ class AboutFragment : Fragment() {
"https://github.com/material-components/material-components-android", License.APACHE2), "https://github.com/material-components/material-components-android", License.APACHE2),
ThirdPartyComponent("ExoPlayer", "2014 - 2020", "The Android Open Source Project", ThirdPartyComponent("ExoPlayer", "2014 - 2020", "The Android Open Source Project",
"https://github.com/google/ExoPlayer", License.APACHE2), "https://github.com/google/ExoPlayer", License.APACHE2),
ThirdPartyComponent("Gson", "2008", "Google Inc.",
"https://github.com/google/gson", License.APACHE2),
ThirdPartyComponent("Material design icons", "2020", "Google Inc.", ThirdPartyComponent("Material design icons", "2020", "Google Inc.",
"https://github.com/google/material-design-icons", License.APACHE2), "https://github.com/google/material-design-icons", License.APACHE2),
ThirdPartyComponent("Ktor", "2014-2021", "JetBrains s.r.o and contributors", ThirdPartyComponent("Material Dialogs", "", "Aidan Follestad",
"https://ktor.io/", License.APACHE2), "https://github.com/afollestad/material-dialogs", License.APACHE2),
ThirdPartyComponent("kotlinx.coroutines", "2016-2021", "JetBrains s.r.o", ThirdPartyComponent("Jsoup", "2009 - 2020", "Jonathan Hedley",
"https://jsoup.org/", License.MIT),
ThirdPartyComponent("kotlinx.coroutines", "2016 - 2019", "JetBrains",
"https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2), "https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2),
ThirdPartyComponent(" kotlinx.serialization", "2017-2021", "JetBrains s.r.o",
"https://github.com/Kotlin/kotlinx.serialization", 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),
ThirdPartyComponent("Glide Transformations", "2020", "Wasabeef", ThirdPartyComponent("Glide Transformations", "2020", "Wasabeef",
@ -130,9 +132,9 @@ class AboutFragment : Fragment() {
License.MIT -> parseLicense(R.raw.mit_full) License.MIT -> parseLicense(R.raw.mit_full)
} }
MaterialAlertDialogBuilder(requireContext()) MaterialDialog(requireContext())
.setTitle(license.long) .title(text = license.long)
.setMessage(licenseText) .message(text = licenseText)
.show() .show()
} }

View File

@ -1,37 +1,53 @@
package org.mosad.teapod.ui.activity.main.fragments package org.mosad.teapod.ui.activity.main.fragments
import android.app.Activity
import android.content.Intent
import android.os.Bundle 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.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.google.android.material.dialog.MaterialAlertDialogBuilder import com.afollestad.materialdialogs.MaterialDialog
import kotlinx.coroutines.Deferred import com.afollestad.materialdialogs.list.listItemsSingleChoice
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.LoginModalBottomSheet 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 ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.also { uri ->
//StorageController.exportMyList(requireContext(), uri)
}
}
}
private val getUriImport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.also { uri ->
// val success = StorageController.importMyList(requireContext(), uri)
// if (success == 0) {
// Toast.makeText(
// context, getString(R.string.import_data_success),
// Toast.LENGTH_SHORT
// ).show()
// }
}
}
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -42,9 +58,7 @@ 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)
binding.textAccountLogin.text = EncryptedPreferences.login // TODO reimplement for ct, if possible (maybe account status would be better? (premium))
// 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 {
@ -54,30 +68,24 @@ class AccountFragment : Fragment() {
) )
} }
// add preferred subtitles binding.textAccountLogin.text = EncryptedPreferences.login
lifecycleScope.launch { binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time))
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.linearDevSettings.isVisible = Preferences.devSettings binding.switchSecondary.isChecked = Preferences.preferSecondary
binding.switchUpdatePlayhead.isChecked = Preferences.updatePlayhead binding.switchAutoplay.isChecked = Preferences.autoplay
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time)) binding.linearDevSettings.isVisible = Preferences.devSettings
initActions() initActions()
} }
private fun initActions() { private fun initActions() {
binding.linearAccountLogin.setOnClickListener { binding.linearAccountLogin.setOnClickListener {
showLoginDialog() showLoginDialog(true)
} }
binding.linearAccountSubscription.setOnClickListener { binding.linearAccountSubscription.setOnClickListener {
@ -85,19 +93,6 @@ class AccountFragment : Fragment() {
//startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl()))) //startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
} }
binding.linearSettingsContentLanguage.setOnClickListener {
showContentLanguageSelection()
}
binding.switchSecondary.setOnClickListener {
Preferences.savePreferSecondary(requireContext(), binding.switchSecondary.isChecked)
}
binding.switchAutoplay.setOnClickListener {
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
}
binding.linearTheme.setOnClickListener { binding.linearTheme.setOnClickListener {
showThemeDialog() showThemeDialog()
} }
@ -106,97 +101,65 @@ class AccountFragment : Fragment() {
activity?.showFragment(AboutFragment()) activity?.showFragment(AboutFragment())
} }
binding.switchUpdatePlayhead.setOnClickListener { binding.switchSecondary.setOnClickListener {
Preferences.saveUpdatePlayhead(requireContext(), binding.switchUpdatePlayhead.isChecked) Preferences.savePreferSecondary(requireContext(), binding.switchSecondary.isChecked)
}
binding.switchAutoplay.setOnClickListener {
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
} }
binding.linearExportData.setOnClickListener { binding.linearExportData.setOnClickListener {
// unused val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/json"
putExtra(Intent.EXTRA_TITLE, "my-list.json")
}
getUriExport.launch(i)
} }
binding.linearImportData.setOnClickListener { binding.linearImportData.setOnClickListener {
// unused val i = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
getUriImport.launch(i)
} }
} }
private fun showLoginDialog() { private fun showLoginDialog(firstTry: Boolean) {
val loginModal = LoginModalBottomSheet().apply { LoginDialog(requireContext(), firstTry).positiveButton {
EncryptedPreferences.saveCredentials(login, password, context)
// TODO
// if (!AoDParser.login()) {
// showLoginDialog(false)
// Log.w(javaClass.name, "Login failed, please try again.")
// }
}.show {
login = EncryptedPreferences.login login = EncryptedPreferences.login
password = "" password = ""
positiveAction = {
EncryptedPreferences.saveCredentials(login, password, requireContext())
// TODO only dismiss if login was successful
this.dismiss()
}
negativeAction = {
this.dismiss()
}
}
activity?.let { loginModal.show(it.supportFragmentManager, LoginModalBottomSheet.TAG) }
}
private fun showContentLanguageSelection() {
// we should be able to use the index of supportedLocals for language selection, items is GUI only
val items = supportedLocals.map {
it.toDisplayString(getString(R.string.settings_content_language_none))
}.toTypedArray()
var initialSelection: Int
// profile should be completed here, therefore blocking
runBlocking {
initialSelection = supportedLocals.indexOf(Locale.forLanguageTag(
profile.await().preferredContentSubtitleLanguage))
if (initialSelection < 0) initialSelection = supportedLocals.lastIndex
}
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() { private fun showThemeDialog() {
val items = arrayOf( val themes = listOf(
resources.getString(R.string.theme_light), resources.getString(R.string.theme_light),
resources.getString(R.string.theme_dark) resources.getString(R.string.theme_dark)
) )
MaterialAlertDialogBuilder(requireContext()) MaterialDialog(requireContext()).show {
.setTitle(R.string.settings_content_language) title(R.string.theme)
.setSingleChoiceItems(items, Preferences.theme.ordinal){ _, which -> listItemsSingleChoice(items = themes, initialSelection = Preferences.theme.ordinal) { _, index, _ ->
when(which) { when(index) {
0 -> Preferences.saveTheme(requireContext(), Theme.LIGHT) 0 -> Preferences.saveTheme(context, Theme.LIGHT)
1 -> Preferences.saveTheme(requireContext(), Theme.DARK) 1 -> Preferences.saveTheme(context, Theme.DARK)
else -> Preferences.saveTheme(requireContext(), Theme.DARK) else -> Preferences.saveTheme(context, Theme.DARK)
} }
(activity as MainActivity).restart() (activity as MainActivity).restart()
} }
.show() }
} }
} }

View File

@ -1,55 +1,34 @@
/**
* 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.ui.activity.main.fragments package org.mosad.teapod.ui.activity.main.fragments
import android.os.Bundle import android.os.Bundle
import android.util.Log
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 androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentHomeBinding import org.mosad.teapod.databinding.FragmentHomeBinding
import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.ui.activity.main.viewmodel.HomeViewModel import org.mosad.teapod.parser.crunchyroll.Item
import org.mosad.teapod.util.adapter.MediaEpisodeListAdapter import org.mosad.teapod.parser.crunchyroll.SortBy
import org.mosad.teapod.util.adapter.MediaItemListAdapter import org.mosad.teapod.util.adapter.MediaItemAdapter
import org.mosad.teapod.util.decoration.MediaItemDecoration import org.mosad.teapod.util.decoration.MediaItemDecoration
import org.mosad.teapod.util.setDrawableTop
import org.mosad.teapod.util.showFragment import org.mosad.teapod.util.showFragment
import org.mosad.teapod.util.toItemMediaList import org.mosad.teapod.util.toItemMediaList
import kotlin.random.Random
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
private val classTag = javaClass.name
private val model: HomeViewModel by viewModels()
private lateinit var binding: FragmentHomeBinding private lateinit var binding: FragmentHomeBinding
private lateinit var adapterUpNext: MediaItemAdapter
private lateinit var adapterWatchlist: MediaItemAdapter
private lateinit var adapterNewTitles: MediaItemAdapter
private lateinit var adapterTopTen: MediaItemAdapter
private lateinit var highlightMedia: Item
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentHomeBinding.inflate(inflater, container, false) binding = FragmentHomeBinding.inflate(inflater, container, false)
@ -59,53 +38,83 @@ class HomeFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.recyclerUpNext.addItemDecoration(MediaItemDecoration(9)) lifecycleScope.launch {
context?.let {
initHighlight()
initRecyclerViews()
initActions()
}
}
}
private fun initHighlight() {
lifecycleScope.launch {
val newTitles = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 10)
// FIXME crashes on newTitles.items.size == 0
highlightMedia = newTitles.items[Random.nextInt(newTitles.items.size)]
// add media item to gui
binding.textHighlightTitle.text = highlightMedia.title
Glide.with(requireContext()).load(highlightMedia.images.poster_wide[0][3].source)
.into(binding.imageHighlight)
// TODO watchlist indicator
// if (StorageController.myList.contains(0)) {
// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24)
// } else {
// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24)
// }
}
}
/**
* Suspend, since adapters need to be initialized before we can initialize the actions.
*/
private suspend fun initRecyclerViews() {
binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9))
binding.recyclerRecommendations.addItemDecoration(MediaItemDecoration(9)) binding.recyclerNewEpisodes.addItemDecoration(MediaItemDecoration(9))
binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9)) binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9))
binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9)) binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9))
binding.recyclerUpNext.adapter = MediaEpisodeListAdapter( val asyncJobList = arrayListOf<Job>()
MediaEpisodeListAdapter.OnClickListener {
val activity = activity
if (activity is MainActivity) {
activity.startPlayer(it.panel.episodeMetadata.seasonId, it.panel.id)
}
}
)
binding.recyclerWatchlist.adapter = MediaItemListAdapter( // continue watching
MediaItemListAdapter.OnClickListener { val upNextJob = lifecycleScope.launch {
activity?.showFragment(MediaFragment(it.id)) // TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately
adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().toItemMediaList())
binding.recyclerNewEpisodes.adapter = adapterUpNext
} }
) asyncJobList.add(upNextJob)
binding.recyclerRecommendations.adapter = MediaItemListAdapter( // watchlist
MediaItemListAdapter.OnClickListener { val watchlistJob = lifecycleScope.launch {
activity?.showFragment(MediaFragment(it.id)) adapterWatchlist = MediaItemAdapter(Crunchyroll.watchlist(50).toItemMediaList())
binding.recyclerWatchlist.adapter = adapterWatchlist
} }
) asyncJobList.add(watchlistJob)
binding.recyclerNewTitles.adapter = MediaItemListAdapter( // new simulcasts
MediaItemListAdapter.OnClickListener { val simulcastsJob = lifecycleScope.launch {
activity?.showFragment(MediaFragment(it.id)) // val latestSeasonTag = Crunchyroll.seasonList().items.first().id
// val newSimulcasts = Crunchyroll.browse(seasonTag = latestSeasonTag, n = 50)
val newSimulcasts = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50)
adapterNewTitles = MediaItemAdapter(newSimulcasts.toItemMediaList())
binding.recyclerNewTitles.adapter = adapterNewTitles
} }
) asyncJobList.add(simulcastsJob)
binding.recyclerTopTen.adapter = MediaItemListAdapter( // newly added / top ten
MediaItemListAdapter.OnClickListener { val newlyAddedJob = lifecycleScope.launch {
activity?.showFragment(MediaFragment(it.id)) adapterTopTen = MediaItemAdapter(Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).toItemMediaList())
binding.recyclerTopTen.adapter = adapterTopTen
} }
) asyncJobList.add(newlyAddedJob)
binding.textHighlightMyList.setOnClickListener { asyncJobList.joinAll()
model.toggleHighlightWatchlist()
// disable the watchlist button until the result has been loaded
binding.textHighlightMyList.isClickable = false
// TODO since this might take a few seconds show a loading animation for the watchlist button
} }
private fun initActions() {
binding.buttonPlayHighlight.setOnClickListener { binding.buttonPlayHighlight.setOnClickListener {
// TODO implement // TODO implement
lifecycleScope.launch { lifecycleScope.launch {
@ -116,60 +125,37 @@ class HomeFragment : Fragment() {
} }
} }
viewLifecycleOwner.lifecycleScope.launch { binding.textHighlightMyList.setOnClickListener {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { // TODO implement
model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> // if (StorageController.myList.contains(0)) {
when (uiState) { // StorageController.myList.remove(0)
is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState) // binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24)
is HomeViewModel.UiState.Loading -> bindUiStateLoading() // } else {
is HomeViewModel.UiState.Error -> bindUiStateError(uiState) // StorageController.myList.add(0)
// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24)
// }
// StorageController.saveMyList(requireContext())
} }
}
}
}
}
private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
val adapterUpNext = binding.recyclerUpNext.adapter as MediaEpisodeListAdapter
adapterUpNext.submitList(uiState.upNextItems.filter { !it.fullyWatched })
val adapterWatchlist = binding.recyclerWatchlist.adapter as MediaItemListAdapter
adapterWatchlist.submitList(uiState.watchlistItems.toItemMediaList())
val adapterRecommendations = binding.recyclerRecommendations.adapter as MediaItemListAdapter
adapterRecommendations.submitList(uiState.recommendationsItems.toItemMediaList())
val adapterNewTitles = binding.recyclerNewTitles.adapter as MediaItemListAdapter
adapterNewTitles.submitList(uiState.recentlyAddedItems.toItemMediaList())
val adapterTopTen = binding.recyclerTopTen.adapter as MediaItemListAdapter
adapterTopTen.submitList(uiState.topTenItems.toItemMediaList())
// highlight item
binding.textHighlightTitle.text = uiState.highlightItem.title
Glide.with(requireContext()).load(uiState.highlightItem.images.poster_wide[0][3].source)
.into(binding.imageHighlight)
val iconHighlightWatchlist = if (uiState.highlightIsWatchlist) {
R.drawable.ic_baseline_check_24
} else {
R.drawable.ic_baseline_add_24
}
binding.textHighlightMyList.setDrawableTop(iconHighlightWatchlist)
binding.textHighlightMyList.isClickable = true
binding.textHighlightInfo.setOnClickListener { binding.textHighlightInfo.setOnClickListener {
activity?.showFragment(MediaFragment(uiState.highlightItem.id)) activity?.showFragment(MediaFragment(highlightMedia.id))
}
adapterUpNext.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment(id))
}
adapterWatchlist.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment(id))
}
adapterNewTitles.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment(id))
}
adapterTopTen.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment(id)) //(mediaId))
} }
} }
private fun bindUiStateLoading() {
// currently not used
}
private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) {
// currently not used
Log.e(classTag, "A error occurred while loading a UiState: ${uiState.message}")
}
} }

View File

@ -8,7 +8,8 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -36,7 +37,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private lateinit var binding: FragmentMediaBinding private lateinit var binding: FragmentMediaBinding
private lateinit var pagerAdapter: FragmentStateAdapter private lateinit var pagerAdapter: FragmentStateAdapter
private val model: MediaFragmentViewModel by viewModels() private val model: MediaFragmentViewModel by activityViewModels()
private val fragments = arrayListOf<Fragment>() private val fragments = arrayListOf<Fragment>()
private var watchlistJobRunning = false private var watchlistJobRunning = false
@ -50,10 +51,13 @@ 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
pagerAdapter = ScreenSlidePagerAdapter(this) pagerAdapter = ScreenSlidePagerAdapter(requireActivity())
// fix material components issue #1878, if more tabs are added increase // fix material components issue #1878, if more tabs are added increase
binding.pagerEpisodesSimilar.offscreenPageLimit = 2 binding.pagerEpisodesSimilar.offscreenPageLimit = 2
binding.pagerEpisodesSimilar.adapter = pagerAdapter binding.pagerEpisodesSimilar.adapter = pagerAdapter
@ -78,12 +82,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
super.onResume() super.onResume()
if (runOnResume) { if (runOnResume) {
/**
* FIXME
* this is currently also run on back press when multiple MediaFragments have
* been open and closed via similar tab
*/
lifecycleScope.launch { lifecycleScope.launch {
model.updateOnResume() model.updateOnResume()
@ -135,15 +133,12 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24
Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction) Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction)
/** // clear fragments, since it lives in onCreate scope (don't do this in onPause/onStop -> FragmentManager transaction)
* clear fragments, since it lives in onCreate scope, val fragmentsSize = if (fragments.lastIndex < 0) 0 else fragments.lastIndex
* don't do this in onPause/onStop -> FragmentManager transaction
* (will be called on similar -> new MediaFragment -> onBackPressed)
*/
val fragmentsSize = fragments.size
fragments.clear() fragments.clear()
pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize) pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize)
// add the episodes fragment (as tab). Note: Movies are tv shows!
MediaFragmentEpisodes().also { MediaFragmentEpisodes().also {
fragments.add(it) fragments.add(it)
pagerAdapter.notifyItemInserted(fragments.indexOf(it)) pagerAdapter.notifyItemInserted(fragments.indexOf(it))
@ -178,12 +173,13 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
} }
// if has similar titles // if has similar titles
if (model.similarTo.total > 0) { // TODO reimplement
MediaFragmentSimilar().also { // if (media.similar.isNotEmpty()) {
fragments.add(it) // MediaFragmentSimilar().also {
pagerAdapter.notifyItemInserted(fragments.indexOf(it)) // fragments.add(it)
} // pagerAdapter.notifyItemInserted(fragments.indexOf(it))
} // }
// }
// disable scrolling on appbar, if no tabs where added // disable scrolling on appbar, if no tabs where added
if(fragments.isEmpty()) { if(fragments.isEmpty()) {
@ -232,7 +228,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
/** /**
* A simple pager adapter * A simple pager adapter
*/ */
private inner class ScreenSlidePagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
override fun getItemCount(): Int = fragments.size override fun getItemCount(): Int = fragments.size
override fun createFragment(position: Int): Fragment = fragments[position] override fun createFragment(position: Int): Fragment = fragments[position]

View File

@ -8,10 +8,9 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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
@ -22,7 +21,7 @@ class MediaFragmentEpisodes : Fragment() {
private lateinit var binding: FragmentMediaEpisodesBinding private lateinit var binding: FragmentMediaEpisodesBinding
private lateinit var adapterRecEpisodes: EpisodeItemAdapter private lateinit var adapterRecEpisodes: EpisodeItemAdapter
private val model: MediaFragmentViewModel by viewModels({requireParentFragment()}) private val model: MediaFragmentViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentMediaEpisodesBinding.inflate(inflater, container, false) binding = FragmentMediaEpisodesBinding.inflate(inflater, container, false)
@ -35,23 +34,20 @@ class MediaFragmentEpisodes : Fragment() {
adapterRecEpisodes = EpisodeItemAdapter( adapterRecEpisodes = EpisodeItemAdapter(
model.currentEpisodesCrunchy, model.currentEpisodesCrunchy,
model.tmdbTVSeason.episodes, model.tmdbTVSeason.episodes,
model.currentPlayheads, model.currentPlayheads
EpisodeItemAdapter.OnClickListener { episode ->
playEpisode(episode.seasonId, episode.id)
},
EpisodeItemAdapter.ViewType.MEDIA_FRAGMENT
) )
binding.recyclerEpisodes.adapter = adapterRecEpisodes binding.recyclerEpisodes.adapter = adapterRecEpisodes
// set onItemClick, adapter is initialized
adapterRecEpisodes.onImageClick = { seasonId, episodeId ->
playEpisode(seasonId, episodeId)
}
// don't show season selection if only one season is present // don't show season selection if only one season is present
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 = getString( binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title
R.string.season_number_title,
model.currentSeasonCrunchy.seasonNumber,
model.currentSeasonCrunchy.title
)
binding.buttonSeasonSelection.setOnClickListener { v -> binding.buttonSeasonSelection.setOnClickListener { v ->
showSeasonSelection(v) showSeasonSelection(v)
} }
@ -61,21 +57,14 @@ class MediaFragmentEpisodes : Fragment() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun updateWatchedState() { fun updateWatchedState() {
// model.currentPlayheads is a val mutable map -> notify dataset changed // model.currentPlayheads is a val mutable map -> notify dataset changed
if (this::adapterRecEpisodes.isInitialized) {
adapterRecEpisodes.notifyDataSetChanged() adapterRecEpisodes.notifyDataSetChanged()
} }
}
private fun showSeasonSelection(v: View) { private fun showSeasonSelection(v: View) {
// 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(getString( popup.menu.add(season.title).also {
R.string.season_number_title,
season.seasonNumber,
season.title
)
).also {
it.setOnMenuItemClickListener { it.setOnMenuItemClickListener {
onSeasonSelected(season.id) onSeasonSelected(season.id)
false false
@ -97,11 +86,7 @@ class MediaFragmentEpisodes : Fragment() {
// load the new season // load the new season
lifecycleScope.launch { lifecycleScope.launch {
model.setCurrentSeason(seasonId) model.setCurrentSeason(seasonId)
binding.buttonSeasonSelection.text = getString( binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title
R.string.season_number_title,
model.currentSeasonCrunchy.seasonNumber,
model.currentSeasonCrunchy.title
)
adapterRecEpisodes.notifyDataSetChanged() adapterRecEpisodes.notifyDataSetChanged()
} }
} }

View File

@ -1,25 +1,3 @@
/**
* 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.ui.activity.main.fragments package org.mosad.teapod.ui.activity.main.fragments
import android.os.Bundle import android.os.Bundle
@ -27,18 +5,19 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.activityViewModels
import org.mosad.teapod.databinding.FragmentMediaSimilarBinding import org.mosad.teapod.databinding.FragmentMediaSimilarBinding
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
import org.mosad.teapod.util.adapter.MediaItemListAdapter import org.mosad.teapod.util.adapter.MediaItemAdapter
import org.mosad.teapod.util.decoration.MediaItemDecoration import org.mosad.teapod.util.decoration.MediaItemDecoration
import org.mosad.teapod.util.showFragment import org.mosad.teapod.util.showFragment
import org.mosad.teapod.util.toItemMediaList
class MediaFragmentSimilar : Fragment() { class MediaFragmentSimilar : Fragment() {
private val model: MediaFragmentViewModel by viewModels({requireParentFragment()})
private lateinit var binding: FragmentMediaSimilarBinding private lateinit var binding: FragmentMediaSimilarBinding
private val model: MediaFragmentViewModel by activityViewModels()
private lateinit var adapterSimilar: MediaItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentMediaSimilarBinding.inflate(inflater, container, false) binding = FragmentMediaSimilarBinding.inflate(inflater, container, false)
@ -48,14 +27,15 @@ class MediaFragmentSimilar : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapterSimilar = MediaItemAdapter(emptyList()) //(model.media.similar)
binding.recyclerMediaSimilar.adapter = adapterSimilar
binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9))
binding.recyclerMediaSimilar.adapter = MediaItemListAdapter(
MediaItemListAdapter.OnClickListener {
activity?.showFragment(MediaFragment(it.id))
}
)
val adapterSimilar = binding.recyclerMediaSimilar.adapter as MediaItemListAdapter // set onItemClick only in adapter is initialized
adapterSimilar.submitList(model.similarTo.toItemMediaList()) if (this::adapterSimilar.isInitialized) {
adapterSimilar.onItemClick = { mediaId, _ ->
activity?.showFragment(MediaFragment("")) //(mediaId))
}
}
} }
} }

View File

@ -1,123 +0,0 @@
/**
* 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.ui.activity.main.viewmodel
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.mosad.teapod.parser.crunchyroll.*
import kotlin.random.Random
class HomeViewModel : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
sealed class UiState {
object Loading : UiState()
data class Normal(
val upNextItems: List<ContinueWatchingItem>,
val watchlistItems: List<Item>,
val recommendationsItems: List<Item>,
val recentlyAddedItems: List<Item>,
val topTenItems: List<Item>,
val highlightItem: Item,
val highlightIsWatchlist:Boolean
) : UiState()
data class Error(val message: String?) : UiState()
}
init {
load()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun load() {
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
// run the loading in parallel to speed up the process
val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().items }
val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(50).items }
val recommendationsJob = viewModelScope.async {
Crunchyroll.recommendations(20).items
}
val recentlyAddedJob = viewModelScope.async {
Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50).items
}
val topTenJob = viewModelScope.async {
Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).items
}
val recentlyAddedItems = recentlyAddedJob.await()
// FIXME crashes on newTitles.items.size == 0
val highlightItem = recentlyAddedItems[Random.nextInt(recentlyAddedItems.size)]
val highlightItemIsWatchlist = Crunchyroll.isWatchlist(highlightItem.id)
uiState.emit(UiState.Normal(
upNextJob.await(), watchlistJob.await(), recommendationsJob.await(),
recentlyAddedJob.await(), topTenJob.await(), highlightItem,
highlightItemIsWatchlist
))
} catch (e: Exception) {
uiState.emit(UiState.Error(e.message))
}
}
}
/**
* Toggle the watchlist state of the highlight media.
*/
fun toggleHighlightWatchlist() {
viewModelScope.launch {
uiState.update { currentUiState ->
if (currentUiState is UiState.Normal) {
if (currentUiState.highlightIsWatchlist) {
Crunchyroll.deleteWatchlist(currentUiState.highlightItem.id)
} else {
Crunchyroll.postWatchlist(currentUiState.highlightItem.id)
}
// update the watchlist after a item has been added/removed
val watchlistItems = Crunchyroll.watchlist(50).items
currentUiState.copy(
watchlistItems = watchlistItems,
highlightIsWatchlist = !currentUiState.highlightIsWatchlist)
} else {
currentUiState
}
}
}
}
}

View File

@ -8,6 +8,7 @@ import kotlinx.coroutines.launch
import org.mosad.teapod.parser.crunchyroll.* import org.mosad.teapod.parser.crunchyroll.*
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.DataTypes.MediaType import org.mosad.teapod.util.DataTypes.MediaType
import org.mosad.teapod.util.Meta
import org.mosad.teapod.util.tmdb.* import org.mosad.teapod.util.tmdb.*
/** /**
@ -16,6 +17,8 @@ import org.mosad.teapod.util.tmdb.*
*/ */
class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) {
// var mediaCrunchy = NoneItem
// internal set
var seriesCrunchy = NoneSeries // movies are also series var seriesCrunchy = NoneSeries // movies are also series
internal set internal set
var seasonsCrunchy = NoneSeasons var seasonsCrunchy = NoneSeasons
@ -31,9 +34,6 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
var isWatchlist = false var isWatchlist = false
internal set internal set
var upNextSeries = NoneUpNextSeriesItem var upNextSeries = NoneUpNextSeriesItem
internal set
var similarTo = NoneSimilarToResult
internal set
// TMDB stuff // TMDB stuff
var mediaType = MediaType.OTHER var mediaType = MediaType.OTHER
@ -42,6 +42,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
internal set internal set
var tmdbTVSeason: TMDBTVSeason = NoneTMDBTVSeason var tmdbTVSeason: TMDBTVSeason = NoneTMDBTVSeason
internal set internal set
var mediaMeta: Meta? = null
internal set
/** /**
* @param crunchyId the crunchyroll series id * @param crunchyId the crunchyroll series id
@ -53,17 +55,22 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) },
viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) }, viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) },
viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) }, viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) },
viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(crunchyId) }, viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(crunchyId) }
viewModelScope.launch { similarTo = Crunchyroll.similarTo(crunchyId) }
).joinAll() ).joinAll()
// println("series: $seriesCrunchy")
// println("seasons: $seasonsCrunchy")
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.preferredLocale) currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal)
// Note: if we need to query metaDB, do it now
// 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)
viewModelScope.launch { episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) }.join() listOf(
viewModelScope.launch { episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) },
viewModelScope.launch { mediaMeta = null }, // TODO metaDB
).joinAll()
// println("episodes: $episodesCrunchy")
currentEpisodesCrunchy.clear() currentEpisodesCrunchy.clear()
currentEpisodesCrunchy.addAll(episodesCrunchy.items) currentEpisodesCrunchy.addAll(episodesCrunchy.items)
@ -96,7 +103,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
MediaType.TVSHOW -> tmdbApiController.searchTVShow(seriesCrunchy.title) MediaType.TVSHOW -> tmdbApiController.searchTVShow(seriesCrunchy.title)
else -> NoneTMDBSearch else -> NoneTMDBSearch
} }
// println(tmdbSearchResult) println(tmdbSearchResult)
tmdbResult = if (tmdbSearchResult.results.isNotEmpty()) { tmdbResult = if (tmdbSearchResult.results.isNotEmpty()) {
when (val result = tmdbSearchResult.results.first()) { when (val result = tmdbSearchResult.results.first()) {
@ -105,7 +112,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
else -> NoneTMDB else -> NoneTMDB
} }
} else NoneTMDB } else NoneTMDB
// println(tmdbResult)
println(tmdbResult)
// currently not used // currently not used
// tmdbTVSeason = if (tmdbResult is TMDBTVShow) { // tmdbTVSeason = if (tmdbResult is TMDBTVShow) {
@ -131,11 +139,6 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id)
currentEpisodesCrunchy.clear() currentEpisodesCrunchy.clear()
currentEpisodesCrunchy.addAll(episodesCrunchy.items) currentEpisodesCrunchy.addAll(episodesCrunchy.items)
// update playheads playheads (including fully watched state)
val episodeIDs = episodesCrunchy.items.map { it.id }
currentPlayheads.clear()
currentPlayheads.putAll(Crunchyroll.playheads(episodeIDs))
} }
suspend fun setWatchlist() { suspend fun setWatchlist() {
@ -159,4 +162,16 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
) )
} }
/**
* get the next episode based on episodeId
* if no matching is found, use first episode
*/
fun updateNextEpisode(episodeId: Int) {
// TODO reimplement if needed
// if (media.type == MediaType.MOVIE) return // return if movie
//
// nextEpisodeId = media.playlist.firstOrNull { it.index > media.getEpisodeById(episodeId).index }?.mediaId
// ?: media.playlist.first().mediaId
}
} }

View File

@ -47,17 +47,15 @@ import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.StyledPlayerControlView import com.google.android.exoplayer2.ui.StyledPlayerControlView
import com.google.android.exoplayer2.util.Util import com.google.android.exoplayer2.util.Util
import kotlinx.android.synthetic.main.activity_player.*
import kotlinx.android.synthetic.main.player_controls.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.databinding.ActivityPlayerBinding
import org.mosad.teapod.databinding.PlayerControlsBinding
import org.mosad.teapod.parser.crunchyroll.NoneEpisode import org.mosad.teapod.parser.crunchyroll.NoneEpisode
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.ui.activity.player.fragment.EpisodeListDialogFragment import org.mosad.teapod.ui.components.EpisodesListPlayer
import org.mosad.teapod.ui.activity.player.fragment.LanguageSettingsDialogFragment import org.mosad.teapod.ui.components.LanguageSettingsPlayer
import org.mosad.teapod.util.hideBars import org.mosad.teapod.util.*
import org.mosad.teapod.util.isInPiPMode
import org.mosad.teapod.util.navToLauncherTask
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.scheduleAtFixedRate import kotlin.concurrent.scheduleAtFixedRate
@ -65,8 +63,6 @@ import kotlin.concurrent.scheduleAtFixedRate
class PlayerActivity : AppCompatActivity() { class PlayerActivity : AppCompatActivity() {
private val model: PlayerViewModel by viewModels() private val model: PlayerViewModel by viewModels()
private lateinit var playerBinding: ActivityPlayerBinding
private lateinit var controlsBinding: PlayerControlsBinding
private lateinit var controller: StyledPlayerControlView private lateinit var controller: StyledPlayerControlView
private lateinit var gestureDetector: GestureDetectorCompat private lateinit var gestureDetector: GestureDetectorCompat
@ -84,11 +80,6 @@ class PlayerActivity : AppCompatActivity() {
setContentView(R.layout.activity_player) setContentView(R.layout.activity_player)
hideBars() // Initial hide the bars hideBars() // Initial hide the bars
playerBinding = ActivityPlayerBinding.bind(findViewById(R.id.player_root))
println(findViewById(R.id.player_controls_root))
controlsBinding = PlayerControlsBinding.bind(findViewById(R.id.player_controls_root))
model.loadMediaAsync( model.loadMediaAsync(
intent.getStringExtra(getString(R.string.intent_season_id)) ?: "", intent.getStringExtra(getString(R.string.intent_season_id)) ?: "",
intent.getStringExtra(getString(R.string.intent_episode_id)) ?: "" intent.getStringExtra(getString(R.string.intent_episode_id)) ?: ""
@ -96,7 +87,7 @@ class PlayerActivity : AppCompatActivity() {
model.currentEpisodeChangedListener.add { onMediaChanged() } model.currentEpisodeChangedListener.add { onMediaChanged() }
gestureDetector = GestureDetectorCompat(this, PlayerGestureListener()) gestureDetector = GestureDetectorCompat(this, PlayerGestureListener())
controller = playerBinding.videoView.findViewById(R.id.exo_controller) controller = video_view.findViewById(R.id.exo_controller)
controller.isAnimationEnabled = false // disable controls (time-bar) animation controller.isAnimationEnabled = false // disable controls (time-bar) animation
initExoPlayer() // call in onCreate, exoplayer lives in view model initExoPlayer() // call in onCreate, exoplayer lives in view model
@ -113,7 +104,7 @@ class PlayerActivity : AppCompatActivity() {
super.onStart() super.onStart()
if (Util.SDK_INT > 23) { if (Util.SDK_INT > 23) {
initPlayer() initPlayer()
playerBinding.videoView.onResume() video_view?.onResume()
} }
} }
@ -123,7 +114,7 @@ class PlayerActivity : AppCompatActivity() {
if (Util.SDK_INT <= 23) { if (Util.SDK_INT <= 23) {
initPlayer() initPlayer()
playerBinding.videoView.onResume() video_view?.onResume()
} }
} }
@ -175,7 +166,7 @@ class PlayerActivity : AppCompatActivity() {
} else { } else {
val width = model.player.videoFormat?.width ?: 0 val width = model.player.videoFormat?.width ?: 0
val height = model.player.videoFormat?.height ?: 0 val height = model.player.videoFormat?.height ?: 0
val contentFrame: View = playerBinding.videoView.findViewById(R.id.exo_content_frame) val contentFrame: View = video_view.findViewById(R.id.exo_content_frame)
val contentRect = with(contentFrame) { val contentRect = with(contentFrame) {
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow) val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
Rect(x, y, x + width, y + height) Rect(x, y, x + width, y + height)
@ -199,9 +190,7 @@ class PlayerActivity : AppCompatActivity() {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
playerBinding.videoView.useController = !isInPictureInPictureMode video_view.useController = !isInPictureInPictureMode
// TODO also hide language settings/episodes list
} }
private fun initPlayer() { private fun initPlayer() {
@ -223,13 +212,17 @@ class PlayerActivity : AppCompatActivity() {
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
super.onPlaybackStateChanged(state) super.onPlaybackStateChanged(state)
playerBinding.loading.visibility = when (state) { loading.visibility = when (state) {
ExoPlayer.STATE_READY -> View.GONE ExoPlayer.STATE_READY -> View.GONE
ExoPlayer.STATE_BUFFERING -> View.VISIBLE ExoPlayer.STATE_BUFFERING -> View.VISIBLE
else -> View.GONE else -> View.GONE
} }
controlsBinding.exoPlayPause.isVisible = !playerBinding.loading.isVisible exo_play_pause.visibility = when (loading.visibility) {
View.GONE -> View.VISIBLE
View.VISIBLE -> View.INVISIBLE
else -> View.VISIBLE
}
if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) { if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) {
playNextEpisode() playNextEpisode()
@ -244,10 +237,10 @@ class PlayerActivity : AppCompatActivity() {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private fun initVideoView() { private fun initVideoView() {
playerBinding.videoView.player = model.player video_view.player = model.player
// when the player controls get hidden, hide the bars too // when the player controls get hidden, hide the bars too
playerBinding.videoView.setControllerVisibilityListener { video_view.setControllerVisibilityListener {
when (it) { when (it) {
View.GONE -> { View.GONE -> {
hideBars() hideBars()
@ -257,23 +250,23 @@ class PlayerActivity : AppCompatActivity() {
} }
} }
playerBinding.videoView.setOnTouchListener { _, event -> video_view.setOnTouchListener { _, event ->
gestureDetector.onTouchEvent(event) gestureDetector.onTouchEvent(event)
true true
} }
} }
private fun initActions() { private fun initActions() {
controlsBinding.exoClosePlayer.setOnClickListener { exo_close_player.setOnClickListener {
this.finish() this.finish()
} }
controlsBinding.rwd10.setOnButtonClickListener { rewind() } rwd_10.setOnButtonClickListener { rewind() }
controlsBinding.ffwd10.setOnButtonClickListener { fastForward() } ffwd_10.setOnButtonClickListener { fastForward() }
playerBinding.buttonNextEp.setOnClickListener { playNextEpisode() } button_next_ep.setOnClickListener { playNextEpisode() }
playerBinding.buttonSkipOp.setOnClickListener { skipOpening() } button_skip_op.setOnClickListener { skipOpening() }
controlsBinding.buttonLanguage.setOnClickListener { showLanguageSettings() } button_language.setOnClickListener { showLanguageSettings() }
controlsBinding.buttonEpisodes.setOnClickListener { showEpisodesList() } button_episodes.setOnClickListener { showEpisodesList() }
controlsBinding.buttonNextEpC.setOnClickListener { playNextEpisode() } button_next_ep_c.setOnClickListener { playNextEpisode() }
} }
private fun initGUI() { private fun initGUI() {
@ -291,7 +284,7 @@ class PlayerActivity : AppCompatActivity() {
timerUpdates = Timer().scheduleAtFixedRate(0, 500) { timerUpdates = Timer().scheduleAtFixedRate(0, 500) {
lifecycleScope.launch { lifecycleScope.launch {
val currentPosition = model.player.currentPosition val currentPosition = model.player.currentPosition
val btnNextEpIsVisible = playerBinding.buttonNextEp.isVisible val btnNextEpIsVisible = button_next_ep.isVisible
val controlsVisible = controller.isVisible val controlsVisible = controller.isVisible
// make sure remaining time is > 0 // make sure remaining time is > 0
@ -315,12 +308,10 @@ class PlayerActivity : AppCompatActivity() {
model.currentEpisodeMeta?.let { model.currentEpisodeMeta?.let {
if (it.openingDuration > 0 && if (it.openingDuration > 0 &&
currentPosition in it.openingStart..(it.openingStart + 10000) && currentPosition in it.openingStart..(it.openingStart + 10000) &&
!playerBinding.buttonSkipOp.isVisible !button_skip_op.isVisible
) { ) {
showButtonSkipOp() showButtonSkipOp()
} else if (playerBinding.buttonSkipOp.isVisible && } else if (button_skip_op.isVisible && currentPosition !in it.openingStart..(it.openingStart + 10000)) {
currentPosition !in it.openingStart..(it.openingStart + 10000)
) {
// the button should only be visible, if currentEpisodeMeta != null // the button should only be visible, if currentEpisodeMeta != null
hideButtonSkipOp() hideButtonSkipOp()
} }
@ -335,7 +326,7 @@ class PlayerActivity : AppCompatActivity() {
} }
private fun onPauseOnStop() { private fun onPauseOnStop() {
playerBinding.videoView.onPause() video_view?.onPause()
model.player.pause() model.player.pause()
timerUpdates.cancel() timerUpdates.cancel()
} }
@ -350,7 +341,7 @@ class PlayerActivity : AppCompatActivity() {
val seconds = TimeUnit.MILLISECONDS.toSeconds(remainingTime) % 60 val seconds = TimeUnit.MILLISECONDS.toSeconds(remainingTime) % 60
// if remaining time is below 60 minutes, don't show hours // if remaining time is below 60 minutes, don't show hours
controlsBinding.exoRemaining.text = if (TimeUnit.MILLISECONDS.toMinutes(remainingTime) < 60) { exo_remaining.text = if (TimeUnit.MILLISECONDS.toMinutes(remainingTime) < 60) {
getString(R.string.time_min_sec, minutes, seconds) getString(R.string.time_min_sec, minutes, seconds)
} else { } else {
getString(R.string.time_hour_min_sec, hours, minutes, seconds) getString(R.string.time_hour_min_sec, hours, minutes, seconds)
@ -368,10 +359,10 @@ class PlayerActivity : AppCompatActivity() {
this.finish() this.finish()
} }
controlsBinding.exoTextTitle.text = model.getMediaTitle() exo_text_title.text = model.getMediaTitle()
// hide the next episode button, if there is none // hide the next episode button, if there is none
controlsBinding.buttonNextEpC.isVisible = hasNextEpisode() button_next_ep_c.visibility = if (hasNextEpisode()) View.VISIBLE else View.GONE
} }
/** /**
@ -391,36 +382,36 @@ class PlayerActivity : AppCompatActivity() {
model.seekToOffset(rwdTime) model.seekToOffset(rwdTime)
// hide/show needed components // hide/show needed components
playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE exo_double_tap_indicator.visibility = View.VISIBLE
playerBinding.ffwd10Indicator.visibility = View.INVISIBLE ffwd_10_indicator.visibility = View.INVISIBLE
controlsBinding.rwd10.visibility = View.INVISIBLE rwd_10.visibility = View.INVISIBLE
playerBinding.rwd10Indicator.onAnimationEndCallback = { rwd_10_indicator.onAnimationEndCallback = {
playerBinding.exoDoubleTapIndicator.visibility = View.GONE exo_double_tap_indicator.visibility = View.GONE
playerBinding.ffwd10Indicator.visibility = View.VISIBLE ffwd_10_indicator.visibility = View.VISIBLE
controlsBinding.rwd10.visibility = View.VISIBLE rwd_10.visibility = View.VISIBLE
} }
// run animation // run animation
playerBinding.rwd10Indicator.runOnClickAnimation() rwd_10_indicator.runOnClickAnimation()
} }
private fun fastForward() { private fun fastForward() {
model.seekToOffset(fwdTime) model.seekToOffset(fwdTime)
// hide/show needed components // hide/show needed components
playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE exo_double_tap_indicator.visibility = View.VISIBLE
playerBinding.rwd10Indicator.visibility = View.INVISIBLE rwd_10_indicator.visibility = View.INVISIBLE
controlsBinding.ffwd10.visibility = View.INVISIBLE ffwd_10.visibility = View.INVISIBLE
playerBinding.ffwd10Indicator.onAnimationEndCallback = { ffwd_10_indicator.onAnimationEndCallback = {
playerBinding.exoDoubleTapIndicator.visibility = View.GONE exo_double_tap_indicator.visibility = View.GONE
playerBinding.rwd10Indicator.visibility = View.VISIBLE rwd_10_indicator.visibility = View.VISIBLE
controlsBinding.ffwd10.visibility = View.VISIBLE ffwd_10.visibility = View.VISIBLE
} }
// run animation // run animation
playerBinding.ffwd10Indicator.runOnClickAnimation() ffwd_10_indicator.runOnClickAnimation()
} }
private fun playNextEpisode() { private fun playNextEpisode() {
@ -434,6 +425,7 @@ class PlayerActivity : AppCompatActivity() {
val seekTime = (it.openingStart + it.openingDuration) - model.player.currentPosition val seekTime = (it.openingStart + it.openingDuration) - model.player.currentPosition
model.seekToOffset(seekTime) model.seekToOffset(seekTime)
} }
} }
/** /**
@ -441,10 +433,10 @@ class PlayerActivity : AppCompatActivity() {
* TODO improve the show animation * TODO improve the show animation
*/ */
private fun showButtonNextEp() { private fun showButtonNextEp() {
playerBinding.buttonNextEp.isVisible = true button_next_ep.isVisible = true
playerBinding.buttonNextEp.alpha = 0.0f button_next_ep.alpha = 0.0f
playerBinding.buttonNextEp.animate() button_next_ep.animate()
.alpha(1.0f) .alpha(1.0f)
.setListener(null) .setListener(null)
} }
@ -454,45 +446,52 @@ class PlayerActivity : AppCompatActivity() {
* TODO improve the hide animation * TODO improve the hide animation
*/ */
private fun hideButtonNextEp() { private fun hideButtonNextEp() {
playerBinding.buttonNextEp.animate() button_next_ep.animate()
.alpha(0.0f) .alpha(0.0f)
.setListener(object : AnimatorListenerAdapter() { .setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
playerBinding.buttonNextEp.isVisible = false button_next_ep.isVisible = false
} }
}) })
} }
private fun showButtonSkipOp() { private fun showButtonSkipOp() {
playerBinding.buttonSkipOp.isVisible = true button_skip_op.isVisible = true
playerBinding.buttonSkipOp.alpha = 0.0f button_skip_op.alpha = 0.0f
playerBinding.buttonSkipOp.animate() button_skip_op.animate()
.alpha(1.0f) .alpha(1.0f)
.setListener(null) .setListener(null)
} }
private fun hideButtonSkipOp() { private fun hideButtonSkipOp() {
playerBinding.buttonSkipOp.animate() button_skip_op.animate()
.alpha(0.0f) .alpha(0.0f)
.setListener(object : AnimatorListenerAdapter() { .setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
playerBinding.buttonSkipOp.isVisible = false button_skip_op.isVisible = false
} }
}) })
} }
private fun showEpisodesList() { private fun showEpisodesList() {
val episodesList = EpisodesListPlayer(this, model = model).apply {
onViewRemovedAction = { model.player.play() }
}
player_layout.addView(episodesList)
pauseAndHideControls() pauseAndHideControls()
EpisodeListDialogFragment().show(supportFragmentManager, EpisodeListDialogFragment.TAG)
} }
private fun showLanguageSettings() { private fun showLanguageSettings() {
val languageSettings = LanguageSettingsPlayer(this, model = model).apply {
onViewRemovedAction = { model.player.play() }
}
player_layout.addView(languageSettings)
pauseAndHideControls() pauseAndHideControls()
LanguageSettingsDialogFragment().show(supportFragmentManager, LanguageSettingsDialogFragment.TAG)
} }
/** /**
@ -522,7 +521,7 @@ class PlayerActivity : AppCompatActivity() {
*/ */
override fun onDoubleTap(e: MotionEvent?): Boolean { override fun onDoubleTap(e: MotionEvent?): Boolean {
val eventPosX = e?.x?.toInt() ?: 0 val eventPosX = e?.x?.toInt() ?: 0
val viewCenterX = playerBinding.videoView.measuredWidth / 2 val viewCenterX = video_view.measuredWidth / 2
// if the event position is on the left side rewind, if it's on the right forward // if the event position is on the left side rewind, if it's on the right forward
if (eventPosX < viewCenterX) rewind() else fastForward() if (eventPosX < viewCenterX) rewind() else fastForward()

View File

@ -31,18 +31,25 @@ import androidx.lifecycle.viewModelScope
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.parser.crunchyroll.* import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.parser.crunchyroll.NoneEpisode
import org.mosad.teapod.parser.crunchyroll.NoneEpisodes
import org.mosad.teapod.parser.crunchyroll.NonePlayback
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.metadb.EpisodeMeta import org.mosad.teapod.util.EpisodeMeta
import org.mosad.teapod.util.metadb.Meta import org.mosad.teapod.util.Meta
import org.mosad.teapod.util.metadb.MetaDBController import org.mosad.teapod.util.TVShowMeta
import org.mosad.teapod.util.metadb.TVShowMeta import org.mosad.teapod.util.tmdb.TMDBTVSeason
import java.util.* import java.util.*
/** /**
@ -51,23 +58,22 @@ import java.util.*
* the next episode will be update and the callback is handled. * the next episode will be update and the callback is handled.
*/ */
class PlayerViewModel(application: Application) : AndroidViewModel(application) { class PlayerViewModel(application: Application) : AndroidViewModel(application) {
private val classTag = javaClass.name
val player = ExoPlayer.Builder(application).build() val player = SimpleExoPlayer.Builder(application).build()
private val dataSourceFactory = DefaultDataSourceFactory(application, Util.getUserAgent(application, "Teapod"))
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 // tmdb/meta data TODO currently not implemented for cr
var mediaMeta: Meta? = null var mediaMeta: Meta? = null
internal set internal set
var tmdbTVSeason: TMDBTVSeason? =null
internal set
var currentEpisodeMeta: EpisodeMeta? = null var currentEpisodeMeta: EpisodeMeta? = null
internal set internal set
var currentPlayheads: PlayheadsMap = mutableMapOf()
internal set
// var tmdbTVSeason: TMDBTVSeason? =null
// internal set
// crunchyroll episodes/playback // crunchyroll episodes/playback
var episodes = NoneEpisodes var episodes = NoneEpisodes
@ -77,7 +83,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
var currentPlayback = NonePlayback var currentPlayback = NonePlayback
// current playback settings // current playback settings
var currentLanguage: Locale = Preferences.preferredLocale var currentLanguage: Locale = Preferences.preferredLocal
internal set internal set
init { init {
@ -96,6 +102,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
if (!isPlaying) updatePlayhead() if (!isPlaying) updatePlayhead()
} }
}) })
} }
override fun onCleared() { override fun onCleared() {
@ -104,7 +112,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
mediaSession.release() mediaSession.release()
player.release() player.release()
Log.d(classTag, "Released player") Log.d(javaClass.name, "Released player")
} }
/** /**
@ -121,19 +129,21 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun loadMediaAsync(seasonId: String, episodeId: String) = viewModelScope.launch { fun loadMediaAsync(seasonId: String, episodeId: String) = viewModelScope.launch {
episodes = Crunchyroll.episodes(seasonId) episodes = Crunchyroll.episodes(seasonId)
listOf(
viewModelScope.launch { mediaMeta = loadMediaMeta(episodes.items.first().seriesId) },
viewModelScope.launch {
val episodeIDs = episodes.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
}
).joinAll()
Log.d(classTag, "meta: $mediaMeta")
setCurrentEpisode(episodeId) setCurrentEpisode(episodeId)
playCurrentMedia(currentPlayhead) playCurrentMedia(currentPlayhead) // TODO, if fully watched, start from 0
// TODO reimplement for cr
// run async as it should be loaded by the time the episodes a
// viewModelScope.launch {
// // get tmdb season info, if metaDB knows the tv show
// if (media.type == DataTypes.MediaType.TVSHOW && mediaMeta != null) {
// val tvShowMeta = mediaMeta as TVShowMeta
// tmdbTVSeason = TMDBApiController().getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber)
// }
// }
//
// currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisodeAoD.mediaId)
// currentLanguage = currentEpisodeAoD.getPreferredStream(preferredLanguage).language
} }
fun setLanguage(language: Locale) { fun setLanguage(language: Locale) {
@ -155,7 +165,6 @@ 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)
} }
@ -168,16 +177,6 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
episode.id == episodeId episode.id == episodeId
} ?: NoneEpisode } ?: NoneEpisode
// TODO improve handling of none present seasons/episodes
// update current episode meta
currentEpisodeMeta = if (mediaMeta is TVShowMeta && currentEpisode.episodeNumber != null) {
(mediaMeta as TVShowMeta)
.seasons.getOrNull(currentEpisode.seasonNumber - 1)
?.episodes?.getOrNull(currentEpisode.episodeNumber!! - 1)
} else {
null
}
// update player gui (title, next ep button) after currentEpisode has changed // update player gui (title, next ep button) after currentEpisode has changed
currentEpisodeChangedListener.forEach { it() } currentEpisodeChangedListener.forEach { it() }
@ -189,17 +188,14 @@ 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 {
// if the episode was fully watched, start at the beginning currentPlayhead = (it.playhead.times(1000)).toLong()
currentPlayhead = if (it.fullyWatched) {
0
} else {
(it.playhead.times(1000)).toLong()
}
} }
} }
) )
} }
Log.d(classTag, "playback: ${currentEpisode.playback}") println("loaded playback ${currentEpisode.playback}")
// TODO update metadata and language (it should not be needed to update the language here!)
if (startPlayback) { if (startPlayback) {
playCurrentMedia() playCurrentMedia()
@ -224,18 +220,20 @@ 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.entries.first().value.url currentPlayback.streams.adaptive_hls[Locale.ROOT.toLanguageTag()]?.url ?: ""
} }
} }
Log.i(classTag, "stream url: $url") println("stream url: $url")
// create the media item // create the media source object
val mediaItem = MediaItem.fromUri(Uri.parse(url)) val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
player.setMediaItem(mediaItem) MediaItem.fromUri(Uri.parse(url))
)
// the actual player playback code
player.setMediaSource(mediaSource)
player.prepare() player.prepare()
if (seekPosition > 0) player.seekTo(seekPosition) if (seekPosition > 0) player.seekTo(seekPosition)
player.playWhenReady = true player.playWhenReady = true
} }
@ -265,8 +263,24 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
return episodes.items.lastOrNull()?.id == currentEpisode.id return episodes.items.lastOrNull()?.id == currentEpisode.id
} }
private suspend fun loadMediaMeta(crSeriesId: String): Meta? { fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? {
return MetaDBController.getTVShowMetadata(crSeriesId) val meta = mediaMeta
return if (meta is TVShowMeta) {
meta.episodes.firstOrNull { it.aodMediaId == aodMediaId }
} else {
null
}
}
// TODO reimplement for cr
private suspend fun loadMediaMeta(aodId: Int): Meta? {
// return if (media.type == DataTypes.MediaType.TVSHOW) {
// MetaDBController().getTVShowMetadata(aodId)
// } else {
// null
// }
return null
} }
/** /**
@ -275,15 +289,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
private fun updatePlayhead() { private fun updatePlayhead() {
val playhead = (player.currentPosition / 1000) val playhead = (player.currentPosition / 1000)
if (playhead > 0 && Preferences.updatePlayhead) { if (playhead > 0) {
viewModelScope.launch { Crunchyroll.postPlayheads(currentEpisode.id, playhead.toInt()) } viewModelScope.launch { Crunchyroll.postPlayheads(currentEpisode.id, playhead.toInt()) }
Log.i(javaClass.name, "Set playhead for episode ${currentEpisode.id} to $playhead sec.") Log.i(javaClass.name, "Set playhead for episode ${currentEpisode.id} to $playhead sec.")
} }
viewModelScope.launch {
val episodeIDs = episodes.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
}
} }
} }

View File

@ -1,68 +0,0 @@
package org.mosad.teapod.ui.activity.player.fragment
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import org.mosad.teapod.R
import org.mosad.teapod.databinding.PlayerEpisodesListBinding
import org.mosad.teapod.ui.activity.player.PlayerViewModel
import org.mosad.teapod.util.adapter.EpisodeItemAdapter
import org.mosad.teapod.util.hideBars
class EpisodeListDialogFragment : DialogFragment() {
private lateinit var model: PlayerViewModel
private lateinit var binding: PlayerEpisodesListBinding
companion object {
const val TAG = "LanguageSettingsDialogFragment"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle)
model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java]
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = PlayerEpisodesListBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonCloseEpisodesList.setOnClickListener {
dismiss()
}
val adapterRecEpisodes = EpisodeItemAdapter(
model.episodes.items,
null,
model.currentPlayheads.toMap(),
EpisodeItemAdapter.OnClickListener { episode ->
dismiss()
model.setCurrentEpisode(episode.id, startPlayback = true)
},
EpisodeItemAdapter.ViewType.PLAYER
)
// episodeNumber starts at 1, we need the episode index -> - 1
adapterRecEpisodes.currentSelected = model.currentEpisode.episodeNumber?.minus(1) ?: 0
binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes
binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected)
// initially hide the status and navigation bar
hideBars(requireDialog().window, binding.root)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
model.player.play()
}
}

View File

@ -0,0 +1,44 @@
package org.mosad.teapod.ui.components
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import org.mosad.teapod.databinding.PlayerEpisodesListBinding
import org.mosad.teapod.ui.activity.player.PlayerViewModel
import org.mosad.teapod.util.adapter.PlayerEpisodeItemAdapter
class EpisodesListPlayer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
model: PlayerViewModel? = null
) : LinearLayout(context, attrs, defStyleAttr) {
private val binding = PlayerEpisodesListBinding.inflate(LayoutInflater.from(context), this, true)
private lateinit var adapterRecEpisodes: PlayerEpisodeItemAdapter
var onViewRemovedAction: (() -> Unit)? = null // TODO find a better solution for this
init {
binding.buttonCloseEpisodesList.setOnClickListener {
(this.parent as ViewGroup).removeView(this)
onViewRemovedAction?.invoke()
}
model?.let {
adapterRecEpisodes = PlayerEpisodeItemAdapter(model.episodes, model.tmdbTVSeason?.episodes)
adapterRecEpisodes.onImageClick = {_, episodeId ->
(this.parent as ViewGroup).removeView(this)
model.setCurrentEpisode(episodeId, startPlayback = true)
}
// episodeNumber starts at 1, we need the episode index -> - 1
adapterRecEpisodes.currentSelected = model.currentEpisode.episodeNumber?.minus(1) ?: 0
binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes
binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected)
}
}
}

View File

@ -1,75 +1,54 @@
package org.mosad.teapod.ui.activity.player.fragment package org.mosad.teapod.ui.components
import android.content.DialogInterface import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Bundle import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.children import androidx.core.view.children
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.databinding.PlayerLanguageSettingsBinding import org.mosad.teapod.databinding.PlayerLanguageSettingsBinding
import org.mosad.teapod.ui.activity.player.PlayerViewModel import org.mosad.teapod.ui.activity.player.PlayerViewModel
import org.mosad.teapod.util.hideBars
import java.util.* import java.util.*
class LanguageSettingsDialogFragment : DialogFragment() { // TODO port to DialogFragment
class LanguageSettingsPlayer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
model: PlayerViewModel? = null
) : LinearLayout(context, attrs, defStyleAttr) {
private lateinit var model: PlayerViewModel private val binding = PlayerLanguageSettingsBinding.inflate(LayoutInflater.from(context), this, true)
private lateinit var binding: PlayerLanguageSettingsBinding var onViewRemovedAction: (() -> Unit)? = null
private var selectedLocale = Locale.ROOT private var selectedLocale = model?.currentLanguage ?: Locale.ROOT
companion object { init {
const val TAG = "LanguageSettingsDialogFragment" model?.let { m ->
} m.currentPlayback.streams.adaptive_hls.keys.forEach { languageTag ->
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle)
model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java]
selectedLocale = model.currentLanguage
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = PlayerLanguageSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.currentPlayback.streams.adaptive_hls.keys.forEach { languageTag ->
val locale = Locale.forLanguageTag(languageTag) val locale = Locale.forLanguageTag(languageTag)
addLanguage(locale, locale == model.currentLanguage) { v -> addLanguage(locale, locale == m.currentLanguage) { v ->
selectedLocale = locale selectedLocale = locale
updateSelectedLanguage(v as TextView) updateSelectedLanguage(v as TextView)
} }
} }
}
binding.buttonCloseLanguageSettings.setOnClickListener { dismiss() } binding.buttonCloseLanguageSettings.setOnClickListener { close() }
binding.buttonCancel.setOnClickListener { dismiss() } binding.buttonCancel.setOnClickListener { close() }
binding.buttonSelect.setOnClickListener { binding.buttonSelect.setOnClickListener {
model.setLanguage(selectedLocale) model?.setLanguage(selectedLocale)
dismiss() close()
}
} }
// initially hide the status and navigation bar private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: OnClickListener) {
hideBars(requireDialog().window, binding.root)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
model.player.play()
}
private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: View.OnClickListener) {
val text = TextView(context).apply { val text = TextView(context).apply {
height = 96 height = 96
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
@ -77,13 +56,13 @@ class LanguageSettingsDialogFragment : DialogFragment() {
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
if (isSelected) { if (isSelected) {
setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme)) setTextColor(context.resources.getColor(R.color.exo_white, context.theme))
setTypeface(null, Typeface.BOLD) setTypeface(null, Typeface.BOLD)
setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0)
compoundDrawablesRelative.getOrNull(0)?.setTint(Color.WHITE) compoundDrawablesRelative.getOrNull(0)?.setTint(Color.WHITE)
compoundDrawablePadding = 12 compoundDrawablePadding = 12
} else { } else {
setTextColor(context.resources.getColor(R.color.textSecondaryDark, context.theme)) setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme))
setPadding(75, 0, 0, 0) setPadding(75, 0, 0, 0)
} }
@ -104,11 +83,12 @@ class LanguageSettingsDialogFragment : DialogFragment() {
setPadding(75, 0, 0, 0) setPadding(75, 0, 0, 0)
} }
} }
} }
// set selected to selected style // set selected to selected style
selected.apply { selected.apply {
setTextColor(context.resources.getColor(R.color.player_white, context.theme)) setTextColor(context.resources.getColor(R.color.exo_white, context.theme))
setTypeface(null, Typeface.BOLD) setTypeface(null, Typeface.BOLD)
setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0)
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
@ -116,4 +96,10 @@ class LanguageSettingsDialogFragment : DialogFragment() {
compoundDrawablePadding = 12 compoundDrawablePadding = 12
} }
} }
private fun close() {
(this.parent as ViewGroup).removeView(this)
onViewRemovedAction?.invoke()
}
} }

View File

@ -0,0 +1,93 @@
/**
* ProjectLaogai
*
* Copyright 2019-2020 <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.ui.components
import android.content.Context
import android.widget.EditText
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.bottomsheets.BottomSheet
import com.afollestad.materialdialogs.bottomsheets.setPeekHeight
import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.customview.getCustomView
import org.mosad.teapod.R
class LoginDialog(val context: Context, firstTry: Boolean) {
private val dialog = MaterialDialog(context, BottomSheet())
private val editTextLogin: EditText
private val editTextPassword: EditText
var login = ""
var password = ""
init {
dialog.title(R.string.login)
.message(if (firstTry) R.string.login_desc else R.string.login_failed_desc)
.customView(R.layout.dialog_login)
.positiveButton(R.string.save)
.negativeButton(R.string.cancel)
.setPeekHeight(900)
editTextLogin = dialog.getCustomView().findViewById(R.id.edit_text_login)
editTextPassword = dialog.getCustomView().findViewById(R.id.edit_text_password)
// fix not working accent color
//dialog.getActionButton(WhichButton.POSITIVE).updateTextColor(Preferences.colorAccent)
//dialog.getActionButton(WhichButton.NEGATIVE).updateTextColor(Preferences.colorAccent)
}
fun positiveButton(func: LoginDialog.() -> Unit): LoginDialog = apply {
dialog.positiveButton {
login = editTextLogin.text.toString()
password = editTextPassword.text.toString()
func()
}
}
fun negativeButton(func: LoginDialog.() -> Unit): LoginDialog = apply {
dialog.negativeButton {
func()
}
}
fun show() {
dialog.show()
}
fun show(func: LoginDialog.() -> Unit): LoginDialog = apply {
func()
editTextLogin.setText(login)
editTextPassword.setText(password)
show()
}
@Suppress("unused")
fun dismiss() {
dialog.dismiss()
}
}

View File

@ -1,54 +0,0 @@
package org.mosad.teapod.ui.components
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.mosad.teapod.databinding.ModalBottomSheetLoginBinding
/**
* A bottom sheet with login credential input fields.
*
* To initialize login or password values, use apply.
*/
class LoginModalBottomSheet : BottomSheetDialogFragment() {
private lateinit var binding: ModalBottomSheetLoginBinding
var login = ""
var password = ""
lateinit var positiveAction: LoginModalBottomSheet.() -> Unit
lateinit var negativeAction: LoginModalBottomSheet.() -> Unit
companion object {
const val TAG = "LoginModalBottomSheet"
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = ModalBottomSheetLoginBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.editTextLogin.setText(login)
binding.editTextPassword.setText(password)
binding.positiveButton.setOnClickListener {
login = binding.editTextLogin.text.toString()
password = binding.editTextPassword.text.toString()
positiveAction.invoke(this)
}
binding.negativeButton.setOnClickListener {
negativeAction.invoke(this)
}
}
}

View File

@ -5,6 +5,9 @@ import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.commit import androidx.fragment.app.commit
@ -28,7 +31,23 @@ fun FragmentActivity.showFragment(fragment: Fragment) {
* hide the status and navigation bar * hide the status and navigation bar
*/ */
fun Activity.hideBars() { fun Activity.hideBars() {
hideBars(window, window.decorView.rootView) window.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setDecorFitsSystemWindows(false)
insetsController?.apply {
hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
}
} else {
@Suppress("deprecation")
decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}
} }
fun Activity.isInPiPMode(): Boolean { fun Activity.isInPiPMode(): Boolean {

View File

@ -0,0 +1,159 @@
/**
* 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.util
import android.util.Log
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.*
import java.io.FileNotFoundException
import java.net.URL
/**
* TODO remove gson usage
*/
class MetaDBController {
companion object {
private const val repoUrl = "https://gitlab.com/Seil0/teapodmetadb/-/raw/main/aod/"
var mediaList = MediaList(listOf())
private var metaCacheList = arrayListOf<Meta>()
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun list() = withContext(Dispatchers.IO) {
val url = URL("$repoUrl/list.json")
val json = url.readText()
mediaList = Gson().fromJson(json, MediaList::class.java)
}
}
/**
* Get the meta data for a movie from MetaDB
* @param aodId The AoD id of the media
* @return A meta movie object, or null if not found
*/
suspend fun getMovieMetadata(aodId: Int): MovieMeta? {
return metaCacheList.firstOrNull {
it.aodId == aodId
} as MovieMeta? ?: getMovieMetadataFromDB(aodId)
}
/**
* Get the meta data for a tv show from MetaDB
* @param aodId The AoD id of the media
* @return A meta tv show object, or null if not found
*/
suspend fun getTVShowMetadata(aodId: Int): TVShowMeta? {
return metaCacheList.firstOrNull {
it.aodId == aodId
} as TVShowMeta? ?: getTVShowMetadataFromDB(aodId)
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getMovieMetadataFromDB(aodId: Int): MovieMeta? = withContext(Dispatchers.IO) {
val url = URL("$repoUrl/movie/$aodId/media.json")
return@withContext try {
val json = url.readText()
val meta = Gson().fromJson(json, MovieMeta::class.java)
metaCacheList.add(meta)
meta
} catch (ex: FileNotFoundException) {
Log.w(javaClass.name, "Waring: The requested file was not found. Requested ID: $aodId", ex)
null
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getTVShowMetadataFromDB(aodId: Int): TVShowMeta? = withContext(Dispatchers.IO) {
val url = URL("$repoUrl/tv/$aodId/media.json")
return@withContext try {
val json = url.readText()
val meta = Gson().fromJson(json, TVShowMeta::class.java)
metaCacheList.add(meta)
meta
} catch (ex: FileNotFoundException) {
Log.w(javaClass.name, "Waring: The requested file was not found. Requested ID: $aodId", ex)
null
}
}
}
// class representing the media list json object
data class MediaList(
val media: List<Int>
)
// abstract class used for meta data objects (tv, movie)
abstract class Meta {
abstract val id: Int
abstract val aodId: Int
abstract val tmdbId: Int
}
// class representing the movie json object
data class MovieMeta(
override val id: Int,
@SerializedName("aod_id")
override val aodId: Int,
@SerializedName("tmdb_id")
override val tmdbId: Int
): Meta()
// class representing the tv show json object
data class TVShowMeta(
override val id: Int,
@SerializedName("aod_id")
override val aodId: Int,
@SerializedName("tmdb_id")
override val tmdbId: Int,
@SerializedName("tmdb_season_id")
val tmdbSeasonId: Int,
@SerializedName("tmdb_season_number")
val tmdbSeasonNumber: Int,
@SerializedName("episodes")
val episodes: List<EpisodeMeta>
): Meta()
// class used in TVShowMeta, part of the tv show json object
data class EpisodeMeta(
val id: Int,
@SerializedName("aod_media_id")
val aodMediaId: Int,
@SerializedName("tmdb_id")
val tmdbId: Int,
@SerializedName("tmdb_number")
val tmdbNumber: Int,
@SerializedName("opening_start")
val openingStart: Long,
@SerializedName("opening_duration")
val openingDuration: Long,
@SerializedName("ending_start")
val endingStart: Long,
@SerializedName("ending_duration")
val endingDuration: Long
)

View File

@ -1,15 +1,10 @@
package org.mosad.teapod.util package org.mosad.teapod.util
import android.view.View
import android.view.Window
import android.widget.TextView import android.widget.TextView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
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)
@ -26,42 +21,9 @@ fun Collection<Item>.toItemMediaList(): List<ItemMedia> {
} }
} }
@JvmName("toItemMediaListItem")
fun List<Item>.toItemMediaList(): List<ItemMedia> {
return this.map {
ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source)
}
}
@JvmName("toItemMediaListContinueWatchingItem") @JvmName("toItemMediaListContinueWatchingItem")
fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> { fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
return items.map { return this.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
}
}
fun hideBars(window: Window?, root: View) {
if (window != null) {
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, root).let { controller ->
controller.hide(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.navigationBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}

View File

@ -4,7 +4,6 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -13,167 +12,84 @@ import com.bumptech.glide.request.RequestOptions
import jp.wasabeef.glide.transformations.RoundedCornersTransformation import jp.wasabeef.glide.transformations.RoundedCornersTransformation
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.databinding.ItemEpisodeBinding import org.mosad.teapod.databinding.ItemEpisodeBinding
import org.mosad.teapod.databinding.ItemEpisodePlayerBinding
import org.mosad.teapod.parser.crunchyroll.Episode import org.mosad.teapod.parser.crunchyroll.Episode
import org.mosad.teapod.parser.crunchyroll.PlayheadObject
import org.mosad.teapod.parser.crunchyroll.PlayheadsMap import org.mosad.teapod.parser.crunchyroll.PlayheadsMap
import org.mosad.teapod.util.tmdb.TMDBTVEpisode import org.mosad.teapod.util.tmdb.TMDBTVEpisode
class EpisodeItemAdapter( class EpisodeItemAdapter(
private val episodes: List<Episode>, private val episodes: List<Episode>,
private val tmdbEpisodes: List<TMDBTVEpisode>?, private val tmdbEpisodes: List<TMDBTVEpisode>?,
private val playheads: PlayheadsMap, private val playheads: PlayheadsMap
private val onClickListener: OnClickListener, ) : RecyclerView.Adapter<EpisodeItemAdapter.EpisodeViewHolder>() {
private val viewType: ViewType
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var currentSelected: Int = -1 // -1, since position should never be < 0 var onImageClick: ((seasonId: String, episodeId: String) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
return when (viewType) { return EpisodeViewHolder(ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false))
ViewType.PLAYER.ordinal -> {
PlayerEpisodeViewHolder((ItemEpisodePlayerBinding.inflate(LayoutInflater.from(parent.context), parent, false)))
}
else -> {
// media fragment episode list is default
EpisodeViewHolder(ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
}
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
val episode = episodes[position] val context = holder.binding.root.context
val playhead = playheads[episode.id] val ep = episodes[position]
val tmdbEpisode = tmdbEpisodes?.getOrNull(position)
when (holder.itemViewType) { val titleText = if (ep.episodeNumber != null) {
ViewType.MEDIA_FRAGMENT.ordinal -> { // for tv shows add ep prefix and episode number
(holder as EpisodeViewHolder).bind(episode, playhead, tmdbEpisode) if (ep.isDubbed) {
} context.getString(R.string.component_episode_title, ep.episode, ep.title)
ViewType.PLAYER.ordinal -> { } else {
(holder as PlayerEpisodeViewHolder).bind(episode, playhead, currentSelected) context.getString(R.string.component_episode_title_sub, ep.episode, ep.title)
}
} }
} else {
ep.title
} }
override fun getItemViewType(position: Int): Int { holder.binding.textEpisodeTitle.text = titleText
return when (viewType) { holder.binding.textEpisodeDesc.text = if (ep.description.isNotEmpty()) {
ViewType.MEDIA_FRAGMENT -> ViewType.MEDIA_FRAGMENT.ordinal ep.description
ViewType.PLAYER -> ViewType.PLAYER.ordinal } else if (tmdbEpisodes != null && position < tmdbEpisodes.size){
tmdbEpisodes[position].overview
} else {
""
} }
// TODO is isNotEmpty() needed? also in PlayerEpisodeItemAdapter
if (ep.images.thumbnail[0][0].source.isNotEmpty()) {
Glide.with(context).load(ep.images.thumbnail[0][0].source)
.apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY)))
.apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0)))
.into(holder.binding.imageEpisode)
}
// add watched icon to episode, if the episode id is present in playheads and fullyWatched
val watchedImage: Drawable? = if (playheads[ep.id]?.fullyWatched == true) {
ContextCompat.getDrawable(context, R.drawable.ic_baseline_check_circle_24)
} else {
null
}
holder.binding.imageWatched.setImageDrawable(watchedImage)
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return episodes.size return episodes.size
} }
fun updateWatchedState(watched: Boolean, position: Int) {
// use getOrNull as there could be a index out of bound when running this in onResume()
// TODO
//episodes.getOrNull(position)?.watched = watched
}
inner class EpisodeViewHolder(val binding: ItemEpisodeBinding) : inner class EpisodeViewHolder(val binding: ItemEpisodeBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
init {
fun bind(episode: Episode, playhead: PlayheadObject?, tmdbEpisode: TMDBTVEpisode?) { // on image click return the episode id and index (within the adapter)
val context = binding.root.context
val titleText = if (episode.episodeNumber != null) {
// for tv shows add ep prefix and episode number
if (episode.isDubbed) {
context.getString(R.string.component_episode_title, episode.episode, episode.title)
} else {
context.getString(R.string.component_episode_title_sub, episode.episode, episode.title)
}
} else {
episode.title
}
binding.textEpisodeTitle.text = titleText
binding.textEpisodeDesc.text = episode.description.ifEmpty {
tmdbEpisode?.overview ?: ""
}
if (episode.images.thumbnail[0][0].source.isNotEmpty()) {
Glide.with(context).load(episode.images.thumbnail[0][0].source)
.apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY)))
.apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0)))
.into(binding.imageEpisode)
}
// add watched progress
val playheadProgress = playhead?.playhead?.let {
((it.toFloat() / (episode.durationMs / 1000)) * 100).toInt()
} ?: 0
binding.progressPlayhead.setProgressCompat(playheadProgress, false)
binding.progressPlayhead.visibility = if (playheadProgress <= 0)
View.GONE else View.VISIBLE
// add watched icon to episode, if the episode id is present in playheads and fullyWatched
val watchedImage: Drawable? = if (playhead?.fullyWatched == true) {
ContextCompat.getDrawable(context, R.drawable.ic_baseline_check_circle_24)
} else {
null
}
binding.imageWatched.setImageDrawable(watchedImage)
binding.imageEpisode.setOnClickListener { binding.imageEpisode.setOnClickListener {
onClickListener.onClick(episode) onImageClick?.invoke(
} episodes[bindingAdapterPosition].seasonId,
} episodes[bindingAdapterPosition].id
} )
inner class PlayerEpisodeViewHolder(val binding: ItemEpisodePlayerBinding) :
RecyclerView.ViewHolder(binding.root) {
// -1, since position should never be < 0
fun bind(episode: Episode, playhead: PlayheadObject?, currentSelected: Int) {
val context = binding.root.context
val titleText = if (episode.episodeNumber != null) {
// for tv shows add ep prefix and episode number
if (episode.isDubbed) {
context.getString(R.string.component_episode_title, episode.episode, episode.title)
} else {
context.getString(R.string.component_episode_title_sub, episode.episode, episode.title)
}
} else {
episode.title
}
binding.textEpisodeTitle2.text = titleText
binding.textEpisodeDesc2.text = episode.description.ifEmpty { "" }
if (episode.images.thumbnail[0][0].source.isNotEmpty()) {
Glide.with(context).load(episode.images.thumbnail[0][0].source)
.apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0)))
.into(binding.imageEpisode)
}
// add watched progress
val playheadProgress = playhead?.playhead?.let {
((it.toFloat() / (episode.durationMs / 1000)) * 100).toInt()
} ?: 0
binding.progressPlayhead.setProgressCompat(playheadProgress, false)
binding.progressPlayhead.visibility = if (playheadProgress <= 0)
View.GONE else View.VISIBLE
// hide the play icon, if it's the current episode
binding.imageEpisodePlay.visibility = if (currentSelected == bindingAdapterPosition) {
View.GONE
} else {
View.VISIBLE
}
if (currentSelected != bindingAdapterPosition) {
binding.imageEpisode.setOnClickListener {
onClickListener.onClick(episode)
} }
} }
} }
} }
class OnClickListener(val clickListener: (episode: Episode) -> Unit) {
fun onClick(episode: Episode) = clickListener(episode)
}
enum class ViewType {
MEDIA_FRAGMENT,
PLAYER
}
}

View File

@ -1,70 +0,0 @@
package org.mosad.teapod.util.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import org.mosad.teapod.R
import org.mosad.teapod.databinding.ItemMediaBinding
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem
class MediaEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter<ContinueWatchingItem, MediaEpisodeListAdapter.MediaViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder {
return MediaViewHolder(
ItemMediaBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: MediaViewHolder, position: Int) {
val item = getItem(position)
holder.binding.root.setOnClickListener {
onClickListener.onClick(item)
}
holder.bind(item)
}
inner class MediaViewHolder(val binding: ItemMediaBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: ContinueWatchingItem) {
val metadata = item.panel.episodeMetadata
binding.textTitle.text = binding.root.context.getString(R.string.season_episode_title,
metadata.seasonNumber, metadata.episodeNumber, metadata.seriesTitle
)
Glide.with(binding.imagePoster)
.load(item.panel.images.thumbnail[0][0].source)
.into(binding.imagePoster)
// add watched progress
val playheadProgress = ((item.playhead.toFloat() / (metadata.durationMs / 1000)) * 100)
.toInt()
binding.progressPlayhead.setProgressCompat(playheadProgress, false)
binding.progressPlayhead.visibility = if (playheadProgress <= 0)
View.GONE else View.VISIBLE
}
}
companion object DiffCallback : DiffUtil.ItemCallback<ContinueWatchingItem>() {
override fun areItemsTheSame(oldItem: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean {
return oldItem.panel.id == newItem.panel.id
}
override fun areContentsTheSame(oldItem: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean {
return oldItem == newItem
}
}
class OnClickListener(val clickListener: (item: ContinueWatchingItem) -> Unit) {
fun onClick(item: ContinueWatchingItem) = clickListener(item)
}
}

View File

@ -2,13 +2,11 @@ package org.mosad.teapod.util.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import org.mosad.teapod.databinding.ItemMediaBinding import org.mosad.teapod.databinding.ItemMediaBinding
import org.mosad.teapod.util.ItemMedia import org.mosad.teapod.util.ItemMedia
@Deprecated("Use MediaItemListAdapter instead")
class MediaItemAdapter(private val items: List<ItemMedia>) : RecyclerView.Adapter<MediaItemAdapter.MediaViewHolder>() { class MediaItemAdapter(private val items: List<ItemMedia>) : RecyclerView.Adapter<MediaItemAdapter.MediaViewHolder>() {
var onItemClick: ((id: String, position: Int) -> Unit)? = null var onItemClick: ((id: String, position: Int) -> Unit)? = null
@ -31,7 +29,6 @@ class MediaItemAdapter(private val items: List<ItemMedia>) : RecyclerView.Adapte
inner class MediaViewHolder(val binding: ItemMediaBinding) : inner class MediaViewHolder(val binding: ItemMediaBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
init { init {
binding.imageEpisodePlay.isVisible = false // hide the play button for media items
binding.root.setOnClickListener { binding.root.setOnClickListener {
onItemClick?.invoke( onItemClick?.invoke(
items[bindingAdapterPosition].id, items[bindingAdapterPosition].id,

View File

@ -1,61 +0,0 @@
package org.mosad.teapod.util.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import org.mosad.teapod.databinding.ItemMediaBinding
import org.mosad.teapod.util.ItemMedia
class MediaItemListAdapter(private val onClickListener: OnClickListener) : ListAdapter<ItemMedia, MediaItemListAdapter.MediaViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder {
return MediaViewHolder(
ItemMediaBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: MediaViewHolder, position: Int) {
val item = getItem(position)
holder.binding.root.setOnClickListener {
onClickListener.onClick(item)
}
holder.bind(item)
}
inner class MediaViewHolder(val binding: ItemMediaBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: ItemMedia) {
binding.textTitle.text = item.title
Glide.with(binding.imagePoster)
.load(item.posterUrl)
.into(binding.imagePoster)
binding.imageEpisodePlay.isVisible = false
binding.progressPlayhead.isVisible = false
}
}
companion object DiffCallback : DiffUtil.ItemCallback<ItemMedia>() {
override fun areItemsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean {
return oldItem == newItem
}
}
class OnClickListener(val clickListener: (item: ItemMedia) -> Unit) {
fun onClick(item: ItemMedia) = clickListener(item)
}
}

View File

@ -0,0 +1,79 @@
package org.mosad.teapod.util.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import jp.wasabeef.glide.transformations.RoundedCornersTransformation
import org.mosad.teapod.R
import org.mosad.teapod.databinding.ItemEpisodePlayerBinding
import org.mosad.teapod.parser.crunchyroll.Episodes
import org.mosad.teapod.util.tmdb.TMDBTVEpisode
class PlayerEpisodeItemAdapter(private val episodes: Episodes, private val tmdbEpisodes: List<TMDBTVEpisode>?) : RecyclerView.Adapter<PlayerEpisodeItemAdapter.EpisodeViewHolder>() {
var onImageClick: ((seasonId: String, episodeId: String) -> Unit)? = null
var currentSelected: Int = -1 // -1, since position should never be < 0
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
return EpisodeViewHolder(ItemEpisodePlayerBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
val context = holder.binding.root.context
val ep = episodes.items[position]
val titleText = if (ep.episodeNumber != null) {
// for tv shows add ep prefix and episode number
if (ep.isDubbed) {
context.getString(R.string.component_episode_title, ep.episode, ep.title)
} else {
context.getString(R.string.component_episode_title_sub, ep.episode, ep.title)
}
} else {
ep.title
}
holder.binding.textEpisodeTitle2.text = titleText
holder.binding.textEpisodeDesc2.text = if (ep.description.isNotEmpty()) {
ep.description
} else if (tmdbEpisodes != null && position < tmdbEpisodes.size){
tmdbEpisodes[position].overview
} else {
""
}
if (ep.images.thumbnail[0][0].source.isNotEmpty()) {
Glide.with(context).load(ep.images.thumbnail[0][0].source)
.apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0)))
.into(holder.binding.imageEpisode)
}
// hide the play icon, if it's the current episode
holder.binding.imageEpisodePlay.visibility = if (currentSelected == position) {
View.GONE
} else {
View.VISIBLE
}
}
override fun getItemCount(): Int {
return episodes.items.size
}
inner class EpisodeViewHolder(val binding: ItemEpisodePlayerBinding) : RecyclerView.ViewHolder(binding.root) {
init {
binding.imageEpisode.setOnClickListener {
// don't execute, if it's the current episode
if (currentSelected != bindingAdapterPosition) {
onImageClick?.invoke(
episodes.items[bindingAdapterPosition].seasonId,
episodes.items[bindingAdapterPosition].id
)
}
}
}
}
}

View File

@ -1,57 +0,0 @@
package org.mosad.teapod.util.metadb
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// class representing the media list json object
@Serializable
data class MediaList(
@SerialName("media") val media: List<String>
)
// abstract class used for meta data objects (tv, movie)
abstract class Meta {
abstract val id: Int
abstract val tmdbId: Int
abstract val crSeriesId: String
}
// class representing the movie json object
@Serializable
data class MovieMeta(
@SerialName("id") override val id: Int,
@SerialName("tmdb_id") override val tmdbId: Int,
@SerialName("cr_series_id") override val crSeriesId: String,
): Meta()
// class representing the tv show json object
@Serializable
data class TVShowMeta(
@SerialName("id") override val id: Int,
@SerialName("tmdb_id") override val tmdbId: Int,
@SerialName("cr_series_id") override val crSeriesId: String,
@SerialName("seasons") val seasons: List<SeasonMeta>,
): Meta()
// class used in TVShowMeta, part of the tv show json object
@Serializable
data class SeasonMeta(
@SerialName("id") val id: Int,
@SerialName("tmdb_season_id") val tmdbSeasonId: Int,
@SerialName("tmdb_season_number") val tmdbSeasonNumber: Int,
@SerialName("cr_season_ids") val crSeasonIds: List<String>,
@SerialName("episodes") val episodes: List<EpisodeMeta>,
)
// class used in TVShowMeta, part of the tv show json object
@Serializable
data class EpisodeMeta(
@SerialName("id") val id: Int,
@SerialName("tmdb_episode_id") val tmdbEpisodeId: Int,
@SerialName("tmdb_episode_number") val tmdbEpisodeNumber: Int,
@SerialName("cr_episode_ids") val crEpisodeIds: List<String>,
@SerialName("opening_start") val openingStart: Long,
@SerialName("opening_duration") val openingDuration: Long,
@SerialName("ending_start") val endingStart: Long,
@SerialName("ending_duration") val endingDuration: Long
)

View File

@ -1,88 +0,0 @@
/**
* 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.util.metadb
import android.util.Log
import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
object MetaDBController {
private val TAG = javaClass.name
private const val repoUrl = "https://gitlab.com/Seil0/teapodmetadb/-/raw/main/crunchy/"
private val client = HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer(Json)
}
}
private var mediaList = MediaList(listOf())
private var metaCacheList = arrayListOf<Meta>()
suspend fun list() = withContext(Dispatchers.IO) {
val raw: String = client.get("$repoUrl/list.json")
mediaList = Json.decodeFromString(raw)
}
/**
* Get the meta data for a movie from MetaDB
* @param crSeriesId The crunchyroll media id
* @return A meta object, or null if not found
*/
suspend fun getTVShowMetadata(crSeriesId: String): TVShowMeta? {
return if (mediaList.media.contains(crSeriesId)) {
metaCacheList.firstOrNull {
it.crSeriesId == crSeriesId
} as TVShowMeta? ?: getTVShowMetadataFromDB(crSeriesId)
} else {
null
}
}
private suspend fun getTVShowMetadataFromDB(crSeriesId: String): TVShowMeta? = withContext(Dispatchers.IO) {
return@withContext try {
val raw: String = client.get("$repoUrl/tv/$crSeriesId/media.json")
val meta: TVShowMeta = Json.decodeFromString(raw)
metaCacheList.add(meta)
meta
} catch (ex: ClientRequestException) {
when (ex.response.status) {
HttpStatusCode.NotFound -> Log.w(TAG, "The requested file was not found. Series ID: $crSeriesId", ex)
else -> Log.e(TAG, "Error while requesting meta data. Series ID: $crSeriesId", ex)
}
null // todo return none object
}
}
}

View File

@ -22,19 +22,16 @@
package org.mosad.teapod.util.tmdb package org.mosad.teapod.util.tmdb
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.json.FuelJson
import io.ktor.client.features.json.serializer.* import com.github.kittinunf.fuel.json.responseJson
import io.ktor.client.request.* import com.github.kittinunf.result.Result
import io.ktor.client.statement.* import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.coroutines.coroutineScope import kotlinx.serialization.decodeFromString
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
/** /**
@ -44,41 +41,30 @@ 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 inline fun <reified T> request( private suspend fun request(
endpoint: String, endpoint: String,
parameters: List<Pair<String, Any?>> = emptyList() parameters: Parameters = emptyList()
): T = coroutineScope { ): Result<FuelJson, FuelError> = coroutineScope {
val path = "$apiUrl$endpoint" val path = "$apiUrl$endpoint"
val params = concatenate( val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters)
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 response: HttpResponse = client.get(path) { val (_, _, result) = Fuel.get(path, params)
params.forEach { .responseJson()
parameter(it.first, it.second)
}
}
response.receive<T>() result
} }
} }
@ -92,12 +78,10 @@ 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)
return try { val result = request(searchEndpoint, parameters)
request(searchEndpoint, parameters) return result.component1()?.obj()?.let {
}catch (ex: SerializationException) { json.decodeFromString(it.toString())
Log.e(classTag, "SerializationException in searchMovie(), with query = $query.", ex) } ?: NoneTMDBSearchMovie
NoneTMDBSearchMovie
}
} }
/** /**
@ -110,12 +94,10 @@ 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)
return try { val result = request(searchEndpoint, parameters)
request(searchEndpoint, parameters) return result.component1()?.obj()?.let {
}catch (ex: SerializationException) { json.decodeFromString(it.toString())
Log.e(classTag, "SerializationException in searchTVShow(), with query = $query.", ex) } ?: NoneTMDBSearchTVShow
NoneTMDBSearchTVShow
}
} }
/** /**
@ -127,12 +109,10 @@ class TMDBApiController {
val movieEndpoint = "/movie/$movieId" val movieEndpoint = "/movie/$movieId"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
return try { val result = request(movieEndpoint)
request(movieEndpoint) return result.component1()?.obj()?.let {
}catch (ex: SerializationException) { json.decodeFromString(it.toString())
Log.e(classTag, "SerializationException in getMovieDetails(), with movieId = $movieId.", ex) } ?: NoneTMDBMovie
NoneTMDBMovie
}
} }
/** /**
@ -144,12 +124,10 @@ class TMDBApiController {
val tvShowEndpoint = "/tv/$tvId" val tvShowEndpoint = "/tv/$tvId"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
return try { val result = request(tvShowEndpoint)
request(tvShowEndpoint) return result.component1()?.obj()?.let {
}catch (ex: SerializationException) { json.decodeFromString(it.toString())
Log.e(classTag, "SerializationException in getTVShowDetails(), with tvId = $tvId.", ex) } ?: NoneTMDBTVShow
NoneTMDBTVShow
}
} }
@Suppress("unused") @Suppress("unused")
@ -163,12 +141,10 @@ class TMDBApiController {
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber" val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
return try { val result = request(tvShowSeasonEndpoint)
request(tvShowSeasonEndpoint) return result.component1()?.obj()?.let {
}catch (ex: SerializationException) { json.decodeFromString(it.toString())
Log.e(classTag, "SerializationException in getTVSeasonDetails(), with tvId = $tvId, seasonNumber = $seasonNumber.", ex) } ?: NoneTMDBTVSeason
NoneTMDBTVSeason
}
} }
} }

View File

@ -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, "1970-01-01", null, "") val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "")
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "1970-01-01", "1970-01-01", "") val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "")
@Serializable @Serializable
data class TMDBTVSeason( data class TMDBTVSeason(

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/black"/>
<item android:gravity="center" android:width="144dp" android:height="144dp">
<bitmap
android:gravity="fill_horizontal|fill_vertical"
android:src="@drawable/ic_splash_logo"/>
</item>
</layer-list>

View File

@ -1,5 +0,0 @@
<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>

View File

@ -1,19 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.03158203"
android:scaleY="0.03158203"
android:translateX="37.83"
android:translateY="44.778053">
<path
android:pathData="m850.19,372.71c87.88,-11.01 119.04,-84.97 123.1,-99.87 4.06,-14.89 24.91,-80.57 11.92,-129.36 -12.99,-48.79 -34.36,-72.36 -58.62,-77.25 -24.25,-4.9 -50.59,10.51 -65,32.81 -14.41,22.3 -14.68,45.14 -14.78,55.29 -0.11,10.15 0.76,23.2 -3.37,33.29 -4.13,10.09 3.23,25.71 6.04,35.23 2.81,9.52 9.67,82.62 5.78,115.57 -3.89,32.95 -5.07,34.29 -5.07,34.29zM0.4,23.58C55.81,77.29 56.45,120.86 56.08,132.92c-0.36,12.06 4.77,130.59 11.47,150.76 4.42,13.3 12.11,50.16 41.78,74.48 25.51,20.91 58.65,31.38 58.65,31.38 0,0 36.42,78.46 78.83,108.64 31.56,22.46 39.61,23.74 46.5,35.55 6.18,10.6 93.56,62.62 275.1,47.23 127.29,-10.79 138.56,-44.3 138.56,-44.3 0,0 49.41,-21.9 101.15,-80.43 12.87,-14.56 4.41,-13.21 28.57,-17.79 24.16,-4.58 138.01,-45.58 170.66,-154.36C1039.99,175.32 1017.81,96.01 994.52,69.12 971.23,42.22 931.6,24.18 912.25,24.93c-18.47,0.71 -44.78,4.24 -80.21,46.87 -35.43,42.62 -28.94,37.4 -39.36,41.73 -6.82,2.83 -5.68,3.91 -26.75,-11.65 -20.23,-14.93 -28.9,-21.24 -43.38,-27.24 -7.96,-3.3 2.05,-5.55 2.59,-19.48 0.54,-13.93 2.4,-23.51 -17.32,-23.77 -19.72,-0.26 -408.02,0.21 -408.02,0.21 0,0 -18.8,-1.29 -7.79,24.82 4.2,9.94 -1.45,6.43 -33.27,25.85 -31.82,19.42 -55.58,34.4 -72.28,66.09 -8.43,16 -22.91,23.02 -27.97,8.05C153.44,141.43 125.2,48.96 105.17,23.22 85.56,-1.97 77.8,0.26 77.8,0.26Z"
android:strokeLineJoin="miter"
android:strokeWidth="0.41878"
android:fillColor="#000000"
android:strokeColor="#000000"
android:fillType="evenOdd"
android:strokeLineCap="butt"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -2,7 +2,7 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_root" android:id="@+id/player_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#000000" android:background="#000000"
@ -24,7 +24,7 @@
android:layout_height="70dp" android:layout_height="70dp"
android:layout_gravity="center" android:layout_gravity="center"
android:indeterminate="true" android:indeterminate="true"
app:indicatorColor="@color/player_white" app:indicatorColor="@color/exo_white"
tools:visibility="visible" /> tools:visibility="visible" />
<LinearLayout <LinearLayout
@ -77,14 +77,14 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:layout_marginBottom="72dp" android:layout_marginBottom="70dp"
android:gravity="center" android:gravity="center"
android:text="@string/next_episode" android:text="@string/next_episode"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@android:color/primary_text_light" android:textColor="@android:color/primary_text_light"
android:textSize="16sp" android:textSize="16sp"
android:visibility="gone" android:visibility="gone"
app:backgroundTint="@color/player_white" app:backgroundTint="@color/exo_white"
app:iconGravity="textStart" /> app:iconGravity="textStart" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
@ -93,14 +93,14 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:layout_marginBottom="72dp" android:layout_marginBottom="70dp"
android:gravity="center" android:gravity="center"
android:text="@string/skip_opening" android:text="@string/skip_opening"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@android:color/primary_text_light" android:textColor="@android:color/primary_text_light"
android:textSize="16sp" android:textSize="16sp"
android:visibility="gone" android:visibility="gone"
app:backgroundTint="@color/player_white" app:backgroundTint="@color/exo_white"
app:iconGravity="textStart" /> app:iconGravity="textStart" />
</FrameLayout> </FrameLayout>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/linLayout_login"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingEnd="24dp">
<EditText
android:id="@+id/edit_text_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="7dp"
android:ems="10"
android:hint="@string/login"
android:importantForAutofill="no"
android:inputType="textEmailAddress" />
<EditText
android:id="@+id/edit_text_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="7dp"
android:ems="10"
android:hint="@string/password"
android:importantForAutofill="no"
android:inputType="textPassword" />
</LinearLayout>

View File

@ -146,46 +146,6 @@
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"
@ -198,7 +158,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_prefer_subbed" android:contentDescription="@string/settings_secondary"
android:minWidth="48dp" android:minWidth="48dp"
android:minHeight="48dp" android:minHeight="48dp"
android:padding="9dp" android:padding="9dp"
@ -225,7 +185,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_prefer_subbed" android:text="@string/settings_secondary"
android:textSize="16sp" /> android:textSize="16sp" />
<TextView <TextView
@ -234,7 +194,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_prefer_subbed_desc" android:text="@string/settings_secondary_desc"
android:textColor="?textSecondary" /> android:textColor="?textSecondary" />
</LinearLayout> </LinearLayout>
@ -243,7 +203,6 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:checked="true" android:checked="true"
android:contentDescription="@string/settings_prefer_subbed"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
@ -305,7 +264,6 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:checked="true" android:checked="true"
android:contentDescription="@string/settings_autoplay"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
@ -380,69 +338,6 @@
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
<LinearLayout
android:id="@+id/linear_update_playhead"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="horizontal"
android:padding="7dp">
<ImageView
android:id="@+id/imageView5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/update_playhead"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="9dp"
android:scaleType="fitXY"
android:src="@drawable/ic_baseline_access_time_24"
app:tint="?iconColor" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/linearLayout4"
android:layout_width="0dp"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/switch_update_playhead"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/text_update_playhead"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/update_playhead"
android:textSize="16sp" />
<TextView
android:id="@+id/text_update_playhead_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/update_playhead_desc"
android:textColor="?textSecondary" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_update_playhead"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:contentDescription="@string/update_playhead"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/linear_export_data" android:id="@+id/linear_export_data"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -450,8 +345,7 @@
android:foreground="?android:selectableItemBackground" android:foreground="?android:selectableItemBackground"
android:gravity="center" android:gravity="center"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="7dp" android:padding="7dp">
android:visibility="gone">
<ImageView <ImageView
android:id="@+id/image_export_data" android:id="@+id/image_export_data"
@ -496,8 +390,7 @@
android:foreground="?android:selectableItemBackground" android:foreground="?android:selectableItemBackground"
android:gravity="center" android:gravity="center"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="7dp" android:padding="7dp">
android:visibility="gone">
<ImageView <ImageView
android:id="@+id/image_import_data" android:id="@+id/image_import_data"

View File

@ -115,7 +115,7 @@
android:paddingBottom="7dp"> android:paddingBottom="7dp">
<TextView <TextView
android:id="@+id/text_up_next" android:id="@+id/text_new_episodes"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="10dp" android:paddingStart="10dp"
@ -127,7 +127,7 @@
android:textStyle="bold" /> android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_up_next" android:id="@+id/recycler_new_episodes"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="horizontal" android:orientation="horizontal"
@ -163,34 +163,6 @@
tools:listitem="@layout/item_media" /> tools:listitem="@layout/item_media" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/linear_recommendations"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="7dp">
<TextView
android:id="@+id/text_recommendations"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="10dp"
android:paddingTop="15dp"
android:paddingEnd="5dp"
android:paddingBottom="5dp"
android:text="@string/recommendations"
android:textSize="16sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_recommendations"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_media" />
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/linear_new_titles" android:id="@+id/linear_new_titles"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linear_episodes"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:orientation="vertical">

View File

@ -10,22 +10,21 @@
android:paddingBottom="7dp"> android:paddingBottom="7dp">
<LinearLayout <LinearLayout
android:id="@+id/linear_episode"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:layout_width="128dp" android:layout_width="wrap_content"
android:layout_height="72dp"> android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_episode" android:id="@+id/image_episode"
android:layout_width="match_parent" android:layout_width="128dp"
android:layout_height="match_parent" android:layout_height="72dp"
android:contentDescription="@string/component_poster_desc" android:contentDescription="@string/component_poster_desc"
app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster"
app:srcCompat="@color/imagePlaceholder" /> app:srcCompat="@color/md_disabled_text_dark_theme" />
<ImageView <ImageView
android:id="@+id/image_episode_play" android:id="@+id/image_episode_play"
@ -36,15 +35,6 @@
android:contentDescription="@string/button_play" android:contentDescription="@string/button_play"
app:srcCompat="@drawable/ic_baseline_play_arrow_24" app:srcCompat="@drawable/ic_baseline_play_arrow_24"
app:tint="#FFFFFF" /> app:tint="#FFFFFF" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_playhead"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:max="100"
app:trackColor="#00FFFFFF"
app:trackThickness="2dp" />
</FrameLayout> </FrameLayout>
<TextView <TextView
@ -53,8 +43,6 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginStart="7dp" android:layout_marginStart="7dp"
android:layout_weight="1" android:layout_weight="1"
android:ellipsize="end"
android:maxLines="3"
android:text="@string/component_episode_title" android:text="@string/component_episode_title"
android:textColor="?textPrimary" android:textColor="?textPrimary"
android:textSize="16sp" /> android:textSize="16sp" />

View File

@ -7,16 +7,16 @@
android:padding="7dp"> android:padding="7dp">
<FrameLayout <FrameLayout
android:layout_width="192dp" android:layout_width="wrap_content"
android:layout_height="108dp"> android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_episode" android:id="@+id/image_episode"
android:layout_width="match_parent" android:layout_width="192dp"
android:layout_height="match_parent" android:layout_height="108dp"
android:contentDescription="@string/component_poster_desc" android:contentDescription="@string/component_poster_desc"
app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster"
app:srcCompat="@color/imagePlaceholder" /> app:srcCompat="@color/md_disabled_text_dark_theme" />
<ImageView <ImageView
android:id="@+id/image_episode_play" android:id="@+id/image_episode_play"
@ -26,16 +26,7 @@
android:background="@drawable/bg_circle__black_transparent_24dp" android:background="@drawable/bg_circle__black_transparent_24dp"
android:contentDescription="@string/button_play" android:contentDescription="@string/button_play"
app:srcCompat="@drawable/ic_baseline_play_arrow_24" app:srcCompat="@drawable/ic_baseline_play_arrow_24"
app:tint="@color/player_white" /> app:tint="#FFFFFF" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_playhead"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:max="100"
app:trackColor="#00FFFFFF"
app:trackThickness="2dp" />
</FrameLayout> </FrameLayout>
<TextView <TextView

View File

@ -13,43 +13,18 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<FrameLayout <ImageView
android:id="@+id/frame_image_progress" android:id="@+id/image_poster"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:contentDescription="@string/media_poster_desc"
android:scaleType="centerCrop"
app:layout_constraintBottom_toTopOf="@+id/text_title" app:layout_constraintBottom_toTopOf="@+id/text_title"
app:layout_constraintDimensionRatio="H,16:9" app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@color/md_disabled_text_dark_theme" />
<ImageView
android:id="@+id/image_poster"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/media_poster_desc"
android:scaleType="centerCrop"
tools:srcCompat="@color/imagePlaceholder" />
<ImageView
android:id="@+id/image_episode_play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_circle__black_transparent_24dp"
android:contentDescription="@string/button_play"
app:srcCompat="@drawable/ic_baseline_play_arrow_24"
app:tint="#FFFFFF" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_playhead"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:max="100"
app:trackColor="#00FFFFFF"
app:trackThickness="2dp" />
</FrameLayout>
<TextView <TextView
android:id="@+id/text_title" android:id="@+id/text_title"
@ -62,7 +37,7 @@
android:text="@string/text_title_ex" android:text="@string/text_title_ex"
android:textAlignment="center" android:textAlignment="center"
android:textSize="15sp" android:textSize="15sp"
app:layout_constraintTop_toBottomOf="@+id/frame_image_progress" /> app:layout_constraintTop_toBottomOf="@+id/image_poster" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

View File

@ -1,77 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/standard_bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?themeSecondary"
android:orientation="vertical"
android:paddingTop="24dp"
android:paddingStart="24dp"
android:paddingEnd="24dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="7dp"
android:text="@string/edit_login_credentials"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_supporting_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="5dp"
android:text="@string/edit_login_credentials_desc" />
<EditText
android:id="@+id/edit_text_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="7dp"
android:ems="10"
android:hint="@string/login"
android:importantForAutofill="no"
android:inputType="textEmailAddress"
android:minHeight="48dp" />
<EditText
android:id="@+id/edit_text_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="7dp"
android:ems="10"
android:hint="@string/password"
android:importantForAutofill="no"
android:inputType="textPassword"
android:minHeight="48dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal">
<Button
android:id="@+id/negative_button"
style="@android:style/Widget.Material.Button.Borderless.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
android:text="@string/cancel"
android:textColor="?colorPrimary" />
<Button
android:id="@+id/positive_button"
style="@android:style/Widget.Material.Button.Borderless.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
android:text="@string/save"
android:textColor="?colorPrimary" />
</LinearLayout>
</LinearLayout>

View File

@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_controls_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#73000000"> android:background="#73000000">
@ -19,12 +17,12 @@
<ImageButton <ImageButton
android:id="@+id/exo_close_player" android:id="@+id/exo_close_player"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:scaleType="fitXY"
android:layout_width="44dp"
android:layout_height="44dp"
android:contentDescription="@string/close_player" android:contentDescription="@string/close_player"
android:padding="10dp" android:padding="10dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_baseline_arrow_back_24" /> app:srcCompat="@drawable/ic_baseline_arrow_back_24" />
<TextView <TextView
@ -34,9 +32,8 @@
android:layout_marginEnd="44dp" android:layout_marginEnd="44dp"
android:text="@string/text_title_ex" android:text="@string/text_title_ex"
android:textAlignment="center" android:textAlignment="center"
android:textColor="@color/player_white" android:textColor="@color/exo_white"
android:textSize="16sp" android:textSize="16sp" />
tools:ignore="TextContrastCheck" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
@ -93,15 +90,13 @@
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:layout_marginBottom="@dimen/player_styled_progress_margin_bottom"> android:layout_marginBottom="@dimen/exo_styled_progress_margin_bottom">
<com.google.android.exoplayer2.ui.DefaultTimeBar <View
android:id="@id/exo_progress" android:id="@+id/exo_progress_placeholder"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="@dimen/player_styled_progress_layout_height" android:layout_height="@dimen/exo_styled_progress_layout_height"
android:contentDescription="@string/desc_time_bar" android:layout_marginBottom="2dp"
app:bar_height="3dp"
app:touch_target_height="@dimen/player_styled_progress_layout_height"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/exo_remaining" app:layout_constraintEnd_toStartOf="@+id/exo_remaining"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -110,10 +105,9 @@
<TextView <TextView
android:id="@+id/exo_remaining" android:id="@+id/exo_remaining"
style="@style/ExoStyledControls.TimeText.Position" style="@style/ExoStyledControls.TimeText.Position"
android:layout_height="wrap_content" android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout

View File

@ -22,12 +22,12 @@
<ImageButton <ImageButton
android:id="@+id/button_close_episodes_list" android:id="@+id/button_close_episodes_list"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:scaleType="fitXY"
android:layout_width="44dp"
android:layout_height="44dp"
android:contentDescription="@string/close_player" android:contentDescription="@string/close_player"
android:padding="10dp" android:padding="10dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_baseline_arrow_back_24" /> app:srcCompat="@drawable/ic_baseline_arrow_back_24" />
</LinearLayout> </LinearLayout>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#73000000" android:background="#73000000"
@ -23,12 +22,12 @@
<ImageButton <ImageButton
android:id="@+id/button_close_language_settings" android:id="@+id/button_close_language_settings"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:scaleType="fitXY"
android:layout_width="44dp"
android:layout_height="44dp"
android:contentDescription="@string/close_player" android:contentDescription="@string/close_player"
android:padding="10dp" android:padding="10dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_baseline_arrow_back_24" /> app:srcCompat="@drawable/ic_baseline_arrow_back_24" />
<TextView <TextView
@ -38,8 +37,8 @@
android:layout_marginEnd="44dp" android:layout_marginEnd="44dp"
android:text="@string/subtitles" android:text="@string/subtitles"
android:textAlignment="center" android:textAlignment="center"
android:textColor="@color/player_white" android:textColor="@color/exo_white"
android:textSize="18sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>
@ -76,7 +75,7 @@
android:layout_marginEnd="7dp" android:layout_marginEnd="7dp"
android:text="@string/cancel" android:text="@string/cancel"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/player_white" android:textColor="@color/exo_white"
android:textSize="16sp" android:textSize="16sp"
app:backgroundTint="@color/buttonBackgroundLight" app:backgroundTint="@color/buttonBackgroundLight"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
@ -94,8 +93,7 @@
app:backgroundTint="@color/buttonBackgroundDark" app:backgroundTint="@color/buttonBackgroundDark"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent" />
tools:ignore="TextContrastCheck" />
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_splash_background"/>
<foreground android:drawable="@drawable/ic_splash_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -9,7 +9,6 @@
<string name="highlight_media">Highlight</string> <string name="highlight_media">Highlight</string>
<string name="up_next">Weiterschauen</string> <string name="up_next">Weiterschauen</string>
<string name="my_list">Meine Liste</string> <string name="my_list">Meine Liste</string>
<string name="recommendations">Empfehlungen</string>
<string name="new_episodes">Neue Episoden</string> <string name="new_episodes">Neue Episoden</string>
<string name="new_simulcasts">Neue Simulcasts</string> <string name="new_simulcasts">Neue Simulcasts</string>
<string name="new_titles">Neue Titel</string> <string name="new_titles">Neue Titel</string>
@ -40,27 +39,19 @@
<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_content_language">Bevorzuge Inhaltssprache</string> <string name="settings_secondary">Bevorzuge Japanisch (OmU)</string>
<string name="settings_content_language_desc">Englisch</string> <string name="settings_secondary_desc">Japanisch verwenden, sofern vorhanden</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>
<string name="theme_light">Hell</string> <string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string> <string name="theme_dark">Dunkel</string>
<string name="dev_settings">Entwickler Einstellungen</string> <string name="dev_settings">Entwickler Einstellungen</string>
<string name="update_playhead">Playhead Updates</string>
<string name="update_playhead_desc">Fortschritt bei Episoden auf cr updaten</string>
<string name="export_data">Daten exportieren</string> <string name="export_data">Daten exportieren</string>
<string name="export_data_desc">Speichere "Meine Liste" in eine Datei</string> <string name="export_data_desc">Speichere "Meine Liste" in eine Datei</string>
<string name="import_data">Daten importieren</string> <string name="import_data">Daten importieren</string>
<string name="import_data_desc">Lade "Meine Liste" aus einer Datei</string> <string name="import_data_desc">Lade "Meine Liste" aus einer Datei</string>
<string name="import_data_success">"Meine Liste" erfolgreich importiert</string> <string name="import_data_success">"Meine Liste" erfolgreich importiert</string>
<string name="edit_login_credentials">Anmeldedaten bearbeiten</string>
<string name="edit_login_credentials_desc">Bearbeite deine Crunchyroll Anmeldedaten. Die Anmeldedaten weden verschüsselt aud deinem Gerät gespeichert.</string>
<string name="edit_login_credentials_fail">Benutzername oder Passwort ungültig. Bitte versuche es erneut.</string>
<!-- about fragment --> <!-- about fragment -->
<string name="version">Version</string> <string name="version">Version</string>
@ -85,7 +76,6 @@
<string name="episodes">Folgen</string> <string name="episodes">Folgen</string>
<string name="episode">Folge</string> <string name="episode">Folge</string>
<string name="no_subtitles">Aus</string> <string name="no_subtitles">Aus</string>
<string name="desc_time_bar">Zeitleiste</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="skip">Überspringen</string> <string name="skip">Überspringen</string>
@ -108,7 +98,7 @@
<!-- etc --> <!-- etc -->
<string name="login">Login</string> <string name="login">Login</string>
<string name="login_desc">Um Teapod zu benutzen musst du eingeloggt sein. Die Anmeldedaten weden verschüsselt aud deinem Gerät gespeichert.</string> <string name="login_desc">Um Teapod zu benutzen musst du eingeloggt sein. Die Login-Daten weden verschüsselt aud deinem Gerät gespeichert.</string>
<string name="login_failed_desc">Login nicht erfolgreich. Bitte versuche es erneut.</string> <string name="login_failed_desc">Login nicht erfolgreich. Bitte versuche es erneut.</string>
<string name="password">Passwort</string> <string name="password">Passwort</string>
</resources> </resources>

View File

@ -5,7 +5,6 @@
<color name="colorPrimaryLight">#99dc45</color> <color name="colorPrimaryLight">#99dc45</color>
<color name="colorPrimaryDark">#317a00</color> <color name="colorPrimaryDark">#317a00</color>
<color name="colorAccent">#607d8b</color> <color name="colorAccent">#607d8b</color>
<color name="imagePlaceholder">#c2c2c2</color>
<!-- light theme colors --> <!-- light theme colors -->
<color name="themePrimaryLight">#ffffff</color> <color name="themePrimaryLight">#ffffff</color>
@ -26,9 +25,5 @@
<color name="buttonBackgroundDark">#ffffff</color> <color name="buttonBackgroundDark">#ffffff</color>
<color name="controlHighlightDark">#11ffffff</color> <color name="controlHighlightDark">#11ffffff</color>
<!-- player colors -->
<color name="player_white">#ffffff</color>
<color name="ic_launcher_background">#ffffff</color> <color name="ic_launcher_background">#ffffff</color>
<color name="ic_splash_background">#ffffff</color>
</resources> </resources>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="player_styled_progress_layout_height">28dp</dimen>
<dimen name="player_styled_progress_margin_bottom">52dp</dimen>
</resources>

View File

@ -9,12 +9,10 @@
<string name="highlight_media">Highlight</string> <string name="highlight_media">Highlight</string>
<string name="up_next">Up next</string> <string name="up_next">Up next</string>
<string name="my_list">My list</string> <string name="my_list">My list</string>
<string name="recommendations">Recommendations</string>
<string name="new_episodes">New episodes</string> <string name="new_episodes">New episodes</string>
<string name="new_simulcasts">New simulcasts</string> <string name="new_simulcasts">New simulcasts</string>
<string name="new_titles">New titles</string> <string name="new_titles">New titles</string>
<string name="top_ten">Top 10</string> <string name="top_ten">Top 10</string>
<string name="season_episode_title" translatable="false">S%1$d E%2$d - %3$s</string>
<!-- search fragment --> <!-- search fragment -->
<string name="search_hint">Search for movies and series</string> <string name="search_hint">Search for movies and series</string>
@ -36,44 +34,36 @@
<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>
<!-- account fragment --> <!-- settings 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_content_language">Preferred content language</string> <string name="settings_secondary">Prefer japanese (sub)</string>
<string name="settings_content_language_desc">English</string> <string name="settings_secondary_desc">Use the japanese, if present</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>
<string name="theme_light">Light</string> <string name="theme_light">Light</string>
<string name="theme_dark">Dark</string> <string name="theme_dark">Dark</string>
<string name="dev_settings">Developer Settings</string> <string name="dev_settings">Developer Settings</string>
<string name="update_playhead">Playhead updates</string>
<string name="update_playhead_desc">Update episode playhead on cr</string>
<string name="export_data">export data</string> <string name="export_data">export data</string>
<string name="export_data_desc">export "My list" to a file</string> <string name="export_data_desc">export "My list" to a file</string>
<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>
<string name="edit_login_credentials">Edit credentials</string>
<string name="edit_login_credentials_desc">Edit your crunchyroll login credentials. The credentials will be stored encrypted on your device.</string>
<string name="edit_login_credentials_fail">Invalid login or password. Please try again.</string>
<!-- about fragment --> <!-- about fragment -->
<string name="version">Version</string> <string name="version">Version</string>
@ -107,7 +97,6 @@
<string name="episodes">Episodes</string> <string name="episodes">Episodes</string>
<string name="episode">Episode</string> <string name="episode">Episode</string>
<string name="no_subtitles">None</string> <string name="no_subtitles">None</string>
<string name="desc_time_bar">time bar</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="skip">Skip</string> <string name="skip">Skip</string>
@ -139,14 +128,10 @@
<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>
<!-- dev settings -->
<string name="save_key_update_playhead" translatable="false">org.mosad.teapod.update_playhead</string>
<!-- intents & states --> <!-- intents & states -->
<string name="intent_media_id" translatable="false">intent_media_id</string> <string name="intent_media_id" translatable="false">intent_media_id</string>

View File

@ -18,6 +18,11 @@
<item name="shapeTextBackground">@color/textBackgroundLight</item> <item name="shapeTextBackground">@color/textBackgroundLight</item>
<item name="iconColor">@color/iconColorLight</item> <item name="iconColor">@color/iconColorLight</item>
<item name="buttonBackground">@color/buttonBackgroundLight</item> <item name="buttonBackground">@color/buttonBackgroundLight</item>
<item name="md_background_color">@color/themeSecondaryLight</item>
<item name="md_color_content">@color/textSecondaryLight</item>
<!-- without this, the unchecked single choice buttons while be white -->
<item name="md_color_widget_unchecked">@color/textSecondaryLight</item>
</style> </style>
<style name="AppTheme.Dark" parent="AppTheme"> <style name="AppTheme.Dark" parent="AppTheme">
@ -31,27 +36,17 @@
<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_color_content">@color/textSecondaryDark</item>
<item name="materialAlertDialogTheme">@style/ThemeOverlay.App.MaterialAlertDialog.Dark</item> <!-- without this, the unchecked single choice buttons while be black -->
<item name="md_color_widget_unchecked">@color/textSecondaryDark</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="AppTheme"> <style name="PlayerTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item> <item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item> <item name="android:windowFullscreen">true</item>
@ -61,20 +56,10 @@
</style> </style>
<!-- splash theme --> <!-- splash theme -->
<style name="Theme.App.Starting" parent="Theme.SplashScreen"> <style name="SplashTheme" parent="Theme.AppCompat.NoActionBar">
<!-- Set the splash screen background, animated icon, and animation duration. --> <item name="android:windowBackground">@drawable/bg_splash</item>
<item name="windowSplashScreenBackground">@android:color/black</item>
<!-- Use windowSplashScreenAnimatedIcon to add either a drawable or an -->
<!-- animated drawable. One of these is required. -->
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_splash_round</item>
<item name="windowSplashScreenAnimationDuration">200</item>
<!-- Set the theme of the Activity that directly follows your splash screen. -->
<item name="postSplashScreenTheme">@style/AppTheme.Dark</item> # Required.
</style> </style>
<!-- shapes --> <!-- shapes -->
<style name="ShapeAppearance.Teapod.RoundedPoster" parent="ShapeAppearance.MaterialComponents.LargeComponent"> <style name="ShapeAppearance.Teapod.RoundedPoster" parent="ShapeAppearance.MaterialComponents.LargeComponent">
<item name="cornerFamily">rounded</item> <item name="cornerFamily">rounded</item>
@ -86,14 +71,4 @@
<item name="android:popupBackground">?themeSecondary</item> <item name="android:popupBackground">?themeSecondary</item>
</style> </style>
<!-- fullscreen dialog fragments -->
<style name="FullScreenDialogStyle" parent="AppTheme">
<item name="android:windowFullscreen">true</item>
<item name="android:windowIsFloating">false</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
</style>
</resources> </resources>

View File

@ -1,14 +1,12 @@
// 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.21" ext.kotlin_version = "1.6.0"
ext.ktor_version = "1.6.8"
ext.exo_version = "2.17.1"
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.2.1' classpath 'com.android.tools.build:gradle:7.0.3'
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

View File

@ -1,10 +0,0 @@
Dies ist der zweite beta Release von Teapod 1.0.0 mit Unterstützung für Cunchyroll.
* Unterstützung für Crunchyroll hinzugefügt (Ein premium Account wird benötigt)
* Crunchyroll metadb Unterstützung hinzugefügt (#54)
* Playhead Updates lassen sich nun ausschalten
* Ähnliche Titel zum Mediafragment hinzugefügt
* Empfehlungen für dich zum Homefragment hinzugefügt
* Einen Crash beim login wurde behoben
Alle Änderungen https://git.mosad.xyz/Seil0/teapod/compare/1.0.0-beta1...1.0.0-beta2

View File

@ -1,15 +1,11 @@
Teapod ist eine inoffizielle App für Crunchyroll. Teapod ist eine inoffizielle App für Anime-on-Demand (AoD).
* Schau dir alle Titel von Crunchyroll auf deinem Android Gerät an * Schau dir alle Titel von AoD 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
* Picture in Picture Modus * Speicher deine lieblings Anime in "Meine Liste"
* Überspringe das Intro/Ending dank der TeapodMetaDB Integration
Um Teapod zu verwenden musst du dich mit deinem Crunchyroll Account anmelden. Um Teapod zu verwenden musst du dich mit deinem AoD Account anmelden.
Dieses Projekt ist in keiner Weise mit Crunchyroll verbunden. Dieses Projekt ist in keiner Weise mit Anime-on-Demand verbunden.
TeapodMetaDB unterstützt ausschliesslich Serien, für die Metadaten vorliegen.
Hilf mit, die Datenbank auszubauen: https://gitlab.com/Seil0/teapodmetadb
Bitte melde Fehler und Probleme an support@mosad.xyz Bitte melde Fehler und Probleme an support@mosad.xyz

View File

@ -1 +1 @@
Android App für Crunchyroll Android App für AoD

View File

@ -1,10 +0,0 @@
This is the second beta release of Teapod 1.0.0 with support for crunchyroll.
* Support for crunchyroll (a premium account is needed)
* Crunchyroll metadb support (#54)
* Added a option to disable playhead updates/reporting
* Show similar titles in the media fragment
* Added recommendations to the home fragment
* Fixed a crash on login, which made the app unusable
Full changelog https://git.mosad.xyz/Seil0/teapod/compare/1.0.0-beta1...1.0.0-beta2

View File

@ -1,15 +1,11 @@
Teapod is a unofficial App for Crunchyroll. Teapod is a unofficial App for Anime-on-Demand (AoD).
* Watch all animes from Crunchyroll on your Android device * Watch all animes from AoD 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
* Picture in Picture Mode * Save your favorite animes to "My List"
* Skip the OP/ED thanks to the TeapodMetaDB integration
To use Teapod you have to login with your Crunchyroll account. To use Teapod you have to login with your AoD account.
This Project is not associated with Crunchyroll in any way. This Project is not associated with Anime-on-Demand in any way.
TeapodMetaDB supports only shows where metradata is present.
Help us to expand the database: https://gitlab.com/Seil0/teapodmetadb
Please report bugs and issues to support@mosad.xyz Please report bugs and issues to support@mosad.xyz

View File

@ -1 +1 @@
Android App for Crunchyroll Android App for AoD

Binary file not shown.

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

257
gradlew vendored
View File

@ -1,7 +1,7 @@
#!/bin/sh #!/usr/bin/env sh
# #
# Copyright © 2015-2021 the original authors. # Copyright 2015 the original author or authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -17,101 +17,67 @@
# #
############################################################################## ##############################################################################
# ##
# Gradle start up script for POSIX generated by Gradle. ## Gradle start up script for UN*X
# ##
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
############################################################################## ##############################################################################
# Attempt to set APP_HOME # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
app_path=$0 PRG="$0"
# Need this for relative symlinks.
# Need this for daisy-chained symlinks. while [ -h "$PRG" ] ; do
while ls=`ls -ld "$PRG"`
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path link=`expr "$ls" : '.*-> \(.*\)$'`
[ -h "$app_path" ] if expr "$link" : '/.*' > /dev/null; then
do PRG="$link"
ls=$( ls -ld "$app_path" ) else
link=${ls#*' -> '} PRG=`dirname "$PRG"`"/$link"
case $link in #( fi
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done done
SAVED="`pwd`"
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle" APP_NAME="Gradle"
APP_BASE_NAME=${0##*/} APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD="maximum"
warn () { warn () {
echo "$*" echo "$*"
} >&2 }
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} >&2 }
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "$( uname )" in #( case "`uname`" in
CYGWIN* ) cygwin=true ;; #( CYGWIN* )
Darwin* ) darwin=true ;; #( cygwin=true
MSYS* | MINGW* ) msys=true ;; #( ;;
NONSTOP* ) nonstop=true ;; Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@ -121,9 +87,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java JAVACMD="$JAVA_HOME/jre/sh/java"
else else
JAVACMD=$JAVA_HOME/bin/java JAVACMD="$JAVA_HOME/bin/java"
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@ -132,7 +98,7 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi fi
else else
JAVACMD=java JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
@ -140,95 +106,80 @@ location of your Java installation."
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
case $MAX_FD in #( MAX_FD_LIMIT=`ulimit -H -n`
max*) if [ $? -eq 0 ] ; then
MAX_FD=$( ulimit -H -n ) || if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
warn "Could not query maximum file descriptor limit" MAX_FD="$MAX_FD_LIMIT"
esac fi
case $MAX_FD in #( ulimit -n $MAX_FD
'' | soft) :;; #( if [ $? -ne 0 ] ; then
*) warn "Could not set maximum file descriptor limit: $MAX_FD"
ulimit -n "$MAX_FD" || fi
warn "Could not set maximum file descriptor limit to $MAX_FD" else
esac warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi fi
# Collect all arguments for the java command, stacking in reverse order: # For Darwin, add options to specify how the application appears in the dock
# * args from the command line if $darwin; then
# * the main class name GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
# * -classpath fi
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=`cygpath --unix "$JAVACMD"`
# Now convert the arguments - kludge to limit ourselves to /bin/sh # We build the pattern for arguments to be converted via cygpath
for arg do ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
if SEP=""
case $arg in #( for dir in $ROOTDIRSRAW ; do
-*) false ;; # don't mess with options #( ROOTDIRS="$ROOTDIRS$SEP$dir"
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath SEP="|"
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi fi
# Collect all arguments for the java command; # Escape application args
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of save () {
# shell script including quotes and variable substitutions, so put them in for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
# double quotes to make sure that they get re-expanded; and echo " "
# * put everything else in single quotes, so that it's not re-expanded. }
APP_ARGS=`save "$@"`
set -- \ # Collect all arguments for the java command, following the shell quoting and substitution rules
"-Dorg.gradle.appname=$APP_BASE_NAME" \ eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"