Compare commits
	
		
			60 Commits
		
	
	
		
			1.0.0-beta
			...
			1.1.0-beta
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 19552d3950 | |||
| 49e0b1ec29 | |||
| af66d968cc | 
| @ -5,15 +5,15 @@ plugins { | ||||
| } | ||||
|  | ||||
| android { | ||||
|     compileSdkVersion 31 | ||||
|     compileSdkVersion 33 | ||||
|     buildToolsVersion "30.0.3" | ||||
|  | ||||
|     defaultConfig { | ||||
|         applicationId "org.mosad.teapod" | ||||
|         minSdkVersion 23 | ||||
|         targetSdkVersion 31 | ||||
|         versionCode 9010 //00.09.010 | ||||
|         versionName "1.0.0-beta2" | ||||
|         targetSdkVersion 32 | ||||
|         versionCode 100990 //01.00.000 | ||||
|         versionName "1.1.0-beta1" | ||||
|  | ||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||
|         resValue "string", "build_time", buildTime() | ||||
| @ -48,37 +48,40 @@ android { | ||||
| dependencies { | ||||
|     implementation fileTree(dir: "libs", include: ["*.jar"]) | ||||
|     implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1' | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3' | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1' | ||||
|  | ||||
|     implementation 'androidx.core:core-ktx:1.7.0' | ||||
|     implementation 'androidx.core:core-splashscreen:1.0.0-rc01' | ||||
|     implementation 'androidx.appcompat:appcompat:1.4.1' | ||||
|     implementation 'androidx.constraintlayout:constraintlayout:2.1.3' | ||||
|     implementation 'androidx.navigation:navigation-fragment-ktx:2.4.2' | ||||
|     implementation 'androidx.navigation:navigation-ui-ktx:2.4.2' | ||||
|     implementation 'androidx.security:security-crypto:1.1.0-alpha03' | ||||
|     implementation 'androidx.core:core-ktx:1.9.0' | ||||
|     implementation 'androidx.core:core-splashscreen:1.0.0' | ||||
|     implementation 'androidx.appcompat:appcompat:1.6.0' | ||||
|     implementation 'androidx.constraintlayout:constraintlayout:2.1.4' | ||||
|     implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' | ||||
|     implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' | ||||
|     implementation 'androidx.security:security-crypto:1.1.0-alpha04' | ||||
|     implementation 'androidx.legacy:legacy-support-v4:1.0.0' | ||||
|     implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' | ||||
|     implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1' | ||||
|     implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' | ||||
|     implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' | ||||
|  | ||||
|     implementation 'com.google.android.material:material:1.5.0' | ||||
|     implementation 'com.google.android.material:material:1.7.0' | ||||
|     implementation "com.google.android.exoplayer:exoplayer-core:$exo_version" | ||||
|     implementation "com.google.android.exoplayer:exoplayer-hls:$exo_version" | ||||
|     implementation "com.google.android.exoplayer:exoplayer-dash:$exo_version" | ||||
|     implementation "com.google.android.exoplayer:exoplayer-ui:$exo_version" | ||||
|     implementation "com.google.android.exoplayer:extension-mediasession:$exo_version" | ||||
|  | ||||
|     implementation 'com.github.bumptech.glide:glide:4.13.1' | ||||
|     implementation 'com.facebook.shimmer:shimmer:0.5.0' | ||||
|  | ||||
|     implementation 'com.github.bumptech.glide:glide:4.14.2' | ||||
|     implementation 'jp.wasabeef:glide-transformations:4.3.0' | ||||
|  | ||||
|     implementation "io.ktor:ktor-client-core:$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' | ||||
|     androidTestImplementation 'androidx.test.ext:junit:1.1.3' | ||||
|     androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' | ||||
|     androidTestImplementation 'androidx.test.ext:junit:1.1.5' | ||||
|     androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
							
								
								
									
										3
									
								
								app/proguard-rules.pro
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								app/proguard-rules.pro
									
									
									
									
										vendored
									
									
								
							| @ -52,6 +52,9 @@ | ||||
| # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. | ||||
| -keepattributes RuntimeVisibleAnnotations,AnnotationDefault | ||||
|  | ||||
| # This is generated automatically by the Android Gradle plugin. | ||||
| -dontwarn org.slf4j.impl.StaticLoggerBinder | ||||
|  | ||||
| #misc | ||||
| -dontwarn java.lang.instrument.ClassFileTransformer | ||||
| -dontwarn java.lang.ClassValue | ||||
|  | ||||
| @ -25,15 +25,17 @@ package org.mosad.teapod.parser.crunchyroll | ||||
| import android.util.Log | ||||
| import io.ktor.client.* | ||||
| import io.ktor.client.call.* | ||||
| import io.ktor.client.features.* | ||||
| import io.ktor.client.features.json.* | ||||
| import io.ktor.client.features.json.serializer.* | ||||
| import io.ktor.client.plugins.* | ||||
| import io.ktor.client.plugins.contentnegotiation.* | ||||
| import io.ktor.client.request.* | ||||
| import io.ktor.client.request.forms.* | ||||
| import io.ktor.client.statement.* | ||||
| import io.ktor.http.* | ||||
| import io.ktor.serialization.* | ||||
| import io.ktor.serialization.kotlinx.json.* | ||||
| import kotlinx.coroutines.* | ||||
| import kotlinx.serialization.SerializationException | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import kotlinx.serialization.json.JsonObject | ||||
| import kotlinx.serialization.json.buildJsonObject | ||||
| @ -41,17 +43,18 @@ import kotlinx.serialization.json.put | ||||
| import org.mosad.teapod.preferences.EncryptedPreferences | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
|  | ||||
| private val json = Json { ignoreUnknownKeys = true } | ||||
|  | ||||
| object Crunchyroll { | ||||
|     private val TAG = javaClass.name | ||||
|  | ||||
|     private val client = HttpClient { | ||||
|         install(JsonFeature) { | ||||
|             serializer = KotlinxSerializer(json) | ||||
|         install(ContentNegotiation) { | ||||
|             json(Json { | ||||
|                 ignoreUnknownKeys = true | ||||
|             }) | ||||
|         } | ||||
|     } | ||||
|     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 var basicApiToken: String = "" | ||||
|  | ||||
| @ -61,6 +64,7 @@ object Crunchyroll { | ||||
|     private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext") | ||||
|  | ||||
|     private var accountID = "" | ||||
|     private var externalID = "" | ||||
|  | ||||
|     private var policy = "" | ||||
|     private var signature = "" | ||||
| @ -76,7 +80,7 @@ object Crunchyroll { | ||||
|      */ | ||||
|     fun initBasicApiToken() = runBlocking { | ||||
|         withContext(Dispatchers.IO) { | ||||
|             basicApiToken = (client.get(basicApiTokenUrl) as HttpResponse).readText() | ||||
|             basicApiToken = client.get { url(basicApiTokenUrl) }.bodyAsText() | ||||
|             Log.i(TAG, "basic auth token: $basicApiToken") | ||||
|         } | ||||
|     } | ||||
| @ -106,7 +110,7 @@ object Crunchyroll { | ||||
|                 val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { | ||||
|                     header("Authorization", "Basic $basicApiToken") | ||||
|                 } | ||||
|                 token = response.receive() | ||||
|                 token = response.body() | ||||
|                 tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000) | ||||
|                 response.status | ||||
|             } catch (ex: ClientRequestException) { | ||||
| @ -154,21 +158,24 @@ object Crunchyroll { | ||||
|  | ||||
|                 // for json set body and content type | ||||
|                 if (bodyObject is JsonObject) { | ||||
|                     body = bodyObject | ||||
|                     setBody(bodyObject) | ||||
|                     contentType(ContentType.Application.Json) | ||||
|                 } | ||||
|             } | ||||
|             }.body() | ||||
|  | ||||
|             response | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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( | ||||
|         endpoint: String, | ||||
|         params: List<Pair<String, Any?>> = listOf(), | ||||
|         url: String = "" | ||||
|     ): T { | ||||
|         val path = url.ifEmpty { "$baseUrl$endpoint" } | ||||
|         val path = url.ifEmpty { baseUrl }.plus(endpoint) | ||||
|  | ||||
|         return request(path, HttpMethod.Get, params) | ||||
|     } | ||||
| @ -239,12 +246,13 @@ object Crunchyroll { | ||||
|  | ||||
|         val account: Account = try { | ||||
|             requestGet(indexEndpoint) | ||||
|         } catch (ex: SerializationException) { | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "SerializationException in account(). This is bad!", ex) | ||||
|             NoneAccount | ||||
|         } | ||||
|  | ||||
|         accountID = account.accountId | ||||
|         externalID = account.externalId | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -268,7 +276,7 @@ object Crunchyroll { | ||||
|     ): BrowseResult { | ||||
|         val browseEndpoint = "/content/v1/browse" | ||||
|         val parameters = mutableListOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "sort_by" to sortBy.str, | ||||
|             "start" to start, | ||||
|             "n" to n | ||||
| @ -291,14 +299,15 @@ object Crunchyroll { | ||||
|             Log.d(TAG, "browse result not cached, fetching: $parameters") | ||||
|             val browseResult: BrowseResult = try { | ||||
|                 requestGet(browseEndpoint, parameters) | ||||
|             }catch (ex: SerializationException) { | ||||
|             }catch (ex: Exception) { | ||||
|                 Log.e(TAG, "SerializationException in browse().", ex) | ||||
|                 NoneBrowseResult | ||||
|             } | ||||
|  | ||||
|             // if the cache has more than 100 entries clear it, so it doesn't become a memory problem | ||||
|             // Note: this value is totally guessed and should be replaced by a properly researched value | ||||
|             if (browsingCache.size > 100) { | ||||
|             // TODO 100 is way to high as it's not the number of items but BrowseResults | ||||
|             if (browsingCache.size > 10) { | ||||
|                 browsingCache.clear() | ||||
|             } | ||||
|  | ||||
| @ -313,6 +322,8 @@ object Crunchyroll { | ||||
|      * Search fo a query term. | ||||
|      * Note: currently this function only supports series/tv shows. | ||||
|      * | ||||
|      * TODO migrate to v2 | ||||
|      * | ||||
|      * @param query The query term as String | ||||
|      * @param n The maximum number of results to return, default = 10 | ||||
|      * @return A **[SearchResult]** object | ||||
| @ -320,7 +331,7 @@ object Crunchyroll { | ||||
|     suspend fun search(query: String, n: Int = 10): SearchResult { | ||||
|         val searchEndpoint = "/content/v1/search" | ||||
|         val parameters = listOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "q" to query, | ||||
|             "n" to n, | ||||
|             "type" to "series" | ||||
| @ -331,8 +342,8 @@ object Crunchyroll { | ||||
|  | ||||
|         return try { | ||||
|             requestGet(searchEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in search(), with query = \"$query\".", ex) | ||||
|             NoneSearchResult | ||||
|         } | ||||
|     } | ||||
| @ -347,7 +358,7 @@ object Crunchyroll { | ||||
|     suspend fun objects(objects: List<String>): Collection<Item> { | ||||
|         val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}" | ||||
|         val parameters = listOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
| @ -355,28 +366,12 @@ object Crunchyroll { | ||||
|  | ||||
|         return try { | ||||
|             requestGet(episodesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in objects().", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in objects().", ex) | ||||
|             NoneCollection | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Main media functions: series, season, episodes, playback | ||||
|      */ | ||||
| @ -385,18 +380,16 @@ object Crunchyroll { | ||||
|      * series id == crunchyroll id? | ||||
|      */ | ||||
|     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( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
|             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(seriesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in series().", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in series().", ex) | ||||
|             NoneSeries | ||||
|         } | ||||
|     } | ||||
| @ -404,21 +397,29 @@ object Crunchyroll { | ||||
|     /** | ||||
|      * 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 { | ||||
|         val upNextSeriesEndpoint = "/content/v1/up_next_series" | ||||
|     suspend fun upNextSeries(seriesId: String): UpNextSeriesList { | ||||
|         val upNextSeriesEndpoint = "/content/v2/discover/up_next/$seriesId" | ||||
|         val parameters = listOf( | ||||
|             "series_id" to seriesId, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag() | ||||
|             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(upNextSeriesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in upNextSeries().", ex) | ||||
|             NoneUpNextSeriesItem | ||||
|         } catch (ex: NoTransformationFoundException) { | ||||
|             // should be 204 No Content | ||||
|             NoneUpNextSeriesList | ||||
|         } catch (ex: JsonConvertException) { | ||||
|             Log.e(TAG, "JsonConvertException in upNextSeries() with seriesId=$seriesId", ex) | ||||
|             NoneUpNextSeriesList | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in upNextSeries().", ex) | ||||
|             NoneUpNextSeriesList | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -429,19 +430,16 @@ object Crunchyroll { | ||||
|      * @return A **[Seasons]** object with a list of **[Season]** | ||||
|      */ | ||||
|     suspend fun seasons(seriesId: String): Seasons { | ||||
|         val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons" | ||||
|         val seasonsEndpoint = "/content/v2/cms/series/$seriesId/seasons" | ||||
|         val parameters = listOf( | ||||
|             "series_id" to seriesId, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
|             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(seasonsEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in seasons().", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in seasons().", ex) | ||||
|             NoneSeasons | ||||
|         } | ||||
|     } | ||||
| @ -453,19 +451,16 @@ object Crunchyroll { | ||||
|      * @return A **[Episodes]** object with a list of **[Episode]** | ||||
|      */ | ||||
|     suspend fun episodes(seasonId: String): Episodes { | ||||
|         val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes" | ||||
|         val episodesEndpoint = "/content/v2/cms/seasons/$seasonId/episodes" | ||||
|         val parameters = listOf( | ||||
|             "season_id" to seasonId, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
|             "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(episodesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in episodes().", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in episodes().", ex) | ||||
|             NoneEpisodes | ||||
|         } | ||||
|     } | ||||
| @ -473,18 +468,28 @@ object Crunchyroll { | ||||
|     /** | ||||
|      * Get all available subtitles and streams of a episode. | ||||
|      * | ||||
|      * @param url The playback url of a episode | ||||
|      * @return A **[Playback]** object | ||||
|      * @param url The streams url of a episode | ||||
|      * @return A **[Streams]** object | ||||
|      */ | ||||
|     suspend fun playback(url: String): Playback { | ||||
|     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 = url) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in playback(), with url = $url.", ex) | ||||
|             NonePlayback | ||||
|             requestGet(url, parameters) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in streams().", 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 | ||||
|      */ | ||||
| @ -496,14 +501,18 @@ object Crunchyroll { | ||||
|      * @return **[Boolean]**: ture if it was found, else false | ||||
|      */ | ||||
|     suspend fun isWatchlist(seriesId: String): Boolean { | ||||
|         val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId" | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|         val watchlistSeriesEndpoint = "/content/v2/$accountID/watchlist" | ||||
|         val parameters = listOf( | ||||
|             "content_ids" to seriesId, | ||||
|             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) | ||||
|                 .containsKey(seriesId) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex) | ||||
|             (requestGet(watchlistSeriesEndpoint, parameters) as Collection2<IsWatchlistItem>) | ||||
|                 .total == 1 | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in isWatchlist() with seriesId = $seriesId", ex) | ||||
|             false | ||||
|         } | ||||
|     } | ||||
| @ -514,14 +523,21 @@ object Crunchyroll { | ||||
|      * @param seriesId The crunchyroll series id of the media to check | ||||
|      */ | ||||
|     suspend fun postWatchlist(seriesId: String) { | ||||
|         val watchlistPostEndpoint = "/content/v1/watchlist/$accountID" | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|         val watchlistPostEndpoint = "/content/v2/$accountID/watchlist" | ||||
|         val parameters = listOf( | ||||
|             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         val json = buildJsonObject { | ||||
|             put("content_id", seriesId) | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             requestPost(watchlistPostEndpoint, parameters, json) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in postWatchlist() with seriesId = $seriesId", ex) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -530,10 +546,17 @@ object Crunchyroll { | ||||
|      * @param seriesId The crunchyroll series id of the media to check | ||||
|      */ | ||||
|     suspend fun deleteWatchlist(seriesId: String) { | ||||
|         val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId" | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|         val watchlistDeleteEndpoint = "/content/v2/$accountID/watchlist/$seriesId" | ||||
|         val parameters = listOf( | ||||
|             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         try { | ||||
|             requestDelete(watchlistDeleteEndpoint, parameters) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in deleteWatchlist() with seriesId = $seriesId", ex) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -544,18 +567,20 @@ object Crunchyroll { | ||||
|      * @param episodeIDs A **[List]** of episodes IDs as strings. | ||||
|      * @return A **[Map]**<String, **[PlayheadObject]**> containing playback info. | ||||
|      */ | ||||
|     suspend fun playheads(episodeIDs: List<String>): PlayheadsMap { | ||||
|         val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}" | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|     suspend fun playheads(episodeIDs: List<String>): Playheads { | ||||
|         val playheadsEndpoint = "/content/v2/$accountID/playheads" | ||||
|         val parameters = listOf( | ||||
|             "content_ids" to episodeIDs.joinToString(","), | ||||
|             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|  | ||||
|         return try { | ||||
|             requestGet(playheadsEndpoint, parameters) | ||||
|         } catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in playheads().", ex) | ||||
|             emptyMap() | ||||
|         } catch (ex: Throwable) { | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in playheads().", ex.cause) | ||||
|             emptyMap() | ||||
|             NonePlayheads | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -567,7 +592,7 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun postPlayheads(episodeId: String, playhead: Int) { | ||||
|         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 { | ||||
|             put("content_id", episodeId) | ||||
| @ -576,11 +601,32 @@ object Crunchyroll { | ||||
|  | ||||
|         try { | ||||
|             requestPost(playheadsEndpoint, parameters, json) | ||||
|         } catch (ex: Throwable) { | ||||
|         } 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. | ||||
|      * | ||||
| @ -592,14 +638,14 @@ object Crunchyroll { | ||||
|         val similarToEndpoint = "/content/v1/$accountID/similar_to" | ||||
|         val parameters = listOf( | ||||
|             "guid" to seriesId, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "n" to n | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(similarToEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in similarTo().", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in similarTo().", ex) | ||||
|             NoneSimilarToResult | ||||
|         } | ||||
|     } | ||||
| @ -612,23 +658,24 @@ object Crunchyroll { | ||||
|      * List items present in the watchlist. | ||||
|      * | ||||
|      * @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 { | ||||
|         val watchlistEndpoint = "/content/v1/$accountID/watchlist" | ||||
|     suspend fun watchlist(n: Int = 20): Collection<Item> { | ||||
|         val watchlistEndpoint = "/content/v2/discover/$accountID/watchlist" | ||||
|         val parameters = listOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "n" to n | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "n" to n, | ||||
|             "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         val list: ContinueWatchingList = try { | ||||
|         val list: Watchlist = try { | ||||
|             requestGet(watchlistEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in watchlist().", ex) | ||||
|             NoneContinueWatchingList | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in watchlist().", ex) | ||||
|             NoneWatchlist | ||||
|         } | ||||
|  | ||||
|         val objects = list.items.map{ it.panel.episodeMetadata.seriesId } | ||||
|         val objects = list.data.map{ it.panel.episodeMetadata.seriesId } | ||||
|         return objects(objects) | ||||
|     } | ||||
|  | ||||
| @ -636,27 +683,27 @@ object Crunchyroll { | ||||
|      * List the next up episodes for the logged in account. | ||||
|      * | ||||
|      * @param n Number of items to return, defaults to 20. | ||||
|      * @return A **[ContinueWatchingList]** containing up to n **[ContinueWatchingItem]**. | ||||
|      * @return A **[HistoryList]** containing up to n **[UpNextAccountItem]**. | ||||
|      */ | ||||
|     suspend fun upNextAccount(n: Int = 20): ContinueWatchingList { | ||||
|         val watchlistEndpoint = "/content/v1/$accountID/up_next_account" | ||||
|     suspend fun upNextAccount(n: Int = 20): HistoryList { | ||||
|         val watchlistEndpoint = "/content/v2/discover/$accountID/history" | ||||
|         val parameters = listOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "n" to n | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(watchlistEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in upNextAccount().", ex) | ||||
|             NoneContinueWatchingList | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in upNextAccount().", ex) | ||||
|             NoneHistoryList | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun recommendations(n: Int = 20, start: Int = 0): RecommendationsList { | ||||
|         val recommendationsEndpoint = "/content/v1/$accountID/recommendations" | ||||
|         val parameters = listOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), | ||||
|             "n" to n, | ||||
|             "start" to start, | ||||
|             "variant_id" to 0 | ||||
| @ -664,8 +711,8 @@ object Crunchyroll { | ||||
|  | ||||
|         return try { | ||||
|             requestGet(recommendationsEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in recommendations().", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in recommendations().", ex) | ||||
|             NoneRecommendationsList | ||||
|         } | ||||
|     } | ||||
| @ -684,8 +731,8 @@ object Crunchyroll { | ||||
|  | ||||
|         return try { | ||||
|             requestGet(profileEndpoint) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in profile().", ex) | ||||
|         } catch (ex: Exception) { | ||||
|             Log.e(TAG, "Exception in profile().", ex) | ||||
|             NoneProfile | ||||
|         } | ||||
|     } | ||||
| @ -704,4 +751,20 @@ object Crunchyroll { | ||||
|         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 | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -117,22 +117,18 @@ data class Collection<T>( | ||||
|     @SerialName("items") val items: List<T> | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class Collection2<T>( | ||||
|     @SerialName("total") val total: Int, | ||||
|     @SerialName("data") val data: List<T> | ||||
| ) | ||||
|  | ||||
| typealias SearchResult = Collection<SearchCollection> | ||||
| typealias SearchCollection = Collection<Item> | ||||
| typealias BrowseResult = Collection<Item> | ||||
| typealias SimilarToResult = Collection<Item> | ||||
| typealias DiscSeasonList = Collection<SeasonListItem> | ||||
| typealias Watchlist = Collection<Item> | ||||
| typealias ContinueWatchingList = Collection<ContinueWatchingItem> | ||||
| typealias RecommendationsList = Collection<Item> | ||||
|  | ||||
| @Serializable | ||||
| data class UpNextSeriesItem( | ||||
|     @SerialName("playhead") val playhead: Int, | ||||
|     @SerialName("fully_watched") val fullyWatched: Boolean, | ||||
|     @SerialName("never_watched") val neverWatched: Boolean, | ||||
|     @SerialName("panel") val panel: EpisodePanel, | ||||
| ) | ||||
| typealias Benefits = Collection<Benefit> | ||||
|  | ||||
| /** | ||||
|  * panel data classes | ||||
| @ -160,35 +156,45 @@ 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) | ||||
|  | ||||
| /** | ||||
|  * season list data classes | ||||
|  * up next & watchlist data classes | ||||
|  */ | ||||
| @Serializable | ||||
| data class SeasonListItem( | ||||
|     @SerialName("id") val id: String, | ||||
|     @SerialName("localization") val localization: SeasonListLocalization | ||||
| ) | ||||
|  | ||||
| typealias Watchlist = Collection2<WatchlistItem> | ||||
| typealias HistoryList = Collection2<UpNextAccountItem> | ||||
| typealias UpNextSeriesList = Collection2<UpNextSeriesItem> | ||||
|  | ||||
| @Serializable | ||||
| data class SeasonListLocalization( | ||||
|     @SerialName("title") val title: String, | ||||
|     @SerialName("description") val description: String, | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * continue_watching_item data classes | ||||
|  */ | ||||
| @Serializable | ||||
| data class ContinueWatchingItem( | ||||
| data class WatchlistItem( | ||||
|     @SerialName("panel") val panel: EpisodePanel, | ||||
|     @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, | ||||
|     // not present in watchlist -> continue_watching_item | ||||
|     @SerialName("fully_watched") val fullyWatched: Boolean = false, | ||||
|     @SerialName("never_watched") val neverWatched: Boolean = false, | ||||
|     @SerialName("is_favorite") val isFavorite: Boolean, | ||||
| ) | ||||
|  | ||||
| @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("new") val new: Boolean, | ||||
|     @SerialName("playhead") val playhead: Int, | ||||
|     @SerialName("fully_watched") val fullyWatched: Boolean = false, | ||||
| ) | ||||
|  | ||||
| @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 | ||||
| @ -201,7 +207,7 @@ data class EpisodePanel( | ||||
|     @SerialName("description") val description: String, | ||||
|     @SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata, | ||||
|     @SerialName("images") val images: Thumbnail, | ||||
|     @SerialName("playback") val playback: String, | ||||
| //    @SerialName("streams_link") val streamsLink: String, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| @ -215,37 +221,36 @@ data class EpisodeMetadata( | ||||
|     @SerialName("series_title") val seriesTitle: String, | ||||
| ) | ||||
|  | ||||
| val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList())) | ||||
| val NoneEpisodeMetadata = EpisodeMetadata(0, 0, "", 0, "", "", "") | ||||
| val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "") | ||||
|  | ||||
| val NoneCollection = Collection<Item>(0, emptyList()) | ||||
| val NoneSearchResult = SearchResult(0, emptyList()) | ||||
| val NoneBrowseResult = BrowseResult(0, emptyList()) | ||||
| val NoneSimilarToResult = SimilarToResult(0, emptyList()) | ||||
| val NoneDiscSeasonList = DiscSeasonList(0, emptyList()) | ||||
| val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) | ||||
| val NoneWatchlist = Watchlist(0, emptyList()) | ||||
| val NoneHistoryList = HistoryList(0, emptyList()) | ||||
| val NoneUpNextSeriesList = UpNextSeriesList(0, emptyList()) | ||||
| val NoneRecommendationsList = RecommendationsList(0, emptyList()) | ||||
|  | ||||
| val NoneUpNextSeriesItem = UpNextSeriesItem( | ||||
|     playhead = 0, | ||||
|     fullyWatched = false, | ||||
|     neverWatched = false, | ||||
|     panel = NoneEpisodePanel | ||||
| ) | ||||
| val NoneBenefits = Benefits(0, emptyList()) | ||||
|  | ||||
| /** | ||||
|  * series data class | ||||
|  */ | ||||
|  | ||||
| typealias Series = Collection2<SeriesItem> | ||||
|  | ||||
| @Serializable | ||||
| data class Series( | ||||
| data class SeriesItem( | ||||
|     @SerialName("id") val id: String, | ||||
|     @SerialName("title") val title: String, | ||||
|     @SerialName("description") val description: String, | ||||
|     @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> | ||||
|  | ||||
| ) | ||||
| val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList()) | ||||
|  | ||||
| val NoneSeriesItem = SeriesItem("", "", "", Images(emptyList(), emptyList()), false, emptyList(), emptyList()) | ||||
| val NoneSeries = Series(1, listOf(NoneSeriesItem)) | ||||
|  | ||||
| /** | ||||
|  * Seasons data classes | ||||
| @ -253,18 +258,8 @@ val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList( | ||||
| @Serializable | ||||
| data class Seasons( | ||||
|     @SerialName("total") val total: Int, | ||||
|     @SerialName("items") val items: 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 | ||||
|     } | ||||
| } | ||||
|     @SerialName("data") val data: List<Season> | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class Season( | ||||
| @ -287,7 +282,7 @@ val NoneSeason = Season("", "", "", "", 0, isSubbed = false, isDubbed = false) | ||||
| @Serializable | ||||
| data class Episodes( | ||||
|     @SerialName("total") val total: Int, | ||||
|     @SerialName("items") val items: List<Episode> | ||||
|     @SerialName("data") val data: List<Episode> | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| @ -307,7 +302,8 @@ data class Episode( | ||||
|     @SerialName("is_dubbed") val isDubbed: Boolean, | ||||
|     @SerialName("images") val images: Thumbnail, | ||||
|     @SerialName("duration_ms") val durationMs: Int, | ||||
|     @SerialName("playback") val playback: String, | ||||
|     @SerialName("versions") val versions: List<Version>, | ||||
|     @SerialName("streams_link") val streamsLink: String, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| @ -315,6 +311,17 @@ data class Thumbnail( | ||||
|     @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 NoneEpisode = Episode( | ||||
|     id = "", | ||||
| @ -332,10 +339,21 @@ val NoneEpisode = Episode( | ||||
|     isDubbed = false, | ||||
|     images = Thumbnail(listOf()), | ||||
|     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 = Collection2<PlayheadObject> | ||||
|  | ||||
| @Serializable | ||||
| data class PlayheadObject( | ||||
| @ -345,53 +363,61 @@ data class PlayheadObject( | ||||
|     @SerialName("last_modified") val lastModified: String, | ||||
| ) | ||||
|  | ||||
| val NonePlayheads = Playheads(0, emptyList()) | ||||
|  | ||||
| /** | ||||
|  * Meta data for a episode intro. All time values are in seconds. | ||||
|  */ | ||||
| @Serializable | ||||
| data class DatalabIntro( | ||||
|     @SerialName("media_id") val mediaId: String, | ||||
|     @SerialName("startTime") val startTime: Float, | ||||
|     @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, | ||||
| ) | ||||
|  | ||||
| val NoneDatalabIntro = DatalabIntro("", 0f, 0f, 0f, "", "", "") | ||||
|  | ||||
| /** | ||||
|  * playback/stream data classes | ||||
|  */ | ||||
| @Serializable | ||||
| data class Playback( | ||||
|     @SerialName("audio_locale") val audioLocale: String, | ||||
|     @SerialName("subtitles") val subtitles: Map<String, Subtitle>, | ||||
|     @SerialName("streams") val streams: Streams, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class Subtitle( | ||||
|     @SerialName("locale") val locale: String, | ||||
|     @SerialName("url") val url: String, | ||||
|     @SerialName("format") val format: String, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class Streams( | ||||
|     @SerialName("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_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("drm_adaptive_dash") val drm_adaptive_dash: Map<String, Stream>, | ||||
|     @SerialName("drm_adaptive_hls") val drm_adaptive_hls: Map<String, Stream>, | ||||
|     @SerialName("drm_download_hls") val drm_download_hls: Map<String, Stream>, | ||||
|     @SerialName("trailer_dash") val trailer_dash: 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_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_hls") val vo_drm_adaptive_hls: Map<String, Stream>, | ||||
| //    @SerialName("drm_adaptive_dash") val drmAdaptiveDash: Map<String, Stream>, | ||||
| //    @SerialName("drm_adaptive_hls") val drmAdaptiveHls: Map<String, Stream>, | ||||
| //    @SerialName("drm_download_dash") val drmDownloadDash: Map<String, Stream>, | ||||
| //    @SerialName("drm_download_hls") val drmDownloadHls: 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_drm_adaptive_dash") val vo_drm_adaptive_dash: Map<String, Stream>, | ||||
| //    @SerialName("vo_drm_adaptive_hls") val vo_drm_adaptive_hls: Map<String, Stream>, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class Stream( | ||||
|     @SerialName("hardsub_locale") val hardsubLocale: String, | ||||
|     @SerialName("url") val url: String, | ||||
|     @SerialName("vcodec") val vcodec: String, | ||||
|     @SerialName("hardsub_locale") val hardsubLocale: String = "", // default/nullable value since might be optional | ||||
|     @SerialName("url") val url: String = "", // default/nullable value since optional | ||||
|     @SerialName("vcodec") val vcodec: String = "", // default/nullable value since optional | ||||
| ) | ||||
|  | ||||
| val NonePlayback = Playback( | ||||
|     "", | ||||
|     mapOf(), | ||||
|     Streams( | ||||
|         mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), | ||||
|         mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), | ||||
|     ) | ||||
| val NoneStreams = Streams( | ||||
|     0, | ||||
|     arrayListOf(StreamList( | ||||
|         mapOf(), mapOf(), mapOf(), mapOf() | ||||
|     )) | ||||
| ) | ||||
|  | ||||
| /** | ||||
| @ -402,6 +428,7 @@ data class Profile( | ||||
|     @SerialName("avatar") val avatar: String, | ||||
|     @SerialName("email") val email: String, | ||||
|     @SerialName("maturity_rating") val maturityRating: String, | ||||
|     @SerialName("preferred_content_audio_language") val preferredContentAudioLanguage: String, | ||||
|     @SerialName("preferred_content_subtitle_language") val preferredContentSubtitleLanguage: String, | ||||
|     @SerialName("username") val username: String, | ||||
| ) | ||||
| @ -409,6 +436,20 @@ val NoneProfile = Profile( | ||||
|     avatar = "", | ||||
|     email = "", | ||||
|     maturityRating = "", | ||||
|     preferredContentAudioLanguage = "", | ||||
|     preferredContentSubtitleLanguage = "", | ||||
|     username = "" | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * benefit data class | ||||
|  */ | ||||
| @Serializable | ||||
| data class Benefit( | ||||
|     @SerialName("benefit") val benefit: String, | ||||
|     @SerialName("source") val source: String, | ||||
| ) | ||||
| val NoneBenefit = Benefit( | ||||
|     benefit = "", | ||||
|     source = "" | ||||
| ) | ||||
|  | ||||
| @ -8,7 +8,9 @@ import java.util.* | ||||
|  | ||||
| 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 | ||||
|     var preferredSubtitleLocale: Locale = Locale.forLanguageTag("en-US") | ||||
|         internal set | ||||
|     var preferSubbed = false | ||||
|         internal set | ||||
| @ -30,13 +32,22 @@ object Preferences { | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun savePreferredLocal(context: Context, preferredLocale: Locale) { | ||||
|     fun savePreferredAudioLocal(context: Context, preferredLocale: Locale) { | ||||
|         with(getSharedPref(context).edit()) { | ||||
|             putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag()) | ||||
|             apply() | ||||
|         } | ||||
|  | ||||
|         this.preferredLocale = preferredLocale | ||||
|         this.preferredAudioLocale = preferredLocale | ||||
|     } | ||||
|  | ||||
|     fun savePreferredSubtitleLocal(context: Context, preferredLocale: Locale) { | ||||
|         with(getSharedPref(context).edit()) { | ||||
|             putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag()) | ||||
|             apply() | ||||
|         } | ||||
|  | ||||
|         this.preferredSubtitleLocale = preferredLocale | ||||
|     } | ||||
|  | ||||
|     fun savePreferSecondary(context: Context, preferSubbed: Boolean) { | ||||
| @ -90,7 +101,12 @@ object Preferences { | ||||
|     fun load(context: 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( | ||||
|                 context.getString(R.string.save_key_preferred_local), "en-US" | ||||
|             ) ?: "en-US" | ||||
|  | ||||
| @ -26,6 +26,7 @@ import android.content.Intent | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.MenuItem | ||||
| import androidx.activity.addCallback | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen | ||||
| import androidx.fragment.app.Fragment | ||||
| @ -39,10 +40,9 @@ import org.mosad.teapod.preferences.EncryptedPreferences | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| 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.MyListsFragment | ||||
| 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.player.PlayerActivity | ||||
| import org.mosad.teapod.util.DataTypes | ||||
| import org.mosad.teapod.util.metadb.MetaDBController | ||||
| import java.util.* | ||||
| @ -78,16 +78,14 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|         supportFragmentManager.commit { | ||||
|             replace(R.id.nav_host_fragment, activeBaseFragment, activeBaseFragment.javaClass.simpleName) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onBackPressed() { | ||||
|         onBackPressedDispatcher.addCallback { | ||||
|             if (supportFragmentManager.backStackEntryCount > 0) { | ||||
|                 supportFragmentManager.popBackStack() | ||||
|             } else { | ||||
|                 if (activeBaseFragment !is HomeFragment) { | ||||
|                     binding.navView.selectedItemId = R.id.navigation_home | ||||
|             } else { | ||||
|                 super.onBackPressed() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @ -102,12 +100,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|                 activeBaseFragment = HomeFragment() | ||||
|                 true | ||||
|             } | ||||
|             R.id.navigation_library -> { | ||||
|                 activeBaseFragment = LibraryFragment() | ||||
|             R.id.navigation_my_lists -> { | ||||
|                 activeBaseFragment = MyListsFragment() | ||||
|                 true | ||||
|             } | ||||
|             R.id.navigation_search -> { | ||||
|                 activeBaseFragment = SearchFragment() | ||||
|             R.id.navigation_library -> { | ||||
|                 activeBaseFragment = LibraryFragment() | ||||
|                 true | ||||
|             } | ||||
|             R.id.navigation_account -> { | ||||
| @ -171,9 +169,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|             scope.launch { Crunchyroll.account() }, | ||||
|             scope.launch { | ||||
|                 // update the local preferred content language, since it may have changed | ||||
|                 val locale = Locale.forLanguageTag(Crunchyroll.profile().preferredContentSubtitleLanguage) | ||||
|                 Preferences.savePreferredLocal(this@MainActivity, locale) | ||||
|                 val profile = Crunchyroll.profile() | ||||
|  | ||||
|                 val audioLocale = Locale.forLanguageTag(profile.preferredContentAudioLanguage) | ||||
|                 val subtitleLocale = Locale.forLanguageTag(profile.preferredContentSubtitleLanguage) | ||||
|                 Preferences.savePreferredAudioLocal(this@MainActivity, audioLocale) | ||||
|                 Preferences.savePreferredSubtitleLocal(this@MainActivity, subtitleLocale) | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
| @ -191,17 +192,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|         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 | ||||
|      */ | ||||
|  | ||||
| @ -15,6 +15,7 @@ import kotlinx.coroutines.runBlocking | ||||
| import org.mosad.teapod.BuildConfig | ||||
| import org.mosad.teapod.R | ||||
| 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.Profile | ||||
| import org.mosad.teapod.parser.crunchyroll.supportedLocals | ||||
| @ -33,6 +34,9 @@ class AccountFragment : Fragment() { | ||||
|     private var profile: Deferred<Profile> = lifecycleScope.async { | ||||
|         Crunchyroll.profile() | ||||
|     } | ||||
|     private var benefits: Deferred<Benefits> = lifecycleScope.async { | ||||
|         Crunchyroll.benefits() | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentAccountBinding.inflate(inflater, container, false) | ||||
| @ -44,14 +48,18 @@ class AccountFragment : Fragment() { | ||||
|  | ||||
|         binding.textAccountLogin.text = EncryptedPreferences.login | ||||
|  | ||||
|         // TODO reimplement for cr, if possible (maybe account status would be better? (premium)) | ||||
|         // load subscription (async) info before anything else | ||||
|         binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading)) | ||||
|         // load account status and tier (async) info before anything else | ||||
|         lifecycleScope.launch { | ||||
|             binding.textAccountSubscription.text = getString( | ||||
|                 R.string.account_subscription, | ||||
|                 "TODO" | ||||
|             ) | ||||
|             benefits.await().apply { | ||||
|                 this.items.firstOrNull { it.benefit == "cr_premium" }?.let { | ||||
|                     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 | ||||
| @ -80,12 +88,6 @@ class AccountFragment : Fragment() { | ||||
|             showLoginDialog() | ||||
|         } | ||||
|  | ||||
|         binding.linearAccountSubscription.setOnClickListener { | ||||
|             // TODO | ||||
|             //startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl()))) | ||||
|         } | ||||
|  | ||||
|  | ||||
|         binding.linearSettingsContentLanguage.setOnClickListener { | ||||
|             showContentLanguageSelection() | ||||
|         } | ||||
| @ -166,7 +168,7 @@ class AccountFragment : Fragment() { | ||||
|  | ||||
|         }.invokeOnCompletion { | ||||
|             // update the local preferred content language | ||||
|             Preferences.savePreferredLocal(requireContext(), preferredLocale) | ||||
|             Preferences.savePreferredSubtitleLocal(requireContext(), preferredLocale) | ||||
|  | ||||
|             // update profile since the language selection might have changed | ||||
|             profile = lifecycleScope.async { Crunchyroll.profile() } | ||||
|  | ||||
| @ -27,20 +27,24 @@ import android.util.Log | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| 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.viewModels | ||||
| import androidx.lifecycle.Lifecycle | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.lifecycle.repeatOnLifecycle | ||||
| import com.bumptech.glide.Glide | ||||
| import com.facebook.shimmer.ShimmerFrameLayout | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.FragmentHomeBinding | ||||
| import org.mosad.teapod.ui.activity.main.MainActivity | ||||
| import org.mosad.teapod.ui.activity.main.viewmodel.HomeViewModel | ||||
| import org.mosad.teapod.util.adapter.MediaEpisodeListAdapter | ||||
| import org.mosad.teapod.util.adapter.MediaItemListAdapter | ||||
| import org.mosad.teapod.util.decoration.MediaItemDecoration | ||||
| import org.mosad.teapod.util.playerIntent | ||||
| import org.mosad.teapod.util.setDrawableTop | ||||
| import org.mosad.teapod.util.showFragment | ||||
| import org.mosad.teapod.util.toItemMediaList | ||||
| @ -51,6 +55,12 @@ class HomeFragment : Fragment() { | ||||
|     private val model: HomeViewModel by viewModels() | ||||
|     private lateinit var binding: FragmentHomeBinding | ||||
|  | ||||
|     private val itemOffset = 21 | ||||
|  | ||||
|     private val playerResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { | ||||
|         model.updateUpNextItems() | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentHomeBinding.inflate(inflater, container, false) | ||||
|         return binding.root | ||||
| @ -59,43 +69,39 @@ class HomeFragment : Fragment() { | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         binding.recyclerUpNext.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerRecommendations.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9)) | ||||
|  | ||||
|         binding.recyclerUpNext.adapter = MediaEpisodeListAdapter( | ||||
|             MediaEpisodeListAdapter.OnClickListener { | ||||
|                 val activity = activity | ||||
|                 if (activity is MainActivity) { | ||||
|                     activity.startPlayer(it.panel.episodeMetadata.seasonId, it.panel.id) | ||||
|                 } | ||||
|             } | ||||
|                 playerResult.launch(playerIntent(it.panel.episodeMetadata.seasonId, it.panel.id)) | ||||
|             }, | ||||
|             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 { | ||||
| @ -106,15 +112,12 @@ class HomeFragment : Fragment() { | ||||
|             // TODO since this might take a few seconds show a loading animation for the watchlist button | ||||
|         } | ||||
|  | ||||
|         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) | ||||
|             } | ||||
|         } | ||||
|         // 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) { | ||||
| @ -161,10 +164,36 @@ class HomeFragment : Fragment() { | ||||
|         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() { | ||||
|         // currently not used | ||||
|         // 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) { | ||||
| @ -172,4 +201,34 @@ class HomeFragment : Fragment() { | ||||
|         Log.e(classTag, "A error occurred while loading a UiState: ${uiState.message}") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Disable the shimmer effect for all shimmer layouts and hide them. | ||||
|      */ | ||||
|     private fun disableShimmer() { | ||||
|         binding.shimmerLayoutHighlight.apply { | ||||
|             stopShimmer() | ||||
|             isVisible = false | ||||
|         } | ||||
|         binding.shimmerLayoutUpNext.apply { | ||||
|             stopShimmer() | ||||
|             isVisible = false | ||||
|         } | ||||
|         binding.shimmerLayoutWatchlist.apply { | ||||
|             stopShimmer() | ||||
|             isVisible = false | ||||
|         } | ||||
|         binding.shimmerLayoutRecommendations.apply { | ||||
|             stopShimmer() | ||||
|             isVisible = false | ||||
|         } | ||||
|         binding.shimmerLayoutNewTitles.apply { | ||||
|             stopShimmer() | ||||
|             isVisible = false | ||||
|         } | ||||
|         binding.shimmerLayoutTopTen.apply { | ||||
|             stopShimmer() | ||||
|             isVisible = false | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -1,29 +1,30 @@ | ||||
| package org.mosad.teapod.ui.activity.main.fragments | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.appcompat.widget.SearchView | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.viewModels | ||||
| import androidx.lifecycle.Lifecycle | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.lifecycle.repeatOnLifecycle | ||||
| import androidx.recyclerview.widget.GridLayoutManager | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.databinding.FragmentLibraryBinding | ||||
| import org.mosad.teapod.parser.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.ui.activity.main.viewmodel.LibraryFragmentViewModel | ||||
| import org.mosad.teapod.util.adapter.MediaItemListAdapter | ||||
| import org.mosad.teapod.util.showFragment | ||||
|  | ||||
| class LibraryFragment : Fragment() { | ||||
|  | ||||
|     private lateinit var binding: FragmentLibraryBinding | ||||
|     private lateinit var adapter: MediaItemAdapter | ||||
|  | ||||
|     private val itemList = arrayListOf<ItemMedia>() | ||||
|     private val pageSize = 30 | ||||
|     private var nextItemIndex = 0 | ||||
|     private lateinit var adapter: MediaItemListAdapter | ||||
|     private val model: LibraryFragmentViewModel by viewModels() | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentLibraryBinding.inflate(inflater, container, false) | ||||
| @ -33,53 +34,73 @@ class LibraryFragment : Fragment() { | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         // init async | ||||
|         lifecycleScope.launch { | ||||
|             // create and set the adapter, needs context | ||||
|             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.onItemClick = { mediaIdStr, _ -> | ||||
|                     activity?.showFragment(MediaFragment(mediaIdStr)) | ||||
|                 } | ||||
|  | ||||
|                 binding.recyclerMediaLibrary.adapter = adapter | ||||
|                 binding.recyclerMediaLibrary.addItemDecoration(MediaItemDecoration(9)) | ||||
|         // TODO replace with pagination3 | ||||
|         // https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797 | ||||
|                 binding.recyclerMediaLibrary.addOnScrollListener(PaginationScrollListener()) | ||||
|         binding.recyclerMediaSearch.addOnScrollListener(PaginationScrollListener()) | ||||
|  | ||||
|         adapter = MediaItemListAdapter(MediaItemListAdapter.OnClickListener { | ||||
|             binding.searchText.clearFocus() | ||||
|             activity?.showFragment(MediaFragment(it.id)) | ||||
|         }) | ||||
|         binding.recyclerMediaSearch.adapter = adapter | ||||
|  | ||||
|         binding.searchText.setOnQueryTextListener(object : SearchView.OnQueryTextListener { | ||||
|             override fun onQueryTextSubmit(query: String?): Boolean { | ||||
|                 query?.let { model.search(it) } | ||||
|                 return false // return false to dismiss the keyboard | ||||
|             } | ||||
|  | ||||
|             override fun onQueryTextChange(newText: String?): Boolean { | ||||
|                 newText?.let { model.search(it) } | ||||
|                 return false // return false to dismiss the keyboard | ||||
|             } | ||||
|         }) | ||||
|  | ||||
|         viewLifecycleOwner.lifecycleScope.launch { | ||||
|             viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||||
|                 model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> | ||||
|                     when (uiState) { | ||||
|                         is LibraryFragmentViewModel.UiState.Browse -> bindUiStateBrowse(uiState) | ||||
|                         is LibraryFragmentViewModel.UiState.Search -> bindUiStateSearch(uiState) | ||||
|                         is LibraryFragmentViewModel.UiState.Loading -> bindUiStateLoading() | ||||
|                         is LibraryFragmentViewModel.UiState.Error -> bindUiStateError(uiState) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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() { | ||||
|         private var isLoading = false | ||||
|  | ||||
|         override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { | ||||
|             super.onScrolled(recyclerView, dx, dy) | ||||
|             val layoutManager = recyclerView.layoutManager as GridLayoutManager? | ||||
|  | ||||
|             if (!isLoading) layoutManager?.let { | ||||
|                 // itemList.size - 5 to start loading a bit earlier than the actual end | ||||
|                 if (layoutManager.findLastCompletelyVisibleItemPosition() >= (itemList.size - 5)) { | ||||
|                     // load new browse results async | ||||
|                     isLoading = true | ||||
|                     lifecycleScope.launch { | ||||
|                         val firstNewItemIndex = itemList.lastIndex + 1 | ||||
|                         val results = Crunchyroll.browse(start = nextItemIndex, n = pageSize) | ||||
|                         itemList.addAll(results.items.map { item -> | ||||
|                             ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source) | ||||
|                         }) | ||||
|                         nextItemIndex += pageSize | ||||
|  | ||||
|                         adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize) | ||||
|                         isLoading = false | ||||
|             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) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
| @ -87,3 +108,5 @@ class LibraryFragment : Fragment() { | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -7,6 +7,7 @@ import android.util.Log | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.viewModels | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| @ -19,12 +20,13 @@ import jp.wasabeef.glide.transformations.BlurTransformation | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.FragmentMediaBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.NoneUpNextSeriesItem | ||||
| import org.mosad.teapod.ui.activity.main.MainActivity | ||||
| import org.mosad.teapod.parser.crunchyroll.NoneUpNextSeriesList | ||||
| 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.TMDBMovie | ||||
| import org.mosad.teapod.util.tmdb.TMDBTVShow | ||||
| import org.mosad.teapod.util.toItemMediaList | ||||
|  | ||||
| /** | ||||
|  * The media detail fragment. | ||||
| @ -40,7 +42,10 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|  | ||||
|     private val fragments = arrayListOf<Fragment>() | ||||
|     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 { | ||||
| @ -74,33 +79,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onResume() { | ||||
|         super.onResume() | ||||
|  | ||||
|         if (runOnResume) { | ||||
|             /** | ||||
|              * FIXME | ||||
|              * this is currently also run on back press when multiple MediaFragments have | ||||
|              * been open and closed via similar tab | ||||
|              */ | ||||
|  | ||||
|             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 | ||||
|      */ | ||||
| @ -120,14 +98,14 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|             .into(binding.imageBackdrop) | ||||
|  | ||||
|         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) | ||||
|             else -> "" | ||||
|         } | ||||
|         binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull() | ||||
|  | ||||
|         binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesItem) { | ||||
|             upNextSeries.panel.title | ||||
|         binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesList) { | ||||
|             upNextSeries.data.first().panel.title | ||||
|         } else seriesCrunchy.title | ||||
|         binding.textOverview.text = seriesCrunchy.description | ||||
|  | ||||
| @ -149,6 +127,20 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|             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) | ||||
|         when (tmdbResult) { | ||||
|             is TMDBTVShow -> { | ||||
| @ -177,27 +169,14 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // if has similar titles | ||||
|         if (model.similarTo.total > 0) { | ||||
|             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 | ||||
|     } | ||||
|  | ||||
|     private fun initActions() = with(model) { | ||||
|         binding.buttonPlay.setOnClickListener { | ||||
|             if (upNextSeries != NoneUpNextSeriesItem) { | ||||
|                 playEpisode(upNextSeries.panel.episodeMetadata.seasonId, upNextSeries.panel.id) | ||||
|             if (upNextSeries != NoneUpNextSeriesList) { | ||||
|                 val panel = upNextSeries.data.first().panel | ||||
|                 playEpisode(panel.episodeMetadata.seasonId, panel.id) | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @ -218,15 +197,25 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * play the current episode | ||||
|      * 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") | ||||
|     private fun playerFinishedCallback() = lifecycleScope.launch { | ||||
|         model.updateOnResume() | ||||
|  | ||||
|         //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") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|  | ||||
| @ -2,7 +2,6 @@ package org.mosad.teapod.ui.activity.main.fragments | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| @ -13,7 +12,6 @@ import androidx.lifecycle.lifecycleScope | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| 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.util.adapter.EpisodeItemAdapter | ||||
|  | ||||
| @ -37,7 +35,7 @@ class MediaFragmentEpisodes : Fragment() { | ||||
|             model.tmdbTVSeason.episodes, | ||||
|             model.currentPlayheads, | ||||
|             EpisodeItemAdapter.OnClickListener { episode -> | ||||
|                 playEpisode(episode.seasonId, episode.id) | ||||
|                 (requireParentFragment() as? MediaFragment)?.playEpisode(episode.seasonId, episode.id) | ||||
|             }, | ||||
|             EpisodeItemAdapter.ViewType.MEDIA_FRAGMENT | ||||
|         ) | ||||
| @ -69,7 +67,7 @@ class MediaFragmentEpisodes : Fragment() { | ||||
|     private fun showSeasonSelection(v: View) { | ||||
|         // TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus | ||||
|         val popup = PopupMenu(requireContext(), v) | ||||
|         model.seasonsCrunchy.items.forEach { season -> | ||||
|         model.seasonsCrunchy.data.forEach { season -> | ||||
|             popup.menu.add(getString( | ||||
|                     R.string.season_number_title, | ||||
|                     season.seasonNumber, | ||||
| @ -106,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 | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -27,17 +27,13 @@ import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.viewModels | ||||
| 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.MediaItemListAdapter | ||||
| import org.mosad.teapod.util.decoration.MediaItemDecoration | ||||
| import org.mosad.teapod.util.showFragment | ||||
| import org.mosad.teapod.util.toItemMediaList | ||||
|  | ||||
| class MediaFragmentSimilar : Fragment()  { | ||||
| class MediaFragmentSimilar(val items: List<ItemMedia>) : Fragment()  { | ||||
|  | ||||
|     private val model: MediaFragmentViewModel by viewModels({requireParentFragment()}) | ||||
|     private lateinit var binding: FragmentMediaSimilarBinding | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
| @ -48,7 +44,6 @@ class MediaFragmentSimilar : Fragment()  { | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerMediaSimilar.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
| @ -56,6 +51,6 @@ class MediaFragmentSimilar : Fragment()  { | ||||
|         ) | ||||
|  | ||||
|         val adapterSimilar = binding.recyclerMediaSimilar.adapter as MediaItemListAdapter | ||||
|         adapterSimilar.submitList(model.similarTo.toItemMediaList()) | ||||
|         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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -26,24 +26,28 @@ import androidx.lifecycle.LifecycleCoroutineScope | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import kotlinx.coroutines.async | ||||
| import kotlinx.coroutines.flow.* | ||||
| 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<ContinueWatchingItem>, | ||||
|             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() | ||||
| @ -62,8 +66,8 @@ class HomeViewModel : ViewModel()  { | ||||
|             uiState.emit(UiState.Loading) | ||||
|             try { | ||||
|                 // run the loading in parallel to speed up the process | ||||
|                 val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().items } | ||||
|                 val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(50).items } | ||||
|                 val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().data } | ||||
|                 val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(WATCHLIST_LENGTH).items } | ||||
|                 val recommendationsJob = viewModelScope.async { | ||||
|                     Crunchyroll.recommendations(20).items | ||||
|                 } | ||||
| @ -77,12 +81,17 @@ class HomeViewModel : ViewModel()  { | ||||
|                 val recentlyAddedItems = recentlyAddedJob.await() | ||||
|                 // FIXME crashes on newTitles.items.size == 0 | ||||
|                 val highlightItem = recentlyAddedItems[Random.nextInt(recentlyAddedItems.size)] | ||||
|                 val highlightItemIsWatchlist = Crunchyroll.isWatchlist(highlightItem.id) | ||||
|                 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, | ||||
|                     highlightItemIsWatchlist | ||||
|                     highlightItemUpNextJob.await(), highlightItemIsWatchlistJob.await() | ||||
|                 )) | ||||
|             } catch (e: Exception) { | ||||
|                 uiState.emit(UiState.Error(e.message)) | ||||
| @ -105,7 +114,7 @@ class HomeViewModel : ViewModel()  { | ||||
|                     } | ||||
|  | ||||
|                     // update the watchlist after a item has been added/removed | ||||
|                     val watchlistItems = Crunchyroll.watchlist(50).items | ||||
|                     val watchlistItems = Crunchyroll.watchlist(WATCHLIST_LENGTH).items | ||||
|  | ||||
|                     currentUiState.copy( | ||||
|                         watchlistItems = watchlistItems, | ||||
| @ -115,9 +124,22 @@ class HomeViewModel : ViewModel()  { | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|  | ||||
|     /** | ||||
|      * 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().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) | ||||
|                         .items.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,12 +3,13 @@ package org.mosad.teapod.ui.activity.main.viewmodel | ||||
| import android.app.Application | ||||
| import androidx.lifecycle.AndroidViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import kotlinx.coroutines.async | ||||
| import kotlinx.coroutines.joinAll | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.parser.crunchyroll.* | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| import org.mosad.teapod.util.DataTypes.MediaType | ||||
| import org.mosad.teapod.util.tmdb.* | ||||
| import org.mosad.teapod.util.toPlayheadsMap | ||||
|  | ||||
| /** | ||||
|  * handle media, next ep and tmdb | ||||
| @ -16,7 +17,7 @@ import org.mosad.teapod.util.tmdb.* | ||||
|  */ | ||||
| class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { | ||||
|  | ||||
|     var seriesCrunchy = NoneSeries // movies are also series | ||||
|     var seriesCrunchy = NoneSeriesItem // movies are also series | ||||
|         internal set | ||||
|     var seasonsCrunchy = NoneSeasons | ||||
|         internal set | ||||
| @ -26,11 +27,12 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         internal set | ||||
|     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() | ||||
|     var isWatchlist = false | ||||
|         internal set | ||||
|     var upNextSeries = NoneUpNextSeriesItem | ||||
|     var upNextSeries = NoneUpNextSeriesList | ||||
|         internal set | ||||
|     var similarTo = NoneSimilarToResult | ||||
|         internal set | ||||
| @ -50,36 +52,38 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|     suspend fun loadCrunchy(crunchyId: String) { | ||||
|         // load series and seasons info in parallel | ||||
|         listOf( | ||||
|             viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, | ||||
|             viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId).data.first() }, | ||||
|             viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) }, | ||||
|             viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) }, | ||||
|             viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(crunchyId) }, | ||||
|             viewModelScope.launch { similarTo = Crunchyroll.similarTo(crunchyId) } | ||||
|         ).joinAll() | ||||
|  | ||||
|         // load the preferred season (preferred language, language per season, not per stream) | ||||
|         currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocale) | ||||
|         // load the preferred season: | ||||
|         // 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) | ||||
|         viewModelScope.launch { episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) }.join() | ||||
|         currentEpisodesCrunchy.clear() | ||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.items) | ||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.data) | ||||
|  | ||||
|         // set media type | ||||
|         mediaType = episodesCrunchy.items.firstOrNull()?.let { | ||||
|         mediaType = episodesCrunchy.data.firstOrNull()?.let { | ||||
|             if (it.episodeNumber != null) MediaType.TVSHOW else MediaType.MOVIE | ||||
|         } ?: MediaType.OTHER | ||||
|  | ||||
|         // load playheads and tmdb in parallel | ||||
|         listOf( | ||||
|             viewModelScope.launch { | ||||
|                 // get playheads (including fully watched state) | ||||
|                 val episodeIDs = episodesCrunchy.items.map { it.id } | ||||
|                 currentPlayheads.clear() | ||||
|                 currentPlayheads.putAll(Crunchyroll.playheads(episodeIDs)) | ||||
|             }, | ||||
|             updatePlayheadsAsync(), | ||||
|             viewModelScope.launch { loadTmdbInfo() } // use tmdb search to get media info | ||||
|         ).joinAll() | ||||
|     } | ||||
| @ -96,7 +100,6 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|             MediaType.TVSHOW -> tmdbApiController.searchTVShow(seriesCrunchy.title) | ||||
|             else -> NoneTMDBSearch | ||||
|         } | ||||
| //        println(tmdbSearchResult) | ||||
|  | ||||
|         tmdbResult = if (tmdbSearchResult.results.isNotEmpty()) { | ||||
|             when (val result = tmdbSearchResult.results.first()) { | ||||
| @ -105,7 +108,6 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|                 else -> NoneTMDB | ||||
|             } | ||||
|         } else NoneTMDB | ||||
| //        println(tmdbResult) | ||||
|  | ||||
|         // currently not used | ||||
| //        tmdbTVSeason = if (tmdbResult is TMDBTVShow) { | ||||
| @ -113,6 +115,16 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
| //        } 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. | ||||
|      * | ||||
| @ -124,18 +136,16 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|  | ||||
|         // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, | ||||
|         // don't change the current season (this should/can never happen) | ||||
|         currentSeasonCrunchy = seasonsCrunchy.items.firstOrNull { | ||||
|         currentSeasonCrunchy = seasonsCrunchy.data.firstOrNull { | ||||
|             it.id == seasonId | ||||
|         } ?: currentSeasonCrunchy | ||||
|  | ||||
|         episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) | ||||
|         currentEpisodesCrunchy.clear() | ||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.items) | ||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.data) | ||||
|  | ||||
|         // update playheads playheads (including fully watched state) | ||||
|         val episodeIDs = episodesCrunchy.items.map { it.id } | ||||
|         currentPlayheads.clear() | ||||
|         currentPlayheads.putAll(Crunchyroll.playheads(episodeIDs)) | ||||
|         updatePlayheadsAsync().await() | ||||
|     } | ||||
|  | ||||
|     suspend fun setWatchlist() { | ||||
| @ -150,11 +160,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|  | ||||
|     suspend fun updateOnResume() { | ||||
|         joinAll( | ||||
|             viewModelScope.launch { | ||||
|                 val episodeIDs = episodesCrunchy.items.map { it.id } | ||||
|                 currentPlayheads.clear() | ||||
|                 currentPlayheads.putAll(Crunchyroll.playheads(episodeIDs)) | ||||
|             }, | ||||
|             updatePlayheadsAsync(), | ||||
|             viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(seriesCrunchy.id) } | ||||
|         ) | ||||
|     } | ||||
|  | ||||
| @ -3,13 +3,14 @@ package org.mosad.teapod.ui.activity.onboarding | ||||
| import android.content.Intent | ||||
| import android.os.Bundle | ||||
| import android.view.View | ||||
| import androidx.activity.addCallback | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import androidx.viewpager2.adapter.FragmentStateAdapter | ||||
| 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.ui.activity.main.MainActivity | ||||
|  | ||||
| class OnboardingActivity : AppCompatActivity() { | ||||
|  | ||||
| @ -35,15 +36,13 @@ class OnboardingActivity : AppCompatActivity() { | ||||
|         if (fragments.size <= 1) { | ||||
|             binding.tabLayout.visibility = View.GONE | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onBackPressed() { | ||||
|         if (binding.viewPager.currentItem == 0) { | ||||
|             super.onBackPressed() | ||||
|         } else { | ||||
|         onBackPressedDispatcher.addCallback { | ||||
|             if (binding.viewPager.currentItem != 0) { | ||||
|                 binding.viewPager.currentItem = binding.viewPager.currentItem - 1 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun nextFragment() { | ||||
|         if (binding.viewPager.currentItem < fragments.size - 1) { | ||||
|  | ||||
| @ -46,6 +46,7 @@ import androidx.lifecycle.lifecycleScope | ||||
| import com.google.android.exoplayer2.ExoPlayer | ||||
| import com.google.android.exoplayer2.Player | ||||
| import com.google.android.exoplayer2.ui.StyledPlayerControlView | ||||
| import com.google.android.exoplayer2.ui.StyledPlayerView | ||||
| import com.google.android.exoplayer2.util.Util | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| @ -70,7 +71,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|  | ||||
|     private lateinit var controller: StyledPlayerControlView | ||||
|     private lateinit var gestureDetector: GestureDetectorCompat | ||||
|     private lateinit var timerUpdates: TimerTask | ||||
|     private lateinit var controlsUpdates: TimerTask | ||||
|  | ||||
|     private var wasInPiP = false | ||||
|     private var remainingTime: Long = 0 | ||||
| @ -85,8 +86,6 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         hideBars() // Initial hide the bars | ||||
|  | ||||
|         playerBinding = ActivityPlayerBinding.bind(findViewById(R.id.player_root)) | ||||
|  | ||||
|         println(findViewById(R.id.player_controls_root)) | ||||
|         controlsBinding = PlayerControlsBinding.bind(findViewById(R.id.player_controls_root)) | ||||
|  | ||||
|         model.loadMediaAsync( | ||||
| @ -194,9 +193,11 @@ class PlayerActivity : AppCompatActivity() { | ||||
|  | ||||
|     override fun onPictureInPictureModeChanged( | ||||
|         isInPictureInPictureMode: Boolean, | ||||
|         newConfig: Configuration? | ||||
|         newConfig: Configuration | ||||
|     ) { | ||||
|         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. | ||||
|         playerBinding.videoView.useController = !isInPictureInPictureMode | ||||
| @ -229,7 +230,11 @@ class PlayerActivity : AppCompatActivity() { | ||||
|                     else -> View.GONE | ||||
|                 } | ||||
|  | ||||
|                 controlsBinding.exoPlayPause.isVisible = !playerBinding.loading.isVisible | ||||
|                 // don't use isVisible to hide exoPlayPause, as it will set the visibility to GONE | ||||
|                 controlsBinding.exoPlayPause.visibility = when(playerBinding.loading.isVisible) { | ||||
|                     true -> View.INVISIBLE | ||||
|                     false -> View.VISIBLE | ||||
|                 } | ||||
|  | ||||
|                 if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) { | ||||
|                     playNextEpisode() | ||||
| @ -247,7 +252,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         playerBinding.videoView.player = model.player | ||||
|  | ||||
|         // when the player controls get hidden, hide the bars too | ||||
|         playerBinding.videoView.setControllerVisibilityListener { | ||||
|         playerBinding.videoView.setControllerVisibilityListener(StyledPlayerView.ControllerVisibilityListener { | ||||
|             when (it) { | ||||
|                 View.GONE -> { | ||||
|                     hideBars() | ||||
| @ -255,7 +260,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|                 } | ||||
|                 View.VISIBLE -> updateControls() | ||||
|             } | ||||
|         } | ||||
|         }) | ||||
|  | ||||
|         playerBinding.videoView.setOnTouchListener { _, event -> | ||||
|             gestureDetector.onTouchEvent(event) | ||||
| @ -284,11 +289,11 @@ class PlayerActivity : AppCompatActivity() { | ||||
|     } | ||||
|  | ||||
|     private fun initTimeUpdates() { | ||||
|         if (this::timerUpdates.isInitialized) { | ||||
|             timerUpdates.cancel() | ||||
|         if (this::controlsUpdates.isInitialized) { | ||||
|             controlsUpdates.cancel() | ||||
|         } | ||||
|  | ||||
|         timerUpdates = Timer().scheduleAtFixedRate(0, 500) { | ||||
|         controlsUpdates = Timer().scheduleAtFixedRate(0, 500) { | ||||
|             lifecycleScope.launch { | ||||
|                 val currentPosition = model.player.currentPosition | ||||
|                 val btnNextEpIsVisible = playerBinding.buttonNextEp.isVisible | ||||
| @ -298,12 +303,14 @@ class PlayerActivity : AppCompatActivity() { | ||||
|                 if (model.player.duration > 0) { | ||||
|                     remainingTime = model.player.duration - currentPosition | ||||
|                     remainingTime = if (remainingTime < 0) 0 else remainingTime | ||||
|                 } else { | ||||
|                     remainingTime = 0 | ||||
|                 } | ||||
|  | ||||
|                 // TODO add metaDB ending_start support | ||||
|                 // if remaining time < 20 sec, a next ep is set, autoplay is enabled and not in pip: | ||||
|                 // show next ep button | ||||
|                 if (remainingTime in 1..20000) { | ||||
|                 // if remaining time > 1 and < 20 sec, a next ep is set, autoplay is enabled | ||||
|                 // and not in pip: show next ep button | ||||
|                 if (remainingTime in 1000..20000) { | ||||
|                     if (!btnNextEpIsVisible && hasNextEpisode() && Preferences.autoplay && !isInPiPMode()) { | ||||
|                         showButtonNextEp() | ||||
|                     } | ||||
| @ -311,19 +318,18 @@ class PlayerActivity : AppCompatActivity() { | ||||
|                     hideButtonNextEp() | ||||
|                 } | ||||
|  | ||||
|                 // if meta data is present and opening_start & opening_duration are valid, show skip opening | ||||
|                 model.currentEpisodeMeta?.let { | ||||
|                     if (it.openingDuration > 0 && | ||||
|                         currentPosition in it.openingStart..(it.openingStart + 10000) && | ||||
|                         !playerBinding.buttonSkipOp.isVisible | ||||
|                     ) { | ||||
|                 // into metadata is present and we can show the skip button | ||||
|                 if (model.currentIntroMetadata.duration >= 10) { | ||||
|                     val startTime = model.currentIntroMetadata.startTime.toInt() * 1000 | ||||
|                     if (currentPosition in startTime..(startTime + 10000) && !playerBinding.buttonSkipOp.isVisible) { | ||||
|                         showButtonSkipOp() | ||||
|                     } else if (playerBinding.buttonSkipOp.isVisible && | ||||
|                         currentPosition !in it.openingStart..(it.openingStart + 10000) | ||||
|                         currentPosition !in startTime..(startTime + 10000) | ||||
|                     ) { | ||||
|                         // the button should only be visible, if currentEpisodeMeta != null | ||||
|                         // the button should only be visible if currentEpisodeMeta != null | ||||
|                         hideButtonSkipOp() | ||||
|                     } | ||||
|  | ||||
|                 } | ||||
|  | ||||
|                 // if controls are visible, update them | ||||
| @ -337,7 +343,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|     private fun onPauseOnStop() { | ||||
|         playerBinding.videoView.onPause() | ||||
|         model.player.pause() | ||||
|         timerUpdates.cancel() | ||||
|         controlsUpdates.cancel() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -424,14 +430,23 @@ class PlayerActivity : AppCompatActivity() { | ||||
|     } | ||||
|  | ||||
|     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() | ||||
|         model.playNextEpisode() | ||||
|  | ||||
|         // enable the next episode buttons when playNextEpisode() has returned | ||||
|         playerBinding.buttonNextEp.isClickable = true | ||||
|         controlsBinding.buttonNextEpC.isClickable = true | ||||
|     } | ||||
|  | ||||
|     private fun skipOpening() { | ||||
|         // calculate the seek time | ||||
|         model.currentEpisodeMeta?.let { | ||||
|             val seekTime = (it.openingStart + it.openingDuration) - model.player.currentPosition | ||||
|         if (model.currentIntroMetadata.duration > 10) { | ||||
|             val endTime = model.currentIntroMetadata.endTime.toInt() * 1000 | ||||
|             val seekTime = endTime - model.player.currentPosition | ||||
|             model.seekToOffset(seekTime) | ||||
|         } | ||||
|     } | ||||
| @ -457,7 +472,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         playerBinding.buttonNextEp.animate() | ||||
|             .alpha(0.0f) | ||||
|             .setListener(object : AnimatorListenerAdapter() { | ||||
|                 override fun onAnimationEnd(animation: Animator?) { | ||||
|                 override fun onAnimationEnd(animation: Animator) { | ||||
|                     super.onAnimationEnd(animation) | ||||
|                     playerBinding.buttonNextEp.isVisible = false | ||||
|                 } | ||||
| @ -477,7 +492,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         playerBinding.buttonSkipOp.animate() | ||||
|             .alpha(0.0f) | ||||
|             .setListener(object : AnimatorListenerAdapter() { | ||||
|                 override fun onAnimationEnd(animation: Animator?) { | ||||
|                 override fun onAnimationEnd(animation: Animator) { | ||||
|                     super.onAnimationEnd(animation) | ||||
|                     playerBinding.buttonSkipOp.isVisible = false | ||||
|                 } | ||||
| @ -509,7 +524,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         /** | ||||
|          * on single tap hide or show the controls | ||||
|          */ | ||||
|         override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { | ||||
|         override fun onSingleTapConfirmed(e: MotionEvent): Boolean { | ||||
|             if (!isInPiPMode()) { | ||||
|                 if (controller.isVisible) controller.hide() else controller.show() | ||||
|             } | ||||
| @ -520,8 +535,8 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         /** | ||||
|          * on double tap rewind or forward | ||||
|          */ | ||||
|         override fun onDoubleTap(e: MotionEvent?): Boolean { | ||||
|             val eventPosX = e?.x?.toInt() ?: 0 | ||||
|         override fun onDoubleTap(e: MotionEvent): Boolean { | ||||
|             val eventPosX = e.x.toInt() | ||||
|             val viewCenterX = playerBinding.videoView.measuredWidth / 2 | ||||
|  | ||||
|             // if the event position is on the left side rewind, if it's on the right forward | ||||
| @ -533,14 +548,14 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         /** | ||||
|          * not used | ||||
|          */ | ||||
|         override fun onDoubleTapEvent(e: MotionEvent?): Boolean { | ||||
|         override fun onDoubleTapEvent(e: MotionEvent): Boolean { | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * on long press toggle pause/play | ||||
|          */ | ||||
|         override fun onLongPress(e: MotionEvent?) { | ||||
|         override fun onLongPress(e: MotionEvent) { | ||||
|             model.togglePausePlay() | ||||
|         } | ||||
|  | ||||
|  | ||||
| @ -32,10 +32,7 @@ import com.google.android.exoplayer2.ExoPlayer | ||||
| import com.google.android.exoplayer2.MediaItem | ||||
| import com.google.android.exoplayer2.Player | ||||
| import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.joinAll | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.runBlocking | ||||
| import kotlinx.coroutines.* | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.parser.crunchyroll.* | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| @ -43,7 +40,9 @@ import org.mosad.teapod.util.metadb.EpisodeMeta | ||||
| 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 kotlin.concurrent.scheduleAtFixedRate | ||||
|  | ||||
| /** | ||||
|  * PlayerViewModel handles all stuff related to media/episodes. | ||||
| @ -55,6 +54,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|  | ||||
|     val player = ExoPlayer.Builder(application).build() | ||||
|     private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION") | ||||
|     private val playheadAutoUpdate: TimerTask | ||||
|  | ||||
|     val currentEpisodeChangedListener = ArrayList<() -> Unit>() | ||||
|     private var currentPlayhead: Long = 0 | ||||
| @ -64,7 +64,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|         internal set | ||||
|     var currentEpisodeMeta: EpisodeMeta? = null | ||||
|         internal set | ||||
|     var currentPlayheads: PlayheadsMap = mutableMapOf() | ||||
|     var currentPlayheads = mapOf<String, PlayheadObject>() | ||||
|         internal set | ||||
|     var currentIntroMetadata: DatalabIntro = NoneDatalabIntro | ||||
|         internal set | ||||
| //    var tmdbTVSeason: TMDBTVSeason? =null | ||||
| //        internal set | ||||
| @ -74,13 +76,21 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|         internal set | ||||
|     var currentEpisode = NoneEpisode | ||||
|         internal set | ||||
|     var currentPlayback = NonePlayback | ||||
|     var currentVersion = NoneVersion | ||||
|         internal set | ||||
|     var currentStreams = NoneStreams | ||||
|         internal set | ||||
|  | ||||
|     // current playback settings | ||||
|     var currentLanguage: Locale = Preferences.preferredLocale | ||||
|     var currentAudioLocale: Locale = Preferences.preferredAudioLocale | ||||
|         internal set | ||||
|     var currentSubtitleLocale: Locale = Preferences.preferredSubtitleLocale | ||||
|         internal set | ||||
|  | ||||
|     init { | ||||
|         // disable platform diagnostics since they might be shared with google | ||||
|         ExoPlayer.Builder(application).setUsePlatformDiagnostics(false) | ||||
|  | ||||
|         initMediaSession() | ||||
|  | ||||
|         player.addListener(object : Player.Listener { | ||||
| @ -96,6 +106,14 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|                 if (!isPlaying) updatePlayhead() | ||||
|             } | ||||
|         }) | ||||
|  | ||||
|         playheadAutoUpdate = Timer().scheduleAtFixedRate(0, 30000) { | ||||
|             viewModelScope.launch { | ||||
|                 if (player.isPlaying){ | ||||
|                     updatePlayhead() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onCleared() { | ||||
| @ -122,27 +140,47 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|         episodes = Crunchyroll.episodes(seasonId) | ||||
|  | ||||
|         listOf( | ||||
|             viewModelScope.launch { mediaMeta = loadMediaMeta(episodes.items.first().seriesId) }, | ||||
|             viewModelScope.launch { mediaMeta = loadMediaMeta(episodes.data.first().seriesId) }, | ||||
|             viewModelScope.launch { | ||||
|                 val episodeIDs = episodes.items.map { it.id } | ||||
|                 currentPlayheads = Crunchyroll.playheads(episodeIDs) | ||||
|                 val episodeIDs = episodes.data.map { it.id } | ||||
|                 currentPlayheads = Crunchyroll.playheads(episodeIDs).toPlayheadsMap() | ||||
|             } | ||||
|         ).joinAll() | ||||
|  | ||||
|  | ||||
|         Log.d(classTag, "meta: $mediaMeta") | ||||
|  | ||||
|         setCurrentEpisode(episodeId) | ||||
|         playCurrentMedia(currentPlayhead) | ||||
|     } | ||||
|  | ||||
|     fun setLanguage(language: Locale) { | ||||
|         currentLanguage = language | ||||
|     fun setLanguage(newAudioLocale: Locale, newSubtitleLocale: Locale) { | ||||
|         // TODO if the audio locale has changes update the streams, if only the subtitle locale has changed load the new stream | ||||
|         if (newAudioLocale != currentAudioLocale) { | ||||
|             currentAudioLocale = newAudioLocale | ||||
|  | ||||
|             currentVersion = currentEpisode.versions.firstOrNull { | ||||
|                 it.audioLocale == currentAudioLocale.toLanguageTag() | ||||
|             } ?: currentEpisode.versions.first() | ||||
|  | ||||
|             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 | ||||
|  | ||||
|     /** | ||||
|      * 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) { | ||||
|         player.seekTo(player.currentPosition + offset) | ||||
|     } | ||||
| @ -156,15 +194,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|      */ | ||||
|     fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId -> | ||||
|         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 | ||||
|      * @param episodeId The ID of the episode you want to set currentEpisodeCr to | ||||
|      */ | ||||
|     fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) { | ||||
|         currentEpisode = episodes.items.find { episode -> | ||||
|     suspend fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) { | ||||
|         currentEpisode = episodes.data.find { episode -> | ||||
|             episode.id == episodeId | ||||
|         } ?: NoneEpisode | ||||
|  | ||||
| @ -182,13 +220,23 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|         currentEpisodeChangedListener.forEach { it() } | ||||
|  | ||||
|         // needs to be blocking, currentPlayback must be present when calling playCurrentMedia() | ||||
|         runBlocking { | ||||
|         joinAll( | ||||
|             viewModelScope.launch(Dispatchers.IO) { | ||||
|                     currentPlayback = Crunchyroll.playback(currentEpisode.playback) | ||||
|                 currentVersion = if (Preferences.preferSubbed) { | ||||
|                     currentEpisode.versions.first { it.original } | ||||
|                 } else { | ||||
|                     currentEpisode.versions | ||||
|                         .firstOrNull { it.audioLocale == currentAudioLocale.toLanguageTag() } | ||||
|                         ?: currentEpisode.versions.first() | ||||
|                 } | ||||
|  | ||||
|                 currentStreams = Crunchyroll.streamsFromMediaGUID(currentVersion.mediaGUID) | ||||
|                 Log.d(classTag, currentVersion.toString()) | ||||
|             }, | ||||
|             viewModelScope.launch(Dispatchers.IO) { | ||||
|                     Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let { | ||||
|                 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 | ||||
| @ -196,10 +244,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|                         (it.playhead.times(1000)).toLong() | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             viewModelScope.launch(Dispatchers.IO) { | ||||
|                 currentIntroMetadata = NoneDatalabIntro //Crunchyroll.datalabIntro(currentEpisode.id) | ||||
|             } | ||||
|         ) | ||||
|         } | ||||
|         Log.d(classTag, "playback: ${currentEpisode.playback}") | ||||
|         Log.d(classTag, "streams: ${currentEpisode.streamsLink}") | ||||
|  | ||||
|         if (startPlayback) { | ||||
|             playCurrentMedia() | ||||
| @ -207,26 +257,26 @@ 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) { | ||||
|         // 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 url = when { | ||||
|             currentPlayback.streams.adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> { | ||||
|                 currentPlayback.streams.adaptive_hls[preferredLocale.toLanguageTag()]?.url | ||||
|             currentStreams.data[0].adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> { | ||||
|                 currentStreams.data[0].adaptive_hls[preferredLocale.toLanguageTag()]?.url | ||||
|             } | ||||
|             currentPlayback.streams.adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> { | ||||
|                 currentLanguage = fallbackLocal | ||||
|                 currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url | ||||
|             currentStreams.data[0].adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> { | ||||
|                 currentSubtitleLocale = fallbackLocal | ||||
|                 currentStreams.data[0].adaptive_hls[fallbackLocal.toLanguageTag()]?.url | ||||
|             } | ||||
|             else -> { | ||||
|                 // if no language tag is present use the first entry | ||||
|                 currentLanguage = Locale.ROOT | ||||
|                 currentPlayback.streams.adaptive_hls.entries.first().value.url | ||||
|                 currentSubtitleLocale = Locale.ROOT | ||||
|                 currentStreams.data[0].adaptive_hls.entries.first().value.url | ||||
|             } | ||||
|         } | ||||
|         Log.i(classTag, "stream url: $url") | ||||
| @ -262,7 +312,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|      * @return Boolean: true if it is the last, else false. | ||||
|      */ | ||||
|     fun currentEpisodeIsLastEpisode(): Boolean { | ||||
|         return episodes.items.lastOrNull()?.id == currentEpisode.id | ||||
|         return episodes.data.lastOrNull()?.id == currentEpisode.id | ||||
|     } | ||||
|  | ||||
|     private suspend fun loadMediaMeta(crSeriesId: String): Meta? { | ||||
| @ -276,13 +326,14 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|         val playhead = (player.currentPosition / 1000) | ||||
|  | ||||
|         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.") | ||||
|         } | ||||
|  | ||||
|         viewModelScope.launch { | ||||
|             val episodeIDs = episodes.items.map { it.id } | ||||
|             currentPlayheads = Crunchyroll.playheads(episodeIDs) | ||||
|             val episodeIDs = episodes.data.map { it.id } | ||||
|             currentPlayheads = Crunchyroll.playheads(episodeIDs).toPlayheadsMap() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -7,6 +7,7 @@ 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 | ||||
| @ -41,18 +42,21 @@ class EpisodeListDialogFragment : DialogFragment()  { | ||||
|         } | ||||
|  | ||||
|         val adapterRecEpisodes = EpisodeItemAdapter( | ||||
|             model.episodes.items, | ||||
|             model.episodes.data, | ||||
|             null, | ||||
|             model.currentPlayheads.toMap(), | ||||
|             model.currentPlayheads, | ||||
|             EpisodeItemAdapter.OnClickListener { episode -> | ||||
|                 dismiss() | ||||
|                 // TODO make this none blocking, if necessary? | ||||
|                 runBlocking { | ||||
|                     model.setCurrentEpisode(episode.id, startPlayback = true) | ||||
|                 } | ||||
|             }, | ||||
|             EpisodeItemAdapter.ViewType.PLAYER | ||||
|         ) | ||||
|  | ||||
|         // episodeNumber starts at 1, we need the episode index -> - 1 | ||||
|         adapterRecEpisodes.currentSelected = model.currentEpisode.episodeNumber?.minus(1) ?: 0 | ||||
|         // 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) | ||||
|  | ||||
| @ -9,6 +9,7 @@ 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 | ||||
| @ -24,7 +25,8 @@ class LanguageSettingsDialogFragment : DialogFragment() { | ||||
|     private lateinit var model: PlayerViewModel | ||||
|     private lateinit var binding: PlayerLanguageSettingsBinding | ||||
|  | ||||
|     private var selectedLocale = Locale.ROOT | ||||
|     private var selectedSubtitleLocale = Locale.ROOT | ||||
|     private var selectedAudioLocale = Locale.ROOT | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "LanguageSettingsDialogFragment" | ||||
| @ -34,7 +36,7 @@ class LanguageSettingsDialogFragment : DialogFragment() { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle) | ||||
|         model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java] | ||||
|         selectedLocale = model.currentLanguage | ||||
|         selectedSubtitleLocale = model.currentSubtitleLocale | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
| @ -45,23 +47,55 @@ class LanguageSettingsDialogFragment : DialogFragment() { | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         model.currentPlayback.streams.adaptive_hls.keys.forEach { languageTag -> | ||||
|         var selectedSubtitleView: TextView? = null | ||||
|         model.currentStreams.data[0].adaptive_hls.keys.forEach { languageTag -> | ||||
|             val locale = Locale.forLanguageTag(languageTag) | ||||
|             addLanguage(locale, locale == model.currentLanguage) { v -> | ||||
|                 selectedLocale = locale | ||||
|                 updateSelectedLanguage(v as TextView) | ||||
|             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(selectedLocale) | ||||
|             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) { | ||||
| @ -69,33 +103,32 @@ class LanguageSettingsDialogFragment : DialogFragment() { | ||||
|         model.player.play() | ||||
|     } | ||||
|  | ||||
|     private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: View.OnClickListener) { | ||||
|     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) | ||||
|  | ||||
|             if (isSelected) { | ||||
|                 setTextColor(context.resources.getColor(R.color.textPrimaryDark, 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.textSecondaryDark, context.theme)) | ||||
|             setPadding(75, 0, 0, 0) | ||||
|             } | ||||
|  | ||||
|             setOnClickListener(onClick) | ||||
|         } | ||||
|  | ||||
|         binding.linearLanguages.addView(text) | ||||
|         linear.addView(text) | ||||
|  | ||||
|         return text | ||||
|     } | ||||
|  | ||||
|     private fun updateSelectedLanguage(selected: TextView) { | ||||
|     /** | ||||
|      * 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 | ||||
|         binding.linearLanguages.children.forEach { child -> | ||||
|         languageLayout.children.forEach { child -> | ||||
|             if (child is TextView) { | ||||
|                 child.apply { | ||||
|                     setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme)) | ||||
|  | ||||
| @ -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 | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -28,7 +28,7 @@ class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(con | ||||
|             repeatCount = 1 | ||||
|             repeatMode = ObjectAnimator.REVERSE | ||||
|             addListener(object : AnimatorListenerAdapter() { | ||||
|                 override fun onAnimationStart(animation: Animator?) { | ||||
|                 override fun onAnimationStart(animation: Animator) { | ||||
|                     binding.imageButton.isEnabled = false // disable button | ||||
|                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_forward_24) | ||||
|                 } | ||||
| @ -39,7 +39,7 @@ class FastForwardButton(context: Context, attrs: AttributeSet?): FrameLayout(con | ||||
|             duration = animationDuration | ||||
|             addListener(object : AnimatorListenerAdapter() { | ||||
|                 // 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.setBackgroundResource(R.drawable.ic_baseline_forward_10_24) | ||||
|  | ||||
|  | ||||
| @ -28,7 +28,7 @@ class RewindButton(context: Context, attrs: AttributeSet): FrameLayout(context, | ||||
|             repeatCount = 1 | ||||
|             repeatMode = ObjectAnimator.REVERSE | ||||
|             addListener(object : AnimatorListenerAdapter() { | ||||
|                 override fun onAnimationStart(animation: Animator?) { | ||||
|                 override fun onAnimationStart(animation: Animator) { | ||||
|                     binding.imageButton.isEnabled = false // disable button | ||||
|                     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 { | ||||
|             duration = animationDuration | ||||
|             addListener(object : AnimatorListenerAdapter() { | ||||
|                 override fun onAnimationEnd(animation: Animator?) { | ||||
|                 override fun onAnimationEnd(animation: Animator) { | ||||
|                     binding.imageButton.isEnabled = true // enable button | ||||
|                     binding.imageButton.setBackgroundResource(R.drawable.ic_baseline_rewind_10_24) | ||||
|  | ||||
|  | ||||
| @ -1,16 +1,32 @@ | ||||
| package org.mosad.teapod.util | ||||
|  | ||||
| import android.content.Intent | ||||
| import android.view.View | ||||
| import android.view.Window | ||||
| import android.widget.TextView | ||||
| import androidx.core.view.WindowCompat | ||||
| 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.Collection | ||||
| import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem | ||||
| import org.mosad.teapod.parser.crunchyroll.Collection2 | ||||
| import org.mosad.teapod.parser.crunchyroll.Item | ||||
| import org.mosad.teapod.parser.crunchyroll.PlayheadObject | ||||
| import org.mosad.teapod.ui.activity.player.PlayerActivity | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * 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) { | ||||
|     this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0) | ||||
| } | ||||
| @ -33,19 +49,6 @@ fun List<Item>.toItemMediaList(): List<ItemMedia> { | ||||
|     } | ||||
| } | ||||
|  | ||||
| @JvmName("toItemMediaListContinueWatchingItem") | ||||
| fun Collection<ContinueWatchingItem>.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 { | ||||
|         ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source) | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Locale.toDisplayString(fallback: String): String { | ||||
|     return if (this.displayLanguage.isNotEmpty() && this.displayCountry.isNotEmpty()) { | ||||
|         "${this.displayLanguage} (${this.displayCountry})" | ||||
| @ -56,6 +59,10 @@ fun Locale.toDisplayString(fallback: String): String { | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Collection2<PlayheadObject>.toPlayheadsMap(): Map<String, PlayheadObject> { | ||||
|     return this.data.associateBy { it.contentId } | ||||
| } | ||||
|  | ||||
| fun hideBars(window: Window?, root: View) { | ||||
|     if (window != null) { | ||||
|         WindowCompat.setDecorFitsSystemWindows(window, false) | ||||
|  | ||||
| @ -16,13 +16,12 @@ 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.PlayheadObject | ||||
| import org.mosad.teapod.parser.crunchyroll.PlayheadsMap | ||||
| import org.mosad.teapod.util.tmdb.TMDBTVEpisode | ||||
|  | ||||
| class EpisodeItemAdapter( | ||||
|     private val episodes: List<Episode>, | ||||
|     private val tmdbEpisodes: List<TMDBTVEpisode>?, | ||||
|     private val playheads: PlayheadsMap, | ||||
|     private val playheads: Map<String, PlayheadObject>, | ||||
|     private val onClickListener: OnClickListener, | ||||
|     private val viewType: ViewType | ||||
| ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { | ||||
|  | ||||
| @ -9,18 +9,21 @@ import androidx.recyclerview.widget.RecyclerView | ||||
| import com.bumptech.glide.Glide | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.ItemMediaBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem | ||||
| import org.mosad.teapod.parser.crunchyroll.UpNextAccountItem | ||||
|  | ||||
| class MediaEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter<ContinueWatchingItem, MediaEpisodeListAdapter.MediaViewHolder>(DiffCallback) { | ||||
| class MediaEpisodeListAdapter(private val onClickListener: OnClickListener, private val itemOffset: Int = 0) : ListAdapter<UpNextAccountItem, MediaEpisodeListAdapter.MediaViewHolder>(DiffCallback) { | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { | ||||
|         return MediaViewHolder( | ||||
|             ItemMediaBinding.inflate( | ||||
|         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) { | ||||
| @ -34,7 +37,7 @@ class MediaEpisodeListAdapter(private val onClickListener: OnClickListener) : Li | ||||
|     inner class MediaViewHolder(val binding: ItemMediaBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|  | ||||
|         fun bind(item: ContinueWatchingItem) { | ||||
|         fun bind(item: UpNextAccountItem) { | ||||
|             val metadata = item.panel.episodeMetadata | ||||
|  | ||||
|             binding.textTitle.text = binding.root.context.getString(R.string.season_episode_title, | ||||
| @ -54,17 +57,17 @@ class MediaEpisodeListAdapter(private val onClickListener: OnClickListener) : Li | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object DiffCallback : DiffUtil.ItemCallback<ContinueWatchingItem>() { | ||||
|         override fun areItemsTheSame(oldItem: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean { | ||||
|     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: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean { | ||||
|         override fun areContentsTheSame(oldItem: UpNextAccountItem, newItem: UpNextAccountItem): Boolean { | ||||
|             return oldItem == newItem | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class OnClickListener(val clickListener: (item: ContinueWatchingItem) -> Unit) { | ||||
|         fun onClick(item: ContinueWatchingItem) = clickListener(item) | ||||
|     class OnClickListener(val clickListener: (item: UpNextAccountItem) -> Unit) { | ||||
|         fun onClick(item: UpNextAccountItem) = clickListener(item) | ||||
|     } | ||||
| } | ||||
| @ -1,44 +0,0 @@ | ||||
| package org.mosad.teapod.util.adapter | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.ViewGroup | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.bumptech.glide.Glide | ||||
| import org.mosad.teapod.databinding.ItemMediaBinding | ||||
| import org.mosad.teapod.util.ItemMedia | ||||
|  | ||||
| @Deprecated("Use MediaItemListAdapter instead") | ||||
| 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.imageEpisodePlay.isVisible = false // hide the play button for media items | ||||
|             binding.root.setOnClickListener { | ||||
|                 onItemClick?.invoke( | ||||
|                     items[bindingAdapterPosition].id, | ||||
|                     bindingAdapterPosition | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -7,19 +7,23 @@ 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) : ListAdapter<ItemMedia, MediaItemListAdapter.MediaViewHolder>(DiffCallback) { | ||||
| class MediaItemListAdapter(private val onClickListener: OnClickListener, private val itemOffset: Int = 0) : ListAdapter<ItemMedia, MediaItemListAdapter.MediaViewHolder>(DiffCallback) { | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { | ||||
|         return MediaViewHolder( | ||||
|             ItemMediaBinding.inflate( | ||||
|         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) { | ||||
| @ -36,7 +40,7 @@ class MediaItemListAdapter(private val onClickListener: OnClickListener) : ListA | ||||
|         fun bind(item: ItemMedia) { | ||||
|             binding.textTitle.text = item.title | ||||
|  | ||||
|             Glide.with(binding.imagePoster) | ||||
|             Glide.with(binding.root.context) | ||||
|                 .load(item.posterUrl) | ||||
|                 .into(binding.imagePoster) | ||||
|  | ||||
|  | ||||
| @ -24,11 +24,12 @@ package org.mosad.teapod.util.metadb | ||||
|  | ||||
| import android.util.Log | ||||
| import io.ktor.client.* | ||||
| import io.ktor.client.features.* | ||||
| import io.ktor.client.features.json.* | ||||
| import io.ktor.client.features.json.serializer.* | ||||
| import io.ktor.client.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 | ||||
| @ -40,8 +41,8 @@ object MetaDBController { | ||||
|     private const val repoUrl = "https://gitlab.com/Seil0/teapodmetadb/-/raw/main/crunchy/" | ||||
|  | ||||
|     private val client = HttpClient { | ||||
|         install(JsonFeature) { | ||||
|             serializer = KotlinxSerializer(Json) | ||||
|         install(ContentNegotiation) { | ||||
|             json() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -49,7 +50,7 @@ object MetaDBController { | ||||
|     private var metaCacheList = arrayListOf<Meta>() | ||||
|  | ||||
|     suspend fun list() = withContext(Dispatchers.IO) { | ||||
|         val raw: String = client.get("$repoUrl/list.json") | ||||
|         val raw: String = client.get("$repoUrl/list.json").body() | ||||
|         mediaList = Json.decodeFromString(raw) | ||||
|     } | ||||
|  | ||||
| @ -70,7 +71,7 @@ object MetaDBController { | ||||
|  | ||||
|     private suspend fun getTVShowMetadataFromDB(crSeriesId: String): TVShowMeta? = withContext(Dispatchers.IO) { | ||||
|         return@withContext try { | ||||
|             val raw: String = client.get("$repoUrl/tv/$crSeriesId/media.json") | ||||
|             val raw: String = client.get("$repoUrl/tv/$crSeriesId/media.json").body() | ||||
|             val meta: TVShowMeta = Json.decodeFromString(raw) | ||||
|             metaCacheList.add(meta) | ||||
|  | ||||
|  | ||||
| @ -25,10 +25,10 @@ package org.mosad.teapod.util.tmdb | ||||
| import android.util.Log | ||||
| import io.ktor.client.* | ||||
| import io.ktor.client.call.* | ||||
| import io.ktor.client.features.json.* | ||||
| import io.ktor.client.features.json.serializer.* | ||||
| import io.ktor.client.plugins.contentnegotiation.* | ||||
| import io.ktor.client.request.* | ||||
| import io.ktor.client.statement.* | ||||
| import io.ktor.serialization.kotlinx.json.* | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.coroutineScope | ||||
| import kotlinx.coroutines.invoke | ||||
| @ -46,10 +46,11 @@ import org.mosad.teapod.util.concatenate | ||||
| class TMDBApiController { | ||||
|     private val classTag = javaClass.name | ||||
|  | ||||
|     private val json = Json { ignoreUnknownKeys = true } | ||||
|     private val client = HttpClient { | ||||
|         install(JsonFeature) { | ||||
|             serializer = KotlinxSerializer(json) | ||||
|         install(ContentNegotiation) { | ||||
|             json(Json { | ||||
|                 ignoreUnknownKeys = true | ||||
|             }) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -66,7 +67,7 @@ class TMDBApiController { | ||||
|     ): T = coroutineScope { | ||||
|         val path = "$apiUrl$endpoint" | ||||
|         val params = concatenate( | ||||
|             listOf("api_key" to apiKey, "language" to Preferences.preferredLocale.language), | ||||
|             listOf("api_key" to apiKey, "language" to Preferences.preferredSubtitleLocale.language), | ||||
|             parameters | ||||
|         ) | ||||
|  | ||||
| @ -78,7 +79,7 @@ class TMDBApiController { | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             response.receive<T>() | ||||
|             response.body<T>() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -89,7 +90,7 @@ class TMDBApiController { | ||||
|      * NoneTMDBSearchMovie if nothing was found | ||||
|      */ | ||||
|     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) | ||||
|  | ||||
|         return try { | ||||
|  | ||||
| @ -32,7 +32,7 @@ import kotlinx.serialization.Serializable | ||||
|  | ||||
| interface TMDBResult { | ||||
|     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 posterPath: String? | ||||
|     val backdropPath: String? | ||||
| @ -40,7 +40,7 @@ interface TMDBResult { | ||||
|  | ||||
| data class TMDBBase( | ||||
|     override val id: Int, | ||||
|     override val name: String, | ||||
|     override val name: String?, | ||||
|     override val overview: String?, | ||||
|     override val posterPath: String?, | ||||
|     override val backdropPath: String? | ||||
| @ -59,7 +59,7 @@ data class TMDBSearch<T>( | ||||
| @Serializable | ||||
| data class TMDBSearchResultMovie( | ||||
|     @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("poster_path") override val posterPath: String?, | ||||
|     @SerialName("backdrop_path") override val backdropPath: String?, | ||||
| @ -68,7 +68,7 @@ data class TMDBSearchResultMovie( | ||||
| @Serializable | ||||
| data class TMDBSearchResultTVShow( | ||||
|     @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("poster_path") override val posterPath: String?, | ||||
|     @SerialName("backdrop_path") override val backdropPath: String?, | ||||
| @ -92,7 +92,7 @@ data class TMDBMovie( | ||||
|     @SerialName("release_date") val releaseDate: String, | ||||
|     @SerialName("runtime") val runtime: Int?, | ||||
|     @SerialName("status") val status: String, | ||||
|     // TODO generes | ||||
|     // TODO genres | ||||
| ) : TMDBResult | ||||
|  | ||||
| @Serializable | ||||
| @ -102,10 +102,10 @@ data class TMDBTVShow( | ||||
|     @SerialName("overview")override val overview: String, | ||||
|     @SerialName("poster_path") override val posterPath: String?, | ||||
|     @SerialName("backdrop_path") override val backdropPath: String?, | ||||
|     @SerialName("first_air_date") val firstAirDate: String, | ||||
|     @SerialName("last_air_date") val lastAirDate: String, | ||||
|     @SerialName("status") val status: String, | ||||
|     // TODO generes | ||||
|     @SerialName("first_air_date") val firstAirDate: String?, | ||||
|     @SerialName("last_air_date") val lastAirDate: String?, | ||||
|     @SerialName("status") val status: String?, | ||||
|     // TODO genres | ||||
| ) : TMDBResult | ||||
|  | ||||
| // use null for nullable types, the gui needs to handle/implement a fallback for null values | ||||
|  | ||||
| @ -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> | ||||
							
								
								
									
										7
									
								
								app/src/main/res/drawable/placeholder_image.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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="?shapeTextBackground"/> | ||||
|     <size | ||||
|         android:width="1920px" | ||||
|         android:height="1080px"/> | ||||
| </shape> | ||||
| @ -19,6 +19,6 @@ | ||||
|         android:layout_centerInParent="true" | ||||
|         android:layout_marginStart="42dp" | ||||
|         android:text="@string/fwd_10_s" | ||||
|         android:textColor="@color/exo_white" | ||||
|         android:textColor="@color/player_white" | ||||
|         android:visibility="gone" /> | ||||
| </RelativeLayout> | ||||
| @ -20,7 +20,7 @@ | ||||
|         android:layout_centerInParent="true" | ||||
|         android:layout_marginEnd="42dp" | ||||
|         android:text="@string/rwd_10_s" | ||||
|         android:textColor="@color/exo_white" | ||||
|         android:textColor="@color/player_white" | ||||
|         android:visibility="gone" /> | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -112,7 +112,7 @@ | ||||
|                             android:layout_width="match_parent" | ||||
|                             android:layout_height="wrap_content" | ||||
|                             android:layout_weight="1" | ||||
|                             android:text="@string/account_subscription" | ||||
|                             android:text="@string/loading" | ||||
|                             android:textSize="16sp" /> | ||||
|  | ||||
|                         <TextView | ||||
| @ -120,7 +120,7 @@ | ||||
|                             android:layout_width="match_parent" | ||||
|                             android:layout_height="wrap_content" | ||||
|                             android:layout_weight="1" | ||||
|                             android:text="@string/account_subscription_desc" | ||||
|                             android:text="@string/account_tier" | ||||
|                             android:textColor="?textSecondary" /> | ||||
|                     </LinearLayout> | ||||
|                 </LinearLayout> | ||||
|  | ||||
| @ -15,8 +15,19 @@ | ||||
|         <LinearLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginBottom="7dp" | ||||
|             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 | ||||
|                 android:id="@+id/linear_highlight" | ||||
|                 android:layout_width="match_parent" | ||||
| @ -110,9 +121,8 @@ | ||||
|             <LinearLayout | ||||
|                 android:id="@+id/linear_up_next" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:orientation="vertical" | ||||
|                 android:paddingBottom="7dp"> | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:orientation="vertical"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/text_up_next" | ||||
| @ -126,10 +136,30 @@ | ||||
|                     android:textSize="16sp" | ||||
|                     android:textStyle="bold" /> | ||||
|  | ||||
|                 <com.facebook.shimmer.ShimmerFrameLayout | ||||
|                     android:id="@+id/shimmer_layout_up_next" | ||||
|                     android:layout_width="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="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:orientation="horizontal" | ||||
|                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" | ||||
|                     tools:listitem="@layout/item_media" /> | ||||
| @ -139,8 +169,7 @@ | ||||
|                 android:id="@+id/linear_watchlist" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:orientation="vertical" | ||||
|                 android:paddingBottom="7dp"> | ||||
|                 android:orientation="vertical"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/text_watchlist" | ||||
| @ -154,10 +183,30 @@ | ||||
|                     android:textSize="16sp" | ||||
|                     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 | ||||
|                     android:id="@+id/recycler_watchlist" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:orientation="horizontal" | ||||
|                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" | ||||
|                     tools:listitem="@layout/item_media" /> | ||||
| @ -182,6 +231,26 @@ | ||||
|                     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" | ||||
| @ -195,8 +264,7 @@ | ||||
|                 android:id="@+id/linear_new_titles" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:orientation="vertical" | ||||
|                 android:paddingBottom="7dp"> | ||||
|                 android:orientation="vertical"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/text_new_titles" | ||||
| @ -210,6 +278,26 @@ | ||||
|                     android:textSize="16sp" | ||||
|                     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 | ||||
|                     android:id="@+id/recycler_new_titles" | ||||
|                     android:layout_width="match_parent" | ||||
| @ -223,8 +311,7 @@ | ||||
|                 android:id="@+id/linear_top_ten" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:orientation="vertical" | ||||
|                 android:paddingBottom="7dp"> | ||||
|                 android:orientation="vertical"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/text_top_ten" | ||||
| @ -238,6 +325,26 @@ | ||||
|                     android:textSize="16sp" | ||||
|                     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 | ||||
|                     android:id="@+id/recycler_top_ten" | ||||
|                     android:layout_width="match_parent" | ||||
|  | ||||
| @ -7,19 +7,34 @@ | ||||
|     android:background="?themePrimary" | ||||
|     tools:context=".ui.activity.main.fragments.LibraryFragment"> | ||||
|  | ||||
|     <androidx.recyclerview.widget.RecyclerView | ||||
|         android:id="@+id/recycler_media_library" | ||||
|     <org.mosad.teapod.ui.components.EmptySubmitSearchView | ||||
|         android:id="@+id/search_text" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="match_parent" | ||||
|         android:layout_height="0dp" | ||||
|         android:background="?themeSecondary" | ||||
|         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:padding="3dp" | ||||
|         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_toTopOf="parent" | ||||
|         app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" | ||||
|         app:spanCount="2" | ||||
|         tools:listitem="@layout/item_media" /> | ||||
|         app:layout_constraintTop_toBottomOf="@+id/search_text" | ||||
|         app:spanCount="@integer/item_media_columns" | ||||
|         tools:listitem="@layout/item_media"> | ||||
|  | ||||
|     </androidx.recyclerview.widget.RecyclerView> | ||||
|  | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| @ -24,29 +24,42 @@ | ||||
|                 android:orientation="vertical" | ||||
|                 app:layout_scrollFlags="scroll"> | ||||
|  | ||||
|                 <RelativeLayout | ||||
|                 <androidx.constraintlayout.widget.ConstraintLayout | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content"> | ||||
|  | ||||
|                     <FrameLayout | ||||
|                         android:id="@+id/frame_image_progress" | ||||
|                         android:layout_width="0dp" | ||||
|                         android:layout_height="0dp" | ||||
|                         app:layout_constraintBottom_toBottomOf="parent" | ||||
|                         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_backdrop" | ||||
|                             android:layout_width="match_parent" | ||||
|                         android:layout_height="wrap_content" | ||||
|                         android:adjustViewBounds="false" | ||||
|                             android:layout_height="match_parent" | ||||
|                             android:contentDescription="@string/media_poster_backdrop_desc" | ||||
|                         android:maxHeight="231dp" | ||||
|                         android:minHeight="220dp" | ||||
|                         android:scaleType="centerCrop" /> | ||||
|                             android:scaleType="fitCenter" | ||||
|                             tools:srcCompat="@drawable/placeholder_image" /> | ||||
|  | ||||
|                         <com.google.android.material.imageview.ShapeableImageView | ||||
|                             android:id="@+id/image_poster" | ||||
|                             android:layout_width="wrap_content" | ||||
|                         android:layout_height="200dp" | ||||
|                         android:layout_centerInParent="true" | ||||
|                             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/ic_launcher_background" /> | ||||
|  | ||||
|                 </RelativeLayout> | ||||
|                     </FrameLayout> | ||||
|  | ||||
|                 </androidx.constraintlayout.widget.ConstraintLayout> | ||||
|  | ||||
|                 <LinearLayout | ||||
|                     android:id="@+id/linear_media_info" | ||||
|  | ||||
| @ -16,7 +16,7 @@ | ||||
|         android:paddingEnd="3dp" | ||||
|         android:paddingBottom="3dp" | ||||
|         app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" | ||||
|         app:spanCount="2" | ||||
|         app:spanCount="@integer/item_media_columns" | ||||
|         tools:listitem="@layout/item_media" /> | ||||
|  | ||||
| </FrameLayout> | ||||
							
								
								
									
										46
									
								
								app/src/main/res/layout/fragment_my_lists.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								app/src/main/res/layout/fragment_my_lists.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| <?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.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" | ||||
|         app:tabSelectedTextColor="?textPrimary" | ||||
|         app:tabTextColor="?textSecondary"> | ||||
|  | ||||
|         <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> | ||||
| @ -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> | ||||
							
								
								
									
										96
									
								
								app/src/main/res/layout/item_highlight_shimmer.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								app/src/main/res/layout/item_highlight_shimmer.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,96 @@ | ||||
| <?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" | ||||
|     android:background="?themePrimary"> | ||||
|  | ||||
|     <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:background="?themePrimary" | ||||
|         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:drawableTint="?shapeTextBackground" | ||||
|                 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" | ||||
|                 app:backgroundTint="?shapeTextBackground" /> | ||||
|  | ||||
|             <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:drawableTint="?shapeTextBackground" | ||||
|                 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,17 +1,25 @@ | ||||
| <?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:tools="http://schemas.android.com/tools" | ||||
|     android:layout_width="195dp" | ||||
|     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" | ||||
|         android:backgroundTint="?themeSecondary" | ||||
|     android:visibility="visible" | ||||
|         app:cardCornerRadius="7dp" | ||||
|     app:cardElevation="4dp"> | ||||
|         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="match_parent"> | ||||
|             android:layout_height="wrap_content"> | ||||
|  | ||||
|             <FrameLayout | ||||
|                 android:id="@+id/frame_image_progress" | ||||
| @ -25,11 +33,11 @@ | ||||
|  | ||||
|                 <ImageView | ||||
|                     android:id="@+id/image_poster" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                     android:layout_width="wrap_content" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:contentDescription="@string/media_poster_desc" | ||||
|                 android:scaleType="centerCrop" | ||||
|                 tools:srcCompat="@color/imagePlaceholder" /> | ||||
|                     android:scaleType="fitCenter" | ||||
|                     tools:srcCompat="@drawable/placeholder_image" /> | ||||
|  | ||||
|                 <ImageView | ||||
|                     android:id="@+id/image_episode_play" | ||||
| @ -53,7 +61,7 @@ | ||||
|  | ||||
|             <TextView | ||||
|                 android:id="@+id/text_title" | ||||
|             android:layout_width="match_parent" | ||||
|                 android:layout_width="0dp" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:gravity="center" | ||||
|                 android:lines="2" | ||||
| @ -62,7 +70,11 @@ | ||||
|                 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> | ||||
|  | ||||
							
								
								
									
										58
									
								
								app/src/main/res/layout/item_media_shimmer.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								app/src/main/res/layout/item_media_shimmer.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | ||||
| <?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" | ||||
|         android:backgroundTint="?themeSecondary" | ||||
|         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" | ||||
|                     android:background="?shapeTextBackground" | ||||
|                     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> | ||||
| @ -131,7 +131,7 @@ | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginEnd="7dp" | ||||
|             android:text="@string/subtitles" | ||||
|             android:text="@string/language" | ||||
|             android:textAllCaps="false" | ||||
|             app:icon="@drawable/ic_baseline_subtitles_24" | ||||
|             app:layout_constraintBottom_toBottomOf="parent" | ||||
|  | ||||
| @ -36,7 +36,6 @@ | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_marginEnd="44dp" | ||||
|             android:text="@string/subtitles" | ||||
|             android:textAlignment="center" | ||||
|             android:textColor="@color/player_white" | ||||
|             android:textSize="18sp" | ||||
| @ -45,16 +44,79 @@ | ||||
|     </LinearLayout> | ||||
|  | ||||
|     <LinearLayout | ||||
|         android:id="@+id/linear_languages" | ||||
|         android:layout_width="0dp" | ||||
|         android:layout_height="0dp" | ||||
|         android:layout_marginStart="56dp" | ||||
|         android:layout_marginEnd="56dp" | ||||
|         android:orientation="vertical" | ||||
|         android:orientation="horizontal" | ||||
|         app:layout_constraintBottom_toTopOf="@+id/linear_bottom" | ||||
|         app:layout_constraintEnd_toEndOf="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 | ||||
|         android:id="@+id/linear_bottom" | ||||
|  | ||||
| @ -6,15 +6,15 @@ | ||||
|         android:icon="@drawable/ic_home_black_24dp" | ||||
|         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 | ||||
|         android:id="@+id/navigation_library" | ||||
|         android:icon="@drawable/ic_baseline_video_library_24" | ||||
|         android:title="@string/title_library" /> | ||||
|  | ||||
|     <item | ||||
|         android:id="@+id/navigation_search" | ||||
|         android:icon="@drawable/ic_baseline_search_24" | ||||
|         android:title="@string/title_search" /> | ||||
|     <item | ||||
|         android:id="@+id/navigation_account" | ||||
|         android:icon="@drawable/ic_baseline_account_box_24" | ||||
|  | ||||
| @ -11,18 +11,18 @@ | ||||
|         android:label="@string/title_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 | ||||
|         android:id="@+id/navigation_library" | ||||
|         android:name="org.mosad.teapod.ui.activity.main.fragments.LibraryFragment" | ||||
|         android:label="@string/title_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 | ||||
|         android:id="@+id/navigation_account" | ||||
|         android:name="org.mosad.teapod.ui.activity.main.fragments.AccountFragment" | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <string name="title_home">Startseite</string> | ||||
|     <string name="title_my_lists">Meine Listen</string> | ||||
|     <string name="title_library">Übersicht</string> | ||||
|     <string name="title_search">Suche</string> | ||||
|     <string name="title_account">Account</string> | ||||
|  | ||||
|     <!-- home fragment --> | ||||
| @ -18,6 +18,9 @@ | ||||
|     <!-- search fragment --> | ||||
|     <string name="search_hint">Suche nach Filmen und Serien</string> | ||||
|  | ||||
|     <!-- my lists fragment --> | ||||
|     <string name="downloads">Downloads</string> | ||||
|  | ||||
|     <!-- media fragment --> | ||||
|     <string name="button_play">Abspielen</string> | ||||
|     <plurals name="text_episodes_count"> | ||||
| @ -37,6 +40,8 @@ | ||||
|     <string name="account_login_desc">Zum bearbeiten tippen</string> | ||||
|     <string name="account_subscription">Abo %1$s</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_about_desc">Version %1$s (%2$s)</string> | ||||
|     <string name="settings">Einstellungen</string> | ||||
| @ -81,6 +86,7 @@ | ||||
|     <string name="next_episode">Nächste Folge</string> | ||||
|     <string name="skip_opening">Intro überspringen</string> | ||||
|     <string name="language">Sprache</string> | ||||
|     <string name="audio">Audio</string> | ||||
|     <string name="subtitles">Untertitel</string> | ||||
|     <string name="episodes">Folgen</string> | ||||
|     <string name="episode">Folge</string> | ||||
|  | ||||
							
								
								
									
										4
									
								
								app/src/main/res/values-sw600dp/dimens.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/src/main/res/values-sw600dp/dimens.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <item name="item_media_columns" type="integer">3</item> | ||||
| </resources> | ||||
							
								
								
									
										4
									
								
								app/src/main/res/values-sw720dp/dimens.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/src/main/res/values-sw720dp/dimens.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <item name="item_media_columns" type="integer">4</item> | ||||
| </resources> | ||||
							
								
								
									
										4
									
								
								app/src/main/res/values-sw840dp/dimens.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/src/main/res/values-sw840dp/dimens.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <item name="item_media_columns" type="integer">5</item> | ||||
| </resources> | ||||
| @ -2,4 +2,5 @@ | ||||
| <resources> | ||||
|     <dimen name="player_styled_progress_layout_height">28dp</dimen> | ||||
|     <dimen name="player_styled_progress_margin_bottom">52dp</dimen> | ||||
|     <item name="item_media_columns" type="integer">2</item> | ||||
| </resources> | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <resources> | ||||
|     <string name="app_name" translatable="false">Teapod</string> | ||||
|     <string name="title_home">Home</string> | ||||
|     <string name="title_my_lists">My Lists</string> | ||||
|     <string name="title_library">Library</string> | ||||
|     <string name="title_search">Search</string> | ||||
|     <string name="title_account">Account</string> | ||||
|  | ||||
|     <!-- home fragment --> | ||||
| @ -21,6 +21,10 @@ | ||||
|     <string name="media_poster_desc" translatable="false">poster</string> | ||||
|     <string name="media_poster_backdrop_desc" translatable="false">poster backdrop</string> | ||||
|  | ||||
|     <!-- my lists fragment --> | ||||
|     <string name="crunchylists" translatable="false">Crunchylists</string> | ||||
|     <string name="downloads">Downloads</string> | ||||
|  | ||||
|     <!-- media fragment --> | ||||
|     <string name="button_play">Play</string> | ||||
|     <string name="text_title_ex" translatable="false">A Silent Voice</string> | ||||
| @ -49,6 +53,11 @@ | ||||
|     <string name="account_login_desc">Tap to edit</string> | ||||
|     <string name="account_subscription">Subscription %1$s</string> | ||||
|     <string name="account_subscription_desc">Tap to extend</string> | ||||
|     <string name="account_premium">Premium member</string> | ||||
|     <string name="account_tier">Tier: %1$s</string> | ||||
|     <string name="account_tier_fan" translatable="false">Fan</string> | ||||
|     <string name="account_tier_mega_fan" translatable="false">Mega Fan</string> | ||||
|     <string name="account_tier_ultimate_fan" translatable="false">Ultimate Fan</string> | ||||
|     <string name="settings">Settings</string> | ||||
|     <string name="settings_content_language">Preferred content language</string> | ||||
|     <string name="settings_content_language_desc">English</string> | ||||
| @ -103,6 +112,7 @@ | ||||
|     <string name="time_min_sec" translatable="false">%1$02d:%2$02d</string> | ||||
|     <string name="time_hour_min_sec" translatable="false">%1$d:%2$02d:%3$02d</string> | ||||
|     <string name="language">Language</string> | ||||
|     <string name="audio">Audio</string> | ||||
|     <string name="subtitles">Subtitles</string> | ||||
|     <string name="episodes">Episodes</string> | ||||
|     <string name="episode">Episode</string> | ||||
| @ -141,6 +151,7 @@ | ||||
|     <string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string> | ||||
|     <!-- for legacy reasons the prefer subbed key is called prefer_secondary--> | ||||
|     <string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string> | ||||
|     <string name="save_key_preferred_audio_local" translatable="false">org.mosad.teapod.preferred_audio_local</string> | ||||
|     <string name="save_key_preferred_local" translatable="false">org.mosad.teapod.preferred_local</string> | ||||
|     <string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string> | ||||
|     <string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string> | ||||
|  | ||||
| @ -5,6 +5,7 @@ | ||||
|         <item name="colorPrimaryDark">@color/colorPrimaryDark</item> | ||||
|         <item name="colorAccent">@color/colorAccent</item> | ||||
|         <item name="popupMenuStyle">@style/Widget.App.PopupMenu</item> | ||||
|         <item name="searchViewStyle">@style/SearchViewStyle</item> | ||||
|     </style> | ||||
|  | ||||
|     <style name="AppTheme.Light" parent="AppTheme"> | ||||
| @ -50,6 +51,13 @@ | ||||
|         <item name="android:textColor">?textPrimary</item> | ||||
|     </style> | ||||
|  | ||||
|     <!-- search view theme --> | ||||
|     <style name="SearchViewStyle" parent="Widget.AppCompat.SearchView.ActionBar"> | ||||
|         <item name="iconifiedByDefault">false</item> | ||||
|         <item name="searchIcon">@drawable/ic_baseline_search_24</item> | ||||
|         <item name="queryHint">@string/search_hint</item> | ||||
|     </style> | ||||
|  | ||||
|     <!-- player theme --> | ||||
|     <style name="PlayerTheme" parent="AppTheme"> | ||||
|         <item name="android:windowNoTitle">true</item> | ||||
| @ -74,7 +82,6 @@ | ||||
|         <item name="postSplashScreenTheme">@style/AppTheme.Dark</item>  # Required. | ||||
|     </style> | ||||
|  | ||||
|  | ||||
|     <!-- shapes --> | ||||
|     <style name="ShapeAppearance.Teapod.RoundedPoster" parent="ShapeAppearance.MaterialComponents.LargeComponent"> | ||||
|         <item name="cornerFamily">rounded</item> | ||||
| @ -95,5 +102,4 @@ | ||||
|         <item name="android:windowTranslucentNavigation">true</item> | ||||
|     </style> | ||||
|  | ||||
|  | ||||
| </resources> | ||||
| @ -1,17 +0,0 @@ | ||||
| package org.mosad.teapod | ||||
|  | ||||
| import org.junit.Test | ||||
|  | ||||
| import org.junit.Assert.* | ||||
|  | ||||
| /** | ||||
|  * Example local unit test, which will execute on the development machine (host). | ||||
|  * | ||||
|  * See [testing documentation](http://d.android.com/tools/testing). | ||||
|  */ | ||||
| class ExampleUnitTest { | ||||
|     @Test | ||||
|     fun addition_isCorrect() { | ||||
|         assertEquals(4, 2 + 2) | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,24 @@ | ||||
| package org.mosad.teapod.parser.crunchyroll | ||||
|  | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import org.junit.Assert | ||||
| import org.junit.Test | ||||
|  | ||||
| class DataTypesTest { | ||||
|  | ||||
|     @Test | ||||
|     fun testTokenType() { | ||||
|         val testToken = javaClass.getResource("/token.json")!!.readText() | ||||
|         val token: Token = Json.decodeFromString(testToken) | ||||
|  | ||||
|         Assert.assertEquals("TestAccessToken-1_TestAccessToken", token.accessToken) | ||||
|         Assert.assertEquals("00000000-0000-0000-0000-000000000000", token.refreshToken) | ||||
|         Assert.assertEquals(300, token.expiresIn) | ||||
|         Assert.assertEquals("Bearer", token.tokenType) | ||||
|         Assert.assertEquals("account content offline_access reviews talkbox", token.scope) | ||||
|         Assert.assertEquals("DE", token.country) | ||||
|         Assert.assertEquals("00000000-0000-0000-0000-000000000000", token.accountId) | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										9
									
								
								app/src/test/resources/token.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/test/resources/token.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| { | ||||
|   "access_token":"TestAccessToken-1_TestAccessToken", | ||||
|   "refresh_token":"00000000-0000-0000-0000-000000000000", | ||||
|   "expires_in":300, | ||||
|   "token_type":"Bearer", | ||||
|   "scope":"account content offline_access reviews talkbox", | ||||
|   "country":"DE", | ||||
|   "account_id":"00000000-0000-0000-0000-000000000000" | ||||
| } | ||||
| @ -1,14 +1,14 @@ | ||||
| // Top-level build file where you can add configuration options common to all sub-projects/modules. | ||||
| buildscript { | ||||
|     ext.kotlin_version = "1.6.21" | ||||
|     ext.ktor_version = "1.6.8" | ||||
|     ext.exo_version = "2.17.1" | ||||
|     ext.kotlin_version = "1.7.20" | ||||
|     ext.ktor_version = "2.2.1" | ||||
|     ext.exo_version = "2.18.2" | ||||
|     repositories { | ||||
|         google() | ||||
|         mavenCentral() | ||||
|     } | ||||
|     dependencies { | ||||
|         classpath 'com.android.tools.build:gradle:7.2.1' | ||||
|         classpath 'com.android.tools.build:gradle:7.4.1' | ||||
|         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" | ||||
|  | ||||
|         // NOTE: Do not place your application dependencies here; they belong | ||||
|  | ||||
							
								
								
									
										6
									
								
								fastlane/metadata/android/de/changelogs/100000.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								fastlane/metadata/android/de/changelogs/100000.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| Dies ist der erste stabile Release von Teapod mit Unterstützung für Cunchyroll. | ||||
|  | ||||
| * Unterstützung für Crunchyroll hinzugefügt (Ein premium Account wird benötigt) | ||||
| * Diverse UI/UX Verbesserungen | ||||
|  | ||||
| Alle Änderungen: https://git.mosad.xyz/Seil0/teapod/compare/0.4.2...1.0.0 | ||||
							
								
								
									
										9
									
								
								fastlane/metadata/android/de/changelogs/100990.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								fastlane/metadata/android/de/changelogs/100990.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| Dies ist der erste beta Release von Teapod 1.1. | ||||
|  | ||||
| * Unterstützung für Crunchyroll v2 API | ||||
| * Intro überspringen hinzugefügt | ||||
| * Seperaten Screen für Meine Liste | ||||
| * Dynamische SPaltenanzahl für alle Screes um große Bildschirme besser zu unterstützen | ||||
| * Kleine UI/UX Verbesserungen | ||||
|  | ||||
| Alle Änderungen: https://git.mosad.xyz/Seil0/teapod/compare/1.0.0...1.1.0-beta1 | ||||
							
								
								
									
										9
									
								
								fastlane/metadata/android/de/changelogs/9020.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								fastlane/metadata/android/de/changelogs/9020.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| Dies ist der dritte beta Release von Teapod 1.0.0 mit Unterstützung für Cunchyroll. | ||||
|  | ||||
| * Diverse UI/UX Verbesserungen | ||||
| * Playhead Updates werden nun alle 30 Sekunden durchgeführt | ||||
| * Fehlende Playhead Updates beim schließen des Players behoben (#62) | ||||
| * Abo Status und Stufe zum Accountscreen hinzugefügt | ||||
| * Das Verhalten des "Nächste Episode" Buttons wurde verbessert (#53) | ||||
|  | ||||
| Alle Änderungen https://git.mosad.xyz/Seil0/teapod/compare/1.0.0-beta2...1.0.0-beta3 | ||||
							
								
								
									
										6
									
								
								fastlane/metadata/android/en-US/changelogs/100000.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								fastlane/metadata/android/en-US/changelogs/100000.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | ||||
| This is the first stable release of Teapod with support for crunchyroll. | ||||
|  | ||||
| * Support for crunchyroll (a premium account is needed) | ||||
| * UI/UX improvements | ||||
|  | ||||
| Full changelog: https://git.mosad.xyz/Seil0/teapod/compare/0.4.2...1.0.0 | ||||
							
								
								
									
										9
									
								
								fastlane/metadata/android/en-US/changelogs/100990.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								fastlane/metadata/android/en-US/changelogs/100990.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| This is the first beta release of Teapod 1.1. | ||||
|  | ||||
| * Migrate crunchyroll parser to v2 (fixes crunchyroll) | ||||
| * Add skip intro function | ||||
| * Add a separate Watchlist fragment | ||||
| * Dynamically set coulmn count based on the display size | ||||
| * Minor UI/UX improvements | ||||
|  | ||||
| Full changelog: https://git.mosad.xyz/Seil0/teapod/compare/1.0.0...1.1.0-beta1 | ||||
							
								
								
									
										9
									
								
								fastlane/metadata/android/en-US/changelogs/9020.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								fastlane/metadata/android/en-US/changelogs/9020.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| This is the third beta release of Teapod 1.0.0 with support for crunchyroll. | ||||
|  | ||||
| * UI/UX improvements | ||||
| * Playhead is now updated every 30 seconds | ||||
| * Fixed missing playhead updates when closing the player (#62) | ||||
| * Add subscription status and tier info to the account screen | ||||
| * Improved the behaviour of the "next episde" button (#53) | ||||
|  | ||||
| Full changelog https://git.mosad.xyz/Seil0/teapod/compare/1.0.0-beta2...1.0.0-beta3 | ||||
							
								
								
									
										
											BIN
										
									
								
								fastlane/metadata/android/en-US/images/icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								fastlane/metadata/android/en-US/images/icon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 13 KiB | 
							
								
								
									
										
											BIN
										
									
								
								gradle/wrapper/gradle-wrapper.jar
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								gradle/wrapper/gradle-wrapper.jar
									
									
									
									
										vendored
									
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										2
									
								
								gradle/wrapper/gradle-wrapper.properties
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								gradle/wrapper/gradle-wrapper.properties
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| distributionBase=GRADLE_USER_HOME | ||||
| distributionPath=wrapper/dists | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip | ||||
| zipStoreBase=GRADLE_USER_HOME | ||||
| zipStorePath=wrapper/dists | ||||
|  | ||||
							
								
								
									
										6
									
								
								gradlew
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								gradlew
									
									
									
									
										vendored
									
									
								
							| @ -205,6 +205,12 @@ set -- \ | ||||
|         org.gradle.wrapper.GradleWrapperMain \ | ||||
|         "$@" | ||||
|  | ||||
| # Stop when "xargs" is not available. | ||||
| if ! command -v xargs >/dev/null 2>&1 | ||||
| then | ||||
|     die "xargs is not available" | ||||
| fi | ||||
|  | ||||
| # Use "xargs" to parse quoted args. | ||||
| # | ||||
| # With -n1 it outputs one arg per line, with the quotes and backslashes removed. | ||||
|  | ||||
							
								
								
									
										10
									
								
								gradlew.bat
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								gradlew.bat
									
									
									
									
										vendored
									
									
								
							| @ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome | ||||
|  | ||||
| set JAVA_EXE=java.exe | ||||
| %JAVA_EXE% -version >NUL 2>&1 | ||||
| if "%ERRORLEVEL%" == "0" goto execute | ||||
| if %ERRORLEVEL% equ 0 goto execute | ||||
|  | ||||
| echo. | ||||
| echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. | ||||
| @ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar | ||||
|  | ||||
| :end | ||||
| @rem End local scope for the variables with windows NT shell | ||||
| if "%ERRORLEVEL%"=="0" goto mainEnd | ||||
| if %ERRORLEVEL% equ 0 goto mainEnd | ||||
|  | ||||
| :fail | ||||
| rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of | ||||
| rem the _cmd.exe /c_ return code! | ||||
| if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 | ||||
| exit /b 1 | ||||
| set EXIT_CODE=%ERRORLEVEL% | ||||
| if %EXIT_CODE% equ 0 set EXIT_CODE=1 | ||||
| if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% | ||||
| exit /b %EXIT_CODE% | ||||
|  | ||||
| :mainEnd | ||||
| if "%OS%"=="Windows_NT" endlocal | ||||
|  | ||||
		Reference in New Issue
	
	Block a user