Compare commits

...

27 Commits

Author SHA1 Message Date
0fd7cc964f
update gradle wrapper, agp, kotlin and libraries
* kotline 1.9.22 -> 2.0.20
* ktor 2.3.6 -> 3.0.0
* agp 8.3.0 -> 8.7.1
2024-10-29 22:07:06 +01:00
b07a6fd407
update gradle wrapper, agp, kotlin and libraries 2024-03-03 21:21:12 +01:00
7d661712f7
update to kotlin 1.9.0 2023-11-15 15:50:13 +01:00
8fcf047e99
update agp and libraries
* agp 8.1.2 -> 8.1.3
* ktor 2.3.4 -> 2.3.6
* androidx.core 1.10.1 -> 1.12.0
* androidx.navigation 2.6.0 -> 2.7.5
* androidx.lifecycle 2.6.1 -> 2.6.1
* com.google.android.material 1.9.0 -> 1.10.0
* gradle wrapper 8.2.1 -> 8.4
2023-11-15 15:37:44 +01:00
17dbe945e5
mograte MyListFragment to use a simple ViewModel
fixes crashes in MyListFragment if the User closes the fragment with a loading job still running, part of #56
2023-10-15 18:44:22 +02:00
5f609d4c33
update agp and libraries
* agp 8.1.0 -> 8.1.2
* ktor 2.3.2 -> 2.3.4
* kotlinx-serialization-json 1.5.1 -> 1.6.0
2023-09-29 23:47:47 +02:00
6515f657d0
partially revert c448b44fc4 2023-08-11 15:07:33 +02:00
c448b44fc4
updatelibraries and agp
* agp 8.0.2 -> 8.1.0
* kotlinx-coroutines-android 1.7.2 -> 1.7.3
* navigation-fragment-ktx 2.6.0 -> 2.7.0
* navigation-ui-ktx 2.6.0 -> 2.7.0
2023-08-11 14:58:17 +02:00
88ebc378d3
add changelog for beta3; update gradle wrapper to 8.2.1 2023-08-11 14:41:55 +02:00
1a012cba7d
add support for dedicated subtitle and audio language settings 2023-07-21 21:42:55 +02:00
59a457430e
migrate more Crunchyroll API endpoints to v2 2023-07-21 17:22:45 +02:00
0662d656ac
update libraries, agp and kotlin
* kotlin 1.8.10 -> 1.8.22
* kotlinx-coroutines-android 1.6.4 -> 1.7.2
* kotlinx-serialization-json 1.5.0 -> 1.5.1
* core-ktx 1.10.0 -> 1.10.1
* core-splashscreen 1.0.0 -> 1.0.1
* navigation-fragment-ktx 2.5.3 -> 2.6.0
* navigation-ui-ktx 2.5.3 -> 2.6.0
* security-crypto 1.1.0-alpha05 -> 1.1.0-alpha06
* material 1.8.0 -> 1.9.0
* ktor 2.2.4 -> 2.3.2
* exo-player 2.18.5 -> 2.18.7
* agp 8.0.0 -> 8.0.2
2023-07-21 11:43:38 +02:00
3549a3d2a7
migrate Crunchyroll.objects() to new v2 endpoint
fixes #71
2023-07-21 11:39:48 +02:00
c89ae54929
fix typo in changelog for 1.1.0-beta2 2023-04-16 16:51:59 +02:00
3aa03783a9
add changelogs for 1.1.0-beta2 2023-04-16 16:49:09 +02:00
4bceacf75c
make versions in DataTypes -> Episodes -> Episode nullable since it is in fact nullable 2023-04-16 16:24:28 +02:00
cf02bee7d4
minor fixes
* fix episode count in MediaFragement
* fix tmdb language tag
* update media type detection to use the episode field as episodeNumber may be messinging from certain episodes of tv shows
2023-04-16 13:49:22 +02:00
01d026cc7f
update libraries, agp and gradle
* core-ktx 1.9.0 -> 1.10.0
* lifecycle-runtime-ktx 2.5.1 -> 2.6.1
* lifecycle-viewmodel-ktx 2.5.1 -> 2.6.1
* glide 4.15.0 -> 4.15.1
* exoplayer 2.18.3 -> 2.18.5
* agp 7.4.2 -> 8.0.0
* gradle wrapper 7.6 -> 8.1
2023-04-16 00:06:40 +02:00
7580093649 Merge pull request 'migrate to material 3' (#70) from feature/material-3 into develop
Reviewed-on: #70
2023-04-15 23:51:08 +02:00
f266731115
remove old theme definition 2023-04-15 23:50:40 +02:00
a6a23c8560
fix onboarding colors for light/dark theme 2023-04-15 23:46:13 +02:00
2cb05de810
fix theme selection dialog to work with system theme also use system as new default 2023-04-15 22:48:59 +02:00
5cf4527a92
clean up color and theme definitions
also use separate theme definition for light/dark
2023-04-15 22:35:19 +02:00
14ad34138c
fix onboarding fragments and bottom sheet login 2023-04-15 22:02:49 +02:00
47e1f6bd49
initial migration to material 3 2023-03-29 16:16:31 +02:00
fdcb76e26e
update glide and kotlinx-serialization
* glide 4.14.2 -> 4.15.0
* kotlinx-serialization 1.4.1 -> 1.5.0
2023-03-01 17:24:02 +01:00
7004d73b9f
update libraries and kotlin
* kotlin 1.7.20 -> 1.8.10
* ktor 2.2.1 -> 2.2.4
* exo player 2.18.2 -> 2.18.3
* androidx.appcompat 1.6.0 -> 1.6.1
* androidx.security:security-crypto 1.1.0-alpha04 -> 1.1.0-alpha05
* com.google.android.material 1.7.0 -> 1.8.0
2023-03-01 17:17:23 +01:00
70 changed files with 835 additions and 591 deletions

View File

@ -26,4 +26,4 @@ Currently you need to have an Crunchyroll account to contribute to Teapod. Contr
#### Why is it called Teapod?
Teapod is a Acronym for "The ultimate anime app on demand", hence this project is called Teapod and not Teapot.
Teapod © 2020-2022 [@Seil0](https://git.mosad.xyz/Seil0)
Teapod © 2020-2023 [@Seil0](https://git.mosad.xyz/Seil0)

View File

@ -4,16 +4,23 @@ plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
}
kotlin {
jvmToolchain 17
sourceSets.configureEach {
languageSettings.optIn("kotlin.RequiresOptIn")
}
}
android {
compileSdkVersion 33
buildToolsVersion "30.0.3"
compileSdk 34
buildToolsVersion = '34.0.0'
defaultConfig {
applicationId "org.mosad.teapod"
minSdkVersion 23
targetSdkVersion 32
versionCode 100990 //01.00.000
versionName "1.1.0-beta1"
minSdk 23
targetSdk 33
versionCode 100992 //01.00.000
versionName "1.1.0-beta3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime()
@ -22,6 +29,7 @@ android {
buildFeatures {
viewBinding true
buildConfig true
}
buildTypes {
@ -32,37 +40,28 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
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.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation 'androidx.appcompat:appcompat:1.6.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
implementation 'androidx.security:security-crypto:1.1.0-alpha04'
implementation 'androidx.navigation:navigation-fragment-ktx:2.8.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.8.3'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.6'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation "androidx.paging:paging-runtime-ktx:3.3.2"
implementation 'com.google.android.material:material:1.7.0'
implementation 'com.google.android.material:material:1.12.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"
@ -71,7 +70,7 @@ dependencies {
implementation 'com.facebook.shimmer:shimmer:0.5.0'
implementation 'com.github.bumptech.glide:glide:4.14.2'
implementation 'com.github.bumptech.glide:glide:4.16.0'
implementation 'jp.wasabeef:glide-transformations:4.3.0'
implementation "io.ktor:ktor-client-core:$ktor_version"
@ -80,8 +79,8 @@ dependencies {
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}

View File

@ -10,7 +10,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme.Dark">
android:theme="@style/AppTheme">
<activity
android:exported="true"
android:name="org.mosad.teapod.ui.activity.main.MainActivity"

View File

@ -34,8 +34,6 @@ import io.ktor.http.*
import io.ktor.serialization.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
@ -60,16 +58,12 @@ object Crunchyroll {
private lateinit var token: Token
private var tokenValidUntil: Long = 0
@OptIn(DelicateCoroutinesApi::class)
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext")
private var accountID = ""
private var externalID = ""
private var policy = ""
private var signature = ""
private var keyPairID = ""
private val browsingCache = hashMapOf<String, BrowseResult>()
/**
@ -149,7 +143,7 @@ object Crunchyroll {
}
return@coroutineScope (Dispatchers.IO) {
val response: T = client.request(url) {
val response = client.request(url) {
method = httpMethod
header("Authorization", "${token.tokenType} ${token.accessToken}")
params.forEach {
@ -161,9 +155,9 @@ object Crunchyroll {
setBody(bodyObject)
contentType(ContentType.Application.Json)
}
}.body()
}
response
response.body<T>()
}
}
@ -214,27 +208,10 @@ object Crunchyroll {
}
/**
* Basic functions: index, account
* Basic functions: account
* Needed for other functions to work properly!
*/
/**
* Retrieve the identifiers necessary for streaming. If the identifiers are
* retrieved, set the corresponding global var. The identifiers are valid for 24h.
*/
suspend fun index() {
val indexEndpoint = "/index/v2"
val index: Index = requestGet(indexEndpoint)
policy = index.cms.policy
signature = index.cms.signature
keyPairID = index.cms.keyPairId
Log.i(TAG, "Policy : $policy")
Log.i(TAG, "Signature : $signature")
Log.i(TAG, "Key Pair ID : $keyPairID")
}
/**
* Retrieve the account id and set the corresponding global var.
* The account id is needed for other calls.
@ -262,24 +239,30 @@ object Crunchyroll {
/**
* Browse the media available on crunchyroll.
*
* @param sortBy
* @param n Number of items to return, defaults to 10
*
* @param start start of the item list, used for pagination, default = 0
* @param n number of items to return, default = 10
* @param sortBy the sort order, see **[SortBy]**
* @param ratings add user rating to the objects, default = false
* @param seasonTag filter by season tag, if present
* @param categories filter by category, if present
* @return A **[BrowseResult]** object is returned.
*/
suspend fun browse(
categories: List<Categories> = emptyList(),
sortBy: SortBy = SortBy.ALPHABETICAL,
seasonTag: String = "",
start: Int = 0,
n: Int = 10
n: Int = 10,
sortBy: SortBy = SortBy.ALPHABETICAL,
ratings: Boolean = false,
seasonTag: String = "",
categories: List<Categories> = emptyList()
): BrowseResult {
val browseEndpoint = "/content/v1/browse"
val browseEndpoint = "/content/v2/discover/browse"
val parameters = mutableListOf(
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"sort_by" to sortBy.str,
"start" to start,
"n" to n
"n" to n,
"sort_by" to sortBy.str,
"ratings" to ratings,
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
)
// if a season tag is present add it to the parameters
@ -304,9 +287,10 @@ object Crunchyroll {
NoneBrowseResult
}
// if the cache has more than 100 entries clear it, so it doesn't become a memory problem
// if the cache has more than 10 entries clear it, so it doesn't become a memory problem
// Note: this value is totally guessed and should be replaced by a properly researched value
// TODO 100 is way to high as it's not the number of items but BrowseResults
if (browsingCache.size > 10) {
browsingCache.clear()
}
@ -322,19 +306,20 @@ object Crunchyroll {
* Search fo a query term.
* Note: currently this function only supports series/tv shows.
*
* TODO migrate to v2
*
* @param query The query term as String
* @param n The maximum number of results to return, default = 10
* @param ratings add user rating to the objects, default = false
* @return A **[SearchResult]** object
*/
suspend fun search(query: String, n: Int = 10): SearchResult {
val searchEndpoint = "/content/v1/search"
suspend fun search(query: String, n: Int = 10, ratings: Boolean = false): SearchResult {
val searchEndpoint = "/content/v2/discover/search"
val parameters = listOf(
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"q" to query,
"n" to n,
"type" to "series"
"type" to "series",
"ratings" to ratings,
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
)
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
@ -353,22 +338,22 @@ object Crunchyroll {
* Note: episode objects are currently not supported
*
* @param objects The object IDs as list of Strings
* @param ratings add user rating to the objects
* @return A **[Collection]** of Panels
*/
suspend fun objects(objects: List<String>): Collection<Item> {
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
suspend fun objects(objects: List<String>, ratings: Boolean = false): CollectionV2<Item> {
val episodesEndpoint = "/content/v2/cms/objects/${objects.joinToString(",")}"
val parameters = listOf(
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"Signature" to signature,
"Policy" to policy,
"Key-Pair-Id" to keyPairID
"ratings" to ratings,
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
)
return try {
requestGet(episodesEndpoint, parameters)
} catch (ex: Exception) {
Log.e(TAG, "Exception in objects().", ex)
NoneCollection
NoneCollectionV2
}
}
@ -389,7 +374,7 @@ object Crunchyroll {
return try {
requestGet(seriesEndpoint, parameters)
} catch (ex: Exception) {
Log.e(TAG, "Exception in series().", ex)
Log.e(TAG, "Exception in series() for id $seriesId.", ex)
NoneSeries
}
}
@ -418,7 +403,7 @@ object Crunchyroll {
Log.e(TAG, "JsonConvertException in upNextSeries() with seriesId=$seriesId", ex)
NoneUpNextSeriesList
} catch (ex: Exception) {
Log.e(TAG, "Exception in upNextSeries().", ex)
Log.e(TAG, "Exception in upNextSeries() for seriesId $seriesId.", ex)
NoneUpNextSeriesList
}
}
@ -439,7 +424,7 @@ object Crunchyroll {
return try {
requestGet(seasonsEndpoint, parameters)
} catch (ex: Exception) {
Log.e(TAG, "Exception in seasons().", ex)
Log.e(TAG, "Exception in seasons() for seriesId $seriesId.", ex)
NoneSeasons
}
}
@ -460,7 +445,7 @@ object Crunchyroll {
return try {
requestGet(episodesEndpoint, parameters)
} catch (ex: Exception) {
Log.e(TAG, "Exception in episodes().", ex)
Log.e(TAG, "Exception in episodes() for seasonId $seasonId.", ex)
NoneEpisodes
}
}
@ -480,7 +465,7 @@ object Crunchyroll {
return try {
requestGet(url, parameters)
} catch (ex: Exception) {
Log.e(TAG, "Exception in streams().", ex)
Log.e(TAG, "Exception in streams() with url $url.", ex)
NoneStreams
}
}
@ -509,10 +494,10 @@ object Crunchyroll {
)
return try {
(requestGet(watchlistSeriesEndpoint, parameters) as Collection2<IsWatchlistItem>)
(requestGet(watchlistSeriesEndpoint, parameters) as CollectionV2<IsWatchlistItem>)
.total == 1
} catch (ex: Exception) {
Log.e(TAG, "Exception in isWatchlist() with seriesId = $seriesId", ex)
Log.e(TAG, "Exception in isWatchlist() with seriesId $seriesId", ex)
false
}
}
@ -536,7 +521,7 @@ object Crunchyroll {
try {
requestPost(watchlistPostEndpoint, parameters, json)
} catch (ex: Exception) {
Log.e(TAG, "Exception in postWatchlist() with seriesId = $seriesId", ex)
Log.e(TAG, "Exception in postWatchlist() with seriesId $seriesId", ex)
}
}
@ -555,7 +540,7 @@ object Crunchyroll {
try {
requestDelete(watchlistDeleteEndpoint, parameters)
} catch (ex: Exception) {
Log.e(TAG, "Exception in deleteWatchlist() with seriesId = $seriesId", ex)
Log.e(TAG, "Exception in deleteWatchlist() with seriesId $seriesId", ex)
}
}
@ -575,7 +560,6 @@ object Crunchyroll {
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
)
return try {
requestGet(playheadsEndpoint, parameters)
} catch (ex: Exception) {
@ -632,14 +616,16 @@ object Crunchyroll {
*
* @param seriesId The crunchyroll series id of the media
* @param n The maximum number of results to return, default = 10
* @param ratings add user rating to the objects
* @return A **[SimilarToResult]** object
*/
suspend fun similarTo(seriesId: String, n: Int = 10): SimilarToResult {
val similarToEndpoint = "/content/v1/$accountID/similar_to"
suspend fun similarTo(seriesId: String, n: Int = 10, ratings: Boolean = false): SimilarToResult {
val similarToEndpoint = "/content/v2/discover/$accountID/similar_to/$seriesId"
val parameters = listOf(
"guid" to seriesId,
"n" to n,
"ratings" to ratings,
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"n" to n
)
return try {
@ -660,7 +646,7 @@ object Crunchyroll {
* @param n Number of items to return, defaults to 20.
* @return A **[Collection]** containing up to n **[Item]**.
*/
suspend fun watchlist(n: Int = 20): Collection<Item> {
suspend fun watchlist(n: Int = 20): CollectionV2<Item> {
val watchlistEndpoint = "/content/v2/discover/$accountID/watchlist"
val parameters = listOf(
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
@ -682,10 +668,10 @@ object Crunchyroll {
/**
* List the next up episodes for the logged in account.
*
* @param n Number of items to return, defaults to 20.
* @param n Number of items to return, default = 20
* @return A **[HistoryList]** containing up to n **[UpNextAccountItem]**.
*/
suspend fun upNextAccount(n: Int = 20): HistoryList {
suspend fun upNextAccount(n: Int = 10): HistoryList {
val watchlistEndpoint = "/content/v2/discover/$accountID/history"
val parameters = listOf(
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
@ -700,13 +686,21 @@ object Crunchyroll {
}
}
suspend fun recommendations(n: Int = 20, start: Int = 0): RecommendationsList {
val recommendationsEndpoint = "/content/v1/$accountID/recommendations"
/**
* Returns a collection of recommendations for the currently logged in account.
*
* @param start start of the item list, used for pagination, default = 0
* @param n number of items to return, default = 10
* @param ratings add user rating to the objects, default = false
* @return A **[RecommendationsList]** containing up to n **[Item]**.
*/
suspend fun recommendations(start: Int = 0, n: Int = 10, ratings: Boolean = false): RecommendationsList {
val recommendationsEndpoint = "/content/v2/discover/$accountID/recommendations"
val parameters = listOf(
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"n" to n,
"start" to start,
"variant_id" to 0
"n" to n,
"ratings" to ratings,
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
)
return try {
@ -742,7 +736,7 @@ object Crunchyroll {
*
* @param languageTag the preferred language as language tag
*/
suspend fun postPrefSubLanguage(languageTag: String) {
suspend fun setPreferredSubtitleLanguage(languageTag: String) {
val profileEndpoint = "/accounts/v1/me/profile"
val json = buildJsonObject {
put("preferred_content_subtitle_language", languageTag)
@ -751,6 +745,20 @@ object Crunchyroll {
requestPatch(profileEndpoint, bodyObject = json)
}
/**
* Patch the preferred content audio language.
*
* @param languageTag the preferred language as language tag
*/
suspend fun setPreferredAudioLanguage(languageTag: String) {
val profileEndpoint = "/accounts/v1/me/profile"
val json = buildJsonObject {
put("preferred_content_audio_language", languageTag)
}
requestPatch(profileEndpoint, bodyObject = json)
}
/**
* Get additional profile (benefits) information for the currently logged in account.
*

View File

@ -24,19 +24,47 @@ package org.mosad.teapod.parser.crunchyroll
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.*
import java.util.Locale
val supportedLocals = listOf(
val supportedAudioLocals = listOf(
Locale.forLanguageTag("ar-SA"),
Locale.forLanguageTag("ca-ES"),
Locale.forLanguageTag("de-DE"),
Locale.forLanguageTag("en-US"),
Locale.forLanguageTag("en-IN"),
Locale.forLanguageTag("es-419"),
Locale.forLanguageTag("es-ES"),
Locale.forLanguageTag("fr-FR"),
Locale.forLanguageTag("hi-IN"),
Locale.forLanguageTag("it-IT"),
Locale.forLanguageTag("ko-KR"),
Locale.forLanguageTag("pl-PL"),
Locale.forLanguageTag("pt-BR"),
Locale.forLanguageTag("pt-PT"),
Locale.forLanguageTag("ru-RU"),
Locale.forLanguageTag("ta-IN"),
Locale.forLanguageTag("th-TH"),
Locale.forLanguageTag("zh-CN"),
Locale.forLanguageTag("zh-TW"),
Locale.ROOT
)
val supportedSubtitleLocals = listOf(
Locale.forLanguageTag("ar-SA"),
Locale.forLanguageTag("ca-ES"),
Locale.forLanguageTag("de-DE"),
Locale.forLanguageTag("en-US"),
Locale.forLanguageTag("es-419"),
Locale.forLanguageTag("es-ES"),
Locale.forLanguageTag("fr-FR"),
Locale.forLanguageTag("hi-IN"),
Locale.forLanguageTag("it-IT"),
Locale.forLanguageTag("ms-MY"),
Locale.forLanguageTag("pl-PL"),
Locale.forLanguageTag("pt-BR"),
Locale.forLanguageTag("pt-PT"),
Locale.forLanguageTag("ru-RU"),
Locale.forLanguageTag("tr-TR"),
Locale.ROOT
)
@ -44,6 +72,10 @@ val supportedLocals = listOf(
* data classes for browse
* TODO make class names more clear/possibly overlapping for now
*/
/**
* Enum of all supported sorting orders.
*/
enum class SortBy(val str: String) {
ALPHABETICAL("alphabetical"),
NEWLY_ADDED("newly_added"),
@ -112,23 +144,22 @@ val NoneAccount = Account("", "", false, "")
*/
@Serializable
data class Collection<T>(
data class CollectionV1<T>(
@SerialName("total") val total: Int,
@SerialName("items") val items: List<T>
)
@Serializable
data class Collection2<T>(
data class CollectionV2<T>(
@SerialName("total") val total: Int,
@SerialName("data") val data: List<T>
)
typealias SearchResult = Collection<SearchCollection>
typealias SearchCollection = Collection<Item>
typealias BrowseResult = Collection<Item>
typealias SimilarToResult = Collection<Item>
typealias RecommendationsList = Collection<Item>
typealias Benefits = Collection<Benefit>
typealias SearchResult = CollectionV2<SearchTypedList<Item>>
typealias BrowseResult = CollectionV2<Item>
typealias SimilarToResult = CollectionV2<Item>
typealias RecommendationsList = CollectionV2<Item>
typealias Benefits = CollectionV1<Benefit>
/**
* panel data classes
@ -159,9 +190,9 @@ data class Poster(val height: Int, val width: Int, val source: String, val type:
* up next & watchlist data classes
*/
typealias Watchlist = Collection2<WatchlistItem>
typealias HistoryList = Collection2<UpNextAccountItem>
typealias UpNextSeriesList = Collection2<UpNextSeriesItem>
typealias Watchlist = CollectionV2<WatchlistItem>
typealias HistoryList = CollectionV2<UpNextAccountItem>
typealias UpNextSeriesList = CollectionV2<UpNextSeriesItem>
@Serializable
data class WatchlistItem(
@ -221,7 +252,7 @@ data class EpisodeMetadata(
@SerialName("series_title") val seriesTitle: String,
)
val NoneCollection = Collection<Item>(0, emptyList())
val NoneCollectionV2 = CollectionV2<Item>(0, emptyList())
val NoneSearchResult = SearchResult(0, emptyList())
val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneSimilarToResult = SimilarToResult(0, emptyList())
@ -235,7 +266,7 @@ val NoneBenefits = Benefits(0, emptyList())
* series data class
*/
typealias Series = Collection2<SeriesItem>
typealias Series = CollectionV2<SeriesItem>
@Serializable
data class SeriesItem(
@ -245,11 +276,11 @@ data class SeriesItem(
@SerialName("images") val images: Images,
@SerialName("is_simulcast") val isSimulcast: Boolean,
@SerialName("maturity_ratings") val maturityRatings: List<String>,
@SerialName("audio_locales") val audioLocales: List<String>
@SerialName("audio_locales") val audioLocales: List<String>,
@SerialName("episode_count") val episodeCount: Int
)
val NoneSeriesItem = SeriesItem("", "", "", Images(emptyList(), emptyList()), false, emptyList(), emptyList())
val NoneSeriesItem = SeriesItem("", "", "", Images(emptyList(), emptyList()), false, emptyList(), emptyList(), 0)
val NoneSeries = Series(1, listOf(NoneSeriesItem))
/**
@ -302,7 +333,7 @@ data class Episode(
@SerialName("is_dubbed") val isDubbed: Boolean,
@SerialName("images") val images: Thumbnail,
@SerialName("duration_ms") val durationMs: Int,
@SerialName("versions") val versions: List<Version>,
@SerialName("versions") val versions: List<Version>? = null,
@SerialName("streams_link") val streamsLink: String,
)
@ -353,7 +384,7 @@ val NoneVersion = Version(
variant = ""
)
typealias Playheads = Collection2<PlayheadObject>
typealias Playheads = CollectionV2<PlayheadObject>
@Serializable
data class PlayheadObject(
@ -449,7 +480,18 @@ data class Benefit(
@SerialName("benefit") val benefit: String,
@SerialName("source") val source: String,
)
@Suppress("unused")
val NoneBenefit = Benefit(
benefit = "",
source = ""
)
/**
* search result typed list data class
*/
@Serializable
data class SearchTypedList<T>(
@SerialName("type") val type: String,
@SerialName("count") val count: Int,
@SerialName("items") val items: List<T>
)

View File

@ -12,13 +12,11 @@ object Preferences {
internal set
var preferredSubtitleLocale: Locale = Locale.forLanguageTag("en-US")
internal set
var preferSubbed = false
internal set
var autoplay = true
internal set
var devSettings = false
internal set
var theme = DataTypes.Theme.DARK
var theme = DataTypes.Theme.SYSTEM
internal set
// dev settings
@ -50,15 +48,6 @@ object Preferences {
this.preferredSubtitleLocale = preferredLocale
}
fun savePreferSecondary(context: Context, preferSubbed: Boolean) {
with(getSharedPref(context).edit()) {
putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSubbed)
apply()
}
this.preferSubbed = preferSubbed
}
fun saveAutoplay(context: Context, autoplay: Boolean) {
with(getSharedPref(context).edit()) {
putBoolean(context.getString(R.string.save_key_autoplay), autoplay)
@ -111,9 +100,6 @@ object Preferences {
context.getString(R.string.save_key_preferred_local), "en-US"
) ?: "en-US"
)
preferSubbed = sharedPref.getBoolean(
context.getString(R.string.save_key_prefer_secondary), false
)
autoplay = sharedPref.getBoolean(
context.getString(R.string.save_key_autoplay), true
)
@ -122,8 +108,8 @@ object Preferences {
)
theme = DataTypes.Theme.valueOf(
sharedPref.getString(
context.getString(R.string.save_key_theme), DataTypes.Theme.DARK.toString()
) ?: DataTypes.Theme.DARK.toString()
context.getString(R.string.save_key_theme), DataTypes.Theme.SYSTEM.toString()
) ?: DataTypes.Theme.SYSTEM.toString()
)
// dev settings

View File

@ -28,6 +28,7 @@ import android.util.Log
import android.view.MenuItem
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
@ -69,7 +70,14 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
super.onCreate(savedInstanceState)
load() // start the initial loading
theme.applyStyle(getThemeResource(), true)
// theming
val mode = when (Preferences.theme) {
DataTypes.Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
DataTypes.Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(mode)
binding = ActivityMainBinding.inflate(layoutInflater)
binding.navView.setOnItemSelectedListener(this)
@ -122,12 +130,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
return ret
}
private fun getThemeResource(): Int {
return when (Preferences.theme) {
DataTypes.Theme.LIGHT -> R.style.AppTheme_Light
else -> R.style.AppTheme_Dark
}
}
// private fun getThemeResource(): Int {
// return when (Preferences.theme) {
// DataTypes.Theme.LIGHT -> R.style.AppTheme_Light
// else -> R.style.AppTheme_Dark
// }
// }
/**
* initial loading and login are run in parallel, as initial loading doesn't require
@ -165,7 +173,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
private fun initCrunchyroll(): List<Job> {
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialCrunchyLoading"))
return listOf(
scope.launch { Crunchyroll.index() },
scope.launch { Crunchyroll.account() },
scope.launch {
// update the local preferred content language, since it may have changed

View File

@ -8,17 +8,15 @@ import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import org.mosad.teapod.BuildConfig
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentAccountBinding
import org.mosad.teapod.parser.crunchyroll.Benefits
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.parser.crunchyroll.Profile
import org.mosad.teapod.parser.crunchyroll.supportedLocals
import org.mosad.teapod.parser.crunchyroll.supportedAudioLocals
import org.mosad.teapod.parser.crunchyroll.supportedSubtitleLocals
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.ui.activity.main.MainActivity
@ -64,15 +62,18 @@ class AccountFragment : Fragment() {
// add preferred subtitles
lifecycleScope.launch {
binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag(
binding.textSettingsAudioLanguageDesc.text = Locale.forLanguageTag(
profile.await().preferredContentAudioLanguage
).displayLanguage
binding.textSettingsSubtitleLanguageDesc.text = Locale.forLanguageTag(
profile.await().preferredContentSubtitleLanguage
).displayLanguage
}
binding.switchSecondary.isChecked = Preferences.preferSubbed
binding.switchAutoplay.isChecked = Preferences.autoplay
binding.textThemeSelected.text = when (Preferences.theme) {
Theme.SYSTEM -> getString(R.string.theme_system)
Theme.LIGHT -> getString(R.string.theme_light)
Theme.DARK -> getString(R.string.theme_dark)
else -> getString(R.string.theme_light)
}
binding.linearDevSettings.isVisible = Preferences.devSettings
@ -88,12 +89,12 @@ class AccountFragment : Fragment() {
showLoginDialog()
}
binding.linearSettingsContentLanguage.setOnClickListener {
showContentLanguageSelection()
binding.linearSettingsAudioLanguage.setOnClickListener {
showAudioLanguageSelection()
}
binding.switchSecondary.setOnClickListener {
Preferences.savePreferSecondary(requireContext(), binding.switchSecondary.isChecked)
binding.linearSettingsSubtitleLanguage.setOnClickListener {
showSubtitleLanguageSelection()
}
binding.switchAutoplay.setOnClickListener {
@ -138,43 +139,86 @@ class AccountFragment : Fragment() {
activity?.let { loginModal.show(it.supportFragmentManager, LoginModalBottomSheet.TAG) }
}
private fun showContentLanguageSelection() {
private fun showAudioLanguageSelection() {
// we should be able to use the index of supportedLocals for language selection, items is GUI only
val items = supportedLocals.map {
val items = supportedAudioLocals.map {
it.toDisplayString(getString(R.string.settings_content_language_none))
}.toTypedArray()
var initialSelection: Int
// profile should be completed here, therefore blocking
runBlocking {
initialSelection = supportedLocals.indexOf(Locale.forLanguageTag(
profile.await().preferredContentSubtitleLanguage))
if (initialSelection < 0) initialSelection = supportedLocals.lastIndex
initialSelection = supportedAudioLocals.indexOf(Locale.forLanguageTag(
profile.await().preferredContentAudioLanguage))
if (initialSelection < 0) initialSelection = supportedAudioLocals.lastIndex
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.settings_content_language)
.setTitle(R.string.settings_audio_language)
.setSingleChoiceItems(items, initialSelection){ dialog, which ->
updatePrefContentLanguage(supportedLocals[which])
updateAudioLanguage(supportedAudioLocals[which])
dialog.dismiss()
}
.show()
}
@kotlinx.coroutines.ExperimentalCoroutinesApi
private fun updatePrefContentLanguage(preferredLocale: Locale) {
private fun showSubtitleLanguageSelection() {
// we should be able to use the index of supportedLocals for language selection, items is GUI only
val items = supportedSubtitleLocals.map {
it.toDisplayString(getString(R.string.settings_content_language_none))
}.toTypedArray()
var initialSelection: Int
// profile should be completed here, therefore blocking
runBlocking {
initialSelection = supportedSubtitleLocals.indexOf(Locale.forLanguageTag(
profile.await().preferredContentSubtitleLanguage))
if (initialSelection < 0) initialSelection = supportedSubtitleLocals.lastIndex
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.settings_audio_language)
.setSingleChoiceItems(items, initialSelection){ dialog, which ->
updateSubtitleLanguage(supportedSubtitleLocals[which])
dialog.dismiss()
}
.show()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun updateAudioLanguage(preferredLocale: Locale) {
lifecycleScope.launch {
Crunchyroll.postPrefSubLanguage(preferredLocale.toLanguageTag())
Crunchyroll.setPreferredAudioLanguage(preferredLocale.toLanguageTag())
}.invokeOnCompletion {
// update the local preferred content language
Preferences.savePreferredSubtitleLocal(requireContext(), preferredLocale)
// update the local preferred audio language
Preferences.savePreferredAudioLocal(requireContext(), preferredLocale)
// update profile since the language selection might have changed
profile = lifecycleScope.async { Crunchyroll.profile() }
profile.invokeOnCompletion {
// update language once loading profile is completed
binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag(
binding.textSettingsAudioLanguageDesc.text = Locale.forLanguageTag(
profile.getCompleted().preferredContentAudioLanguage
).displayLanguage
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun updateSubtitleLanguage(preferredLocal: Locale) {
lifecycleScope.launch {
Crunchyroll.setPreferredSubtitleLanguage(preferredLocal.toLanguageTag())
}.invokeOnCompletion {
// update the local preferred subtitle language
Preferences.savePreferredSubtitleLocal(requireContext(), preferredLocal)
// update profile since the language selection might have changed
profile = lifecycleScope.async { Crunchyroll.profile() }
profile.invokeOnCompletion {
// update language once loading profile is completed
binding.textSettingsAudioLanguageDesc.text = Locale.forLanguageTag(
profile.getCompleted().preferredContentSubtitleLanguage
).displayLanguage
}
@ -183,17 +227,19 @@ class AccountFragment : Fragment() {
private fun showThemeDialog() {
val items = arrayOf(
resources.getString(R.string.theme_system),
resources.getString(R.string.theme_light),
resources.getString(R.string.theme_dark)
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.settings_content_language)
.setTitle(R.string.theme)
.setSingleChoiceItems(items, Preferences.theme.ordinal){ _, which ->
when(which) {
0 -> Preferences.saveTheme(requireContext(), Theme.LIGHT)
1 -> Preferences.saveTheme(requireContext(), Theme.DARK)
else -> Preferences.saveTheme(requireContext(), Theme.DARK)
0 -> Preferences.saveTheme(requireContext(), Theme.SYSTEM)
1 -> Preferences.saveTheme(requireContext(), Theme.LIGHT)
2 -> Preferences.saveTheme(requireContext(), Theme.DARK)
else -> Preferences.saveTheme(requireContext(), Theme.SYSTEM)
}
(activity as MainActivity).restart()

View File

@ -47,7 +47,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
playerFinishedCallback()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentMediaBinding.inflate(inflater, container, false)
return binding.root
@ -91,6 +90,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
// load poster and backdrop
Glide.with(requireContext()).load(posterUrl)
.apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY)))
.into(binding.imagePoster)
Glide.with(requireContext()).load(backdropUrl)
.apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY)))
@ -147,14 +147,14 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
// episodes count
binding.textEpisodesOrRuntime.text = resources.getQuantityString(
R.plurals.text_episodes_count,
episodesCrunchy.total,
episodesCrunchy.total
seriesCrunchy.episodeCount,
seriesCrunchy.episodeCount
)
}
is TMDBMovie -> {
val tmdbMovie = (tmdbResult as TMDBMovie?)
val tmdbMovie = tmdbResult as TMDBMovie
if (tmdbMovie?.runtime != null) {
if (tmdbMovie.runtime != null) {
binding.textEpisodesOrRuntime.text = resources.getQuantityString(
R.plurals.text_runtime,
tmdbMovie.runtime,

View File

@ -1,17 +1,21 @@
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 androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentMyListsBinding
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.ui.activity.main.viewmodel.MyListsFragmentViewModel
import org.mosad.teapod.util.toItemMediaList
class MyListsFragment : Fragment() {
@ -19,6 +23,8 @@ class MyListsFragment : Fragment() {
private lateinit var binding: FragmentMyListsBinding
private lateinit var pagerAdapter: FragmentStateAdapter
private val model: MyListsFragmentViewModel by viewModels()
private val fragments = arrayListOf<Fragment>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -33,7 +39,6 @@ class MyListsFragment : Fragment() {
pagerAdapter = ScreenSlidePagerAdapter(this)
binding.pagerMyLists.adapter = pagerAdapter
// TODO is position 0 always episodes? (and 1 always similar titles)
TabLayoutMediator(binding.tabMyLists, binding.pagerMyLists) { tab, position ->
tab.text = when(position) {
0 -> getString(R.string.my_list)
@ -43,15 +48,33 @@ class MyListsFragment : Fragment() {
}
}.attach()
lifecycleScope.launch {
val items = Crunchyroll.watchlist(50)
MediaFragmentSimilar(items.toItemMediaList()).also {
fragments.add(it)
pagerAdapter.notifyItemInserted(fragments.indexOf(it))
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
model.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
when (uiState) {
is MyListsFragmentViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is MyListsFragmentViewModel.UiState.Loading -> bindUiStateLoading()
is MyListsFragmentViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
}
}
private fun bindUiStateNormal(uiState: MyListsFragmentViewModel.UiState.Normal) {
MediaFragmentSimilar(uiState.watchlistItems.toItemMediaList()).also {
fragments.add(it)
pagerAdapter.notifyItemInserted(fragments.indexOf(it))
}
}
private fun bindUiStateLoading() {
// currently not used
}
private fun bindUiStateError(uiState: MyListsFragmentViewModel.UiState.Error) {
// currently not used
Log.e(javaClass.name, "A error occurred while loading a UiState: ${uiState.message}")
}
/**

View File

@ -32,7 +32,7 @@ import kotlinx.coroutines.launch
import org.mosad.teapod.parser.crunchyroll.*
import kotlin.random.Random
class HomeViewModel : ViewModel() {
class HomeViewModel : ViewModel() {
private val WATCHLIST_LENGTH = 50
@ -66,16 +66,16 @@ class HomeViewModel : ViewModel() {
uiState.emit(UiState.Loading)
try {
// run the loading in parallel to speed up the process
val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().data }
val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(WATCHLIST_LENGTH).items }
val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount(n = 20).data }
val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(WATCHLIST_LENGTH).data }
val recommendationsJob = viewModelScope.async {
Crunchyroll.recommendations(20).items
Crunchyroll.recommendations(n = 20).data