Compare commits
	
		
			122 Commits
		
	
	
		
			1.0.0-beta
			...
			1.1.0-beta
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6515f657d0 | |||
| c448b44fc4 | |||
| 88ebc378d3 | |||
| 1a012cba7d | |||
| 59a457430e | |||
| 0662d656ac | |||
| 3549a3d2a7 | |||
| c89ae54929 | |||
| 3aa03783a9 | |||
| 4bceacf75c | |||
| cf02bee7d4 | |||
| 01d026cc7f | |||
| 7580093649 | |||
| f266731115 | |||
| a6a23c8560 | |||
| 2cb05de810 | |||
| 5cf4527a92 | |||
| 14ad34138c | |||
| 47e1f6bd49 | |||
| fdcb76e26e | |||
| 7004d73b9f | |||
| a13eb15adf | |||
| d40ab9519c | |||
| 2e7db26d1d | |||
| 8b7fb3ac5f | |||
| 097383a082 | |||
| 9380f98098 | |||
| e0f05169f5 | |||
| e113a9c795 | |||
| 8e397e13d2 | |||
| 31e7adac03 | |||
| 63f5e69094 | |||
| bf6f2d916e | |||
| 81a20e0aa9 | |||
| ed8f3fdcda | |||
| fffbeaeb49 | |||
| 21caa8eb1b | |||
| bbc819551b | |||
| 2004a3f483 | |||
| 0a31c2fd88 | |||
| f49b5a2730 | |||
| a95813e91e | |||
| 8bdaa8122b | |||
| e2ea0a364e | |||
| 777c6e0212 | |||
| 71d5c58653 | |||
| 6624e71228 | |||
| d33de371d1 | |||
| 1ecd25bb06 | |||
| fa28eb35ab | |||
| d3fe81224b | |||
| 34c7f9d081 | |||
| e835715b9c | |||
| 001141337d | |||
| 5cd3d25ebe | |||
| 215e01c53a | |||
| 1751963574 | |||
| 9c3548a866 | |||
| ebd96f9849 | |||
| 85b17d7a76 | |||
| f128efea0d | |||
| da94003368 | |||
| 3fdc2aff1b | |||
| 326da147f1 | |||
| f398c82f62 | |||
| 821f8b5590 | |||
| 0028cb6dd7 | |||
| 127bd030b9 | |||
| 3cadaa5c7a | |||
| 97966f5ad3 | |||
| 4c55bb771f | |||
| 8eb737a831 | |||
| 522b893dc8 | |||
| 69e0b6bcca | |||
| c34b95795f | |||
| 9059306e90 | |||
| ed0c0a4c61 | |||
| 03a79346b7 | |||
| 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 | |||
| 19552d3950 | |||
| 49e0b1ec29 | |||
| af66d968cc | 
| @ -26,4 +26,4 @@ Currently you need to have an Crunchyroll account to contribute to Teapod. Contr | |||||||
| #### Why is it called Teapod? | #### Why is it called Teapod? | ||||||
| Teapod is a Acronym for "The ultimate anime app on demand", hence this project is called Teapod and not Teapot. | Teapod is a Acronym for "The ultimate anime app on demand", hence this project is called Teapod and not Teapot. | ||||||
|  |  | ||||||
| Teapod © 2020-2022 [@Seil0](https://git.mosad.xyz/Seil0) | Teapod © 2020-2023 [@Seil0](https://git.mosad.xyz/Seil0) | ||||||
|  | |||||||
| @ -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 |     compileSdk 33 | ||||||
|     buildToolsVersion "30.0.3" |     buildToolsVersion '30.0.3' | ||||||
|  |  | ||||||
|     defaultConfig { |     defaultConfig { | ||||||
|         applicationId "org.mosad.teapod" |         applicationId "org.mosad.teapod" | ||||||
|         minSdkVersion 23 |         minSdk 23 | ||||||
|         targetSdkVersion 30 |         targetSdk 33 | ||||||
|         versionCode 9000 //00.09.000 |         versionCode 100992 //01.00.000 | ||||||
|         versionName "1.0.0-beta1" |         versionName "1.1.0-beta3" | ||||||
|  |  | ||||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||||
|         resValue "string", "build_time", buildTime() |         resValue "string", "build_time", buildTime() | ||||||
| @ -39,45 +38,50 @@ android { | |||||||
|     } |     } | ||||||
|     kotlinOptions { |     kotlinOptions { | ||||||
|         jvmTarget = '1.8' |         jvmTarget = '1.8' | ||||||
|  |         kotlin.sourceSets.configureEach { | ||||||
|  |             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.7.3' | ||||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1' |     implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1' | ||||||
|  |  | ||||||
|     implementation 'androidx.core:core-ktx:1.6.0' |     implementation 'androidx.core:core-ktx:1.10.1' | ||||||
|     implementation 'androidx.appcompat:appcompat:1.3.1' |     implementation 'androidx.core:core-splashscreen:1.0.1' | ||||||
|     implementation 'androidx.constraintlayout:constraintlayout:2.1.0' |     implementation 'androidx.appcompat:appcompat:1.6.1' | ||||||
|     implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' |     implementation 'androidx.constraintlayout:constraintlayout:2.1.4' | ||||||
|     implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' |     implementation 'androidx.navigation:navigation-fragment-ktx:2.6.0' | ||||||
|     implementation 'androidx.security:security-crypto:1.1.0-alpha03' |     implementation 'androidx.navigation:navigation-ui-ktx:2.6.0' | ||||||
|  |     implementation 'androidx.security:security-crypto:1.1.0-alpha06' | ||||||
|     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.6.1' | ||||||
|     implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' |     implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' | ||||||
|  |  | ||||||
|     implementation 'com.google.android.material:material:1.4.0' |     implementation 'com.google.android.material:material:1.9.0' | ||||||
|     implementation 'com.google.code.gson:gson:2.8.8' // TODO remove, still used by metadb |     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 'com.github.bumptech.glide:glide:4.12.0' |     implementation 'com.facebook.shimmer:shimmer:0.5.0' | ||||||
|  |  | ||||||
|  |     implementation 'com.github.bumptech.glide:glide:4.15.1' | ||||||
|     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' // TODO remove once unused |  | ||||||
|     implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' // TODO remove once unused |  | ||||||
|  |  | ||||||
|     implementation "io.ktor:ktor-client-core:$ktor_version" |     implementation "io.ktor:ktor-client-core:$ktor_version" | ||||||
|     implementation "io.ktor:ktor-client-android:$ktor_version" |     implementation "io.ktor:ktor-client-android:$ktor_version" | ||||||
|     implementation "io.ktor:ktor-client-serialization:$ktor_version" |     implementation "io.ktor:ktor-client-content-negotiation:$ktor_version" | ||||||
|  |     implementation "io.ktor:ktor-serialization-kotlinx-json:$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.5' | ||||||
|     androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' |     androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' | ||||||
|  |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								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. | ||||||
| @ -56,6 +52,9 @@ | |||||||
| # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. | ||||||
| -keepattributes RuntimeVisibleAnnotations,AnnotationDefault | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault | ||||||
|  |  | ||||||
|  | # This is generated automatically by the Android Gradle plugin. | ||||||
|  | -dontwarn org.slf4j.impl.StaticLoggerBinder | ||||||
|  |  | ||||||
| #misc | #misc | ||||||
| -dontwarn java.lang.instrument.ClassFileTransformer | -dontwarn java.lang.instrument.ClassFileTransformer | ||||||
| -dontwarn java.lang.ClassValue | -dontwarn java.lang.ClassValue | ||||||
|  | |||||||
| @ -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" /> | ||||||
|  |  | ||||||
| @ -11,34 +10,29 @@ | |||||||
|         android:label="@string/app_name" |         android:label="@string/app_name" | ||||||
|         android:roundIcon="@mipmap/ic_launcher_round" |         android:roundIcon="@mipmap/ic_launcher_round" | ||||||
|         android:supportsRtl="true" |         android:supportsRtl="true" | ||||||
|         android:theme="@style/AppTheme.Dark"> |         android:theme="@style/AppTheme"> | ||||||
|         <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" | ||||||
|  | |||||||
| @ -25,46 +25,46 @@ package org.mosad.teapod.parser.crunchyroll | |||||||
| import android.util.Log | import android.util.Log | ||||||
| import io.ktor.client.* | import io.ktor.client.* | ||||||
| import io.ktor.client.call.* | import io.ktor.client.call.* | ||||||
| import io.ktor.client.features.json.* | import io.ktor.client.plugins.* | ||||||
| import io.ktor.client.features.json.serializer.* | import io.ktor.client.plugins.contentnegotiation.* | ||||||
| import io.ktor.client.request.* | import io.ktor.client.request.* | ||||||
| import io.ktor.client.request.forms.* | import io.ktor.client.request.forms.* | ||||||
| import io.ktor.client.statement.* | import io.ktor.client.statement.* | ||||||
| import io.ktor.http.* | import io.ktor.http.* | ||||||
|  | import io.ktor.serialization.* | ||||||
|  | import io.ktor.serialization.kotlinx.json.* | ||||||
| import kotlinx.coroutines.* | import kotlinx.coroutines.* | ||||||
| import kotlinx.serialization.SerializationException |  | ||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
| import kotlinx.serialization.json.JsonObject | 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 } |  | ||||||
|  |  | ||||||
| object Crunchyroll { | object Crunchyroll { | ||||||
|     private val TAG = javaClass.name |     private val TAG = javaClass.name | ||||||
|  |  | ||||||
|     private val client = HttpClient { |     private val client = HttpClient { | ||||||
|         install(JsonFeature) { |         install(ContentNegotiation) { | ||||||
|             serializer = KotlinxSerializer(json) |             json(Json { | ||||||
|  |                 ignoreUnknownKeys = true | ||||||
|  |             }) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     private const val baseUrl = "https://beta-api.crunchyroll.com" |     private const val baseUrl = "https://beta-api.crunchyroll.com" | ||||||
|  |     private const val staticUrl = "https://static.crunchyroll.com" | ||||||
|     private const val basicApiTokenUrl = "https://gitlab.com/-/snippets/2274956/raw/main/snippetfile1.txt" |     private const val basicApiTokenUrl = "https://gitlab.com/-/snippets/2274956/raw/main/snippetfile1.txt" | ||||||
|     private var basicApiToken: String = "" |     private var basicApiToken: String = "" | ||||||
|  |  | ||||||
|     private lateinit var token: Token |     private lateinit var token: Token | ||||||
|     private var tokenValidUntil: Long = 0 |     private var tokenValidUntil: Long = 0 | ||||||
|  |     @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) | ||||||
|  |     private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext") | ||||||
|  |  | ||||||
|     private var accountID = "" |     private var accountID = "" | ||||||
|  |     private var externalID = "" | ||||||
|  |  | ||||||
|     private var policy = "" |     private val browsingCache = hashMapOf<String, BrowseResult>() | ||||||
|     private var signature = "" |  | ||||||
|     private var keyPairID = "" |  | ||||||
|  |  | ||||||
|     private val browsingCache = arrayListOf<Item>() |  | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Load the pai token, see: |      * Load the pai token, see: | ||||||
| @ -74,7 +74,7 @@ object Crunchyroll { | |||||||
|      */ |      */ | ||||||
|     fun initBasicApiToken() = runBlocking { |     fun initBasicApiToken() = runBlocking { | ||||||
|         withContext(Dispatchers.IO) { |         withContext(Dispatchers.IO) { | ||||||
|             basicApiToken = (client.get(basicApiTokenUrl) as HttpResponse).readText() |             basicApiToken = client.get { url(basicApiTokenUrl) }.bodyAsText() | ||||||
|             Log.i(TAG, "basic auth token: $basicApiToken") |             Log.i(TAG, "basic auth token: $basicApiToken") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @ -98,15 +98,27 @@ object Crunchyroll { | |||||||
|  |  | ||||||
|         var success = false// is false |         var success = false// is false | ||||||
|         withContext(Dispatchers.IO) { |         withContext(Dispatchers.IO) { | ||||||
|             // TODO handle exceptions |             Log.i(TAG, "getting token ...") | ||||||
|             val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { |  | ||||||
|                 header("Authorization", "Basic $basicApiToken") |  | ||||||
|             } |  | ||||||
|             token = response.receive() |  | ||||||
|             tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000) |  | ||||||
|  |  | ||||||
|             Log.i(TAG, "login complete with code ${response.status}") |             val status = try { | ||||||
|             success = (response.status == HttpStatusCode.OK) |                 val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { | ||||||
|  |                     header("Authorization", "Basic $basicApiToken") | ||||||
|  |                 } | ||||||
|  |                 token = response.body() | ||||||
|  |                 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 | ||||||
| @ -126,10 +138,12 @@ object Crunchyroll { | |||||||
|         params: List<Pair<String, Any?>> = listOf(), |         params: List<Pair<String, Any?>> = listOf(), | ||||||
|         bodyObject: Any = Any() |         bodyObject: Any = Any() | ||||||
|     ): T = coroutineScope { |     ): T = coroutineScope { | ||||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() |         withContext(tokenRefreshContext) { | ||||||
|  |             if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||||
|  |         } | ||||||
|  |  | ||||||
|         return@coroutineScope (Dispatchers.IO) { |         return@coroutineScope (Dispatchers.IO) { | ||||||
|             val response: T = client.request(url) { |             val response = client.request(url) { | ||||||
|                 method = httpMethod |                 method = httpMethod | ||||||
|                 header("Authorization", "${token.tokenType} ${token.accessToken}") |                 header("Authorization", "${token.tokenType} ${token.accessToken}") | ||||||
|                 params.forEach { |                 params.forEach { | ||||||
| @ -138,21 +152,24 @@ object Crunchyroll { | |||||||
|  |  | ||||||
|                 // for json set body and content type |                 // for json set body and content type | ||||||
|                 if (bodyObject is JsonObject) { |                 if (bodyObject is JsonObject) { | ||||||
|                     body = bodyObject |                     setBody(bodyObject) | ||||||
|                     contentType(ContentType.Application.Json) |                     contentType(ContentType.Application.Json) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             response |             response.body<T>() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Send a HTTP GET request with [params] to the [endpoint] at [url], if url is empty use baseUrl | ||||||
|  |      */ | ||||||
|     private suspend inline fun <reified T> requestGet( |     private suspend inline fun <reified T> requestGet( | ||||||
|         endpoint: String, |         endpoint: String, | ||||||
|         params: List<Pair<String, Any?>> = listOf(), |         params: List<Pair<String, Any?>> = listOf(), | ||||||
|         url: String = "" |         url: String = "" | ||||||
|     ): T { |     ): T { | ||||||
|         val path = url.ifEmpty { "$baseUrl$endpoint" } |         val path = url.ifEmpty { baseUrl }.plus(endpoint) | ||||||
|  |  | ||||||
|         return request(path, HttpMethod.Get, params) |         return request(path, HttpMethod.Get, params) | ||||||
|     } |     } | ||||||
| @ -191,27 +208,10 @@ object Crunchyroll { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Basic functions: index, account |      * Basic functions: account | ||||||
|      * Needed for other functions to work properly! |      * Needed for other functions to work properly! | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Retrieve the identifiers necessary for streaming. If the identifiers are |  | ||||||
|      * retrieved, set the corresponding global var. The identifiers are valid for 24h. |  | ||||||
|      */ |  | ||||||
|     suspend fun index() { |  | ||||||
|         val indexEndpoint = "/index/v2" |  | ||||||
|  |  | ||||||
|         val index: Index = requestGet(indexEndpoint) |  | ||||||
|         policy = index.cms.policy |  | ||||||
|         signature = index.cms.signature |  | ||||||
|         keyPairID = index.cms.keyPairId |  | ||||||
|  |  | ||||||
|         Log.i(TAG, "Policy : $policy") |  | ||||||
|         Log.i(TAG, "Signature : $signature") |  | ||||||
|         Log.i(TAG, "Key Pair ID : $keyPairID") |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Retrieve the account id and set the corresponding global var. |      * Retrieve the account id and set the corresponding global var. | ||||||
|      * The account id is needed for other calls. |      * The account id is needed for other calls. | ||||||
| @ -223,72 +223,103 @@ object Crunchyroll { | |||||||
|  |  | ||||||
|         val account: Account = try { |         val account: Account = try { | ||||||
|             requestGet(indexEndpoint) |             requestGet(indexEndpoint) | ||||||
|         } catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in account(). This is bad!", ex) |             Log.e(TAG, "SerializationException in account(). This is bad!", ex) | ||||||
|             NoneAccount |             NoneAccount | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         accountID = account.accountId |         accountID = account.accountId | ||||||
|  |         externalID = account.externalId | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * General element/media functions: browse, search, objects, season_list |      * General element/media functions: browse, search, objects, season_list | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
|     // TODO categories |  | ||||||
|     /** |     /** | ||||||
|      * Browse the media available on crunchyroll. |      * Browse the media available on crunchyroll. | ||||||
|      * |      * | ||||||
|      * @param sortBy |      * @param start start of the item list, used for pagination, default = 0 | ||||||
|      * @param n Number of items to return, defaults to 10 |      * @param n number of items to return, default = 10 | ||||||
|      * |      * @param sortBy the sort order, see **[SortBy]** | ||||||
|  |      * @param ratings add user rating to the objects, default = false | ||||||
|  |      * @param seasonTag filter by season tag, if present | ||||||
|  |      * @param categories filter by category, if present | ||||||
|      * @return A **[BrowseResult]** object is returned. |      * @return A **[BrowseResult]** object is returned. | ||||||
|      */ |      */ | ||||||
|     suspend fun browse( |     suspend fun browse( | ||||||
|         sortBy: SortBy = SortBy.ALPHABETICAL, |  | ||||||
|         seasonTag: String = "", |  | ||||||
|         start: Int = 0, |         start: Int = 0, | ||||||
|         n: Int = 10 |         n: Int = 10, | ||||||
|  |         sortBy: SortBy = SortBy.ALPHABETICAL, | ||||||
|  |         ratings: Boolean = false, | ||||||
|  |         seasonTag: String = "", | ||||||
|  |         categories: List<Categories> = emptyList() | ||||||
|     ): BrowseResult { |     ): BrowseResult { | ||||||
|         val browseEndpoint = "/content/v1/browse" |         val browseEndpoint = "/content/v2/discover/browse" | ||||||
|         val noneOptParams = listOf( |         val parameters = mutableListOf( | ||||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), |  | ||||||
|             "sort_by" to sortBy.str, |  | ||||||
|             "start" to start, |             "start" to start, | ||||||
|             "n" to n |             "n" to n, | ||||||
|  |             "sort_by" to sortBy.str, | ||||||
|  |             "ratings" to ratings, | ||||||
|  |             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         // 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) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // if a season tag is present add it to the parameters | ||||||
|  |         if (categories.isNotEmpty()) { | ||||||
|  |             parameters.add("categories" to categories.joinToString(",") { it.str }) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // fetch result if not already cached | ||||||
|  |         if (browsingCache.contains(parameters.toString())) { | ||||||
|  |             Log.d(TAG, "browse result cached: $parameters") | ||||||
|         } else { |         } else { | ||||||
|             noneOptParams |             Log.d(TAG, "browse result not cached, fetching: $parameters") | ||||||
|  |             val browseResult: BrowseResult = try { | ||||||
|  |                 requestGet(browseEndpoint, parameters) | ||||||
|  |             }catch (ex: Exception) { | ||||||
|  |                 Log.e(TAG, "SerializationException in browse().", ex) | ||||||
|  |                 NoneBrowseResult | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             // if the cache has more than 10 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 > 10) { | ||||||
|  |                 browsingCache.clear() | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // add results to cache | ||||||
|  |             browsingCache[parameters.toString()] = browseResult | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         val browseResult: BrowseResult = try { |         return browsingCache[parameters.toString()] ?: NoneBrowseResult | ||||||
|             requestGet(browseEndpoint, parameters) |  | ||||||
|         }catch (ex: SerializationException) { |  | ||||||
|             Log.e(TAG, "SerializationException in browse().", ex) |  | ||||||
|             NoneBrowseResult |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // add results to cache TODO improve |  | ||||||
|         browsingCache.clear() |  | ||||||
|         browsingCache.addAll(browseResult.items) |  | ||||||
|  |  | ||||||
|         return browseResult |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 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 | ||||||
|  |      * @param ratings add user rating to the objects, default = false | ||||||
|  |      * @return A **[SearchResult]** object | ||||||
|      */ |      */ | ||||||
|     suspend fun search(query: String, n: Int = 10): SearchResult { |     suspend fun search(query: String, n: Int = 10, ratings: Boolean = false): SearchResult { | ||||||
|         val searchEndpoint = "/content/v1/search" |         val searchEndpoint = "/content/v2/discover/search" | ||||||
|         val parameters = listOf( |         val parameters = listOf( | ||||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), |  | ||||||
|             "q" to query, |             "q" to query, | ||||||
|             "n" to n, |             "n" to n, | ||||||
|             "type" to "series" |             "type" to "series", | ||||||
|  |             "ratings" to ratings, | ||||||
|  |             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         // TODO episodes have thumbnails as image, and not poster_tall/poster_tall, |         // TODO episodes have thumbnails as image, and not poster_tall/poster_tall, | ||||||
| @ -296,8 +327,8 @@ object Crunchyroll { | |||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             requestGet(searchEndpoint, parameters) |             requestGet(searchEndpoint, parameters) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex) |             Log.e(TAG, "Exception in search(), with query = \"$query\".", ex) | ||||||
|             NoneSearchResult |             NoneSearchResult | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @ -307,38 +338,22 @@ object Crunchyroll { | |||||||
|      * Note: episode objects are currently not supported |      * Note: episode objects are currently not supported | ||||||
|      * |      * | ||||||
|      * @param objects The object IDs as list of Strings |      * @param objects The object IDs as list of Strings | ||||||
|  |      * @param ratings add user rating to the objects | ||||||
|      * @return A **[Collection]** of Panels |      * @return A **[Collection]** of Panels | ||||||
|      */ |      */ | ||||||
|     suspend fun objects(objects: List<String>): Collection<Item> { |     suspend fun objects(objects: List<String>, ratings: Boolean = false): CollectionV2<Item> { | ||||||
|         val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}" |         val episodesEndpoint = "/content/v2/cms/objects/${objects.joinToString(",")}" | ||||||
|         val parameters = listOf( |         val parameters = listOf( | ||||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), |             "ratings" to ratings, | ||||||
|             "Signature" to signature, |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||||
|             "Policy" to policy, |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|             "Key-Pair-Id" to keyPairID |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             requestGet(episodesEndpoint, parameters) |             requestGet(episodesEndpoint, parameters) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in objects().", ex) |             Log.e(TAG, "Exception in objects().", ex) | ||||||
|             NoneCollection |             NoneCollectionV2 | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * List all available seasons as **[SeasonListItem]**. |  | ||||||
|      */ |  | ||||||
|     @Suppress("unused") |  | ||||||
|     suspend fun seasonList(): DiscSeasonList { |  | ||||||
|         val seasonListEndpoint = "/content/v1/season_list" |  | ||||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |  | ||||||
|  |  | ||||||
|         return try { |  | ||||||
|             requestGet(seasonListEndpoint, parameters) |  | ||||||
|         }catch (ex: SerializationException) { |  | ||||||
|             Log.e(TAG, "SerializationException in seasonList().", ex) |  | ||||||
|             NoneDiscSeasonList |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -350,87 +365,118 @@ object Crunchyroll { | |||||||
|      * series id == crunchyroll id? |      * series id == crunchyroll id? | ||||||
|      */ |      */ | ||||||
|     suspend fun series(seriesId: String): Series { |     suspend fun series(seriesId: String): Series { | ||||||
|         val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId" |         val seriesEndpoint = "/content/v2/cms/series/$seriesId" | ||||||
|         val parameters = listOf( |         val parameters = listOf( | ||||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||||
|             "Signature" to signature, |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|             "Policy" to policy, |  | ||||||
|             "Key-Pair-Id" to keyPairID |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             requestGet(seriesEndpoint, parameters) |             requestGet(seriesEndpoint, parameters) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in series().", ex) |             Log.e(TAG, "Exception in series() for id $seriesId.", ex) | ||||||
|             NoneSeries |             NoneSeries | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * TODO |      * Get the next episode for a series. | ||||||
|  |      * | ||||||
|  |      * FIXME up_next returns no content if the is no next episode | ||||||
|  |      * | ||||||
|  |      * @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): UpNextSeriesList { | ||||||
|         val upNextSeriesEndpoint = "/content/v1/up_next_series" |         val upNextSeriesEndpoint = "/content/v2/discover/up_next/$seriesId" | ||||||
|         val parameters = listOf( |         val parameters = listOf( | ||||||
|             "series_id" to seriesId, |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||||
|             "locale" to Preferences.preferredLocale.toLanguageTag() |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             requestGet(upNextSeriesEndpoint, parameters) |             requestGet(upNextSeriesEndpoint, parameters) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: NoTransformationFoundException) { | ||||||
|             Log.e(TAG, "SerializationException in upNextSeries().", ex) |             // should be 204 No Content | ||||||
|             NoneUpNextSeriesItem |             NoneUpNextSeriesList | ||||||
|         } |         } catch (ex: JsonConvertException) { | ||||||
|     } |             Log.e(TAG, "JsonConvertException in upNextSeries() with seriesId=$seriesId", ex) | ||||||
|  |             NoneUpNextSeriesList | ||||||
|     suspend fun seasons(seriesId: String): Seasons { |         } catch (ex: Exception) { | ||||||
|         val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons" |             Log.e(TAG, "Exception in upNextSeries() for seriesId $seriesId.", ex) | ||||||
|         val parameters = listOf( |             NoneUpNextSeriesList | ||||||
|             "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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     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 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     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 |      * 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 = "/content/v2/cms/series/$seriesId/seasons" | ||||||
|  |         val parameters = listOf( | ||||||
|  |             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return try { | ||||||
|  |             requestGet(seasonsEndpoint, parameters) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in seasons() for seriesId $seriesId.", 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 = "/content/v2/cms/seasons/$seasonId/episodes" | ||||||
|  |         val parameters = listOf( | ||||||
|  |             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return try { | ||||||
|  |             requestGet(episodesEndpoint, parameters) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in episodes() for seasonId $seasonId.", ex) | ||||||
|  |             NoneEpisodes | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get all available subtitles and streams of a episode. | ||||||
|  |      * | ||||||
|  |      * @param url The streams url of a episode | ||||||
|  |      * @return A **[Streams]** object | ||||||
|  |      */ | ||||||
|  |     suspend fun streams(url: String): Streams { | ||||||
|  |         val parameters = listOf( | ||||||
|  |             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return try { | ||||||
|  |             requestGet(url, parameters) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in streams() with url $url.", ex) | ||||||
|  |             NoneStreams | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun streamsFromMediaGUID(mediaGUID: String): Streams { | ||||||
|  |         val streamsEndpoint = "/content/v2/cms/videos/$mediaGUID/streams" | ||||||
|  |         return streams(streamsEndpoint) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Additional media functions: watchlist (series), playhead, similar to | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -440,14 +486,18 @@ object Crunchyroll { | |||||||
|      * @return **[Boolean]**: ture if it was found, else false |      * @return **[Boolean]**: ture if it was found, else false | ||||||
|      */ |      */ | ||||||
|     suspend fun isWatchlist(seriesId: String): Boolean { |     suspend fun isWatchlist(seriesId: String): Boolean { | ||||||
|         val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId" |         val watchlistSeriesEndpoint = "/content/v2/$accountID/watchlist" | ||||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |         val parameters = listOf( | ||||||
|  |             "content_ids" to seriesId, | ||||||
|  |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) |             (requestGet(watchlistSeriesEndpoint, parameters) as CollectionV2<IsWatchlistItem>) | ||||||
|                 .containsKey(seriesId) |                 .total == 1 | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex) |             Log.e(TAG, "Exception in isWatchlist() with seriesId $seriesId", ex) | ||||||
|             false |             false | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @ -458,14 +508,21 @@ object Crunchyroll { | |||||||
|      * @param seriesId The crunchyroll series id of the media to check |      * @param seriesId The crunchyroll series id of the media to check | ||||||
|      */ |      */ | ||||||
|     suspend fun postWatchlist(seriesId: String) { |     suspend fun postWatchlist(seriesId: String) { | ||||||
|         val watchlistPostEndpoint = "/content/v1/watchlist/$accountID" |         val watchlistPostEndpoint = "/content/v2/$accountID/watchlist" | ||||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |         val parameters = listOf( | ||||||
|  |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         val json = buildJsonObject { |         val json = buildJsonObject { | ||||||
|             put("content_id", seriesId) |             put("content_id", seriesId) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         requestPost(watchlistPostEndpoint, parameters, json) |         try { | ||||||
|  |             requestPost(watchlistPostEndpoint, parameters, json) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in postWatchlist() with seriesId $seriesId", ex) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -474,10 +531,17 @@ object Crunchyroll { | |||||||
|      * @param seriesId The crunchyroll series id of the media to check |      * @param seriesId The crunchyroll series id of the media to check | ||||||
|      */ |      */ | ||||||
|     suspend fun deleteWatchlist(seriesId: String) { |     suspend fun deleteWatchlist(seriesId: String) { | ||||||
|         val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId" |         val watchlistDeleteEndpoint = "/content/v2/$accountID/watchlist/$seriesId" | ||||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |         val parameters = listOf( | ||||||
|  |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         requestDelete(watchlistDeleteEndpoint, parameters) |         try { | ||||||
|  |             requestDelete(watchlistDeleteEndpoint, parameters) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in deleteWatchlist() with seriesId $seriesId", ex) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -488,28 +552,88 @@ object Crunchyroll { | |||||||
|      * @param episodeIDs A **[List]** of episodes IDs as strings. |      * @param episodeIDs A **[List]** of episodes IDs as strings. | ||||||
|      * @return A **[Map]**<String, **[PlayheadObject]**> containing playback info. |      * @return A **[Map]**<String, **[PlayheadObject]**> containing playback info. | ||||||
|      */ |      */ | ||||||
|     suspend fun playheads(episodeIDs: List<String>): PlayheadsMap { |     suspend fun playheads(episodeIDs: List<String>): Playheads { | ||||||
|         val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}" |         val playheadsEndpoint = "/content/v2/$accountID/playheads" | ||||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |         val parameters = listOf( | ||||||
|  |             "content_ids" to episodeIDs.joinToString(","), | ||||||
|  |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             requestGet(playheadsEndpoint, parameters) |             requestGet(playheadsEndpoint, parameters) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in upNextSeries().", ex) |             Log.e(TAG, "Exception in playheads().", ex.cause) | ||||||
|             emptyMap() |             NonePlayheads | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Post the playhead to crunchy (playhead position,watched state) | ||||||
|  |      * | ||||||
|  |      * @param episodeId A episode ID as strings. | ||||||
|  |      * @param playhead The episodes playhead in seconds. | ||||||
|  |      */ | ||||||
|     suspend fun postPlayheads(episodeId: String, playhead: Int) { |     suspend fun postPlayheads(episodeId: String, playhead: Int) { | ||||||
|         val playheadsEndpoint = "/content/v1/playheads/$accountID" |         val playheadsEndpoint = "/content/v1/playheads/$accountID" | ||||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |         val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.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) |         try { | ||||||
|  |             requestPost(playheadsEndpoint, parameters, json) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in postPlayheads()", ex.cause) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the intro meta data including start, end and duration of the intro. | ||||||
|  |      * | ||||||
|  |      * @param episodeId A episode ID as strings. | ||||||
|  |      */ | ||||||
|  |     suspend fun datalabIntro(episodeId: String): DatalabIntro { | ||||||
|  |         val datalabIntroEndpoint = "/datalab-intro-v2/$episodeId.json" | ||||||
|  |  | ||||||
|  |         /* | ||||||
|  |          * wtf crunchyroll, why do you return an xml error message when some data is missing, | ||||||
|  |          * this is a json endpoint. For fucks sake, return at least a valid json message. | ||||||
|  |          */ | ||||||
|  |         return try { | ||||||
|  |             val response: HttpResponse = requestGet(datalabIntroEndpoint, url = staticUrl) | ||||||
|  |             Json.decodeFromString(response.bodyAsText()) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in datalabIntro(). EpisodeId=$episodeId", ex) | ||||||
|  |             NoneDatalabIntro | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 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 | ||||||
|  |      * @param ratings add user rating to the objects | ||||||
|  |      * @return A **[SimilarToResult]** object | ||||||
|  |      */ | ||||||
|  |     suspend fun similarTo(seriesId: String, n: Int = 10, ratings: Boolean = false): SimilarToResult { | ||||||
|  |         val similarToEndpoint = "/content/v2/discover/$accountID/similar_to/$seriesId" | ||||||
|  |         val parameters = listOf( | ||||||
|  |             "n" to n, | ||||||
|  |             "ratings" to ratings, | ||||||
|  |             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return try { | ||||||
|  |             requestGet(similarToEndpoint, parameters) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in similarTo().", ex) | ||||||
|  |             NoneSimilarToResult | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -520,44 +644,70 @@ object Crunchyroll { | |||||||
|      * List items present in the watchlist. |      * List items present in the watchlist. | ||||||
|      * |      * | ||||||
|      * @param n Number of items to return, defaults to 20. |      * @param n Number of items to return, defaults to 20. | ||||||
|      * @return A **[Watchlist]** containing up to n **[Item]**. |      * @return A **[Collection]** containing up to n **[Item]**. | ||||||
|      */ |      */ | ||||||
|     suspend fun watchlist(n: Int = 20): Watchlist { |     suspend fun watchlist(n: Int = 20): CollectionV2<Item> { | ||||||
|         val watchlistEndpoint = "/content/v1/$accountID/watchlist" |         val watchlistEndpoint = "/content/v2/discover/$accountID/watchlist" | ||||||
|         val parameters = listOf( |         val parameters = listOf( | ||||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|             "n" to n |             "n" to n, | ||||||
|  |             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag() | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         val list: ContinueWatchingList = try { |         val list: Watchlist = try { | ||||||
|             requestGet(watchlistEndpoint, parameters) |             requestGet(watchlistEndpoint, parameters) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in watchlist().", ex) |             Log.e(TAG, "Exception in watchlist().", ex) | ||||||
|             NoneContinueWatchingList |             NoneWatchlist | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         val objects = list.items.map{ it.panel.episodeMetadata.seriesId } |         val objects = list.data.map{ it.panel.episodeMetadata.seriesId } | ||||||
|         return objects(objects) |         return objects(objects) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * List the next up episodes for the logged in account. |      * List the next up episodes for the logged in account. | ||||||
|      * |      * | ||||||
|      * @param n Number of items to return, defaults to 20. |      * @param n Number of items to return, default = 20 | ||||||
|      * @return A **[ContinueWatchingList]** containing up to n **[ContinueWatchingItem]**. |      * @return A **[HistoryList]** containing up to n **[UpNextAccountItem]**. | ||||||
|      */ |      */ | ||||||
|     suspend fun upNextAccount(n: Int = 20): ContinueWatchingList { |     suspend fun upNextAccount(n: Int = 10): HistoryList { | ||||||
|         val watchlistEndpoint = "/content/v1/$accountID/up_next_account" |         val watchlistEndpoint = "/content/v2/discover/$accountID/history" | ||||||
|         val parameters = listOf( |         val parameters = listOf( | ||||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|             "n" to n |             "n" to n | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             requestGet(watchlistEndpoint, parameters) |             requestGet(watchlistEndpoint, parameters) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in upNextAccount().", ex) |             Log.e(TAG, "Exception in upNextAccount().", ex) | ||||||
|             NoneContinueWatchingList |             NoneHistoryList | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns a collection of recommendations for the currently logged in account. | ||||||
|  |      * | ||||||
|  |      * @param start start of the item list, used for pagination, default = 0 | ||||||
|  |      * @param n number of items to return, default = 10 | ||||||
|  |      * @param ratings add user rating to the objects, default = false | ||||||
|  |      * @return A **[RecommendationsList]** containing up to n **[Item]**. | ||||||
|  |      */ | ||||||
|  |     suspend fun recommendations(start: Int = 0, n: Int = 10, ratings: Boolean = false): RecommendationsList { | ||||||
|  |         val recommendationsEndpoint = "/content/v2/discover/$accountID/recommendations" | ||||||
|  |         val parameters = listOf( | ||||||
|  |             "start" to start, | ||||||
|  |             "n" to n, | ||||||
|  |             "ratings" to ratings, | ||||||
|  |             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return try { | ||||||
|  |             requestGet(recommendationsEndpoint, parameters) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in recommendations().", ex) | ||||||
|  |             NoneRecommendationsList | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -565,18 +715,28 @@ object Crunchyroll { | |||||||
|      * Account/Profile functions |      * Account/Profile functions | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get profile information for the currently logged in account. | ||||||
|  |      * | ||||||
|  |      * @return A **[Profile]** object | ||||||
|  |      */ | ||||||
|     suspend fun profile(): Profile { |     suspend fun profile(): Profile { | ||||||
|         val profileEndpoint = "/accounts/v1/me/profile" |         val profileEndpoint = "/accounts/v1/me/profile" | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             requestGet(profileEndpoint) |             requestGet(profileEndpoint) | ||||||
|         }catch (ex: SerializationException) { |         } catch (ex: Exception) { | ||||||
|             Log.e(TAG, "SerializationException in profile().", ex) |             Log.e(TAG, "Exception in profile().", ex) | ||||||
|             NoneProfile |             NoneProfile | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun postPrefSubLanguage(languageTag: String) { |     /** | ||||||
|  |      * Post the preferred content subtitle language. | ||||||
|  |      * | ||||||
|  |      * @param languageTag the preferred language as language tag | ||||||
|  |      */ | ||||||
|  |     suspend fun setPreferredSubtitleLanguage(languageTag: String) { | ||||||
|         val profileEndpoint = "/accounts/v1/me/profile" |         val profileEndpoint = "/accounts/v1/me/profile" | ||||||
|         val json = buildJsonObject { |         val json = buildJsonObject { | ||||||
|             put("preferred_content_subtitle_language", languageTag) |             put("preferred_content_subtitle_language", languageTag) | ||||||
| @ -585,4 +745,34 @@ object Crunchyroll { | |||||||
|         requestPatch(profileEndpoint, bodyObject = json) |         requestPatch(profileEndpoint, bodyObject = json) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Patch the preferred content audio language. | ||||||
|  |      * | ||||||
|  |      * @param languageTag the preferred language as language tag | ||||||
|  |      */ | ||||||
|  |     suspend fun setPreferredAudioLanguage(languageTag: String) { | ||||||
|  |         val profileEndpoint = "/accounts/v1/me/profile" | ||||||
|  |         val json = buildJsonObject { | ||||||
|  |             put("preferred_content_audio_language", languageTag) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         requestPatch(profileEndpoint, bodyObject = json) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get additional profile (benefits) information for the currently logged in account. | ||||||
|  |      * | ||||||
|  |      * * @return A **[Profile]** object | ||||||
|  |      */ | ||||||
|  |     suspend fun benefits(): Benefits { | ||||||
|  |         val profileEndpoint = "/subs/v1/subscriptions/$externalID/benefits" | ||||||
|  |  | ||||||
|  |         return try { | ||||||
|  |             requestGet(profileEndpoint) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             Log.e(TAG, "Exception in benefits().", ex) | ||||||
|  |             NoneBenefits | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -24,19 +24,47 @@ 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.Locale | ||||||
|  |  | ||||||
| val supportedLocals = listOf( | val supportedAudioLocals = listOf( | ||||||
|     Locale.forLanguageTag("ar-SA"), |     Locale.forLanguageTag("ar-SA"), | ||||||
|  |     Locale.forLanguageTag("ca-ES"), | ||||||
|  |     Locale.forLanguageTag("de-DE"), | ||||||
|  |     Locale.forLanguageTag("en-US"), | ||||||
|  |     Locale.forLanguageTag("en-IN"), | ||||||
|  |     Locale.forLanguageTag("es-419"), | ||||||
|  |     Locale.forLanguageTag("es-ES"), | ||||||
|  |     Locale.forLanguageTag("fr-FR"), | ||||||
|  |     Locale.forLanguageTag("hi-IN"), | ||||||
|  |     Locale.forLanguageTag("it-IT"), | ||||||
|  |     Locale.forLanguageTag("ko-KR"), | ||||||
|  |     Locale.forLanguageTag("pl-PL"), | ||||||
|  |     Locale.forLanguageTag("pt-BR"), | ||||||
|  |     Locale.forLanguageTag("pt-PT"), | ||||||
|  |     Locale.forLanguageTag("ru-RU"), | ||||||
|  |     Locale.forLanguageTag("ta-IN"), | ||||||
|  |     Locale.forLanguageTag("th-TH"), | ||||||
|  |     Locale.forLanguageTag("zh-CN"), | ||||||
|  |     Locale.forLanguageTag("zh-TW"), | ||||||
|  |     Locale.ROOT | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | val supportedSubtitleLocals = listOf( | ||||||
|  |     Locale.forLanguageTag("ar-SA"), | ||||||
|  |     Locale.forLanguageTag("ca-ES"), | ||||||
|     Locale.forLanguageTag("de-DE"), |     Locale.forLanguageTag("de-DE"), | ||||||
|     Locale.forLanguageTag("en-US"), |     Locale.forLanguageTag("en-US"), | ||||||
|     Locale.forLanguageTag("es-419"), |     Locale.forLanguageTag("es-419"), | ||||||
|     Locale.forLanguageTag("es-ES"), |     Locale.forLanguageTag("es-ES"), | ||||||
|     Locale.forLanguageTag("fr-FR"), |     Locale.forLanguageTag("fr-FR"), | ||||||
|  |     Locale.forLanguageTag("hi-IN"), | ||||||
|     Locale.forLanguageTag("it-IT"), |     Locale.forLanguageTag("it-IT"), | ||||||
|  |     Locale.forLanguageTag("ms-MY"), | ||||||
|  |     Locale.forLanguageTag("pl-PL"), | ||||||
|     Locale.forLanguageTag("pt-BR"), |     Locale.forLanguageTag("pt-BR"), | ||||||
|     Locale.forLanguageTag("pt-PT"), |     Locale.forLanguageTag("pt-PT"), | ||||||
|     Locale.forLanguageTag("ru-RU"), |     Locale.forLanguageTag("ru-RU"), | ||||||
|  |     Locale.forLanguageTag("tr-TR"), | ||||||
|     Locale.ROOT |     Locale.ROOT | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @ -44,12 +72,35 @@ val supportedLocals = listOf( | |||||||
|  * 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 | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Enum of all supported sorting orders. | ||||||
|  |  */ | ||||||
| enum class SortBy(val str: String) { | enum class SortBy(val str: String) { | ||||||
|     ALPHABETICAL("alphabetical"), |     ALPHABETICAL("alphabetical"), | ||||||
|     NEWLY_ADDED("newly_added"), |     NEWLY_ADDED("newly_added"), | ||||||
|     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! |  * token, index, account. This must pe present for the app to work! | ||||||
|  */ |  */ | ||||||
| @ -93,31 +144,28 @@ val NoneAccount = Account("", "", false, "") | |||||||
|  */ |  */ | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| data class Collection<T>( | data class CollectionV1<T>( | ||||||
|     @SerialName("total") val total: Int, |     @SerialName("total") val total: Int, | ||||||
|     @SerialName("items") val items: List<T> |     @SerialName("items") val items: List<T> | ||||||
| ) | ) | ||||||
|  |  | ||||||
| typealias SearchResult = Collection<SearchCollection> |  | ||||||
| typealias SearchCollection = Collection<Item> |  | ||||||
| typealias BrowseResult = Collection<Item> |  | ||||||
| typealias DiscSeasonList = Collection<SeasonListItem> |  | ||||||
| typealias Watchlist = Collection<Item> |  | ||||||
| typealias ContinueWatchingList = Collection<ContinueWatchingItem> |  | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| data class UpNextSeriesItem( | data class CollectionV2<T>( | ||||||
|     @SerialName("playhead") val playhead: Int, |     @SerialName("total") val total: Int, | ||||||
|     @SerialName("fully_watched") val fullyWatched: Boolean, |     @SerialName("data") val data: List<T> | ||||||
|     @SerialName("never_watched") val neverWatched: Boolean, |  | ||||||
|     @SerialName("panel") val panel: EpisodePanel, |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | typealias SearchResult = CollectionV2<SearchTypedList<Item>> | ||||||
|  | typealias BrowseResult = CollectionV2<Item> | ||||||
|  | typealias SimilarToResult = CollectionV2<Item> | ||||||
|  | typealias RecommendationsList = CollectionV2<Item> | ||||||
|  | typealias Benefits = CollectionV1<Benefit> | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * 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( | ||||||
| @ -128,6 +176,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 | ||||||
| @ -138,38 +187,48 @@ data class Images(val poster_tall: List<List<Poster>>, val poster_wide: List<Lis | |||||||
| data class Poster(val height: Int, val width: Int, val source: String, val type: String) | data class Poster(val height: Int, val width: Int, val source: String, val type: String) | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * season list data classes |  * up next & watchlist data classes | ||||||
|  */ |  */ | ||||||
| @Serializable |  | ||||||
| data class SeasonListItem( | typealias Watchlist = CollectionV2<WatchlistItem> | ||||||
|     @SerialName("id") val id: String, | typealias HistoryList = CollectionV2<UpNextAccountItem> | ||||||
|     @SerialName("localization") val localization: SeasonListLocalization | typealias UpNextSeriesList = CollectionV2<UpNextSeriesItem> | ||||||
| ) |  | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| data class SeasonListLocalization( | data class WatchlistItem( | ||||||
|     @SerialName("title") val title: String, |     @SerialName("panel") val panel: EpisodePanel, | ||||||
|     @SerialName("description") val description: String, |     @SerialName("new") val new: Boolean, | ||||||
| ) |     @SerialName("playhead") val playhead: Int, | ||||||
|  |     @SerialName("fully_watched") val fullyWatched: Boolean = false, | ||||||
| /** |     @SerialName("never_watched") val neverWatched: Boolean = false, | ||||||
|  * continue_watching_item data classes |     @SerialName("is_favorite") val isFavorite: Boolean, | ||||||
|  */ | ) | ||||||
| @Serializable |  | ||||||
| data class ContinueWatchingItem( | @Serializable | ||||||
|  | data class IsWatchlistItem( | ||||||
|  |     @SerialName("id") val id: String, | ||||||
|  |     @SerialName("is_favorite") val isFavorite: Boolean, | ||||||
|  |     @SerialName("date_added") val dateAdded: String | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
|  | data class UpNextAccountItem( | ||||||
|     @SerialName("panel") val panel: EpisodePanel, |     @SerialName("panel") val panel: EpisodePanel, | ||||||
|     @SerialName("new") val new: Boolean, |     @SerialName("new") val new: Boolean, | ||||||
|     @SerialName("new_content") val newContent: Boolean, |  | ||||||
|     // not present in up_next_account -> continue_watching_item |  | ||||||
| //    @SerialName("is_favorite") val isFavorite: Boolean, |  | ||||||
| //    @SerialName("never_watched") val neverWatched: Boolean, |  | ||||||
| //    @SerialName("completion_status") val completionStatus: Boolean, |  | ||||||
|     @SerialName("playhead") val playhead: Int, |     @SerialName("playhead") val playhead: Int, | ||||||
|     // not present in watchlist -> continue_watching_item |  | ||||||
|     @SerialName("fully_watched") val fullyWatched: Boolean = false, |     @SerialName("fully_watched") val fullyWatched: Boolean = false, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // EpisodePanel is used in ContinueWatchingItem | @Serializable | ||||||
|  | data class UpNextSeriesItem( | ||||||
|  |     @SerialName("panel") val panel: EpisodePanel, | ||||||
|  |     @SerialName("playhead") val playhead: Int, | ||||||
|  |     @SerialName("fully_watched") val fullyWatched: Boolean, | ||||||
|  |     @SerialName("never_watched") val neverWatched: Boolean, | ||||||
|  |  | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // 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, | ||||||
| @ -179,60 +238,59 @@ data class EpisodePanel( | |||||||
|     @SerialName("description") val description: String, |     @SerialName("description") val description: String, | ||||||
|     @SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata, |     @SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata, | ||||||
|     @SerialName("images") val images: Thumbnail, |     @SerialName("images") val images: Thumbnail, | ||||||
|     @SerialName("playback") val playback: String, | //    @SerialName("streams_link") val streamsLink: String, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @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 NoneCollectionV2 = CollectionV2<Item>(0, emptyList()) | ||||||
| val NoneEpisodeMetadata = EpisodeMetadata(0, "", "", "") |  | ||||||
| val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "") |  | ||||||
|  |  | ||||||
| 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 NoneDiscSeasonList = DiscSeasonList(0, emptyList()) | val NoneSimilarToResult = SimilarToResult(0, emptyList()) | ||||||
| val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) | val NoneWatchlist = Watchlist(0, emptyList()) | ||||||
|  | val NoneHistoryList = HistoryList(0, emptyList()) | ||||||
| val NoneUpNextSeriesItem = UpNextSeriesItem(0, false, false, NoneEpisodePanel) | val NoneUpNextSeriesList = UpNextSeriesList(0, emptyList()) | ||||||
|  | val NoneRecommendationsList = RecommendationsList(0, emptyList()) | ||||||
|  | val NoneBenefits = Benefits(0, emptyList()) | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Series data type |  * series data class | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
|  | typealias Series = CollectionV2<SeriesItem> | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| data class Series( | data class SeriesItem( | ||||||
|     @SerialName("id") val id: String, |     @SerialName("id") val id: String, | ||||||
|     @SerialName("title") val title: String, |     @SerialName("title") val title: String, | ||||||
|     @SerialName("description") val description: String, |     @SerialName("description") val description: String, | ||||||
|     @SerialName("images") val images: Images, |     @SerialName("images") val images: Images, | ||||||
|     @SerialName("maturity_ratings") val maturityRatings: List<String> |     @SerialName("is_simulcast") val isSimulcast: Boolean, | ||||||
|  |     @SerialName("maturity_ratings") val maturityRatings: List<String>, | ||||||
|  |     @SerialName("audio_locales") val audioLocales: List<String>, | ||||||
|  |     @SerialName("episode_count") val episodeCount: Int | ||||||
| ) | ) | ||||||
| val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList()) |  | ||||||
|  | val NoneSeriesItem = SeriesItem("", "", "", Images(emptyList(), emptyList()), false, emptyList(), emptyList(), 0) | ||||||
|  | val NoneSeries = Series(1, listOf(NoneSeriesItem)) | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Seasons data type |  * Seasons data classes | ||||||
|  */ |  */ | ||||||
| @Serializable | @Serializable | ||||||
| data class Seasons( | data class Seasons( | ||||||
|     @SerialName("total") val total: Int, |     @SerialName("total") val total: Int, | ||||||
|     @SerialName("items") val items: List<Season> |     @SerialName("data") val data: List<Season> | ||||||
| ) { | ) | ||||||
|     fun getPreferredSeason(local: Locale): Season { |  | ||||||
|         return items.firstOrNull { season -> |  | ||||||
|             // try to get the the first seasons which matches the preferred local |  | ||||||
|             season.slugTitle.endsWith("${local.getDisplayLanguage(Locale.ENGLISH)}-dub", true) |  | ||||||
|         } ?: items.firstOrNull { season -> |  | ||||||
|             // if there is no season with the preferred local, try to find a subbed season |  | ||||||
|             season.isSubbed |  | ||||||
|         } ?: items.first() // if no preferred language and no sub, use the first season |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| data class Season( | data class Season( | ||||||
| @ -250,12 +308,12 @@ val NoneSeason = Season("", "", "", "", 0, isSubbed = false, isDubbed = false) | |||||||
|  |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Episodes data type |  * Episodes data classes | ||||||
|  */ |  */ | ||||||
| @Serializable | @Serializable | ||||||
| data class Episodes( | data class Episodes( | ||||||
|     @SerialName("total") val total: Int, |     @SerialName("total") val total: Int, | ||||||
|     @SerialName("items") val items: List<Episode> |     @SerialName("data") val data: List<Episode> | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| @ -275,7 +333,8 @@ data class Episode( | |||||||
|     @SerialName("is_dubbed") val isDubbed: Boolean, |     @SerialName("is_dubbed") val isDubbed: Boolean, | ||||||
|     @SerialName("images") val images: Thumbnail, |     @SerialName("images") val images: Thumbnail, | ||||||
|     @SerialName("duration_ms") val durationMs: Int, |     @SerialName("duration_ms") val durationMs: Int, | ||||||
|     @SerialName("playback") val playback: String, |     @SerialName("versions") val versions: List<Version>? = null, | ||||||
|  |     @SerialName("streams_link") val streamsLink: String, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| @ -283,6 +342,17 @@ data class Thumbnail( | |||||||
|     @SerialName("thumbnail") val thumbnail: List<List<Poster>> |     @SerialName("thumbnail") val thumbnail: List<List<Poster>> | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
|  | data class Version( | ||||||
|  |     @SerialName("audio_locale") val audioLocale: String, | ||||||
|  |     @SerialName("guid") val guid: String, | ||||||
|  |     @SerialName("is_premium_only") val isPremiumOnly: Boolean, | ||||||
|  |     @SerialName("media_guid") val mediaGUID: String, | ||||||
|  |     @SerialName("original") val original: Boolean, | ||||||
|  |     @SerialName("season_guid") val seasonGUID: String, | ||||||
|  |     @SerialName("variant") val variant: String, | ||||||
|  | ) | ||||||
|  |  | ||||||
| val NoneEpisodes = Episodes(0, listOf()) | val NoneEpisodes = Episodes(0, listOf()) | ||||||
| val NoneEpisode = Episode( | val NoneEpisode = Episode( | ||||||
|     id = "", |     id = "", | ||||||
| @ -300,10 +370,21 @@ val NoneEpisode = Episode( | |||||||
|     isDubbed = false, |     isDubbed = false, | ||||||
|     images = Thumbnail(listOf()), |     images = Thumbnail(listOf()), | ||||||
|     durationMs = 0, |     durationMs = 0, | ||||||
|     playback = "" |     versions = emptyList(), | ||||||
|  |     streamsLink = "" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| typealias PlayheadsMap = Map<String, PlayheadObject> | val NoneVersion = Version( | ||||||
|  |     audioLocale = "", | ||||||
|  |     guid = "", | ||||||
|  |     isPremiumOnly = false, | ||||||
|  |     mediaGUID = "", | ||||||
|  |     original = true, | ||||||
|  |     seasonGUID = "", | ||||||
|  |     variant = "" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | typealias Playheads = CollectionV2<PlayheadObject> | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| data class PlayheadObject( | data class PlayheadObject( | ||||||
| @ -313,60 +394,72 @@ data class PlayheadObject( | |||||||
|     @SerialName("last_modified") val lastModified: String, |     @SerialName("last_modified") val lastModified: String, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | val NonePlayheads = Playheads(0, emptyList()) | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Playback/stream data type |  * Meta data for a episode intro. All time values are in seconds. | ||||||
|  */ |  */ | ||||||
| @Serializable | @Serializable | ||||||
| data class Playback( | data class DatalabIntro( | ||||||
|     @SerialName("audio_locale") val audioLocale: String, |     @SerialName("media_id") val mediaId: String, | ||||||
|     @SerialName("subtitles") val subtitles: Map<String, Subtitle>, |     @SerialName("startTime") val startTime: Float, | ||||||
|     @SerialName("streams") val streams: Streams, |     @SerialName("endTime") val endTime: Float, | ||||||
|  |     @SerialName("duration") val duration: Float, | ||||||
|  |     @SerialName("comparedWith") val comparedWith: String, | ||||||
|  |     @SerialName("ordering") val ordering: String, | ||||||
|  |     @SerialName("last_updated") val lastUpdated: String, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @Serializable | val NoneDatalabIntro = DatalabIntro("", 0f, 0f, 0f, "", "", "") | ||||||
| data class Subtitle( |  | ||||||
|     @SerialName("locale") val locale: String, |  | ||||||
|     @SerialName("url") val url: String, |  | ||||||
|     @SerialName("format") val format: String, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * playback/stream data classes | ||||||
|  |  */ | ||||||
| @Serializable | @Serializable | ||||||
| data class Streams( | data class Streams( | ||||||
|  |     @SerialName("total") val total: Int, | ||||||
|  |     @SerialName("data") val data: List<StreamList>, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
|  | data class StreamList( | ||||||
|     @SerialName("adaptive_dash") val adaptive_dash: Map<String, Stream>, |     @SerialName("adaptive_dash") val adaptive_dash: Map<String, Stream>, | ||||||
|     @SerialName("adaptive_hls") val adaptive_hls: Map<String, Stream>, |     @SerialName("adaptive_hls") val adaptive_hls: Map<String, Stream>, | ||||||
|  |     @SerialName("download_dash") val downloadDash: Map<String, Stream>, | ||||||
|     @SerialName("download_hls") val download_hls: Map<String, Stream>, |     @SerialName("download_hls") val download_hls: Map<String, Stream>, | ||||||
|     @SerialName("drm_adaptive_dash") val drm_adaptive_dash: Map<String, Stream>, | //    @SerialName("drm_adaptive_dash") val drmAdaptiveDash: Map<String, Stream>, | ||||||
|     @SerialName("drm_adaptive_hls") val drm_adaptive_hls: Map<String, Stream>, | //    @SerialName("drm_adaptive_hls") val drmAdaptiveHls: Map<String, Stream>, | ||||||
|     @SerialName("drm_download_hls") val drm_download_hls: Map<String, Stream>, | //    @SerialName("drm_download_dash") val drmDownloadDash: Map<String, Stream>, | ||||||
|     @SerialName("trailer_dash") val trailer_dash: Map<String, Stream>, | //    @SerialName("drm_download_hls") val drmDownloadHls: Map<String, Stream>, | ||||||
|     @SerialName("trailer_hls") val trailer_hls: Map<String, Stream>, | //    @SerialName("vo_adaptive_dash") val vo_adaptive_dash: Map<String, Stream>, | ||||||
|     @SerialName("vo_adaptive_dash") val vo_adaptive_dash: Map<String, Stream>, | //    @SerialName("vo_adaptive_hls") val vo_adaptive_hls: Map<String, Stream>, | ||||||
|     @SerialName("vo_adaptive_hls") val vo_adaptive_hls: Map<String, Stream>, | //    @SerialName("vo_drm_adaptive_dash") val vo_drm_adaptive_dash: Map<String, Stream>, | ||||||
|     @SerialName("vo_drm_adaptive_dash") val vo_drm_adaptive_dash: Map<String, Stream>, | //    @SerialName("vo_drm_adaptive_hls") val vo_drm_adaptive_hls: Map<String, Stream>, | ||||||
|     @SerialName("vo_drm_adaptive_hls") val vo_drm_adaptive_hls: Map<String, Stream>, |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| data class Stream( | data class Stream( | ||||||
|     @SerialName("hardsub_locale") val hardsubLocale: String, |     @SerialName("hardsub_locale") val hardsubLocale: String = "", // default/nullable value since might be optional | ||||||
|     @SerialName("url") val url: String, |     @SerialName("url") val url: String = "", // default/nullable value since optional | ||||||
|     @SerialName("vcodec") val vcodec: String, |     @SerialName("vcodec") val vcodec: String = "", // default/nullable value since optional | ||||||
| ) | ) | ||||||
|  |  | ||||||
| val NonePlayback = Playback( | val NoneStreams = Streams( | ||||||
|     "", |     0, | ||||||
|     mapOf(), |     arrayListOf(StreamList( | ||||||
|     Streams( |         mapOf(), mapOf(), mapOf(), mapOf() | ||||||
|         mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), |     )) | ||||||
|         mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), |  | ||||||
|     ) |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * profile data class | ||||||
|  |  */ | ||||||
| @Serializable | @Serializable | ||||||
| data class Profile( | data class Profile( | ||||||
|     @SerialName("avatar") val avatar: String, |     @SerialName("avatar") val avatar: String, | ||||||
|     @SerialName("email") val email: String, |     @SerialName("email") val email: String, | ||||||
|     @SerialName("maturity_rating") val maturityRating: String, |     @SerialName("maturity_rating") val maturityRating: String, | ||||||
|  |     @SerialName("preferred_content_audio_language") val preferredContentAudioLanguage: String, | ||||||
|     @SerialName("preferred_content_subtitle_language") val preferredContentSubtitleLanguage: String, |     @SerialName("preferred_content_subtitle_language") val preferredContentSubtitleLanguage: String, | ||||||
|     @SerialName("username") val username: String, |     @SerialName("username") val username: String, | ||||||
| ) | ) | ||||||
| @ -374,6 +467,31 @@ val NoneProfile = Profile( | |||||||
|     avatar = "", |     avatar = "", | ||||||
|     email = "", |     email = "", | ||||||
|     maturityRating = "", |     maturityRating = "", | ||||||
|  |     preferredContentAudioLanguage = "", | ||||||
|     preferredContentSubtitleLanguage = "", |     preferredContentSubtitleLanguage = "", | ||||||
|     username = "" |     username = "" | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * benefit data class | ||||||
|  |  */ | ||||||
|  | @Serializable | ||||||
|  | data class Benefit( | ||||||
|  |     @SerialName("benefit") val benefit: String, | ||||||
|  |     @SerialName("source") val source: String, | ||||||
|  | ) | ||||||
|  | @Suppress("unused") | ||||||
|  | val NoneBenefit = Benefit( | ||||||
|  |     benefit = "", | ||||||
|  |     source = "" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * search result typed list data class | ||||||
|  |  */ | ||||||
|  | @Serializable | ||||||
|  | data class SearchTypedList<T>( | ||||||
|  |     @SerialName("type") val type: String, | ||||||
|  |     @SerialName("count") val count: Int, | ||||||
|  |     @SerialName("items") val items: List<T> | ||||||
|  | ) | ||||||
|  | |||||||
| @ -8,15 +8,19 @@ import java.util.* | |||||||
|  |  | ||||||
| object Preferences { | object Preferences { | ||||||
|  |  | ||||||
|     var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start |     var preferredAudioLocale: Locale = Locale.forLanguageTag("en-US") | ||||||
|         internal set |         internal set | ||||||
|     var preferSubbed = false |     var preferredSubtitleLocale: Locale = Locale.forLanguageTag("en-US") | ||||||
|         internal set |         internal set | ||||||
|     var autoplay = true |     var autoplay = true | ||||||
|         internal set |         internal set | ||||||
|     var devSettings = false |     var devSettings = false | ||||||
|         internal set |         internal set | ||||||
|     var theme = DataTypes.Theme.DARK |     var theme = DataTypes.Theme.SYSTEM | ||||||
|  |         internal set | ||||||
|  |  | ||||||
|  |     // dev settings | ||||||
|  |     var updatePlayhead = true | ||||||
|         internal set |         internal set | ||||||
|  |  | ||||||
|     private fun getSharedPref(context: Context): SharedPreferences { |     private fun getSharedPref(context: Context): SharedPreferences { | ||||||
| @ -26,22 +30,22 @@ object Preferences { | |||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun savePreferredLocal(context: Context, preferredLocale: Locale) { |     fun savePreferredAudioLocal(context: Context, preferredLocale: Locale) { | ||||||
|         with(getSharedPref(context).edit()) { |         with(getSharedPref(context).edit()) { | ||||||
|             putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag()) |             putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag()) | ||||||
|             apply() |             apply() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         this.preferredLocale = preferredLocale |         this.preferredAudioLocale = preferredLocale | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun savePreferSecondary(context: Context, preferSubbed: Boolean) { |     fun savePreferredSubtitleLocal(context: Context, preferredLocale: Locale) { | ||||||
|         with(getSharedPref(context).edit()) { |         with(getSharedPref(context).edit()) { | ||||||
|             putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSubbed) |             putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag()) | ||||||
|             apply() |             apply() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         this.preferSubbed = preferSubbed |         this.preferredSubtitleLocale = preferredLocale | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun saveAutoplay(context: Context, autoplay: Boolean) { |     fun saveAutoplay(context: Context, autoplay: Boolean) { | ||||||
| @ -71,20 +75,31 @@ object Preferences { | |||||||
|         this.theme = theme |         this.theme = theme | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun saveUpdatePlayhead(context: Context, updatePlayhead: Boolean) { | ||||||
|  |         with(getSharedPref(context).edit()) { | ||||||
|  |             putBoolean(context.getString(R.string.save_key_update_playhead), updatePlayhead) | ||||||
|  |             apply() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         this.updatePlayhead = updatePlayhead | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * initially load the stored values |      * initially load the stored values | ||||||
|      */ |      */ | ||||||
|     fun load(context: Context) { |     fun load(context: Context) { | ||||||
|         val sharedPref = getSharedPref(context) |         val sharedPref = getSharedPref(context) | ||||||
|  |  | ||||||
|         preferredLocale = Locale.forLanguageTag( |         preferredAudioLocale = Locale.forLanguageTag( | ||||||
|  |             sharedPref.getString( | ||||||
|  |                 context.getString(R.string.save_key_preferred_audio_local), "en-US" | ||||||
|  |             ) ?: "en-US" | ||||||
|  |         ) | ||||||
|  |         preferredSubtitleLocale = Locale.forLanguageTag( | ||||||
|             sharedPref.getString( |             sharedPref.getString( | ||||||
|                 context.getString(R.string.save_key_preferred_local), "en-US" |                 context.getString(R.string.save_key_preferred_local), "en-US" | ||||||
|             ) ?: "en-US" |             ) ?: "en-US" | ||||||
|         ) |         ) | ||||||
|         preferSubbed = sharedPref.getBoolean( |  | ||||||
|             context.getString(R.string.save_key_prefer_secondary), false |  | ||||||
|         ) |  | ||||||
|         autoplay = sharedPref.getBoolean( |         autoplay = sharedPref.getBoolean( | ||||||
|             context.getString(R.string.save_key_autoplay), true |             context.getString(R.string.save_key_autoplay), true | ||||||
|         ) |         ) | ||||||
| @ -93,8 +108,13 @@ object Preferences { | |||||||
|         ) |         ) | ||||||
|         theme = DataTypes.Theme.valueOf( |         theme = DataTypes.Theme.valueOf( | ||||||
|             sharedPref.getString( |             sharedPref.getString( | ||||||
|                 context.getString(R.string.save_key_theme), DataTypes.Theme.DARK.toString() |                 context.getString(R.string.save_key_theme), DataTypes.Theme.SYSTEM.toString() | ||||||
|             ) ?:  DataTypes.Theme.DARK.toString() |             ) ?:  DataTypes.Theme.SYSTEM.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() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @ -26,7 +26,10 @@ import android.content.Intent | |||||||
| import android.os.Bundle | import android.os.Bundle | ||||||
| import android.util.Log | import android.util.Log | ||||||
| import android.view.MenuItem | import android.view.MenuItem | ||||||
|  | import androidx.activity.addCallback | ||||||
| import androidx.appcompat.app.AppCompatActivity | import androidx.appcompat.app.AppCompatActivity | ||||||
|  | import androidx.appcompat.app.AppCompatDelegate | ||||||
|  | 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 | ||||||
| @ -38,12 +41,11 @@ 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.fragments.AccountFragment | import org.mosad.teapod.ui.activity.main.fragments.AccountFragment | ||||||
| import org.mosad.teapod.ui.activity.main.fragments.HomeFragment | import org.mosad.teapod.ui.activity.main.fragments.HomeFragment | ||||||
|  | import org.mosad.teapod.ui.activity.main.fragments.MyListsFragment | ||||||
| import org.mosad.teapod.ui.activity.main.fragments.LibraryFragment | import org.mosad.teapod.ui.activity.main.fragments.LibraryFragment | ||||||
| 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.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 java.util.* | ||||||
| import kotlin.system.measureTimeMillis | import kotlin.system.measureTimeMillis | ||||||
|  |  | ||||||
| @ -62,10 +64,20 @@ 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 | ||||||
|         theme.applyStyle(getThemeResource(), true) |  | ||||||
|  |         // theming | ||||||
|  |         val mode = when (Preferences.theme) { | ||||||
|  |             DataTypes.Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO | ||||||
|  |             DataTypes.Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES | ||||||
|  |             else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM | ||||||
|  |         } | ||||||
|  |         AppCompatDelegate.setDefaultNightMode(mode) | ||||||
|  |  | ||||||
|         binding = ActivityMainBinding.inflate(layoutInflater) |         binding = ActivityMainBinding.inflate(layoutInflater) | ||||||
|         binding.navView.setOnItemSelectedListener(this) |         binding.navView.setOnItemSelectedListener(this) | ||||||
| @ -74,16 +86,14 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | |||||||
|         supportFragmentManager.commit { |         supportFragmentManager.commit { | ||||||
|             replace(R.id.nav_host_fragment, activeBaseFragment, activeBaseFragment.javaClass.simpleName) |             replace(R.id.nav_host_fragment, activeBaseFragment, activeBaseFragment.javaClass.simpleName) | ||||||
|         } |         } | ||||||
|     } |  | ||||||
|  |  | ||||||
|     override fun onBackPressed() { |         onBackPressedDispatcher.addCallback { | ||||||
|         if (supportFragmentManager.backStackEntryCount > 0) { |             if (supportFragmentManager.backStackEntryCount > 0) { | ||||||
|             supportFragmentManager.popBackStack() |                 supportFragmentManager.popBackStack() | ||||||
|         } else { |  | ||||||
|             if (activeBaseFragment !is HomeFragment) { |  | ||||||
|                 binding.navView.selectedItemId = R.id.navigation_home |  | ||||||
|             } else { |             } else { | ||||||
|                 super.onBackPressed() |                 if (activeBaseFragment !is HomeFragment) { | ||||||
|  |                     binding.navView.selectedItemId = R.id.navigation_home | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @ -98,12 +108,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | |||||||
|                 activeBaseFragment = HomeFragment() |                 activeBaseFragment = HomeFragment() | ||||||
|                 true |                 true | ||||||
|             } |             } | ||||||
|             R.id.navigation_library -> { |             R.id.navigation_my_lists -> { | ||||||
|                 activeBaseFragment = LibraryFragment() |                 activeBaseFragment = MyListsFragment() | ||||||
|                 true |                 true | ||||||
|             } |             } | ||||||
|             R.id.navigation_search -> { |             R.id.navigation_library -> { | ||||||
|                 activeBaseFragment = SearchFragment() |                 activeBaseFragment = LibraryFragment() | ||||||
|                 true |                 true | ||||||
|             } |             } | ||||||
|             R.id.navigation_account -> { |             R.id.navigation_account -> { | ||||||
| @ -120,12 +130,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | |||||||
|         return ret |         return ret | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun getThemeResource(): Int { | //    private fun getThemeResource(): Int { | ||||||
|         return when (Preferences.theme) { | //        return when (Preferences.theme) { | ||||||
|             DataTypes.Theme.LIGHT -> R.style.AppTheme_Light | //            DataTypes.Theme.LIGHT -> R.style.AppTheme_Light | ||||||
|             else -> R.style.AppTheme_Dark | //            else -> R.style.AppTheme_Dark | ||||||
|         } | //        } | ||||||
|     } | //    } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * initial loading and login are run in parallel, as initial loading doesn't require |      * initial loading and login are run in parallel, as initial loading doesn't require | ||||||
| @ -137,6 +147,9 @@ 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 |             // always initialize the api token | ||||||
|             Crunchyroll.initBasicApiToken() |             Crunchyroll.initBasicApiToken() | ||||||
|  |  | ||||||
| @ -148,39 +161,34 @@ 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(classTag, "loading in $time ms") |         Log.i(classTag, "loading in $time ms") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun initCrunchyroll(): List<Job> { |     private fun initCrunchyroll(): List<Job> { | ||||||
|         val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) |         val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialCrunchyLoading")) | ||||||
|         return listOf( |         return listOf( | ||||||
|             scope.launch { Crunchyroll.index() }, |  | ||||||
|             scope.launch { Crunchyroll.account() }, |             scope.launch { Crunchyroll.account() }, | ||||||
|             scope.launch { |             scope.launch { | ||||||
|                 // update the local preferred content language, since it may have changed |                 // update the local preferred content language, since it may have changed | ||||||
|                 val locale = Locale.forLanguageTag(Crunchyroll.profile().preferredContentSubtitleLanguage) |                 val profile = Crunchyroll.profile() | ||||||
|                 Preferences.savePreferredLocal(this@MainActivity, locale) |  | ||||||
|  |  | ||||||
|  |                 val audioLocale = Locale.forLanguageTag(profile.preferredContentAudioLanguage) | ||||||
|  |                 val subtitleLocale = Locale.forLanguageTag(profile.preferredContentSubtitleLanguage) | ||||||
|  |                 Preferences.savePreferredAudioLocal(this@MainActivity, audioLocale) | ||||||
|  |                 Preferences.savePreferredSubtitleLocal(this@MainActivity, subtitleLocale) | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     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(classTag, "Login canceled, exiting.") |  | ||||||
|             finish() |  | ||||||
|         }.show() |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -191,17 +199,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | |||||||
|         finish() |         finish() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * start the player as new activity |  | ||||||
|      */ |  | ||||||
|     fun startPlayer(seasonId: String, episodeId: String) { |  | ||||||
|         val intent = Intent(this, PlayerActivity::class.java).apply { |  | ||||||
|             putExtra(getString(R.string.intent_season_id), seasonId) |  | ||||||
|             putExtra(getString(R.string.intent_episode_id), episodeId) |  | ||||||
|         } |  | ||||||
|         startActivity(intent) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * use custom restart instead of recreate(), since it has animations |      * use custom restart instead of recreate(), since it has animations | ||||||
|      */ |      */ | ||||||
|  | |||||||
| @ -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", |  | ||||||
|                 "https://github.com/afollestad/material-dialogs", License.APACHE2), |  | ||||||
|             ThirdPartyComponent("Ktor", "2014-2021", "JetBrains s.r.o and contributors", |             ThirdPartyComponent("Ktor", "2014-2021", "JetBrains s.r.o and contributors", | ||||||
|                 "https://ktor.io/", License.APACHE2), |                 "https://ktor.io/", License.APACHE2), | ||||||
|             ThirdPartyComponent("kotlinx.coroutines", "2016-2021", "JetBrains s.r.o", |             ThirdPartyComponent("kotlinx.coroutines", "2016-2021", "JetBrains s.r.o", | ||||||
|                 "https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2), |                 "https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2), | ||||||
|  |             ThirdPartyComponent(" 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", | ||||||
|  | |||||||
| @ -1,30 +1,26 @@ | |||||||
| 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 androidx.activity.result.contract.ActivityResultContracts |  | ||||||
| import androidx.core.view.isVisible | import androidx.core.view.isVisible | ||||||
| import androidx.fragment.app.Fragment | import androidx.fragment.app.Fragment | ||||||
| import androidx.lifecycle.lifecycleScope | import androidx.lifecycle.lifecycleScope | ||||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||||
| import kotlinx.coroutines.Deferred | import kotlinx.coroutines.* | ||||||
| import kotlinx.coroutines.async |  | ||||||
| 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.Benefits | ||||||
| import org.mosad.teapod.parser.crunchyroll.Crunchyroll | import org.mosad.teapod.parser.crunchyroll.Crunchyroll | ||||||
| import org.mosad.teapod.parser.crunchyroll.Profile | import org.mosad.teapod.parser.crunchyroll.Profile | ||||||
| import org.mosad.teapod.parser.crunchyroll.supportedLocals | import org.mosad.teapod.parser.crunchyroll.supportedAudioLocals | ||||||
|  | import org.mosad.teapod.parser.crunchyroll.supportedSubtitleLocals | ||||||
| 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 org.mosad.teapod.util.toDisplayString | ||||||
| @ -36,27 +32,8 @@ class AccountFragment : Fragment() { | |||||||
|     private var profile: Deferred<Profile> = lifecycleScope.async { |     private var profile: Deferred<Profile> = lifecycleScope.async { | ||||||
|         Crunchyroll.profile() |         Crunchyroll.profile() | ||||||
|     } |     } | ||||||
|  |     private var benefits: Deferred<Benefits> = lifecycleScope.async { | ||||||
|     private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> |         Crunchyroll.benefits() | ||||||
|         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 { | ||||||
| @ -69,30 +46,38 @@ class AccountFragment : Fragment() { | |||||||
|  |  | ||||||
|         binding.textAccountLogin.text = EncryptedPreferences.login |         binding.textAccountLogin.text = EncryptedPreferences.login | ||||||
|  |  | ||||||
|         // TODO reimplement for cr, if possible (maybe account status would be better? (premium)) |         // load account status and tier (async) info before anything else | ||||||
|         // load subscription (async) info before anything else |  | ||||||
|         binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading)) |  | ||||||
|         lifecycleScope.launch { |         lifecycleScope.launch { | ||||||
|             binding.textAccountSubscription.text = getString( |             benefits.await().apply { | ||||||
|                 R.string.account_subscription, |                 this.items.firstOrNull { it.benefit == "cr_premium" }?.let { | ||||||
|                 "TODO" |                     binding.textAccountSubscription.text = getString(R.string.account_premium) | ||||||
|             ) |                 } | ||||||
|  |  | ||||||
|  |                 this.items.firstOrNull { it.benefit == "cr_fan_pack" }?.let { | ||||||
|  |                     binding.textAccountSubscriptionDesc.text = | ||||||
|  |                         getString(R.string.account_tier, getString(R.string.account_tier_mega_fan)) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // add preferred subtitles |         // add preferred subtitles | ||||||
|         lifecycleScope.launch { |         lifecycleScope.launch { | ||||||
|             binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag( |             binding.textSettingsAudioLanguageDesc.text = Locale.forLanguageTag( | ||||||
|  |                 profile.await().preferredContentAudioLanguage | ||||||
|  |             ).displayLanguage | ||||||
|  |             binding.textSettingsSubtitleLanguageDesc.text = Locale.forLanguageTag( | ||||||
|                 profile.await().preferredContentSubtitleLanguage |                 profile.await().preferredContentSubtitleLanguage | ||||||
|             ).displayLanguage |             ).displayLanguage | ||||||
|         } |         } | ||||||
|         binding.switchSecondary.isChecked = Preferences.preferSubbed |  | ||||||
|         binding.switchAutoplay.isChecked = Preferences.autoplay |         binding.switchAutoplay.isChecked = Preferences.autoplay | ||||||
|         binding.textThemeSelected.text = when (Preferences.theme) { |         binding.textThemeSelected.text = when (Preferences.theme) { | ||||||
|  |             Theme.SYSTEM -> getString(R.string.theme_system) | ||||||
|  |             Theme.LIGHT -> getString(R.string.theme_light) | ||||||
|             Theme.DARK -> getString(R.string.theme_dark) |             Theme.DARK -> getString(R.string.theme_dark) | ||||||
|             else -> getString(R.string.theme_light) |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         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)) |         binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time)) | ||||||
|  |  | ||||||
| @ -101,21 +86,15 @@ class AccountFragment : Fragment() { | |||||||
|  |  | ||||||
|     private fun initActions() { |     private fun initActions() { | ||||||
|         binding.linearAccountLogin.setOnClickListener { |         binding.linearAccountLogin.setOnClickListener { | ||||||
|             showLoginDialog(true) |             showLoginDialog() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         binding.linearAccountSubscription.setOnClickListener { |         binding.linearSettingsAudioLanguage.setOnClickListener { | ||||||
|             // TODO |             showAudioLanguageSelection() | ||||||
|             //startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl()))) |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         binding.linearSettingsSubtitleLanguage.setOnClickListener { | ||||||
|         binding.linearSettingsContentLanguage.setOnClickListener { |             showSubtitleLanguageSelection() | ||||||
|             showContentLanguageSelection() |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         binding.switchSecondary.setOnClickListener { |  | ||||||
|             Preferences.savePreferSecondary(requireContext(), binding.switchSecondary.isChecked) |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         binding.switchAutoplay.setOnClickListener { |         binding.switchAutoplay.setOnClickListener { | ||||||
| @ -130,76 +109,116 @@ class AccountFragment : Fragment() { | |||||||
|             activity?.showFragment(AboutFragment()) |             activity?.showFragment(AboutFragment()) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         binding.switchUpdatePlayhead.setOnClickListener { | ||||||
|  |             Preferences.saveUpdatePlayhead(requireContext(), binding.switchUpdatePlayhead.isChecked) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         binding.linearExportData.setOnClickListener { |         binding.linearExportData.setOnClickListener { | ||||||
|             val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { |             // unused | ||||||
|                 addCategory(Intent.CATEGORY_OPENABLE) |  | ||||||
|                 type = "text/json" |  | ||||||
|                 putExtra(Intent.EXTRA_TITLE, "my-list.json") |  | ||||||
|             } |  | ||||||
|             getUriExport.launch(i) |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         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() { |     private fun showAudioLanguageSelection() { | ||||||
|         // we should be able to use the index of supportedLocals for language selection, items is GUI only |         // we should be able to use the index of supportedLocals for language selection, items is GUI only | ||||||
|         val items = supportedLocals.map { |         val items = supportedAudioLocals.map { | ||||||
|             it.toDisplayString(getString(R.string.settings_content_language_none)) |             it.toDisplayString(getString(R.string.settings_content_language_none)) | ||||||
|         }.toTypedArray() |         }.toTypedArray() | ||||||
|  |  | ||||||
|         var initialSelection: Int |         var initialSelection: Int | ||||||
|         // profile should be completed here, therefore blocking |         // profile should be completed here, therefore blocking | ||||||
|         runBlocking { |         runBlocking { | ||||||
|             initialSelection = supportedLocals.indexOf(Locale.forLanguageTag( |             initialSelection = supportedAudioLocals.indexOf(Locale.forLanguageTag( | ||||||
|                 profile.await().preferredContentSubtitleLanguage)) |                 profile.await().preferredContentAudioLanguage)) | ||||||
|             if (initialSelection < 0) initialSelection = supportedLocals.lastIndex |             if (initialSelection < 0) initialSelection = supportedAudioLocals.lastIndex | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         MaterialAlertDialogBuilder(requireContext()) |         MaterialAlertDialogBuilder(requireContext()) | ||||||
|             .setTitle(R.string.settings_content_language) |             .setTitle(R.string.settings_audio_language) | ||||||
|             .setSingleChoiceItems(items, initialSelection){ dialog, which -> |             .setSingleChoiceItems(items, initialSelection){ dialog, which -> | ||||||
|                 updatePrefContentLanguage(supportedLocals[which]) |                 updateAudioLanguage(supportedAudioLocals[which]) | ||||||
|                 dialog.dismiss() |                 dialog.dismiss() | ||||||
|             } |             } | ||||||
|             .show() |             .show() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     @kotlinx.coroutines.ExperimentalCoroutinesApi |     private fun showSubtitleLanguageSelection() { | ||||||
|     private fun updatePrefContentLanguage(preferredLocale: Locale) { |         // we should be able to use the index of supportedLocals for language selection, items is GUI only | ||||||
|  |         val items = supportedSubtitleLocals.map { | ||||||
|  |             it.toDisplayString(getString(R.string.settings_content_language_none)) | ||||||
|  |         }.toTypedArray() | ||||||
|  |  | ||||||
|  |         var initialSelection: Int | ||||||
|  |         // profile should be completed here, therefore blocking | ||||||
|  |         runBlocking { | ||||||
|  |             initialSelection = supportedSubtitleLocals.indexOf(Locale.forLanguageTag( | ||||||
|  |                 profile.await().preferredContentSubtitleLanguage)) | ||||||
|  |             if (initialSelection < 0) initialSelection = supportedSubtitleLocals.lastIndex | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         MaterialAlertDialogBuilder(requireContext()) | ||||||
|  |             .setTitle(R.string.settings_audio_language) | ||||||
|  |             .setSingleChoiceItems(items, initialSelection){ dialog, which -> | ||||||
|  |                 updateSubtitleLanguage(supportedSubtitleLocals[which]) | ||||||
|  |                 dialog.dismiss() | ||||||
|  |             } | ||||||
|  |             .show() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @OptIn(ExperimentalCoroutinesApi::class) | ||||||
|  |     private fun updateAudioLanguage(preferredLocale: Locale) { | ||||||
|         lifecycleScope.launch { |         lifecycleScope.launch { | ||||||
|             Crunchyroll.postPrefSubLanguage(preferredLocale.toLanguageTag()) |             Crunchyroll.setPreferredAudioLanguage(preferredLocale.toLanguageTag()) | ||||||
|  |  | ||||||
|         }.invokeOnCompletion { |         }.invokeOnCompletion { | ||||||
|             // update the local preferred content language |             // update the local preferred audio language | ||||||
|             Preferences.savePreferredLocal(requireContext(), preferredLocale) |             Preferences.savePreferredAudioLocal(requireContext(), preferredLocale) | ||||||
|  |  | ||||||
|             // update profile since the language selection might have changed |             // update profile since the language selection might have changed | ||||||
|             profile = lifecycleScope.async { Crunchyroll.profile() } |             profile = lifecycleScope.async { Crunchyroll.profile() } | ||||||
|             profile.invokeOnCompletion { |             profile.invokeOnCompletion { | ||||||
|                 // update language once loading profile is completed |                 // update language once loading profile is completed | ||||||
|                 binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag( |                 binding.textSettingsAudioLanguageDesc.text = Locale.forLanguageTag( | ||||||
|  |                     profile.getCompleted().preferredContentAudioLanguage | ||||||
|  |                 ).displayLanguage | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @OptIn(ExperimentalCoroutinesApi::class) | ||||||
|  |     private fun updateSubtitleLanguage(preferredLocal: Locale) { | ||||||
|  |         lifecycleScope.launch { | ||||||
|  |             Crunchyroll.setPreferredSubtitleLanguage(preferredLocal.toLanguageTag()) | ||||||
|  |  | ||||||
|  |         }.invokeOnCompletion { | ||||||
|  |             // update the local preferred subtitle language | ||||||
|  |             Preferences.savePreferredSubtitleLocal(requireContext(), preferredLocal) | ||||||
|  |  | ||||||
|  |             // update profile since the language selection might have changed | ||||||
|  |             profile = lifecycleScope.async { Crunchyroll.profile() } | ||||||
|  |             profile.invokeOnCompletion { | ||||||
|  |                 // update language once loading profile is completed | ||||||
|  |                 binding.textSettingsAudioLanguageDesc.text = Locale.forLanguageTag( | ||||||
|                     profile.getCompleted().preferredContentSubtitleLanguage |                     profile.getCompleted().preferredContentSubtitleLanguage | ||||||
|                 ).displayLanguage |                 ).displayLanguage | ||||||
|             } |             } | ||||||
| @ -208,17 +227,19 @@ class AccountFragment : Fragment() { | |||||||
|  |  | ||||||
|     private fun showThemeDialog() { |     private fun showThemeDialog() { | ||||||
|         val items = arrayOf( |         val items = arrayOf( | ||||||
|  |             resources.getString(R.string.theme_system), | ||||||
|             resources.getString(R.string.theme_light), |             resources.getString(R.string.theme_light), | ||||||
|             resources.getString(R.string.theme_dark) |             resources.getString(R.string.theme_dark) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         MaterialAlertDialogBuilder(requireContext()) |         MaterialAlertDialogBuilder(requireContext()) | ||||||
|             .setTitle(R.string.settings_content_language) |             .setTitle(R.string.theme) | ||||||
|             .setSingleChoiceItems(items, Preferences.theme.ordinal){ _, which -> |             .setSingleChoiceItems(items, Preferences.theme.ordinal){ _, which -> | ||||||
|                 when(which) { |                 when(which) { | ||||||
|                     0 -> Preferences.saveTheme(requireContext(), Theme.LIGHT) |                     0 -> Preferences.saveTheme(requireContext(), Theme.SYSTEM) | ||||||
|                     1 -> Preferences.saveTheme(requireContext(), Theme.DARK) |                     1 -> Preferences.saveTheme(requireContext(), Theme.LIGHT) | ||||||
|                     else -> Preferences.saveTheme(requireContext(), Theme.DARK) |                     2 -> Preferences.saveTheme(requireContext(), Theme.DARK) | ||||||
|  |                     else -> Preferences.saveTheme(requireContext(), Theme.SYSTEM) | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 (activity as MainActivity).restart() |                 (activity as MainActivity).restart() | ||||||
|  | |||||||
| @ -1,34 +1,65 @@ | |||||||
|  | /** | ||||||
|  |  * 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 android.widget.LinearLayout | ||||||
|  | import androidx.activity.result.contract.ActivityResultContracts | ||||||
|  | import androidx.core.view.children | ||||||
|  | import androidx.core.view.isVisible | ||||||
| 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 com.facebook.shimmer.ShimmerFrameLayout | ||||||
| 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.viewmodel.HomeViewModel | ||||||
| import org.mosad.teapod.parser.crunchyroll.Item | import org.mosad.teapod.util.adapter.MediaEpisodeListAdapter | ||||||
| import org.mosad.teapod.parser.crunchyroll.SortBy | import org.mosad.teapod.util.adapter.MediaItemListAdapter | ||||||
| import org.mosad.teapod.util.adapter.MediaItemAdapter | import org.mosad.teapod.util.playerIntent | ||||||
| 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 |     private val itemOffset = 21 | ||||||
|  |  | ||||||
|  |     private val playerResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { | ||||||
|  |         model.updateUpNextItems() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     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,124 +69,165 @@ 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.adapter = MediaEpisodeListAdapter( | ||||||
|             context?.let { |             MediaEpisodeListAdapter.OnClickListener { | ||||||
|                 initHighlight() |                 playerResult.launch(playerIntent(it.panel.episodeMetadata.seasonId, it.panel.id)) | ||||||
|                 initRecyclerViews() |             }, | ||||||
|                 initActions() |             itemOffset | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         binding.recyclerWatchlist.adapter = MediaItemListAdapter( | ||||||
|  |             MediaItemListAdapter.OnClickListener { | ||||||
|  |                 activity?.showFragment(MediaFragment(it.id)) | ||||||
|  |             }, | ||||||
|  |             itemOffset | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         binding.recyclerRecommendations.adapter = MediaItemListAdapter( | ||||||
|  |             MediaItemListAdapter.OnClickListener { | ||||||
|  |                 activity?.showFragment(MediaFragment(it.id)) | ||||||
|  |             }, | ||||||
|  |             itemOffset | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         binding.recyclerNewTitles.adapter = MediaItemListAdapter( | ||||||
|  |             MediaItemListAdapter.OnClickListener { | ||||||
|  |                 activity?.showFragment(MediaFragment(it.id)) | ||||||
|  |             }, | ||||||
|  |             itemOffset | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         binding.recyclerTopTen.adapter = MediaItemListAdapter( | ||||||
|  |             MediaItemListAdapter.OnClickListener { | ||||||
|  |                 activity?.showFragment(MediaFragment(it.id)) | ||||||
|  |             }, | ||||||
|  |             itemOffset | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         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 | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // set the shimmer items size as it's depending on the screen size | ||||||
|  |         setShimmerLayoutItemSize(binding.shimmerLayoutUpNext) | ||||||
|  |         setShimmerLayoutItemSize(binding.shimmerLayoutWatchlist) | ||||||
|  |         setShimmerLayoutItemSize(binding.shimmerLayoutRecommendations) | ||||||
|  |         setShimmerLayoutItemSize(binding.shimmerLayoutNewTitles) | ||||||
|  |         setShimmerLayoutItemSize(binding.shimmerLayoutTopTen) | ||||||
|  |  | ||||||
|  |         viewLifecycleOwner.lifecycleScope.launch { | ||||||
|  |             viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||||||
|  |                 model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> | ||||||
|  |                     when (uiState) { | ||||||
|  |                         is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState) | ||||||
|  |                         is HomeViewModel.UiState.Loading -> bindUiStateLoading() | ||||||
|  |                         is HomeViewModel.UiState.Error -> bindUiStateError(uiState) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun initHighlight() { |     private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) { | ||||||
|         lifecycleScope.launch { |         val adapterUpNext = binding.recyclerUpNext.adapter as MediaEpisodeListAdapter | ||||||
|             val newTitles = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 10) |         adapterUpNext.submitList(uiState.upNextItems.filter { !it.fullyWatched }) | ||||||
|             // FIXME crashes on newTitles.items.size == 0 |  | ||||||
|             highlightMedia =  newTitles.items[Random.nextInt(newTitles.items.size)] |  | ||||||
|  |  | ||||||
|             // add media item to gui |         val adapterWatchlist = binding.recyclerWatchlist.adapter as MediaItemListAdapter | ||||||
|             binding.textHighlightTitle.text = highlightMedia.title |         adapterWatchlist.submitList(uiState.watchlistItems.toItemMediaList()) | ||||||
|             Glide.with(requireContext()).load(highlightMedia.images.poster_wide[0][3].source) |  | ||||||
|                 .into(binding.imageHighlight) |  | ||||||
|  |  | ||||||
|             // TODO watchlist indicator |         val adapterRecommendations = binding.recyclerRecommendations.adapter as MediaItemListAdapter | ||||||
| //            if (StorageController.myList.contains(0)) { |         adapterRecommendations.submitList(uiState.recommendationsItems.toItemMediaList()) | ||||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) |  | ||||||
| //            } else { |         val adapterNewTitles = binding.recyclerNewTitles.adapter as MediaItemListAdapter | ||||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) |         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 { | ||||||
|  |             activity?.showFragment(MediaFragment(uiState.highlightItem.id)) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         binding.buttonPlayHighlight.setOnClickListener { | ||||||
|  |             val panel = uiState.highlightItemUpNext.panel | ||||||
|  |             playerResult.launch(playerIntent(panel.episodeMetadata.seasonId, panel.id)) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // disable the shimmer effect | ||||||
|  |         disableShimmer() | ||||||
|  |  | ||||||
|  |         // make highlights layout visible again | ||||||
|  |         binding.linearHighlight.isVisible = true | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun bindUiStateLoading() { | ||||||
|  |         // hide highlights layout | ||||||
|  |         binding.linearHighlight.isVisible = false | ||||||
|  |  | ||||||
|  |         binding.shimmerLayoutUpNext.startShimmer() | ||||||
|  |         binding.shimmerLayoutWatchlist.startShimmer() | ||||||
|  |         binding.shimmerLayoutRecommendations.startShimmer() | ||||||
|  |         binding.shimmerLayoutNewTitles.startShimmer() | ||||||
|  |         binding.shimmerLayoutTopTen.startShimmer() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun setShimmerLayoutItemSize(shimmerLayout: ShimmerFrameLayout) { | ||||||
|  |         (shimmerLayout.children.first() as? LinearLayout)?.children?.forEach { child -> | ||||||
|  |             child.layoutParams.apply { | ||||||
|  |                 width = (resources.displayMetrics.widthPixels / requireContext().resources.getInteger(R.integer.item_media_columns)) - itemOffset | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) { | ||||||
|  |         // currently not used | ||||||
|  |         Log.e(classTag, "A error occurred while loading a UiState: ${uiState.message}") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Suspend, since adapters need to be initialized before we can initialize the actions. |      * Disable the shimmer effect for all shimmer layouts and hide them. | ||||||
|      */ |      */ | ||||||
|     private suspend fun initRecyclerViews() { |     private fun disableShimmer() { | ||||||
|         binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) |         binding.shimmerLayoutHighlight.apply { | ||||||
|         binding.recyclerNewEpisodes.addItemDecoration(MediaItemDecoration(9)) |             stopShimmer() | ||||||
|         binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9)) |             isVisible = false | ||||||
|         binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9)) |  | ||||||
|  |  | ||||||
|         val asyncJobList = arrayListOf<Job>() |  | ||||||
|  |  | ||||||
|         // continue watching |  | ||||||
|         val upNextJob = lifecycleScope.launch { |  | ||||||
|             // TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately |  | ||||||
|             adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().items |  | ||||||
|                 .filter { !it.fullyWatched }.toItemMediaList()) |  | ||||||
|             binding.recyclerNewEpisodes.adapter = adapterUpNext |  | ||||||
|         } |         } | ||||||
|         asyncJobList.add(upNextJob) |         binding.shimmerLayoutUpNext.apply { | ||||||
|  |             stopShimmer() | ||||||
|         // watchlist |             isVisible = false | ||||||
|         val watchlistJob = lifecycleScope.launch { |  | ||||||
|             adapterWatchlist = MediaItemAdapter(Crunchyroll.watchlist(50).toItemMediaList()) |  | ||||||
|             binding.recyclerWatchlist.adapter = adapterWatchlist |  | ||||||
|         } |         } | ||||||
|         asyncJobList.add(watchlistJob) |         binding.shimmerLayoutWatchlist.apply { | ||||||
|  |             stopShimmer() | ||||||
|         // new simulcasts |             isVisible = false | ||||||
|         val simulcastsJob = lifecycleScope.launch { |  | ||||||
|             // val latestSeasonTag = Crunchyroll.seasonList().items.first().id |  | ||||||
|             // val newSimulcasts = Crunchyroll.browse(seasonTag = latestSeasonTag, n = 50) |  | ||||||
|             val newSimulcasts = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50) |  | ||||||
|  |  | ||||||
|             adapterNewTitles = MediaItemAdapter(newSimulcasts.toItemMediaList()) |  | ||||||
|             binding.recyclerNewTitles.adapter = adapterNewTitles |  | ||||||
|         } |         } | ||||||
|         asyncJobList.add(simulcastsJob) |         binding.shimmerLayoutRecommendations.apply { | ||||||
|  |             stopShimmer() | ||||||
|         // newly added / top ten |             isVisible = false | ||||||
|         val newlyAddedJob = lifecycleScope.launch { |  | ||||||
|             adapterTopTen = MediaItemAdapter(Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).toItemMediaList()) |  | ||||||
|             binding.recyclerTopTen.adapter = adapterTopTen |  | ||||||
|         } |         } | ||||||
|         asyncJobList.add(newlyAddedJob) |         binding.shimmerLayoutNewTitles.apply { | ||||||
|  |             stopShimmer() | ||||||
|         asyncJobList.joinAll() |             isVisible = false | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private fun initActions() { |  | ||||||
|         binding.buttonPlayHighlight.setOnClickListener { |  | ||||||
|             // TODO implement |  | ||||||
|             lifecycleScope.launch { |  | ||||||
|                 //val media = AoDParser.getMediaById(0) |  | ||||||
|  |  | ||||||
|                 // Log.d(javaClass.name, "Starting Player with  mediaId: ${media.aodId}") |  | ||||||
|                 //(activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId) |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|  |         binding.shimmerLayoutTopTen.apply { | ||||||
|         binding.textHighlightMyList.setOnClickListener { |             stopShimmer() | ||||||
|             // TODO implement |             isVisible = false | ||||||
| //            if (StorageController.myList.contains(0)) { |  | ||||||
| //                StorageController.myList.remove(0) |  | ||||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) |  | ||||||
| //            } else { |  | ||||||
| //                StorageController.myList.add(0) |  | ||||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) |  | ||||||
| //            } |  | ||||||
| //            StorageController.saveMyList(requireContext()) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         binding.textHighlightInfo.setOnClickListener { |  | ||||||
|             activity?.showFragment(MediaFragment(highlightMedia.id)) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         adapterUpNext.onItemClick = { id, _ -> |  | ||||||
|             activity?.showFragment(MediaFragment(id)) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         adapterWatchlist.onItemClick = { id, _ -> |  | ||||||
|             activity?.showFragment(MediaFragment(id)) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         adapterNewTitles.onItemClick = { id, _ -> |  | ||||||
|             activity?.showFragment(MediaFragment(id)) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         adapterTopTen.onItemClick = { id, _ -> |  | ||||||
|             activity?.showFragment(MediaFragment(id)) //(mediaId)) |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,29 +1,30 @@ | |||||||
| package org.mosad.teapod.ui.activity.main.fragments | package org.mosad.teapod.ui.activity.main.fragments | ||||||
|  |  | ||||||
|  | import android.annotation.SuppressLint | ||||||
| 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.appcompat.widget.SearchView | ||||||
| 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 androidx.recyclerview.widget.GridLayoutManager | import androidx.recyclerview.widget.GridLayoutManager | ||||||
| import androidx.recyclerview.widget.RecyclerView | import androidx.recyclerview.widget.RecyclerView | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import org.mosad.teapod.databinding.FragmentLibraryBinding | import org.mosad.teapod.databinding.FragmentLibraryBinding | ||||||
| import org.mosad.teapod.parser.crunchyroll.Crunchyroll | import org.mosad.teapod.ui.activity.main.viewmodel.LibraryFragmentViewModel | ||||||
| import org.mosad.teapod.util.ItemMedia | import org.mosad.teapod.util.adapter.MediaItemListAdapter | ||||||
| import org.mosad.teapod.util.adapter.MediaItemAdapter |  | ||||||
| import org.mosad.teapod.util.decoration.MediaItemDecoration |  | ||||||
| import org.mosad.teapod.util.showFragment | import org.mosad.teapod.util.showFragment | ||||||
|  |  | ||||||
| class LibraryFragment : Fragment() { | class LibraryFragment : Fragment() { | ||||||
|  |  | ||||||
|     private lateinit var binding: FragmentLibraryBinding |     private lateinit var binding: FragmentLibraryBinding | ||||||
|     private lateinit var adapter: MediaItemAdapter |     private lateinit var adapter: MediaItemListAdapter | ||||||
|  |     private val model: LibraryFragmentViewModel by viewModels() | ||||||
|     private val itemList = arrayListOf<ItemMedia>() |  | ||||||
|     private val pageSize = 30 |  | ||||||
|     private var nextItemIndex = 0 |  | ||||||
|  |  | ||||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { |     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||||
|         binding = FragmentLibraryBinding.inflate(inflater, container, false) |         binding = FragmentLibraryBinding.inflate(inflater, container, false) | ||||||
| @ -33,57 +34,79 @@ class LibraryFragment : Fragment() { | |||||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||||
|         super.onViewCreated(view, savedInstanceState) |         super.onViewCreated(view, savedInstanceState) | ||||||
|  |  | ||||||
|         // init async |         // TODO replace with pagination3 | ||||||
|         lifecycleScope.launch { |         // https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797 | ||||||
|             // create and set the adapter, needs context |         binding.recyclerMediaSearch.addOnScrollListener(PaginationScrollListener()) | ||||||
|             context?.let { |  | ||||||
|                 val initialResults = Crunchyroll.browse(n = pageSize) |  | ||||||
|                 itemList.addAll(initialResults.items.map { item -> |  | ||||||
|                     ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) |  | ||||||
|                 }) |  | ||||||
|                 nextItemIndex += pageSize |  | ||||||
|  |  | ||||||
|                 adapter = MediaItemAdapter(itemList) |         adapter = MediaItemListAdapter(MediaItemListAdapter.OnClickListener { | ||||||
|                 adapter.onItemClick = { mediaIdStr, _ -> |             binding.searchText.clearFocus() | ||||||
|                     activity?.showFragment(MediaFragment(mediaIdStr)) |             activity?.showFragment(MediaFragment(it.id)) | ||||||
|                 } |         }) | ||||||
|  |         binding.recyclerMediaSearch.adapter = adapter | ||||||
|  |  | ||||||
|                 binding.recyclerMediaLibrary.adapter = adapter |         binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener { | ||||||
|                 binding.recyclerMediaLibrary.addItemDecoration(MediaItemDecoration(9)) |             override fun onQueryTextSubmit(query: String?): Boolean { | ||||||
|                 // TODO replace with pagination3 |                 query?.let { model.search(it) } | ||||||
|                 // https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797 |                 return false // return false to dismiss the keyboard | ||||||
|                 binding.recyclerMediaLibrary.addOnScrollListener(PaginationScrollListener()) |  | ||||||
|             } |             } | ||||||
|  |  | ||||||
|         } |             override fun onQueryTextChange(newText: String?): Boolean { | ||||||
|     } |                 newText?.let { model.search(it) } | ||||||
|  |                 return false // return false to dismiss the keyboard | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |  | ||||||
|     inner class PaginationScrollListener: RecyclerView.OnScrollListener() { |         viewLifecycleOwner.lifecycleScope.launch { | ||||||
|         private var isLoading = false |             viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||||||
|  |                 model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> | ||||||
|         override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { |                     when (uiState) { | ||||||
|             super.onScrolled(recyclerView, dx, dy) |                         is LibraryFragmentViewModel.UiState.Browse -> bindUiStateBrowse(uiState) | ||||||
|             val layoutManager = recyclerView.layoutManager as GridLayoutManager? |                         is LibraryFragmentViewModel.UiState.Search -> bindUiStateSearch(uiState) | ||||||
|  |                         is LibraryFragmentViewModel.UiState.Loading -> bindUiStateLoading() | ||||||
|             if (!isLoading) layoutManager?.let { |                         is LibraryFragmentViewModel.UiState.Error -> bindUiStateError(uiState) | ||||||
|                 // itemList.size - 5 to start loading a bit earlier than the actual end |  | ||||||
|                 if (layoutManager.findLastCompletelyVisibleItemPosition() >= (itemList.size - 5)) { |  | ||||||
|                     // load new browse results async |  | ||||||
|                     isLoading = true |  | ||||||
|                     lifecycleScope.launch { |  | ||||||
|                         val firstNewItemIndex = itemList.lastIndex + 1 |  | ||||||
|                         val results = Crunchyroll.browse(start = nextItemIndex, n = pageSize) |  | ||||||
|                         itemList.addAll(results.items.map { item -> |  | ||||||
|                             ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) |  | ||||||
|                         }) |  | ||||||
|                         nextItemIndex += pageSize |  | ||||||
|  |  | ||||||
|                         adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize) |  | ||||||
|                         isLoading = false |  | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private fun bindUiStateBrowse(uiState: LibraryFragmentViewModel.UiState.Browse) { | ||||||
|  |         adapter.submitList(uiState.itemList) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @SuppressLint("NotifyDataSetChanged") | ||||||
|  |     private fun bindUiStateSearch(uiState: LibraryFragmentViewModel.UiState.Search) { | ||||||
|  |         adapter.submitList(uiState.itemList) | ||||||
|  |         adapter.notifyDataSetChanged() // this is needed, else the adapter will not update | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun bindUiStateLoading() { | ||||||
|  |         // currently not used | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun bindUiStateError(uiState: LibraryFragmentViewModel.UiState.Error) { | ||||||
|  |         // currently not used | ||||||
|  |         Log.e(javaClass.name, "A error occurred while loading a UiState: ${uiState.message}") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     inner class PaginationScrollListener: RecyclerView.OnScrollListener() { | ||||||
|  |  | ||||||
|  |         override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { | ||||||
|  |             super.onScrolled(recyclerView, dx, dy) | ||||||
|  |  | ||||||
|  |             if (!model.isLazyLoading) { | ||||||
|  |                 val layoutManager = recyclerView.layoutManager as? GridLayoutManager | ||||||
|  |                 layoutManager?.let { | ||||||
|  |                     // adapter.itemCount - 10 to start loading a bit earlier than the actual end | ||||||
|  |                     if (layoutManager.findLastCompletelyVisibleItemPosition() >= (adapter.itemCount - 10)) { | ||||||
|  |                         model.onLazyLoad().invokeOnCompletion { | ||||||
|  |                             adapter.notifyItemRangeInserted(adapter.itemCount, model.PAGESIZE) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
| @ -7,9 +7,9 @@ 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.activity.result.contract.ActivityResultContracts | ||||||
| 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 | ||||||
| @ -20,12 +20,13 @@ import jp.wasabeef.glide.transformations.BlurTransformation | |||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import org.mosad.teapod.R | import org.mosad.teapod.R | ||||||
| import org.mosad.teapod.databinding.FragmentMediaBinding | import org.mosad.teapod.databinding.FragmentMediaBinding | ||||||
| import org.mosad.teapod.parser.crunchyroll.NoneUpNextSeriesItem | import org.mosad.teapod.parser.crunchyroll.NoneUpNextSeriesList | ||||||
| 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 | ||||||
|  | import org.mosad.teapod.util.playerIntent | ||||||
| import org.mosad.teapod.util.tmdb.TMDBApiController | import org.mosad.teapod.util.tmdb.TMDBApiController | ||||||
| import org.mosad.teapod.util.tmdb.TMDBMovie | import org.mosad.teapod.util.tmdb.TMDBMovie | ||||||
| import org.mosad.teapod.util.tmdb.TMDBTVShow | import org.mosad.teapod.util.tmdb.TMDBTVShow | ||||||
|  | import org.mosad.teapod.util.toItemMediaList | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * The media detail fragment. |  * The media detail fragment. | ||||||
| @ -37,12 +38,14 @@ 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 | ||||||
|     private var runOnResume = false |  | ||||||
|  |  | ||||||
|  |     private val playerResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { | ||||||
|  |         playerFinishedCallback() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { |     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||||
|         binding = FragmentMediaBinding.inflate(inflater, container, false) |         binding = FragmentMediaBinding.inflate(inflater, container, false) | ||||||
| @ -54,7 +57,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | |||||||
|         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 | ||||||
| @ -75,27 +78,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     override fun onResume() { |  | ||||||
|         super.onResume() |  | ||||||
|  |  | ||||||
|         if (runOnResume) { |  | ||||||
|             lifecycleScope.launch { |  | ||||||
|                 model.updateOnResume() |  | ||||||
|  |  | ||||||
|                 if (model.upNextSeries != NoneUpNextSeriesItem) { |  | ||||||
|                     binding.textTitle.text = model.upNextSeries.panel.title |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 // needs to be called after model.updateOnResume() |  | ||||||
|                 if (fragments.elementAtOrNull(0) is MediaFragmentEpisodes) { |  | ||||||
|                     (fragments[0] as MediaFragmentEpisodes).updateWatchedState() |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } else { |  | ||||||
|             runOnResume = true |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * if tmdb data is present, use it, else use the aod data |      * if tmdb data is present, use it, else use the aod data | ||||||
|      */ |      */ | ||||||
| @ -108,6 +90,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | |||||||
|  |  | ||||||
|         // load poster and backdrop |         // load poster and backdrop | ||||||
|         Glide.with(requireContext()).load(posterUrl) |         Glide.with(requireContext()).load(posterUrl) | ||||||
|  |             .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) | ||||||
|             .into(binding.imagePoster) |             .into(binding.imagePoster) | ||||||
|         Glide.with(requireContext()).load(backdropUrl) |         Glide.with(requireContext()).load(backdropUrl) | ||||||
|             .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) |             .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) | ||||||
| @ -115,14 +98,14 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | |||||||
|             .into(binding.imageBackdrop) |             .into(binding.imageBackdrop) | ||||||
|  |  | ||||||
|         binding.textYear.text = when(tmdbResult) { |         binding.textYear.text = when(tmdbResult) { | ||||||
|             is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate.substring(0, 4) |             is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate?.substring(0, 4) | ||||||
|             is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4) |             is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4) | ||||||
|             else -> "" |             else -> "" | ||||||
|         } |         } | ||||||
|         binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull() |         binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull() | ||||||
|  |  | ||||||
|         binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesItem) { |         binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesList) { | ||||||
|             upNextSeries.panel.title |             upNextSeries.data.first().panel.title | ||||||
|         } else seriesCrunchy.title |         } else seriesCrunchy.title | ||||||
|         binding.textOverview.text = seriesCrunchy.description |         binding.textOverview.text = seriesCrunchy.description | ||||||
|  |  | ||||||
| @ -130,31 +113,48 @@ 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)) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // if has similar titles | ||||||
|  |         if (model.similarTo.total > 0) { | ||||||
|  |             MediaFragmentSimilar(model.similarTo.toItemMediaList()).also { | ||||||
|  |                 fragments.add(it) | ||||||
|  |                 pagerAdapter.notifyItemInserted(fragments.indexOf(it)) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // disable scrolling on appbar, if no tabs where added | ||||||
|  |         if(fragments.isEmpty()) { | ||||||
|  |             val params = binding.linearMedia.layoutParams as AppBarLayout.LayoutParams | ||||||
|  |             params.scrollFlags = 0 // clear all scroll flags | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // specific gui (via tmdb) |         // specific gui (via tmdb) | ||||||
|         when (tmdbResult) { |         when (tmdbResult) { | ||||||
|             is TMDBTVShow -> { |             is TMDBTVShow -> { | ||||||
|                 // episodes count |                 // episodes count | ||||||
|                 binding.textEpisodesOrRuntime.text = resources.getQuantityString( |                 binding.textEpisodesOrRuntime.text = resources.getQuantityString( | ||||||
|                     R.plurals.text_episodes_count, |                     R.plurals.text_episodes_count, | ||||||
|                     episodesCrunchy.total, |                     seriesCrunchy.episodeCount, | ||||||
|                     episodesCrunchy.total |                     seriesCrunchy.episodeCount | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|             is TMDBMovie -> { |             is TMDBMovie -> { | ||||||
|                 val tmdbMovie = (tmdbResult as TMDBMovie?) |                 val tmdbMovie = tmdbResult as TMDBMovie | ||||||
|  |  | ||||||
|                 if (tmdbMovie?.runtime != null) { |                 if (tmdbMovie.runtime != null) { | ||||||
|                     binding.textEpisodesOrRuntime.text = resources.getQuantityString( |                     binding.textEpisodesOrRuntime.text = resources.getQuantityString( | ||||||
|                         R.plurals.text_runtime, |                         R.plurals.text_runtime, | ||||||
|                         tmdbMovie.runtime, |                         tmdbMovie.runtime, | ||||||
| @ -169,28 +169,14 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // if has similar titles |  | ||||||
|         // TODO reimplement |  | ||||||
| //        if (media.similar.isNotEmpty()) { |  | ||||||
| //            MediaFragmentSimilar().also { |  | ||||||
| //                fragments.add(it) |  | ||||||
| //                pagerAdapter.notifyItemInserted(fragments.indexOf(it)) |  | ||||||
| //            } |  | ||||||
| //        } |  | ||||||
|  |  | ||||||
|         // disable scrolling on appbar, if no tabs where added |  | ||||||
|         if(fragments.isEmpty()) { |  | ||||||
|             val params = binding.linearMedia.layoutParams as AppBarLayout.LayoutParams |  | ||||||
|             params.scrollFlags = 0 // clear all scroll flags |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         binding.frameLoading.visibility = View.GONE // hide loading indicator |         binding.frameLoading.visibility = View.GONE // hide loading indicator | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun initActions() = with(model) { |     private fun initActions() = with(model) { | ||||||
|         binding.buttonPlay.setOnClickListener { |         binding.buttonPlay.setOnClickListener { | ||||||
|             if (upNextSeries != NoneUpNextSeriesItem) { |             if (upNextSeries != NoneUpNextSeriesList) { | ||||||
|                 playEpisode(upNextSeries.panel.episodeMetadata.seasonId, upNextSeries.panel.id) |                 val panel = upNextSeries.data.first().panel | ||||||
|  |                 playEpisode(panel.episodeMetadata.seasonId, panel.id) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @ -211,21 +197,31 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     private fun playerFinishedCallback() = lifecycleScope.launch { | ||||||
|      * play the current episode |         model.updateOnResume() | ||||||
|      * TODO this is also used in MediaFragmentEpisode, we should only have on implementation |  | ||||||
|      */ |  | ||||||
|     private fun playEpisode(seasonId: String, episodeId: String) { |  | ||||||
|         (activity as MainActivity).startPlayer(seasonId, episodeId) |  | ||||||
|         Log.d(javaClass.name, "Started Player with  episodeId: $episodeId") |  | ||||||
|  |  | ||||||
|         //model.updateNextEpisode(episodeId) // set the correct next episode |         if (model.upNextSeries != NoneUpNextSeriesList) { | ||||||
|  |             binding.textTitle.text = model.upNextSeries.data.first().panel.title | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // needs to be called after model.updateOnResume() | ||||||
|  |         (fragments.elementAtOrNull(0) as? MediaFragmentEpisodes)?.updateWatchedState() | ||||||
|  |  | ||||||
|  |         Log.d(javaClass.name, "Updated model and gui after player closed") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * play a episode, also runs callback on player result return | ||||||
|  |      */ | ||||||
|  |     fun playEpisode(seasonId: String, episodeId: String) { | ||||||
|  |         playerResult.launch(playerIntent(seasonId, episodeId)) | ||||||
|  |         Log.d(javaClass.name, "Started Player with  episodeId: $episodeId") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 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] | ||||||
|  | |||||||
| @ -2,18 +2,16 @@ package org.mosad.teapod.ui.activity.main.fragments | |||||||
|  |  | ||||||
| import android.annotation.SuppressLint | import android.annotation.SuppressLint | ||||||
| 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.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.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.viewmodel.MediaFragmentViewModel | import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel | ||||||
| import org.mosad.teapod.util.adapter.EpisodeItemAdapter | import org.mosad.teapod.util.adapter.EpisodeItemAdapter | ||||||
|  |  | ||||||
| @ -22,7 +20,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) | ||||||
| @ -35,15 +33,14 @@ class MediaFragmentEpisodes : Fragment() { | |||||||
|         adapterRecEpisodes = EpisodeItemAdapter( |         adapterRecEpisodes = EpisodeItemAdapter( | ||||||
|             model.currentEpisodesCrunchy, |             model.currentEpisodesCrunchy, | ||||||
|             model.tmdbTVSeason.episodes, |             model.tmdbTVSeason.episodes, | ||||||
|             model.currentPlayheads |             model.currentPlayheads, | ||||||
|  |             EpisodeItemAdapter.OnClickListener { episode -> | ||||||
|  |                 (requireParentFragment() as? MediaFragment)?.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 | ||||||
| @ -62,13 +59,15 @@ 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 | ||||||
|         adapterRecEpisodes.notifyDataSetChanged() |         if (this::adapterRecEpisodes.isInitialized) { | ||||||
|  |             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.data.forEach { season -> | ||||||
|             popup.menu.add(getString( |             popup.menu.add(getString( | ||||||
|                     R.string.season_number_title, |                     R.string.season_number_title, | ||||||
|                     season.seasonNumber, |                     season.seasonNumber, | ||||||
| @ -105,11 +104,4 @@ class MediaFragmentEpisodes : Fragment() { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun playEpisode(seasonId: String, episodeId: String) { |  | ||||||
|         (activity as MainActivity).startPlayer(seasonId, episodeId) |  | ||||||
|         Log.d(javaClass.name, "Started Player with  episodeId: $episodeId") |  | ||||||
|  |  | ||||||
|         //model.updateNextEpisode(episodeId) // set the correct next episode |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } | } | ||||||
| @ -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,14 @@ 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 org.mosad.teapod.databinding.FragmentMediaSimilarBinding | import org.mosad.teapod.databinding.FragmentMediaSimilarBinding | ||||||
| import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel | import org.mosad.teapod.util.ItemMedia | ||||||
| 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.showFragment | import org.mosad.teapod.util.showFragment | ||||||
|  |  | ||||||
| class MediaFragmentSimilar : Fragment()  { | class MediaFragmentSimilar(val items: List<ItemMedia>) : Fragment()  { | ||||||
|  |  | ||||||
|     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 +44,13 @@ 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 = MediaItemListAdapter( | ||||||
|         binding.recyclerMediaSimilar.adapter = adapterSimilar |             MediaItemListAdapter.OnClickListener { | ||||||
|         binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) |                 activity?.showFragment(MediaFragment(it.id)) | ||||||
|  |  | ||||||
|         // set onItemClick only in adapter is initialized |  | ||||||
|         if (this::adapterSimilar.isInitialized) { |  | ||||||
|             adapterSimilar.onItemClick = { mediaId, _ -> |  | ||||||
|                 activity?.showFragment(MediaFragment("")) //(mediaId)) |  | ||||||
|             } |             } | ||||||
|         } |         ) | ||||||
|  |  | ||||||
|  |         val adapterSimilar = binding.recyclerMediaSimilar.adapter as MediaItemListAdapter | ||||||
|  |         adapterSimilar.submitList(items) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @ -0,0 +1,67 @@ | |||||||
|  | package org.mosad.teapod.ui.activity.main.fragments | ||||||
|  |  | ||||||
|  | import android.os.Bundle | ||||||
|  | import android.view.LayoutInflater | ||||||
|  | import android.view.View | ||||||
|  | import android.view.ViewGroup | ||||||
|  | import androidx.fragment.app.Fragment | ||||||
|  | import androidx.lifecycle.lifecycleScope | ||||||
|  | import androidx.viewpager2.adapter.FragmentStateAdapter | ||||||
|  | import com.google.android.material.tabs.TabLayoutMediator | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import org.mosad.teapod.R | ||||||
|  | import org.mosad.teapod.databinding.FragmentMyListsBinding | ||||||
|  | import org.mosad.teapod.parser.crunchyroll.Crunchyroll | ||||||
|  | import org.mosad.teapod.util.toItemMediaList | ||||||
|  |  | ||||||
|  | class MyListsFragment : Fragment() { | ||||||
|  |  | ||||||
|  |     private lateinit var binding: FragmentMyListsBinding | ||||||
|  |     private lateinit var pagerAdapter: FragmentStateAdapter | ||||||
|  |  | ||||||
|  |     private val fragments = arrayListOf<Fragment>() | ||||||
|  |  | ||||||
|  |     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||||
|  |         binding = FragmentMyListsBinding.inflate(inflater, container, false) | ||||||
|  |         return binding.root | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||||
|  |         super.onViewCreated(view, savedInstanceState) | ||||||
|  |  | ||||||
|  |         // tab layout and pager | ||||||
|  |         pagerAdapter = ScreenSlidePagerAdapter(this) | ||||||
|  |         binding.pagerMyLists.adapter = pagerAdapter | ||||||
|  |  | ||||||
|  |         // TODO is position 0 always episodes? (and 1 always similar titles) | ||||||
|  |         TabLayoutMediator(binding.tabMyLists, binding.pagerMyLists) { tab, position -> | ||||||
|  |             tab.text = when(position) { | ||||||
|  |                 0 -> getString(R.string.my_list) | ||||||
|  |                 1 -> getString(R.string.crunchylists) | ||||||
|  |                 2 -> getString(R.string.downloads) | ||||||
|  |                 else -> "" | ||||||
|  |             } | ||||||
|  |         }.attach() | ||||||
|  |  | ||||||
|  |         lifecycleScope.launch { | ||||||
|  |             val items = Crunchyroll.watchlist(50) | ||||||
|  |  | ||||||
|  |             MediaFragmentSimilar(items.toItemMediaList()).also { | ||||||
|  |                 fragments.add(it) | ||||||
|  |                 pagerAdapter.notifyItemInserted(fragments.indexOf(it)) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * A simple pager adapter | ||||||
|  |      * TODO also present in MediaFragment | ||||||
|  |      */ | ||||||
|  |     private inner class ScreenSlidePagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { | ||||||
|  |         override fun getItemCount(): Int = fragments.size | ||||||
|  |  | ||||||
|  |         override fun createFragment(position: Int): Fragment = fragments[position] | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @ -1,118 +0,0 @@ | |||||||
| package org.mosad.teapod.ui.activity.main.fragments |  | ||||||
|  |  | ||||||
| import android.os.Bundle |  | ||||||
| import android.view.LayoutInflater |  | ||||||
| import android.view.View |  | ||||||
| import android.view.ViewGroup |  | ||||||
| import android.widget.SearchView |  | ||||||
| import androidx.fragment.app.Fragment |  | ||||||
| import androidx.lifecycle.lifecycleScope |  | ||||||
| import kotlinx.coroutines.Job |  | ||||||
| import kotlinx.coroutines.async |  | ||||||
| import kotlinx.coroutines.launch |  | ||||||
| import org.mosad.teapod.databinding.FragmentSearchBinding |  | ||||||
| import org.mosad.teapod.parser.crunchyroll.Crunchyroll |  | ||||||
| import org.mosad.teapod.util.ItemMedia |  | ||||||
| import org.mosad.teapod.util.adapter.MediaItemAdapter |  | ||||||
| import org.mosad.teapod.util.decoration.MediaItemDecoration |  | ||||||
| import org.mosad.teapod.util.showFragment |  | ||||||
|  |  | ||||||
| class SearchFragment : Fragment() { |  | ||||||
|  |  | ||||||
|     private lateinit var binding: FragmentSearchBinding |  | ||||||
|     private lateinit var adapter: MediaItemAdapter |  | ||||||
|  |  | ||||||
|     private val itemList = arrayListOf<ItemMedia>() |  | ||||||
|     private var searchJob: Job? = null |  | ||||||
|     private var oldSearchQuery = "" |  | ||||||
|  |  | ||||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { |  | ||||||
|         binding = FragmentSearchBinding.inflate(inflater, container, false) |  | ||||||
|         return binding.root |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |  | ||||||
|         super.onViewCreated(view, savedInstanceState) |  | ||||||
|  |  | ||||||
|         lifecycleScope.launch { |  | ||||||
|             // create and set the adapter, needs context |  | ||||||
|                 context?.let { |  | ||||||
|                     adapter = MediaItemAdapter(itemList) |  | ||||||
|                     adapter.onItemClick = { mediaIdStr, _ -> |  | ||||||
|                         binding.searchText.clearFocus() |  | ||||||
|                         activity?.showFragment(MediaFragment(mediaIdStr)) |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     binding.recyclerMediaSearch.adapter = adapter |  | ||||||
|                     binding.recyclerMediaSearch.addItemDecoration(MediaItemDecoration(9)) |  | ||||||
|                 } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         initActions() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private fun initActions() { |  | ||||||
|         binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener { |  | ||||||
|             override fun onQueryTextSubmit(query: String?): Boolean { |  | ||||||
|                 query?.let { search(it) } |  | ||||||
|                 return false |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             override fun onQueryTextChange(newText: String?): Boolean { |  | ||||||
|                 newText?.let { search(it) } |  | ||||||
|                 return false |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private fun search(query: String) { |  | ||||||
|         // if the query hasn't changed since the last successful search, return |  | ||||||
|         if (query == oldSearchQuery) return |  | ||||||
|  |  | ||||||
|         // cancel search job if one is already running |  | ||||||
|         if (searchJob?.isActive == true) searchJob?.cancel() |  | ||||||
|  |  | ||||||
|         searchJob = lifecycleScope.async { |  | ||||||
|             // TODO maybe wait a few ms (500ms?) before searching, if the user inputs any other chars |  | ||||||
|             val results = Crunchyroll.search(query, 50) |  | ||||||
|  |  | ||||||
|             itemList.clear() // TODO needs clean up |  | ||||||
|  |  | ||||||
|             // TODO add top results first heading |  | ||||||
|             itemList.addAll(results.items[0].items.map { item -> |  | ||||||
|                 ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) |  | ||||||
|             }) |  | ||||||
|  |  | ||||||
|             // TODO currently only tv shows are supported, hence only the first items array |  | ||||||
|             //  should be always present |  | ||||||
|  |  | ||||||
| //            // TODO add tv shows heading |  | ||||||
| //            if (results.items.size >= 2) { |  | ||||||
| //                itemList.addAll(results.items[1].items.map { item -> |  | ||||||
| //                    ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) |  | ||||||
| //                }) |  | ||||||
| //            } |  | ||||||
| // |  | ||||||
| //            // TODO add movies heading |  | ||||||
| //            if (results.items.size >= 3) { |  | ||||||
| //                itemList.addAll(results.items[2].items.map { item -> |  | ||||||
| //                    ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) |  | ||||||
| //                }) |  | ||||||
| //            } |  | ||||||
| // |  | ||||||
| //            // TODO add episodes heading |  | ||||||
| //            if (results.items.size >= 4) { |  | ||||||
| //                itemList.addAll(results.items[3].items.map { item -> |  | ||||||
| //                    ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) |  | ||||||
| //                }) |  | ||||||
| //            } |  | ||||||
|  |  | ||||||
|             adapter.notifyDataSetChanged() |  | ||||||
|             //adapter.notifyItemRangeInserted(0, itemList.size) |  | ||||||
|  |  | ||||||
|             // after successfully searching the query term, add it as old query, to make sure we |  | ||||||
|             // don't search again if the query hasn't changed |  | ||||||
|             oldSearchQuery = query |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @ -0,0 +1,145 @@ | |||||||
|  | /** | ||||||
|  |  * 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.MutableStateFlow | ||||||
|  | import kotlinx.coroutines.flow.update | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import org.mosad.teapod.parser.crunchyroll.* | ||||||
|  | import kotlin.random.Random | ||||||
|  |  | ||||||
|  | class HomeViewModel : ViewModel()  { | ||||||
|  |  | ||||||
|  |     private val WATCHLIST_LENGTH = 50 | ||||||
|  |  | ||||||
|  |     private val uiState = MutableStateFlow<UiState>(UiState.Loading) | ||||||
|  |  | ||||||
|  |     sealed class UiState { | ||||||
|  |         object Loading : UiState() | ||||||
|  |         data class Normal( | ||||||
|  |             val upNextItems: List<UpNextAccountItem>, | ||||||
|  |             val watchlistItems: List<Item>, | ||||||
|  |             val recommendationsItems: List<Item>, | ||||||
|  |             val recentlyAddedItems: List<Item>, | ||||||
|  |             val topTenItems: List<Item>, | ||||||
|  |             val highlightItem: Item, | ||||||
|  |             val highlightItemUpNext: UpNextSeriesItem, | ||||||
|  |             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(n = 20).data } | ||||||
|  |                 val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(WATCHLIST_LENGTH).data } | ||||||
|  |                 val recommendationsJob = viewModelScope.async { | ||||||
|  |                     Crunchyroll.recommendations(n = 20).data | ||||||
|  |                 } | ||||||
|  |                 val recentlyAddedJob = viewModelScope.async { | ||||||
|  |                     Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50).data | ||||||
|  |                 } | ||||||
|  |                 val topTenJob = viewModelScope.async { | ||||||
|  |                     Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).data | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 val recentlyAddedItems = recentlyAddedJob.await() | ||||||
|  |                 // FIXME crashes on newTitles.items.size == 0 | ||||||
|  |                 val highlightItem = recentlyAddedItems[Random.nextInt(recentlyAddedItems.size)] | ||||||
|  |                 val highlightItemUpNextJob = viewModelScope.async { | ||||||
|  |                     Crunchyroll.upNextSeries(highlightItem.id).data.first() | ||||||
|  |                 } | ||||||
|  |                 val highlightItemIsWatchlistJob = viewModelScope.async { | ||||||
|  |                     Crunchyroll.isWatchlist(highlightItem.id) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 uiState.emit(UiState.Normal( | ||||||
|  |                     upNextJob.await(), watchlistJob.await(), recommendationsJob.await(), | ||||||
|  |                     recentlyAddedJob.await(), topTenJob.await(), highlightItem, | ||||||
|  |                     highlightItemUpNextJob.await(), highlightItemIsWatchlistJob.await() | ||||||
|  |                 )) | ||||||
|  |             } 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(WATCHLIST_LENGTH).data | ||||||
|  |  | ||||||
|  |                     currentUiState.copy( | ||||||
|  |                         watchlistItems = watchlistItems, | ||||||
|  |                         highlightIsWatchlist = !currentUiState.highlightIsWatchlist) | ||||||
|  |                 } else { | ||||||
|  |                     currentUiState | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Update the up next list. To be used on player result callbacks. | ||||||
|  |      */ | ||||||
|  |     fun updateUpNextItems() { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             uiState.update { currentUiState -> | ||||||
|  |                 if (currentUiState is UiState.Normal) { | ||||||
|  |                     val upNextItems = Crunchyroll.upNextAccount(n = 20).data | ||||||
|  |                     currentUiState.copy(upNextItems = upNextItems) | ||||||
|  |                 } else { | ||||||
|  |                     currentUiState | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @ -0,0 +1,131 @@ | |||||||
|  | package org.mosad.teapod.ui.activity.main.viewmodel | ||||||
|  |  | ||||||
|  | import androidx.lifecycle.LifecycleCoroutineScope | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import kotlinx.coroutines.Job | ||||||
|  | import kotlinx.coroutines.async | ||||||
|  | import kotlinx.coroutines.delay | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
|  | import kotlinx.coroutines.flow.update | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import org.mosad.teapod.parser.crunchyroll.Crunchyroll | ||||||
|  | import org.mosad.teapod.util.ItemMedia | ||||||
|  | import org.mosad.teapod.util.toItemMediaList | ||||||
|  |  | ||||||
|  | class LibraryFragmentViewModel : ViewModel() { | ||||||
|  |  | ||||||
|  |     val PAGESIZE = 50 | ||||||
|  |  | ||||||
|  |     private val uiState = MutableStateFlow<UiState>(UiState.Loading) | ||||||
|  |     private var oldSearchQuery = "" | ||||||
|  |     private var searchJob: Job? = null | ||||||
|  |     var isLazyLoading = false | ||||||
|  |         internal set | ||||||
|  |  | ||||||
|  |     sealed class UiState { | ||||||
|  |         object Loading : UiState() | ||||||
|  |         data class Browse( | ||||||
|  |             val itemList: MutableList<ItemMedia> | ||||||
|  |         ) : UiState() | ||||||
|  |         data class Search( | ||||||
|  |             val itemList: List<ItemMedia> | ||||||
|  |         ) : UiState() | ||||||
|  |         data class Error(val message: String?) : UiState() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     init { | ||||||
|  |         load() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { | ||||||
|  |         scope.launch { uiState.collect { collector(it) } } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * initially load the first n browsing items | ||||||
|  |      */ | ||||||
|  |     private fun load() { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             uiState.emit(UiState.Loading) | ||||||
|  |  | ||||||
|  |             try { | ||||||
|  |                 initBrowse() | ||||||
|  |             } catch (ex: Exception) { | ||||||
|  |                 uiState.emit(UiState.Error(ex.message)) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Search for a query string at Crunchyroll and emit the new ui state. | ||||||
|  |      */ | ||||||
|  |     fun search(query: String) { | ||||||
|  |         // return if nothing has changed | ||||||
|  |         if (query == oldSearchQuery) return | ||||||
|  |  | ||||||
|  |         // update the old query since it has changed | ||||||
|  |         oldSearchQuery = query | ||||||
|  |  | ||||||
|  |         viewModelScope.launch { | ||||||
|  |  | ||||||
|  |             // always cancel a running search job | ||||||
|  |             if (searchJob?.isActive == true) searchJob?.cancel() | ||||||
|  |  | ||||||
|  |             // handle state change: browse <-> search | ||||||
|  |             if (query.isEmpty()) { | ||||||
|  |                 // if the query is empty change back to browse state | ||||||
|  |                 initBrowse() | ||||||
|  |             } else { | ||||||
|  |                 // TODO handle errors | ||||||
|  |  | ||||||
|  |                 // if the current ui state is not search, clear the recyclerview | ||||||
|  |                 if (uiState.value !is UiState.Search) { | ||||||
|  |                     uiState.emit(UiState.Search(emptyList())) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // create a new search job | ||||||
|  |                 searchJob = viewModelScope.async { | ||||||
|  |                     // wait for a few ms: if the user is typing the task will get canceled | ||||||
|  |                     delay(250) | ||||||
|  |  | ||||||
|  |                     val results = Crunchyroll.search(query, 50) | ||||||
|  |                         .data.firstOrNull()?.items?.toItemMediaList() | ||||||
|  |                         ?: listOf() | ||||||
|  |                     uiState.emit(UiState.Search(results)) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun onLazyLoad() = viewModelScope.launch { | ||||||
|  |         isLazyLoading = true | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             uiState.update { currentUiState -> | ||||||
|  |                 if (currentUiState is UiState.Browse) { | ||||||
|  |                     val newBrowseItems = Crunchyroll.browse(start = currentUiState.itemList.size, n = PAGESIZE) | ||||||
|  |                         .toItemMediaList() | ||||||
|  |                     currentUiState.itemList.addAll(newBrowseItems) | ||||||
|  |                 } | ||||||
|  |                 currentUiState | ||||||
|  |             } | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             uiState.emit(UiState.Error(ex.message)) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isLazyLoading = false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private suspend fun initBrowse() { | ||||||
|  |         try { | ||||||
|  |             val initialBrowseItems = Crunchyroll.browse(n = PAGESIZE) | ||||||
|  |                 .toItemMediaList() | ||||||
|  |                 .toMutableList() | ||||||
|  |             uiState.emit(UiState.Browse(initialBrowseItems)) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             uiState.emit(UiState.Error(ex.message)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @ -3,13 +3,13 @@ package org.mosad.teapod.ui.activity.main.viewmodel | |||||||
| import android.app.Application | import android.app.Application | ||||||
| import androidx.lifecycle.AndroidViewModel | import androidx.lifecycle.AndroidViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
|  | import kotlinx.coroutines.async | ||||||
| import kotlinx.coroutines.joinAll | import kotlinx.coroutines.joinAll | ||||||
| import kotlinx.coroutines.launch | 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.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.* | ||||||
|  | import org.mosad.teapod.util.toPlayheadsMap | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * handle media, next ep and tmdb |  * handle media, next ep and tmdb | ||||||
| @ -17,9 +17,7 @@ import org.mosad.teapod.util.tmdb.* | |||||||
|  */ |  */ | ||||||
| class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { | class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { | ||||||
|  |  | ||||||
| //    var mediaCrunchy = NoneItem |     var seriesCrunchy = NoneSeriesItem // movies are also series | ||||||
| //        internal set |  | ||||||
|     var seriesCrunchy = NoneSeries // movies are also series |  | ||||||
|         internal set |         internal set | ||||||
|     var seasonsCrunchy = NoneSeasons |     var seasonsCrunchy = NoneSeasons | ||||||
|         internal set |         internal set | ||||||
| @ -29,11 +27,15 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | |||||||
|         internal set |         internal set | ||||||
|     val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates) |     val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates) | ||||||
|  |  | ||||||
|     // additional media info |     // additional media info, might change during during user interaction | ||||||
|  |     // use a map to update the episode adapter values | ||||||
|     val currentPlayheads: MutableMap<String, PlayheadObject> = mutableMapOf() |     val currentPlayheads: MutableMap<String, PlayheadObject> = mutableMapOf() | ||||||
|     var isWatchlist = false |     var isWatchlist = false | ||||||
|         internal set |         internal set | ||||||
|     var upNextSeries = NoneUpNextSeriesItem |     var upNextSeries = NoneUpNextSeriesList | ||||||
|  |         internal set | ||||||
|  |     var similarTo = NoneSimilarToResult | ||||||
|  |         internal set | ||||||
|  |  | ||||||
|     // TMDB stuff |     // TMDB stuff | ||||||
|     var mediaType = MediaType.OTHER |     var mediaType = MediaType.OTHER | ||||||
| @ -42,8 +44,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 | ||||||
| @ -52,41 +52,38 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | |||||||
|     suspend fun loadCrunchy(crunchyId: String) { |     suspend fun loadCrunchy(crunchyId: String) { | ||||||
|         // load series and seasons info in parallel |         // load series and seasons info in parallel | ||||||
|         listOf( |         listOf( | ||||||
|             viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, |             viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId).data.first() }, | ||||||
|             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: | ||||||
|         currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocale) |         // next episode > first season | ||||||
|  |         currentSeasonCrunchy = if (upNextSeries != NoneUpNextSeriesList) { | ||||||
|  |             seasonsCrunchy.data.firstOrNull{ season -> | ||||||
|  |                 season.id == upNextSeries.data.first().panel.episodeMetadata.seasonId | ||||||
|  |             } ?: seasonsCrunchy.data.first() | ||||||
|  |         } else { | ||||||
|  |             seasonsCrunchy.data.first() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 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.data) | ||||||
|  |  | ||||||
|         // set media type |         // set media type, for movies the episode field is empty | ||||||
|         mediaType = episodesCrunchy.items.firstOrNull()?.let { |         mediaType = episodesCrunchy.data.firstOrNull()?.let { | ||||||
|             if (it.episodeNumber != null) MediaType.TVSHOW else MediaType.MOVIE |             if (it.episode.isNotEmpty()) MediaType.TVSHOW else MediaType.MOVIE | ||||||
|         } ?: MediaType.OTHER |         } ?: MediaType.OTHER | ||||||
|  |  | ||||||
|         // load playheads and tmdb in parallel |         // load playheads and tmdb in parallel | ||||||
|         listOf( |         listOf( | ||||||
|             viewModelScope.launch { |             updatePlayheadsAsync(), | ||||||
|                 // get playheads (including fully watched state) |  | ||||||
|                 val episodeIDs = episodesCrunchy.items.map { it.id } |  | ||||||
|                 currentPlayheads.clear() |  | ||||||
|                 currentPlayheads.putAll(Crunchyroll.playheads(episodeIDs)) |  | ||||||
|             }, |  | ||||||
|             viewModelScope.launch { loadTmdbInfo() } // use tmdb search to get media info |             viewModelScope.launch { loadTmdbInfo() } // use tmdb search to get media info | ||||||
|         ).joinAll() |         ).joinAll() | ||||||
|     } |     } | ||||||
| @ -103,7 +100,6 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | |||||||
|             MediaType.TVSHOW -> tmdbApiController.searchTVShow(seriesCrunchy.title) |             MediaType.TVSHOW -> tmdbApiController.searchTVShow(seriesCrunchy.title) | ||||||
|             else -> NoneTMDBSearch |             else -> NoneTMDBSearch | ||||||
|         } |         } | ||||||
|         println(tmdbSearchResult) |  | ||||||
|  |  | ||||||
|         tmdbResult = if (tmdbSearchResult.results.isNotEmpty()) { |         tmdbResult = if (tmdbSearchResult.results.isNotEmpty()) { | ||||||
|             when (val result = tmdbSearchResult.results.first()) { |             when (val result = tmdbSearchResult.results.first()) { | ||||||
| @ -113,14 +109,22 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | |||||||
|             } |             } | ||||||
|         } else NoneTMDB |         } else NoneTMDB | ||||||
|  |  | ||||||
|         println(tmdbResult) |  | ||||||
|  |  | ||||||
|         // currently not used |         // currently not used | ||||||
| //        tmdbTVSeason = if (tmdbResult is TMDBTVShow) { | //        tmdbTVSeason = if (tmdbResult is TMDBTVShow) { | ||||||
| //            tmdbApiController.getTVSeasonDetails(tmdbResult.id, 0) | //            tmdbApiController.getTVSeasonDetails(tmdbResult.id, 0) | ||||||
| //        } else NoneTMDBTVSeason | //        } else NoneTMDBTVSeason | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get current playheads for all episodes | ||||||
|  |      */ | ||||||
|  |     private fun updatePlayheadsAsync() = viewModelScope.async { | ||||||
|  |         currentPlayheads.clear() | ||||||
|  |         currentPlayheads.putAll( | ||||||
|  |             Crunchyroll.playheads(episodesCrunchy.data.map { it.id }).toPlayheadsMap() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Set currentSeasonCrunchy based on the season id. Also set the new seasons episodes. |      * Set currentSeasonCrunchy based on the season id. Also set the new seasons episodes. | ||||||
|      * |      * | ||||||
| @ -132,13 +136,16 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | |||||||
|  |  | ||||||
|         // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, |         // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, | ||||||
|         // don't change the current season (this should/can never happen) |         // don't change the current season (this should/can never happen) | ||||||
|         currentSeasonCrunchy = seasonsCrunchy.items.firstOrNull { |         currentSeasonCrunchy = seasonsCrunchy.data.firstOrNull { | ||||||
|             it.id == seasonId |             it.id == seasonId | ||||||
|         } ?: currentSeasonCrunchy |         } ?: currentSeasonCrunchy | ||||||
|  |  | ||||||
|         episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) |         episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) | ||||||
|         currentEpisodesCrunchy.clear() |         currentEpisodesCrunchy.clear() | ||||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.items) |         currentEpisodesCrunchy.addAll(episodesCrunchy.data) | ||||||
|  |  | ||||||
|  |         // update playheads playheads (including fully watched state) | ||||||
|  |         updatePlayheadsAsync().await() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun setWatchlist() { |     suspend fun setWatchlist() { | ||||||
| @ -153,25 +160,9 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | |||||||
|  |  | ||||||
|     suspend fun updateOnResume() { |     suspend fun updateOnResume() { | ||||||
|         joinAll( |         joinAll( | ||||||
|             viewModelScope.launch { |             updatePlayheadsAsync(), | ||||||
|                 val episodeIDs = episodesCrunchy.items.map { it.id } |  | ||||||
|                 currentPlayheads.clear() |  | ||||||
|                 currentPlayheads.putAll(Crunchyroll.playheads(episodeIDs)) |  | ||||||
|             }, |  | ||||||
|             viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(seriesCrunchy.id) } |             viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(seriesCrunchy.id) } | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * 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 |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -3,13 +3,14 @@ package org.mosad.teapod.ui.activity.onboarding | |||||||
| import android.content.Intent | import android.content.Intent | ||||||
| import android.os.Bundle | import android.os.Bundle | ||||||
| import android.view.View | import android.view.View | ||||||
|  | import androidx.activity.addCallback | ||||||
| import androidx.appcompat.app.AppCompatActivity | import androidx.appcompat.app.AppCompatActivity | ||||||
| import androidx.fragment.app.Fragment | import androidx.fragment.app.Fragment | ||||||
| import androidx.fragment.app.FragmentActivity | import androidx.fragment.app.FragmentActivity | ||||||
| import androidx.viewpager2.adapter.FragmentStateAdapter | import androidx.viewpager2.adapter.FragmentStateAdapter | ||||||
| import com.google.android.material.tabs.TabLayoutMediator | import com.google.android.material.tabs.TabLayoutMediator | ||||||
| import org.mosad.teapod.ui.activity.main.MainActivity |  | ||||||
| import org.mosad.teapod.databinding.ActivityOnboardingBinding | import org.mosad.teapod.databinding.ActivityOnboardingBinding | ||||||
|  | import org.mosad.teapod.ui.activity.main.MainActivity | ||||||
|  |  | ||||||
| class OnboardingActivity : AppCompatActivity() { | class OnboardingActivity : AppCompatActivity() { | ||||||
|  |  | ||||||
| @ -35,13 +36,11 @@ class OnboardingActivity : AppCompatActivity() { | |||||||
|         if (fragments.size <= 1) { |         if (fragments.size <= 1) { | ||||||
|             binding.tabLayout.visibility = View.GONE |             binding.tabLayout.visibility = View.GONE | ||||||
|         } |         } | ||||||
|     } |  | ||||||
|  |  | ||||||
|     override fun onBackPressed() { |         onBackPressedDispatcher.addCallback { | ||||||
|         if (binding.viewPager.currentItem == 0) { |             if (binding.viewPager.currentItem != 0) { | ||||||
|             super.onBackPressed() |                 binding.viewPager.currentItem = binding.viewPager.currentItem - 1 | ||||||
|         } else { |             } | ||||||
|             binding.viewPager.currentItem = binding.viewPager.currentItem - 1 |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | |||||||
| @ -46,16 +46,19 @@ import androidx.lifecycle.lifecycleScope | |||||||
| import com.google.android.exoplayer2.ExoPlayer | 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.ui.StyledPlayerView | ||||||
| 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,10 +66,12 @@ 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 | ||||||
|     private lateinit var timerUpdates: TimerTask |     private lateinit var controlsUpdates: TimerTask | ||||||
|  |  | ||||||
|     private var wasInPiP = false |     private var wasInPiP = false | ||||||
|     private var remainingTime: Long = 0 |     private var remainingTime: Long = 0 | ||||||
| @ -80,6 +85,9 @@ 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)) | ||||||
|  |         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 +95,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 +112,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 +122,7 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|  |  | ||||||
|         if (Util.SDK_INT <= 23) { |         if (Util.SDK_INT <= 23) { | ||||||
|             initPlayer() |             initPlayer() | ||||||
|             video_view?.onResume() |             playerBinding.videoView.onResume() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -166,7 +174,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) | ||||||
| @ -185,12 +193,16 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|  |  | ||||||
|     override fun onPictureInPictureModeChanged( |     override fun onPictureInPictureModeChanged( | ||||||
|         isInPictureInPictureMode: Boolean, |         isInPictureInPictureMode: Boolean, | ||||||
|         newConfig: Configuration? |         newConfig: Configuration | ||||||
|     ) { |     ) { | ||||||
|         super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) |         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||||||
|  |             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,16 +224,16 @@ 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) { |                 // don't use isVisible to hide exoPlayPause, as it will set the visibility to GONE | ||||||
|                     View.GONE -> View.VISIBLE |                 controlsBinding.exoPlayPause.visibility = when(playerBinding.loading.isVisible) { | ||||||
|                     View.VISIBLE -> View.INVISIBLE |                     true -> View.INVISIBLE | ||||||
|                     else -> View.VISIBLE |                     false -> View.VISIBLE | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) { |                 if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) { | ||||||
| @ -237,10 +249,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(StyledPlayerView.ControllerVisibilityListener { | ||||||
|             when (it) { |             when (it) { | ||||||
|                 View.GONE -> { |                 View.GONE -> { | ||||||
|                     hideBars() |                     hideBars() | ||||||
| @ -248,25 +260,25 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|                 } |                 } | ||||||
|                 View.VISIBLE -> updateControls() |                 View.VISIBLE -> updateControls() | ||||||
|             } |             } | ||||||
|         } |         }) | ||||||
|  |  | ||||||
|         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() { | ||||||
| @ -277,26 +289,28 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun initTimeUpdates() { |     private fun initTimeUpdates() { | ||||||
|         if (this::timerUpdates.isInitialized) { |         if (this::controlsUpdates.isInitialized) { | ||||||
|             timerUpdates.cancel() |             controlsUpdates.cancel() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         timerUpdates = Timer().scheduleAtFixedRate(0, 500) { |         controlsUpdates = 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 | ||||||
|                 if (model.player.duration > 0) { |                 if (model.player.duration > 0) { | ||||||
|                     remainingTime = model.player.duration - currentPosition |                     remainingTime = model.player.duration - currentPosition | ||||||
|                     remainingTime = if (remainingTime < 0) 0 else remainingTime |                     remainingTime = if (remainingTime < 0) 0 else remainingTime | ||||||
|  |                 } else { | ||||||
|  |                     remainingTime = 0 | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 // TODO add metaDB ending_start support |                 // TODO add metaDB ending_start support | ||||||
|                 // if remaining time < 20 sec, a next ep is set, autoplay is enabled and not in pip: |                 // if remaining time > 1 and < 20 sec, a next ep is set, autoplay is enabled | ||||||
|                 // show next ep button |                 // and not in pip: show next ep button | ||||||
|                 if (remainingTime in 1..20000) { |                 if (remainingTime in 1000..20000) { | ||||||
|                     if (!btnNextEpIsVisible && hasNextEpisode() && Preferences.autoplay && !isInPiPMode()) { |                     if (!btnNextEpIsVisible && hasNextEpisode() && Preferences.autoplay && !isInPiPMode()) { | ||||||
|                         showButtonNextEp() |                         showButtonNextEp() | ||||||
|                     } |                     } | ||||||
| @ -304,17 +318,18 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|                     hideButtonNextEp() |                     hideButtonNextEp() | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 // if meta data is present and opening_start & opening_duration are valid, show skip opening |                 // into metadata is present and we can show the skip button | ||||||
|                 model.currentEpisodeMeta?.let { |                 if (model.currentIntroMetadata.duration >= 10) { | ||||||
|                     if (it.openingDuration > 0 && |                     val startTime = model.currentIntroMetadata.startTime.toInt() * 1000 | ||||||
|                         currentPosition in it.openingStart..(it.openingStart + 10000) && |                     if (currentPosition in startTime..(startTime + 10000) && !playerBinding.buttonSkipOp.isVisible) { | ||||||
|                         !button_skip_op.isVisible |  | ||||||
|                     ) { |  | ||||||
|                         showButtonSkipOp() |                         showButtonSkipOp() | ||||||
|                     } else if (button_skip_op.isVisible && currentPosition !in it.openingStart..(it.openingStart + 10000)) { |                     } else if (playerBinding.buttonSkipOp.isVisible && | ||||||
|                         // the button should only be visible, if currentEpisodeMeta != null |                         currentPosition !in startTime..(startTime + 10000) | ||||||
|  |                     ) { | ||||||
|  |                         // the button should only be visible if currentEpisodeMeta != null | ||||||
|                         hideButtonSkipOp() |                         hideButtonSkipOp() | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 // if controls are visible, update them |                 // if controls are visible, update them | ||||||
| @ -326,9 +341,9 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun onPauseOnStop() { |     private fun onPauseOnStop() { | ||||||
|         video_view?.onPause() |         playerBinding.videoView.onPause() | ||||||
|         model.player.pause() |         model.player.pause() | ||||||
|         timerUpdates.cancel() |         controlsUpdates.cancel() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -341,7 +356,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 +374,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,50 +397,58 @@ 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() { | ||||||
|         model.playNextEpisode() |         // disable the next episode buttons, so a user can't double click it | ||||||
|  |         playerBinding.buttonNextEp.isClickable = false | ||||||
|  |         controlsBinding.buttonNextEpC.isClickable = false | ||||||
|  |  | ||||||
|         hideButtonNextEp() |         hideButtonNextEp() | ||||||
|  |         model.playNextEpisode() | ||||||
|  |  | ||||||
|  |         // enable the next episode buttons when playNextEpisode() has returned | ||||||
|  |         playerBinding.buttonNextEp.isClickable = true | ||||||
|  |         controlsBinding.buttonNextEpC.isClickable = true | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun skipOpening() { |     private fun skipOpening() { | ||||||
|         // calculate the seek time |         // calculate the seek time | ||||||
|         model.currentEpisodeMeta?.let { |         if (model.currentIntroMetadata.duration > 10) { | ||||||
|             val seekTime = (it.openingStart + it.openingDuration) - model.player.currentPosition |             val endTime = model.currentIntroMetadata.endTime.toInt() * 1000 | ||||||
|  |             val seekTime = endTime - model.player.currentPosition | ||||||
|             model.seekToOffset(seekTime) |             model.seekToOffset(seekTime) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -433,10 +456,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 +469,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) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -508,7 +524,7 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|         /** |         /** | ||||||
|          * on single tap hide or show the controls |          * on single tap hide or show the controls | ||||||
|          */ |          */ | ||||||
|         override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { |         override fun onSingleTapConfirmed(e: MotionEvent): Boolean { | ||||||
|             if (!isInPiPMode()) { |             if (!isInPiPMode()) { | ||||||
|                 if (controller.isVisible) controller.hide() else controller.show() |                 if (controller.isVisible) controller.hide() else controller.show() | ||||||
|             } |             } | ||||||
| @ -519,9 +535,9 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|         /** |         /** | ||||||
|          * on double tap rewind or forward |          * on double tap rewind or forward | ||||||
|          */ |          */ | ||||||
|         override fun onDoubleTap(e: MotionEvent?): Boolean { |         override fun onDoubleTap(e: MotionEvent): Boolean { | ||||||
|             val eventPosX = e?.x?.toInt() ?: 0 |             val eventPosX = e.x.toInt() | ||||||
|             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() | ||||||
| @ -532,14 +548,14 @@ class PlayerActivity : AppCompatActivity() { | |||||||
|         /** |         /** | ||||||
|          * not used |          * not used | ||||||
|          */ |          */ | ||||||
|         override fun onDoubleTapEvent(e: MotionEvent?): Boolean { |         override fun onDoubleTapEvent(e: MotionEvent): Boolean { | ||||||
|             return true |             return true | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /** |         /** | ||||||
|          * on long press toggle pause/play |          * on long press toggle pause/play | ||||||
|          */ |          */ | ||||||
|         override fun onLongPress(e: MotionEvent?) { |         override fun onLongPress(e: MotionEvent) { | ||||||
|             model.togglePausePlay() |             model.togglePausePlay() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | |||||||
| @ -31,24 +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 kotlinx.coroutines.* | ||||||
| import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory |  | ||||||
| import com.google.android.exoplayer2.util.Util |  | ||||||
| import kotlinx.coroutines.Dispatchers |  | ||||||
| import kotlinx.coroutines.joinAll |  | ||||||
| import kotlinx.coroutines.launch |  | ||||||
| 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.tmdb.TMDBTVSeason | import org.mosad.teapod.util.metadb.Meta | ||||||
|  | import org.mosad.teapod.util.metadb.MetaDBController | ||||||
|  | import org.mosad.teapod.util.metadb.TVShowMeta | ||||||
|  | import org.mosad.teapod.util.toPlayheadsMap | ||||||
| import java.util.* | import java.util.* | ||||||
|  | import kotlin.concurrent.scheduleAtFixedRate | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * PlayerViewModel handles all stuff related to media/episodes. |  * PlayerViewModel handles all stuff related to media/episodes. | ||||||
| @ -56,35 +50,47 @@ 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") | ||||||
|  |     private val playheadAutoUpdate: TimerTask | ||||||
|  |  | ||||||
|     val currentEpisodeChangedListener = ArrayList<() -> Unit>() |     val currentEpisodeChangedListener = ArrayList<() -> Unit>() | ||||||
|     private var currentPlayhead: Long = 0 |     private var currentPlayhead: Long = 0 | ||||||
|  |  | ||||||
|     // tmdb/meta data |     // tmdb/meta data | ||||||
|     // TODO meta data currently not implemented for cr |     var mediaMeta: Meta? = null | ||||||
| //    var mediaMeta: Meta? = null |  | ||||||
| //        internal set |  | ||||||
|     var tmdbTVSeason: TMDBTVSeason? =null |  | ||||||
|         internal set |         internal set | ||||||
|     var currentEpisodeMeta: EpisodeMeta? = null |     var currentEpisodeMeta: EpisodeMeta? = null | ||||||
|         internal set |         internal set | ||||||
|  |     var currentPlayheads = mapOf<String, PlayheadObject>() | ||||||
|  |         internal set | ||||||
|  |     var currentIntroMetadata: DatalabIntro = NoneDatalabIntro | ||||||
|  |         internal set | ||||||
|  | //    var tmdbTVSeason: TMDBTVSeason? =null | ||||||
|  | //        internal set | ||||||
|  |  | ||||||
|     // crunchyroll episodes/playback |     // crunchyroll episodes/playback | ||||||
|     var episodes = NoneEpisodes |     var episodes = NoneEpisodes | ||||||
|         internal set |         internal set | ||||||
|     var currentEpisode = NoneEpisode |     var currentEpisode = NoneEpisode | ||||||
|         internal set |         internal set | ||||||
|     var currentPlayback = NonePlayback |     var currentVersion = NoneVersion | ||||||
|  |         internal set | ||||||
|  |     var currentStreams = NoneStreams | ||||||
|  |         internal set | ||||||
|  |  | ||||||
|     // current playback settings |     // current playback settings | ||||||
|     var currentLanguage: Locale = Preferences.preferredLocale |     var currentAudioLocale: Locale = Preferences.preferredAudioLocale | ||||||
|  |         internal set | ||||||
|  |     var currentSubtitleLocale: Locale = Preferences.preferredSubtitleLocale | ||||||
|         internal set |         internal set | ||||||
|  |  | ||||||
|     init { |     init { | ||||||
|  |         // disable platform diagnostics since they might be shared with google | ||||||
|  |         ExoPlayer.Builder(application).setUsePlatformDiagnostics(false) | ||||||
|  |  | ||||||
|         initMediaSession() |         initMediaSession() | ||||||
|  |  | ||||||
|         player.addListener(object : Player.Listener { |         player.addListener(object : Player.Listener { | ||||||
| @ -100,6 +106,14 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | |||||||
|                 if (!isPlaying) updatePlayhead() |                 if (!isPlaying) updatePlayhead() | ||||||
|             } |             } | ||||||
|         }) |         }) | ||||||
|  |  | ||||||
|  |         playheadAutoUpdate = Timer().scheduleAtFixedRate(0, 30000) { | ||||||
|  |             viewModelScope.launch { | ||||||
|  |                 if (player.isPlaying){ | ||||||
|  |                     updatePlayhead() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     override fun onCleared() { |     override fun onCleared() { | ||||||
| @ -108,7 +122,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") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @ -125,30 +139,48 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | |||||||
|     fun loadMediaAsync(seasonId: String, episodeId: String) = viewModelScope.launch { |     fun loadMediaAsync(seasonId: String, episodeId: String) = viewModelScope.launch { | ||||||
|         episodes = Crunchyroll.episodes(seasonId) |         episodes = Crunchyroll.episodes(seasonId) | ||||||
|  |  | ||||||
|  |         listOf( | ||||||
|  |             viewModelScope.launch { mediaMeta = loadMediaMeta(episodes.data.first().seriesId) }, | ||||||
|  |             viewModelScope.launch { | ||||||
|  |                 val episodeIDs = episodes.data.map { it.id } | ||||||
|  |                 currentPlayheads = Crunchyroll.playheads(episodeIDs).toPlayheadsMap() | ||||||
|  |             } | ||||||
|  |         ).joinAll() | ||||||
|  |         Log.d(classTag, "meta: $mediaMeta") | ||||||
|  |  | ||||||
|         setCurrentEpisode(episodeId) |         setCurrentEpisode(episodeId) | ||||||
|         playCurrentMedia(currentPlayhead) |         playCurrentMedia(currentPlayhead) | ||||||
|  |  | ||||||
|         // TODO reimplement for cr |  | ||||||
|         // run async as it should be loaded by the time the episodes a |  | ||||||
| //        viewModelScope.launch { |  | ||||||
| //            // get tmdb season info, if metaDB knows the tv show |  | ||||||
| //            if (media.type == DataTypes.MediaType.TVSHOW && mediaMeta != null) { |  | ||||||
| //                val tvShowMeta = mediaMeta as TVShowMeta |  | ||||||
| //                tmdbTVSeason = TMDBApiController().getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber) |  | ||||||
| //            } |  | ||||||
| //        } |  | ||||||
| // |  | ||||||
| //        currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisodeAoD.mediaId) |  | ||||||
| //        currentLanguage = currentEpisodeAoD.getPreferredStream(preferredLanguage).language |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun setLanguage(language: Locale) { |     fun setLanguage(newAudioLocale: Locale, newSubtitleLocale: Locale) { | ||||||
|         currentLanguage = language |         // TODO if the audio locale has changes update the streams, if only the subtitle locale has changed load the new stream | ||||||
|         playCurrentMedia(player.currentPosition) |         if (newAudioLocale != currentAudioLocale) { | ||||||
|  |             currentAudioLocale = newAudioLocale | ||||||
|  |  | ||||||
|  |             currentVersion = currentEpisode.versions?.firstOrNull { | ||||||
|  |                 it.audioLocale == currentAudioLocale.toLanguageTag() | ||||||
|  |             } ?: currentEpisode.versions?.first() ?: NoneVersion | ||||||
|  |  | ||||||
|  |             viewModelScope.launch { | ||||||
|  |                 currentStreams = Crunchyroll.streamsFromMediaGUID(currentVersion.mediaGUID) | ||||||
|  |                 Log.d(classTag, currentVersion.toString()) | ||||||
|  |  | ||||||
|  |                 playCurrentMedia(player.currentPosition) | ||||||
|  |             } | ||||||
|  |         } else if (newSubtitleLocale != currentSubtitleLocale) { | ||||||
|  |             currentSubtitleLocale = newSubtitleLocale | ||||||
|  |             playCurrentMedia(player.currentPosition) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // else nothing has changed so no need do do anything | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // player actions |     // player actions | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Seeks to a offset position specified in milliseconds in the current MediaItem. | ||||||
|  |      * @param offset The offset position in the current MediaItem. | ||||||
|  |      */ | ||||||
|     fun seekToOffset(offset: Long) { |     fun seekToOffset(offset: Long) { | ||||||
|         player.seekTo(player.currentPosition + offset) |         player.seekTo(player.currentPosition + offset) | ||||||
|     } |     } | ||||||
| @ -162,42 +194,63 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | |||||||
|      */ |      */ | ||||||
|     fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId -> |     fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId -> | ||||||
|         updatePlayhead() // update playhead before switching to new episode |         updatePlayhead() // update playhead before switching to new episode | ||||||
|         setCurrentEpisode(nextEpisodeId, startPlayback = true) |         viewModelScope.launch { setCurrentEpisode(nextEpisodeId, startPlayback = true) } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Set currentEpisodeCr to the episode of the given ID |      * Set currentEpisodeCr to the episode of the given ID | ||||||
|      * @param episodeId The ID of the episode you want to set currentEpisodeCr to |      * @param episodeId The ID of the episode you want to set currentEpisodeCr to | ||||||
|      */ |      */ | ||||||
|     fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) { |     suspend fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) { | ||||||
|         currentEpisode = episodes.items.find { episode -> |         currentEpisode = episodes.data.find { episode -> | ||||||
|             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() } | ||||||
|  |  | ||||||
|         // needs to be blocking, currentPlayback must be present when calling playCurrentMedia() |         // needs to be blocking, currentPlayback must be present when calling playCurrentMedia() | ||||||
|         runBlocking { |         joinAll( | ||||||
|             joinAll( |             viewModelScope.launch(Dispatchers.IO) { | ||||||
|                 viewModelScope.launch(Dispatchers.IO) { |                 currentVersion = currentEpisode.versions?.firstOrNull { | ||||||
|                     currentPlayback = Crunchyroll.playback(currentEpisode.playback) |                     it.audioLocale == currentAudioLocale.toLanguageTag() | ||||||
|                 }, |                 } ?: currentEpisode.versions?.first() ?: NoneVersion | ||||||
|                 viewModelScope.launch(Dispatchers.IO) { |  | ||||||
|                     Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let { |                 // get the current streams object, if no version is set, use streamsLink | ||||||
|                         // if the episode was fully watched, start at the beginning |                 currentStreams = if (currentVersion != NoneVersion) { | ||||||
|                         currentPlayhead = if (it.fullyWatched) { |                     Crunchyroll.streamsFromMediaGUID(currentVersion.mediaGUID) | ||||||
|                             0 |                 } else { | ||||||
|                         } else { |                     Crunchyroll.streams(currentEpisode.streamsLink) | ||||||
|                             (it.playhead.times(1000)).toLong() |                 } | ||||||
|                         } |                 Log.d(classTag, currentVersion.toString()) | ||||||
|  |             }, | ||||||
|  |             viewModelScope.launch(Dispatchers.IO) { | ||||||
|  |                 Crunchyroll.playheads(listOf(currentEpisode.id)).data.firstOrNull { | ||||||
|  |                     it.contentId == currentEpisode.id | ||||||
|  |                 }?.let { | ||||||
|  |                     // if the episode was fully watched, start at the beginning | ||||||
|  |                     currentPlayhead = if (it.fullyWatched) { | ||||||
|  |                         0 | ||||||
|  |                     } else { | ||||||
|  |                         (it.playhead.times(1000)).toLong() | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             ) |             }, | ||||||
|         } |             viewModelScope.launch(Dispatchers.IO) { | ||||||
|         println("loaded playback ${currentEpisode.playback}") |                 currentIntroMetadata = Crunchyroll.datalabIntro(currentEpisode.id) | ||||||
|  |             } | ||||||
|         // TODO update metadata and language (it should not be needed to update the language here!) |         ) | ||||||
|  |         Log.d(classTag, "streams: ${currentEpisode.streamsLink}") | ||||||
|  |  | ||||||
|         if (startPlayback) { |         if (startPlayback) { | ||||||
|             playCurrentMedia() |             playCurrentMedia() | ||||||
| @ -205,38 +258,35 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Play the current media from currentPlaybackCr. |      * Play the current media from currentStreams. | ||||||
|      * |      * | ||||||
|      * @param seekPosition The seek position for the episode (default = 0). |      * @param seekPosition The seek position for the media (default = 0). | ||||||
|      */ |      */ | ||||||
|     fun playCurrentMedia(seekPosition: Long = 0) { |     fun playCurrentMedia(seekPosition: Long = 0) { | ||||||
|         // get preferred stream url, set current language if it differs from the preferred one |         // get preferred stream url, set current language if it differs from the preferred one | ||||||
|         val preferredLocale = currentLanguage |         val preferredLocale = currentSubtitleLocale | ||||||
|         val fallbackLocal = Locale.US |         val fallbackLocal = Locale.US | ||||||
|         val url = when { |         val url = when { | ||||||
|             currentPlayback.streams.adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> { |             currentStreams.data[0].adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> { | ||||||
|                 currentPlayback.streams.adaptive_hls[preferredLocale.toLanguageTag()]?.url |                 currentStreams.data[0].adaptive_hls[preferredLocale.toLanguageTag()]?.url | ||||||
|             } |             } | ||||||
|             currentPlayback.streams.adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> { |             currentStreams.data[0].adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> { | ||||||
|                 currentLanguage = fallbackLocal |                 currentSubtitleLocale = fallbackLocal | ||||||
|                 currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url |                 currentStreams.data[0].adaptive_hls[fallbackLocal.toLanguageTag()]?.url | ||||||
|             } |             } | ||||||
|             else -> { |             else -> { | ||||||
|                 // if no language tag is present use the first entry |                 // if no language tag is present use the first entry | ||||||
|                 currentLanguage = Locale.ROOT |                 currentSubtitleLocale = Locale.ROOT | ||||||
|                 currentPlayback.streams.adaptive_hls.entries.first().value.url |                 currentStreams.data[0].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,28 +313,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | |||||||
|      * @return Boolean: true if it is the last, else false. |      * @return Boolean: true if it is the last, else false. | ||||||
|      */ |      */ | ||||||
|     fun currentEpisodeIsLastEpisode(): Boolean { |     fun currentEpisodeIsLastEpisode(): Boolean { | ||||||
|         return episodes.items.lastOrNull()?.id == currentEpisode.id |         return episodes.data.lastOrNull()?.id == currentEpisode.id | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // TODO reimplement for cr |     private suspend fun loadMediaMeta(crSeriesId: String): Meta? { | ||||||
| //    fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? { |         return MetaDBController.getTVShowMetadata(crSeriesId) | ||||||
| //        val meta = mediaMeta |     } | ||||||
| //        return if (meta is TVShowMeta) { |  | ||||||
| //            meta.episodes.firstOrNull { it.aodMediaId == aodMediaId } |  | ||||||
| //        } else { |  | ||||||
| //            null |  | ||||||
| //        } |  | ||||||
| //    } |  | ||||||
| // |  | ||||||
| //    private suspend fun loadMediaMeta(aodId: Int): Meta? { |  | ||||||
| //        return if (media.type == DataTypes.MediaType.TVSHOW) { |  | ||||||
| //            MetaDBController().getTVShowMetadata(aodId) |  | ||||||
| //        } else { |  | ||||||
| //            null |  | ||||||
| //        } |  | ||||||
| // |  | ||||||
| //        return null |  | ||||||
| //    } |  | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Update the playhead of the current episode, if currentPosition > 1000ms. |      * Update the playhead of the current episode, if currentPosition > 1000ms. | ||||||
| @ -292,10 +326,16 @@ 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()) } |             // don't use viewModelScope here. This task may needs to finish, when ViewModel will be cleared | ||||||
|  |             CoroutineScope(Dispatchers.IO).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.data.map { it.id } | ||||||
|  |             currentPlayheads = Crunchyroll.playheads(episodeIDs).toPlayheadsMap() | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -0,0 +1,72 @@ | |||||||
|  | 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 kotlinx.coroutines.runBlocking | ||||||
|  | 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.data, | ||||||
|  |             null, | ||||||
|  |             model.currentPlayheads, | ||||||
|  |             EpisodeItemAdapter.OnClickListener { episode -> | ||||||
|  |                 dismiss() | ||||||
|  |                 // TODO make this none blocking, if necessary? | ||||||
|  |                 runBlocking { | ||||||
|  |                     model.setCurrentEpisode(episode.id, startPlayback = true) | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             EpisodeItemAdapter.ViewType.PLAYER | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         // get the position/index of the currently playing episode | ||||||
|  |         adapterRecEpisodes.currentSelected = model.episodes.data.indexOfFirst { it.id == model.currentEpisode.id } | ||||||
|  |  | ||||||
|  |         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() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -0,0 +1,152 @@ | |||||||
|  | package org.mosad.teapod.ui.activity.player.fragment | ||||||
|  |  | ||||||
|  | import android.content.DialogInterface | ||||||
|  | import android.graphics.Color | ||||||
|  | import android.graphics.Typeface | ||||||
|  | import android.os.Bundle | ||||||
|  | import android.util.TypedValue | ||||||
|  | import android.view.Gravity | ||||||
|  | import android.view.LayoutInflater | ||||||
|  | import android.view.View | ||||||
|  | import android.view.ViewGroup | ||||||
|  | import android.widget.LinearLayout | ||||||
|  | import android.widget.TextView | ||||||
|  | import androidx.core.view.children | ||||||
|  | import androidx.fragment.app.DialogFragment | ||||||
|  | import androidx.lifecycle.ViewModelProvider | ||||||
|  | import org.mosad.teapod.R | ||||||
|  | import org.mosad.teapod.databinding.PlayerLanguageSettingsBinding | ||||||
|  | import org.mosad.teapod.ui.activity.player.PlayerViewModel | ||||||
|  | import org.mosad.teapod.util.hideBars | ||||||
|  | import java.util.* | ||||||
|  |  | ||||||
|  | class LanguageSettingsDialogFragment : DialogFragment() { | ||||||
|  |  | ||||||
|  |     private lateinit var model: PlayerViewModel | ||||||
|  |     private lateinit var binding: PlayerLanguageSettingsBinding | ||||||
|  |  | ||||||
|  |     private var selectedSubtitleLocale = Locale.ROOT | ||||||
|  |     private var selectedAudioLocale = Locale.ROOT | ||||||
|  |  | ||||||
|  |     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] | ||||||
|  |         selectedSubtitleLocale = model.currentSubtitleLocale | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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) | ||||||
|  |  | ||||||
|  |         var selectedSubtitleView: TextView? = null | ||||||
|  |         model.currentStreams.data[0].adaptive_hls.keys.forEach { languageTag -> | ||||||
|  |             val locale = Locale.forLanguageTag(languageTag) | ||||||
|  |             val subtitleView = addLanguage(binding.linearSubtitleLanguages, locale) { v -> | ||||||
|  |                 selectedSubtitleLocale = locale | ||||||
|  |                 updateSelectedLanguage(binding.linearSubtitleLanguages, v as TextView) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // if the view is the currently selected one, highlight it | ||||||
|  |             if (locale == model.currentSubtitleLocale) { | ||||||
|  |                 selectedSubtitleView = subtitleView | ||||||
|  |                 updateSelectedLanguage(binding.linearSubtitleLanguages, subtitleView) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val currentAudioLocal = Locale.forLanguageTag(model.currentVersion.audioLocale) | ||||||
|  |         var selectedAudioView: TextView? = null | ||||||
|  |         model.currentEpisode.versions?.forEach { version -> | ||||||
|  |             val locale = Locale.forLanguageTag(version.audioLocale) | ||||||
|  |             val audioView = addLanguage(binding.linearAudioLanguages, locale) { v -> | ||||||
|  |                 selectedAudioLocale = locale | ||||||
|  |                 updateSelectedLanguage(binding.linearAudioLanguages, v as TextView) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // if the view is the currently selected one, highlight it | ||||||
|  |             if (locale == currentAudioLocal) { | ||||||
|  |                 selectedAudioView = audioView | ||||||
|  |                 updateSelectedLanguage(binding.linearAudioLanguages, audioView) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         binding.buttonCloseLanguageSettings.setOnClickListener { dismiss() } | ||||||
|  |         binding.buttonCancel.setOnClickListener { dismiss() } | ||||||
|  |         binding.buttonSelect.setOnClickListener { | ||||||
|  |             model.setLanguage(selectedAudioLocale, selectedSubtitleLocale) | ||||||
|  |             dismiss() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // initially hide the status and navigation bar | ||||||
|  |         hideBars(requireDialog().window, binding.root) | ||||||
|  |  | ||||||
|  |         // scroll to the position of the view, if it's the selected language | ||||||
|  |         binding.scrollSubtitleLanguages.post { | ||||||
|  |             binding.scrollSubtitleLanguages.scrollTo(0, selectedSubtitleView?.top ?: 0) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         binding.scrollAudioLanguages.post { | ||||||
|  |             binding.scrollSubtitleLanguages.scrollTo(0, selectedAudioView?.top ?: 0) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onDismiss(dialog: DialogInterface) { | ||||||
|  |         super.onDismiss(dialog) | ||||||
|  |         model.player.play() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun addLanguage(linear: LinearLayout, locale: Locale, onClick: View.OnClickListener): TextView { | ||||||
|  |         val text = TextView(context).apply { | ||||||
|  |             height = 96 | ||||||
|  |             gravity = Gravity.CENTER_VERTICAL | ||||||
|  |             text = if (locale == Locale.ROOT) context.getString(R.string.no_subtitles) else locale.displayLanguage | ||||||
|  |             setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) | ||||||
|  |             setTextColor(context.resources.getColor(R.color.player_text, context.theme)) | ||||||
|  |             setPadding(75, 0, 0, 0) | ||||||
|  |  | ||||||
|  |             setOnClickListener(onClick) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         linear.addView(text) | ||||||
|  |  | ||||||
|  |         return text | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Highlights the selected audio/subtitle language | ||||||
|  |      * | ||||||
|  |      * @param languageLayout The audio/subtitle Layout to update | ||||||
|  |      * @param selected The newly selected language TextView | ||||||
|  |      */ | ||||||
|  |     private fun updateSelectedLanguage(languageLayout: LinearLayout, selected: TextView) { | ||||||
|  |         // rest all tf to not selected style | ||||||
|  |         languageLayout.children.forEach { child -> | ||||||
|  |             if (child is TextView) { | ||||||
|  |                 child.apply { | ||||||
|  |                     setTextColor(context.resources.getColor(R.color.player_text, context.theme)) | ||||||
|  |                     setTypeface(null, Typeface.NORMAL) | ||||||
|  |                     setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) | ||||||
|  |                     setPadding(75, 0, 0, 0) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // set selected to selected style | ||||||
|  |         selected.apply { | ||||||
|  |             setTextColor(context.resources.getColor(R.color.player_white, context.theme)) | ||||||
|  |             setTypeface(null, Typeface.BOLD) | ||||||
|  |             setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0) | ||||||
|  |             setPadding(0, 0, 0, 0) | ||||||
|  |             compoundDrawablesRelative.getOrNull(0)?.setTint(Color.WHITE) | ||||||
|  |             compoundDrawablePadding = 12 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -0,0 +1,31 @@ | |||||||
|  | package org.mosad.teapod.ui.components | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import android.util.AttributeSet | ||||||
|  | import android.view.KeyEvent | ||||||
|  | import android.widget.TextView | ||||||
|  | import androidx.appcompat.R | ||||||
|  | import androidx.appcompat.widget.SearchView | ||||||
|  |  | ||||||
|  | // see https://stackoverflow.com/questions/30046201/android-searchview-empty-query-doesnt-work | ||||||
|  | class EmptySubmitSearchView : SearchView { | ||||||
|  |  | ||||||
|  |     constructor(context: Context) : super(context) | ||||||
|  |     constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) | ||||||
|  |     constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) | ||||||
|  |  | ||||||
|  |     override fun setOnQueryTextListener(listener: OnQueryTextListener?) { | ||||||
|  |         super.setOnQueryTextListener(listener) | ||||||
|  |  | ||||||
|  |         findViewById<SearchAutoComplete?>(R.id.search_src_text).setOnEditorActionListener { _: TextView?, _: Int, event: KeyEvent? -> | ||||||
|  |             if (event != null && event.keyCode == KeyEvent.KEYCODE_ENTER) { | ||||||
|  |                 listener?.onQueryTextSubmit(query.toString()) | ||||||
|  |             } else { | ||||||
|  |                 listener?.onQueryTextSubmit(query.toString()) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             false | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @ -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) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @ -28,7 +28,7 @@ class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(con | |||||||
|             repeatCount = 1 |             repeatCount = 1 | ||||||
|             repeatMode = ObjectAnimator.REVERSE |             repeatMode = ObjectAnimator.REVERSE | ||||||
|             addListener(object : AnimatorListenerAdapter() { |             addListener(object : AnimatorListenerAdapter() { | ||||||
|                 override fun onAnimationStart(animation: Animator?) { |                 override fun onAnimationStart(animation: Animator) { | ||||||
|                     binding.imageButton.isEnabled = false // disable button |                     binding.imageButton.isEnabled = false // disable button | ||||||
|                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_24) |                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_24) | ||||||
|                 } |                 } | ||||||
| @ -39,7 +39,7 @@ class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(con | |||||||
|             duration = animationDuration |             duration = animationDuration | ||||||
|             addListener(object : AnimatorListenerAdapter() { |             addListener(object : AnimatorListenerAdapter() { | ||||||
|                 // the label animation takes longer then the button animation, reset stuff in here |                 // the label animation takes longer then the button animation, reset stuff in here | ||||||
|                 override fun onAnimationEnd(animation: Animator?) { |                 override fun onAnimationEnd(animation: Animator) { | ||||||
|                     binding.imageButton.isEnabled = true // enable button |                     binding.imageButton.isEnabled = true // enable button | ||||||
|                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_10_24) |                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_10_24) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,105 +0,0 @@ | |||||||
| package org.mosad.teapod.ui.components |  | ||||||
|  |  | ||||||
| import android.content.Context |  | ||||||
| import android.graphics.Color |  | ||||||
| import android.graphics.Typeface |  | ||||||
| import android.util.AttributeSet |  | ||||||
| import android.util.TypedValue |  | ||||||
| import android.view.Gravity |  | ||||||
| import android.view.LayoutInflater |  | ||||||
| import android.view.ViewGroup |  | ||||||
| import android.widget.LinearLayout |  | ||||||
| import android.widget.TextView |  | ||||||
| import androidx.core.view.children |  | ||||||
| import org.mosad.teapod.R |  | ||||||
| import org.mosad.teapod.databinding.PlayerLanguageSettingsBinding |  | ||||||
| import org.mosad.teapod.ui.activity.player.PlayerViewModel |  | ||||||
| import java.util.* |  | ||||||
|  |  | ||||||
| // TODO port to 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) |  | ||||||
|     var onViewRemovedAction: (() -> Unit)? = null |  | ||||||
|  |  | ||||||
|     private var selectedLocale = model?.currentLanguage ?: Locale.ROOT |  | ||||||
|  |  | ||||||
|     init { |  | ||||||
|         model?.let { m -> |  | ||||||
|             m.currentPlayback.streams.adaptive_hls.keys.forEach { languageTag -> |  | ||||||
|                 val locale = Locale.forLanguageTag(languageTag) |  | ||||||
|                 addLanguage(locale, locale == m.currentLanguage) { v -> |  | ||||||
|                     selectedLocale = locale |  | ||||||
|                     updateSelectedLanguage(v as TextView) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         binding.buttonCloseLanguageSettings.setOnClickListener { close() } |  | ||||||
|         binding.buttonCancel.setOnClickListener { close() } |  | ||||||
|         binding.buttonSelect.setOnClickListener { |  | ||||||
|             model?.setLanguage(selectedLocale) |  | ||||||
|             close() |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: OnClickListener) { |  | ||||||
|         val text = TextView(context).apply { |  | ||||||
|             height = 96 |  | ||||||
|             gravity = Gravity.CENTER_VERTICAL |  | ||||||
|             text = if (locale == Locale.ROOT) context.getString(R.string.no_subtitles) else locale.displayLanguage |  | ||||||
|             setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) |  | ||||||
|  |  | ||||||
|             if (isSelected) { |  | ||||||
|                 setTextColor(context.resources.getColor(R.color.exo_white, context.theme)) |  | ||||||
|                 setTypeface(null, Typeface.BOLD) |  | ||||||
|                 setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0) |  | ||||||
|                 compoundDrawablesRelative.getOrNull(0)?.setTint(Color.WHITE) |  | ||||||
|                 compoundDrawablePadding = 12 |  | ||||||
|             } else { |  | ||||||
|                 setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme)) |  | ||||||
|                 setPadding(75, 0, 0, 0) |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             setOnClickListener(onClick) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         binding.linearLanguages.addView(text) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private fun updateSelectedLanguage(selected: TextView) { |  | ||||||
|         // rest all tf to not selected style |  | ||||||
|         binding.linearLanguages.children.forEach { child -> |  | ||||||
|             if (child is TextView) { |  | ||||||
|                 child.apply { |  | ||||||
|                     setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme)) |  | ||||||
|                     setTypeface(null, Typeface.NORMAL) |  | ||||||
|                     setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) |  | ||||||
|                     setPadding(75, 0, 0, 0) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // set selected to selected style |  | ||||||
|         selected.apply { |  | ||||||
|             setTextColor(context.resources.getColor(R.color.exo_white, context.theme)) |  | ||||||
|             setTypeface(null, Typeface.BOLD) |  | ||||||
|             setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0) |  | ||||||
|             setPadding(0, 0, 0, 0) |  | ||||||
|             compoundDrawablesRelative.getOrNull(0)?.setTint(Color.WHITE) |  | ||||||
|             compoundDrawablePadding = 12 |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private fun close() { |  | ||||||
|         (this.parent as ViewGroup).removeView(this) |  | ||||||
|         onViewRemovedAction?.invoke() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @ -1,94 +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 |  | ||||||
|  |  | ||||||
| // TODO rework and port away from MaterialDialog |  | ||||||
| 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) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -28,7 +28,7 @@ class RewindButton(context: Context, attrs: AttributeSet): FrameLayout(context, | |||||||
|             repeatCount = 1 |             repeatCount = 1 | ||||||
|             repeatMode = ObjectAnimator.REVERSE |             repeatMode = ObjectAnimator.REVERSE | ||||||
|             addListener(object : AnimatorListenerAdapter() { |             addListener(object : AnimatorListenerAdapter() { | ||||||
|                 override fun onAnimationStart(animation: Animator?) { |                 override fun onAnimationStart(animation: Animator) { | ||||||
|                     binding.imageButton.isEnabled = false // disable button |                     binding.imageButton.isEnabled = false // disable button | ||||||
|                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_24) |                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_24) | ||||||
|                 } |                 } | ||||||
| @ -38,7 +38,7 @@ class RewindButton(context: Context, attrs: AttributeSet): FrameLayout(context, | |||||||
|         labelAnimation = ObjectAnimator.ofFloat(binding.textView, View.TRANSLATION_X, -35f).apply { |         labelAnimation = ObjectAnimator.ofFloat(binding.textView, View.TRANSLATION_X, -35f).apply { | ||||||
|             duration = animationDuration |             duration = animationDuration | ||||||
|             addListener(object : AnimatorListenerAdapter() { |             addListener(object : AnimatorListenerAdapter() { | ||||||
|                 override fun onAnimationEnd(animation: Animator?) { |                 override fun onAnimationEnd(animation: Animator) { | ||||||
|                     binding.imageButton.isEnabled = true // enable button |                     binding.imageButton.isEnabled = true // enable button | ||||||
|                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_10_24) |                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_10_24) | ||||||
|  |  | ||||||
|  | |||||||
| @ -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 { | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ class DataTypes { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     enum class Theme(val str: String) { |     enum class Theme(val str: String) { | ||||||
|  |         SYSTEM("System"), | ||||||
|         LIGHT("Light"), |         LIGHT("Light"), | ||||||
|         DARK("Dark") |         DARK("Dark") | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -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,30 @@ | |||||||
| package org.mosad.teapod.util | package org.mosad.teapod.util | ||||||
|  |  | ||||||
|  | import android.content.Intent | ||||||
|  | import android.view.View | ||||||
|  | import android.view.Window | ||||||
| import android.widget.TextView | import android.widget.TextView | ||||||
| import org.mosad.teapod.parser.crunchyroll.Collection | import androidx.core.view.WindowCompat | ||||||
| import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem | import androidx.core.view.WindowInsetsCompat | ||||||
|  | import androidx.core.view.WindowInsetsControllerCompat | ||||||
|  | import androidx.fragment.app.Fragment | ||||||
|  | import org.mosad.teapod.R | ||||||
|  | import org.mosad.teapod.parser.crunchyroll.CollectionV2 | ||||||
| import org.mosad.teapod.parser.crunchyroll.Item | import org.mosad.teapod.parser.crunchyroll.Item | ||||||
| import java.util.* | import org.mosad.teapod.parser.crunchyroll.PlayheadObject | ||||||
|  | import org.mosad.teapod.ui.activity.player.PlayerActivity | ||||||
|  | import java.util.Locale | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Create a Intent for PlayerActivity with season and episode id. | ||||||
|  |  * | ||||||
|  |  * @param seasonId The ID of the season the episode to be played is in | ||||||
|  |  * @param episodeId The ID of the episode to play | ||||||
|  |  */ | ||||||
|  | fun Fragment.playerIntent(seasonId: String, episodeId: String) = Intent(context, PlayerActivity::class.java).apply { | ||||||
|  |     putExtra(getString(R.string.intent_season_id), seasonId) | ||||||
|  |     putExtra(getString(R.string.intent_episode_id), episodeId) | ||||||
|  | } | ||||||
|  |  | ||||||
| fun TextView.setDrawableTop(drawable: Int) { | fun TextView.setDrawableTop(drawable: Int) { | ||||||
|     this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0) |     this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0) | ||||||
| @ -15,22 +35,16 @@ fun <T> concatenate(vararg lists: List<T>): List<T> { | |||||||
| } | } | ||||||
|  |  | ||||||
| // TODO move to correct location | // TODO move to correct location | ||||||
| fun Collection<Item>.toItemMediaList(): List<ItemMedia> { | fun CollectionV2<Item>.toItemMediaList(): List<ItemMedia> { | ||||||
|     return this.items.map { |     return this.data.map { | ||||||
|         ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source) |         ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @JvmName("toItemMediaListContinueWatchingItem") | @JvmName("toItemMediaListItem") | ||||||
| fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> { | fun List<Item>.toItemMediaList(): List<ItemMedia> { | ||||||
|     return items.map { |  | ||||||
|         ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fun List<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> { |  | ||||||
|     return this.map { |     return this.map { | ||||||
|         ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source) |         ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @ -43,3 +57,17 @@ fun Locale.toDisplayString(fallback: String): String { | |||||||
|         fallback |         fallback | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | fun CollectionV2<PlayheadObject>.toPlayheadsMap(): Map<String, PlayheadObject> { | ||||||
|  |     return this.data.associateBy { it.contentId } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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,166 @@ 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.PlayheadsMap | import org.mosad.teapod.parser.crunchyroll.PlayheadObject | ||||||
| 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: Map<String, PlayheadObject>, | ||||||
| ) : 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,73 @@ | |||||||
|  | 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.UpNextAccountItem | ||||||
|  |  | ||||||
|  | class MediaEpisodeListAdapter(private val onClickListener: OnClickListener, private val itemOffset: Int = 0) : ListAdapter<UpNextAccountItem, MediaEpisodeListAdapter.MediaViewHolder>(DiffCallback) { | ||||||
|  |  | ||||||
|  |     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { | ||||||
|  |         val binding = ItemMediaBinding.inflate( | ||||||
|  |             LayoutInflater.from(parent.context), | ||||||
|  |             parent, | ||||||
|  |             false | ||||||
|  |         ) | ||||||
|  |         binding.root.layoutParams.apply { | ||||||
|  |             width = (parent.measuredWidth / parent.context.resources.getInteger(R.integer.item_media_columns)) - itemOffset | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return MediaViewHolder(binding) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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: UpNextAccountItem) { | ||||||
|  |             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<UpNextAccountItem>() { | ||||||
|  |         override fun areItemsTheSame(oldItem: UpNextAccountItem, newItem: UpNextAccountItem): Boolean { | ||||||
|  |             return oldItem.panel.id == newItem.panel.id | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun areContentsTheSame(oldItem: UpNextAccountItem, newItem: UpNextAccountItem): Boolean { | ||||||
|  |             return oldItem == newItem | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class OnClickListener(val clickListener: (item: UpNextAccountItem) -> Unit) { | ||||||
|  |         fun onClick(item: UpNextAccountItem) = clickListener(item) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -1,41 +0,0 @@ | |||||||
| package org.mosad.teapod.util.adapter |  | ||||||
|  |  | ||||||
| import android.view.LayoutInflater |  | ||||||
| import android.view.ViewGroup |  | ||||||
| import androidx.recyclerview.widget.RecyclerView |  | ||||||
| import com.bumptech.glide.Glide |  | ||||||
| import org.mosad.teapod.databinding.ItemMediaBinding |  | ||||||
| import org.mosad.teapod.util.ItemMedia |  | ||||||
|  |  | ||||||
| class MediaItemAdapter(private val items: List<ItemMedia>) : RecyclerView.Adapter<MediaItemAdapter.MediaViewHolder>() { |  | ||||||
|  |  | ||||||
|     var onItemClick: ((id: String, position: Int) -> Unit)? = null |  | ||||||
|  |  | ||||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaItemAdapter.MediaViewHolder { |  | ||||||
|         return MediaViewHolder(ItemMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false)) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     override fun onBindViewHolder(holder: MediaItemAdapter.MediaViewHolder, position: Int) { |  | ||||||
|         holder.binding.root.apply { |  | ||||||
|             holder.binding.textTitle.text = items[position].title |  | ||||||
|             Glide.with(context).load(items[position].posterUrl).into(holder.binding.imagePoster) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     override fun getItemCount(): Int { |  | ||||||
|         return items.size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     inner class MediaViewHolder(val binding: ItemMediaBinding) : |  | ||||||
|         RecyclerView.ViewHolder(binding.root) { |  | ||||||
|         init { |  | ||||||
|             binding.root.setOnClickListener { |  | ||||||
|                 onItemClick?.invoke( |  | ||||||
|                     items[bindingAdapterPosition].id, |  | ||||||
|                     bindingAdapterPosition |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @ -0,0 +1,65 @@ | |||||||
|  | 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.R | ||||||
|  | import org.mosad.teapod.databinding.ItemMediaBinding | ||||||
|  | import org.mosad.teapod.util.ItemMedia | ||||||
|  |  | ||||||
|  | class MediaItemListAdapter(private val onClickListener: OnClickListener, private val itemOffset: Int = 0) : ListAdapter<ItemMedia, MediaItemListAdapter.MediaViewHolder>(DiffCallback) { | ||||||
|  |  | ||||||
|  |     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { | ||||||
|  |         val binding = ItemMediaBinding.inflate( | ||||||
|  |             LayoutInflater.from(parent.context), | ||||||
|  |             parent, | ||||||
|  |             false | ||||||
|  |         ) | ||||||
|  |         binding.root.layoutParams.apply { | ||||||
|  |             width = (parent.measuredWidth / parent.context.resources.getInteger(R.integer.item_media_columns)) - itemOffset | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return MediaViewHolder(binding) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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.root.context) | ||||||
|  |                 .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,89 @@ | |||||||
|  | /** | ||||||
|  |  * 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.call.* | ||||||
|  | import io.ktor.client.plugins.* | ||||||
|  | import io.ktor.client.plugins.contentnegotiation.* | ||||||
|  | import io.ktor.client.request.* | ||||||
|  | import io.ktor.http.* | ||||||
|  | import io.ktor.serialization.kotlinx.json.* | ||||||
|  | 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(ContentNegotiation) { | ||||||
|  |             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").body() | ||||||
|  |         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").body() | ||||||
|  |             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 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @ -25,10 +25,10 @@ package org.mosad.teapod.util.tmdb | |||||||
| import android.util.Log | import android.util.Log | ||||||
| import io.ktor.client.* | import io.ktor.client.* | ||||||
| import io.ktor.client.call.* | import io.ktor.client.call.* | ||||||
| import io.ktor.client.features.json.* | import io.ktor.client.plugins.contentnegotiation.* | ||||||
| import io.ktor.client.features.json.serializer.* |  | ||||||
| import io.ktor.client.request.* | import io.ktor.client.request.* | ||||||
| import io.ktor.client.statement.* | import io.ktor.client.statement.* | ||||||
|  | import io.ktor.serialization.kotlinx.json.* | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.coroutineScope | import kotlinx.coroutines.coroutineScope | ||||||
| import kotlinx.coroutines.invoke | import kotlinx.coroutines.invoke | ||||||
| @ -46,10 +46,11 @@ import org.mosad.teapod.util.concatenate | |||||||
| class TMDBApiController { | class TMDBApiController { | ||||||
|     private val classTag = javaClass.name |     private val classTag = javaClass.name | ||||||
|  |  | ||||||
|     private val json = Json { ignoreUnknownKeys = true } |  | ||||||
|     private val client = HttpClient { |     private val client = HttpClient { | ||||||
|         install(JsonFeature) { |         install(ContentNegotiation) { | ||||||
|             serializer = KotlinxSerializer(json) |             json(Json { | ||||||
|  |                 ignoreUnknownKeys = true | ||||||
|  |             }) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -66,7 +67,7 @@ class TMDBApiController { | |||||||
|     ): T = coroutineScope { |     ): T = coroutineScope { | ||||||
|         val path = "$apiUrl$endpoint" |         val path = "$apiUrl$endpoint" | ||||||
|         val params = concatenate( |         val params = concatenate( | ||||||
|             listOf("api_key" to apiKey, "language" to Preferences.preferredLocale.language), |             listOf("api_key" to apiKey, "language" to Preferences.preferredSubtitleLocale.toLanguageTag()), | ||||||
|             parameters |             parameters | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -78,7 +79,7 @@ class TMDBApiController { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             response.receive<T>() |             response.body<T>() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -89,7 +90,7 @@ class TMDBApiController { | |||||||
|      * NoneTMDBSearchMovie if nothing was found |      * NoneTMDBSearchMovie if nothing was found | ||||||
|      */ |      */ | ||||||
|     suspend fun searchMovie(query: String): TMDBSearch<TMDBSearchResultMovie> { |     suspend fun searchMovie(query: String): TMDBSearch<TMDBSearchResultMovie> { | ||||||
|         val searchEndpoint = "/search/multi" |         val searchEndpoint = "/search/movie" | ||||||
|         val parameters = listOf("query" to query, "include_adult" to false) |         val parameters = listOf("query" to query, "include_adult" to false) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|  | |||||||
| @ -32,7 +32,7 @@ import kotlinx.serialization.Serializable | |||||||
|  |  | ||||||
| interface TMDBResult { | interface TMDBResult { | ||||||
|     val id: Int |     val id: Int | ||||||
|     val name: String |     val name: String? // for movies tmdb return string or null | ||||||
|     val overview: String? // for movies tmdb return string or null |     val overview: String? // for movies tmdb return string or null | ||||||
|     val posterPath: String? |     val posterPath: String? | ||||||
|     val backdropPath: String? |     val backdropPath: String? | ||||||
| @ -40,7 +40,7 @@ interface TMDBResult { | |||||||
|  |  | ||||||
| data class TMDBBase( | data class TMDBBase( | ||||||
|     override val id: Int, |     override val id: Int, | ||||||
|     override val name: String, |     override val name: String?, | ||||||
|     override val overview: String?, |     override val overview: String?, | ||||||
|     override val posterPath: String?, |     override val posterPath: String?, | ||||||
|     override val backdropPath: String? |     override val backdropPath: String? | ||||||
| @ -59,7 +59,7 @@ data class TMDBSearch<T>( | |||||||
| @Serializable | @Serializable | ||||||
| data class TMDBSearchResultMovie( | data class TMDBSearchResultMovie( | ||||||
|     @SerialName("id") override val id: Int, |     @SerialName("id") override val id: Int, | ||||||
|     @SerialName("title") override val name: String, |     @SerialName("title") override val name: String?, | ||||||
|     @SerialName("overview") override val overview: String?, |     @SerialName("overview") override val overview: String?, | ||||||
|     @SerialName("poster_path") override val posterPath: String?, |     @SerialName("poster_path") override val posterPath: String?, | ||||||
|     @SerialName("backdrop_path") override val backdropPath: String?, |     @SerialName("backdrop_path") override val backdropPath: String?, | ||||||
| @ -68,7 +68,7 @@ data class TMDBSearchResultMovie( | |||||||
| @Serializable | @Serializable | ||||||
| data class TMDBSearchResultTVShow( | data class TMDBSearchResultTVShow( | ||||||
|     @SerialName("id") override val id: Int, |     @SerialName("id") override val id: Int, | ||||||
|     @SerialName("name") override val name: String, |     @SerialName("name") override val name: String?, | ||||||
|     @SerialName("overview") override val overview: String?, |     @SerialName("overview") override val overview: String?, | ||||||
|     @SerialName("poster_path") override val posterPath: String?, |     @SerialName("poster_path") override val posterPath: String?, | ||||||
|     @SerialName("backdrop_path") override val backdropPath: String?, |     @SerialName("backdrop_path") override val backdropPath: String?, | ||||||
| @ -92,7 +92,7 @@ data class TMDBMovie( | |||||||
|     @SerialName("release_date") val releaseDate: String, |     @SerialName("release_date") val releaseDate: String, | ||||||
|     @SerialName("runtime") val runtime: Int?, |     @SerialName("runtime") val runtime: Int?, | ||||||
|     @SerialName("status") val status: String, |     @SerialName("status") val status: String, | ||||||
|     // TODO generes |     // TODO genres | ||||||
| ) : TMDBResult | ) : TMDBResult | ||||||
|  |  | ||||||
| @Serializable | @Serializable | ||||||
| @ -102,10 +102,10 @@ data class TMDBTVShow( | |||||||
|     @SerialName("overview")override val overview: String, |     @SerialName("overview")override val overview: String, | ||||||
|     @SerialName("poster_path") override val posterPath: String?, |     @SerialName("poster_path") override val posterPath: String?, | ||||||
|     @SerialName("backdrop_path") override val backdropPath: String?, |     @SerialName("backdrop_path") override val backdropPath: String?, | ||||||
|     @SerialName("first_air_date") val firstAirDate: String, |     @SerialName("first_air_date") val firstAirDate: String?, | ||||||
|     @SerialName("last_air_date") val lastAirDate: String, |     @SerialName("last_air_date") val lastAirDate: String?, | ||||||
|     @SerialName("status") val status: String, |     @SerialName("status") val status: String?, | ||||||
|     // TODO generes |     // TODO genres | ||||||
| ) : TMDBResult | ) : TMDBResult | ||||||
|  |  | ||||||
| // 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 | ||||||
|  | |||||||
| @ -1,5 +0,0 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> |  | ||||||
| <selector xmlns:android="http://schemas.android.com/apk/res/android"> |  | ||||||
|     <item android:color="?attr/colorPrimary" android:state_checked="true"/> |  | ||||||
|     <item android:color="?attr/iconColor"/> |  | ||||||
| </selector> |  | ||||||
| @ -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> |  | ||||||
| @ -6,7 +6,7 @@ | |||||||
|             android:shape="ring" |             android:shape="ring" | ||||||
|             android:thickness="4dp" |             android:thickness="4dp" | ||||||
|             android:useLevel="false"> |             android:useLevel="false"> | ||||||
|             <solid android:color="?iconColor"/> |             <solid android:color="?colorOutline"/> | ||||||
|         </shape> |         </shape> | ||||||
|     </item> |     </item> | ||||||
| </layer-list> | </layer-list> | ||||||
| @ -6,7 +6,7 @@ | |||||||
|             android:shape="ring" |             android:shape="ring" | ||||||
|             android:thickness="4dp" |             android:thickness="4dp" | ||||||
|             android:useLevel="false"> |             android:useLevel="false"> | ||||||
|             <solid android:color="@color/colorAccent" /> |             <solid android:color="?colorSecondary" /> | ||||||
|         </shape> |         </shape> | ||||||
|     </item> |     </item> | ||||||
| </layer-list> | </layer-list> | ||||||
| @ -1,6 +1,13 @@ | |||||||
| <vector android:height="24dp" android:tint="#FFFFFF" | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     android:viewportHeight="24" android:viewportWidth="24" |     android:width="24dp" | ||||||
|     android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> |     android:height="24dp" | ||||||
|     <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,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/> |     android:viewportWidth="24" | ||||||
|     <path android:fillColor="@android:color/white" android:pathData="M12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"/> |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|  |     <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,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/> | ||||||
|  |     <path | ||||||
|  |         android:fillColor="@android:color/white" | ||||||
|  |         android:pathData="M12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z" /> | ||||||
| </vector> | </vector> | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ | |||||||
|     android:viewportWidth="24" |     android:viewportWidth="24" | ||||||
|     android:viewportHeight="24" |     android:viewportHeight="24" | ||||||
|     android:tint="?attr/colorControlNormal"> |     android:tint="?attr/colorControlNormal"> | ||||||
|   <path |     <path | ||||||
|       android:fillColor="@android:color/white" |         android:fillColor="@android:color/white" | ||||||
|       android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/> |         android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/> | ||||||
| </vector> | </vector> | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								app/src/main/res/drawable/ic_baseline_audiotrack_24.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,10 @@ | |||||||
|  | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|  |     android:width="24dp" | ||||||
|  |     android:height="24dp" | ||||||
|  |     android:viewportWidth="24" | ||||||
|  |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|  |     <path | ||||||
|  |         android:fillColor="@android:color/white" | ||||||
|  |         android:pathData="M12,3v9.28c-0.47,-0.17 -0.97,-0.28 -1.5,-0.28C8.01,12 6,14.01 6,16.5S8.01,21 10.5,21c2.31,0 4.2,-1.75 4.45,-4H15V6h4V3h-7z"/> | ||||||
|  | </vector> | ||||||
| @ -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="M17,3L7,3c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3L19,5c0,-1.1 -0.9,-2 -2,-2zM17,18l-5,-2.18L7,18L7,5h10v13z"/> | ||||||
|  | </vector> | ||||||
| @ -1,5 +1,10 @@ | |||||||
| <vector android:height="24dp" android:tint="#FFFFFF" | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     android:viewportHeight="24" android:viewportWidth="24" |     android:width="24dp" | ||||||
|     android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> |     android:height="24dp" | ||||||
|     <path android:fillColor="@android:color/white" android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/> |     android:viewportWidth="24" | ||||||
|  |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|  |     <path | ||||||
|  |         android:fillColor="@android:color/white" | ||||||
|  |         android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/> | ||||||
| </vector> | </vector> | ||||||
|  | |||||||
| @ -1,5 +1,10 @@ | |||||||
| <vector android:height="24dp" android:tint="#FFFFFF" | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     android:viewportHeight="24" android:viewportWidth="24" |     android:width="24dp" | ||||||
|     android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> |     android:height="24dp" | ||||||
|     <path android:fillColor="@android:color/white" android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/> |     android:viewportWidth="24" | ||||||
|  |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|  |     <path | ||||||
|  |         android:fillColor="@android:color/white" | ||||||
|  |         android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/> | ||||||
| </vector> | </vector> | ||||||
|  | |||||||
| @ -1,5 +1,10 @@ | |||||||
| <vector android:height="24dp" android:tint="#FFFFFF" | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     android:viewportHeight="24" android:viewportWidth="24" |     android:width="24dp" | ||||||
|     android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> |     android:height="24dp" | ||||||
|     <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"/> |     android:viewportWidth="24" | ||||||
|  |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|  |     <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> | </vector> | ||||||
|  | |||||||
| @ -1,5 +1,8 @@ | |||||||
| <vector android:height="24dp" android:tint="#FFFFFF" | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     android:viewportHeight="24" android:viewportWidth="24" |     android:width="24dp" | ||||||
|     android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> |     android:height="24dp" | ||||||
|  |     android:viewportWidth="24" | ||||||
|  |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|     <path android:fillColor="@android:color/white" android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/> |     <path android:fillColor="@android:color/white" android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/> | ||||||
| </vector> | </vector> | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ | |||||||
|     android:height="24dp" |     android:height="24dp" | ||||||
|     android:viewportWidth="24" |     android:viewportWidth="24" | ||||||
|     android:viewportHeight="24" |     android:viewportHeight="24" | ||||||
|     android:tint="?attr/iconColor"> |     android:tint="?attr/colorControlNormal"> | ||||||
|   <path |   <path | ||||||
|       android:fillColor="@android:color/white" |       android:fillColor="@android:color/white" | ||||||
|       android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/> |       android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/> | ||||||
|  | |||||||
| @ -1,5 +1,10 @@ | |||||||
| <vector android:height="24dp" android:tint="#FFFFFF" | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     android:viewportHeight="24" android:viewportWidth="24" |     android:width="24dp" | ||||||
|     android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> |     android:height="24dp" | ||||||
|     <path android:fillColor="@android:color/white" android:pathData="M2.53,19.65l1.34,0.56v-9.03l-2.43,5.86c-0.41,1.02 0.08,2.19 1.09,2.61zM22.03,15.95L17.07,3.98c-0.31,-0.75 -1.04,-1.21 -1.81,-1.23 -0.26,0 -0.53,0.04 -0.79,0.15L7.1,5.95c-0.75,0.31 -1.21,1.03 -1.23,1.8 -0.01,0.27 0.04,0.54 0.15,0.8l4.96,11.97c0.31,0.76 1.05,1.22 1.83,1.23 0.26,0 0.52,-0.05 0.77,-0.15l7.36,-3.05c1.02,-0.42 1.51,-1.59 1.09,-2.6zM7.88,8.75c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM5.88,19.75c0,1.1 0.9,2 2,2h1.45l-3.45,-8.34v6.34z"/> |     android:viewportWidth="24" | ||||||
|  |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|  |     <path | ||||||
|  |         android:fillColor="@android:color/white" | ||||||
|  |         android:pathData="M2.53,19.65l1.34,0.56v-9.03l-2.43,5.86c-0.41,1.02 0.08,2.19 1.09,2.61zM22.03,15.95L17.07,3.98c-0.31,-0.75 -1.04,-1.21 -1.81,-1.23 -0.26,0 -0.53,0.04 -0.79,0.15L7.1,5.95c-0.75,0.31 -1.21,1.03 -1.23,1.8 -0.01,0.27 0.04,0.54 0.15,0.8l4.96,11.97c0.31,0.76 1.05,1.22 1.83,1.23 0.26,0 0.52,-0.05 0.77,-0.15l7.36,-3.05c1.02,-0.42 1.51,-1.59 1.09,-2.6zM7.88,8.75c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM5.88,19.75c0,1.1 0.9,2 2,2h1.45l-3.45,-8.34v6.34z"/> | ||||||
| </vector> | </vector> | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     android:width="24dp" |     android:width="24dp" | ||||||
|     android:height="24dp" |     android:height="24dp" | ||||||
|     android:tint="#FFFFFF" |  | ||||||
|     android:viewportWidth="24" |     android:viewportWidth="24" | ||||||
|     android:viewportHeight="24"> |     android:viewportHeight="24" | ||||||
|  |     android:tint="?attr/colorControlNormal"> | ||||||
|     <path |     <path | ||||||
|         android:fillColor="@android:color/white" |         android:fillColor="@android:color/white" | ||||||
|         android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" /> |         android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" /> | ||||||
|  | |||||||
							
								
								
									
										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 | 
							
								
								
									
										7
									
								
								app/src/main/res/drawable/placeholder_image.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,7 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <shape xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <solid android:color="?colorSurfaceVariant"/> | ||||||
|  |     <size | ||||||
|  |         android:width="1920px" | ||||||
|  |         android:height="1080px"/> | ||||||
|  | </shape> | ||||||
							
								
								
									
										7
									
								
								app/src/main/res/drawable/placeholder_image_2_3.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,7 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <shape xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <solid android:color="?colorSurfaceVariant"/> | ||||||
|  |     <size | ||||||
|  |         android:width="400px" | ||||||
|  |         android:height="600px"/> | ||||||
|  | </shape> | ||||||
| @ -1,5 +1,5 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> | <?xml version="1.0" encoding="utf-8"?> | ||||||
| <shape xmlns:android="http://schemas.android.com/apk/res/android"> | <shape xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|     <solid android:color="?attr/shapeTextBackground"/> |     <solid android:color="?colorSurfaceVariant"/> | ||||||
|     <corners android:radius="3dp"/> |     <corners android:radius="3dp"/> | ||||||
| </shape> | </shape> | ||||||
| @ -9,8 +9,6 @@ | |||||||
|         android:id="@+id/nav_view" |         android:id="@+id/nav_view" | ||||||
|         android:layout_width="0dp" |         android:layout_width="0dp" | ||||||
|         android:layout_height="wrap_content" |         android:layout_height="wrap_content" | ||||||
|         android:background="?themeSecondary" |  | ||||||
|         app:itemIconTint="@color/bottom_nav_item_tint" |  | ||||||
|         app:layout_constraintBottom_toBottomOf="parent" |         app:layout_constraintBottom_toBottomOf="parent" | ||||||
|         app:layout_constraintLeft_toLeftOf="parent" |         app:layout_constraintLeft_toLeftOf="parent" | ||||||
|         app:layout_constraintRight_toRightOf="parent" |         app:layout_constraintRight_toRightOf="parent" | ||||||
|  | |||||||
| @ -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> | ||||||
| @ -19,6 +19,6 @@ | |||||||
|         android:layout_centerInParent="true" |         android:layout_centerInParent="true" | ||||||
|         android:layout_marginStart="42dp" |         android:layout_marginStart="42dp" | ||||||
|         android:text="@string/fwd_10_s" |         android:text="@string/fwd_10_s" | ||||||
|         android:textColor="@color/exo_white" |         android:textColor="@color/player_white" | ||||||
|         android:visibility="gone" /> |         android:visibility="gone" /> | ||||||
| </RelativeLayout> | </RelativeLayout> | ||||||
| @ -20,7 +20,7 @@ | |||||||
|         android:layout_centerInParent="true" |         android:layout_centerInParent="true" | ||||||
|         android:layout_marginEnd="42dp" |         android:layout_marginEnd="42dp" | ||||||
|         android:text="@string/rwd_10_s" |         android:text="@string/rwd_10_s" | ||||||
|         android:textColor="@color/exo_white" |         android:textColor="@color/player_white" | ||||||
|         android:visibility="gone" /> |         android:visibility="gone" /> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -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> |  | ||||||
| @ -1,11 +1,8 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> | <?xml version="1.0" encoding="utf-8"?> | ||||||
| <androidx.core.widget.NestedScrollView | <androidx.core.widget.NestedScrollView 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:tools="http://schemas.android.com/tools" |     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="?themePrimary" |  | ||||||
|     tools:context=".ui.activity.main.fragments.AboutFragment"> |     tools:context=".ui.activity.main.fragments.AboutFragment"> | ||||||
|  |  | ||||||
|     <LinearLayout |     <LinearLayout | ||||||
| @ -67,8 +64,7 @@ | |||||||
|                     android:minHeight="48dp" |                     android:minHeight="48dp" | ||||||
|                     android:padding="9dp" |                     android:padding="9dp" | ||||||
|                     android:scaleType="fitXY" |                     android:scaleType="fitXY" | ||||||
|                     android:src="@drawable/ic_outline_info_24" |                     android:src="@drawable/ic_outline_info_24" /> | ||||||
|                     app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
| @ -89,8 +85,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/version_desc" |                         android:text="@string/version_desc" /> | ||||||
|                         android:textColor="?textSecondary" /> |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|             </LinearLayout> |             </LinearLayout> | ||||||
|  |  | ||||||
| @ -112,8 +107,7 @@ | |||||||
|                     android:minHeight="48dp" |                     android:minHeight="48dp" | ||||||
|                     android:padding="9dp" |                     android:padding="9dp" | ||||||
|                     android:scaleType="fitXY" |                     android:scaleType="fitXY" | ||||||
|                     android:src="@drawable/ic_baseline_people_24" |                     android:src="@drawable/ic_baseline_people_24" /> | ||||||
|                     app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
| @ -134,8 +128,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/author_desc" |                         android:text="@string/author_desc" /> | ||||||
|                         android:textColor="?textSecondary" /> |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|             </LinearLayout> |             </LinearLayout> | ||||||
|  |  | ||||||
| @ -157,8 +150,7 @@ | |||||||
|                     android:minHeight="48dp" |                     android:minHeight="48dp" | ||||||
|                     android:padding="9dp" |                     android:padding="9dp" | ||||||
|                     android:scaleType="fitXY" |                     android:scaleType="fitXY" | ||||||
|                     android:src="@drawable/ic_baseline_code_24" |                     android:src="@drawable/ic_baseline_code_24" /> | ||||||
|                     app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
| @ -179,8 +171,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/teapod_repo" |                         android:text="@string/teapod_repo" /> | ||||||
|                         android:textColor="?textSecondary" /> |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|             </LinearLayout> |             </LinearLayout> | ||||||
|  |  | ||||||
| @ -202,8 +193,7 @@ | |||||||
|                     android:minHeight="48dp" |                     android:minHeight="48dp" | ||||||
|                     android:padding="9dp" |                     android:padding="9dp" | ||||||
|                     android:scaleType="fitXY" |                     android:scaleType="fitXY" | ||||||
|                     android:src="@drawable/ic_baseline_description_24" |                     android:src="@drawable/ic_baseline_description_24" /> | ||||||
|                     app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
| @ -224,8 +214,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/license_desc" |                         android:text="@string/license_desc" /> | ||||||
|                         android:textColor="?textSecondary" /> |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|             </LinearLayout> |             </LinearLayout> | ||||||
|  |  | ||||||
| @ -267,8 +256,7 @@ | |||||||
|             android:layout_marginEnd="7dp" |             android:layout_marginEnd="7dp" | ||||||
|             android:paddingBottom="5dp" |             android:paddingBottom="5dp" | ||||||
|             android:text="@string/tmdb_notice" |             android:text="@string/tmdb_notice" | ||||||
|             android:textAlignment="center" |             android:textAlignment="center" /> | ||||||
|             android:textColor="?textSecondary" /> |  | ||||||
|  |  | ||||||
|     </LinearLayout> |     </LinearLayout> | ||||||
| </androidx.core.widget.NestedScrollView> | </androidx.core.widget.NestedScrollView> | ||||||
| @ -4,12 +4,12 @@ | |||||||
|     xmlns:tools="http://schemas.android.com/tools" |     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="?themePrimary" |  | ||||||
|     tools:context=".ui.activity.main.fragments.AccountFragment"> |     tools:context=".ui.activity.main.fragments.AccountFragment"> | ||||||
|  |  | ||||||
|     <ScrollView |     <ScrollView | ||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
|         android:layout_height="match_parent"> |         android:layout_height="match_parent" | ||||||
|  |         android:scrollbars="none"> | ||||||
|  |  | ||||||
|         <LinearLayout |         <LinearLayout | ||||||
|             android:layout_width="match_parent" |             android:layout_width="match_parent" | ||||||
| @ -23,7 +23,6 @@ | |||||||
|                 android:layout_width="match_parent" |                 android:layout_width="match_parent" | ||||||
|                 android:layout_height="wrap_content" |                 android:layout_height="wrap_content" | ||||||
|                 android:layout_marginTop="12dp" |                 android:layout_marginTop="12dp" | ||||||
|                 android:background="?themeSecondary" |  | ||||||
|                 android:elevation="5dp" |                 android:elevation="5dp" | ||||||
|                 android:orientation="vertical"> |                 android:orientation="vertical"> | ||||||
|  |  | ||||||
| @ -34,7 +33,7 @@ | |||||||
|                     android:paddingStart="7dp" |                     android:paddingStart="7dp" | ||||||
|                     android:paddingEnd="7dp" |                     android:paddingEnd="7dp" | ||||||
|                     android:text="@string/account" |                     android:text="@string/account" | ||||||
|                     android:textSize="16sp" |                     android:textAppearance="@style/TextAppearance.Material3.TitleMedium" | ||||||
|                     android:textStyle="bold" /> |                     android:textStyle="bold" /> | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
| @ -55,8 +54,7 @@ | |||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         android:src="@drawable/ic_baseline_account_box_24" |                         android:src="@drawable/ic_baseline_account_box_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <LinearLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -69,15 +67,14 @@ | |||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:layout_weight="1" |                             android:layout_weight="1" | ||||||
|                             android:text="@string/account_login_ex" |                             android:text="@string/account_login_ex" | ||||||
|                             android:textSize="16sp" /> |                             android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|  |  | ||||||
|                         <TextView |                         <TextView | ||||||
|                             android:id="@+id/text_account_login_desc" |                             android:id="@+id/text_account_login_desc" | ||||||
|                             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/account_login_desc" |                             android:text="@string/account_login_desc" /> | ||||||
|                             android:textColor="?textSecondary" /> |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|  |  | ||||||
| @ -99,8 +96,7 @@ | |||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         android:src="@drawable/ic_baseline_access_time_24" |                         android:src="@drawable/ic_baseline_access_time_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <LinearLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -112,16 +108,15 @@ | |||||||
|                             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/account_subscription" |                             android:text="@string/loading" | ||||||
|                             android:textSize="16sp" /> |                             android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|  |  | ||||||
|                         <TextView |                         <TextView | ||||||
|                             android:id="@+id/text_account_subscription_desc" |                             android:id="@+id/text_account_subscription_desc" | ||||||
|                             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/account_subscription_desc" |                             android:text="@string/account_tier" /> | ||||||
|                             android:textColor="?textSecondary" /> |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|  |  | ||||||
| @ -132,7 +127,6 @@ | |||||||
|                 android:layout_width="match_parent" |                 android:layout_width="match_parent" | ||||||
|                 android:layout_height="wrap_content" |                 android:layout_height="wrap_content" | ||||||
|                 android:layout_marginTop="12dp" |                 android:layout_marginTop="12dp" | ||||||
|                 android:background="?themeSecondary" |  | ||||||
|                 android:elevation="5dp" |                 android:elevation="5dp" | ||||||
|                 android:orientation="vertical"> |                 android:orientation="vertical"> | ||||||
|  |  | ||||||
| @ -143,11 +137,11 @@ | |||||||
|                     android:paddingStart="7dp" |                     android:paddingStart="7dp" | ||||||
|                     android:paddingEnd="7dp" |                     android:paddingEnd="7dp" | ||||||
|                     android:text="@string/settings" |                     android:text="@string/settings" | ||||||
|                     android:textSize="16sp" |                     android:textAppearance="@style/TextAppearance.Material3.TitleMedium" | ||||||
|                     android:textStyle="bold" /> |                     android:textStyle="bold" /> | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
|                     android:id="@+id/linear_settings_content_language" |                     android:id="@+id/linear_settings_audio_language" | ||||||
|                     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" | ||||||
| @ -157,13 +151,12 @@ | |||||||
|                         android:id="@+id/imageView4" |                         android:id="@+id/imageView4" | ||||||
|                         android:layout_width="wrap_content" |                         android:layout_width="wrap_content" | ||||||
|                         android:layout_height="wrap_content" |                         android:layout_height="wrap_content" | ||||||
|                         android:contentDescription="@string/settings_content_language" |                         android:contentDescription="@string/settings_audio_language" | ||||||
|                         android:minWidth="48dp" |                         android:minWidth="48dp" | ||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         android:src="@drawable/ic_baseline_language_24" |                         android:src="@drawable/ic_baseline_audiotrack_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <LinearLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -174,81 +167,53 @@ | |||||||
|                             android:id="@+id/text_settings_content_language" |                             android:id="@+id/text_settings_content_language" | ||||||
|                             android:layout_width="match_parent" |                             android:layout_width="match_parent" | ||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:text="@string/settings_content_language" |                             android:text="@string/settings_audio_language" | ||||||
|                             android:textSize="16sp" /> |                             android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|  |  | ||||||
|                         <TextView |                         <TextView | ||||||
|                             android:id="@+id/text_settings_content_language_desc" |                             android:id="@+id/text_settings_audio_language_desc" | ||||||
|                             android:layout_width="match_parent" |                             android:layout_width="match_parent" | ||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:text="@string/settings_content_language_desc" |                             android:text="@string/settings_content_language_desc" /> | ||||||
|                             android:textColor="?textSecondary" /> |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
|                     android:id="@+id/linear_settings_secondary" |                     android:id="@+id/linear_settings_subtitle_language" | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
|                     android:layout_height="match_parent" |                     android:layout_height="match_parent" | ||||||
|                     android:gravity="center" |  | ||||||
|                     android:orientation="horizontal" |                     android:orientation="horizontal" | ||||||
|                     android:padding="7dp"> |                     android:padding="7dp"> | ||||||
|  |  | ||||||
|                     <ImageView |                     <ImageView | ||||||
|                         android:id="@+id/imageView3" |                         android:id="@+id/imageView7" | ||||||
|                         android:layout_width="wrap_content" |                         android:layout_width="wrap_content" | ||||||
|                         android:layout_height="wrap_content" |                         android:layout_height="wrap_content" | ||||||
|                         android:contentDescription="@string/settings_prefer_subbed" |                         android:contentDescription="@string/settings_subtitle_language" | ||||||
|                         android:minWidth="48dp" |                         android:minWidth="48dp" | ||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         android:src="@drawable/ic_baseline_subtitles_24" |                         android:src="@drawable/ic_baseline_subtitles_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <androidx.constraintlayout.widget.ConstraintLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
|                         android:layout_height="wrap_content"> |                         android:layout_height="match_parent" | ||||||
|  |                         android:orientation="vertical"> | ||||||
|  |  | ||||||
|                         <LinearLayout |                         <TextView | ||||||
|                             android:id="@+id/linearLayout" |                             android:id="@+id/text_settings_subtitle_language" | ||||||
|                             android:layout_width="0dp" |                             android:layout_width="match_parent" | ||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:orientation="vertical" |                             android:text="@string/settings_subtitle_language" | ||||||
|                             app:layout_constraintBottom_toBottomOf="parent" |                             android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|                             app:layout_constraintEnd_toStartOf="@+id/switch_secondary" |  | ||||||
|                             app:layout_constraintStart_toStartOf="parent" |  | ||||||
|                             app:layout_constraintTop_toTopOf="parent"> |  | ||||||
|  |  | ||||||
|                             <TextView |                         <TextView | ||||||
|                                 android:id="@+id/text_settings_secondary" |                             android:id="@+id/text_settings_subtitle_language_desc" | ||||||
|                                 android:layout_width="match_parent" |                             android:layout_width="match_parent" | ||||||
|                                 android:layout_height="wrap_content" |  | ||||||
|                                 android:layout_weight="1" |  | ||||||
|                                 android:text="@string/settings_prefer_subbed" |  | ||||||
|                                 android:textSize="16sp" /> |  | ||||||
|  |  | ||||||
|                             <TextView |  | ||||||
|                                 android:id="@+id/text_settings_secondary_desc" |  | ||||||
|                                 android:layout_width="match_parent" |  | ||||||
|                                 android:layout_height="wrap_content" |  | ||||||
|                                 android:layout_weight="1" |  | ||||||
|                                 android:maxLines="2" |  | ||||||
|                                 android:text="@string/settings_prefer_subbed_desc" |  | ||||||
|                                 android:textColor="?textSecondary" /> |  | ||||||
|                         </LinearLayout> |  | ||||||
|  |  | ||||||
|                         <com.google.android.material.switchmaterial.SwitchMaterial |  | ||||||
|                             android:id="@+id/switch_secondary" |  | ||||||
|                             android:layout_width="wrap_content" |  | ||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:checked="true" |                             android:text="@string/settings_content_language_desc" /> | ||||||
|                             app:layout_constraintBottom_toBottomOf="parent" |                     </LinearLayout> | ||||||
|                             app:layout_constraintEnd_toEndOf="parent" |  | ||||||
|                             app:layout_constraintTop_toTopOf="parent" /> |  | ||||||
|                     </androidx.constraintlayout.widget.ConstraintLayout> |  | ||||||
|  |  | ||||||
|  |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
| @ -267,8 +232,7 @@ | |||||||
|                         android:minWidth="48dp" |                         android:minWidth="48dp" | ||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:src="@drawable/ic_baseline_autorenew_24" |                         android:src="@drawable/ic_baseline_autorenew_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <androidx.constraintlayout.widget.ConstraintLayout |                     <androidx.constraintlayout.widget.ConstraintLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -289,14 +253,13 @@ | |||||||
|                                 android:layout_width="match_parent" |                                 android:layout_width="match_parent" | ||||||
|                                 android:layout_height="wrap_content" |                                 android:layout_height="wrap_content" | ||||||
|                                 android:text="@string/settings_autoplay" |                                 android:text="@string/settings_autoplay" | ||||||
|                                 android:textSize="16sp" /> |                                 android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|  |  | ||||||
|                             <TextView |                             <TextView | ||||||
|                                 android:id="@+id/text_settings_auoplay_desc" |                                 android:id="@+id/text_settings_auoplay_desc" | ||||||
|                                 android:layout_width="match_parent" |                                 android:layout_width="match_parent" | ||||||
|                                 android:layout_height="wrap_content" |                                 android:layout_height="wrap_content" | ||||||
|                                 android:text="@string/settings_autoplay_desc" |                                 android:text="@string/settings_autoplay_desc" /> | ||||||
|                                 android:textColor="?textSecondary" /> |  | ||||||
|                         </LinearLayout> |                         </LinearLayout> | ||||||
|  |  | ||||||
|                         <com.google.android.material.switchmaterial.SwitchMaterial |                         <com.google.android.material.switchmaterial.SwitchMaterial | ||||||
| @ -304,6 +267,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" /> | ||||||
| @ -329,8 +293,7 @@ | |||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         android:src="@drawable/ic_baseline_style_24" |                         android:src="@drawable/ic_baseline_style_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <LinearLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -343,15 +306,14 @@ | |||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:layout_weight="1" |                             android:layout_weight="1" | ||||||
|                             android:text="@string/theme" |                             android:text="@string/theme" | ||||||
|                             android:textSize="16sp" /> |                             android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|  |  | ||||||
|                         <TextView |                         <TextView | ||||||
|                             android:id="@+id/text_theme_selected" |                             android:id="@+id/text_theme_selected" | ||||||
|                             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/theme_light" |                             android:text="@string/theme_light" /> | ||||||
|                             android:textColor="?textSecondary" /> |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
|  |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
| @ -363,7 +325,6 @@ | |||||||
|                 android:layout_width="match_parent" |                 android:layout_width="match_parent" | ||||||
|                 android:layout_height="wrap_content" |                 android:layout_height="wrap_content" | ||||||
|                 android:layout_marginTop="12dp" |                 android:layout_marginTop="12dp" | ||||||
|                 android:background="?themeSecondary" |  | ||||||
|                 android:clipToPadding="false" |                 android:clipToPadding="false" | ||||||
|                 android:elevation="5dp" |                 android:elevation="5dp" | ||||||
|                 android:orientation="vertical"> |                 android:orientation="vertical"> | ||||||
| @ -375,9 +336,70 @@ | |||||||
|                     android:paddingStart="7dp" |                     android:paddingStart="7dp" | ||||||
|                     android:paddingEnd="7dp" |                     android:paddingEnd="7dp" | ||||||
|                     android:text="@string/dev_settings" |                     android:text="@string/dev_settings" | ||||||
|                     android:textSize="16sp" |                     android:textAppearance="@style/TextAppearance.Material3.TitleMedium" | ||||||
|                     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" /> | ||||||
|  |  | ||||||
|  |                     <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:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|  |  | ||||||
|  |                             <TextView | ||||||
|  |                                 android:id="@+id/text_update_playhead_desc" | ||||||
|  |                                 android:layout_width="match_parent" | ||||||
|  |                                 android:layout_height="wrap_content" | ||||||
|  |                                 android:text="@string/update_playhead_desc" /> | ||||||
|  |                         </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" | ||||||
| @ -385,7 +407,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" | ||||||
| @ -396,8 +419,7 @@ | |||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         app:srcCompat="@drawable/ic_outline_upload_24" |                         app:srcCompat="@drawable/ic_outline_upload_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <LinearLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -417,8 +439,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/export_data_desc" |                             android:text="@string/export_data_desc" /> | ||||||
|                             android:textColor="?textSecondary" /> |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
|  |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
| @ -430,7 +451,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" | ||||||
| @ -441,8 +463,7 @@ | |||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         app:srcCompat="@drawable/ic_outline_download_24" |                         app:srcCompat="@drawable/ic_outline_download_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <LinearLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -462,8 +483,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/import_data_desc" |                             android:text="@string/import_data_desc" /> | ||||||
|                             android:textColor="?textSecondary" /> |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
|  |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
| @ -475,7 +495,6 @@ | |||||||
|                 android:layout_width="match_parent" |                 android:layout_width="match_parent" | ||||||
|                 android:layout_height="wrap_content" |                 android:layout_height="wrap_content" | ||||||
|                 android:layout_marginTop="12dp" |                 android:layout_marginTop="12dp" | ||||||
|                 android:background="?themeSecondary" |  | ||||||
|                 android:clipToPadding="false" |                 android:clipToPadding="false" | ||||||
|                 android:elevation="5dp" |                 android:elevation="5dp" | ||||||
|                 android:orientation="vertical"> |                 android:orientation="vertical"> | ||||||
| @ -487,7 +506,7 @@ | |||||||
|                     android:paddingStart="7dp" |                     android:paddingStart="7dp" | ||||||
|                     android:paddingEnd="7dp" |                     android:paddingEnd="7dp" | ||||||
|                     android:text="@string/info" |                     android:text="@string/info" | ||||||
|                     android:textSize="16sp" |                     android:textAppearance="@style/TextAppearance.Material3.TitleMedium" | ||||||
|                     android:textStyle="bold" /> |                     android:textStyle="bold" /> | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
| @ -508,8 +527,7 @@ | |||||||
|                         android:minHeight="48dp" |                         android:minHeight="48dp" | ||||||
|                         android:padding="9dp" |                         android:padding="9dp" | ||||||
|                         android:scaleType="fitXY" |                         android:scaleType="fitXY" | ||||||
|                         app:srcCompat="@drawable/ic_outline_info_24" |                         app:srcCompat="@drawable/ic_outline_info_24" /> | ||||||
|                         app:tint="?iconColor" /> |  | ||||||
|  |  | ||||||
|                     <LinearLayout |                     <LinearLayout | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="match_parent" | ||||||
| @ -522,15 +540,14 @@ | |||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:layout_weight="1" |                             android:layout_weight="1" | ||||||
|                             android:text="@string/info_about" |                             android:text="@string/info_about" | ||||||
|                             android:textSize="16sp" /> |                             android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> | ||||||
|  |  | ||||||
|                         <TextView |                         <TextView | ||||||
|                             android:id="@+id/text_info_about_desc" |                             android:id="@+id/text_info_about_desc" | ||||||
|                             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/info_about_desc" |                             android:text="@string/info_about_desc" /> | ||||||
|                             android:textColor="?textSecondary" /> |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
|  |  | ||||||
|                 </LinearLayout> |                 </LinearLayout> | ||||||
|  | |||||||
| @ -5,18 +5,29 @@ | |||||||
|     android:id="@+id/ff_test" |     android:id="@+id/ff_test" | ||||||
|     android:layout_width="match_parent" |     android:layout_width="match_parent" | ||||||
|     android:layout_height="match_parent" |     android:layout_height="match_parent" | ||||||
|     android:background="?themePrimary" |  | ||||||
|     tools:context=".ui.activity.main.fragments.HomeFragment"> |     tools:context=".ui.activity.main.fragments.HomeFragment"> | ||||||
|  |  | ||||||
|     <ScrollView |     <ScrollView | ||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
|         android:layout_height="match_parent"> |         android:layout_height="match_parent" | ||||||
|  |         android:scrollbars="none"> | ||||||
|  |  | ||||||
|         <LinearLayout |         <LinearLayout | ||||||
|             android:layout_width="match_parent" |             android:layout_width="match_parent" | ||||||
|             android:layout_height="wrap_content" |             android:layout_height="wrap_content" | ||||||
|  |             android:layout_marginBottom="7dp" | ||||||
|             android:orientation="vertical"> |             android:orientation="vertical"> | ||||||
|  |  | ||||||
|  |             <com.facebook.shimmer.ShimmerFrameLayout | ||||||
|  |                 android:id="@+id/shimmer_layout_highlight" | ||||||
|  |                 android:layout_width="match_parent" | ||||||
|  |                 android:layout_height="match_parent" | ||||||
|  |                 tools:visibility="gone"> | ||||||
|  |  | ||||||
|  |                 <include layout="@layout/item_highlight_shimmer" /> | ||||||
|  |  | ||||||
|  |             </com.facebook.shimmer.ShimmerFrameLayout> | ||||||
|  |  | ||||||
|             <LinearLayout |             <LinearLayout | ||||||
|                 android:id="@+id/linear_highlight" |                 android:id="@+id/linear_highlight" | ||||||
|                 android:layout_width="match_parent" |                 android:layout_width="match_parent" | ||||||
| @ -59,9 +70,7 @@ | |||||||
|                         android:layout_height="wrap_content" |                         android:layout_height="wrap_content" | ||||||
|                         android:gravity="center" |                         android:gravity="center" | ||||||
|                         android:text="@string/my_list" |                         android:text="@string/my_list" | ||||||
|                         android:textColor="?textSecondary" |  | ||||||
|                         android:textSize="12sp" |                         android:textSize="12sp" | ||||||
|                         app:drawableTint="?buttonBackground" |  | ||||||
|                         app:drawableTopCompat="@drawable/ic_baseline_add_24" /> |                         app:drawableTopCompat="@drawable/ic_baseline_add_24" /> | ||||||
|  |  | ||||||
|                     <Space |                     <Space | ||||||
| @ -76,12 +85,9 @@ | |||||||
|                         android:gravity="center" |                         android:gravity="center" | ||||||
|                         android:text="@string/button_play" |                         android:text="@string/button_play" | ||||||
|                         android:textAllCaps="false" |                         android:textAllCaps="false" | ||||||
|                         android:textColor="?themePrimary" |  | ||||||
|                         android:textSize="16sp" |                         android:textSize="16sp" | ||||||
|                         app:backgroundTint="?buttonBackground" |  | ||||||
|                         app:icon="@drawable/ic_baseline_play_arrow_24" |                         app:icon="@drawable/ic_baseline_play_arrow_24" | ||||||
|                         app:iconGravity="textStart" |                         app:iconGravity="textStart" /> | ||||||
|                         app:iconTint="?themePrimary" /> |  | ||||||
|  |  | ||||||
|                     <Space |                     <Space | ||||||
|                         android:layout_width="0dp" |                         android:layout_width="0dp" | ||||||
| @ -94,9 +100,7 @@ | |||||||
|                         android:layout_height="wrap_content" |                         android:layout_height="wrap_content" | ||||||
|                         android:gravity="center" |                         android:gravity="center" | ||||||
|                         android:text="@string/info" |                         android:text="@string/info" | ||||||
|                         android:textColor="?textSecondary" |  | ||||||
|                         android:textSize="12sp" |                         android:textSize="12sp" | ||||||
|                         app:drawableTint="?buttonBackground" |  | ||||||
|                         app:drawableTopCompat="@drawable/ic_outline_info_24" /> |                         app:drawableTopCompat="@drawable/ic_outline_info_24" /> | ||||||
|  |  | ||||||
|                     <Space |                     <Space | ||||||
| @ -110,12 +114,11 @@ | |||||||
|             <LinearLayout |             <LinearLayout | ||||||
|                 android:id="@+id/linear_up_next" |                 android:id="@+id/linear_up_next" | ||||||
|                 android:layout_width="match_parent" |                 android:layout_width="match_parent" | ||||||
|                 android:layout_height="match_parent" |                 android:layout_height="wrap_content" | ||||||
|                 android:orientation="vertical" |                 android:orientation="vertical"> | ||||||
|                 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" | ||||||
| @ -126,10 +129,30 @@ | |||||||
|                     android:textSize="16sp" |                     android:textSize="16sp" | ||||||
|                     android:textStyle="bold" /> |                     android:textStyle="bold" /> | ||||||
|  |  | ||||||
|                 <androidx.recyclerview.widget.RecyclerView |                 <com.facebook.shimmer.ShimmerFrameLayout | ||||||
|                     android:id="@+id/recycler_new_episodes" |                     android:id="@+id/shimmer_layout_up_next" | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
|                     android:layout_height="match_parent" |                     android:layout_height="wrap_content" | ||||||
|  |                     tools:visibility="gone"> | ||||||
|  |  | ||||||
|  |                     <LinearLayout | ||||||
|  |                         android:layout_width="match_parent" | ||||||
|  |                         android:layout_height="wrap_content" | ||||||
|  |                         android:orientation="horizontal"> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                     </LinearLayout> | ||||||
|  |  | ||||||
|  |                 </com.facebook.shimmer.ShimmerFrameLayout> | ||||||
|  |  | ||||||
|  |                 <androidx.recyclerview.widget.RecyclerView | ||||||
|  |                     android:id="@+id/recycler_up_next" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="wrap_content" | ||||||
|                     android:orientation="horizontal" |                     android:orientation="horizontal" | ||||||
|                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" |                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" | ||||||
|                     tools:listitem="@layout/item_media" /> |                     tools:listitem="@layout/item_media" /> | ||||||
| @ -139,8 +162,7 @@ | |||||||
|                 android:id="@+id/linear_watchlist" |                 android:id="@+id/linear_watchlist" | ||||||
|                 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"> | ||||||
|                 android:paddingBottom="7dp"> |  | ||||||
|  |  | ||||||
|                 <TextView |                 <TextView | ||||||
|                     android:id="@+id/text_watchlist" |                     android:id="@+id/text_watchlist" | ||||||
| @ -154,9 +176,77 @@ | |||||||
|                     android:textSize="16sp" |                     android:textSize="16sp" | ||||||
|                     android:textStyle="bold" /> |                     android:textStyle="bold" /> | ||||||
|  |  | ||||||
|  |                 <com.facebook.shimmer.ShimmerFrameLayout | ||||||
|  |                     android:id="@+id/shimmer_layout_watchlist" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="match_parent" | ||||||
|  |                     tools:visibility="gone"> | ||||||
|  |  | ||||||
|  |                     <LinearLayout | ||||||
|  |                         android:layout_width="match_parent" | ||||||
|  |                         android:layout_height="wrap_content" | ||||||
|  |                         android:orientation="horizontal"> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                     </LinearLayout> | ||||||
|  |  | ||||||
|  |                 </com.facebook.shimmer.ShimmerFrameLayout> | ||||||
|  |  | ||||||
|                 <androidx.recyclerview.widget.RecyclerView |                 <androidx.recyclerview.widget.RecyclerView | ||||||
|                     android:id="@+id/recycler_watchlist" |                     android:id="@+id/recycler_watchlist" | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="wrap_content" | ||||||
|  |                     android:orientation="horizontal" | ||||||
|  |                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" | ||||||
|  |                     tools:listitem="@layout/item_media" /> | ||||||
|  |             </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" /> | ||||||
|  |  | ||||||
|  |                 <com.facebook.shimmer.ShimmerFrameLayout | ||||||
|  |                     android:id="@+id/shimmer_layout_recommendations" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="match_parent" | ||||||
|  |                     tools:visibility="gone"> | ||||||
|  |  | ||||||
|  |                     <LinearLayout | ||||||
|  |                         android:layout_width="match_parent" | ||||||
|  |                         android:layout_height="wrap_content" | ||||||
|  |                         android:orientation="horizontal"> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                     </LinearLayout> | ||||||
|  |  | ||||||
|  |                 </com.facebook.shimmer.ShimmerFrameLayout> | ||||||
|  |  | ||||||
|  |                 <androidx.recyclerview.widget.RecyclerView | ||||||
|  |                     android:id="@+id/recycler_recommendations" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|                     android:layout_height="match_parent" |                     android:layout_height="match_parent" | ||||||
|                     android:orientation="horizontal" |                     android:orientation="horizontal" | ||||||
|                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" |                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" | ||||||
| @ -167,8 +257,7 @@ | |||||||
|                 android:id="@+id/linear_new_titles" |                 android:id="@+id/linear_new_titles" | ||||||
|                 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"> | ||||||
|                 android:paddingBottom="7dp"> |  | ||||||
|  |  | ||||||
|                 <TextView |                 <TextView | ||||||
|                     android:id="@+id/text_new_titles" |                     android:id="@+id/text_new_titles" | ||||||
| @ -182,6 +271,26 @@ | |||||||
|                     android:textSize="16sp" |                     android:textSize="16sp" | ||||||
|                     android:textStyle="bold" /> |                     android:textStyle="bold" /> | ||||||
|  |  | ||||||
|  |                 <com.facebook.shimmer.ShimmerFrameLayout | ||||||
|  |                     android:id="@+id/shimmer_layout_new_titles" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="match_parent" | ||||||
|  |                     tools:visibility="gone"> | ||||||
|  |  | ||||||
|  |                     <LinearLayout | ||||||
|  |                         android:layout_width="match_parent" | ||||||
|  |                         android:layout_height="wrap_content" | ||||||
|  |                         android:orientation="horizontal"> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                     </LinearLayout> | ||||||
|  |  | ||||||
|  |                 </com.facebook.shimmer.ShimmerFrameLayout> | ||||||
|  |  | ||||||
|                 <androidx.recyclerview.widget.RecyclerView |                 <androidx.recyclerview.widget.RecyclerView | ||||||
|                     android:id="@+id/recycler_new_titles" |                     android:id="@+id/recycler_new_titles" | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
| @ -195,8 +304,7 @@ | |||||||
|                 android:id="@+id/linear_top_ten" |                 android:id="@+id/linear_top_ten" | ||||||
|                 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"> | ||||||
|                 android:paddingBottom="7dp"> |  | ||||||
|  |  | ||||||
|                 <TextView |                 <TextView | ||||||
|                     android:id="@+id/text_top_ten" |                     android:id="@+id/text_top_ten" | ||||||
| @ -210,6 +318,26 @@ | |||||||
|                     android:textSize="16sp" |                     android:textSize="16sp" | ||||||
|                     android:textStyle="bold" /> |                     android:textStyle="bold" /> | ||||||
|  |  | ||||||
|  |                 <com.facebook.shimmer.ShimmerFrameLayout | ||||||
|  |                     android:id="@+id/shimmer_layout_top_ten" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="match_parent" | ||||||
|  |                     tools:visibility="gone"> | ||||||
|  |  | ||||||
|  |                     <LinearLayout | ||||||
|  |                         android:layout_width="match_parent" | ||||||
|  |                         android:layout_height="wrap_content" | ||||||
|  |                         android:orientation="horizontal"> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                         <include layout="@layout/item_media_shimmer" /> | ||||||
|  |                     </LinearLayout> | ||||||
|  |  | ||||||
|  |                 </com.facebook.shimmer.ShimmerFrameLayout> | ||||||
|  |  | ||||||
|                 <androidx.recyclerview.widget.RecyclerView |                 <androidx.recyclerview.widget.RecyclerView | ||||||
|                     android:id="@+id/recycler_top_ten" |                     android:id="@+id/recycler_top_ten" | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
|  | |||||||
| @ -4,22 +4,35 @@ | |||||||
|     xmlns:tools="http://schemas.android.com/tools" |     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="?themePrimary" |  | ||||||
|     tools:context=".ui.activity.main.fragments.LibraryFragment"> |     tools:context=".ui.activity.main.fragments.LibraryFragment"> | ||||||
|  |  | ||||||
|     <androidx.recyclerview.widget.RecyclerView |     <org.mosad.teapod.ui.components.EmptySubmitSearchView | ||||||
|         android:id="@+id/recycler_media_library" |         android:id="@+id/search_text" | ||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
|         android:layout_height="match_parent" |         android:layout_height="0dp" | ||||||
|  |         android:elevation="8dp" | ||||||
|  |         android:iconifiedByDefault="false" | ||||||
|  |         android:paddingBottom="5dp" | ||||||
|  |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |         app:layout_constraintStart_toStartOf="parent" | ||||||
|  |         app:layout_constraintTop_toTopOf="parent"> | ||||||
|  |     </org.mosad.teapod.ui.components.EmptySubmitSearchView> | ||||||
|  |  | ||||||
|  |     <androidx.recyclerview.widget.RecyclerView | ||||||
|  |         android:id="@+id/recycler_media_search" | ||||||
|  |         android:layout_width="match_parent" | ||||||
|  |         android:layout_height="0dp" | ||||||
|         android:clipToPadding="false" |         android:clipToPadding="false" | ||||||
|         android:padding="3dp" |  | ||||||
|         android:orientation="vertical" |         android:orientation="vertical" | ||||||
|  |         android:padding="3dp" | ||||||
|  |         app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" | ||||||
|         app:layout_constraintBottom_toBottomOf="parent" |         app:layout_constraintBottom_toBottomOf="parent" | ||||||
|         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_toBottomOf="@+id/search_text" | ||||||
|         app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" |         app:spanCount="@integer/item_media_columns" | ||||||
|         app:spanCount="2" |         tools:listitem="@layout/item_media"> | ||||||
|         tools:listitem="@layout/item_media" /> |  | ||||||
|  |     </androidx.recyclerview.widget.RecyclerView> | ||||||
|  |  | ||||||
| </androidx.constraintlayout.widget.ConstraintLayout> | </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
| @ -4,7 +4,6 @@ | |||||||
|     xmlns:tools="http://schemas.android.com/tools" |     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="?themePrimary" |  | ||||||
|     tools:context=".ui.activity.main.fragments.MediaFragment"> |     tools:context=".ui.activity.main.fragments.MediaFragment"> | ||||||
|  |  | ||||||
|     <androidx.coordinatorlayout.widget.CoordinatorLayout |     <androidx.coordinatorlayout.widget.CoordinatorLayout | ||||||
| @ -14,8 +13,7 @@ | |||||||
|         <com.google.android.material.appbar.AppBarLayout |         <com.google.android.material.appbar.AppBarLayout | ||||||
|             android:id="@+id/app_layout" |             android:id="@+id/app_layout" | ||||||
|             android:layout_width="match_parent" |             android:layout_width="match_parent" | ||||||
|             android:layout_height="wrap_content" |             android:layout_height="wrap_content"> | ||||||
|             android:background="?themePrimary"> |  | ||||||
|  |  | ||||||
|             <LinearLayout |             <LinearLayout | ||||||
|                 android:id="@+id/linear_media" |                 android:id="@+id/linear_media" | ||||||
| @ -24,29 +22,42 @@ | |||||||
|                 android:orientation="vertical" |                 android:orientation="vertical" | ||||||
|                 app:layout_scrollFlags="scroll"> |                 app:layout_scrollFlags="scroll"> | ||||||
|  |  | ||||||
|                 <RelativeLayout |                 <androidx.constraintlayout.widget.ConstraintLayout | ||||||
|                     android:layout_width="match_parent" |                     android:layout_width="match_parent" | ||||||
|                     android:layout_height="wrap_content"> |                     android:layout_height="wrap_content"> | ||||||
|  |  | ||||||
|                     <ImageView |                     <FrameLayout | ||||||
|                         android:id="@+id/image_backdrop" |                         android:id="@+id/frame_image_progress" | ||||||
|                         android:layout_width="match_parent" |                         android:layout_width="0dp" | ||||||
|                         android:layout_height="wrap_content" |                         android:layout_height="0dp" | ||||||
|                         android:adjustViewBounds="false" |                         app:layout_constraintBottom_toBottomOf="parent" | ||||||
|                         android:contentDescription="@string/media_poster_backdrop_desc" |                         app:layout_constraintDimensionRatio="H,16:9" | ||||||
|                         android:maxHeight="231dp" |                         app:layout_constraintEnd_toEndOf="parent" | ||||||
|                         android:minHeight="220dp" |                         app:layout_constraintStart_toStartOf="parent" | ||||||
|                         android:scaleType="centerCrop" /> |                         app:layout_constraintTop_toTopOf="parent"> | ||||||
|  |  | ||||||
|                     <com.google.android.material.imageview.ShapeableImageView |                         <ImageView | ||||||
|                         android:id="@+id/image_poster" |                             android:id="@+id/image_backdrop" | ||||||
|                         android:layout_width="wrap_content" |                             android:layout_width="match_parent" | ||||||
|                         android:layout_height="200dp" |                             android:layout_height="match_parent" | ||||||
|                         android:layout_centerInParent="true" |                             android:contentDescription="@string/media_poster_backdrop_desc" | ||||||
|                         app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" |                             android:scaleType="fitCenter" | ||||||
|                         tools:src="@drawable/ic_launcher_background" /> |                             tools:srcCompat="@android:color/darker_gray" /> | ||||||
|  |  | ||||||
|                 </RelativeLayout> |                         <com.google.android.material.imageview.ShapeableImageView | ||||||
|  |                             android:id="@+id/image_poster" | ||||||
|  |                             android:layout_width="wrap_content" | ||||||
|  |                             android:layout_height="match_parent" | ||||||
|  |                             android:layout_gravity="center_horizontal" | ||||||
|  |                             android:layout_marginTop="7dp" | ||||||
|  |                             android:layout_marginBottom="7dp" | ||||||
|  |                             android:scaleType="fitCenter" | ||||||
|  |                             app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" | ||||||
|  |                             tools:src="@drawable/placeholder_image_2_3" /> | ||||||
|  |  | ||||||
|  |                     </FrameLayout> | ||||||
|  |  | ||||||
|  |                 </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
|  |  | ||||||
|                 <LinearLayout |                 <LinearLayout | ||||||
|                     android:id="@+id/linear_media_info" |                     android:id="@+id/linear_media_info" | ||||||
| @ -95,12 +106,9 @@ | |||||||
|                     android:gravity="center" |                     android:gravity="center" | ||||||
|                     android:text="@string/button_play" |                     android:text="@string/button_play" | ||||||
|                     android:textAllCaps="false" |                     android:textAllCaps="false" | ||||||
|                     android:textColor="?themePrimary" |  | ||||||
|                     android:textSize="16sp" |                     android:textSize="16sp" | ||||||
|                     app:backgroundTint="?buttonBackground" |  | ||||||
|                     app:icon="@drawable/ic_baseline_play_arrow_24" |                     app:icon="@drawable/ic_baseline_play_arrow_24" | ||||||
|                     app:iconGravity="textStart" |                     app:iconGravity="textStart" /> | ||||||
|                     app:iconTint="?themePrimary" /> |  | ||||||
|  |  | ||||||
|                 <TextView |                 <TextView | ||||||
|                     android:id="@+id/text_title" |                     android:id="@+id/text_title" | ||||||
| @ -150,15 +158,13 @@ | |||||||
|                             android:paddingTop="11dp" |                             android:paddingTop="11dp" | ||||||
|                             android:paddingEnd="11dp" |                             android:paddingEnd="11dp" | ||||||
|                             android:paddingBottom="7dp" |                             android:paddingBottom="7dp" | ||||||
|                             android:src="@drawable/ic_baseline_add_24" |                             android:src="@drawable/ic_baseline_add_24" /> | ||||||
|                             app:tint="?buttonBackground" /> |  | ||||||
|  |  | ||||||
|                         <TextView |                         <TextView | ||||||
|                             android:id="@+id/text_my_list_action" |                             android:id="@+id/text_my_list_action" | ||||||
|                             android:layout_width="wrap_content" |                             android:layout_width="wrap_content" | ||||||
|                             android:layout_height="wrap_content" |                             android:layout_height="wrap_content" | ||||||
|                             android:text="@string/my_list" |                             android:text="@string/my_list" | ||||||
|                             android:textColor="?textSecondary" |  | ||||||
|                             android:textSize="12sp" /> |                             android:textSize="12sp" /> | ||||||
|  |  | ||||||
|                     </LinearLayout> |                     </LinearLayout> | ||||||
| @ -173,9 +179,7 @@ | |||||||
|                     android:layout_marginEnd="7dp" |                     android:layout_marginEnd="7dp" | ||||||
|                     android:background="@android:color/transparent" |                     android:background="@android:color/transparent" | ||||||
|                     app:tabGravity="start" |                     app:tabGravity="start" | ||||||
|                     app:tabMode="scrollable" |                     app:tabMode="scrollable" /> | ||||||
|                     app:tabSelectedTextColor="?textPrimary" |  | ||||||
|                     app:tabTextColor="?textSecondary" /> |  | ||||||
|  |  | ||||||
|             </LinearLayout> |             </LinearLayout> | ||||||
|         </com.google.android.material.appbar.AppBarLayout> |         </com.google.android.material.appbar.AppBarLayout> | ||||||
| @ -194,7 +198,7 @@ | |||||||
|         android:id="@+id/frame_loading" |         android:id="@+id/frame_loading" | ||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
|         android:layout_height="match_parent" |         android:layout_height="match_parent" | ||||||
|         android:background="?themePrimary" |         android:background="?android:colorBackground" | ||||||
|         android:visibility="gone"> |         android:visibility="gone"> | ||||||
|  |  | ||||||
|         <com.google.android.material.progressindicator.CircularProgressIndicator |         <com.google.android.material.progressindicator.CircularProgressIndicator | ||||||
|  | |||||||
| @ -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"> | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ | |||||||
|         android:paddingEnd="3dp" |         android:paddingEnd="3dp" | ||||||
|         android:paddingBottom="3dp" |         android:paddingBottom="3dp" | ||||||
|         app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" |         app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" | ||||||
|         app:spanCount="2" |         app:spanCount="@integer/item_media_columns" | ||||||
|         tools:listitem="@layout/item_media" /> |         tools:listitem="@layout/item_media" /> | ||||||
|  |  | ||||||
| </FrameLayout> | </FrameLayout> | ||||||
							
								
								
									
										44
									
								
								app/src/main/res/layout/fragment_my_lists.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,44 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|  |     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||||
|  |     xmlns:tools="http://schemas.android.com/tools" | ||||||
|  |     android:layout_width="match_parent" | ||||||
|  |     android:layout_height="match_parent" | ||||||
|  |     tools:context=".ui.activity.main.fragments.MyListsFragment"> | ||||||
|  |  | ||||||
|  |     <com.google.android.material.tabs.TabLayout | ||||||
|  |         android:id="@+id/tab_my_lists" | ||||||
|  |         android:layout_width="0dp" | ||||||
|  |         android:layout_height="wrap_content" | ||||||
|  |         android:background="@android:color/transparent" | ||||||
|  |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |         app:layout_constraintStart_toStartOf="parent" | ||||||
|  |         app:layout_constraintTop_toTopOf="parent" | ||||||
|  |         app:tabMode="fixed"> | ||||||
|  |         <!-- TODO app:tabTextColor="?colorOnPrimary" --> | ||||||
|  |  | ||||||
|  |         <com.google.android.material.tabs.TabItem | ||||||
|  |             android:layout_width="wrap_content" | ||||||
|  |             android:layout_height="wrap_content" | ||||||
|  |             android:text="@string/my_list" /> | ||||||
|  |  | ||||||
|  |         <com.google.android.material.tabs.TabItem | ||||||
|  |             android:layout_width="wrap_content" | ||||||
|  |             android:layout_height="wrap_content" | ||||||
|  |             android:text="@string/crunchylists" /> | ||||||
|  |  | ||||||
|  |         <com.google.android.material.tabs.TabItem | ||||||
|  |             android:layout_width="wrap_content" | ||||||
|  |             android:layout_height="wrap_content" | ||||||
|  |             android:text="@string/downloads" /> | ||||||
|  |     </com.google.android.material.tabs.TabLayout> | ||||||
|  |  | ||||||
|  |     <androidx.viewpager2.widget.ViewPager2 | ||||||
|  |         android:id="@+id/pager_my_lists" | ||||||
|  |         android:layout_width="0dp" | ||||||
|  |         android:layout_height="0dp" | ||||||
|  |         app:layout_constraintBottom_toBottomOf="parent" | ||||||
|  |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |         app:layout_constraintStart_toStartOf="parent" | ||||||
|  |         app:layout_constraintTop_toBottomOf="@+id/tab_my_lists" /> | ||||||
|  | </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
| @ -2,8 +2,7 @@ | |||||||
| <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" | ||||||
|     android:layout_width="match_parent" |     android:layout_width="match_parent" | ||||||
|     android:layout_height="match_parent" |     android:layout_height="match_parent"> | ||||||
|     android:background="?themePrimary"> |  | ||||||
|  |  | ||||||
|     <ImageView |     <ImageView | ||||||
|         android:id="@+id/image_login" |         android:id="@+id/image_login" | ||||||
| @ -11,12 +10,12 @@ | |||||||
|         android:layout_height="128dp" |         android:layout_height="128dp" | ||||||
|         android:contentDescription="@string/app_name" |         android:contentDescription="@string/app_name" | ||||||
|         android:scaleType="fitCenter" |         android:scaleType="fitCenter" | ||||||
|  |         android:tint="?colorTeapodIcon" | ||||||
|         app:layout_constraintEnd_toEndOf="parent" |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|         app:layout_constraintHorizontal_bias="0.5" |         app:layout_constraintHorizontal_bias="0.5" | ||||||
|         app:layout_constraintStart_toStartOf="parent" |         app:layout_constraintStart_toStartOf="parent" | ||||||
|         app:layout_constraintTop_toTopOf="parent" |         app:layout_constraintTop_toTopOf="parent" | ||||||
|         app:srcCompat="@drawable/ic_launcher_foreground" |         app:srcCompat="@drawable/ic_launcher_foreground" /> | ||||||
|         app:tint="?buttonBackground" /> |  | ||||||
|  |  | ||||||
|     <LinearLayout |     <LinearLayout | ||||||
|         android:id="@+id/linear_login" |         android:id="@+id/linear_login" | ||||||
|  | |||||||
| @ -1,11 +1,9 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> | <?xml version="1.0" encoding="utf-8"?> | ||||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" |     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||||
|     xmlns:tools="http://schemas.android.com/tools" |  | ||||||
|     android:orientation="vertical" |     android:orientation="vertical" | ||||||
|     android:layout_width="match_parent" |     android:layout_width="match_parent" | ||||||
|     android:layout_height="match_parent" |     android:layout_height="match_parent"> | ||||||
|     android:background="?themePrimary"> |  | ||||||
|  |  | ||||||
|     <androidx.constraintlayout.widget.ConstraintLayout |     <androidx.constraintlayout.widget.ConstraintLayout | ||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
| @ -17,12 +15,12 @@ | |||||||
|             android:layout_height="128dp" |             android:layout_height="128dp" | ||||||
|             android:contentDescription="@string/app_name" |             android:contentDescription="@string/app_name" | ||||||
|             android:scaleType="fitCenter" |             android:scaleType="fitCenter" | ||||||
|  |             android:tint="?colorTeapodIcon" | ||||||
|             app:layout_constraintEnd_toEndOf="parent" |             app:layout_constraintEnd_toEndOf="parent" | ||||||
|             app:layout_constraintHorizontal_bias="0.5" |             app:layout_constraintHorizontal_bias="0.5" | ||||||
|             app:layout_constraintStart_toStartOf="parent" |             app:layout_constraintStart_toStartOf="parent" | ||||||
|             app:layout_constraintTop_toTopOf="parent" |             app:layout_constraintTop_toTopOf="parent" | ||||||
|             app:srcCompat="@drawable/ic_launcher_foreground" |             app:srcCompat="@drawable/ic_launcher_foreground" /> | ||||||
|             app:tint="?buttonBackground" /> |  | ||||||
|  |  | ||||||
|         <LinearLayout |         <LinearLayout | ||||||
|             android:id="@+id/linearLayout3" |             android:id="@+id/linearLayout3" | ||||||
|  | |||||||
| @ -1,43 +0,0 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> |  | ||||||
| <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |  | ||||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" |  | ||||||
|     xmlns:tools="http://schemas.android.com/tools" |  | ||||||
|     android:layout_width="match_parent" |  | ||||||
|     android:layout_height="match_parent" |  | ||||||
|     android:background="?themePrimary" |  | ||||||
|     tools:context=".ui.activity.main.fragments.SearchFragment"> |  | ||||||
|  |  | ||||||
|     <SearchView |  | ||||||
|         android:id="@+id/search_text" |  | ||||||
|         android:layout_width="match_parent" |  | ||||||
|         android:layout_height="0dp" |  | ||||||
|         android:background="?themeSecondary" |  | ||||||
|         android:elevation="8dp" |  | ||||||
|         android:iconifiedByDefault="false" |  | ||||||
|         android:paddingBottom="5dp" |  | ||||||
|         android:queryHint="@string/search_hint" |  | ||||||
|         android:searchIcon="@drawable/ic_baseline_search_24" |  | ||||||
|         app:layout_constraintEnd_toEndOf="parent" |  | ||||||
|         app:layout_constraintStart_toStartOf="parent" |  | ||||||
|         app:layout_constraintTop_toTopOf="parent"> |  | ||||||
|  |  | ||||||
|     </SearchView> |  | ||||||
|  |  | ||||||
|     <androidx.recyclerview.widget.RecyclerView |  | ||||||
|         android:id="@+id/recycler_media_search" |  | ||||||
|         android:layout_width="match_parent" |  | ||||||
|         android:layout_height="0dp" |  | ||||||
|         android:clipToPadding="false" |  | ||||||
|         android:orientation="vertical" |  | ||||||
|         android:padding="3dp" |  | ||||||
|         app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" |  | ||||||
|         app:layout_constraintBottom_toBottomOf="parent" |  | ||||||
|         app:layout_constraintEnd_toEndOf="parent" |  | ||||||
|         app:layout_constraintStart_toStartOf="parent" |  | ||||||
|         app:layout_constraintTop_toBottomOf="@+id/search_text" |  | ||||||
|         app:spanCount="2" |  | ||||||
|         tools:listitem="@layout/item_media"> |  | ||||||
|  |  | ||||||
|     </androidx.recyclerview.widget.RecyclerView> |  | ||||||
|  |  | ||||||
| </androidx.constraintlayout.widget.ConstraintLayout> |  | ||||||
| @ -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" |                 android:src="@drawable/placeholder_image" | ||||||
|                 app:srcCompat="@color/md_disabled_text_dark_theme" /> |                 app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" /> | ||||||
|  |  | ||||||
|             <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,8 +53,9 @@ | |||||||
|             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:textSize="16sp" /> |             android:textSize="16sp" /> | ||||||
|  |  | ||||||
|         <ImageView |         <ImageView | ||||||
| @ -53,8 +64,7 @@ | |||||||
|             android:layout_height="30dp" |             android:layout_height="30dp" | ||||||
|             android:layout_margin="2dp" |             android:layout_margin="2dp" | ||||||
|             android:contentDescription="@string/component_watched_desc" |             android:contentDescription="@string/component_watched_desc" | ||||||
|             app:srcCompat="@drawable/ic_baseline_check_circle_24" |             app:srcCompat="@drawable/ic_baseline_check_circle_24" /> | ||||||
|             app:tint="?iconColor" /> |  | ||||||
|     </LinearLayout> |     </LinearLayout> | ||||||
|  |  | ||||||
|     <TextView |     <TextView | ||||||
| @ -62,6 +72,6 @@ | |||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
|         android:layout_height="wrap_content" |         android:layout_height="wrap_content" | ||||||
|         android:ellipsize="end" |         android:ellipsize="end" | ||||||
|         android:maxLines="3" |         android:maxLines="3" /> | ||||||
|         android:textColor="?textSecondary" /> |     <!-- TODO android:textColor="?textSecondary" --> | ||||||
| </LinearLayout> | </LinearLayout> | ||||||
| @ -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" |             android:src="@drawable/placeholder_image" | ||||||
|             app:srcCompat="@color/md_disabled_text_dark_theme" /> |             app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" /> | ||||||
|  |  | ||||||
|         <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 | ||||||
| @ -35,7 +44,7 @@ | |||||||
|         android:layout_height="wrap_content" |         android:layout_height="wrap_content" | ||||||
|         android:layout_marginTop="7dp" |         android:layout_marginTop="7dp" | ||||||
|         android:text="@string/component_episode_title" |         android:text="@string/component_episode_title" | ||||||
|         android:textColor="@color/textPrimaryDark" |         android:textColor="@color/player_text" | ||||||
|         android:textSize="16sp" /> |         android:textSize="16sp" /> | ||||||
|  |  | ||||||
|     <View |     <View | ||||||
| @ -44,7 +53,7 @@ | |||||||
|         android:layout_height="1dp" |         android:layout_height="1dp" | ||||||
|         android:layout_marginTop="5dp" |         android:layout_marginTop="5dp" | ||||||
|         android:layout_marginBottom="5dp" |         android:layout_marginBottom="5dp" | ||||||
|         android:background="@color/textSecondaryDark" /> |         android:background="@color/player_text_secondary" /> | ||||||
|  |  | ||||||
|     <TextView |     <TextView | ||||||
|         android:id="@+id/text_episode_desc2" |         android:id="@+id/text_episode_desc2" | ||||||
| @ -53,6 +62,6 @@ | |||||||
|         android:layout_marginTop="5dp" |         android:layout_marginTop="5dp" | ||||||
|         android:maxLines="10" |         android:maxLines="10" | ||||||
|         android:text="@string/text_overview_ex" |         android:text="@string/text_overview_ex" | ||||||
|         android:textColor="@color/textPrimaryDark" /> |         android:textColor="@color/player_text" /> | ||||||
|  |  | ||||||
| </LinearLayout> | </LinearLayout> | ||||||
							
								
								
									
										91
									
								
								app/src/main/res/layout/item_highlight_shimmer.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,91 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|  |     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||||
|  |     xmlns:tools="http://schemas.android.com/tools" | ||||||
|  |     android:layout_width="match_parent" | ||||||
|  |     android:layout_height="wrap_content"> | ||||||
|  |  | ||||||
|  |     <ImageView | ||||||
|  |         android:id="@+id/shimmer_image_highlight" | ||||||
|  |         android:layout_width="0dp" | ||||||
|  |         android:layout_height="0dp" | ||||||
|  |         android:src="@drawable/placeholder_image" | ||||||
|  |         app:layout_constraintDimensionRatio="H,16:9" | ||||||
|  |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |         app:layout_constraintStart_toStartOf="parent" | ||||||
|  |         app:layout_constraintTop_toTopOf="parent" | ||||||
|  |         tools:ignore="ContentDescription" /> | ||||||
|  |  | ||||||
|  |     <LinearLayout | ||||||
|  |         android:id="@+id/shimmer_linear_highlight" | ||||||
|  |         android:layout_width="0dp" | ||||||
|  |         android:layout_height="wrap_content" | ||||||
|  |         android:orientation="vertical" | ||||||
|  |         android:paddingBottom="7dp" | ||||||
|  |         app:layout_constraintBottom_toBottomOf="parent" | ||||||
|  |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |         app:layout_constraintStart_toStartOf="parent" | ||||||
|  |         app:layout_constraintTop_toBottomOf="@+id/shimmer_image_highlight"> | ||||||
|  |  | ||||||
|  |         <ImageView | ||||||
|  |             android:id="@+id/image_dummy_text" | ||||||
|  |             android:layout_width="128dp" | ||||||
|  |             android:layout_height="21dp" | ||||||
|  |             android:layout_marginTop="7dp" | ||||||
|  |             android:layout_gravity="center" | ||||||
|  |             app:srcCompat="@drawable/shape_rounded_corner" | ||||||
|  |             tools:ignore="ContentDescription" /> | ||||||
|  |  | ||||||
|  |         <LinearLayout | ||||||
|  |             android:layout_width="match_parent" | ||||||
|  |             android:layout_height="match_parent" | ||||||
|  |             android:layout_marginTop="7dp" | ||||||
|  |             android:gravity="center" | ||||||
|  |             android:orientation="horizontal"> | ||||||
|  |  | ||||||
|  |             <Space | ||||||
|  |                 android:layout_width="0dp" | ||||||
|  |                 android:layout_height="1dp" | ||||||
|  |                 android:layout_weight="1" /> | ||||||
|  |  | ||||||
|  |             <TextView | ||||||
|  |                 android:id="@+id/shimmer_text_highlight_my_list" | ||||||
|  |                 android:layout_width="64dp" | ||||||
|  |                 android:layout_height="wrap_content" | ||||||
|  |                 android:gravity="center" | ||||||
|  |                 android:textSize="12sp" | ||||||
|  |                 app:drawableTopCompat="@drawable/ic_baseline_add_24" /> | ||||||
|  |  | ||||||
|  |             <Space | ||||||
|  |                 android:layout_width="0dp" | ||||||
|  |                 android:layout_height="1dp" | ||||||
|  |                 android:layout_weight="1" /> | ||||||
|  |  | ||||||
|  |             <com.google.android.material.button.MaterialButton | ||||||
|  |                 android:id="@+id/shimmer_button_play_highlight" | ||||||
|  |                 android:layout_width="wrap_content" | ||||||
|  |                 android:layout_height="wrap_content" | ||||||
|  |                 android:gravity="center" | ||||||
|  |                 android:textSize="16sp" /> | ||||||
|  |  | ||||||
|  |             <Space | ||||||
|  |                 android:layout_width="0dp" | ||||||
|  |                 android:layout_height="1dp" | ||||||
|  |                 android:layout_weight="1" /> | ||||||
|  |  | ||||||
|  |             <TextView | ||||||
|  |                 android:id="@+id/shimmer_text_highlight_info" | ||||||
|  |                 android:layout_width="64dp" | ||||||
|  |                 android:layout_height="wrap_content" | ||||||
|  |                 android:gravity="center" | ||||||
|  |                 app:drawableTopCompat="@drawable/ic_outline_info_24" /> | ||||||
|  |  | ||||||
|  |             <Space | ||||||
|  |                 android:layout_width="0dp" | ||||||
|  |                 android:layout_height="1dp" | ||||||
|  |                 android:layout_weight="1" /> | ||||||
|  |  | ||||||
|  |         </LinearLayout> | ||||||
|  |     </LinearLayout> | ||||||
|  |  | ||||||
|  | </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
| @ -1,43 +1,79 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> | <?xml version="1.0" encoding="utf-8"?> | ||||||
| <com.google.android.material.card.MaterialCardView 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" |     xmlns:tools="http://schemas.android.com/tools" | ||||||
|     android:layout_width="195dp" |     android:layout_width="match_parent" | ||||||
|     android:layout_height="wrap_content" |     android:layout_height="wrap_content"> | ||||||
|     android:backgroundTint="?themeSecondary" |  | ||||||
|     android:visibility="visible" |  | ||||||
|     app:cardCornerRadius="7dp" |  | ||||||
|     app:cardElevation="4dp"> |  | ||||||
|  |  | ||||||
|     <androidx.constraintlayout.widget.ConstraintLayout |     <com.google.android.material.card.MaterialCardView | ||||||
|         android:layout_width="match_parent" |         android:layout_width="match_parent" | ||||||
|         android:layout_height="match_parent"> |         android:layout_height="wrap_content" | ||||||
|  |         app:cardCornerRadius="7dp" | ||||||
|  |         app:cardElevation="4dp" | ||||||
|  |         app:cardUseCompatPadding="true" | ||||||
|  |         app:layout_constraintBottom_toBottomOf="parent" | ||||||
|  |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |         app:layout_constraintStart_toStartOf="parent" | ||||||
|  |         app:layout_constraintTop_toTopOf="parent"> | ||||||
|  |  | ||||||
|         <ImageView |         <androidx.constraintlayout.widget.ConstraintLayout | ||||||
|             android:id="@+id/image_poster" |  | ||||||
|             android:layout_width="0dp" |  | ||||||
|             android:layout_height="0dp" |  | ||||||
|             android:contentDescription="@string/media_poster_desc" |  | ||||||
|             android:scaleType="centerCrop" |  | ||||||
|             app:layout_constraintBottom_toTopOf="@+id/text_title" |  | ||||||
|             app:layout_constraintDimensionRatio="H,16:9" |  | ||||||
|             app:layout_constraintEnd_toEndOf="parent" |  | ||||||
|             app:layout_constraintStart_toStartOf="parent" |  | ||||||
|             app:layout_constraintTop_toTopOf="parent" |  | ||||||
|             tools:srcCompat="@color/md_disabled_text_dark_theme" /> |  | ||||||
|  |  | ||||||
|         <TextView |  | ||||||
|             android:id="@+id/text_title" |  | ||||||
|             android:layout_width="match_parent" |             android:layout_width="match_parent" | ||||||
|             android:layout_height="wrap_content" |             android:layout_height="wrap_content"> | ||||||
|             android:gravity="center" |  | ||||||
|             android:lines="2" |  | ||||||
|             android:maxLines="2" |  | ||||||
|             android:padding="3dp" |  | ||||||
|             android:text="@string/text_title_ex" |  | ||||||
|             android:textAlignment="center" |  | ||||||
|             android:textSize="15sp" |  | ||||||
|             app:layout_constraintTop_toBottomOf="@+id/image_poster" /> |  | ||||||
|     </androidx.constraintlayout.widget.ConstraintLayout> |  | ||||||
|  |  | ||||||
| </com.google.android.material.card.MaterialCardView> |             <FrameLayout | ||||||
|  |                 android:id="@+id/frame_image_progress" | ||||||
|  |                 android:layout_width="0dp" | ||||||
|  |                 android:layout_height="0dp" | ||||||
|  |                 app:layout_constraintBottom_toTopOf="@+id/text_title" | ||||||
|  |                 app:layout_constraintDimensionRatio="H,16:9" | ||||||
|  |                 app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |                 app:layout_constraintStart_toStartOf="parent" | ||||||
|  |                 app:layout_constraintTop_toTopOf="parent"> | ||||||
|  |  | ||||||
|  |                 <ImageView | ||||||
|  |                     android:id="@+id/image_poster" | ||||||
|  |                     android:layout_width="wrap_content" | ||||||
|  |                     android:layout_height="wrap_content" | ||||||
|  |                     android:contentDescription="@string/media_poster_desc" | ||||||
|  |                     android:scaleType="fitCenter" | ||||||
|  |                     tools:srcCompat="@drawable/placeholder_image" /> | ||||||
|  |  | ||||||
|  |                 <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 | ||||||
|  |                 android:id="@+id/text_title" | ||||||
|  |                 android:layout_width="0dp" | ||||||
|  |                 android:layout_height="wrap_content" | ||||||
|  |                 android:gravity="center" | ||||||
|  |                 android:lines="2" | ||||||
|  |                 android:maxLines="2" | ||||||
|  |                 android:padding="3dp" | ||||||
|  |                 android:text="@string/text_title_ex" | ||||||
|  |                 android:textAlignment="center" | ||||||
|  |                 android:textSize="15sp" | ||||||
|  |                 app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |                 app:layout_constraintStart_toStartOf="parent" | ||||||
|  |                 app:layout_constraintTop_toBottomOf="@+id/frame_image_progress" /> | ||||||
|  |         </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
|  |  | ||||||
|  |     </com.google.android.material.card.MaterialCardView> | ||||||
|  |  | ||||||
|  | </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
|  | |||||||
							
								
								
									
										56
									
								
								app/src/main/res/layout/item_media_shimmer.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,56 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|  |     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||||
|  |     xmlns:tools="http://schemas.android.com/tools" | ||||||
|  |     android:layout_width="match_parent" | ||||||
|  |     android:layout_height="wrap_content"> | ||||||
|  |  | ||||||
|  |     <com.google.android.material.card.MaterialCardView | ||||||
|  |         android:layout_width="match_parent" | ||||||
|  |         android:layout_height="wrap_content" | ||||||
|  |         app:cardCornerRadius="7dp" | ||||||
|  |         app:cardElevation="4dp" | ||||||
|  |         app:cardUseCompatPadding="true" | ||||||
|  |         app:layout_constraintBottom_toBottomOf="parent" | ||||||
|  |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |         app:layout_constraintStart_toStartOf="parent" | ||||||
|  |         app:layout_constraintTop_toTopOf="parent"> | ||||||
|  |  | ||||||
|  |         <androidx.constraintlayout.widget.ConstraintLayout | ||||||
|  |             android:layout_width="match_parent" | ||||||
|  |             android:layout_height="wrap_content" | ||||||
|  |             app:layout_constraintWidth_max="195dp"> | ||||||
|  |  | ||||||
|  |             <FrameLayout | ||||||
|  |                 android:id="@+id/frame_image_progress" | ||||||
|  |                 android:layout_width="0dp" | ||||||
|  |                 android:layout_height="0dp" | ||||||
|  |                 app:layout_constraintDimensionRatio="H,16:9" | ||||||
|  |                 app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |                 app:layout_constraintStart_toStartOf="parent" | ||||||
|  |                 app:layout_constraintTop_toTopOf="parent"> | ||||||
|  |  | ||||||
|  |                 <ImageView | ||||||
|  |                     android:id="@+id/image_poster" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="match_parent" | ||||||
|  |                     tools:ignore="ContentDescription" /> | ||||||
|  |  | ||||||
|  |             </FrameLayout> | ||||||
|  |  | ||||||
|  |             <ImageView | ||||||
|  |                 android:id="@+id/image_dummy_text" | ||||||
|  |                 android:layout_width="128dp" | ||||||
|  |                 android:layout_height="19dp" | ||||||
|  |                 android:layout_margin="11dp" | ||||||
|  |                 app:layout_constraintBottom_toBottomOf="parent" | ||||||
|  |                 app:layout_constraintEnd_toEndOf="parent" | ||||||
|  |                 app:layout_constraintStart_toStartOf="parent" | ||||||
|  |                 app:layout_constraintTop_toBottomOf="@+id/frame_image_progress" | ||||||
|  |                 app:srcCompat="@drawable/shape_rounded_corner" | ||||||
|  |                 tools:ignore="ContentDescription" /> | ||||||
|  |         </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
|  |  | ||||||
|  |     </com.google.android.material.card.MaterialCardView> | ||||||
|  |  | ||||||
|  | </androidx.constraintlayout.widget.ConstraintLayout> | ||||||
							
								
								
									
										74
									
								
								app/src/main/res/layout/modal_bottom_sheet_login.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,74 @@ | |||||||
|  | <?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: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" /> | ||||||
|  |  | ||||||
|  |                 <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" /> | ||||||
|  |         </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 | ||||||
| @ -125,7 +131,7 @@ | |||||||
|             android:layout_width="0dp" |             android:layout_width="0dp" | ||||||
|             android:layout_height="wrap_content" |             android:layout_height="wrap_content" | ||||||
|             android:layout_marginEnd="7dp" |             android:layout_marginEnd="7dp" | ||||||
|             android:text="@string/subtitles" |             android:text="@string/language" | ||||||
|             android:textAllCaps="false" |             android:textAllCaps="false" | ||||||
|             app:icon="@drawable/ic_baseline_subtitles_24" |             app:icon="@drawable/ic_baseline_subtitles_24" | ||||||
|             app:layout_constraintBottom_toBottomOf="parent" |             app:layout_constraintBottom_toBottomOf="parent" | ||||||
|  | |||||||
| @ -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 | ||||||
| @ -35,25 +36,87 @@ | |||||||
|             android:layout_width="match_parent" |             android:layout_width="match_parent" | ||||||
|             android:layout_height="wrap_content" |             android:layout_height="wrap_content" | ||||||
|             android:layout_marginEnd="44dp" |             android:layout_marginEnd="44dp" | ||||||
|             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> | ||||||
|  |  | ||||||
|     <LinearLayout |     <LinearLayout | ||||||
|         android:id="@+id/linear_languages" |  | ||||||
|         android:layout_width="0dp" |         android:layout_width="0dp" | ||||||
|         android:layout_height="0dp" |         android:layout_height="0dp" | ||||||
|         android:layout_marginStart="56dp" |         android:orientation="horizontal" | ||||||
|         android:layout_marginEnd="56dp" |  | ||||||
|         android:orientation="vertical" |  | ||||||
|         app:layout_constraintBottom_toTopOf="@+id/linear_bottom" |         app:layout_constraintBottom_toTopOf="@+id/linear_bottom" | ||||||
|         app:layout_constraintEnd_toEndOf="parent" |         app:layout_constraintEnd_toEndOf="parent" | ||||||
|         app:layout_constraintStart_toStartOf="parent" |         app:layout_constraintStart_toStartOf="parent" | ||||||
|         app:layout_constraintTop_toBottomOf="@+id/linear_top" /> |         app:layout_constraintTop_toBottomOf="@+id/linear_top"> | ||||||
|  |  | ||||||
|  |         <LinearLayout | ||||||
|  |             android:layout_width="match_parent" | ||||||
|  |             android:layout_height="match_parent" | ||||||
|  |             android:layout_weight="1" | ||||||
|  |             android:orientation="vertical"> | ||||||
|  |  | ||||||
|  |             <TextView | ||||||
|  |                 android:id="@+id/text_audio" | ||||||
|  |                 android:layout_width="match_parent" | ||||||
|  |                 android:layout_height="wrap_content" | ||||||
|  |                 android:text="@string/audio" | ||||||
|  |                 android:textAlignment="center" | ||||||
|  |                 android:textColor="@color/player_white" | ||||||
|  |                 android:textSize="18sp" | ||||||
|  |                 android:textStyle="bold" /> | ||||||
|  |  | ||||||
|  |             <ScrollView | ||||||
|  |                 android:id="@+id/scroll_audio_languages" | ||||||
|  |                 android:layout_width="match_parent" | ||||||
|  |                 android:layout_height="match_parent" | ||||||
|  |                 android:contentDescription="@string/audio"> | ||||||
|  |  | ||||||
|  |                 <LinearLayout | ||||||
|  |                     android:id="@+id/linear_audio_languages" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="wrap_content" | ||||||
|  |                     android:layout_marginStart="56dp" | ||||||
|  |                     android:layout_marginEnd="56dp" | ||||||
|  |                     android:orientation="vertical" /> | ||||||
|  |             </ScrollView> | ||||||
|  |         </LinearLayout> | ||||||
|  |  | ||||||
|  |         <LinearLayout | ||||||
|  |             android:layout_width="match_parent" | ||||||
|  |             android:layout_height="match_parent" | ||||||
|  |             android:layout_weight="1" | ||||||
|  |             android:orientation="vertical"> | ||||||
|  |  | ||||||
|  |             <TextView | ||||||
|  |                 android:id="@+id/text_subtitles" | ||||||
|  |                 android:layout_width="match_parent" | ||||||
|  |                 android:layout_height="wrap_content" | ||||||
|  |                 android:text="@string/subtitles" | ||||||
|  |                 android:textAlignment="center" | ||||||
|  |                 android:textColor="@color/player_white" | ||||||
|  |                 android:textSize="18sp" | ||||||
|  |                 android:textStyle="bold" /> | ||||||
|  |  | ||||||
|  |             <ScrollView | ||||||
|  |                 android:id="@+id/scroll_subtitle_languages" | ||||||
|  |                 android:layout_width="match_parent" | ||||||
|  |                 android:layout_height="match_parent" | ||||||
|  |                 android:contentDescription="@string/subtitles"> | ||||||
|  |  | ||||||
|  |                 <LinearLayout | ||||||
|  |                     android:id="@+id/linear_subtitle_languages" | ||||||
|  |                     android:layout_width="match_parent" | ||||||
|  |                     android:layout_height="wrap_content" | ||||||
|  |                     android:layout_marginStart="56dp" | ||||||
|  |                     android:layout_marginEnd="56dp" | ||||||
|  |                     android:orientation="vertical" /> | ||||||
|  |             </ScrollView> | ||||||
|  |         </LinearLayout> | ||||||
|  |  | ||||||
|  |     </LinearLayout> | ||||||
|  |  | ||||||
|     <LinearLayout |     <LinearLayout | ||||||
|         android:id="@+id/linear_bottom" |         android:id="@+id/linear_bottom" | ||||||
| @ -75,9 +138,9 @@ | |||||||
|             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/button_text_color_light" | ||||||
|             android:textSize="16sp" |             android:textSize="16sp" | ||||||
|             app:backgroundTint="@color/buttonBackgroundLight" |             app:backgroundTint="@color/button_background_light" | ||||||
|             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" /> | ||||||
| @ -88,12 +151,13 @@ | |||||||
|             android:layout_height="wrap_content" |             android:layout_height="wrap_content" | ||||||
|             android:text="@string/apply" |             android:text="@string/apply" | ||||||
|             android:textAllCaps="false" |             android:textAllCaps="false" | ||||||
|             android:textColor="@color/themePrimaryDark" |             android:textColor="@color/button_text_color_dark" | ||||||
|             android:textSize="16sp" |             android:textSize="16sp" | ||||||
|             app:backgroundTint="@color/buttonBackgroundDark" |             app:backgroundTint="@color/button_background_dark" | ||||||
|             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> | ||||||
| @ -6,15 +6,15 @@ | |||||||
|         android:icon="@drawable/ic_home_black_24dp" |         android:icon="@drawable/ic_home_black_24dp" | ||||||
|         android:title="@string/title_home" /> |         android:title="@string/title_home" /> | ||||||
|  |  | ||||||
|  |     <item | ||||||
|  |         android:id="@+id/navigation_my_lists" | ||||||
|  |         android:icon="@drawable/ic_baseline_bookmark_border_24" | ||||||
|  |         android:title="@string/title_my_lists" /> | ||||||
|  |  | ||||||
|     <item |     <item | ||||||
|         android:id="@+id/navigation_library" |         android:id="@+id/navigation_library" | ||||||
|         android:icon="@drawable/ic_baseline_video_library_24" |         android:icon="@drawable/ic_baseline_video_library_24" | ||||||
|         android:title="@string/title_library" /> |         android:title="@string/title_library" /> | ||||||
|  |  | ||||||
|     <item |  | ||||||
|         android:id="@+id/navigation_search" |  | ||||||
|         android:icon="@drawable/ic_baseline_search_24" |  | ||||||
|         android:title="@string/title_search" /> |  | ||||||
|     <item |     <item | ||||||
|         android:id="@+id/navigation_account" |         android:id="@+id/navigation_account" | ||||||
|         android:icon="@drawable/ic_baseline_account_box_24" |         android:icon="@drawable/ic_baseline_account_box_24" | ||||||
|  | |||||||
							
								
								
									
										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 | 
| @ -11,18 +11,18 @@ | |||||||
|         android:label="@string/title_home" |         android:label="@string/title_home" | ||||||
|         tools:layout="@layout/fragment_home" /> |         tools:layout="@layout/fragment_home" /> | ||||||
|  |  | ||||||
|  |     <fragment | ||||||
|  |         android:id="@+id/navigation_my_lists" | ||||||
|  |         android:name="org.mosad.teapod.ui.activity.main.fragments.MyListsFragment" | ||||||
|  |         android:label="@string/title_my_lists" | ||||||
|  |         tools:layout="@layout/fragment_my_lists" /> | ||||||
|  |  | ||||||
|     <fragment |     <fragment | ||||||
|         android:id="@+id/navigation_library" |         android:id="@+id/navigation_library" | ||||||
|         android:name="org.mosad.teapod.ui.activity.main.fragments.LibraryFragment" |         android:name="org.mosad.teapod.ui.activity.main.fragments.LibraryFragment" | ||||||
|         android:label="@string/title_library" |         android:label="@string/title_library" | ||||||
|         tools:layout="@layout/fragment_library" /> |         tools:layout="@layout/fragment_library" /> | ||||||
|  |  | ||||||
|     <fragment |  | ||||||
|         android:id="@+id/navigation_search" |  | ||||||
|         android:name="org.mosad.teapod.ui.activity.main.fragments.SearchFragment" |  | ||||||
|         android:label="@string/title_search" |  | ||||||
|         tools:layout="@layout/fragment_search" /> |  | ||||||
|  |  | ||||||
|     <fragment |     <fragment | ||||||
|         android:id="@+id/navigation_account" |         android:id="@+id/navigation_account" | ||||||
|         android:name="org.mosad.teapod.ui.activity.main.fragments.AccountFragment" |         android:name="org.mosad.teapod.ui.activity.main.fragments.AccountFragment" | ||||||
|  | |||||||
| @ -1,14 +1,15 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> | <?xml version="1.0" encoding="utf-8"?> | ||||||
| <resources> | <resources> | ||||||
|     <string name="title_home">Startseite</string> |     <string name="title_home">Startseite</string> | ||||||
|  |     <string name="title_my_lists">Meine Listen</string> | ||||||
|     <string name="title_library">Übersicht</string> |     <string name="title_library">Übersicht</string> | ||||||
|     <string name="title_search">Suche</string> |  | ||||||
|     <string name="title_account">Account</string> |     <string name="title_account">Account</string> | ||||||
|  |  | ||||||
|     <!-- home fragment --> |     <!-- home fragment --> | ||||||
|     <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> | ||||||
| @ -17,6 +18,9 @@ | |||||||
|     <!-- search fragment --> |     <!-- search fragment --> | ||||||
|     <string name="search_hint">Suche nach Filmen und Serien</string> |     <string name="search_hint">Suche nach Filmen und Serien</string> | ||||||
|  |  | ||||||
|  |     <!-- my lists fragment --> | ||||||
|  |     <string name="downloads">Downloads</string> | ||||||
|  |  | ||||||
|     <!-- media fragment --> |     <!-- media fragment --> | ||||||
|     <string name="button_play">Abspielen</string> |     <string name="button_play">Abspielen</string> | ||||||
|     <plurals name="text_episodes_count"> |     <plurals name="text_episodes_count"> | ||||||
| @ -36,10 +40,13 @@ | |||||||
|     <string name="account_login_desc">Zum bearbeiten tippen</string> |     <string name="account_login_desc">Zum bearbeiten tippen</string> | ||||||
|     <string name="account_subscription">Abo %1$s</string> |     <string name="account_subscription">Abo %1$s</string> | ||||||
|     <string name="account_subscription_desc">Zum verlängern tippen</string> |     <string name="account_subscription_desc">Zum verlängern tippen</string> | ||||||
|  |     <string name="account_premium">Premium Mitglied</string> | ||||||
|  |     <string name="account_tier">Typ: %1$s</string> | ||||||
|     <string name="info">Info</string> |     <string name="info">Info</string> | ||||||
|     <string name="info_about_desc">Version %1$s (%2$s)</string> |     <string name="info_about_desc">Version %1$s (%2$s)</string> | ||||||
|     <string name="settings">Einstellungen</string> |     <string name="settings">Einstellungen</string> | ||||||
|     <string name="settings_content_language">Bevorzuge Inhaltssprache</string> |     <string name="settings_audio_language">Audio Sprache</string> | ||||||
|  |     <string name="settings_subtitle_language">Untertielsprache</string> | ||||||
|     <string name="settings_content_language_desc">Englisch</string> |     <string name="settings_content_language_desc">Englisch</string> | ||||||
|     <string name="settings_content_language_none">Keine</string> |     <string name="settings_content_language_none">Keine</string> | ||||||
|     <string name="settings_prefer_subbed">Bevorzuge OmU</string> |     <string name="settings_prefer_subbed">Bevorzuge OmU</string> | ||||||
| @ -49,12 +56,18 @@ | |||||||
|     <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="theme_system">System</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> | ||||||
| @ -75,10 +88,12 @@ | |||||||
|     <string name="next_episode">Nächste Folge</string> |     <string name="next_episode">Nächste Folge</string> | ||||||
|     <string name="skip_opening">Intro überspringen</string> |     <string name="skip_opening">Intro überspringen</string> | ||||||
|     <string name="language">Sprache</string> |     <string name="language">Sprache</string> | ||||||
|  |     <string name="audio">Audio</string> | ||||||
|     <string name="subtitles">Untertitel</string> |     <string name="subtitles">Untertitel</string> | ||||||
|     <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> | ||||||
| @ -101,7 +116,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> | ||||||