Compare commits
58 Commits
9bf0ae2f63
...
1.0.0-beta
Author | SHA1 | Date | |
---|---|---|---|
ad1e3068cd
|
|||
de1f19c2b7
|
|||
12bbc2ef5f
|
|||
0186cef79e
|
|||
bc5509cf93
|
|||
ef9a0f00d0
|
|||
b85d7ae025
|
|||
69c9666d2b
|
|||
7d6c300f7e
|
|||
1ebc1194e6
|
|||
c48328723b
|
|||
95c8a72c94
|
|||
fc04e8e222
|
|||
a898a70653
|
|||
58aab72097
|
|||
35157b78f5
|
|||
c6a00ea061
|
|||
80a7fc4398
|
|||
dd6ca8b90e
|
|||
e80e81af0f
|
|||
f852600dc7
|
|||
aa49169034
|
|||
7abb5cd3e8
|
|||
3a71bdd2c7
|
|||
629c144c5b
|
|||
b2196f11da
|
|||
5b5a74a1de
|
|||
7a860a7270
|
|||
e97ad9a245
|
|||
cf435fdb72
|
|||
42895a6fba
|
|||
eaf1cf78e9
|
|||
1af82f8370
|
|||
d31a19a4f1
|
|||
b27666ee69 | |||
e76cbda04d
|
|||
7fbf639a70
|
|||
ff63b3d7a4
|
|||
7d32cecd89
|
|||
72280f29d8
|
|||
cd4cfb7a0c
|
|||
4a5a6c04ca
|
|||
554c66e11f
|
|||
0aece1d8fa | |||
f820d2aac0 | |||
0ea2e5ee97
|
|||
a092c5b8be
|
|||
ab660d0ae7
|
|||
be1c001942
|
|||
30a5331bbc
|
|||
0797e9fa3d
|
|||
75204e522d
|
|||
2016e03e56
|
|||
4505f95309
|
|||
e8bf63a666
|
|||
a51001ec2e
|
|||
0b5a8e69fb
|
|||
61c96f5ce2
|
@ -1,14 +1,13 @@
|
|||||||
# Teapod
|
# Teapod
|
||||||
|
|
||||||
Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all your favourite animes from AoD on your android device. To use Teapod you need to have a subscription to AoD.
|
Teapod is a unofficial App for Crunchyroll. It allows you to watch all your favourite animes from Crunchyroll on your android device. To use Teapod you need to have a account at Crunchyroll.
|
||||||
|
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">](https://f-droid.org/de/packages/org.mosad.teapod/)
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">](https://f-droid.org/de/packages/org.mosad.teapod/)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
* Watch all animes from AoD on your Android device
|
* Watch all animes from Crunchyroll on your Android device
|
||||||
* Native Player based on ExoPayer
|
* Native Player based on ExoPayer
|
||||||
* Prefer the OmU version via the app settings
|
* Prefer the OmU version via the app settings
|
||||||
* Save your favorite animes to "My List"
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Home.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Home.webp)
|
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Home.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Home.webp)
|
||||||
@ -17,10 +16,10 @@ Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all
|
|||||||
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp)
|
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp)
|
||||||
|
|
||||||
### License
|
### License
|
||||||
Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Anime on Demand in any way. But they allow open source apps for their service.
|
Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Crunchyroll in any way.
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
Currently you need to have an AoD account to contribute to Teapod. Contributing without on is kind of impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write me an email.
|
Currently you need to have an Crunchyroll account to contribute to Teapod. Contributing without one is impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write an email.
|
||||||
|
|
||||||
### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe)
|
### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe)
|
||||||
|
|
||||||
|
@ -1,20 +1,19 @@
|
|||||||
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 30
|
compileSdkVersion 31
|
||||||
buildToolsVersion "30.0.3"
|
buildToolsVersion "30.0.3"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "org.mosad.teapod"
|
applicationId "org.mosad.teapod"
|
||||||
minSdkVersion 23
|
minSdkVersion 23
|
||||||
targetSdkVersion 30
|
targetSdkVersion 31
|
||||||
versionCode 4200 //00.04.200
|
versionCode 9010 //00.09.010
|
||||||
versionName "1.0.0-alpha3"
|
versionName "1.0.0-beta2"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
resValue "string", "build_time", buildTime()
|
resValue "string", "build_time", buildTime()
|
||||||
@ -39,42 +38,43 @@ 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.5.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1'
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3'
|
||||||
|
|
||||||
implementation 'androidx.core:core-ktx:1.6.0'
|
implementation 'androidx.core:core-ktx:1.7.0'
|
||||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
implementation 'androidx.core:core-splashscreen:1.0.0-rc01'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
|
implementation 'androidx.appcompat:appcompat:1.4.1'
|
||||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||||
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
|
implementation 'androidx.navigation:navigation-fragment-ktx:2.4.2'
|
||||||
|
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.3.1'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
|
||||||
|
|
||||||
implementation 'com.google.android.material:material:1.4.0'
|
implementation 'com.google.android.material:material:1.5.0'
|
||||||
implementation 'com.google.code.gson:gson:2.8.8'
|
implementation "com.google.android.exoplayer:exoplayer-core:$exo_version"
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
|
implementation "com.google.android.exoplayer:exoplayer-hls:$exo_version"
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0'
|
implementation "com.google.android.exoplayer:exoplayer-dash:$exo_version"
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0'
|
implementation "com.google.android.exoplayer:exoplayer-ui:$exo_version"
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
|
implementation "com.google.android.exoplayer:extension-mediasession:$exo_version"
|
||||||
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
|
|
||||||
|
|
||||||
implementation 'org.jsoup:jsoup:1.14.2'
|
implementation 'com.github.bumptech.glide:glide:4.13.1'
|
||||||
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 'com.github.kittinunf.fuel:fuel:2.3.1'
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||||
implementation 'com.github.kittinunf.fuel:fuel-android:2.3.1'
|
implementation "io.ktor:ktor-client-android:$ktor_version"
|
||||||
implementation 'com.github.kittinunf.fuel:fuel-json:2.3.1'
|
implementation "io.ktor:ktor-client-serialization:$ktor_version"
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
|
4
app/proguard-rules.pro
vendored
@ -24,10 +24,6 @@
|
|||||||
|
|
||||||
-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.
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<?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" />
|
||||||
|
|
||||||
@ -13,32 +12,27 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme.Dark">
|
android:theme="@style/AppTheme.Dark">
|
||||||
<activity
|
<activity
|
||||||
android:name="org.mosad.teapod.ui.activity.SplashActivity"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:name="org.mosad.teapod.ui.activity.main.MainActivity"
|
||||||
android:theme="@style/SplashTheme"
|
android:screenOrientation="portrait"
|
||||||
android:screenOrientation="portrait">
|
android:theme="@style/Theme.App.Starting">
|
||||||
<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:name="org.mosad.teapod.ui.activity.main.MainActivity"
|
android:exported="false"
|
||||||
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"
|
||||||
|
@ -1,31 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Teapod
|
||||||
|
*
|
||||||
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
||||||
|
* MA 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
package org.mosad.teapod.parser.crunchyroll
|
package org.mosad.teapod.parser.crunchyroll
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import io.ktor.client.*
|
||||||
import com.github.kittinunf.fuel.core.FuelError
|
import io.ktor.client.call.*
|
||||||
import com.github.kittinunf.fuel.core.Parameters
|
import io.ktor.client.features.*
|
||||||
import com.github.kittinunf.fuel.core.extensions.jsonBody
|
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.request.forms.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.mosad.teapod.preferences.EncryptedPreferences
|
import org.mosad.teapod.preferences.EncryptedPreferences
|
||||||
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 var accessToken = ""
|
private lateinit var token: Token
|
||||||
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 = ""
|
||||||
|
|
||||||
@ -33,11 +66,20 @@ object Crunchyroll {
|
|||||||
private var signature = ""
|
private var signature = ""
|
||||||
private var keyPairID = ""
|
private var keyPairID = ""
|
||||||
|
|
||||||
// TODO temp helper vary
|
private val browsingCache = hashMapOf<String, BrowseResult>()
|
||||||
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.
|
||||||
@ -49,39 +91,36 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
fun login(username: String, password: String): Boolean = runBlocking {
|
fun login(username: String, password: String): Boolean = runBlocking {
|
||||||
val tokenEndpoint = "/auth/v1/token"
|
val tokenEndpoint = "/auth/v1/token"
|
||||||
val formData = listOf(
|
val formData = Parameters.build {
|
||||||
"username" to username,
|
append("username", username)
|
||||||
"password" to password,
|
append("password", password)
|
||||||
"grant_type" to "password",
|
append("grant_type", "password")
|
||||||
"scope" to "offline_access"
|
append("scope", "offline_access")
|
||||||
)
|
|
||||||
|
|
||||||
var success: Boolean // is false
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData)
|
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
.appendHeader(
|
|
||||||
"Authorization",
|
|
||||||
"Basic "
|
|
||||||
)
|
|
||||||
.responseJson()
|
|
||||||
|
|
||||||
// TODO fix JSONException: No value for
|
|
||||||
result.component1()?.obj()?.let {
|
|
||||||
accessToken = it.get("access_token").toString()
|
|
||||||
tokenType = it.get("token_type").toString()
|
|
||||||
|
|
||||||
// token will be invalid 1 sec
|
|
||||||
val expiresIn = (it.get("expires_in").toString().toLong() - 1)
|
|
||||||
tokenValidUntil = System.currentTimeMillis() + (expiresIn * 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// println("request: $request")
|
var success = false// is false
|
||||||
// println("response: $response")
|
withContext(Dispatchers.IO) {
|
||||||
// println("response: $result")
|
Log.i(TAG, "getting token ...")
|
||||||
|
|
||||||
Log.i(javaClass.name, "login complete with code ${response.statusCode}")
|
val status = try {
|
||||||
success = (response.statusCode == 200)
|
val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) {
|
||||||
|
header("Authorization", "Basic $basicApiToken")
|
||||||
|
}
|
||||||
|
token = response.receive()
|
||||||
|
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000)
|
||||||
|
response.status
|
||||||
|
} catch (ex: ClientRequestException) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Login complete with code $status")
|
||||||
|
success = (status == HttpStatusCode.OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
return@runBlocking success
|
return@runBlocking success
|
||||||
@ -95,56 +134,76 @@ object Crunchyroll {
|
|||||||
* Requests: get, post, delete
|
* Requests: get, post, delete
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private suspend fun request(
|
private suspend inline fun <reified T> request(
|
||||||
endpoint: String,
|
url: String,
|
||||||
params: Parameters = listOf(),
|
httpMethod: HttpMethod,
|
||||||
url: String = ""
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
): Result<FuelJson, FuelError> = coroutineScope {
|
bodyObject: Any = Any()
|
||||||
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
): T = coroutineScope {
|
||||||
|
withContext(tokenRefreshContext) {
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
||||||
|
}
|
||||||
|
|
||||||
return@coroutineScope (Dispatchers.IO) {
|
return@coroutineScope (Dispatchers.IO) {
|
||||||
val (request, response, result) = Fuel.get(path, params)
|
val response: T = client.request(url) {
|
||||||
.header("Authorization", "$tokenType $accessToken")
|
method = httpMethod
|
||||||
.responseJson()
|
header("Authorization", "${token.tokenType} ${token.accessToken}")
|
||||||
|
params.forEach {
|
||||||
// println("request request: $request")
|
parameter(it.first, it.second)
|
||||||
// println("request response: $response")
|
|
||||||
// println("request result: $result")
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// for json set body and content type
|
||||||
|
if (bodyObject is JsonObject) {
|
||||||
|
body = bodyObject
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> requestGet(
|
||||||
|
endpoint: String,
|
||||||
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
|
url: String = ""
|
||||||
|
): T {
|
||||||
|
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
||||||
|
|
||||||
|
return request(path, HttpMethod.Get, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestPost(
|
private suspend fun requestPost(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
body: String
|
bodyObject: JsonObject
|
||||||
) = coroutineScope {
|
) {
|
||||||
val path = "$baseUrl$endpoint"
|
val path = "$baseUrl$endpoint"
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject)
|
||||||
Fuel.post(path, params)
|
Log.i(TAG, "Response: $response")
|
||||||
.header("Authorization", "$tokenType $accessToken")
|
|
||||||
.jsonBody(body)
|
|
||||||
.response() // without a response, crunchy doesn't accept the request
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun requestPatch(
|
||||||
|
endpoint: String,
|
||||||
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
|
bodyObject: JsonObject
|
||||||
|
) {
|
||||||
|
val path = "$baseUrl$endpoint"
|
||||||
|
|
||||||
|
val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject)
|
||||||
|
Log.i(TAG, "Response: $response")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestDelete(
|
private suspend fun requestDelete(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
url: String = ""
|
url: String = ""
|
||||||
) = coroutineScope {
|
) = coroutineScope {
|
||||||
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val response: HttpResponse = request(path, HttpMethod.Delete, params)
|
||||||
Fuel.delete(path, params)
|
Log.i(TAG, "Response: $response")
|
||||||
.header("Authorization", "$tokenType $accessToken")
|
|
||||||
.response() // without a response, crunchy doesn't accept the request
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -158,17 +217,15 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun index() {
|
suspend fun index() {
|
||||||
val indexEndpoint = "/index/v2"
|
val indexEndpoint = "/index/v2"
|
||||||
val result = request(indexEndpoint)
|
|
||||||
|
|
||||||
result.component1()?.obj()?.getJSONObject("cms")?.let {
|
val index: Index = requestGet(indexEndpoint)
|
||||||
policy = it.get("policy").toString()
|
policy = index.cms.policy
|
||||||
signature = it.get("signature").toString()
|
signature = index.cms.signature
|
||||||
keyPairID = it.get("key_pair_id").toString()
|
keyPairID = index.cms.keyPairId
|
||||||
}
|
|
||||||
|
|
||||||
println("policy: $policy")
|
Log.i(TAG, "Policy : $policy")
|
||||||
println("signature: $signature")
|
Log.i(TAG, "Signature : $signature")
|
||||||
println("keyPairID: $keyPairID")
|
Log.i(TAG, "Key Pair ID : $keyPairID")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -179,18 +236,21 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun account() {
|
suspend fun account() {
|
||||||
val indexEndpoint = "/accounts/v1/me"
|
val indexEndpoint = "/accounts/v1/me"
|
||||||
val result = request(indexEndpoint)
|
|
||||||
|
|
||||||
result.component1()?.obj()?.let {
|
val account: Account = try {
|
||||||
accountID = it.get("account_id").toString()
|
requestGet(indexEndpoint)
|
||||||
|
} catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in account(). This is bad!", ex)
|
||||||
|
NoneAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountID = account.accountId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General element/media functions: browse, search, objects, season_list
|
* General element/media functions: browse, search, objects, season_list
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// TODO locale de-DE, categories
|
|
||||||
/**
|
/**
|
||||||
* Browse the media available on crunchyroll.
|
* Browse the media available on crunchyroll.
|
||||||
*
|
*
|
||||||
@ -200,47 +260,81 @@ 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 noneOptParams = listOf("sort_by" to sortBy.str, "start" to start, "n" to n)
|
val parameters = mutableListOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"sort_by" to sortBy.str,
|
||||||
|
"start" to start,
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
// if a season tag is present add it to the parameters
|
// if a season tag is present add it to the parameters
|
||||||
val parameters = if (seasonTag.isNotEmpty()) {
|
if (seasonTag.isNotEmpty()) {
|
||||||
concatenate(noneOptParams, listOf("season_tag" to seasonTag))
|
parameters.add("season_tag" to seasonTag)
|
||||||
} else {
|
|
||||||
noneOptParams
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = request(browseEndpoint, parameters)
|
// if a season tag is present add it to the parameters
|
||||||
val browseResult = result.component1()?.obj()?.let {
|
if (categories.isNotEmpty()) {
|
||||||
json.decodeFromString(it.toString())
|
parameters.add("categories" to categories.joinToString(",") { it.str })
|
||||||
} ?: NoneBrowseResult
|
}
|
||||||
|
|
||||||
// add results to cache TODO improve
|
// fetch result if not already cached
|
||||||
|
if (browsingCache.contains(parameters.toString())) {
|
||||||
|
Log.d(TAG, "browse result cached: $parameters")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "browse result not cached, fetching: $parameters")
|
||||||
|
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
|
||||||
|
// Note: this value is totally guessed and should be replaced by a properly researched value
|
||||||
|
if (browsingCache.size > 100) {
|
||||||
browsingCache.clear()
|
browsingCache.clear()
|
||||||
browsingCache.addAll(browseResult.items)
|
}
|
||||||
|
|
||||||
return browseResult
|
// add results to cache
|
||||||
|
browsingCache[parameters.toString()] = browseResult
|
||||||
|
}
|
||||||
|
|
||||||
|
return browsingCache[parameters.toString()] ?: NoneBrowseResult
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO
|
* Search fo a query term.
|
||||||
|
* 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("q" to query, "n" to n, "locale" to locale, "type" to "series")
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"q" to query,
|
||||||
|
"n" to n,
|
||||||
|
"type" to "series"
|
||||||
|
)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
|
||||||
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
|
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
|
||||||
// to work around this, for now only tv shows are supported
|
// to work around this, for now only tv shows are supported
|
||||||
|
|
||||||
return result.component1()?.obj()?.let {
|
return try {
|
||||||
json.decodeFromString(it.toString())
|
requestGet(searchEndpoint, parameters)
|
||||||
} ?: NoneSearchResult
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex)
|
||||||
|
NoneSearchResult
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -253,17 +347,18 @@ object Crunchyroll {
|
|||||||
suspend fun objects(objects: List<String>): Collection<Item> {
|
suspend fun objects(objects: List<String>): Collection<Item> {
|
||||||
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
|
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(episodesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(episodesEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in objects().", ex)
|
||||||
} ?: NoneCollection
|
NoneCollection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -272,13 +367,14 @@ object Crunchyroll {
|
|||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
suspend fun seasonList(): DiscSeasonList {
|
suspend fun seasonList(): DiscSeasonList {
|
||||||
val seasonListEndpoint = "/content/v1/season_list"
|
val seasonListEndpoint = "/content/v1/season_list"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val result = request(seasonListEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(seasonListEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in seasonList().", ex)
|
||||||
} ?: NoneDiscSeasonList
|
NoneDiscSeasonList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -289,82 +385,108 @@ object Crunchyroll {
|
|||||||
* series id == crunchyroll id?
|
* series id == crunchyroll id?
|
||||||
*/
|
*/
|
||||||
suspend fun series(seriesId: String): Series {
|
suspend fun series(seriesId: String): Series {
|
||||||
val seriesEndpoint = "/cms/v2/$country/M3/crunchyroll/series/$seriesId"
|
val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(seriesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(seriesEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in series().", ex)
|
||||||
} ?: NoneSeries
|
NoneSeries
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO
|
* Get the next episode for a series.
|
||||||
|
*
|
||||||
|
* @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 locale
|
"locale" to Preferences.preferredLocale.toLanguageTag()
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = request(upNextSeriesEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(upNextSeriesEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
}catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in upNextSeries().", ex)
|
||||||
} ?: NoneUpNextSeriesItem
|
NoneUpNextSeriesItem
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun seasons(seriesId: String): Seasons {
|
|
||||||
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
|
|
||||||
val parameters = listOf(
|
|
||||||
"series_id" to seriesId,
|
|
||||||
"locale" to locale,
|
|
||||||
"Signature" to signature,
|
|
||||||
"Policy" to policy,
|
|
||||||
"Key-Pair-Id" to keyPairID
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = request(episodesEndpoint, parameters)
|
|
||||||
|
|
||||||
return result.component1()?.obj()?.let {
|
|
||||||
json.decodeFromString(it.toString())
|
|
||||||
} ?: NoneSeasons
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun episodes(seasonId: String): Episodes {
|
|
||||||
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/episodes"
|
|
||||||
val parameters = listOf(
|
|
||||||
"season_id" to seasonId,
|
|
||||||
"locale" to locale,
|
|
||||||
"Signature" to signature,
|
|
||||||
"Policy" to policy,
|
|
||||||
"Key-Pair-Id" to keyPairID
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = request(episodesEndpoint, parameters)
|
|
||||||
|
|
||||||
return result.component1()?.obj()?.let {
|
|
||||||
json.decodeFromString(it.toString())
|
|
||||||
} ?: NoneEpisodes
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun playback(url: String): Playback {
|
|
||||||
val result = request("", url = url)
|
|
||||||
|
|
||||||
return result.component1()?.obj()?.let {
|
|
||||||
json.decodeFromString(it.toString())
|
|
||||||
} ?: NonePlayback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Additional media functions: watchlist (series), playhead
|
* 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 {
|
||||||
|
val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons"
|
||||||
|
val parameters = listOf(
|
||||||
|
"series_id" to seriesId,
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"Signature" to signature,
|
||||||
|
"Policy" to policy,
|
||||||
|
"Key-Pair-Id" to keyPairID
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
requestGet(seasonsEndpoint, parameters)
|
||||||
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in seasons().", ex)
|
||||||
|
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 {
|
||||||
|
val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes"
|
||||||
|
val parameters = listOf(
|
||||||
|
"season_id" to seasonId,
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"Signature" to signature,
|
||||||
|
"Policy" to policy,
|
||||||
|
"Key-Pair-Id" to keyPairID
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
requestGet(episodesEndpoint, parameters)
|
||||||
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in episodes().", ex)
|
||||||
|
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 {
|
||||||
|
return try {
|
||||||
|
requestGet("", url = url)
|
||||||
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in playback(), with url = $url.", ex)
|
||||||
|
NonePlayback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional media functions: watchlist (series), playhead, similar to
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -375,12 +497,15 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun isWatchlist(seriesId: String): Boolean {
|
suspend fun isWatchlist(seriesId: String): Boolean {
|
||||||
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val result = request(watchlistSeriesEndpoint, parameters)
|
return try {
|
||||||
// if needed implement parsing
|
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
|
||||||
|
.containsKey(seriesId)
|
||||||
return result.component1()?.obj()?.has(seriesId) ?: false
|
}catch (ex: SerializationException) {
|
||||||
|
Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex)
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -390,13 +515,13 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun postWatchlist(seriesId: String) {
|
suspend fun postWatchlist(seriesId: String) {
|
||||||
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
|
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val json = buildJsonObject {
|
val json = buildJsonObject {
|
||||||
put("content_id", seriesId)
|
put("content_id", seriesId)
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPost(watchlistPostEndpoint, parameters, json.toString())
|
requestPost(watchlistPostEndpoint, parameters, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -406,7 +531,7 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun deleteWatchlist(seriesId: String) {
|
suspend fun deleteWatchlist(seriesId: String) {
|
||||||
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
requestDelete(watchlistDeleteEndpoint, parameters)
|
requestDelete(watchlistDeleteEndpoint, parameters)
|
||||||
}
|
}
|
||||||
@ -421,25 +546,62 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
|
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
|
||||||
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
|
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val result = request(playheadsEndpoint, parameters)
|
return try {
|
||||||
|
requestGet(playheadsEndpoint, parameters)
|
||||||
return result.component1()?.obj()?.let {
|
} catch (ex: SerializationException) {
|
||||||
json.decodeFromString(it.toString())
|
Log.e(TAG, "SerializationException in playheads().", ex)
|
||||||
} ?: 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 locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val json = buildJsonObject {
|
val json = buildJsonObject {
|
||||||
put("content_id", episodeId)
|
put("content_id", episodeId)
|
||||||
put("playhead", playhead)
|
put("playhead", playhead)
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPost(playheadsEndpoint, parameters, json.toString())
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -454,12 +616,17 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun watchlist(n: Int = 20): Watchlist {
|
suspend fun watchlist(n: Int = 20): Watchlist {
|
||||||
val watchlistEndpoint = "/content/v1/$accountID/watchlist"
|
val watchlistEndpoint = "/content/v1/$accountID/watchlist"
|
||||||
val parameters = listOf("locale" to locale, "n" to n)
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
val watchlistResult = request(watchlistEndpoint, parameters)
|
val list: ContinueWatchingList = try {
|
||||||
val list: ContinueWatchingList = watchlistResult.component1()?.obj()?.let {
|
requestGet(watchlistEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneContinueWatchingList
|
Log.e(TAG, "SerializationException in watchlist().", ex)
|
||||||
|
NoneContinueWatchingList
|
||||||
|
}
|
||||||
|
|
||||||
val objects = list.items.map{ it.panel.episodeMetadata.seriesId }
|
val objects = list.items.map{ it.panel.episodeMetadata.seriesId }
|
||||||
return objects(objects)
|
return objects(objects)
|
||||||
@ -473,12 +640,68 @@ object Crunchyroll {
|
|||||||
*/
|
*/
|
||||||
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
|
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
|
||||||
val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
|
val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
|
||||||
val parameters = listOf("locale" to locale, "n" to n)
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
val resultUpNextAccount = request(watchlistEndpoint, parameters)
|
return try {
|
||||||
return resultUpNextAccount.component1()?.obj()?.let {
|
requestGet(watchlistEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneContinueWatchingList
|
Log.e(TAG, "SerializationException in upNextAccount().", ex)
|
||||||
|
NoneContinueWatchingList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Teapod
|
||||||
|
*
|
||||||
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
||||||
|
* MA 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
package org.mosad.teapod.parser.crunchyroll
|
package org.mosad.teapod.parser.crunchyroll
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
val supportedLocals = listOf(
|
||||||
|
Locale.forLanguageTag("ar-SA"),
|
||||||
|
Locale.forLanguageTag("de-DE"),
|
||||||
|
Locale.forLanguageTag("en-US"),
|
||||||
|
Locale.forLanguageTag("es-419"),
|
||||||
|
Locale.forLanguageTag("es-ES"),
|
||||||
|
Locale.forLanguageTag("fr-FR"),
|
||||||
|
Locale.forLanguageTag("it-IT"),
|
||||||
|
Locale.forLanguageTag("pt-BR"),
|
||||||
|
Locale.forLanguageTag("pt-PT"),
|
||||||
|
Locale.forLanguageTag("ru-RU"),
|
||||||
|
Locale.ROOT
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* data classes for browse
|
* data classes for browse
|
||||||
* TODO make class names more clear/possibly overlapping for now
|
* TODO make class names more clear/possibly overlapping for now
|
||||||
@ -14,6 +50,63 @@ 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
|
||||||
*/
|
*/
|
||||||
@ -27,23 +120,25 @@ 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(
|
||||||
val playhead: Int,
|
@SerialName("playhead") val playhead: Int,
|
||||||
val fully_watched: Boolean,
|
@SerialName("fully_watched") val fullyWatched: Boolean,
|
||||||
val never_watched: Boolean,
|
@SerialName("never_watched") val neverWatched: Boolean,
|
||||||
val panel: EpisodePanel,
|
@SerialName("panel") val panel: EpisodePanel,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* panel data classes
|
* panel data classes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// the data class Item is used in browse and search
|
// the data class Item is used in browse, search, watchlist and similar to
|
||||||
// TODO rename to MediaPanel
|
// TODO rename to MediaPanel
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Item(
|
data class Item(
|
||||||
@ -54,6 +149,7 @@ 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
|
||||||
@ -92,10 +188,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,
|
@SerialName("fully_watched") val fullyWatched: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
// EpisodePanel is used in ContinueWatchingItem
|
// EpisodePanel is used in ContinueWatchingItem and UpNextSeriesItem
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EpisodePanel(
|
data class EpisodePanel(
|
||||||
@SerialName("id") val id: String,
|
@SerialName("id") val id: String,
|
||||||
@ -111,25 +207,35 @@ 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, "", "", "")
|
val NoneEpisodeMetadata = EpisodeMetadata(0, 0, "", 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(0, false, false, NoneEpisodePanel)
|
val NoneUpNextSeriesItem = UpNextSeriesItem(
|
||||||
|
playhead = 0,
|
||||||
|
fullyWatched = false,
|
||||||
|
neverWatched = false,
|
||||||
|
panel = NoneEpisodePanel
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Series data type
|
* series data class
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Series(
|
data class Series(
|
||||||
@ -142,7 +248,7 @@ data class Series(
|
|||||||
val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList())
|
val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seasons data type
|
* Seasons data classes
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Seasons(
|
data class Seasons(
|
||||||
@ -150,22 +256,13 @@ data class Seasons(
|
|||||||
@SerialName("items") val items: List<Season>
|
@SerialName("items") val items: List<Season>
|
||||||
) {
|
) {
|
||||||
fun getPreferredSeason(local: Locale): Season {
|
fun getPreferredSeason(local: Locale): Season {
|
||||||
|
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
|
||||||
items.forEach { season ->
|
season.slugTitle.endsWith("${local.getDisplayLanguage(Locale.ENGLISH)}-dub", true)
|
||||||
if (season.title.startsWith("(${local.language})", true)) {
|
} ?: items.firstOrNull { season ->
|
||||||
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
|
||||||
items.forEach { season ->
|
season.isSubbed
|
||||||
if (season.isSubbed) {
|
} ?: items.first() // if no preferred language and no sub, use the first season
|
||||||
return season
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there is no preferred language season and no sub, use the first season
|
|
||||||
return items.first()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,6 +270,7 @@ data class Seasons(
|
|||||||
data class Season(
|
data class Season(
|
||||||
@SerialName("id") val id: String,
|
@SerialName("id") val id: String,
|
||||||
@SerialName("title") val title: String,
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("slug_title") val slugTitle: String,
|
||||||
@SerialName("series_id") val seriesId: String,
|
@SerialName("series_id") val seriesId: String,
|
||||||
@SerialName("season_number") val seasonNumber: Int,
|
@SerialName("season_number") val seasonNumber: Int,
|
||||||
@SerialName("is_subbed") val isSubbed: Boolean,
|
@SerialName("is_subbed") val isSubbed: Boolean,
|
||||||
@ -180,11 +278,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 type
|
* Episodes data classes
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Episodes(
|
data class Episodes(
|
||||||
@ -248,7 +346,7 @@ data class PlayheadObject(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playback/stream data type
|
* playback/stream data classes
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Playback(
|
data class Playback(
|
||||||
@ -295,3 +393,22 @@ 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 = ""
|
||||||
|
)
|
||||||
|
@ -8,9 +8,9 @@ import java.util.*
|
|||||||
|
|
||||||
object Preferences {
|
object Preferences {
|
||||||
|
|
||||||
var preferSecondary = false
|
var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start
|
||||||
internal set
|
internal set
|
||||||
var preferredLocal = Locale.GERMANY
|
var preferSubbed = false
|
||||||
internal set
|
internal set
|
||||||
var autoplay = true
|
var autoplay = true
|
||||||
internal set
|
internal set
|
||||||
@ -19,6 +19,10 @@ 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),
|
||||||
@ -26,13 +30,22 @@ object Preferences {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun savePreferSecondary(context: Context, preferSecondary: Boolean) {
|
fun savePreferredLocal(context: Context, preferredLocale: Locale) {
|
||||||
with(getSharedPref(context).edit()) {
|
with(getSharedPref(context).edit()) {
|
||||||
putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSecondary)
|
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.preferSecondary = preferSecondary
|
this.preferredLocale = preferredLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePreferSecondary(context: Context, preferSubbed: Boolean) {
|
||||||
|
with(getSharedPref(context).edit()) {
|
||||||
|
putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSubbed)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preferSubbed = preferSubbed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveAutoplay(context: Context, autoplay: Boolean) {
|
fun saveAutoplay(context: Context, autoplay: Boolean) {
|
||||||
@ -62,13 +75,27 @@ 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)
|
||||||
|
|
||||||
preferSecondary = sharedPref.getBoolean(
|
preferredLocale = Locale.forLanguageTag(
|
||||||
|
sharedPref.getString(
|
||||||
|
context.getString(R.string.save_key_preferred_local), "en-US"
|
||||||
|
) ?: "en-US"
|
||||||
|
)
|
||||||
|
preferSubbed = sharedPref.getBoolean(
|
||||||
context.getString(R.string.save_key_prefer_secondary), false
|
context.getString(R.string.save_key_prefer_secondary), false
|
||||||
)
|
)
|
||||||
autoplay = sharedPref.getBoolean(
|
autoplay = sharedPref.getBoolean(
|
||||||
@ -82,6 +109,11 @@ 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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -27,6 +27,7 @@ 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
|
||||||
@ -42,11 +43,13 @@ 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
|
||||||
@ -60,6 +63,9 @@ 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
|
||||||
@ -135,6 +141,12 @@ 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,
|
||||||
@ -143,35 +155,32 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
) {
|
) {
|
||||||
showOnboarding()
|
showOnboarding()
|
||||||
} else {
|
} else {
|
||||||
runBlocking { initCrunchyroll().joinAll() }
|
runBlocking {
|
||||||
|
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> {
|
||||||
println("init")
|
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialCrunchyLoading"))
|
||||||
|
|
||||||
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 showLoginDialog() {
|
private fun initMetaDB(): Job {
|
||||||
LoginDialog(this, false).positiveButton {
|
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialMetaDBLoading"))
|
||||||
EncryptedPreferences.saveCredentials(login, password, context)
|
return scope.launch { MetaDBController.list() }
|
||||||
|
|
||||||
// TODO
|
|
||||||
// if (!AoDParser.login()) {
|
|
||||||
// showLoginDialog()
|
|
||||||
// Log.w(javaClass.name, "Login failed, please try again.")
|
|
||||||
// }
|
|
||||||
}.negativeButton {
|
|
||||||
Log.i(javaClass.name, "Login canceled, exiting.")
|
|
||||||
finish()
|
|
||||||
}.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -9,7 +9,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.RawRes
|
import androidx.annotation.RawRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.afollestad.materialdialogs.MaterialDialog
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import org.mosad.teapod.BuildConfig
|
import org.mosad.teapod.BuildConfig
|
||||||
import org.mosad.teapod.R
|
import org.mosad.teapod.R
|
||||||
import org.mosad.teapod.databinding.FragmentAboutBinding
|
import org.mosad.teapod.databinding.FragmentAboutBinding
|
||||||
@ -68,9 +68,9 @@ class AboutFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.linearLicense.setOnClickListener {
|
binding.linearLicense.setOnClickListener {
|
||||||
MaterialDialog(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.title(text = License.GPL3.long)
|
.setTitle(License.GPL3.long)
|
||||||
.message(text = parseLicense(R.raw.gpl_3_full))
|
.setMessage(parseLicense(R.raw.gpl_3_full))
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,16 +107,14 @@ 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("Material Dialogs", "", "Aidan Follestad",
|
ThirdPartyComponent("Ktor", "2014-2021", "JetBrains s.r.o and contributors",
|
||||||
"https://github.com/afollestad/material-dialogs", License.APACHE2),
|
"https://ktor.io/", License.APACHE2),
|
||||||
ThirdPartyComponent("Jsoup", "2009 - 2020", "Jonathan Hedley",
|
ThirdPartyComponent("kotlinx.coroutines", "2016-2021", "JetBrains s.r.o",
|
||||||
"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",
|
||||||
@ -132,9 +130,9 @@ class AboutFragment : Fragment() {
|
|||||||
License.MIT -> parseLicense(R.raw.mit_full)
|
License.MIT -> parseLicense(R.raw.mit_full)
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialDialog(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.title(text = license.long)
|
.setTitle(license.long)
|
||||||
.message(text = licenseText)
|
.setMessage(licenseText)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,53 +1,37 @@
|
|||||||
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.afollestad.materialdialogs.MaterialDialog
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.mosad.teapod.BuildConfig
|
import org.mosad.teapod.BuildConfig
|
||||||
import org.mosad.teapod.R
|
import org.mosad.teapod.R
|
||||||
import org.mosad.teapod.databinding.FragmentAccountBinding
|
import org.mosad.teapod.databinding.FragmentAccountBinding
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.Profile
|
||||||
|
import org.mosad.teapod.parser.crunchyroll.supportedLocals
|
||||||
import org.mosad.teapod.preferences.EncryptedPreferences
|
import org.mosad.teapod.preferences.EncryptedPreferences
|
||||||
import org.mosad.teapod.preferences.Preferences
|
import org.mosad.teapod.preferences.Preferences
|
||||||
import org.mosad.teapod.ui.activity.main.MainActivity
|
import org.mosad.teapod.ui.activity.main.MainActivity
|
||||||
import org.mosad.teapod.ui.components.LoginDialog
|
import org.mosad.teapod.ui.components.LoginModalBottomSheet
|
||||||
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 {
|
||||||
private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
Crunchyroll.profile()
|
||||||
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 {
|
||||||
@ -58,7 +42,9 @@ class AccountFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
// TODO reimplement for ct, if possible (maybe account status would be better? (premium))
|
binding.textAccountLogin.text = EncryptedPreferences.login
|
||||||
|
|
||||||
|
// TODO reimplement for cr, if possible (maybe account status would be better? (premium))
|
||||||
// load subscription (async) info before anything else
|
// load subscription (async) info before anything else
|
||||||
binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading))
|
binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading))
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@ -68,24 +54,30 @@ class AccountFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.textAccountLogin.text = EncryptedPreferences.login
|
// add preferred subtitles
|
||||||
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time))
|
lifecycleScope.launch {
|
||||||
|
binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag(
|
||||||
|
profile.await().preferredContentSubtitleLanguage
|
||||||
|
).displayLanguage
|
||||||
|
}
|
||||||
|
binding.switchSecondary.isChecked = Preferences.preferSubbed
|
||||||
|
binding.switchAutoplay.isChecked = Preferences.autoplay
|
||||||
binding.textThemeSelected.text = when (Preferences.theme) {
|
binding.textThemeSelected.text = when (Preferences.theme) {
|
||||||
Theme.DARK -> getString(R.string.theme_dark)
|
Theme.DARK -> getString(R.string.theme_dark)
|
||||||
else -> getString(R.string.theme_light)
|
else -> getString(R.string.theme_light)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.switchSecondary.isChecked = Preferences.preferSecondary
|
|
||||||
binding.switchAutoplay.isChecked = Preferences.autoplay
|
|
||||||
|
|
||||||
binding.linearDevSettings.isVisible = Preferences.devSettings
|
binding.linearDevSettings.isVisible = Preferences.devSettings
|
||||||
|
binding.switchUpdatePlayhead.isChecked = Preferences.updatePlayhead
|
||||||
|
|
||||||
|
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time))
|
||||||
|
|
||||||
initActions()
|
initActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initActions() {
|
private fun initActions() {
|
||||||
binding.linearAccountLogin.setOnClickListener {
|
binding.linearAccountLogin.setOnClickListener {
|
||||||
showLoginDialog(true)
|
showLoginDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.linearAccountSubscription.setOnClickListener {
|
binding.linearAccountSubscription.setOnClickListener {
|
||||||
@ -93,12 +85,9 @@ class AccountFragment : Fragment() {
|
|||||||
//startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
|
//startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.linearTheme.setOnClickListener {
|
|
||||||
showThemeDialog()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.linearInfo.setOnClickListener {
|
binding.linearSettingsContentLanguage.setOnClickListener {
|
||||||
activity?.showFragment(AboutFragment())
|
showContentLanguageSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.switchSecondary.setOnClickListener {
|
binding.switchSecondary.setOnClickListener {
|
||||||
@ -109,57 +98,105 @@ class AccountFragment : Fragment() {
|
|||||||
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
|
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.linearExportData.setOnClickListener {
|
binding.linearTheme.setOnClickListener {
|
||||||
val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
showThemeDialog()
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "text/json"
|
|
||||||
putExtra(Intent.EXTRA_TITLE, "my-list.json")
|
|
||||||
}
|
}
|
||||||
getUriExport.launch(i)
|
|
||||||
|
binding.linearInfo.setOnClickListener {
|
||||||
|
activity?.showFragment(AboutFragment())
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.switchUpdatePlayhead.setOnClickListener {
|
||||||
|
Preferences.saveUpdatePlayhead(requireContext(), binding.switchUpdatePlayhead.isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.linearExportData.setOnClickListener {
|
||||||
|
// unused
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.linearImportData.setOnClickListener {
|
binding.linearImportData.setOnClickListener {
|
||||||
val i = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
// unused
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "*/*"
|
|
||||||
}
|
|
||||||
getUriImport.launch(i)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showLoginDialog(firstTry: Boolean) {
|
private fun showLoginDialog() {
|
||||||
LoginDialog(requireContext(), firstTry).positiveButton {
|
val loginModal = LoginModalBottomSheet().apply {
|
||||||
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 themes = listOf(
|
val items = arrayOf(
|
||||||
resources.getString(R.string.theme_light),
|
resources.getString(R.string.theme_light),
|
||||||
resources.getString(R.string.theme_dark)
|
resources.getString(R.string.theme_dark)
|
||||||
)
|
)
|
||||||
|
|
||||||
MaterialDialog(requireContext()).show {
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
title(R.string.theme)
|
.setTitle(R.string.settings_content_language)
|
||||||
listItemsSingleChoice(items = themes, initialSelection = Preferences.theme.ordinal) { _, index, _ ->
|
.setSingleChoiceItems(items, Preferences.theme.ordinal){ _, which ->
|
||||||
when(index) {
|
when(which) {
|
||||||
0 -> Preferences.saveTheme(context, Theme.LIGHT)
|
0 -> Preferences.saveTheme(requireContext(), Theme.LIGHT)
|
||||||
1 -> Preferences.saveTheme(context, Theme.DARK)
|
1 -> Preferences.saveTheme(requireContext(), Theme.DARK)
|
||||||
else -> Preferences.saveTheme(context, Theme.DARK)
|
else -> Preferences.saveTheme(requireContext(), Theme.DARK)
|
||||||
}
|
}
|
||||||
|
|
||||||
(activity as MainActivity).restart()
|
(activity as MainActivity).restart()
|
||||||
}
|
}
|
||||||
}
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,34 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 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.parser.crunchyroll.Crunchyroll
|
import org.mosad.teapod.ui.activity.main.MainActivity
|
||||||
import org.mosad.teapod.parser.crunchyroll.Item
|
import org.mosad.teapod.ui.activity.main.viewmodel.HomeViewModel
|
||||||
import org.mosad.teapod.parser.crunchyroll.SortBy
|
import org.mosad.teapod.util.adapter.MediaEpisodeListAdapter
|
||||||
import org.mosad.teapod.util.adapter.MediaItemAdapter
|
import org.mosad.teapod.util.adapter.MediaItemListAdapter
|
||||||
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)
|
||||||
@ -38,83 +59,53 @@ 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)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
binding.recyclerUpNext.addItemDecoration(MediaItemDecoration(9))
|
||||||
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.recyclerNewEpisodes.addItemDecoration(MediaItemDecoration(9))
|
binding.recyclerRecommendations.addItemDecoration(MediaItemDecoration(9))
|
||||||
binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9))
|
binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9))
|
||||||
binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9))
|
binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9))
|
||||||
|
|
||||||
val asyncJobList = arrayListOf<Job>()
|
binding.recyclerUpNext.adapter = MediaEpisodeListAdapter(
|
||||||
|
MediaEpisodeListAdapter.OnClickListener {
|
||||||
// continue watching
|
val activity = activity
|
||||||
val upNextJob = lifecycleScope.launch {
|
if (activity is MainActivity) {
|
||||||
// TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately
|
activity.startPlayer(it.panel.episodeMetadata.seasonId, it.panel.id)
|
||||||
adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().toItemMediaList())
|
|
||||||
binding.recyclerNewEpisodes.adapter = adapterUpNext
|
|
||||||
}
|
}
|
||||||
asyncJobList.add(upNextJob)
|
|
||||||
|
|
||||||
// watchlist
|
|
||||||
val watchlistJob = lifecycleScope.launch {
|
|
||||||
adapterWatchlist = MediaItemAdapter(Crunchyroll.watchlist(50).toItemMediaList())
|
|
||||||
binding.recyclerWatchlist.adapter = adapterWatchlist
|
|
||||||
}
|
}
|
||||||
asyncJobList.add(watchlistJob)
|
)
|
||||||
|
|
||||||
// new simulcasts
|
binding.recyclerWatchlist.adapter = MediaItemListAdapter(
|
||||||
val simulcastsJob = lifecycleScope.launch {
|
MediaItemListAdapter.OnClickListener {
|
||||||
// val latestSeasonTag = Crunchyroll.seasonList().items.first().id
|
activity?.showFragment(MediaFragment(it.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)
|
)
|
||||||
|
|
||||||
// newly added / top ten
|
binding.recyclerRecommendations.adapter = MediaItemListAdapter(
|
||||||
val newlyAddedJob = lifecycleScope.launch {
|
MediaItemListAdapter.OnClickListener {
|
||||||
adapterTopTen = MediaItemAdapter(Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).toItemMediaList())
|
activity?.showFragment(MediaFragment(it.id))
|
||||||
binding.recyclerTopTen.adapter = adapterTopTen
|
|
||||||
}
|
}
|
||||||
asyncJobList.add(newlyAddedJob)
|
)
|
||||||
|
|
||||||
asyncJobList.joinAll()
|
binding.recyclerNewTitles.adapter = MediaItemListAdapter(
|
||||||
|
MediaItemListAdapter.OnClickListener {
|
||||||
|
activity?.showFragment(MediaFragment(it.id))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.recyclerTopTen.adapter = MediaItemListAdapter(
|
||||||
|
MediaItemListAdapter.OnClickListener {
|
||||||
|
activity?.showFragment(MediaFragment(it.id))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.textHighlightMyList.setOnClickListener {
|
||||||
|
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 {
|
||||||
@ -125,37 +116,60 @@ class HomeFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.textHighlightMyList.setOnClickListener {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
// TODO implement
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
// if (StorageController.myList.contains(0)) {
|
model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
// StorageController.myList.remove(0)
|
when (uiState) {
|
||||||
// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24)
|
is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
// } else {
|
is HomeViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
// StorageController.myList.add(0)
|
is HomeViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
// 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(highlightMedia.id))
|
activity?.showFragment(MediaFragment(uiState.highlightItem.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}")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -8,8 +8,7 @@ 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.FragmentActivity
|
import androidx.fragment.app.viewModels
|
||||||
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
|
||||||
@ -37,7 +36,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 activityViewModels()
|
private val model: MediaFragmentViewModel by viewModels()
|
||||||
|
|
||||||
private val fragments = arrayListOf<Fragment>()
|
private val fragments = arrayListOf<Fragment>()
|
||||||
private var watchlistJobRunning = false
|
private var watchlistJobRunning = false
|
||||||
@ -51,13 +50,10 @@ 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(requireActivity())
|
pagerAdapter = ScreenSlidePagerAdapter(this)
|
||||||
// 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
|
||||||
@ -82,6 +78,12 @@ 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()
|
||||||
|
|
||||||
@ -133,12 +135,15 @@ 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)
|
/**
|
||||||
val fragmentsSize = if (fragments.lastIndex < 0) 0 else fragments.lastIndex
|
* clear fragments, since it lives in onCreate scope,
|
||||||
|
* 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))
|
||||||
@ -173,13 +178,12 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if has similar titles
|
// if has similar titles
|
||||||
// TODO reimplement
|
if (model.similarTo.total > 0) {
|
||||||
// if (media.similar.isNotEmpty()) {
|
MediaFragmentSimilar().also {
|
||||||
// MediaFragmentSimilar().also {
|
fragments.add(it)
|
||||||
// fragments.add(it)
|
pagerAdapter.notifyItemInserted(fragments.indexOf(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()) {
|
||||||
@ -228,7 +232,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
|
|||||||
/**
|
/**
|
||||||
* A simple pager adapter
|
* A simple pager adapter
|
||||||
*/
|
*/
|
||||||
private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
|
private inner class ScreenSlidePagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
|
||||||
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]
|
||||||
|
@ -8,9 +8,10 @@ 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.activityViewModels
|
import androidx.fragment.app.viewModels
|
||||||
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
|
||||||
@ -21,7 +22,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 activityViewModels()
|
private val model: MediaFragmentViewModel by viewModels({requireParentFragment()})
|
||||||
|
|
||||||
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)
|
||||||
@ -34,20 +35,23 @@ 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 = model.currentSeasonCrunchy.title
|
binding.buttonSeasonSelection.text = getString(
|
||||||
|
R.string.season_number_title,
|
||||||
|
model.currentSeasonCrunchy.seasonNumber,
|
||||||
|
model.currentSeasonCrunchy.title
|
||||||
|
)
|
||||||
binding.buttonSeasonSelection.setOnClickListener { v ->
|
binding.buttonSeasonSelection.setOnClickListener { v ->
|
||||||
showSeasonSelection(v)
|
showSeasonSelection(v)
|
||||||
}
|
}
|
||||||
@ -57,14 +61,21 @@ 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(season.title).also {
|
popup.menu.add(getString(
|
||||||
|
R.string.season_number_title,
|
||||||
|
season.seasonNumber,
|
||||||
|
season.title
|
||||||
|
)
|
||||||
|
).also {
|
||||||
it.setOnMenuItemClickListener {
|
it.setOnMenuItemClickListener {
|
||||||
onSeasonSelected(season.id)
|
onSeasonSelected(season.id)
|
||||||
false
|
false
|
||||||
@ -86,7 +97,11 @@ class MediaFragmentEpisodes : Fragment() {
|
|||||||
// load the new season
|
// load the new season
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
model.setCurrentSeason(seasonId)
|
model.setCurrentSeason(seasonId)
|
||||||
binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title
|
binding.buttonSeasonSelection.text = getString(
|
||||||
|
R.string.season_number_title,
|
||||||
|
model.currentSeasonCrunchy.seasonNumber,
|
||||||
|
model.currentSeasonCrunchy.title
|
||||||
|
)
|
||||||
adapterRecEpisodes.notifyDataSetChanged()
|
adapterRecEpisodes.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
@ -5,19 +27,18 @@ 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.activityViewModels
|
import androidx.fragment.app.viewModels
|
||||||
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.MediaItemAdapter
|
import org.mosad.teapod.util.adapter.MediaItemListAdapter
|
||||||
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)
|
||||||
@ -27,15 +48,14 @@ 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))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// set onItemClick only in adapter is initialized
|
val adapterSimilar = binding.recyclerMediaSimilar.adapter as MediaItemListAdapter
|
||||||
if (this::adapterSimilar.isInitialized) {
|
adapterSimilar.submitList(model.similarTo.toItemMediaList())
|
||||||
adapterSimilar.onItemClick = { mediaId, _ ->
|
|
||||||
activity?.showFragment(MediaFragment("")) //(mediaId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -8,7 +8,6 @@ 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.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,8 +16,6 @@ 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
|
||||||
@ -34,6 +31,9 @@ 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,8 +42,6 @@ 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
|
||||||
@ -55,22 +53,17 @@ 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.preferredLocal)
|
currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocale)
|
||||||
|
|
||||||
|
// 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)
|
||||||
listOf(
|
viewModelScope.launch { episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) }.join()
|
||||||
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)
|
||||||
|
|
||||||
@ -103,7 +96,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()) {
|
||||||
@ -112,8 +105,7 @@ 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) {
|
||||||
@ -139,6 +131,11 @@ 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() {
|
||||||
@ -162,16 +159,4 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -47,15 +47,17 @@ 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.components.EpisodesListPlayer
|
import org.mosad.teapod.ui.activity.player.fragment.EpisodeListDialogFragment
|
||||||
import org.mosad.teapod.ui.components.LanguageSettingsPlayer
|
import org.mosad.teapod.ui.activity.player.fragment.LanguageSettingsDialogFragment
|
||||||
import org.mosad.teapod.util.*
|
import org.mosad.teapod.util.hideBars
|
||||||
|
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
|
||||||
@ -63,6 +65,8 @@ 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
|
||||||
@ -80,6 +84,11 @@ 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)) ?: ""
|
||||||
@ -87,7 +96,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
model.currentEpisodeChangedListener.add { onMediaChanged() }
|
model.currentEpisodeChangedListener.add { onMediaChanged() }
|
||||||
gestureDetector = GestureDetectorCompat(this, PlayerGestureListener())
|
gestureDetector = GestureDetectorCompat(this, PlayerGestureListener())
|
||||||
|
|
||||||
controller = video_view.findViewById(R.id.exo_controller)
|
controller = playerBinding.videoView.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
|
||||||
@ -104,7 +113,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
super.onStart()
|
super.onStart()
|
||||||
if (Util.SDK_INT > 23) {
|
if (Util.SDK_INT > 23) {
|
||||||
initPlayer()
|
initPlayer()
|
||||||
video_view?.onResume()
|
playerBinding.videoView.onResume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +123,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
if (Util.SDK_INT <= 23) {
|
if (Util.SDK_INT <= 23) {
|
||||||
initPlayer()
|
initPlayer()
|
||||||
video_view?.onResume()
|
playerBinding.videoView.onResume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,7 +175,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 = video_view.findViewById(R.id.exo_content_frame)
|
val contentFrame: View = playerBinding.videoView.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)
|
||||||
@ -190,7 +199,9 @@ 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.
|
||||||
video_view.useController = !isInPictureInPictureMode
|
playerBinding.videoView.useController = !isInPictureInPictureMode
|
||||||
|
|
||||||
|
// TODO also hide language settings/episodes list
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initPlayer() {
|
private fun initPlayer() {
|
||||||
@ -212,17 +223,13 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
override fun onPlaybackStateChanged(state: Int) {
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
super.onPlaybackStateChanged(state)
|
super.onPlaybackStateChanged(state)
|
||||||
|
|
||||||
loading.visibility = when (state) {
|
playerBinding.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
|
||||||
}
|
}
|
||||||
|
|
||||||
exo_play_pause.visibility = when (loading.visibility) {
|
controlsBinding.exoPlayPause.isVisible = !playerBinding.loading.isVisible
|
||||||
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()
|
||||||
@ -237,10 +244,10 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
private fun initVideoView() {
|
private fun initVideoView() {
|
||||||
video_view.player = model.player
|
playerBinding.videoView.player = model.player
|
||||||
|
|
||||||
// when the player controls get hidden, hide the bars too
|
// when the player controls get hidden, hide the bars too
|
||||||
video_view.setControllerVisibilityListener {
|
playerBinding.videoView.setControllerVisibilityListener {
|
||||||
when (it) {
|
when (it) {
|
||||||
View.GONE -> {
|
View.GONE -> {
|
||||||
hideBars()
|
hideBars()
|
||||||
@ -250,23 +257,23 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
video_view.setOnTouchListener { _, event ->
|
playerBinding.videoView.setOnTouchListener { _, event ->
|
||||||
gestureDetector.onTouchEvent(event)
|
gestureDetector.onTouchEvent(event)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initActions() {
|
private fun initActions() {
|
||||||
exo_close_player.setOnClickListener {
|
controlsBinding.exoClosePlayer.setOnClickListener {
|
||||||
this.finish()
|
this.finish()
|
||||||
}
|
}
|
||||||
rwd_10.setOnButtonClickListener { rewind() }
|
controlsBinding.rwd10.setOnButtonClickListener { rewind() }
|
||||||
ffwd_10.setOnButtonClickListener { fastForward() }
|
controlsBinding.ffwd10.setOnButtonClickListener { fastForward() }
|
||||||
button_next_ep.setOnClickListener { playNextEpisode() }
|
playerBinding.buttonNextEp.setOnClickListener { playNextEpisode() }
|
||||||
button_skip_op.setOnClickListener { skipOpening() }
|
playerBinding.buttonSkipOp.setOnClickListener { skipOpening() }
|
||||||
button_language.setOnClickListener { showLanguageSettings() }
|
controlsBinding.buttonLanguage.setOnClickListener { showLanguageSettings() }
|
||||||
button_episodes.setOnClickListener { showEpisodesList() }
|
controlsBinding.buttonEpisodes.setOnClickListener { showEpisodesList() }
|
||||||
button_next_ep_c.setOnClickListener { playNextEpisode() }
|
controlsBinding.buttonNextEpC.setOnClickListener { playNextEpisode() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initGUI() {
|
private fun initGUI() {
|
||||||
@ -284,7 +291,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 = button_next_ep.isVisible
|
val btnNextEpIsVisible = playerBinding.buttonNextEp.isVisible
|
||||||
val controlsVisible = controller.isVisible
|
val controlsVisible = controller.isVisible
|
||||||
|
|
||||||
// make sure remaining time is > 0
|
// make sure remaining time is > 0
|
||||||
@ -308,10 +315,12 @@ 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) &&
|
||||||
!button_skip_op.isVisible
|
!playerBinding.buttonSkipOp.isVisible
|
||||||
) {
|
) {
|
||||||
showButtonSkipOp()
|
showButtonSkipOp()
|
||||||
} else if (button_skip_op.isVisible && currentPosition !in it.openingStart..(it.openingStart + 10000)) {
|
} else if (playerBinding.buttonSkipOp.isVisible &&
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
@ -326,7 +335,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onPauseOnStop() {
|
private fun onPauseOnStop() {
|
||||||
video_view?.onPause()
|
playerBinding.videoView.onPause()
|
||||||
model.player.pause()
|
model.player.pause()
|
||||||
timerUpdates.cancel()
|
timerUpdates.cancel()
|
||||||
}
|
}
|
||||||
@ -341,7 +350,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
|
||||||
exo_remaining.text = if (TimeUnit.MILLISECONDS.toMinutes(remainingTime) < 60) {
|
controlsBinding.exoRemaining.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)
|
||||||
@ -359,10 +368,10 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
this.finish()
|
this.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
exo_text_title.text = model.getMediaTitle()
|
controlsBinding.exoTextTitle.text = model.getMediaTitle()
|
||||||
|
|
||||||
// hide the next episode button, if there is none
|
// hide the next episode button, if there is none
|
||||||
button_next_ep_c.visibility = if (hasNextEpisode()) View.VISIBLE else View.GONE
|
controlsBinding.buttonNextEpC.isVisible = hasNextEpisode()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -382,36 +391,36 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
model.seekToOffset(rwdTime)
|
model.seekToOffset(rwdTime)
|
||||||
|
|
||||||
// hide/show needed components
|
// hide/show needed components
|
||||||
exo_double_tap_indicator.visibility = View.VISIBLE
|
playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE
|
||||||
ffwd_10_indicator.visibility = View.INVISIBLE
|
playerBinding.ffwd10Indicator.visibility = View.INVISIBLE
|
||||||
rwd_10.visibility = View.INVISIBLE
|
controlsBinding.rwd10.visibility = View.INVISIBLE
|
||||||
|
|
||||||
rwd_10_indicator.onAnimationEndCallback = {
|
playerBinding.rwd10Indicator.onAnimationEndCallback = {
|
||||||
exo_double_tap_indicator.visibility = View.GONE
|
playerBinding.exoDoubleTapIndicator.visibility = View.GONE
|
||||||
ffwd_10_indicator.visibility = View.VISIBLE
|
playerBinding.ffwd10Indicator.visibility = View.VISIBLE
|
||||||
rwd_10.visibility = View.VISIBLE
|
controlsBinding.rwd10.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
// run animation
|
// run animation
|
||||||
rwd_10_indicator.runOnClickAnimation()
|
playerBinding.rwd10Indicator.runOnClickAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fastForward() {
|
private fun fastForward() {
|
||||||
model.seekToOffset(fwdTime)
|
model.seekToOffset(fwdTime)
|
||||||
|
|
||||||
// hide/show needed components
|
// hide/show needed components
|
||||||
exo_double_tap_indicator.visibility = View.VISIBLE
|
playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE
|
||||||
rwd_10_indicator.visibility = View.INVISIBLE
|
playerBinding.rwd10Indicator.visibility = View.INVISIBLE
|
||||||
ffwd_10.visibility = View.INVISIBLE
|
controlsBinding.ffwd10.visibility = View.INVISIBLE
|
||||||
|
|
||||||
ffwd_10_indicator.onAnimationEndCallback = {
|
playerBinding.ffwd10Indicator.onAnimationEndCallback = {
|
||||||
exo_double_tap_indicator.visibility = View.GONE
|
playerBinding.exoDoubleTapIndicator.visibility = View.GONE
|
||||||
rwd_10_indicator.visibility = View.VISIBLE
|
playerBinding.rwd10Indicator.visibility = View.VISIBLE
|
||||||
ffwd_10.visibility = View.VISIBLE
|
controlsBinding.ffwd10.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
// run animation
|
// run animation
|
||||||
ffwd_10_indicator.runOnClickAnimation()
|
playerBinding.ffwd10Indicator.runOnClickAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playNextEpisode() {
|
private fun playNextEpisode() {
|
||||||
@ -425,7 +434,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -433,10 +441,10 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
* TODO improve the show animation
|
* TODO improve the show animation
|
||||||
*/
|
*/
|
||||||
private fun showButtonNextEp() {
|
private fun showButtonNextEp() {
|
||||||
button_next_ep.isVisible = true
|
playerBinding.buttonNextEp.isVisible = true
|
||||||
button_next_ep.alpha = 0.0f
|
playerBinding.buttonNextEp.alpha = 0.0f
|
||||||
|
|
||||||
button_next_ep.animate()
|
playerBinding.buttonNextEp.animate()
|
||||||
.alpha(1.0f)
|
.alpha(1.0f)
|
||||||
.setListener(null)
|
.setListener(null)
|
||||||
}
|
}
|
||||||
@ -446,52 +454,45 @@ class PlayerActivity : AppCompatActivity() {
|
|||||||
* TODO improve the hide animation
|
* TODO improve the hide animation
|
||||||
*/
|
*/
|
||||||
private fun hideButtonNextEp() {
|
private fun hideButtonNextEp() {
|
||||||
button_next_ep.animate()
|
playerBinding.buttonNextEp.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)
|
||||||
button_next_ep.isVisible = false
|
playerBinding.buttonNextEp.isVisible = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showButtonSkipOp() {
|
private fun showButtonSkipOp() {
|
||||||
button_skip_op.isVisible = true
|
playerBinding.buttonSkipOp.isVisible = true
|
||||||
button_skip_op.alpha = 0.0f
|
playerBinding.buttonSkipOp.alpha = 0.0f
|
||||||
|
|
||||||
button_skip_op.animate()
|
playerBinding.buttonSkipOp.animate()
|
||||||
.alpha(1.0f)
|
.alpha(1.0f)
|
||||||
.setListener(null)
|
.setListener(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideButtonSkipOp() {
|
private fun hideButtonSkipOp() {
|
||||||
button_skip_op.animate()
|
playerBinding.buttonSkipOp.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)
|
||||||
button_skip_op.isVisible = false
|
playerBinding.buttonSkipOp.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -521,7 +522,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 = video_view.measuredWidth / 2
|
val viewCenterX = playerBinding.videoView.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()
|
||||||
|
@ -31,25 +31,18 @@ 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.Crunchyroll
|
import org.mosad.teapod.parser.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.EpisodeMeta
|
import org.mosad.teapod.util.metadb.EpisodeMeta
|
||||||
import org.mosad.teapod.util.Meta
|
import org.mosad.teapod.util.metadb.Meta
|
||||||
import org.mosad.teapod.util.TVShowMeta
|
import org.mosad.teapod.util.metadb.MetaDBController
|
||||||
import org.mosad.teapod.util.tmdb.TMDBTVSeason
|
import org.mosad.teapod.util.metadb.TVShowMeta
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,22 +51,23 @@ 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 = SimpleExoPlayer.Builder(application).build()
|
val player = ExoPlayer.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 TODO currently not implemented for cr
|
// tmdb/meta data
|
||||||
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
|
||||||
@ -83,7 +77,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
var currentPlayback = NonePlayback
|
var currentPlayback = NonePlayback
|
||||||
|
|
||||||
// current playback settings
|
// current playback settings
|
||||||
var currentLanguage: Locale = Preferences.preferredLocal
|
var currentLanguage: Locale = Preferences.preferredLocale
|
||||||
internal set
|
internal set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -102,8 +96,6 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
if (!isPlaying) updatePlayhead()
|
if (!isPlaying) updatePlayhead()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
@ -112,7 +104,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
player.release()
|
player.release()
|
||||||
|
|
||||||
Log.d(javaClass.name, "Released player")
|
Log.d(classTag, "Released player")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -129,21 +121,19 @@ 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)
|
||||||
|
|
||||||
setCurrentEpisode(episodeId)
|
listOf(
|
||||||
playCurrentMedia(currentPlayhead) // TODO, if fully watched, start from 0
|
viewModelScope.launch { mediaMeta = loadMediaMeta(episodes.items.first().seriesId) },
|
||||||
|
viewModelScope.launch {
|
||||||
|
val episodeIDs = episodes.items.map { it.id }
|
||||||
|
currentPlayheads = Crunchyroll.playheads(episodeIDs)
|
||||||
|
}
|
||||||
|
).joinAll()
|
||||||
|
|
||||||
// TODO reimplement for cr
|
|
||||||
// run async as it should be loaded by the time the episodes a
|
Log.d(classTag, "meta: $mediaMeta")
|
||||||
// viewModelScope.launch {
|
|
||||||
// // get tmdb season info, if metaDB knows the tv show
|
setCurrentEpisode(episodeId)
|
||||||
// if (media.type == DataTypes.MediaType.TVSHOW && mediaMeta != null) {
|
playCurrentMedia(currentPlayhead)
|
||||||
// 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) {
|
||||||
@ -165,6 +155,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
* play the next episode, if nextEpisodeId is not null
|
* play the next episode, if nextEpisodeId is not null
|
||||||
*/
|
*/
|
||||||
fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
|
fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
|
||||||
|
updatePlayhead() // update playhead before switching to new episode
|
||||||
setCurrentEpisode(nextEpisodeId, startPlayback = true)
|
setCurrentEpisode(nextEpisodeId, startPlayback = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,6 +168,16 @@ 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() }
|
||||||
|
|
||||||
@ -188,14 +189,17 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
},
|
},
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
|
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
|
||||||
currentPlayhead = (it.playhead.times(1000)).toLong()
|
// if the episode was fully watched, start at the beginning
|
||||||
|
currentPlayhead = if (it.fullyWatched) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(it.playhead.times(1000)).toLong()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
println("loaded playback ${currentEpisode.playback}")
|
Log.d(classTag, "playback: ${currentEpisode.playback}")
|
||||||
|
|
||||||
// TODO update metadata and language (it should not be needed to update the language here!)
|
|
||||||
|
|
||||||
if (startPlayback) {
|
if (startPlayback) {
|
||||||
playCurrentMedia()
|
playCurrentMedia()
|
||||||
@ -220,20 +224,18 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url
|
currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// if no language tag is present use the first entry
|
||||||
currentLanguage = Locale.ROOT
|
currentLanguage = Locale.ROOT
|
||||||
currentPlayback.streams.adaptive_hls[Locale.ROOT.toLanguageTag()]?.url ?: ""
|
currentPlayback.streams.adaptive_hls.entries.first().value.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println("stream url: $url")
|
Log.i(classTag, "stream url: $url")
|
||||||
|
|
||||||
// create the media source object
|
// create the media item
|
||||||
val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
|
val mediaItem = MediaItem.fromUri(Uri.parse(url))
|
||||||
MediaItem.fromUri(Uri.parse(url))
|
player.setMediaItem(mediaItem)
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
@ -263,24 +265,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
return episodes.items.lastOrNull()?.id == currentEpisode.id
|
return episodes.items.lastOrNull()?.id == currentEpisode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? {
|
private suspend fun loadMediaMeta(crSeriesId: String): Meta? {
|
||||||
val meta = mediaMeta
|
return MetaDBController.getTVShowMetadata(crSeriesId)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -289,10 +275,15 @@ 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) {
|
if (playhead > 0 && Preferences.updatePlayhead) {
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,68 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
@ -1,54 +1,75 @@
|
|||||||
package org.mosad.teapod.ui.components
|
package org.mosad.teapod.ui.activity.player.fragment
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.DialogInterface
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.util.AttributeSet
|
import android.os.Bundle
|
||||||
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.*
|
||||||
|
|
||||||
// TODO port to DialogFragment
|
class LanguageSettingsDialogFragment : DialogFragment() {
|
||||||
class LanguageSettingsPlayer @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
defStyleAttr: Int = 0,
|
|
||||||
model: PlayerViewModel? = null
|
|
||||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
|
||||||
|
|
||||||
private val binding = PlayerLanguageSettingsBinding.inflate(LayoutInflater.from(context), this, true)
|
private lateinit var model: PlayerViewModel
|
||||||
var onViewRemovedAction: (() -> Unit)? = null
|
private lateinit var binding: PlayerLanguageSettingsBinding
|
||||||
|
|
||||||
private var selectedLocale = model?.currentLanguage ?: Locale.ROOT
|
private var selectedLocale = Locale.ROOT
|
||||||
|
|
||||||
init {
|
companion object {
|
||||||
model?.let { m ->
|
const val TAG = "LanguageSettingsDialogFragment"
|
||||||
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 == m.currentLanguage) { v ->
|
addLanguage(locale, locale == model.currentLanguage) { v ->
|
||||||
selectedLocale = locale
|
selectedLocale = locale
|
||||||
updateSelectedLanguage(v as TextView)
|
updateSelectedLanguage(v as TextView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
binding.buttonCloseLanguageSettings.setOnClickListener { close() }
|
binding.buttonCloseLanguageSettings.setOnClickListener { dismiss() }
|
||||||
binding.buttonCancel.setOnClickListener { close() }
|
binding.buttonCancel.setOnClickListener { dismiss() }
|
||||||
binding.buttonSelect.setOnClickListener {
|
binding.buttonSelect.setOnClickListener {
|
||||||
model?.setLanguage(selectedLocale)
|
model.setLanguage(selectedLocale)
|
||||||
close()
|
dismiss()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: OnClickListener) {
|
// initially hide the status and navigation bar
|
||||||
|
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
|
||||||
@ -56,13 +77,13 @@ class LanguageSettingsPlayer @JvmOverloads constructor(
|
|||||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
setTextColor(context.resources.getColor(R.color.exo_white, context.theme))
|
setTextColor(context.resources.getColor(R.color.textPrimaryDark, 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.textPrimaryDark, context.theme))
|
setTextColor(context.resources.getColor(R.color.textSecondaryDark, context.theme))
|
||||||
setPadding(75, 0, 0, 0)
|
setPadding(75, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,12 +104,11 @@ class LanguageSettingsPlayer @JvmOverloads constructor(
|
|||||||
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.exo_white, context.theme))
|
setTextColor(context.resources.getColor(R.color.player_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)
|
||||||
@ -96,10 +116,4 @@ class LanguageSettingsPlayer @JvmOverloads constructor(
|
|||||||
compoundDrawablePadding = 12
|
compoundDrawablePadding = 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun close() {
|
|
||||||
(this.parent as ViewGroup).removeView(this)
|
|
||||||
onViewRemovedAction?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
@ -1,44 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -5,9 +5,6 @@ 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
|
||||||
@ -31,23 +28,7 @@ fun FragmentActivity.showFragment(fragment: Fragment) {
|
|||||||
* hide the status and navigation bar
|
* hide the status and navigation bar
|
||||||
*/
|
*/
|
||||||
fun Activity.hideBars() {
|
fun Activity.hideBars() {
|
||||||
window.apply {
|
hideBars(window, window.decorView.rootView)
|
||||||
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 {
|
||||||
|
@ -1,159 +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
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
@ -1,10 +1,15 @@
|
|||||||
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)
|
||||||
@ -21,9 +26,42 @@ 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 this.items.map {
|
return items.map {
|
||||||
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
|
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun List<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
|
||||||
|
return this.map {
|
||||||
|
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Locale.toDisplayString(fallback: String): String {
|
||||||
|
return if (this.displayLanguage.isNotEmpty() && this.displayCountry.isNotEmpty()) {
|
||||||
|
"${this.displayLanguage} (${this.displayCountry})"
|
||||||
|
} else if (this.displayCountry.isNotEmpty()) {
|
||||||
|
this.displayLanguage
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ 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
|
||||||
@ -12,84 +13,167 @@ 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,
|
||||||
) : RecyclerView.Adapter<EpisodeItemAdapter.EpisodeViewHolder>() {
|
private val onClickListener: OnClickListener,
|
||||||
|
private val viewType: ViewType
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
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 {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
return EpisodeViewHolder(ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
return when (viewType) {
|
||||||
|
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: EpisodeViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
val context = holder.binding.root.context
|
val episode = episodes[position]
|
||||||
val ep = episodes[position]
|
val playhead = playheads[episode.id]
|
||||||
|
val tmdbEpisode = tmdbEpisodes?.getOrNull(position)
|
||||||
|
|
||||||
val titleText = if (ep.episodeNumber != null) {
|
when (holder.itemViewType) {
|
||||||
// for tv shows add ep prefix and episode number
|
ViewType.MEDIA_FRAGMENT.ordinal -> {
|
||||||
if (ep.isDubbed) {
|
(holder as EpisodeViewHolder).bind(episode, playhead, tmdbEpisode)
|
||||||
context.getString(R.string.component_episode_title, ep.episode, ep.title)
|
}
|
||||||
} else {
|
ViewType.PLAYER.ordinal -> {
|
||||||
context.getString(R.string.component_episode_title_sub, ep.episode, ep.title)
|
(holder as PlayerEpisodeViewHolder).bind(episode, playhead, currentSelected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ep.title
|
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.binding.textEpisodeTitle.text = titleText
|
override fun getItemViewType(position: Int): Int {
|
||||||
holder.binding.textEpisodeDesc.text = if (ep.description.isNotEmpty()) {
|
return when (viewType) {
|
||||||
ep.description
|
ViewType.MEDIA_FRAGMENT -> ViewType.MEDIA_FRAGMENT.ordinal
|
||||||
} else if (tmdbEpisodes != null && position < tmdbEpisodes.size){
|
ViewType.PLAYER -> ViewType.PLAYER.ordinal
|
||||||
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 {
|
|
||||||
// on image click return the episode id and index (within the adapter)
|
fun bind(episode: Episode, playhead: PlayheadObject?, tmdbEpisode: TMDBTVEpisode?) {
|
||||||
|
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 {
|
||||||
onImageClick?.invoke(
|
onClickListener.onClick(episode)
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -2,11 +2,13 @@ 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
|
||||||
@ -29,6 +31,7 @@ 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,
|
||||||
|
@ -0,0 +1,61 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -1,79 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
57
app/src/main/java/org/mosad/teapod/util/metadb/DatTypes.kt
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
)
|
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -22,16 +22,19 @@
|
|||||||
|
|
||||||
package org.mosad.teapod.util.tmdb
|
package org.mosad.teapod.util.tmdb
|
||||||
|
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import android.util.Log
|
||||||
import com.github.kittinunf.fuel.core.FuelError
|
import io.ktor.client.*
|
||||||
import com.github.kittinunf.fuel.core.Parameters
|
import io.ktor.client.call.*
|
||||||
import com.github.kittinunf.fuel.json.FuelJson
|
import io.ktor.client.features.json.*
|
||||||
import com.github.kittinunf.fuel.json.responseJson
|
import io.ktor.client.features.json.serializer.*
|
||||||
import com.github.kittinunf.result.Result
|
import io.ktor.client.request.*
|
||||||
import kotlinx.coroutines.*
|
import io.ktor.client.statement.*
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.invoke
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.mosad.teapod.preferences.Preferences
|
||||||
import org.mosad.teapod.util.concatenate
|
import org.mosad.teapod.util.concatenate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,30 +44,41 @@ import org.mosad.teapod.util.concatenate
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
class TMDBApiController {
|
class TMDBApiController {
|
||||||
|
private val classTag = javaClass.name
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val client = HttpClient {
|
||||||
|
install(JsonFeature) {
|
||||||
|
serializer = KotlinxSerializer(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val apiUrl = "https://api.themoviedb.org/3"
|
private val apiUrl = "https://api.themoviedb.org/3"
|
||||||
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
|
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
|
||||||
private val language = "de"
|
|
||||||
|
|
||||||
companion object{
|
companion object{
|
||||||
const val imageUrl = "https://image.tmdb.org/t/p/w500"
|
const val imageUrl = "https://image.tmdb.org/t/p/w500"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun request(
|
private suspend inline fun <reified T> request(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
parameters: Parameters = emptyList()
|
parameters: List<Pair<String, Any?>> = emptyList()
|
||||||
): Result<FuelJson, FuelError> = coroutineScope {
|
): T = coroutineScope {
|
||||||
val path = "$apiUrl$endpoint"
|
val path = "$apiUrl$endpoint"
|
||||||
val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters)
|
val params = concatenate(
|
||||||
|
listOf("api_key" to apiKey, "language" to Preferences.preferredLocale.language),
|
||||||
|
parameters
|
||||||
|
)
|
||||||
|
|
||||||
// TODO handle FileNotFoundException
|
// TODO handle FileNotFoundException
|
||||||
return@coroutineScope (Dispatchers.IO) {
|
return@coroutineScope (Dispatchers.IO) {
|
||||||
val (_, _, result) = Fuel.get(path, params)
|
val response: HttpResponse = client.get(path) {
|
||||||
.responseJson()
|
params.forEach {
|
||||||
|
parameter(it.first, it.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
response.receive<T>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +92,12 @@ class TMDBApiController {
|
|||||||
val searchEndpoint = "/search/multi"
|
val searchEndpoint = "/search/multi"
|
||||||
val parameters = listOf("query" to query, "include_adult" to false)
|
val parameters = listOf("query" to query, "include_adult" to false)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(searchEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBSearchMovie
|
Log.e(classTag, "SerializationException in searchMovie(), with query = $query.", ex)
|
||||||
|
NoneTMDBSearchMovie
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,10 +110,12 @@ class TMDBApiController {
|
|||||||
val searchEndpoint = "/search/tv"
|
val searchEndpoint = "/search/tv"
|
||||||
val parameters = listOf("query" to query, "include_adult" to false)
|
val parameters = listOf("query" to query, "include_adult" to false)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(searchEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBSearchTVShow
|
Log.e(classTag, "SerializationException in searchTVShow(), with query = $query.", ex)
|
||||||
|
NoneTMDBSearchTVShow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -109,10 +127,12 @@ class TMDBApiController {
|
|||||||
val movieEndpoint = "/movie/$movieId"
|
val movieEndpoint = "/movie/$movieId"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(movieEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(movieEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBMovie
|
Log.e(classTag, "SerializationException in getMovieDetails(), with movieId = $movieId.", ex)
|
||||||
|
NoneTMDBMovie
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -124,10 +144,12 @@ class TMDBApiController {
|
|||||||
val tvShowEndpoint = "/tv/$tvId"
|
val tvShowEndpoint = "/tv/$tvId"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(tvShowEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(tvShowEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBTVShow
|
Log.e(classTag, "SerializationException in getTVShowDetails(), with tvId = $tvId.", ex)
|
||||||
|
NoneTMDBTVShow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
@ -141,10 +163,12 @@ class TMDBApiController {
|
|||||||
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
|
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(tvShowSeasonEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(tvShowSeasonEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBTVSeason
|
Log.e(classTag, "SerializationException in getTVSeasonDetails(), with tvId = $tvId, seasonNumber = $seasonNumber.", ex)
|
||||||
|
NoneTMDBTVSeason
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -110,8 +110,8 @@ data class TMDBTVShow(
|
|||||||
|
|
||||||
// use null for nullable types, the gui needs to handle/implement a fallback for null values
|
// use null for nullable types, the gui needs to handle/implement a fallback for null values
|
||||||
val NoneTMDB = TMDBBase(0, "", "", null, null)
|
val NoneTMDB = TMDBBase(0, "", "", null, null)
|
||||||
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "")
|
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "1970-01-01", null, "")
|
||||||
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "")
|
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "1970-01-01", "1970-01-01", "")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TMDBTVSeason(
|
data class TMDBTVSeason(
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
<?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>
|
|
5
app/src/main/res/drawable/ic_baseline_language_24.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z"/>
|
||||||
|
</vector>
|
19
app/src/main/res/drawable/ic_splash_foreground.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<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>
|
Before Width: | Height: | Size: 10 KiB |
@ -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_layout"
|
android:id="@+id/player_root"
|
||||||
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/exo_white"
|
app:indicatorColor="@color/player_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="70dp"
|
android:layout_marginBottom="72dp"
|
||||||
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/exo_white"
|
app:backgroundTint="@color/player_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="70dp"
|
android:layout_marginBottom="72dp"
|
||||||
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/exo_white"
|
app:backgroundTint="@color/player_white"
|
||||||
app:iconGravity="textStart" />
|
app:iconGravity="textStart" />
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
@ -1,30 +0,0 @@
|
|||||||
<?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>
|
|
@ -146,6 +146,46 @@
|
|||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/linear_settings_content_language"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="7dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imageView4"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="@string/settings_content_language"
|
||||||
|
android:minWidth="48dp"
|
||||||
|
android:minHeight="48dp"
|
||||||
|
android:padding="9dp"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/ic_baseline_language_24"
|
||||||
|
app:tint="?iconColor" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_settings_content_language"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_content_language"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_settings_content_language_desc"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_content_language_desc"
|
||||||
|
android:textColor="?textSecondary" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/linear_settings_secondary"
|
android:id="@+id/linear_settings_secondary"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@ -158,7 +198,7 @@
|
|||||||
android:id="@+id/imageView3"
|
android:id="@+id/imageView3"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:contentDescription="@string/settings_secondary"
|
android:contentDescription="@string/settings_prefer_subbed"
|
||||||
android:minWidth="48dp"
|
android:minWidth="48dp"
|
||||||
android:minHeight="48dp"
|
android:minHeight="48dp"
|
||||||
android:padding="9dp"
|
android:padding="9dp"
|
||||||
@ -185,7 +225,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="@string/settings_secondary"
|
android:text="@string/settings_prefer_subbed"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@ -194,7 +234,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:maxLines="2"
|
android:maxLines="2"
|
||||||
android:text="@string/settings_secondary_desc"
|
android:text="@string/settings_prefer_subbed_desc"
|
||||||
android:textColor="?textSecondary" />
|
android:textColor="?textSecondary" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@ -203,6 +243,7 @@
|
|||||||
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" />
|
||||||
@ -264,6 +305,7 @@
|
|||||||
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" />
|
||||||
@ -338,6 +380,69 @@
|
|||||||
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"
|
||||||
@ -345,7 +450,8 @@
|
|||||||
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"
|
||||||
@ -390,7 +496,8 @@
|
|||||||
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"
|
||||||
|
@ -115,7 +115,7 @@
|
|||||||
android:paddingBottom="7dp">
|
android:paddingBottom="7dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_new_episodes"
|
android:id="@+id/text_up_next"
|
||||||
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_new_episodes"
|
android:id="@+id/recycler_up_next"
|
||||||
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,6 +163,34 @@
|
|||||||
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"
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
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">
|
||||||
|
@ -10,21 +10,22 @@
|
|||||||
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="wrap_content"
|
android:layout_width="128dp"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="72dp">
|
||||||
|
|
||||||
<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="128dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="72dp"
|
android:layout_height="match_parent"
|
||||||
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/md_disabled_text_dark_theme" />
|
app:srcCompat="@color/imagePlaceholder" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/image_episode_play"
|
android:id="@+id/image_episode_play"
|
||||||
@ -35,6 +36,15 @@
|
|||||||
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
|
||||||
@ -43,6 +53,8 @@
|
|||||||
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" />
|
||||||
|
@ -7,16 +7,16 @@
|
|||||||
android:padding="7dp">
|
android:padding="7dp">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="192dp"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="108dp">
|
||||||
|
|
||||||
<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="192dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="108dp"
|
android:layout_height="match_parent"
|
||||||
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/md_disabled_text_dark_theme" />
|
app:srcCompat="@color/imagePlaceholder" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/image_episode_play"
|
android:id="@+id/image_episode_play"
|
||||||
@ -26,7 +26,16 @@
|
|||||||
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="#FFFFFF" />
|
app:tint="@color/player_white" />
|
||||||
|
|
||||||
|
<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
|
||||||
|
@ -13,18 +13,43 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<ImageView
|
<FrameLayout
|
||||||
android:id="@+id/image_poster"
|
android:id="@+id/frame_image_progress"
|
||||||
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"
|
||||||
@ -37,7 +62,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/image_poster" />
|
app:layout_constraintTop_toBottomOf="@+id/frame_image_progress" />
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
77
app/src/main/res/layout/modal_bottom_sheet_login.xml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<?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>
|
@ -1,6 +1,8 @@
|
|||||||
<?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">
|
||||||
@ -17,12 +19,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
|
||||||
@ -32,8 +34,9 @@
|
|||||||
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/exo_white"
|
android:textColor="@color/player_white"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp"
|
||||||
|
tools:ignore="TextContrastCheck" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@ -90,13 +93,15 @@
|
|||||||
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/exo_styled_progress_margin_bottom">
|
android:layout_marginBottom="@dimen/player_styled_progress_margin_bottom">
|
||||||
|
|
||||||
<View
|
<com.google.android.exoplayer2.ui.DefaultTimeBar
|
||||||
android:id="@+id/exo_progress_placeholder"
|
android:id="@id/exo_progress"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="@dimen/exo_styled_progress_layout_height"
|
android:layout_height="@dimen/player_styled_progress_layout_height"
|
||||||
android:layout_marginBottom="2dp"
|
android:contentDescription="@string/desc_time_bar"
|
||||||
|
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"
|
||||||
@ -105,9 +110,10 @@
|
|||||||
<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="0dp"
|
android:layout_height="wrap_content"
|
||||||
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
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<?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"
|
||||||
@ -22,12 +23,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
|
||||||
@ -37,8 +38,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/exo_white"
|
android:textColor="@color/player_white"
|
||||||
android:textSize="16sp"
|
android:textSize="18sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@ -75,7 +76,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/exo_white"
|
android:textColor="@color/player_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"
|
||||||
@ -93,7 +94,8 @@
|
|||||||
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>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_splash_round.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?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>
|
BIN
app/src/main/res/mipmap-hdpi/ic_splash_round.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_splash_round.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_splash_round.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_splash_round.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_splash_round.png
Normal file
After Width: | Height: | Size: 10 KiB |
@ -9,6 +9,7 @@
|
|||||||
<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>
|
||||||
@ -39,19 +40,27 @@
|
|||||||
<string name="info">Info</string>
|
<string name="info">Info</string>
|
||||||
<string name="info_about_desc">Version %1$s (%2$s)</string>
|
<string name="info_about_desc">Version %1$s (%2$s)</string>
|
||||||
<string name="settings">Einstellungen</string>
|
<string name="settings">Einstellungen</string>
|
||||||
<string name="settings_secondary">Bevorzuge Japanisch (OmU)</string>
|
<string name="settings_content_language">Bevorzuge Inhaltssprache</string>
|
||||||
<string name="settings_secondary_desc">Japanisch verwenden, sofern vorhanden</string>
|
<string name="settings_content_language_desc">Englisch</string>
|
||||||
|
<string name="settings_content_language_none">Keine</string>
|
||||||
|
<string name="settings_prefer_subbed">Bevorzuge OmU</string>
|
||||||
|
<string name="settings_prefer_subbed_desc">Original Sprache verwenden, sofern vorhanden</string>
|
||||||
<string name="settings_autoplay">Autoplay</string>
|
<string name="settings_autoplay">Autoplay</string>
|
||||||
<string name="settings_autoplay_desc">Nächste Episode automatisch abspielen</string>
|
<string name="settings_autoplay_desc">Nächste Episode automatisch abspielen</string>
|
||||||
<string name="theme">Design</string>
|
<string name="theme">Design</string>
|
||||||
<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>
|
||||||
@ -76,6 +85,7 @@
|
|||||||
<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>
|
||||||
@ -98,7 +108,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 Login-Daten weden verschüsselt aud deinem Gerät gespeichert.</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_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>
|
@ -5,6 +5,7 @@
|
|||||||
<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>
|
||||||
@ -25,5 +26,9 @@
|
|||||||
<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>
|
5
app/src/main/res/values/dimens.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?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>
|
@ -9,10 +9,12 @@
|
|||||||
<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>
|
||||||
@ -34,36 +36,44 @@
|
|||||||
<item quantity="one">%d Minute</item>
|
<item quantity="one">%d Minute</item>
|
||||||
<item quantity="other">%d Minutes</item>
|
<item quantity="other">%d Minutes</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="season_number_title" translatable="false">S%1$d - %2$s</string>
|
||||||
<string name="similar_titles">Similar titles</string>
|
<string name="similar_titles">Similar titles</string>
|
||||||
<string name="component_episode_title">Ep. %1$s %2$s</string>
|
<string name="component_episode_title">Ep. %1$s %2$s</string>
|
||||||
<string name="component_episode_title_sub">Ep. %1$s %2$s (Sub)</string>
|
<string name="component_episode_title_sub">Ep. %1$s %2$s (Sub)</string>
|
||||||
<string name="component_poster_desc" translatable="false">episode poster</string>
|
<string name="component_poster_desc" translatable="false">episode poster</string>
|
||||||
<string name="component_watched_desc" translatable="false">already watched</string>
|
<string name="component_watched_desc" translatable="false">already watched</string>
|
||||||
|
|
||||||
<!-- settings fragment -->
|
<!-- account fragment -->
|
||||||
<string name="account">Account</string>
|
<string name="account">Account</string>
|
||||||
<string name="account_login_ex" translatable="false">user@example.com</string>
|
<string name="account_login_ex" translatable="false">user@example.com</string>
|
||||||
<string name="account_login_desc">Tap to edit</string>
|
<string name="account_login_desc">Tap to edit</string>
|
||||||
<string name="account_subscription">Subscription %1$s</string>
|
<string name="account_subscription">Subscription %1$s</string>
|
||||||
<string name="account_subscription_desc">Tap to extend</string>
|
<string name="account_subscription_desc">Tap to extend</string>
|
||||||
<string name="info">Info</string>
|
|
||||||
<string name="info_about" translatable="false">Teapod by @Seil0</string>
|
|
||||||
<string name="info_about_desc">Version %1$s (%2$s)</string>
|
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="settings_secondary">Prefer japanese (sub)</string>
|
<string name="settings_content_language">Preferred content language</string>
|
||||||
<string name="settings_secondary_desc">Use the japanese, if present</string>
|
<string name="settings_content_language_desc">English</string>
|
||||||
|
<string name="settings_content_language_none">None</string>
|
||||||
|
<string name="settings_prefer_subbed">Prefer subbed</string>
|
||||||
|
<string name="settings_prefer_subbed_desc">Use original language, if present</string>
|
||||||
<string name="settings_autoplay">Autoplay</string>
|
<string name="settings_autoplay">Autoplay</string>
|
||||||
<string name="settings_autoplay_desc">Play next episode automatically</string>
|
<string name="settings_autoplay_desc">Play next episode automatically</string>
|
||||||
<string name="theme">Theme</string>
|
<string name="theme">Theme</string>
|
||||||
<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>
|
||||||
@ -97,6 +107,7 @@
|
|||||||
<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>
|
||||||
@ -128,10 +139,14 @@
|
|||||||
<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>
|
||||||
|
@ -18,11 +18,6 @@
|
|||||||
<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">
|
||||||
@ -36,17 +31,27 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
<!-- without this, the unchecked single choice buttons while be black -->
|
<item name="materialAlertDialogTheme">@style/ThemeOverlay.App.MaterialAlertDialog.Dark</item>
|
||||||
<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="Theme.MaterialComponents.Light.NoActionBar">
|
<style name="PlayerTheme" parent="AppTheme">
|
||||||
<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>
|
||||||
@ -56,10 +61,20 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- splash theme -->
|
<!-- splash theme -->
|
||||||
<style name="SplashTheme" parent="Theme.AppCompat.NoActionBar">
|
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||||
<item name="android:windowBackground">@drawable/bg_splash</item>
|
<!-- Set the splash screen background, animated icon, and animation duration. -->
|
||||||
|
<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>
|
||||||
@ -71,4 +86,14 @@
|
|||||||
<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>
|
@ -1,12 +1,14 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = "1.6.10"
|
ext.kotlin_version = "1.6.21"
|
||||||
|
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.1.0'
|
classpath 'com.android.tools.build:gradle:7.2.1'
|
||||||
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
|
||||||
|
10
fastlane/metadata/android/de/changelogs/9010.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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
|
@ -1,11 +1,15 @@
|
|||||||
Teapod ist eine inoffizielle App für Anime-on-Demand (AoD).
|
Teapod ist eine inoffizielle App für Crunchyroll.
|
||||||
|
|
||||||
* Schau dir alle Titel von AoD auf deinem Android Gerät an
|
* Schau dir alle Titel von Crunchyroll auf deinem Android Gerät an
|
||||||
* Nativer Player auf Basis des ExoPayers
|
* Nativer Player auf Basis des ExoPayers
|
||||||
* Bevorzuge die OmU Version über die App-Einstellungen
|
* Bevorzuge die OmU Version über die App-Einstellungen
|
||||||
* Speicher deine lieblings Anime in "Meine Liste"
|
* Picture in Picture Modus
|
||||||
|
* Überspringe das Intro/Ending dank der TeapodMetaDB Integration
|
||||||
|
|
||||||
Um Teapod zu verwenden musst du dich mit deinem AoD Account anmelden.
|
Um Teapod zu verwenden musst du dich mit deinem Crunchyroll Account anmelden.
|
||||||
Dieses Projekt ist in keiner Weise mit Anime-on-Demand verbunden.
|
Dieses Projekt ist in keiner Weise mit Crunchyroll verbunden.
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -1 +1 @@
|
|||||||
Android App für AoD
|
Android App für Crunchyroll
|
||||||
|
10
fastlane/metadata/android/en-US/changelogs/9010.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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
|
@ -1,11 +1,15 @@
|
|||||||
Teapod is a unofficial App for Anime-on-Demand (AoD).
|
Teapod is a unofficial App for Crunchyroll.
|
||||||
|
|
||||||
* Watch all animes from AoD on your Android device
|
* Watch all animes from Crunchyroll on your Android device
|
||||||
* Native Player based on ExoPayer
|
* Native Player based on ExoPayer
|
||||||
* Prefer the OmU version via the app settings
|
* Prefer the OmU version via the app settings
|
||||||
* Save your favorite animes to "My List"
|
* Picture in Picture Mode
|
||||||
|
* Skip the OP/ED thanks to the TeapodMetaDB integration
|
||||||
|
|
||||||
To use Teapod you have to login with your AoD account.
|
To use Teapod you have to login with your Crunchyroll account.
|
||||||
This Project is not associated with Anime-on-Demand in any way.
|
This Project is not associated with Crunchyroll in any way.
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -1 +1 @@
|
|||||||
Android App for AoD
|
Android App for Crunchyroll
|
||||||
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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.3.3-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|