Compare commits
	
		
			36 Commits
		
	
	
		
			7fbf639a70
			...
			1.0.0-beta
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ad1e3068cd | |||
| de1f19c2b7 | |||
| 12bbc2ef5f | |||
| 0186cef79e | |||
| bc5509cf93 | |||
| ef9a0f00d0 | |||
| b85d7ae025 | |||
| 69c9666d2b | |||
| 7d6c300f7e | |||
| 1ebc1194e6 | |||
| c48328723b | |||
| 95c8a72c94 | |||
| fc04e8e222 | |||
| a898a70653 | |||
| 58aab72097 | |||
| 35157b78f5 | |||
| c6a00ea061 | |||
| 80a7fc4398 | |||
| dd6ca8b90e | |||
| e80e81af0f | |||
| f852600dc7 | |||
| aa49169034 | |||
| 7abb5cd3e8 | |||
| 3a71bdd2c7 | |||
| 629c144c5b | |||
| b2196f11da | |||
| 5b5a74a1de | |||
| 7a860a7270 | |||
| e97ad9a245 | |||
| cf435fdb72 | |||
| 42895a6fba | |||
| eaf1cf78e9 | |||
| 1af82f8370 | |||
| d31a19a4f1 | |||
| b27666ee69 | |||
| e76cbda04d | 
| @ -1,7 +1,6 @@ | ||||
| plugins { | ||||
|     id 'com.android.application' | ||||
|     id 'kotlin-android' | ||||
|     id 'kotlin-android-extensions' | ||||
|     id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version" | ||||
| } | ||||
|  | ||||
| @ -13,8 +12,8 @@ android { | ||||
|         applicationId "org.mosad.teapod" | ||||
|         minSdkVersion 23 | ||||
|         targetSdkVersion 31 | ||||
|         versionCode 9000 //00.09.000 | ||||
|         versionName "1.0.0-beta1" | ||||
|         versionCode 9010 //00.09.010 | ||||
|         versionName "1.0.0-beta2" | ||||
|  | ||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||
|         resValue "string", "build_time", buildTime() | ||||
| @ -39,37 +38,39 @@ android { | ||||
|     } | ||||
|     kotlinOptions { | ||||
|         jvmTarget = '1.8' | ||||
|         kotlin.sourceSets.all { | ||||
|             languageSettings.optIn("kotlin.RequiresOptIn") | ||||
|         } | ||||
|     } | ||||
|     namespace 'org.mosad.teapod' | ||||
| } | ||||
|  | ||||
| dependencies { | ||||
|     implementation fileTree(dir: "libs", include: ["*.jar"]) | ||||
|     implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1' | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1' | ||||
|     implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3' | ||||
|  | ||||
|     implementation 'androidx.core:core-ktx:1.7.0' | ||||
|     implementation 'androidx.core:core-splashscreen:1.0.0-beta02' | ||||
|     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.1' | ||||
|     implementation 'androidx.navigation:navigation-ui-ktx:2.4.1' | ||||
|     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.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 'com.google.android.material:material:1.5.0' | ||||
|     implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0' | ||||
|     implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0' | ||||
|     implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0' | ||||
|     implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0' | ||||
|     implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0' | ||||
|     implementation "com.google.android.exoplayer: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.12.0' | ||||
|     implementation 'com.github.bumptech.glide:glide:4.13.1' | ||||
|     implementation 'jp.wasabeef:glide-transformations:4.3.0' | ||||
|     implementation 'com.afollestad.material-dialogs:core:3.3.0' // TODO remove once unused | ||||
|     implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' // TODO remove once unused | ||||
|  | ||||
|     implementation "io.ktor:ktor-client-core:$ktor_version" | ||||
|     implementation "io.ktor:ktor-client-android:$ktor_version" | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     package="org.mosad.teapod"> | ||||
|     xmlns:tools="http://schemas.android.com/tools"> | ||||
|  | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|  | ||||
| @ -11,11 +10,12 @@ | ||||
|         android:label="@string/app_name" | ||||
|         android:roundIcon="@mipmap/ic_launcher_round" | ||||
|         android:supportsRtl="true" | ||||
|         android:theme="@style/Theme.App.Starting"> | ||||
|         android:theme="@style/AppTheme.Dark"> | ||||
|         <activity | ||||
|             android:exported="true" | ||||
|             android:name="org.mosad.teapod.ui.activity.main.MainActivity" | ||||
|             android:screenOrientation="portrait"> | ||||
|             android:screenOrientation="portrait" | ||||
|             android:theme="@style/Theme.App.Starting"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN" /> | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|  | ||||
| @ -25,6 +25,7 @@ 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.request.* | ||||
| @ -39,7 +40,6 @@ import kotlinx.serialization.json.buildJsonObject | ||||
| import kotlinx.serialization.json.put | ||||
| import org.mosad.teapod.preferences.EncryptedPreferences | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| import org.mosad.teapod.util.concatenate | ||||
|  | ||||
| private val json = Json { ignoreUnknownKeys = true } | ||||
|  | ||||
| @ -57,6 +57,8 @@ object Crunchyroll { | ||||
|  | ||||
|     private lateinit var token: Token | ||||
|     private var tokenValidUntil: Long = 0 | ||||
|     @OptIn(DelicateCoroutinesApi::class) | ||||
|     private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext") | ||||
|  | ||||
|     private var accountID = "" | ||||
|  | ||||
| @ -64,7 +66,7 @@ object Crunchyroll { | ||||
|     private var signature = "" | ||||
|     private var keyPairID = "" | ||||
|  | ||||
|     private val browsingCache = arrayListOf<Item>() | ||||
|     private val browsingCache = hashMapOf<String, BrowseResult>() | ||||
|  | ||||
|     /** | ||||
|      * Load the pai token, see: | ||||
| @ -98,15 +100,27 @@ object Crunchyroll { | ||||
|  | ||||
|         var success = false// is false | ||||
|         withContext(Dispatchers.IO) { | ||||
|             // TODO handle exceptions | ||||
|             val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { | ||||
|                 header("Authorization", "Basic $basicApiToken") | ||||
|             } | ||||
|             token = response.receive() | ||||
|             tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000) | ||||
|             Log.i(TAG, "getting token ...") | ||||
|  | ||||
|             Log.i(TAG, "login complete with code ${response.status}") | ||||
|             success = (response.status == HttpStatusCode.OK) | ||||
|             val status = try { | ||||
|                 val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { | ||||
|                     header("Authorization", "Basic $basicApiToken") | ||||
|                 } | ||||
|                 token = response.receive() | ||||
|                 tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000) | ||||
|                 response.status | ||||
|             } catch (ex: ClientRequestException) { | ||||
|                 val status = ex.response.status | ||||
|                 if (status == HttpStatusCode.Unauthorized) { | ||||
|                     Log.e(TAG, "Could not complete login: " + | ||||
|                             "${status.value} ${status.description}. " + | ||||
|                             "Probably wrong username or password") | ||||
|                 } | ||||
|  | ||||
|                 status | ||||
|             } | ||||
|             Log.i(TAG, "Login complete with code $status") | ||||
|             success = (status == HttpStatusCode.OK) | ||||
|         } | ||||
|  | ||||
|         return@runBlocking success | ||||
| @ -126,7 +140,9 @@ object Crunchyroll { | ||||
|         params: List<Pair<String, Any?>> = listOf(), | ||||
|         bodyObject: Any = Any() | ||||
|     ): T = coroutineScope { | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
|         withContext(tokenRefreshContext) { | ||||
|             if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
|         } | ||||
|  | ||||
|         return@coroutineScope (Dispatchers.IO) { | ||||
|             val response: T = client.request(url) { | ||||
| @ -235,7 +251,6 @@ object Crunchyroll { | ||||
|      * General element/media functions: browse, search, objects, season_list | ||||
|      */ | ||||
|  | ||||
|     // TODO categories | ||||
|     /** | ||||
|      * Browse the media available on crunchyroll. | ||||
|      * | ||||
| @ -245,13 +260,14 @@ object Crunchyroll { | ||||
|      * @return A **[BrowseResult]** object is returned. | ||||
|      */ | ||||
|     suspend fun browse( | ||||
|         categories: List<Categories> = emptyList(), | ||||
|         sortBy: SortBy = SortBy.ALPHABETICAL, | ||||
|         seasonTag: String = "", | ||||
|         start: Int = 0, | ||||
|         n: Int = 10 | ||||
|     ): BrowseResult { | ||||
|         val browseEndpoint = "/content/v1/browse" | ||||
|         val noneOptParams = listOf( | ||||
|         val parameters = mutableListOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "sort_by" to sortBy.str, | ||||
|             "start" to start, | ||||
| @ -259,28 +275,47 @@ object Crunchyroll { | ||||
|         ) | ||||
|  | ||||
|         // if a season tag is present add it to the parameters | ||||
|         val parameters = if (seasonTag.isNotEmpty()) { | ||||
|             concatenate(noneOptParams, listOf("season_tag" to seasonTag)) | ||||
|         if (seasonTag.isNotEmpty()) { | ||||
|             parameters.add("season_tag" to seasonTag) | ||||
|         } | ||||
|  | ||||
|         // if a season tag is present add it to the parameters | ||||
|         if (categories.isNotEmpty()) { | ||||
|             parameters.add("categories" to categories.joinToString(",") { it.str }) | ||||
|         } | ||||
|  | ||||
|         // fetch result if not already cached | ||||
|         if (browsingCache.contains(parameters.toString())) { | ||||
|             Log.d(TAG, "browse result cached: $parameters") | ||||
|         } else { | ||||
|             noneOptParams | ||||
|             Log.d(TAG, "browse result not cached, fetching: $parameters") | ||||
|             val browseResult: BrowseResult = try { | ||||
|                 requestGet(browseEndpoint, parameters) | ||||
|             }catch (ex: SerializationException) { | ||||
|                 Log.e(TAG, "SerializationException in browse().", ex) | ||||
|                 NoneBrowseResult | ||||
|             } | ||||
|  | ||||
|             // if the cache has more than 100 entries clear it, so it doesn't become a memory problem | ||||
|             // Note: this value is totally guessed and should be replaced by a properly researched value | ||||
|             if (browsingCache.size > 100) { | ||||
|                 browsingCache.clear() | ||||
|             } | ||||
|  | ||||
|             // add results to cache | ||||
|             browsingCache[parameters.toString()] = browseResult | ||||
|         } | ||||
|  | ||||
|         val browseResult: BrowseResult = try { | ||||
|             requestGet(browseEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in browse().", ex) | ||||
|             NoneBrowseResult | ||||
|         } | ||||
|  | ||||
|         // add results to cache TODO improve | ||||
|         browsingCache.clear() | ||||
|         browsingCache.addAll(browseResult.items) | ||||
|  | ||||
|         return browseResult | ||||
|         return browsingCache[parameters.toString()] ?: NoneBrowseResult | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * TODO | ||||
|      * Search fo a query term. | ||||
|      * Note: currently this function only supports series/tv shows. | ||||
|      * | ||||
|      * @param query The query term as String | ||||
|      * @param n The maximum number of results to return, default = 10 | ||||
|      * @return A **[SearchResult]** object | ||||
|      */ | ||||
|     suspend fun search(query: String, n: Int = 10): SearchResult { | ||||
|         val searchEndpoint = "/content/v1/search" | ||||
| @ -367,7 +402,10 @@ object Crunchyroll { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * TODO | ||||
|      * Get the next episode for a series. | ||||
|      * | ||||
|      * @param seriesId The series id for which to call up next | ||||
|      * @return A **[UpNextSeriesItem]** with a Panel representing the up next episode | ||||
|      */ | ||||
|     suspend fun upNextSeries(seriesId: String): UpNextSeriesItem { | ||||
|         val upNextSeriesEndpoint = "/content/v1/up_next_series" | ||||
| @ -384,6 +422,12 @@ object Crunchyroll { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get all available seasons for a series. | ||||
|      * | ||||
|      * @param seriesId The series id for which to get the seasons | ||||
|      * @return A **[Seasons]** object with a list of **[Season]** | ||||
|      */ | ||||
|     suspend fun seasons(seriesId: String): Seasons { | ||||
|         val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons" | ||||
|         val parameters = listOf( | ||||
| @ -402,6 +446,12 @@ object Crunchyroll { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get all available episodes for a season. | ||||
|      * | ||||
|      * @param seasonId The season id for which to get the episodes | ||||
|      * @return A **[Episodes]** object with a list of **[Episode]** | ||||
|      */ | ||||
|     suspend fun episodes(seasonId: String): Episodes { | ||||
|         val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes" | ||||
|         val parameters = listOf( | ||||
| @ -420,6 +470,12 @@ object Crunchyroll { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get all available subtitles and streams of a episode. | ||||
|      * | ||||
|      * @param url The playback url of a episode | ||||
|      * @return A **[Playback]** object | ||||
|      */ | ||||
|     suspend fun playback(url: String): Playback { | ||||
|         return try { | ||||
|             requestGet("", url = url) | ||||
| @ -430,7 +486,7 @@ object Crunchyroll { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Additional media functions: watchlist (series), playhead | ||||
|      * Additional media functions: watchlist (series), playhead, similar to | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
| @ -494,8 +550,11 @@ object Crunchyroll { | ||||
|  | ||||
|         return try { | ||||
|             requestGet(playheadsEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in upNextSeries().", ex) | ||||
|         } catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in playheads().", ex) | ||||
|             emptyMap() | ||||
|         } catch (ex: Throwable) { | ||||
|             Log.e(TAG, "Exception in playheads().", ex.cause) | ||||
|             emptyMap() | ||||
|         } | ||||
|     } | ||||
| @ -515,7 +574,34 @@ object Crunchyroll { | ||||
|             put("playhead", playhead) | ||||
|         } | ||||
|  | ||||
|         requestPost(playheadsEndpoint, parameters, json) | ||||
|         try { | ||||
|             requestPost(playheadsEndpoint, parameters, json) | ||||
|         } catch (ex: Throwable) { | ||||
|             Log.e(TAG, "Exception in postPlayheads()", ex.cause) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get similar media for a show/movie. | ||||
|      * | ||||
|      * @param seriesId The crunchyroll series id of the media | ||||
|      * @param n The maximum number of results to return, default = 10 | ||||
|      * @return A **[SimilarToResult]** object | ||||
|      */ | ||||
|     suspend fun similarTo(seriesId: String, n: Int = 10): SimilarToResult { | ||||
|         val similarToEndpoint = "/content/v1/$accountID/similar_to" | ||||
|         val parameters = listOf( | ||||
|             "guid" to seriesId, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "n" to n | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(similarToEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in similarTo().", ex) | ||||
|             NoneSimilarToResult | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -567,10 +653,32 @@ object Crunchyroll { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun recommendations(n: Int = 20, start: Int = 0): RecommendationsList { | ||||
|         val recommendationsEndpoint = "/content/v1/$accountID/recommendations" | ||||
|         val parameters = listOf( | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "n" to n, | ||||
|             "start" to start, | ||||
|             "variant_id" to 0 | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(recommendationsEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in recommendations().", ex) | ||||
|             NoneRecommendationsList | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Account/Profile functions | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
|      * Get profile information for the currently logged in account. | ||||
|      * | ||||
|      * @return A **[Profile]** object | ||||
|      */ | ||||
|     suspend fun profile(): Profile { | ||||
|         val profileEndpoint = "/accounts/v1/me/profile" | ||||
|  | ||||
| @ -582,6 +690,11 @@ object Crunchyroll { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Post the preferred content subtitle language. | ||||
|      * | ||||
|      * @param languageTag the preferred language as language tag | ||||
|      */ | ||||
|     suspend fun postPrefSubLanguage(languageTag: String) { | ||||
|         val profileEndpoint = "/accounts/v1/me/profile" | ||||
|         val json = buildJsonObject { | ||||
|  | ||||
| @ -50,6 +50,25 @@ enum class SortBy(val str: String) { | ||||
|     POPULARITY("popularity") | ||||
| } | ||||
|  | ||||
| @Suppress("unused") | ||||
| enum class Categories(val str: String) { | ||||
|     ACTION("action"), | ||||
|     ADVENTURE("adventure"), | ||||
|     COMEDY("comedy"), | ||||
|     DRAMA("drama"), | ||||
|     FANTASY("fantasy"), | ||||
|     MUSIC("music"), | ||||
|     ROMANCE("romance"), | ||||
|     SCI_FI("sci-fi"), | ||||
|     SEINEN("seinen"), | ||||
|     SHOJO("shojo"), | ||||
|     SHONEN("shonen"), | ||||
|     SLICE_OF_LIFE("slice+of+life"), | ||||
|     SPORTS("sports"), | ||||
|     SUPERNATURAL("supernatural"), | ||||
|     THRILLER("thriller") | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * token, index, account. This must pe present for the app to work! | ||||
|  */ | ||||
| @ -101,9 +120,11 @@ data class Collection<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( | ||||
| @ -117,7 +138,7 @@ data class UpNextSeriesItem( | ||||
|  * panel data classes | ||||
|  */ | ||||
|  | ||||
| // the data class Item is used in browse and search | ||||
| // the data class Item is used in browse, search, watchlist and similar to | ||||
| // TODO rename to MediaPanel | ||||
| @Serializable | ||||
| data class Item( | ||||
| @ -128,6 +149,7 @@ data class Item( | ||||
|     val description: String, | ||||
|     val images: Images | ||||
|     // TODO series_metadata etc. | ||||
|     // TODO add slug_title if present in search, browse, similar to | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| @ -169,7 +191,7 @@ data class ContinueWatchingItem( | ||||
|     @SerialName("fully_watched") val fullyWatched: Boolean = false, | ||||
| ) | ||||
|  | ||||
| // EpisodePanel is used in ContinueWatchingItem | ||||
| // EpisodePanel is used in ContinueWatchingItem and UpNextSeriesItem | ||||
| @Serializable | ||||
| data class EpisodePanel( | ||||
|     @SerialName("id") val id: String, | ||||
| @ -185,25 +207,35 @@ data class EpisodePanel( | ||||
| @Serializable | ||||
| data class EpisodeMetadata( | ||||
|     @SerialName("duration_ms") val durationMs: Int, | ||||
|     @SerialName("episode_number") val episodeNumber: Int? = null, // default/nullable value since optional | ||||
|     @SerialName("season_id") val seasonId: String, | ||||
|     @SerialName("season_number") val seasonNumber: Int, | ||||
|     @SerialName("season_title") val seasonTitle: String, | ||||
|     @SerialName("series_id") val seriesId: String, | ||||
|     @SerialName("series_title") val seriesTitle: String, | ||||
| ) | ||||
|  | ||||
| val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList())) | ||||
| val NoneEpisodeMetadata = EpisodeMetadata(0, "", "", "") | ||||
| 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 NoneRecommendationsList = RecommendationsList(0, emptyList()) | ||||
|  | ||||
| val NoneUpNextSeriesItem = UpNextSeriesItem(0, false, false, NoneEpisodePanel) | ||||
| val NoneUpNextSeriesItem = UpNextSeriesItem( | ||||
|     playhead = 0, | ||||
|     fullyWatched = false, | ||||
|     neverWatched = false, | ||||
|     panel = NoneEpisodePanel | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * Series data type | ||||
|  * series data class | ||||
|  */ | ||||
| @Serializable | ||||
| data class Series( | ||||
| @ -216,7 +248,7 @@ data class Series( | ||||
| val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList()) | ||||
|  | ||||
| /** | ||||
|  * Seasons data type | ||||
|  * Seasons data classes | ||||
|  */ | ||||
| @Serializable | ||||
| data class Seasons( | ||||
| @ -250,7 +282,7 @@ val NoneSeason = Season("", "", "", "", 0, isSubbed = false, isDubbed = false) | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Episodes data type | ||||
|  * Episodes data classes | ||||
|  */ | ||||
| @Serializable | ||||
| data class Episodes( | ||||
| @ -314,7 +346,7 @@ data class PlayheadObject( | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * Playback/stream data type | ||||
|  * playback/stream data classes | ||||
|  */ | ||||
| @Serializable | ||||
| data class Playback( | ||||
| @ -362,6 +394,9 @@ val NonePlayback = Playback( | ||||
|     ) | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * profile data class | ||||
|  */ | ||||
| @Serializable | ||||
| data class Profile( | ||||
|     @SerialName("avatar") val avatar: String, | ||||
|  | ||||
| @ -43,7 +43,6 @@ 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.ui.components.LoginDialog | ||||
| import org.mosad.teapod.util.DataTypes | ||||
| import org.mosad.teapod.util.metadb.MetaDBController | ||||
| import java.util.* | ||||
| @ -184,21 +183,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen | ||||
|         return scope.launch { MetaDBController.list() } | ||||
|     } | ||||
|  | ||||
|     private fun showLoginDialog() { | ||||
|         LoginDialog(this, false).positiveButton { | ||||
|             EncryptedPreferences.saveCredentials(login, password, context) | ||||
|  | ||||
|             // TODO | ||||
| //            if (!AoDParser.login()) { | ||||
| //                showLoginDialog() | ||||
| //                Log.w(javaClass.name, "Login failed, please try again.") | ||||
| //            } | ||||
|         }.negativeButton { | ||||
|             Log.i(classTag, "Login canceled, exiting.") | ||||
|             finish() | ||||
|         }.show() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * start the onboarding activity and finish the main activity | ||||
|      */ | ||||
|  | ||||
| @ -109,12 +109,12 @@ class AboutFragment : Fragment() { | ||||
|                 "https://github.com/google/ExoPlayer", License.APACHE2), | ||||
|             ThirdPartyComponent("Material design icons", "2020", "Google Inc.", | ||||
|                 "https://github.com/google/material-design-icons", License.APACHE2), | ||||
|             ThirdPartyComponent("Material Dialogs", "", "Aidan Follestad", | ||||
|                 "https://github.com/afollestad/material-dialogs", License.APACHE2), | ||||
|             ThirdPartyComponent("Ktor", "2014-2021", "JetBrains s.r.o and contributors", | ||||
|                 "https://ktor.io/", License.APACHE2), | ||||
|             ThirdPartyComponent("kotlinx.coroutines", "2016-2021", "JetBrains s.r.o", | ||||
|                 "https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2), | ||||
|             ThirdPartyComponent(" kotlinx.serialization", "2017-2021", "JetBrains s.r.o", | ||||
|                 "https://github.com/Kotlin/kotlinx.serialization", License.APACHE2), | ||||
|             ThirdPartyComponent("Glide", "2014", "Google Inc.", | ||||
|                 "https://github.com/bumptech/glide", License.BSD2), | ||||
|             ThirdPartyComponent("Glide Transformations", "2020", "Wasabeef", | ||||
|  | ||||
| @ -1,12 +1,9 @@ | ||||
| package org.mosad.teapod.ui.activity.main.fragments | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.content.Intent | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| @ -24,7 +21,7 @@ import org.mosad.teapod.parser.crunchyroll.supportedLocals | ||||
| import org.mosad.teapod.preferences.EncryptedPreferences | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| import org.mosad.teapod.ui.activity.main.MainActivity | ||||
| import org.mosad.teapod.ui.components.LoginDialog | ||||
| import org.mosad.teapod.ui.components.LoginModalBottomSheet | ||||
| import org.mosad.teapod.util.DataTypes.Theme | ||||
| import org.mosad.teapod.util.showFragment | ||||
| import org.mosad.teapod.util.toDisplayString | ||||
| @ -37,28 +34,6 @@ class AccountFragment : Fragment() { | ||||
|         Crunchyroll.profile() | ||||
|     } | ||||
|  | ||||
|     private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> | ||||
|         if (result.resultCode == Activity.RESULT_OK) { | ||||
|             result.data?.data?.also { uri -> | ||||
|                 //StorageController.exportMyList(requireContext(), uri) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private val getUriImport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> | ||||
|         if (result.resultCode == Activity.RESULT_OK) { | ||||
|             result.data?.data?.also { uri -> | ||||
| //                val success = StorageController.importMyList(requireContext(), uri) | ||||
| //                if (success == 0) { | ||||
| //                    Toast.makeText( | ||||
| //                        context, getString(R.string.import_data_success), | ||||
| //                        Toast.LENGTH_SHORT | ||||
| //                    ).show() | ||||
| //                } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentAccountBinding.inflate(inflater, container, false) | ||||
|         return binding.root | ||||
| @ -102,7 +77,7 @@ class AccountFragment : Fragment() { | ||||
|  | ||||
|     private fun initActions() { | ||||
|         binding.linearAccountLogin.setOnClickListener { | ||||
|             showLoginDialog(true) | ||||
|             showLoginDialog() | ||||
|         } | ||||
|  | ||||
|         binding.linearAccountSubscription.setOnClickListener { | ||||
| @ -136,36 +111,29 @@ class AccountFragment : Fragment() { | ||||
|         } | ||||
|  | ||||
|         binding.linearExportData.setOnClickListener { | ||||
|             val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { | ||||
|                 addCategory(Intent.CATEGORY_OPENABLE) | ||||
|                 type = "text/json" | ||||
|                 putExtra(Intent.EXTRA_TITLE, "my-list.json") | ||||
|             } | ||||
|             getUriExport.launch(i) | ||||
|             // unused | ||||
|         } | ||||
|  | ||||
|         binding.linearImportData.setOnClickListener { | ||||
|             val i = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { | ||||
|                 addCategory(Intent.CATEGORY_OPENABLE) | ||||
|                 type = "*/*" | ||||
|             } | ||||
|             getUriImport.launch(i) | ||||
|             // unused | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun showLoginDialog(firstTry: Boolean) { | ||||
|         LoginDialog(requireContext(), firstTry).positiveButton { | ||||
|             EncryptedPreferences.saveCredentials(login, password, context) | ||||
|  | ||||
|             // TODO | ||||
| //            if (!AoDParser.login()) { | ||||
| //                showLoginDialog(false) | ||||
| //                Log.w(javaClass.name, "Login failed, please try again.") | ||||
| //            } | ||||
|         }.show { | ||||
|     private fun showLoginDialog() { | ||||
|         val loginModal = LoginModalBottomSheet().apply { | ||||
|             login = EncryptedPreferences.login | ||||
|             password = "" | ||||
|             positiveAction = { | ||||
|                 EncryptedPreferences.saveCredentials(login, password, requireContext()) | ||||
|  | ||||
|                 // TODO only dismiss if login was successful | ||||
|                 this.dismiss() | ||||
|             } | ||||
|             negativeAction = { | ||||
|                 this.dismiss() | ||||
|             } | ||||
|         } | ||||
|         activity?.let { loginModal.show(it.supportFragmentManager, LoginModalBottomSheet.TAG) } | ||||
|     } | ||||
|  | ||||
|     private fun showContentLanguageSelection() { | ||||
|  | ||||
| @ -1,34 +1,55 @@ | ||||
| /** | ||||
|  * Teapod | ||||
|  * | ||||
|  * Copyright 2020-2022  <seil0@mosad.xyz> | ||||
|  * | ||||
|  * This program is free software; you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation; either version 3 of the License, or | ||||
|  * (at your option) any later version. | ||||
|  * | ||||
|  * This program is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with this program; if not, write to the Free Software | ||||
|  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||||
|  * MA 02110-1301, USA. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| package org.mosad.teapod.ui.activity.main.fragments | ||||
|  | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| 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 kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.joinAll | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.FragmentHomeBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.Crunchyroll | ||||
| import org.mosad.teapod.parser.crunchyroll.Item | ||||
| import org.mosad.teapod.parser.crunchyroll.SortBy | ||||
| import org.mosad.teapod.util.adapter.MediaItemAdapter | ||||
| 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.setDrawableTop | ||||
| import org.mosad.teapod.util.showFragment | ||||
| import org.mosad.teapod.util.toItemMediaList | ||||
| import kotlin.random.Random | ||||
|  | ||||
| class HomeFragment : Fragment() { | ||||
|  | ||||
|     private val classTag = javaClass.name | ||||
|     private val model: HomeViewModel by viewModels() | ||||
|     private lateinit var binding: FragmentHomeBinding | ||||
|     private lateinit var adapterUpNext: MediaItemAdapter | ||||
|     private lateinit var adapterWatchlist: MediaItemAdapter | ||||
|     private lateinit var adapterNewTitles: MediaItemAdapter | ||||
|     private lateinit var adapterTopTen: MediaItemAdapter | ||||
|  | ||||
|     private lateinit var highlightMedia: Item | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentHomeBinding.inflate(inflater, container, false) | ||||
| @ -38,84 +59,53 @@ class HomeFragment : Fragment() { | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         lifecycleScope.launch { | ||||
|             context?.let { | ||||
|                 initHighlight() | ||||
|                 initRecyclerViews() | ||||
|                 initActions() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun initHighlight() { | ||||
|         lifecycleScope.launch { | ||||
|             val newTitles = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 10) | ||||
|             // FIXME crashes on newTitles.items.size == 0 | ||||
|             highlightMedia =  newTitles.items[Random.nextInt(newTitles.items.size)] | ||||
|  | ||||
|             // add media item to gui | ||||
|             binding.textHighlightTitle.text = highlightMedia.title | ||||
|             Glide.with(requireContext()).load(highlightMedia.images.poster_wide[0][3].source) | ||||
|                 .into(binding.imageHighlight) | ||||
|  | ||||
|             // TODO watchlist indicator | ||||
| //            if (StorageController.myList.contains(0)) { | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) | ||||
| //            } else { | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) | ||||
| //            } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Suspend, since adapters need to be initialized before we can initialize the actions. | ||||
|      */ | ||||
|     private suspend fun initRecyclerViews() { | ||||
|         binding.recyclerUpNext.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerWatchlist.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerNewEpisodes.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerRecommendations.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9)) | ||||
|         binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9)) | ||||
|  | ||||
|         val asyncJobList = arrayListOf<Job>() | ||||
|         binding.recyclerUpNext.adapter = MediaEpisodeListAdapter( | ||||
|             MediaEpisodeListAdapter.OnClickListener { | ||||
|                 val activity = activity | ||||
|                 if (activity is MainActivity) { | ||||
|                     activity.startPlayer(it.panel.episodeMetadata.seasonId, it.panel.id) | ||||
|                 } | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         // continue watching | ||||
|         val upNextJob = lifecycleScope.launch { | ||||
|             // TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately | ||||
|             adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().items | ||||
|                 .filter { !it.fullyWatched }.toItemMediaList()) | ||||
|             binding.recyclerNewEpisodes.adapter = adapterUpNext | ||||
|         binding.recyclerWatchlist.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         binding.recyclerRecommendations.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         binding.recyclerNewTitles.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         binding.recyclerTopTen.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         binding.textHighlightMyList.setOnClickListener { | ||||
|             model.toggleHighlightWatchlist() | ||||
|  | ||||
|             // disable the watchlist button until the result has been loaded | ||||
|             binding.textHighlightMyList.isClickable = false | ||||
|             // TODO since this might take a few seconds show a loading animation for the watchlist button | ||||
|         } | ||||
|         asyncJobList.add(upNextJob) | ||||
|  | ||||
|         // watchlist | ||||
|         val watchlistJob = lifecycleScope.launch { | ||||
|             adapterWatchlist = MediaItemAdapter(Crunchyroll.watchlist(50).toItemMediaList()) | ||||
|             binding.recyclerWatchlist.adapter = adapterWatchlist | ||||
|         } | ||||
|         asyncJobList.add(watchlistJob) | ||||
|  | ||||
|         // new simulcasts | ||||
|         val simulcastsJob = lifecycleScope.launch { | ||||
|             // val latestSeasonTag = Crunchyroll.seasonList().items.first().id | ||||
|             // val newSimulcasts = Crunchyroll.browse(seasonTag = latestSeasonTag, n = 50) | ||||
|             val newSimulcasts = Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50) | ||||
|  | ||||
|             adapterNewTitles = MediaItemAdapter(newSimulcasts.toItemMediaList()) | ||||
|             binding.recyclerNewTitles.adapter = adapterNewTitles | ||||
|         } | ||||
|         asyncJobList.add(simulcastsJob) | ||||
|  | ||||
|         // newly added / top ten | ||||
|         val newlyAddedJob = lifecycleScope.launch { | ||||
|             adapterTopTen = MediaItemAdapter(Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).toItemMediaList()) | ||||
|             binding.recyclerTopTen.adapter = adapterTopTen | ||||
|         } | ||||
|         asyncJobList.add(newlyAddedJob) | ||||
|  | ||||
|         asyncJobList.joinAll() | ||||
|     } | ||||
|  | ||||
|     private fun initActions() { | ||||
|         binding.buttonPlayHighlight.setOnClickListener { | ||||
|             // TODO implement | ||||
|             lifecycleScope.launch { | ||||
| @ -126,37 +116,60 @@ class HomeFragment : Fragment() { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         binding.textHighlightMyList.setOnClickListener { | ||||
|             // TODO implement | ||||
| //            if (StorageController.myList.contains(0)) { | ||||
| //                StorageController.myList.remove(0) | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) | ||||
| //            } else { | ||||
| //                StorageController.myList.add(0) | ||||
| //                binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) | ||||
| //            } | ||||
| //            StorageController.saveMyList(requireContext()) | ||||
|         } | ||||
|  | ||||
|         binding.textHighlightInfo.setOnClickListener { | ||||
|             activity?.showFragment(MediaFragment(highlightMedia.id)) | ||||
|         } | ||||
|  | ||||
|         adapterUpNext.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) | ||||
|         } | ||||
|  | ||||
|         adapterWatchlist.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) | ||||
|         } | ||||
|  | ||||
|         adapterNewTitles.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) | ||||
|         } | ||||
|  | ||||
|         adapterTopTen.onItemClick = { id, _ -> | ||||
|             activity?.showFragment(MediaFragment(id)) //(mediaId)) | ||||
|         viewLifecycleOwner.lifecycleScope.launch { | ||||
|             viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||||
|                 model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState -> | ||||
|                     when (uiState) { | ||||
|                         is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState) | ||||
|                         is HomeViewModel.UiState.Loading -> bindUiStateLoading() | ||||
|                         is HomeViewModel.UiState.Error -> bindUiStateError(uiState) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) { | ||||
|         val adapterUpNext = binding.recyclerUpNext.adapter as MediaEpisodeListAdapter | ||||
|         adapterUpNext.submitList(uiState.upNextItems.filter { !it.fullyWatched }) | ||||
|  | ||||
|         val adapterWatchlist = binding.recyclerWatchlist.adapter as MediaItemListAdapter | ||||
|         adapterWatchlist.submitList(uiState.watchlistItems.toItemMediaList()) | ||||
|  | ||||
|         val adapterRecommendations = binding.recyclerRecommendations.adapter as MediaItemListAdapter | ||||
|         adapterRecommendations.submitList(uiState.recommendationsItems.toItemMediaList()) | ||||
|  | ||||
|         val adapterNewTitles = binding.recyclerNewTitles.adapter as MediaItemListAdapter | ||||
|         adapterNewTitles.submitList(uiState.recentlyAddedItems.toItemMediaList()) | ||||
|  | ||||
|         val adapterTopTen = binding.recyclerTopTen.adapter as MediaItemListAdapter | ||||
|         adapterTopTen.submitList(uiState.topTenItems.toItemMediaList()) | ||||
|  | ||||
|         // highlight item | ||||
|         binding.textHighlightTitle.text = uiState.highlightItem.title | ||||
|         Glide.with(requireContext()).load(uiState.highlightItem.images.poster_wide[0][3].source) | ||||
|             .into(binding.imageHighlight) | ||||
|  | ||||
|         val iconHighlightWatchlist = if (uiState.highlightIsWatchlist) { | ||||
|             R.drawable.ic_baseline_check_24 | ||||
|         } else { | ||||
|             R.drawable.ic_baseline_add_24 | ||||
|         } | ||||
|         binding.textHighlightMyList.setDrawableTop(iconHighlightWatchlist) | ||||
|         binding.textHighlightMyList.isClickable = true | ||||
|  | ||||
|         binding.textHighlightInfo.setOnClickListener { | ||||
|             activity?.showFragment(MediaFragment(uiState.highlightItem.id)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun bindUiStateLoading() { | ||||
|         // currently not used | ||||
|     } | ||||
|  | ||||
|     private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) { | ||||
|         // currently not used | ||||
|         Log.e(classTag, "A error occurred while loading a UiState: ${uiState.message}") | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -8,8 +8,7 @@ import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import androidx.fragment.app.activityViewModels | ||||
| import androidx.fragment.app.viewModels | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.viewpager2.adapter.FragmentStateAdapter | ||||
| import com.bumptech.glide.Glide | ||||
| @ -37,7 +36,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|     private lateinit var binding: FragmentMediaBinding | ||||
|     private lateinit var pagerAdapter: FragmentStateAdapter | ||||
|  | ||||
|     private val model: MediaFragmentViewModel by activityViewModels() | ||||
|     private val model: MediaFragmentViewModel by viewModels() | ||||
|  | ||||
|     private val fragments = arrayListOf<Fragment>() | ||||
|     private var watchlistJobRunning = false | ||||
| @ -54,7 +53,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|         binding.frameLoading.visibility = View.VISIBLE | ||||
|  | ||||
|         // tab layout and pager | ||||
|         pagerAdapter = ScreenSlidePagerAdapter(requireActivity()) | ||||
|         pagerAdapter = ScreenSlidePagerAdapter(this) | ||||
|         // fix material components issue #1878, if more tabs are added increase | ||||
|         binding.pagerEpisodesSimilar.offscreenPageLimit = 2 | ||||
|         binding.pagerEpisodesSimilar.adapter = pagerAdapter | ||||
| @ -79,6 +78,12 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|         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() | ||||
|  | ||||
| @ -130,12 +135,15 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|         val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 | ||||
|         Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction) | ||||
|  | ||||
|         // clear fragments, since it lives in onCreate scope (don't do this in onPause/onStop -> FragmentManager transaction) | ||||
|         val fragmentsSize = if (fragments.lastIndex < 0) 0 else fragments.lastIndex | ||||
|         /** | ||||
|          * clear fragments, since it lives in onCreate scope, | ||||
|          * don't do this in onPause/onStop -> FragmentManager transaction | ||||
|          * (will be called on similar -> new MediaFragment -> onBackPressed) | ||||
|          */ | ||||
|         val fragmentsSize = fragments.size | ||||
|         fragments.clear() | ||||
|         pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize) | ||||
|  | ||||
|         // add the episodes fragment (as tab). Note: Movies are tv shows! | ||||
|         MediaFragmentEpisodes().also { | ||||
|             fragments.add(it) | ||||
|             pagerAdapter.notifyItemInserted(fragments.indexOf(it)) | ||||
| @ -170,13 +178,12 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|         } | ||||
|  | ||||
|         // if has similar titles | ||||
|         // TODO reimplement | ||||
| //        if (media.similar.isNotEmpty()) { | ||||
| //            MediaFragmentSimilar().also { | ||||
| //                fragments.add(it) | ||||
| //                pagerAdapter.notifyItemInserted(fragments.indexOf(it)) | ||||
| //            } | ||||
| //        } | ||||
|         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()) { | ||||
| @ -225,7 +232,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() { | ||||
|     /** | ||||
|      * A simple pager adapter | ||||
|      */ | ||||
|     private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { | ||||
|     private inner class ScreenSlidePagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { | ||||
|         override fun getItemCount(): Int = fragments.size | ||||
|  | ||||
|         override fun createFragment(position: Int): Fragment = fragments[position] | ||||
|  | ||||
| @ -8,7 +8,7 @@ import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.appcompat.widget.PopupMenu | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.activityViewModels | ||||
| import androidx.fragment.app.viewModels | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| @ -22,7 +22,7 @@ class MediaFragmentEpisodes : Fragment() { | ||||
|     private lateinit var binding: FragmentMediaEpisodesBinding | ||||
|     private lateinit var adapterRecEpisodes: EpisodeItemAdapter | ||||
|  | ||||
|     private val model: MediaFragmentViewModel by activityViewModels() | ||||
|     private val model: MediaFragmentViewModel by viewModels({requireParentFragment()}) | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentMediaEpisodesBinding.inflate(inflater, container, false) | ||||
| @ -35,15 +35,14 @@ class MediaFragmentEpisodes : Fragment() { | ||||
|         adapterRecEpisodes = EpisodeItemAdapter( | ||||
|             model.currentEpisodesCrunchy, | ||||
|             model.tmdbTVSeason.episodes, | ||||
|             model.currentPlayheads | ||||
|             model.currentPlayheads, | ||||
|             EpisodeItemAdapter.OnClickListener { episode -> | ||||
|                 playEpisode(episode.seasonId, episode.id) | ||||
|             }, | ||||
|             EpisodeItemAdapter.ViewType.MEDIA_FRAGMENT | ||||
|         ) | ||||
|         binding.recyclerEpisodes.adapter = adapterRecEpisodes | ||||
|  | ||||
|         // set onItemClick, adapter is initialized | ||||
|         adapterRecEpisodes.onImageClick = { seasonId, episodeId -> | ||||
|             playEpisode(seasonId, episodeId) | ||||
|         } | ||||
|  | ||||
|         // don't show season selection if only one season is present | ||||
|         if (model.seasonsCrunchy.total < 2) { | ||||
|             binding.buttonSeasonSelection.visibility = View.GONE | ||||
| @ -62,7 +61,9 @@ class MediaFragmentEpisodes : Fragment() { | ||||
|     @SuppressLint("NotifyDataSetChanged") | ||||
|     fun updateWatchedState() { | ||||
|         // model.currentPlayheads is a val mutable map -> notify dataset changed | ||||
|         adapterRecEpisodes.notifyDataSetChanged() | ||||
|         if (this::adapterRecEpisodes.isInitialized) { | ||||
|             adapterRecEpisodes.notifyDataSetChanged() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun showSeasonSelection(v: View) { | ||||
|  | ||||
| @ -1,3 +1,25 @@ | ||||
| /** | ||||
|  * Teapod | ||||
|  * | ||||
|  * Copyright 2020-2022  <seil0@mosad.xyz> | ||||
|  * | ||||
|  * This program is free software; you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation; either version 3 of the License, or | ||||
|  * (at your option) any later version. | ||||
|  * | ||||
|  * This program is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with this program; if not, write to the Free Software | ||||
|  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||||
|  * MA 02110-1301, USA. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| package org.mosad.teapod.ui.activity.main.fragments | ||||
|  | ||||
| import android.os.Bundle | ||||
| @ -5,19 +27,18 @@ import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.activityViewModels | ||||
| import androidx.fragment.app.viewModels | ||||
| import org.mosad.teapod.databinding.FragmentMediaSimilarBinding | ||||
| import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel | ||||
| import org.mosad.teapod.util.adapter.MediaItemAdapter | ||||
| import org.mosad.teapod.util.adapter.MediaItemListAdapter | ||||
| import org.mosad.teapod.util.decoration.MediaItemDecoration | ||||
| import org.mosad.teapod.util.showFragment | ||||
| import org.mosad.teapod.util.toItemMediaList | ||||
|  | ||||
| class MediaFragmentSimilar : Fragment()  { | ||||
|  | ||||
|     private val model: MediaFragmentViewModel by viewModels({requireParentFragment()}) | ||||
|     private lateinit var binding: FragmentMediaSimilarBinding | ||||
|     private val model: MediaFragmentViewModel by activityViewModels() | ||||
|  | ||||
|     private lateinit var adapterSimilar: MediaItemAdapter | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = FragmentMediaSimilarBinding.inflate(inflater, container, false) | ||||
| @ -27,15 +48,14 @@ class MediaFragmentSimilar : Fragment()  { | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         adapterSimilar = MediaItemAdapter(emptyList()) //(model.media.similar) | ||||
|         binding.recyclerMediaSimilar.adapter = adapterSimilar | ||||
|         binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) | ||||
|  | ||||
|         // set onItemClick only in adapter is initialized | ||||
|         if (this::adapterSimilar.isInitialized) { | ||||
|             adapterSimilar.onItemClick = { mediaId, _ -> | ||||
|                 activity?.showFragment(MediaFragment("")) //(mediaId)) | ||||
|         binding.recyclerMediaSimilar.adapter = MediaItemListAdapter( | ||||
|             MediaItemListAdapter.OnClickListener { | ||||
|                 activity?.showFragment(MediaFragment(it.id)) | ||||
|             } | ||||
|         } | ||||
|         ) | ||||
|  | ||||
|         val adapterSimilar = binding.recyclerMediaSimilar.adapter as MediaItemListAdapter | ||||
|         adapterSimilar.submitList(model.similarTo.toItemMediaList()) | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,123 @@ | ||||
| /** | ||||
|  * Teapod | ||||
|  * | ||||
|  * Copyright 2020-2022  <seil0@mosad.xyz> | ||||
|  * | ||||
|  * This program is free software; you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation; either version 3 of the License, or | ||||
|  * (at your option) any later version. | ||||
|  * | ||||
|  * This program is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with this program; if not, write to the Free Software | ||||
|  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||||
|  * MA 02110-1301, USA. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| package org.mosad.teapod.ui.activity.main.viewmodel | ||||
|  | ||||
| import androidx.lifecycle.LifecycleCoroutineScope | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import kotlinx.coroutines.async | ||||
| import kotlinx.coroutines.flow.* | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.parser.crunchyroll.* | ||||
| import kotlin.random.Random | ||||
|  | ||||
| class HomeViewModel : ViewModel()  { | ||||
|  | ||||
|     private val uiState = MutableStateFlow<UiState>(UiState.Loading) | ||||
|  | ||||
|     sealed class UiState { | ||||
|         object Loading : UiState() | ||||
|         data class Normal( | ||||
|             val upNextItems: List<ContinueWatchingItem>, | ||||
|             val watchlistItems: List<Item>, | ||||
|             val recommendationsItems: List<Item>, | ||||
|             val recentlyAddedItems: List<Item>, | ||||
|             val topTenItems: List<Item>, | ||||
|             val highlightItem: Item, | ||||
|             val highlightIsWatchlist:Boolean | ||||
|         ) : UiState() | ||||
|         data class Error(val message: String?) : UiState() | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|         load() | ||||
|     } | ||||
|  | ||||
|     fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) { | ||||
|         scope.launch { uiState.collect { collector(it) } } | ||||
|     } | ||||
|  | ||||
|     fun load() { | ||||
|         viewModelScope.launch { | ||||
|             uiState.emit(UiState.Loading) | ||||
|             try { | ||||
|                 // run the loading in parallel to speed up the process | ||||
|                 val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().items } | ||||
|                 val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(50).items } | ||||
|                 val recommendationsJob = viewModelScope.async { | ||||
|                     Crunchyroll.recommendations(20).items | ||||
|                 } | ||||
|                 val recentlyAddedJob = viewModelScope.async { | ||||
|                     Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50).items | ||||
|                 } | ||||
|                 val topTenJob = viewModelScope.async { | ||||
|                     Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).items | ||||
|                 } | ||||
|  | ||||
|                 val recentlyAddedItems = recentlyAddedJob.await() | ||||
|                 // FIXME crashes on newTitles.items.size == 0 | ||||
|                 val highlightItem = recentlyAddedItems[Random.nextInt(recentlyAddedItems.size)] | ||||
|                 val highlightItemIsWatchlist = Crunchyroll.isWatchlist(highlightItem.id) | ||||
|  | ||||
|                 uiState.emit(UiState.Normal( | ||||
|                     upNextJob.await(), watchlistJob.await(), recommendationsJob.await(), | ||||
|                     recentlyAddedJob.await(), topTenJob.await(), highlightItem, | ||||
|                     highlightItemIsWatchlist | ||||
|                 )) | ||||
|             } catch (e: Exception) { | ||||
|                 uiState.emit(UiState.Error(e.message)) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Toggle the watchlist state of the highlight media. | ||||
|      */ | ||||
|     fun toggleHighlightWatchlist() { | ||||
|         viewModelScope.launch { | ||||
|             uiState.update { currentUiState -> | ||||
|                 if (currentUiState is UiState.Normal) { | ||||
|                     if (currentUiState.highlightIsWatchlist) { | ||||
|                         Crunchyroll.deleteWatchlist(currentUiState.highlightItem.id) | ||||
|                     } else { | ||||
|                         Crunchyroll.postWatchlist(currentUiState.highlightItem.id) | ||||
|                     } | ||||
|  | ||||
|                     // update the watchlist after a item has been added/removed | ||||
|                     val watchlistItems = Crunchyroll.watchlist(50).items | ||||
|  | ||||
|                     currentUiState.copy( | ||||
|                         watchlistItems = watchlistItems, | ||||
|                         highlightIsWatchlist = !currentUiState.highlightIsWatchlist) | ||||
|                 } else { | ||||
|                     currentUiState | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|  | ||||
|  | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -8,7 +8,6 @@ import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.parser.crunchyroll.* | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| import org.mosad.teapod.util.DataTypes.MediaType | ||||
| import org.mosad.teapod.util.metadb.Meta | ||||
| import org.mosad.teapod.util.tmdb.* | ||||
|  | ||||
| /** | ||||
| @ -17,8 +16,6 @@ import org.mosad.teapod.util.tmdb.* | ||||
|  */ | ||||
| class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { | ||||
|  | ||||
| //    var mediaCrunchy = NoneItem | ||||
| //        internal set | ||||
|     var seriesCrunchy = NoneSeries // movies are also series | ||||
|         internal set | ||||
|     var seasonsCrunchy = NoneSeasons | ||||
| @ -34,6 +31,9 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|     var isWatchlist = false | ||||
|         internal set | ||||
|     var upNextSeries = NoneUpNextSeriesItem | ||||
|         internal set | ||||
|     var similarTo = NoneSimilarToResult | ||||
|         internal set | ||||
|  | ||||
|     // TMDB stuff | ||||
|     var mediaType = MediaType.OTHER | ||||
| @ -42,8 +42,6 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         internal set | ||||
|     var tmdbTVSeason: TMDBTVSeason = NoneTMDBTVSeason | ||||
|         internal set | ||||
|     var mediaMeta: Meta? = null | ||||
|         internal set | ||||
|  | ||||
|     /** | ||||
|      * @param crunchyId the crunchyroll series id | ||||
| @ -55,22 +53,17 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|             viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, | ||||
|             viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) }, | ||||
|             viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) }, | ||||
|             viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(crunchyId) } | ||||
|             viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(crunchyId) }, | ||||
|             viewModelScope.launch { similarTo = Crunchyroll.similarTo(crunchyId) } | ||||
|         ).joinAll() | ||||
| //        println("series: $seriesCrunchy") | ||||
| //        println("seasons: $seasonsCrunchy") | ||||
| //        println(upNextSeries) | ||||
|  | ||||
|         // load the preferred season (preferred language, language per season, not per stream) | ||||
|         currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocale) | ||||
|  | ||||
|         // load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes) | ||||
|         listOf( | ||||
|             viewModelScope.launch { episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) }, | ||||
|             viewModelScope.launch { mediaMeta = null }, // TODO metaDB | ||||
|         ).joinAll() | ||||
| //        println("episodes: $episodesCrunchy") | ||||
|         // 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) | ||||
|  | ||||
| @ -103,7 +96,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|             MediaType.TVSHOW -> tmdbApiController.searchTVShow(seriesCrunchy.title) | ||||
|             else -> NoneTMDBSearch | ||||
|         } | ||||
|         println(tmdbSearchResult) | ||||
| //        println(tmdbSearchResult) | ||||
|  | ||||
|         tmdbResult = if (tmdbSearchResult.results.isNotEmpty()) { | ||||
|             when (val result = tmdbSearchResult.results.first()) { | ||||
| @ -112,8 +105,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|                 else -> NoneTMDB | ||||
|             } | ||||
|         } else NoneTMDB | ||||
|  | ||||
|         println(tmdbResult) | ||||
| //        println(tmdbResult) | ||||
|  | ||||
|         // currently not used | ||||
| //        tmdbTVSeason = if (tmdbResult is TMDBTVShow) { | ||||
| @ -139,6 +131,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) | ||||
|         currentEpisodesCrunchy.clear() | ||||
|         currentEpisodesCrunchy.addAll(episodesCrunchy.items) | ||||
|  | ||||
|         // update playheads playheads (including fully watched state) | ||||
|         val episodeIDs = episodesCrunchy.items.map { it.id } | ||||
|         currentPlayheads.clear() | ||||
|         currentPlayheads.putAll(Crunchyroll.playheads(episodeIDs)) | ||||
|     } | ||||
|  | ||||
|     suspend fun setWatchlist() { | ||||
| @ -162,16 +159,4 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * get the next episode based on episodeId | ||||
|      * if no matching is found, use first episode | ||||
|      */ | ||||
|     fun updateNextEpisode(episodeId: Int) { | ||||
|         // TODO reimplement if needed | ||||
| //        if (media.type == MediaType.MOVIE) return // return if movie | ||||
| // | ||||
| //        nextEpisodeId = media.playlist.firstOrNull { it.index > media.getEpisodeById(episodeId).index }?.mediaId | ||||
| //            ?: media.playlist.first().mediaId | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -47,15 +47,17 @@ import com.google.android.exoplayer2.ExoPlayer | ||||
| import com.google.android.exoplayer2.Player | ||||
| import com.google.android.exoplayer2.ui.StyledPlayerControlView | ||||
| import com.google.android.exoplayer2.util.Util | ||||
| import kotlinx.android.synthetic.main.activity_player.* | ||||
| import kotlinx.android.synthetic.main.player_controls.* | ||||
| import kotlinx.coroutines.launch | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.ActivityPlayerBinding | ||||
| import org.mosad.teapod.databinding.PlayerControlsBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.NoneEpisode | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| import org.mosad.teapod.ui.components.EpisodesListPlayer | ||||
| import org.mosad.teapod.ui.components.LanguageSettingsPlayer | ||||
| import org.mosad.teapod.util.* | ||||
| import org.mosad.teapod.ui.activity.player.fragment.EpisodeListDialogFragment | ||||
| import org.mosad.teapod.ui.activity.player.fragment.LanguageSettingsDialogFragment | ||||
| import org.mosad.teapod.util.hideBars | ||||
| import org.mosad.teapod.util.isInPiPMode | ||||
| import org.mosad.teapod.util.navToLauncherTask | ||||
| import java.util.* | ||||
| import java.util.concurrent.TimeUnit | ||||
| import kotlin.concurrent.scheduleAtFixedRate | ||||
| @ -63,6 +65,8 @@ import kotlin.concurrent.scheduleAtFixedRate | ||||
| class PlayerActivity : AppCompatActivity() { | ||||
|  | ||||
|     private val model: PlayerViewModel by viewModels() | ||||
|     private lateinit var playerBinding: ActivityPlayerBinding | ||||
|     private lateinit var controlsBinding: PlayerControlsBinding | ||||
|  | ||||
|     private lateinit var controller: StyledPlayerControlView | ||||
|     private lateinit var gestureDetector: GestureDetectorCompat | ||||
| @ -80,6 +84,11 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         setContentView(R.layout.activity_player) | ||||
|         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( | ||||
|             intent.getStringExtra(getString(R.string.intent_season_id)) ?: "", | ||||
|             intent.getStringExtra(getString(R.string.intent_episode_id)) ?: "" | ||||
| @ -87,7 +96,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         model.currentEpisodeChangedListener.add { onMediaChanged() } | ||||
|         gestureDetector = GestureDetectorCompat(this, PlayerGestureListener()) | ||||
|  | ||||
|         controller = video_view.findViewById(R.id.exo_controller) | ||||
|         controller = playerBinding.videoView.findViewById(R.id.exo_controller) | ||||
|         controller.isAnimationEnabled = false // disable controls (time-bar) animation | ||||
|  | ||||
|         initExoPlayer() // call in onCreate, exoplayer lives in view model | ||||
| @ -104,7 +113,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         super.onStart() | ||||
|         if (Util.SDK_INT > 23) { | ||||
|             initPlayer() | ||||
|             video_view?.onResume() | ||||
|             playerBinding.videoView.onResume() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -114,7 +123,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|  | ||||
|         if (Util.SDK_INT <= 23) { | ||||
|             initPlayer() | ||||
|             video_view?.onResume() | ||||
|             playerBinding.videoView.onResume() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -166,7 +175,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|             } else { | ||||
|                 val width = model.player.videoFormat?.width ?: 0 | ||||
|                 val height = model.player.videoFormat?.height ?: 0 | ||||
|                 val contentFrame: View = video_view.findViewById(R.id.exo_content_frame) | ||||
|                 val contentFrame: View = playerBinding.videoView.findViewById(R.id.exo_content_frame) | ||||
|                 val contentRect = with(contentFrame) { | ||||
|                     val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow) | ||||
|                     Rect(x, y, x + width, y + height) | ||||
| @ -190,7 +199,9 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) | ||||
|  | ||||
|         // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. | ||||
|         video_view.useController = !isInPictureInPictureMode | ||||
|         playerBinding.videoView.useController = !isInPictureInPictureMode | ||||
|  | ||||
|         // TODO also hide language settings/episodes list | ||||
|     } | ||||
|  | ||||
|     private fun initPlayer() { | ||||
| @ -212,17 +223,13 @@ class PlayerActivity : AppCompatActivity() { | ||||
|             override fun onPlaybackStateChanged(state: Int) { | ||||
|                 super.onPlaybackStateChanged(state) | ||||
|  | ||||
|                 loading.visibility = when (state) { | ||||
|                 playerBinding.loading.visibility = when (state) { | ||||
|                     ExoPlayer.STATE_READY -> View.GONE | ||||
|                     ExoPlayer.STATE_BUFFERING -> View.VISIBLE | ||||
|                     else -> View.GONE | ||||
|                 } | ||||
|  | ||||
|                 exo_play_pause.visibility = when (loading.visibility) { | ||||
|                     View.GONE -> View.VISIBLE | ||||
|                     View.VISIBLE -> View.INVISIBLE | ||||
|                     else -> View.VISIBLE | ||||
|                 } | ||||
|                 controlsBinding.exoPlayPause.isVisible = !playerBinding.loading.isVisible | ||||
|  | ||||
|                 if (state == ExoPlayer.STATE_ENDED && hasNextEpisode() && Preferences.autoplay) { | ||||
|                     playNextEpisode() | ||||
| @ -237,10 +244,10 @@ class PlayerActivity : AppCompatActivity() { | ||||
|  | ||||
|     @SuppressLint("ClickableViewAccessibility") | ||||
|     private fun initVideoView() { | ||||
|         video_view.player = model.player | ||||
|         playerBinding.videoView.player = model.player | ||||
|  | ||||
|         // when the player controls get hidden, hide the bars too | ||||
|         video_view.setControllerVisibilityListener { | ||||
|         playerBinding.videoView.setControllerVisibilityListener { | ||||
|             when (it) { | ||||
|                 View.GONE -> { | ||||
|                     hideBars() | ||||
| @ -250,23 +257,23 @@ class PlayerActivity : AppCompatActivity() { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         video_view.setOnTouchListener { _, event -> | ||||
|         playerBinding.videoView.setOnTouchListener { _, event -> | ||||
|             gestureDetector.onTouchEvent(event) | ||||
|             true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun initActions() { | ||||
|         exo_close_player.setOnClickListener { | ||||
|         controlsBinding.exoClosePlayer.setOnClickListener { | ||||
|             this.finish() | ||||
|         } | ||||
|         rwd_10.setOnButtonClickListener { rewind() } | ||||
|         ffwd_10.setOnButtonClickListener { fastForward() } | ||||
|         button_next_ep.setOnClickListener { playNextEpisode() } | ||||
|         button_skip_op.setOnClickListener { skipOpening() } | ||||
|         button_language.setOnClickListener { showLanguageSettings() } | ||||
|         button_episodes.setOnClickListener { showEpisodesList() } | ||||
|         button_next_ep_c.setOnClickListener { playNextEpisode() } | ||||
|         controlsBinding.rwd10.setOnButtonClickListener { rewind() } | ||||
|         controlsBinding.ffwd10.setOnButtonClickListener { fastForward() } | ||||
|         playerBinding.buttonNextEp.setOnClickListener { playNextEpisode() } | ||||
|         playerBinding.buttonSkipOp.setOnClickListener { skipOpening() } | ||||
|         controlsBinding.buttonLanguage.setOnClickListener { showLanguageSettings() } | ||||
|         controlsBinding.buttonEpisodes.setOnClickListener { showEpisodesList() } | ||||
|         controlsBinding.buttonNextEpC.setOnClickListener { playNextEpisode() } | ||||
|     } | ||||
|  | ||||
|     private fun initGUI() { | ||||
| @ -284,7 +291,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         timerUpdates = Timer().scheduleAtFixedRate(0, 500) { | ||||
|             lifecycleScope.launch { | ||||
|                 val currentPosition = model.player.currentPosition | ||||
|                 val btnNextEpIsVisible = button_next_ep.isVisible | ||||
|                 val btnNextEpIsVisible = playerBinding.buttonNextEp.isVisible | ||||
|                 val controlsVisible = controller.isVisible | ||||
|  | ||||
|                 // make sure remaining time is > 0 | ||||
| @ -308,10 +315,12 @@ class PlayerActivity : AppCompatActivity() { | ||||
|                 model.currentEpisodeMeta?.let { | ||||
|                     if (it.openingDuration > 0 && | ||||
|                         currentPosition in it.openingStart..(it.openingStart + 10000) && | ||||
|                         !button_skip_op.isVisible | ||||
|                         !playerBinding.buttonSkipOp.isVisible | ||||
|                     ) { | ||||
|                         showButtonSkipOp() | ||||
|                     } else if (button_skip_op.isVisible && currentPosition !in it.openingStart..(it.openingStart + 10000)) { | ||||
|                     } else if (playerBinding.buttonSkipOp.isVisible && | ||||
|                         currentPosition !in it.openingStart..(it.openingStart + 10000) | ||||
|                     ) { | ||||
|                         // the button should only be visible, if currentEpisodeMeta != null | ||||
|                         hideButtonSkipOp() | ||||
|                     } | ||||
| @ -326,7 +335,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|     } | ||||
|  | ||||
|     private fun onPauseOnStop() { | ||||
|         video_view?.onPause() | ||||
|         playerBinding.videoView.onPause() | ||||
|         model.player.pause() | ||||
|         timerUpdates.cancel() | ||||
|     } | ||||
| @ -341,7 +350,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         val seconds = TimeUnit.MILLISECONDS.toSeconds(remainingTime) % 60 | ||||
|  | ||||
|         // if remaining time is below 60 minutes, don't show hours | ||||
|         exo_remaining.text = if (TimeUnit.MILLISECONDS.toMinutes(remainingTime) < 60) { | ||||
|         controlsBinding.exoRemaining.text = if (TimeUnit.MILLISECONDS.toMinutes(remainingTime) < 60) { | ||||
|             getString(R.string.time_min_sec, minutes, seconds) | ||||
|         } else { | ||||
|             getString(R.string.time_hour_min_sec, hours, minutes, seconds) | ||||
| @ -359,10 +368,10 @@ class PlayerActivity : AppCompatActivity() { | ||||
|             this.finish() | ||||
|         } | ||||
|  | ||||
|         exo_text_title.text = model.getMediaTitle() | ||||
|         controlsBinding.exoTextTitle.text = model.getMediaTitle() | ||||
|  | ||||
|         // hide the next episode button, if there is none | ||||
|         button_next_ep_c.visibility = if (hasNextEpisode()) View.VISIBLE else View.GONE | ||||
|         controlsBinding.buttonNextEpC.isVisible = hasNextEpisode() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -382,36 +391,36 @@ class PlayerActivity : AppCompatActivity() { | ||||
|         model.seekToOffset(rwdTime) | ||||
|  | ||||
|         // hide/show needed components | ||||
|         exo_double_tap_indicator.visibility = View.VISIBLE | ||||
|         ffwd_10_indicator.visibility = View.INVISIBLE | ||||
|         rwd_10.visibility = View.INVISIBLE | ||||
|         playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE | ||||
|         playerBinding.ffwd10Indicator.visibility = View.INVISIBLE | ||||
|         controlsBinding.rwd10.visibility = View.INVISIBLE | ||||
|  | ||||
|         rwd_10_indicator.onAnimationEndCallback = { | ||||
|             exo_double_tap_indicator.visibility = View.GONE | ||||
|             ffwd_10_indicator.visibility = View.VISIBLE | ||||
|             rwd_10.visibility = View.VISIBLE | ||||
|         playerBinding.rwd10Indicator.onAnimationEndCallback = { | ||||
|             playerBinding.exoDoubleTapIndicator.visibility = View.GONE | ||||
|             playerBinding.ffwd10Indicator.visibility = View.VISIBLE | ||||
|             controlsBinding.rwd10.visibility = View.VISIBLE | ||||
|         } | ||||
|  | ||||
|         // run animation | ||||
|         rwd_10_indicator.runOnClickAnimation() | ||||
|         playerBinding.rwd10Indicator.runOnClickAnimation() | ||||
|     } | ||||
|  | ||||
|     private fun fastForward() { | ||||
|         model.seekToOffset(fwdTime) | ||||
|  | ||||
|         // hide/show needed components | ||||
|         exo_double_tap_indicator.visibility = View.VISIBLE | ||||
|         rwd_10_indicator.visibility = View.INVISIBLE | ||||
|         ffwd_10.visibility = View.INVISIBLE | ||||
|         playerBinding.exoDoubleTapIndicator.visibility = View.VISIBLE | ||||
|         playerBinding.rwd10Indicator.visibility = View.INVISIBLE | ||||
|         controlsBinding.ffwd10.visibility = View.INVISIBLE | ||||
|  | ||||
|         ffwd_10_indicator.onAnimationEndCallback = { | ||||
|             exo_double_tap_indicator.visibility = View.GONE | ||||
|             rwd_10_indicator.visibility = View.VISIBLE | ||||
|             ffwd_10.visibility = View.VISIBLE | ||||
|         playerBinding.ffwd10Indicator.onAnimationEndCallback = { | ||||
|             playerBinding.exoDoubleTapIndicator.visibility = View.GONE | ||||
|             playerBinding.rwd10Indicator.visibility = View.VISIBLE | ||||
|             controlsBinding.ffwd10.visibility = View.VISIBLE | ||||
|         } | ||||
|  | ||||
|         // run animation | ||||
|         ffwd_10_indicator.runOnClickAnimation() | ||||
|         playerBinding.ffwd10Indicator.runOnClickAnimation() | ||||
|     } | ||||
|  | ||||
|     private fun playNextEpisode() { | ||||
| @ -432,10 +441,10 @@ class PlayerActivity : AppCompatActivity() { | ||||
|      * TODO improve the show animation | ||||
|      */ | ||||
|     private fun showButtonNextEp() { | ||||
|         button_next_ep.isVisible = true | ||||
|         button_next_ep.alpha = 0.0f | ||||
|         playerBinding.buttonNextEp.isVisible = true | ||||
|         playerBinding.buttonNextEp.alpha = 0.0f | ||||
|  | ||||
|         button_next_ep.animate() | ||||
|         playerBinding.buttonNextEp.animate() | ||||
|             .alpha(1.0f) | ||||
|             .setListener(null) | ||||
|     } | ||||
| @ -445,52 +454,45 @@ class PlayerActivity : AppCompatActivity() { | ||||
|      * TODO improve the hide animation | ||||
|      */ | ||||
|     private fun hideButtonNextEp() { | ||||
|         button_next_ep.animate() | ||||
|         playerBinding.buttonNextEp.animate() | ||||
|             .alpha(0.0f) | ||||
|             .setListener(object : AnimatorListenerAdapter() { | ||||
|                 override fun onAnimationEnd(animation: Animator?) { | ||||
|                     super.onAnimationEnd(animation) | ||||
|                     button_next_ep.isVisible = false | ||||
|                     playerBinding.buttonNextEp.isVisible = false | ||||
|                 } | ||||
|             }) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private fun showButtonSkipOp() { | ||||
|         button_skip_op.isVisible = true | ||||
|         button_skip_op.alpha = 0.0f | ||||
|         playerBinding.buttonSkipOp.isVisible = true | ||||
|         playerBinding.buttonSkipOp.alpha = 0.0f | ||||
|  | ||||
|         button_skip_op.animate() | ||||
|         playerBinding.buttonSkipOp.animate() | ||||
|             .alpha(1.0f) | ||||
|             .setListener(null) | ||||
|     } | ||||
|  | ||||
|     private fun hideButtonSkipOp() { | ||||
|         button_skip_op.animate() | ||||
|         playerBinding.buttonSkipOp.animate() | ||||
|             .alpha(0.0f) | ||||
|             .setListener(object : AnimatorListenerAdapter() { | ||||
|                 override fun onAnimationEnd(animation: Animator?) { | ||||
|                     super.onAnimationEnd(animation) | ||||
|                     button_skip_op.isVisible = false | ||||
|                     playerBinding.buttonSkipOp.isVisible = false | ||||
|                 } | ||||
|             }) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private fun showEpisodesList() { | ||||
|         val episodesList = EpisodesListPlayer(this, model = model).apply { | ||||
|             onViewRemovedAction = { model.player.play() } | ||||
|         } | ||||
|         player_layout.addView(episodesList) | ||||
|         pauseAndHideControls() | ||||
|         EpisodeListDialogFragment().show(supportFragmentManager, EpisodeListDialogFragment.TAG) | ||||
|     } | ||||
|  | ||||
|     private fun showLanguageSettings() { | ||||
|         val languageSettings = LanguageSettingsPlayer(this, model = model).apply { | ||||
|             onViewRemovedAction = { model.player.play() } | ||||
|         } | ||||
|         player_layout.addView(languageSettings) | ||||
|         pauseAndHideControls() | ||||
|         LanguageSettingsDialogFragment().show(supportFragmentManager, LanguageSettingsDialogFragment.TAG) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -520,7 +522,7 @@ class PlayerActivity : AppCompatActivity() { | ||||
|          */ | ||||
|         override fun onDoubleTap(e: MotionEvent?): Boolean { | ||||
|             val eventPosX = e?.x?.toInt() ?: 0 | ||||
|             val viewCenterX = video_view.measuredWidth / 2 | ||||
|             val viewCenterX = playerBinding.videoView.measuredWidth / 2 | ||||
|  | ||||
|             // if the event position is on the left side rewind, if it's on the right forward | ||||
|             if (eventPosX < viewCenterX) rewind() else fastForward() | ||||
|  | ||||
| @ -31,20 +31,13 @@ import androidx.lifecycle.viewModelScope | ||||
| import com.google.android.exoplayer2.ExoPlayer | ||||
| import com.google.android.exoplayer2.MediaItem | ||||
| import com.google.android.exoplayer2.Player | ||||
| import com.google.android.exoplayer2.SimpleExoPlayer | ||||
| import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector | ||||
| import com.google.android.exoplayer2.source.hls.HlsMediaSource | ||||
| import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory | ||||
| import com.google.android.exoplayer2.util.Util | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.joinAll | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.runBlocking | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.parser.crunchyroll.Crunchyroll | ||||
| import org.mosad.teapod.parser.crunchyroll.NoneEpisode | ||||
| import org.mosad.teapod.parser.crunchyroll.NoneEpisodes | ||||
| import org.mosad.teapod.parser.crunchyroll.NonePlayback | ||||
| import org.mosad.teapod.parser.crunchyroll.* | ||||
| import org.mosad.teapod.preferences.Preferences | ||||
| import org.mosad.teapod.util.metadb.EpisodeMeta | ||||
| import org.mosad.teapod.util.metadb.Meta | ||||
| @ -60,8 +53,7 @@ import java.util.* | ||||
| class PlayerViewModel(application: Application) : AndroidViewModel(application) { | ||||
|     private val classTag = javaClass.name | ||||
|  | ||||
|     val player = SimpleExoPlayer.Builder(application).build() | ||||
|     private val dataSourceFactory = DefaultDataSourceFactory(application, Util.getUserAgent(application, "Teapod")) | ||||
|     val player = ExoPlayer.Builder(application).build() | ||||
|     private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION") | ||||
|  | ||||
|     val currentEpisodeChangedListener = ArrayList<() -> Unit>() | ||||
| @ -72,6 +64,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|         internal set | ||||
|     var currentEpisodeMeta: EpisodeMeta? = null | ||||
|         internal set | ||||
|     var currentPlayheads: PlayheadsMap = mutableMapOf() | ||||
|         internal set | ||||
| //    var tmdbTVSeason: TMDBTVSeason? =null | ||||
| //        internal set | ||||
|  | ||||
| @ -126,7 +120,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|  | ||||
|     fun loadMediaAsync(seasonId: String, episodeId: String) = viewModelScope.launch { | ||||
|         episodes = Crunchyroll.episodes(seasonId) | ||||
|         mediaMeta = loadMediaMeta(episodes.items.first().seriesId) | ||||
|  | ||||
|         listOf( | ||||
|             viewModelScope.launch { mediaMeta = loadMediaMeta(episodes.items.first().seriesId) }, | ||||
|             viewModelScope.launch { | ||||
|                 val episodeIDs = episodes.items.map { it.id } | ||||
|                 currentPlayheads = Crunchyroll.playheads(episodeIDs) | ||||
|             } | ||||
|         ).joinAll() | ||||
|  | ||||
|  | ||||
|         Log.d(classTag, "meta: $mediaMeta") | ||||
|  | ||||
| @ -166,11 +168,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|             episode.id == episodeId | ||||
|         } ?: NoneEpisode | ||||
|  | ||||
|         // TODO improve handling of none present seasons/episodes | ||||
|         // update current episode meta | ||||
|         currentEpisodeMeta = if (mediaMeta is TVShowMeta && currentEpisode.episodeNumber != null) { | ||||
|             (mediaMeta as TVShowMeta) | ||||
|                 .seasons[currentEpisode.seasonNumber - 1] | ||||
|                 .episodes[currentEpisode.episodeNumber!! - 1] | ||||
|                 .seasons.getOrNull(currentEpisode.seasonNumber - 1) | ||||
|                 ?.episodes?.getOrNull(currentEpisode.episodeNumber!! - 1) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
| @ -196,7 +199,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|                 } | ||||
|             ) | ||||
|         } | ||||
|         Log.i(classTag, "playback: ${currentEpisode.playback}") | ||||
|         Log.d(classTag, "playback: ${currentEpisode.playback}") | ||||
|  | ||||
|         if (startPlayback) { | ||||
|             playCurrentMedia() | ||||
| @ -226,16 +229,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|                 currentPlayback.streams.adaptive_hls.entries.first().value.url | ||||
|             } | ||||
|         } | ||||
|         Log.d(classTag, "stream url: $url") | ||||
|         Log.i(classTag, "stream url: $url") | ||||
|  | ||||
|         // create the media source object | ||||
|         val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( | ||||
|             MediaItem.fromUri(Uri.parse(url)) | ||||
|         ) | ||||
|  | ||||
|         // the actual player playback code | ||||
|         player.setMediaSource(mediaSource) | ||||
|         // create the media item | ||||
|         val mediaItem = MediaItem.fromUri(Uri.parse(url)) | ||||
|         player.setMediaItem(mediaItem) | ||||
|         player.prepare() | ||||
|  | ||||
|         if (seekPosition > 0) player.seekTo(seekPosition) | ||||
|         player.playWhenReady = true | ||||
|     } | ||||
| @ -279,6 +279,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|             viewModelScope.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) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,68 @@ | ||||
| package org.mosad.teapod.ui.activity.player.fragment | ||||
|  | ||||
| import android.content.DialogInterface | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.fragment.app.DialogFragment | ||||
| import androidx.lifecycle.ViewModelProvider | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.PlayerEpisodesListBinding | ||||
| import org.mosad.teapod.ui.activity.player.PlayerViewModel | ||||
| import org.mosad.teapod.util.adapter.EpisodeItemAdapter | ||||
| import org.mosad.teapod.util.hideBars | ||||
|  | ||||
| class EpisodeListDialogFragment : DialogFragment()  { | ||||
|  | ||||
|     private lateinit var model: PlayerViewModel | ||||
|     private lateinit var binding: PlayerEpisodesListBinding | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "LanguageSettingsDialogFragment" | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle) | ||||
|         model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java] | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = PlayerEpisodesListBinding.inflate(inflater, container, false) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         binding.buttonCloseEpisodesList.setOnClickListener { | ||||
|             dismiss() | ||||
|         } | ||||
|  | ||||
|         val adapterRecEpisodes = EpisodeItemAdapter( | ||||
|             model.episodes.items, | ||||
|             null, | ||||
|             model.currentPlayheads.toMap(), | ||||
|             EpisodeItemAdapter.OnClickListener { episode -> | ||||
|                 dismiss() | ||||
|                 model.setCurrentEpisode(episode.id, startPlayback = true) | ||||
|             }, | ||||
|             EpisodeItemAdapter.ViewType.PLAYER | ||||
|         ) | ||||
|  | ||||
|         // episodeNumber starts at 1, we need the episode index -> - 1 | ||||
|         adapterRecEpisodes.currentSelected = model.currentEpisode.episodeNumber?.minus(1) ?: 0 | ||||
|  | ||||
|         binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes | ||||
|         binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected) | ||||
|  | ||||
|         // initially hide the status and navigation bar | ||||
|         hideBars(requireDialog().window, binding.root) | ||||
|     } | ||||
|  | ||||
|     override fun onDismiss(dialog: DialogInterface) { | ||||
|         super.onDismiss(dialog) | ||||
|         model.player.play() | ||||
|     } | ||||
| } | ||||
| @ -1,54 +1,75 @@ | ||||
| package org.mosad.teapod.ui.components | ||||
| package org.mosad.teapod.ui.activity.player.fragment | ||||
| 
 | ||||
| import android.content.Context | ||||
| import android.content.DialogInterface | ||||
| import android.graphics.Color | ||||
| import android.graphics.Typeface | ||||
| import android.util.AttributeSet | ||||
| import android.os.Bundle | ||||
| import android.util.TypedValue | ||||
| import android.view.Gravity | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.LinearLayout | ||||
| import android.widget.TextView | ||||
| import androidx.core.view.children | ||||
| import androidx.fragment.app.DialogFragment | ||||
| import androidx.lifecycle.ViewModelProvider | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.PlayerLanguageSettingsBinding | ||||
| import org.mosad.teapod.ui.activity.player.PlayerViewModel | ||||
| import org.mosad.teapod.util.hideBars | ||||
| import java.util.* | ||||
| 
 | ||||
| // TODO port to DialogFragment | ||||
| class LanguageSettingsPlayer @JvmOverloads constructor( | ||||
|     context: Context, | ||||
|     attrs: AttributeSet? = null, | ||||
|     defStyleAttr: Int = 0, | ||||
|     model: PlayerViewModel? = null | ||||
| ) : LinearLayout(context, attrs, defStyleAttr) { | ||||
| class LanguageSettingsDialogFragment : DialogFragment() { | ||||
| 
 | ||||
|     private val binding = PlayerLanguageSettingsBinding.inflate(LayoutInflater.from(context), this, true) | ||||
|     var onViewRemovedAction: (() -> Unit)? = null | ||||
|     private lateinit var model: PlayerViewModel | ||||
|     private lateinit var binding: PlayerLanguageSettingsBinding | ||||
| 
 | ||||
|     private var selectedLocale = model?.currentLanguage ?: Locale.ROOT | ||||
|     private var selectedLocale = Locale.ROOT | ||||
| 
 | ||||
|     init { | ||||
|         model?.let { m -> | ||||
|             m.currentPlayback.streams.adaptive_hls.keys.forEach { languageTag -> | ||||
|                 val locale = Locale.forLanguageTag(languageTag) | ||||
|                 addLanguage(locale, locale == m.currentLanguage) { v -> | ||||
|                     selectedLocale = locale | ||||
|                     updateSelectedLanguage(v as TextView) | ||||
|                 } | ||||
|     companion object { | ||||
|         const val TAG = "LanguageSettingsDialogFragment" | ||||
|     } | ||||
| 
 | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle) | ||||
|         model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java] | ||||
|         selectedLocale = model.currentLanguage | ||||
|     } | ||||
| 
 | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { | ||||
|         binding = PlayerLanguageSettingsBinding.inflate(inflater, container, false) | ||||
|         return binding.root | ||||
|     } | ||||
| 
 | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
| 
 | ||||
|         model.currentPlayback.streams.adaptive_hls.keys.forEach { languageTag -> | ||||
|             val locale = Locale.forLanguageTag(languageTag) | ||||
|             addLanguage(locale, locale == model.currentLanguage) { v -> | ||||
|                 selectedLocale = locale | ||||
|                 updateSelectedLanguage(v as TextView) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         binding.buttonCloseLanguageSettings.setOnClickListener { close() } | ||||
|         binding.buttonCancel.setOnClickListener { close() } | ||||
|         binding.buttonCloseLanguageSettings.setOnClickListener { dismiss() } | ||||
|         binding.buttonCancel.setOnClickListener { dismiss() } | ||||
|         binding.buttonSelect.setOnClickListener { | ||||
|             model?.setLanguage(selectedLocale) | ||||
|             close() | ||||
|             model.setLanguage(selectedLocale) | ||||
|             dismiss() | ||||
|         } | ||||
| 
 | ||||
|         // initially hide the status and navigation bar | ||||
|         hideBars(requireDialog().window, binding.root) | ||||
|     } | ||||
| 
 | ||||
|     private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: OnClickListener) { | ||||
|     override fun onDismiss(dialog: DialogInterface) { | ||||
|         super.onDismiss(dialog) | ||||
|         model.player.play() | ||||
|     } | ||||
| 
 | ||||
|     private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: View.OnClickListener) { | ||||
|         val text = TextView(context).apply { | ||||
|             height = 96 | ||||
|             gravity = Gravity.CENTER_VERTICAL | ||||
| @ -56,13 +77,13 @@ class LanguageSettingsPlayer @JvmOverloads constructor( | ||||
|             setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) | ||||
| 
 | ||||
|             if (isSelected) { | ||||
|                 setTextColor(context.resources.getColor(R.color.exo_white, context.theme)) | ||||
|                 setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme)) | ||||
|                 setTypeface(null, Typeface.BOLD) | ||||
|                 setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0) | ||||
|                 compoundDrawablesRelative.getOrNull(0)?.setTint(Color.WHITE) | ||||
|                 compoundDrawablePadding = 12 | ||||
|             } else { | ||||
|                 setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme)) | ||||
|                 setTextColor(context.resources.getColor(R.color.textSecondaryDark, context.theme)) | ||||
|                 setPadding(75, 0, 0, 0) | ||||
|             } | ||||
| 
 | ||||
| @ -83,12 +104,11 @@ class LanguageSettingsPlayer @JvmOverloads constructor( | ||||
|                     setPadding(75, 0, 0, 0) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         // set selected to selected style | ||||
|         selected.apply { | ||||
|             setTextColor(context.resources.getColor(R.color.exo_white, context.theme)) | ||||
|             setTextColor(context.resources.getColor(R.color.player_white, context.theme)) | ||||
|             setTypeface(null, Typeface.BOLD) | ||||
|             setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0) | ||||
|             setPadding(0, 0, 0, 0) | ||||
| @ -96,10 +116,4 @@ class LanguageSettingsPlayer @JvmOverloads constructor( | ||||
|             compoundDrawablePadding = 12 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun close() { | ||||
|         (this.parent as ViewGroup).removeView(this) | ||||
|         onViewRemovedAction?.invoke() | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| } | ||||
| @ -1,44 +0,0 @@ | ||||
| package org.mosad.teapod.ui.components | ||||
|  | ||||
| import android.content.Context | ||||
| import android.util.AttributeSet | ||||
| import android.view.LayoutInflater | ||||
| import android.view.ViewGroup | ||||
| import android.widget.LinearLayout | ||||
| import org.mosad.teapod.databinding.PlayerEpisodesListBinding | ||||
| import org.mosad.teapod.ui.activity.player.PlayerViewModel | ||||
| import org.mosad.teapod.util.adapter.PlayerEpisodeItemAdapter | ||||
|  | ||||
| class EpisodesListPlayer @JvmOverloads constructor( | ||||
|     context: Context, | ||||
|     attrs: AttributeSet? = null, | ||||
|     defStyleAttr: Int = 0, | ||||
|     model: PlayerViewModel? = null | ||||
| ) : LinearLayout(context, attrs, defStyleAttr) { | ||||
|  | ||||
|     private val binding = PlayerEpisodesListBinding.inflate(LayoutInflater.from(context), this, true) | ||||
|     private lateinit var adapterRecEpisodes: PlayerEpisodeItemAdapter | ||||
|  | ||||
|     var onViewRemovedAction: (() -> Unit)? = null // TODO find a better solution for this | ||||
|  | ||||
|     init { | ||||
|         binding.buttonCloseEpisodesList.setOnClickListener { | ||||
|             (this.parent as ViewGroup).removeView(this) | ||||
|             onViewRemovedAction?.invoke() | ||||
|         } | ||||
|  | ||||
|         model?.let { | ||||
|             adapterRecEpisodes = PlayerEpisodeItemAdapter(model.episodes) | ||||
|             adapterRecEpisodes.onImageClick = {_, episodeId -> | ||||
|                 (this.parent as ViewGroup).removeView(this) | ||||
|                 model.setCurrentEpisode(episodeId, startPlayback = true) | ||||
|             } | ||||
|             // episodeNumber starts at 1, we need the episode index -> - 1 | ||||
|             adapterRecEpisodes.currentSelected = model.currentEpisode.episodeNumber?.minus(1) ?: 0 | ||||
|  | ||||
|             binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes | ||||
|             binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -1,94 +0,0 @@ | ||||
| /** | ||||
|  * ProjectLaogai | ||||
|  * | ||||
|  * Copyright 2019-2020  <seil0@mosad.xyz> | ||||
|  * | ||||
|  * This program is free software; you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU General Public License as published by | ||||
|  * the Free Software Foundation; either version 3 of the License, or | ||||
|  * (at your option) any later version. | ||||
|  * | ||||
|  * This program is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|  * GNU General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU General Public License | ||||
|  * along with this program; if not, write to the Free Software | ||||
|  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||||
|  * MA 02110-1301, USA. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| package org.mosad.teapod.ui.components | ||||
|  | ||||
| import android.content.Context | ||||
| import android.widget.EditText | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.afollestad.materialdialogs.bottomsheets.BottomSheet | ||||
| import com.afollestad.materialdialogs.bottomsheets.setPeekHeight | ||||
| import com.afollestad.materialdialogs.customview.customView | ||||
| import com.afollestad.materialdialogs.customview.getCustomView | ||||
| import org.mosad.teapod.R | ||||
|  | ||||
| // TODO rework and port away from MaterialDialog | ||||
| class LoginDialog(val context: Context, firstTry: Boolean) { | ||||
|  | ||||
|     private val dialog = MaterialDialog(context, BottomSheet()) | ||||
|  | ||||
|     private val editTextLogin: EditText | ||||
|     private val editTextPassword: EditText | ||||
|  | ||||
|     var login = "" | ||||
|     var password = "" | ||||
|  | ||||
|     init { | ||||
|         dialog.title(R.string.login) | ||||
|             .message(if (firstTry) R.string.login_desc else R.string.login_failed_desc) | ||||
|             .customView(R.layout.dialog_login) | ||||
|             .positiveButton(R.string.save) | ||||
|             .negativeButton(R.string.cancel) | ||||
|             .setPeekHeight(900) | ||||
|  | ||||
|         editTextLogin = dialog.getCustomView().findViewById(R.id.edit_text_login) | ||||
|         editTextPassword = dialog.getCustomView().findViewById(R.id.edit_text_password) | ||||
|  | ||||
|         // fix not working accent color | ||||
|         //dialog.getActionButton(WhichButton.POSITIVE).updateTextColor(Preferences.colorAccent) | ||||
|         //dialog.getActionButton(WhichButton.NEGATIVE).updateTextColor(Preferences.colorAccent) | ||||
|     } | ||||
|  | ||||
|     fun positiveButton(func: LoginDialog.() -> Unit): LoginDialog = apply { | ||||
|         dialog.positiveButton { | ||||
|             login = editTextLogin.text.toString() | ||||
|             password = editTextPassword.text.toString() | ||||
|  | ||||
|             func() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun negativeButton(func: LoginDialog.() -> Unit): LoginDialog = apply { | ||||
|         dialog.negativeButton { | ||||
|             func() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun show() { | ||||
|         dialog.show() | ||||
|     } | ||||
|  | ||||
|     fun show(func: LoginDialog.() -> Unit): LoginDialog = apply { | ||||
|         func() | ||||
|  | ||||
|         editTextLogin.setText(login) | ||||
|         editTextPassword.setText(password) | ||||
|  | ||||
|         show() | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     fun dismiss() { | ||||
|         dialog.dismiss() | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -0,0 +1,54 @@ | ||||
| package org.mosad.teapod.ui.components | ||||
|  | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.google.android.material.bottomsheet.BottomSheetDialogFragment | ||||
| import org.mosad.teapod.databinding.ModalBottomSheetLoginBinding | ||||
|  | ||||
| /** | ||||
|  * A bottom sheet with login credential input fields. | ||||
|  * | ||||
|  * To initialize login or password values, use apply. | ||||
|  */ | ||||
| class LoginModalBottomSheet : BottomSheetDialogFragment() { | ||||
|  | ||||
|     private lateinit var binding: ModalBottomSheetLoginBinding | ||||
|  | ||||
|     var login = "" | ||||
|     var password = "" | ||||
|  | ||||
|     lateinit var positiveAction: LoginModalBottomSheet.() -> Unit | ||||
|     lateinit var negativeAction: LoginModalBottomSheet.() -> Unit | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "LoginModalBottomSheet" | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         binding = ModalBottomSheetLoginBinding.inflate(inflater, container, false) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|  | ||||
|         binding.editTextLogin.setText(login) | ||||
|         binding.editTextPassword.setText(password) | ||||
|  | ||||
|         binding.positiveButton.setOnClickListener { | ||||
|             login = binding.editTextLogin.text.toString() | ||||
|             password = binding.editTextPassword.text.toString() | ||||
|  | ||||
|             positiveAction.invoke(this) | ||||
|         } | ||||
|         binding.negativeButton.setOnClickListener { | ||||
|             negativeAction.invoke(this) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -5,9 +5,6 @@ import android.app.ActivityManager | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.os.Build | ||||
| import android.view.View | ||||
| import android.view.WindowInsets | ||||
| import android.view.WindowInsetsController | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.FragmentActivity | ||||
| import androidx.fragment.app.commit | ||||
| @ -31,23 +28,7 @@ fun FragmentActivity.showFragment(fragment: Fragment) { | ||||
|  * hide the status and navigation bar | ||||
|  */ | ||||
| fun Activity.hideBars() { | ||||
|     window.apply { | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { | ||||
|             setDecorFitsSystemWindows(false) | ||||
|             insetsController?.apply { | ||||
|                 hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) | ||||
|                 systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE | ||||
|             } | ||||
|         } else { | ||||
|             @Suppress("deprecation") | ||||
|             decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE | ||||
|                     or View.SYSTEM_UI_FLAG_LAYOUT_STABLE | ||||
|                     or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | ||||
|                     or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | ||||
|                     or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | ||||
|                     or View.SYSTEM_UI_FLAG_FULLSCREEN) | ||||
|         } | ||||
|     } | ||||
|     hideBars(window, window.decorView.rootView) | ||||
| } | ||||
|  | ||||
| fun Activity.isInPiPMode(): Boolean { | ||||
|  | ||||
| @ -1,6 +1,11 @@ | ||||
| package org.mosad.teapod.util | ||||
|  | ||||
| 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 org.mosad.teapod.parser.crunchyroll.Collection | ||||
| import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem | ||||
| import org.mosad.teapod.parser.crunchyroll.Item | ||||
| @ -21,6 +26,13 @@ fun Collection<Item>.toItemMediaList(): List<ItemMedia> { | ||||
|     } | ||||
| } | ||||
|  | ||||
| @JvmName("toItemMediaListItem") | ||||
| fun List<Item>.toItemMediaList(): List<ItemMedia> { | ||||
|     return this.map { | ||||
|         ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @JvmName("toItemMediaListContinueWatchingItem") | ||||
| fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> { | ||||
|     return items.map { | ||||
| @ -43,3 +55,13 @@ fun Locale.toDisplayString(fallback: String): String { | ||||
|         fallback | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun hideBars(window: Window?, root: View) { | ||||
|     if (window != null) { | ||||
|         WindowCompat.setDecorFitsSystemWindows(window, false) | ||||
|         WindowInsetsControllerCompat(window, root).let { controller -> | ||||
|             controller.hide(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.navigationBars()) | ||||
|             controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -4,6 +4,7 @@ import android.graphics.Color | ||||
| import android.graphics.drawable.ColorDrawable | ||||
| import android.graphics.drawable.Drawable | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| @ -12,84 +13,167 @@ import com.bumptech.glide.request.RequestOptions | ||||
| import jp.wasabeef.glide.transformations.RoundedCornersTransformation | ||||
| import org.mosad.teapod.R | ||||
| 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 | ||||
| ) : RecyclerView.Adapter<EpisodeItemAdapter.EpisodeViewHolder>() { | ||||
|     private val playheads: PlayheadsMap, | ||||
|     private val onClickListener: OnClickListener, | ||||
|     private val viewType: ViewType | ||||
| ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { | ||||
|  | ||||
|     var onImageClick: ((seasonId: String, episodeId: String) -> Unit)? = null | ||||
|     var currentSelected: Int = -1 // -1, since position should never be < 0 | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { | ||||
|         return EpisodeViewHolder(ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false)) | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { | ||||
|         return when (viewType) { | ||||
|             ViewType.PLAYER.ordinal -> { | ||||
|                 PlayerEpisodeViewHolder((ItemEpisodePlayerBinding.inflate(LayoutInflater.from(parent.context), parent, false))) | ||||
|             } | ||||
|             else -> { | ||||
|                 // media fragment episode list is default | ||||
|                 EpisodeViewHolder(ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false)) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { | ||||
|         val context = holder.binding.root.context | ||||
|         val ep = episodes[position] | ||||
|     override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { | ||||
|         val episode = episodes[position] | ||||
|         val playhead = playheads[episode.id] | ||||
|         val tmdbEpisode = tmdbEpisodes?.getOrNull(position) | ||||
|  | ||||
|         val titleText = if (ep.episodeNumber != null) { | ||||
|             // for tv shows add ep prefix and episode number | ||||
|             if (ep.isDubbed) { | ||||
|                 context.getString(R.string.component_episode_title, ep.episode, ep.title) | ||||
|             } else { | ||||
|                 context.getString(R.string.component_episode_title_sub, ep.episode, ep.title) | ||||
|         when (holder.itemViewType) { | ||||
|             ViewType.MEDIA_FRAGMENT.ordinal -> { | ||||
|                 (holder as EpisodeViewHolder).bind(episode, playhead, tmdbEpisode) | ||||
|             } | ||||
|             ViewType.PLAYER.ordinal -> { | ||||
|                 (holder as PlayerEpisodeViewHolder).bind(episode, playhead, currentSelected) | ||||
|             } | ||||
|         } else { | ||||
|             ep.title | ||||
|         } | ||||
|     } | ||||
|  | ||||
|         holder.binding.textEpisodeTitle.text = titleText | ||||
|         holder.binding.textEpisodeDesc.text = if (ep.description.isNotEmpty()) { | ||||
|             ep.description | ||||
|         } else if (tmdbEpisodes != null && position < tmdbEpisodes.size){ | ||||
|             tmdbEpisodes[position].overview | ||||
|         } else { | ||||
|             "" | ||||
|     override fun getItemViewType(position: Int): Int { | ||||
|         return when (viewType) { | ||||
|             ViewType.MEDIA_FRAGMENT -> ViewType.MEDIA_FRAGMENT.ordinal | ||||
|             ViewType.PLAYER -> ViewType.PLAYER.ordinal | ||||
|         } | ||||
|  | ||||
|         // TODO is isNotEmpty() needed? also in PlayerEpisodeItemAdapter | ||||
|         if (ep.images.thumbnail[0][0].source.isNotEmpty()) { | ||||
|             Glide.with(context).load(ep.images.thumbnail[0][0].source) | ||||
|                 .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) | ||||
|                 .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) | ||||
|                 .into(holder.binding.imageEpisode) | ||||
|         } | ||||
|  | ||||
|         // add watched icon to episode, if the episode id is present in playheads and fullyWatched | ||||
|         val watchedImage: Drawable? = if (playheads[ep.id]?.fullyWatched == true) { | ||||
|             ContextCompat.getDrawable(context, R.drawable.ic_baseline_check_circle_24) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|         holder.binding.imageWatched.setImageDrawable(watchedImage) | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int { | ||||
|         return episodes.size | ||||
|     } | ||||
|  | ||||
|     fun updateWatchedState(watched: Boolean, position: Int) { | ||||
|         // use getOrNull as there could be a index out of bound when running this in onResume() | ||||
|  | ||||
|         // TODO | ||||
|         //episodes.getOrNull(position)?.watched = watched | ||||
|     } | ||||
|  | ||||
|     inner class EpisodeViewHolder(val binding: ItemEpisodeBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|         init { | ||||
|             // on image click return the episode id and index (within the adapter) | ||||
|  | ||||
|         fun bind(episode: Episode, playhead: PlayheadObject?, tmdbEpisode: TMDBTVEpisode?) { | ||||
|             val context = binding.root.context | ||||
|  | ||||
|             val titleText = if (episode.episodeNumber != null) { | ||||
|                 // for tv shows add ep prefix and episode number | ||||
|                 if (episode.isDubbed) { | ||||
|                     context.getString(R.string.component_episode_title, episode.episode, episode.title) | ||||
|                 } else { | ||||
|                     context.getString(R.string.component_episode_title_sub, episode.episode, episode.title) | ||||
|                 } | ||||
|             } else { | ||||
|                 episode.title | ||||
|             } | ||||
|  | ||||
|             binding.textEpisodeTitle.text = titleText | ||||
|             binding.textEpisodeDesc.text = episode.description.ifEmpty { | ||||
|                 tmdbEpisode?.overview ?: "" | ||||
|             } | ||||
|  | ||||
|             if (episode.images.thumbnail[0][0].source.isNotEmpty()) { | ||||
|                 Glide.with(context).load(episode.images.thumbnail[0][0].source) | ||||
|                     .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) | ||||
|                     .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) | ||||
|                     .into(binding.imageEpisode) | ||||
|             } | ||||
|  | ||||
|             // add watched progress | ||||
|             val playheadProgress = playhead?.playhead?.let { | ||||
|                 ((it.toFloat() / (episode.durationMs / 1000)) * 100).toInt() | ||||
|             } ?: 0 | ||||
|             binding.progressPlayhead.setProgressCompat(playheadProgress, false) | ||||
|             binding.progressPlayhead.visibility = if (playheadProgress <= 0) | ||||
|                 View.GONE  else View.VISIBLE | ||||
|  | ||||
|             // add watched icon to episode, if the episode id is present in playheads and fullyWatched | ||||
|             val watchedImage: Drawable? = if (playhead?.fullyWatched == true) { | ||||
|                 ContextCompat.getDrawable(context, R.drawable.ic_baseline_check_circle_24) | ||||
|             } else { | ||||
|                 null | ||||
|             } | ||||
|             binding.imageWatched.setImageDrawable(watchedImage) | ||||
|  | ||||
|             binding.imageEpisode.setOnClickListener { | ||||
|                 onImageClick?.invoke( | ||||
|                     episodes[bindingAdapterPosition].seasonId, | ||||
|                     episodes[bindingAdapterPosition].id | ||||
|                 ) | ||||
|                 onClickListener.onClick(episode) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     inner class PlayerEpisodeViewHolder(val binding: ItemEpisodePlayerBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|  | ||||
|         // -1, since position should never be < 0 | ||||
|         fun bind(episode: Episode, playhead: PlayheadObject?, currentSelected: Int) { | ||||
|             val context = binding.root.context | ||||
|  | ||||
|             val titleText = if (episode.episodeNumber != null) { | ||||
|                 // for tv shows add ep prefix and episode number | ||||
|                 if (episode.isDubbed) { | ||||
|                     context.getString(R.string.component_episode_title, episode.episode, episode.title) | ||||
|                 } else { | ||||
|                     context.getString(R.string.component_episode_title_sub, episode.episode, episode.title) | ||||
|                 } | ||||
|             } else { | ||||
|                 episode.title | ||||
|             } | ||||
|  | ||||
|             binding.textEpisodeTitle2.text = titleText | ||||
|             binding.textEpisodeDesc2.text = episode.description.ifEmpty { "" } | ||||
|  | ||||
|             if (episode.images.thumbnail[0][0].source.isNotEmpty()) { | ||||
|                 Glide.with(context).load(episode.images.thumbnail[0][0].source) | ||||
|                     .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) | ||||
|                     .into(binding.imageEpisode) | ||||
|             } | ||||
|  | ||||
|             // add watched progress | ||||
|             val playheadProgress = playhead?.playhead?.let { | ||||
|                 ((it.toFloat() / (episode.durationMs / 1000)) * 100).toInt() | ||||
|             } ?: 0 | ||||
|             binding.progressPlayhead.setProgressCompat(playheadProgress, false) | ||||
|             binding.progressPlayhead.visibility = if (playheadProgress <= 0) | ||||
|                 View.GONE  else View.VISIBLE | ||||
|  | ||||
|             // hide the play icon, if it's the current episode | ||||
|             binding.imageEpisodePlay.visibility = if (currentSelected == bindingAdapterPosition) { | ||||
|                 View.GONE | ||||
|             } else { | ||||
|                 View.VISIBLE | ||||
|             } | ||||
|  | ||||
|             if (currentSelected != bindingAdapterPosition) { | ||||
|                 binding.imageEpisode.setOnClickListener { | ||||
|                     onClickListener.onClick(episode) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class OnClickListener(val clickListener: (episode: Episode) -> Unit) { | ||||
|         fun onClick(episode: Episode) = clickListener(episode) | ||||
|     } | ||||
|  | ||||
|     enum class ViewType { | ||||
|         MEDIA_FRAGMENT, | ||||
|         PLAYER | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,70 @@ | ||||
| package org.mosad.teapod.util.adapter | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.recyclerview.widget.DiffUtil | ||||
| import androidx.recyclerview.widget.ListAdapter | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.bumptech.glide.Glide | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.ItemMediaBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem | ||||
|  | ||||
| class MediaEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter<ContinueWatchingItem, MediaEpisodeListAdapter.MediaViewHolder>(DiffCallback) { | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { | ||||
|         return MediaViewHolder( | ||||
|             ItemMediaBinding.inflate( | ||||
|                 LayoutInflater.from(parent.context), | ||||
|                 parent, | ||||
|                 false | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { | ||||
|         val item = getItem(position) | ||||
|         holder.binding.root.setOnClickListener { | ||||
|             onClickListener.onClick(item) | ||||
|         } | ||||
|         holder.bind(item) | ||||
|     } | ||||
|  | ||||
|     inner class MediaViewHolder(val binding: ItemMediaBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|  | ||||
|         fun bind(item: ContinueWatchingItem) { | ||||
|             val metadata = item.panel.episodeMetadata | ||||
|  | ||||
|             binding.textTitle.text = binding.root.context.getString(R.string.season_episode_title, | ||||
|                 metadata.seasonNumber, metadata.episodeNumber, metadata.seriesTitle | ||||
|             ) | ||||
|  | ||||
|             Glide.with(binding.imagePoster) | ||||
|                 .load(item.panel.images.thumbnail[0][0].source) | ||||
|                 .into(binding.imagePoster) | ||||
|  | ||||
|             // add watched progress | ||||
|             val playheadProgress = ((item.playhead.toFloat() / (metadata.durationMs / 1000)) * 100) | ||||
|                 .toInt() | ||||
|             binding.progressPlayhead.setProgressCompat(playheadProgress, false) | ||||
|             binding.progressPlayhead.visibility = if (playheadProgress <= 0) | ||||
|                 View.GONE else View.VISIBLE | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object DiffCallback : DiffUtil.ItemCallback<ContinueWatchingItem>() { | ||||
|         override fun areItemsTheSame(oldItem: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean { | ||||
|             return oldItem.panel.id == newItem.panel.id | ||||
|         } | ||||
|  | ||||
|         override fun areContentsTheSame(oldItem: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean { | ||||
|             return oldItem == newItem | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class OnClickListener(val clickListener: (item: ContinueWatchingItem) -> Unit) { | ||||
|         fun onClick(item: ContinueWatchingItem) = clickListener(item) | ||||
|     } | ||||
| } | ||||
| @ -2,11 +2,13 @@ package org.mosad.teapod.util.adapter | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.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 | ||||
| @ -29,6 +31,7 @@ class MediaItemAdapter(private val items: List<ItemMedia>) : RecyclerView.Adapte | ||||
|     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, | ||||
|  | ||||
| @ -0,0 +1,61 @@ | ||||
| package org.mosad.teapod.util.adapter | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.ViewGroup | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.recyclerview.widget.DiffUtil | ||||
| import androidx.recyclerview.widget.ListAdapter | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.bumptech.glide.Glide | ||||
| import org.mosad.teapod.databinding.ItemMediaBinding | ||||
| import org.mosad.teapod.util.ItemMedia | ||||
|  | ||||
| class MediaItemListAdapter(private val onClickListener: OnClickListener) : ListAdapter<ItemMedia, MediaItemListAdapter.MediaViewHolder>(DiffCallback) { | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { | ||||
|         return MediaViewHolder( | ||||
|             ItemMediaBinding.inflate( | ||||
|                 LayoutInflater.from(parent.context), | ||||
|                 parent, | ||||
|                 false | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { | ||||
|         val item = getItem(position) | ||||
|         holder.binding.root.setOnClickListener { | ||||
|             onClickListener.onClick(item) | ||||
|         } | ||||
|         holder.bind(item) | ||||
|     } | ||||
|  | ||||
|     inner class MediaViewHolder(val binding: ItemMediaBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|  | ||||
|         fun bind(item: ItemMedia) { | ||||
|             binding.textTitle.text = item.title | ||||
|  | ||||
|             Glide.with(binding.imagePoster) | ||||
|                 .load(item.posterUrl) | ||||
|                 .into(binding.imagePoster) | ||||
|  | ||||
|             binding.imageEpisodePlay.isVisible = false | ||||
|             binding.progressPlayhead.isVisible = false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object DiffCallback : DiffUtil.ItemCallback<ItemMedia>() { | ||||
|         override fun areItemsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean { | ||||
|             return oldItem.id == newItem.id | ||||
|         } | ||||
|  | ||||
|         override fun areContentsTheSame(oldItem: ItemMedia, newItem: ItemMedia): Boolean { | ||||
|             return oldItem == newItem | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class OnClickListener(val clickListener: (item: ItemMedia) -> Unit) { | ||||
|         fun onClick(item: ItemMedia) = clickListener(item) | ||||
|     } | ||||
| } | ||||
| @ -1,77 +0,0 @@ | ||||
| package org.mosad.teapod.util.adapter | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.bumptech.glide.Glide | ||||
| import com.bumptech.glide.request.RequestOptions | ||||
| import jp.wasabeef.glide.transformations.RoundedCornersTransformation | ||||
| import org.mosad.teapod.R | ||||
| import org.mosad.teapod.databinding.ItemEpisodePlayerBinding | ||||
| import org.mosad.teapod.parser.crunchyroll.Episodes | ||||
| import org.mosad.teapod.util.tmdb.TMDBTVEpisode | ||||
|  | ||||
| class PlayerEpisodeItemAdapter(private val episodes: Episodes) : RecyclerView.Adapter<PlayerEpisodeItemAdapter.EpisodeViewHolder>() { | ||||
|  | ||||
|     var onImageClick: ((seasonId: String, episodeId: String) -> Unit)? = null | ||||
|     var currentSelected: Int = -1 // -1, since position should never be < 0 | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder { | ||||
|         return EpisodeViewHolder(ItemEpisodePlayerBinding.inflate(LayoutInflater.from(parent.context), parent, false)) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { | ||||
|         val context = holder.binding.root.context | ||||
|         val ep = episodes.items[position] | ||||
|  | ||||
|         val titleText = if (ep.episodeNumber != null) { | ||||
|             // for tv shows add ep prefix and episode number | ||||
|             if (ep.isDubbed) { | ||||
|                 context.getString(R.string.component_episode_title, ep.episode, ep.title) | ||||
|             } else { | ||||
|                 context.getString(R.string.component_episode_title_sub, ep.episode, ep.title) | ||||
|             } | ||||
|         } else { | ||||
|             ep.title | ||||
|         } | ||||
|  | ||||
|         holder.binding.textEpisodeTitle2.text = titleText | ||||
|         holder.binding.textEpisodeDesc2.text = if (ep.description.isNotEmpty()) { | ||||
|             ep.description | ||||
|         } else { | ||||
|             "" | ||||
|         } | ||||
|  | ||||
|         if (ep.images.thumbnail[0][0].source.isNotEmpty()) { | ||||
|             Glide.with(context).load(ep.images.thumbnail[0][0].source) | ||||
|                 .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) | ||||
|                 .into(holder.binding.imageEpisode) | ||||
|         } | ||||
|  | ||||
|         // hide the play icon, if it's the current episode | ||||
|         holder.binding.imageEpisodePlay.visibility = if (currentSelected == position) { | ||||
|             View.GONE | ||||
|         } else { | ||||
|             View.VISIBLE | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int { | ||||
|         return episodes.items.size | ||||
|     } | ||||
|  | ||||
|     inner class EpisodeViewHolder(val binding: ItemEpisodePlayerBinding) : RecyclerView.ViewHolder(binding.root) { | ||||
|         init { | ||||
|             binding.imageEpisode.setOnClickListener { | ||||
|                 // don't execute, if it's the current episode | ||||
|                 if (currentSelected != bindingAdapterPosition) { | ||||
|                     onImageClick?.invoke( | ||||
|                         episodes.items[bindingAdapterPosition].seasonId, | ||||
|                         episodes.items[bindingAdapterPosition].id | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -2,7 +2,7 @@ | ||||
| <FrameLayout 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:id="@+id/player_layout" | ||||
|     android:id="@+id/player_root" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:background="#000000" | ||||
| @ -24,7 +24,7 @@ | ||||
|         android:layout_height="70dp" | ||||
|         android:layout_gravity="center" | ||||
|         android:indeterminate="true" | ||||
|         app:indicatorColor="@color/exo_white" | ||||
|         app:indicatorColor="@color/player_white" | ||||
|         tools:visibility="visible" /> | ||||
|  | ||||
|     <LinearLayout | ||||
| @ -77,14 +77,14 @@ | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_gravity="bottom|end" | ||||
|         android:layout_marginEnd="12dp" | ||||
|         android:layout_marginBottom="70dp" | ||||
|         android:layout_marginBottom="72dp" | ||||
|         android:gravity="center" | ||||
|         android:text="@string/next_episode" | ||||
|         android:textAllCaps="false" | ||||
|         android:textColor="@android:color/primary_text_light" | ||||
|         android:textSize="16sp" | ||||
|         android:visibility="gone" | ||||
|         app:backgroundTint="@color/exo_white" | ||||
|         app:backgroundTint="@color/player_white" | ||||
|         app:iconGravity="textStart" /> | ||||
|  | ||||
|     <com.google.android.material.button.MaterialButton | ||||
| @ -93,14 +93,14 @@ | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_gravity="bottom|end" | ||||
|         android:layout_marginEnd="12dp" | ||||
|         android:layout_marginBottom="70dp" | ||||
|         android:layout_marginBottom="72dp" | ||||
|         android:gravity="center" | ||||
|         android:text="@string/skip_opening" | ||||
|         android:textAllCaps="false" | ||||
|         android:textColor="@android:color/primary_text_light" | ||||
|         android:textSize="16sp" | ||||
|         android:visibility="gone" | ||||
|         app:backgroundTint="@color/exo_white" | ||||
|         app:backgroundTint="@color/player_white" | ||||
|         app:iconGravity="textStart" /> | ||||
|  | ||||
| </FrameLayout> | ||||
| @ -1,30 +0,0 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:id="@+id/linLayout_login" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:orientation="vertical" | ||||
|     android:paddingStart="24dp" | ||||
|     android:paddingEnd="24dp"> | ||||
|  | ||||
|     <EditText | ||||
|         android:id="@+id/edit_text_login" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_margin="7dp" | ||||
|         android:ems="10" | ||||
|         android:hint="@string/login" | ||||
|         android:importantForAutofill="no" | ||||
|         android:inputType="textEmailAddress" /> | ||||
|  | ||||
|     <EditText | ||||
|         android:id="@+id/edit_text_password" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:layout_margin="7dp" | ||||
|         android:ems="10" | ||||
|         android:hint="@string/password" | ||||
|         android:importantForAutofill="no" | ||||
|         android:inputType="textPassword" /> | ||||
|  | ||||
| </LinearLayout> | ||||
| @ -115,7 +115,7 @@ | ||||
|                 android:paddingBottom="7dp"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/text_new_episodes" | ||||
|                     android:id="@+id/text_up_next" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:paddingStart="10dp" | ||||
| @ -127,7 +127,7 @@ | ||||
|                     android:textStyle="bold" /> | ||||
|  | ||||
|                 <androidx.recyclerview.widget.RecyclerView | ||||
|                     android:id="@+id/recycler_new_episodes" | ||||
|                     android:id="@+id/recycler_up_next" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="match_parent" | ||||
|                     android:orientation="horizontal" | ||||
| @ -163,6 +163,34 @@ | ||||
|                     tools:listitem="@layout/item_media" /> | ||||
|             </LinearLayout> | ||||
|  | ||||
|             <LinearLayout | ||||
|                 android:id="@+id/linear_recommendations" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:orientation="vertical" | ||||
|                 android:paddingBottom="7dp"> | ||||
|  | ||||
|                 <TextView | ||||
|                     android:id="@+id/text_recommendations" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:paddingStart="10dp" | ||||
|                     android:paddingTop="15dp" | ||||
|                     android:paddingEnd="5dp" | ||||
|                     android:paddingBottom="5dp" | ||||
|                     android:text="@string/recommendations" | ||||
|                     android:textSize="16sp" | ||||
|                     android:textStyle="bold" /> | ||||
|  | ||||
|                 <androidx.recyclerview.widget.RecyclerView | ||||
|                     android:id="@+id/recycler_recommendations" | ||||
|                     android:layout_width="match_parent" | ||||
|                     android:layout_height="match_parent" | ||||
|                     android:orientation="horizontal" | ||||
|                     app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" | ||||
|                     tools:listitem="@layout/item_media" /> | ||||
|             </LinearLayout> | ||||
|  | ||||
|             <LinearLayout | ||||
|                 android:id="@+id/linear_new_titles" | ||||
|                 android:layout_width="match_parent" | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout | ||||
|     xmlns:android="http://schemas.android.com/apk/res/android" | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:id="@+id/linear_episodes" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:orientation="vertical"> | ||||
|  | ||||
| @ -10,21 +10,22 @@ | ||||
|     android:paddingBottom="7dp"> | ||||
|  | ||||
|     <LinearLayout | ||||
|         android:id="@+id/linear_episode" | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="wrap_content" | ||||
|         android:orientation="horizontal"> | ||||
|  | ||||
|         <FrameLayout | ||||
|             android:layout_width="wrap_content" | ||||
|             android:layout_height="wrap_content"> | ||||
|             android:layout_width="128dp" | ||||
|             android:layout_height="72dp"> | ||||
|  | ||||
|             <com.google.android.material.imageview.ShapeableImageView | ||||
|                 android:id="@+id/image_episode" | ||||
|                 android:layout_width="128dp" | ||||
|                 android:layout_height="72dp" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:contentDescription="@string/component_poster_desc" | ||||
|                 app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" | ||||
|                 app:srcCompat="@color/md_disabled_text_dark_theme" /> | ||||
|                 app:srcCompat="@color/imagePlaceholder" /> | ||||
|  | ||||
|             <ImageView | ||||
|                 android:id="@+id/image_episode_play" | ||||
| @ -35,6 +36,15 @@ | ||||
|                 android:contentDescription="@string/button_play" | ||||
|                 app:srcCompat="@drawable/ic_baseline_play_arrow_24" | ||||
|                 app:tint="#FFFFFF" /> | ||||
|  | ||||
|             <com.google.android.material.progressindicator.LinearProgressIndicator | ||||
|                 android:id="@+id/progress_playhead" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_gravity="bottom" | ||||
|                 android:max="100" | ||||
|                 app:trackColor="#00FFFFFF" | ||||
|                 app:trackThickness="2dp" /> | ||||
|         </FrameLayout> | ||||
|  | ||||
|         <TextView | ||||
| @ -43,6 +53,8 @@ | ||||
|             android:layout_height="match_parent" | ||||
|             android:layout_marginStart="7dp" | ||||
|             android:layout_weight="1" | ||||
|             android:ellipsize="end" | ||||
|             android:maxLines="3" | ||||
|             android:text="@string/component_episode_title" | ||||
|             android:textColor="?textPrimary" | ||||
|             android:textSize="16sp" /> | ||||
|  | ||||
| @ -7,16 +7,16 @@ | ||||
|     android:padding="7dp"> | ||||
|  | ||||
|     <FrameLayout | ||||
|         android:layout_width="wrap_content" | ||||
|         android:layout_height="wrap_content"> | ||||
|         android:layout_width="192dp" | ||||
|         android:layout_height="108dp"> | ||||
|  | ||||
|         <com.google.android.material.imageview.ShapeableImageView | ||||
|             android:id="@+id/image_episode" | ||||
|             android:layout_width="192dp" | ||||
|             android:layout_height="108dp" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="match_parent" | ||||
|             android:contentDescription="@string/component_poster_desc" | ||||
|             app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster" | ||||
|             app:srcCompat="@color/md_disabled_text_dark_theme" /> | ||||
|             app:srcCompat="@color/imagePlaceholder" /> | ||||
|  | ||||
|         <ImageView | ||||
|             android:id="@+id/image_episode_play" | ||||
| @ -26,7 +26,16 @@ | ||||
|             android:background="@drawable/bg_circle__black_transparent_24dp" | ||||
|             android:contentDescription="@string/button_play" | ||||
|             app:srcCompat="@drawable/ic_baseline_play_arrow_24" | ||||
|             app:tint="#FFFFFF" /> | ||||
|             app:tint="@color/player_white" /> | ||||
|  | ||||
|         <com.google.android.material.progressindicator.LinearProgressIndicator | ||||
|             android:id="@+id/progress_playhead" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_gravity="bottom" | ||||
|             android:max="100" | ||||
|             app:trackColor="#00FFFFFF" | ||||
|             app:trackThickness="2dp" /> | ||||
|     </FrameLayout> | ||||
|  | ||||
|     <TextView | ||||
|  | ||||
| @ -13,18 +13,43 @@ | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="match_parent"> | ||||
|  | ||||
|         <ImageView | ||||
|             android:id="@+id/image_poster" | ||||
|         <FrameLayout | ||||
|             android:id="@+id/frame_image_progress" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="0dp" | ||||
|             android:contentDescription="@string/media_poster_desc" | ||||
|             android:scaleType="centerCrop" | ||||
|             app:layout_constraintBottom_toTopOf="@+id/text_title" | ||||
|             app:layout_constraintDimensionRatio="H,16:9" | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintStart_toStartOf="parent" | ||||
|             app:layout_constraintTop_toTopOf="parent" | ||||
|             tools:srcCompat="@color/md_disabled_text_dark_theme" /> | ||||
|             app:layout_constraintTop_toTopOf="parent"> | ||||
|  | ||||
|             <ImageView | ||||
|                 android:id="@+id/image_poster" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="match_parent" | ||||
|                 android:contentDescription="@string/media_poster_desc" | ||||
|                 android:scaleType="centerCrop" | ||||
|                 tools:srcCompat="@color/imagePlaceholder" /> | ||||
|  | ||||
|             <ImageView | ||||
|                 android:id="@+id/image_episode_play" | ||||
|                 android:layout_width="wrap_content" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_gravity="center" | ||||
|                 android:background="@drawable/bg_circle__black_transparent_24dp" | ||||
|                 android:contentDescription="@string/button_play" | ||||
|                 app:srcCompat="@drawable/ic_baseline_play_arrow_24" | ||||
|                 app:tint="#FFFFFF" /> | ||||
|  | ||||
|             <com.google.android.material.progressindicator.LinearProgressIndicator | ||||
|                 android:id="@+id/progress_playhead" | ||||
|                 android:layout_width="match_parent" | ||||
|                 android:layout_height="wrap_content" | ||||
|                 android:layout_gravity="bottom" | ||||
|                 android:max="100" | ||||
|                 app:trackColor="#00FFFFFF" | ||||
|                 app:trackThickness="2dp" /> | ||||
|         </FrameLayout> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/text_title" | ||||
| @ -37,7 +62,7 @@ | ||||
|             android:text="@string/text_title_ex" | ||||
|             android:textAlignment="center" | ||||
|             android:textSize="15sp" | ||||
|             app:layout_constraintTop_toBottomOf="@+id/image_poster" /> | ||||
|             app:layout_constraintTop_toBottomOf="@+id/frame_image_progress" /> | ||||
|     </androidx.constraintlayout.widget.ConstraintLayout> | ||||
|  | ||||
| </com.google.android.material.card.MaterialCardView> | ||||
							
								
								
									
										77
									
								
								app/src/main/res/layout/modal_bottom_sheet_login.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								app/src/main/res/layout/modal_bottom_sheet_login.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,77 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     android:id="@+id/standard_bottom_sheet" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:background="?themeSecondary" | ||||
|     android:orientation="vertical" | ||||
|     android:paddingTop="24dp" | ||||
|     android:paddingStart="24dp" | ||||
|     android:paddingEnd="24dp" | ||||
|     app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/text_title" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:paddingBottom="7dp" | ||||
|             android:text="@string/edit_login_credentials" | ||||
|             android:textSize="20sp" | ||||
|             android:textStyle="bold" /> | ||||
|  | ||||
|         <TextView | ||||
|             android:id="@+id/text_supporting_desc" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:paddingBottom="5dp" | ||||
|             android:text="@string/edit_login_credentials_desc" /> | ||||
|  | ||||
|         <EditText | ||||
|             android:id="@+id/edit_text_login" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_margin="7dp" | ||||
|             android:ems="10" | ||||
|             android:hint="@string/login" | ||||
|             android:importantForAutofill="no" | ||||
|             android:inputType="textEmailAddress" | ||||
|             android:minHeight="48dp" /> | ||||
|  | ||||
|         <EditText | ||||
|             android:id="@+id/edit_text_password" | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:layout_margin="7dp" | ||||
|             android:ems="10" | ||||
|             android:hint="@string/password" | ||||
|             android:importantForAutofill="no" | ||||
|             android:inputType="textPassword" | ||||
|             android:minHeight="48dp" /> | ||||
|  | ||||
|         <LinearLayout | ||||
|             android:layout_width="match_parent" | ||||
|             android:layout_height="wrap_content" | ||||
|             android:gravity="end" | ||||
|             android:orientation="horizontal"> | ||||
|  | ||||
|                 <Button | ||||
|                     android:id="@+id/negative_button" | ||||
|                     style="@android:style/Widget.Material.Button.Borderless.Small" | ||||
|                     android:layout_width="wrap_content" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_marginEnd="24dp" | ||||
|                     android:text="@string/cancel" | ||||
|                     android:textColor="?colorPrimary" /> | ||||
|  | ||||
|                 <Button | ||||
|                     android:id="@+id/positive_button" | ||||
|                     style="@android:style/Widget.Material.Button.Borderless.Small" | ||||
|                     android:layout_width="wrap_content" | ||||
|                     android:layout_height="wrap_content" | ||||
|                     android:layout_marginEnd="24dp" | ||||
|                     android:text="@string/save" | ||||
|                     android:textColor="?colorPrimary" /> | ||||
|         </LinearLayout> | ||||
|  | ||||
| </LinearLayout> | ||||
| @ -1,6 +1,8 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <FrameLayout 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:id="@+id/player_controls_root" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     android:background="#73000000"> | ||||
| @ -17,12 +19,12 @@ | ||||
|  | ||||
|         <ImageButton | ||||
|             android:id="@+id/exo_close_player" | ||||
|             android:layout_width="48dp" | ||||
|             android:layout_height="48dp" | ||||
|             android:background="@android:color/transparent" | ||||
|             android:scaleType="fitXY" | ||||
|             android:layout_width="44dp" | ||||
|             android:layout_height="44dp" | ||||
|             android:contentDescription="@string/close_player" | ||||
|             android:padding="10dp" | ||||
|             android:scaleType="fitXY" | ||||
|             app:srcCompat="@drawable/ic_baseline_arrow_back_24" /> | ||||
|  | ||||
|         <TextView | ||||
| @ -32,8 +34,9 @@ | ||||
|             android:layout_marginEnd="44dp" | ||||
|             android:text="@string/text_title_ex" | ||||
|             android:textAlignment="center" | ||||
|             android:textColor="@color/exo_white" | ||||
|             android:textSize="16sp" /> | ||||
|             android:textColor="@color/player_white" | ||||
|             android:textSize="16sp" | ||||
|             tools:ignore="TextContrastCheck" /> | ||||
|     </LinearLayout> | ||||
|  | ||||
|     <LinearLayout | ||||
| @ -90,13 +93,15 @@ | ||||
|         android:layout_gravity="bottom" | ||||
|         android:layout_marginStart="12dp" | ||||
|         android:layout_marginEnd="12dp" | ||||
|         android:layout_marginBottom="@dimen/exo_styled_progress_margin_bottom"> | ||||
|         android:layout_marginBottom="@dimen/player_styled_progress_margin_bottom"> | ||||
|  | ||||
|         <View | ||||
|             android:id="@+id/exo_progress_placeholder" | ||||
|         <com.google.android.exoplayer2.ui.DefaultTimeBar | ||||
|             android:id="@id/exo_progress" | ||||
|             android:layout_width="0dp" | ||||
|             android:layout_height="@dimen/exo_styled_progress_layout_height" | ||||
|             android:layout_marginBottom="2dp" | ||||
|             android:layout_height="@dimen/player_styled_progress_layout_height" | ||||
|             android:contentDescription="@string/desc_time_bar" | ||||
|             app:bar_height="3dp" | ||||
|             app:touch_target_height="@dimen/player_styled_progress_layout_height" | ||||
|             app:layout_constraintBottom_toBottomOf="parent" | ||||
|             app:layout_constraintEnd_toStartOf="@+id/exo_remaining" | ||||
|             app:layout_constraintStart_toStartOf="parent" | ||||
| @ -105,9 +110,10 @@ | ||||
|         <TextView | ||||
|             android:id="@+id/exo_remaining" | ||||
|             style="@style/ExoStyledControls.TimeText.Position" | ||||
|             android:layout_height="0dp" | ||||
|             android:layout_height="wrap_content" | ||||
|             app:layout_constraintBottom_toBottomOf="parent" | ||||
|             app:layout_constraintEnd_toEndOf="parent" /> | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintTop_toTopOf="parent" /> | ||||
|     </androidx.constraintlayout.widget.ConstraintLayout> | ||||
|  | ||||
|     <androidx.constraintlayout.widget.ConstraintLayout | ||||
|  | ||||
| @ -22,12 +22,12 @@ | ||||
|  | ||||
|         <ImageButton | ||||
|             android:id="@+id/button_close_episodes_list" | ||||
|             android:layout_width="48dp" | ||||
|             android:layout_height="48dp" | ||||
|             android:background="@android:color/transparent" | ||||
|             android:scaleType="fitXY" | ||||
|             android:layout_width="44dp" | ||||
|             android:layout_height="44dp" | ||||
|             android:contentDescription="@string/close_player" | ||||
|             android:padding="10dp" | ||||
|             android:scaleType="fitXY" | ||||
|             app:srcCompat="@drawable/ic_baseline_arrow_back_24" /> | ||||
|     </LinearLayout> | ||||
|  | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| <?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="#73000000" | ||||
| @ -22,12 +23,12 @@ | ||||
|  | ||||
|         <ImageButton | ||||
|             android:id="@+id/button_close_language_settings" | ||||
|             android:layout_width="48dp" | ||||
|             android:layout_height="48dp" | ||||
|             android:background="@android:color/transparent" | ||||
|             android:scaleType="fitXY" | ||||
|             android:layout_width="44dp" | ||||
|             android:layout_height="44dp" | ||||
|             android:contentDescription="@string/close_player" | ||||
|             android:padding="10dp" | ||||
|             android:scaleType="fitXY" | ||||
|             app:srcCompat="@drawable/ic_baseline_arrow_back_24" /> | ||||
|  | ||||
|         <TextView | ||||
| @ -37,8 +38,8 @@ | ||||
|             android:layout_marginEnd="44dp" | ||||
|             android:text="@string/subtitles" | ||||
|             android:textAlignment="center" | ||||
|             android:textColor="@color/exo_white" | ||||
|             android:textSize="16sp" | ||||
|             android:textColor="@color/player_white" | ||||
|             android:textSize="18sp" | ||||
|             android:textStyle="bold" /> | ||||
|  | ||||
|     </LinearLayout> | ||||
| @ -75,7 +76,7 @@ | ||||
|             android:layout_marginEnd="7dp" | ||||
|             android:text="@string/cancel" | ||||
|             android:textAllCaps="false" | ||||
|             android:textColor="@color/exo_white" | ||||
|             android:textColor="@color/player_white" | ||||
|             android:textSize="16sp" | ||||
|             app:backgroundTint="@color/buttonBackgroundLight" | ||||
|             app:layout_constraintBottom_toBottomOf="parent" | ||||
| @ -93,7 +94,8 @@ | ||||
|             app:backgroundTint="@color/buttonBackgroundDark" | ||||
|             app:layout_constraintBottom_toBottomOf="parent" | ||||
|             app:layout_constraintEnd_toEndOf="parent" | ||||
|             app:layout_constraintTop_toTopOf="parent" /> | ||||
|             app:layout_constraintTop_toTopOf="parent" | ||||
|             tools:ignore="TextContrastCheck" /> | ||||
|  | ||||
|     </LinearLayout> | ||||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||||
| @ -9,6 +9,7 @@ | ||||
|     <string name="highlight_media">Highlight</string> | ||||
|     <string name="up_next">Weiterschauen</string> | ||||
|     <string name="my_list">Meine Liste</string> | ||||
|     <string name="recommendations">Empfehlungen</string> | ||||
|     <string name="new_episodes">Neue Episoden</string> | ||||
|     <string name="new_simulcasts">Neue Simulcasts</string> | ||||
|     <string name="new_titles">Neue Titel</string> | ||||
| @ -57,6 +58,9 @@ | ||||
|     <string name="import_data">Daten importieren</string> | ||||
|     <string name="import_data_desc">Lade "Meine Liste" aus einer Datei</string> | ||||
|     <string name="import_data_success">"Meine Liste" erfolgreich importiert</string> | ||||
|     <string name="edit_login_credentials">Anmeldedaten bearbeiten</string> | ||||
|     <string name="edit_login_credentials_desc">Bearbeite deine Crunchyroll Anmeldedaten. Die Anmeldedaten weden verschüsselt aud deinem Gerät gespeichert.</string> | ||||
|     <string name="edit_login_credentials_fail">Benutzername oder Passwort ungültig. Bitte versuche es erneut.</string> | ||||
|  | ||||
|     <!-- about fragment --> | ||||
|     <string name="version">Version</string> | ||||
| @ -81,6 +85,7 @@ | ||||
|     <string name="episodes">Folgen</string> | ||||
|     <string name="episode">Folge</string> | ||||
|     <string name="no_subtitles">Aus</string> | ||||
|     <string name="desc_time_bar">Zeitleiste</string> | ||||
|  | ||||
|     <!-- Onboarding --> | ||||
|     <string name="skip">Überspringen</string> | ||||
| @ -103,7 +108,7 @@ | ||||
|  | ||||
|     <!-- etc --> | ||||
|     <string name="login">Login</string> | ||||
|     <string name="login_desc">Um Teapod zu benutzen musst du eingeloggt sein. Die Login-Daten weden verschüsselt aud deinem Gerät gespeichert.</string> | ||||
|     <string name="login_desc">Um Teapod zu benutzen musst du eingeloggt sein. Die Anmeldedaten weden verschüsselt aud deinem Gerät gespeichert.</string> | ||||
|     <string name="login_failed_desc">Login nicht erfolgreich. Bitte versuche es erneut.</string> | ||||
|     <string name="password">Passwort</string> | ||||
| </resources> | ||||
| @ -5,6 +5,7 @@ | ||||
|     <color name="colorPrimaryLight">#99dc45</color> | ||||
|     <color name="colorPrimaryDark">#317a00</color> | ||||
|     <color name="colorAccent">#607d8b</color> | ||||
|     <color name="imagePlaceholder">#c2c2c2</color> | ||||
|  | ||||
|     <!-- light theme colors --> | ||||
|     <color name="themePrimaryLight">#ffffff</color> | ||||
| @ -25,6 +26,9 @@ | ||||
|     <color name="buttonBackgroundDark">#ffffff</color> | ||||
|     <color name="controlHighlightDark">#11ffffff</color> | ||||
|  | ||||
|     <!-- player colors --> | ||||
|     <color name="player_white">#ffffff</color> | ||||
|  | ||||
|     <color name="ic_launcher_background">#ffffff</color> | ||||
|     <color name="ic_splash_background">#ffffff</color> | ||||
| </resources> | ||||
							
								
								
									
										5
									
								
								app/src/main/res/values/dimens.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/src/main/res/values/dimens.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <dimen name="player_styled_progress_layout_height">28dp</dimen> | ||||
|     <dimen name="player_styled_progress_margin_bottom">52dp</dimen> | ||||
| </resources> | ||||
| @ -9,10 +9,12 @@ | ||||
|     <string name="highlight_media">Highlight</string> | ||||
|     <string name="up_next">Up next</string> | ||||
|     <string name="my_list">My list</string> | ||||
|     <string name="recommendations">Recommendations</string> | ||||
|     <string name="new_episodes">New episodes</string> | ||||
|     <string name="new_simulcasts">New simulcasts</string> | ||||
|     <string name="new_titles">New titles</string> | ||||
|     <string name="top_ten">Top 10</string> | ||||
|     <string name="season_episode_title" translatable="false">S%1$d E%2$d - %3$s</string> | ||||
|  | ||||
|     <!-- search fragment --> | ||||
|     <string name="search_hint">Search for movies and series</string> | ||||
| @ -69,6 +71,9 @@ | ||||
|     <string name="info">Info</string> | ||||
|     <string name="info_about" translatable="false">Teapod by @Seil0</string> | ||||
|     <string name="info_about_desc">Version %1$s (%2$s)</string> | ||||
|     <string name="edit_login_credentials">Edit credentials</string> | ||||
|     <string name="edit_login_credentials_desc">Edit your crunchyroll login credentials. The credentials will be stored encrypted on your device.</string> | ||||
|     <string name="edit_login_credentials_fail">Invalid login or password. Please try again.</string> | ||||
|  | ||||
|     <!-- about fragment --> | ||||
|     <string name="version">Version</string> | ||||
| @ -102,6 +107,7 @@ | ||||
|     <string name="episodes">Episodes</string> | ||||
|     <string name="episode">Episode</string> | ||||
|     <string name="no_subtitles">None</string> | ||||
|     <string name="desc_time_bar">time bar</string> | ||||
|  | ||||
|     <!-- Onboarding --> | ||||
|     <string name="skip">Skip</string> | ||||
|  | ||||
| @ -18,11 +18,6 @@ | ||||
|         <item name="shapeTextBackground">@color/textBackgroundLight</item> | ||||
|         <item name="iconColor">@color/iconColorLight</item> | ||||
|         <item name="buttonBackground">@color/buttonBackgroundLight</item> | ||||
|         <item name="md_background_color">@color/themeSecondaryLight</item> | ||||
|         <item name="md_color_content">@color/textSecondaryLight</item> | ||||
|  | ||||
|         <!-- without this, the unchecked single choice buttons while be white --> | ||||
|         <item name="md_color_widget_unchecked">@color/textSecondaryLight</item> | ||||
|     </style> | ||||
|  | ||||
|     <style name="AppTheme.Dark" parent="AppTheme"> | ||||
| @ -37,11 +32,6 @@ | ||||
|         <item name="iconColor">@color/iconColorDark</item> | ||||
|         <item name="buttonBackground">@color/buttonBackgroundDark</item> | ||||
|  | ||||
|         <item name="md_background_color">@color/themeSecondaryDark</item> | ||||
|         <item name="md_color_content">@color/textSecondaryDark</item> | ||||
|         <!-- without this, the unchecked single choice buttons while be black --> | ||||
|         <item name="md_color_widget_unchecked">@color/textSecondaryDark</item> | ||||
|  | ||||
|         <item name="materialAlertDialogTheme">@style/ThemeOverlay.App.MaterialAlertDialog.Dark</item> | ||||
|         <!-- change on click indicator color for manually set components --> | ||||
|         <item name="colorControlHighlight">@color/controlHighlightDark</item> | ||||
| @ -61,7 +51,7 @@ | ||||
|     </style> | ||||
|  | ||||
|     <!-- player theme --> | ||||
|     <style name="PlayerTheme" parent="Theme.MaterialComponents.Light.NoActionBar"> | ||||
|     <style name="PlayerTheme" parent="AppTheme"> | ||||
|         <item name="android:windowNoTitle">true</item> | ||||
|         <item name="android:windowActionBar">false</item> | ||||
|         <item name="android:windowFullscreen">true</item> | ||||
| @ -96,4 +86,14 @@ | ||||
|         <item name="android:popupBackground">?themeSecondary</item> | ||||
|     </style> | ||||
|  | ||||
|     <!-- fullscreen dialog fragments --> | ||||
|     <style name="FullScreenDialogStyle" parent="AppTheme"> | ||||
|         <item name="android:windowFullscreen">true</item> | ||||
|         <item name="android:windowIsFloating">false</item> | ||||
|         <item name="android:windowBackground">@android:color/transparent</item> | ||||
|         <item name="android:windowTranslucentStatus">true</item> | ||||
|         <item name="android:windowTranslucentNavigation">true</item> | ||||
|     </style> | ||||
|  | ||||
|  | ||||
| </resources> | ||||
| @ -1,13 +1,14 @@ | ||||
| // Top-level build file where you can add configuration options common to all sub-projects/modules. | ||||
| buildscript { | ||||
|     ext.kotlin_version = "1.6.10" | ||||
|     ext.ktor_version = "1.6.7" | ||||
|     ext.kotlin_version = "1.6.21" | ||||
|     ext.ktor_version = "1.6.8" | ||||
|     ext.exo_version = "2.17.1" | ||||
|     repositories { | ||||
|         google() | ||||
|         mavenCentral() | ||||
|     } | ||||
|     dependencies { | ||||
|         classpath 'com.android.tools.build:gradle:7.1.2' | ||||
|         classpath 'com.android.tools.build:gradle:7.2.1' | ||||
|         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" | ||||
|  | ||||
|         // NOTE: Do not place your application dependencies here; they belong | ||||
|  | ||||
							
								
								
									
										10
									
								
								fastlane/metadata/android/de/changelogs/9010.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								fastlane/metadata/android/de/changelogs/9010.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| Dies ist der zweite beta Release von Teapod 1.0.0 mit Unterstützung für Cunchyroll. | ||||
|  | ||||
| * Unterstützung für Crunchyroll hinzugefügt (Ein premium Account wird benötigt) | ||||
| * Crunchyroll metadb Unterstützung hinzugefügt (#54) | ||||
| * Playhead Updates lassen sich nun ausschalten | ||||
| * Ähnliche Titel zum Mediafragment hinzugefügt | ||||
| * Empfehlungen für dich zum Homefragment hinzugefügt | ||||
| * Einen Crash beim login wurde behoben | ||||
|  | ||||
| Alle Änderungen https://git.mosad.xyz/Seil0/teapod/compare/1.0.0-beta1...1.0.0-beta2 | ||||
| @ -3,8 +3,13 @@ Teapod ist eine inoffizielle App für Crunchyroll. | ||||
| * Schau dir alle Titel von Crunchyroll auf deinem Android Gerät an | ||||
| * Nativer Player auf Basis des ExoPayers | ||||
| * Bevorzuge die OmU Version über die App-Einstellungen | ||||
| * Picture in Picture Modus | ||||
| * Überspringe das Intro/Ending dank der TeapodMetaDB Integration | ||||
|  | ||||
| Um Teapod zu verwenden musst du dich mit deinem Crunchyroll Account anmelden. | ||||
| Dieses Projekt ist in keiner Weise mit Crunchyroll verbunden. | ||||
|  | ||||
| TeapodMetaDB unterstützt ausschliesslich Serien, für die Metadaten vorliegen. | ||||
| Hilf mit, die Datenbank auszubauen: https://gitlab.com/Seil0/teapodmetadb | ||||
|  | ||||
| Bitte melde Fehler und Probleme an support@mosad.xyz | ||||
|  | ||||
							
								
								
									
										10
									
								
								fastlane/metadata/android/en-US/changelogs/9010.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								fastlane/metadata/android/en-US/changelogs/9010.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| This is the second beta release of Teapod 1.0.0 with support for crunchyroll. | ||||
|  | ||||
| * Support for crunchyroll (a premium account is needed) | ||||
| * Crunchyroll metadb support (#54) | ||||
| * Added a option to disable playhead updates/reporting | ||||
| * Show similar titles in the media fragment | ||||
| * Added recommendations to the home fragment | ||||
| * Fixed a crash on login, which made the app unusable | ||||
|  | ||||
| Full changelog https://git.mosad.xyz/Seil0/teapod/compare/1.0.0-beta1...1.0.0-beta2 | ||||
| @ -3,8 +3,13 @@ Teapod is a unofficial App for Crunchyroll. | ||||
| * Watch all animes from Crunchyroll on your Android device | ||||
| * Native Player based on ExoPayer | ||||
| * Prefer the OmU version via the app settings | ||||
| * Picture in Picture Mode | ||||
| * Skip the OP/ED thanks to the TeapodMetaDB integration | ||||
|  | ||||
| To use Teapod you have to login with your Crunchyroll account. | ||||
| This Project is not associated with Crunchyroll in any way. | ||||
|  | ||||
| TeapodMetaDB supports only shows where metradata is present. | ||||
| Help us to expand the database: https://gitlab.com/Seil0/teapodmetadb | ||||
|  | ||||
| Please report bugs and issues to support@mosad.xyz | ||||
|  | ||||
							
								
								
									
										
											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.1-bin.zip | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip | ||||
| zipStoreBase=GRADLE_USER_HOME | ||||
| zipStorePath=wrapper/dists | ||||
|  | ||||
		Reference in New Issue
	
	Block a user