Compare commits

...

12 Commits

Author SHA1 Message Date
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
23 changed files with 402 additions and 240 deletions

View File

@ -4,16 +4,23 @@ plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
}
kotlin {
jvmToolchain 11
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 33
versionCode 100991 //01.00.000
versionName "1.1.0-beta2"
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.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
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-alpha05'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
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.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation "androidx.paging:paging-runtime-ktx:3.2.1"
implementation 'com.google.android.material:material:1.8.0'
implementation 'com.google.android.material:material:1.11.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"

View File

@ -34,7 +34,6 @@ import io.ktor.http.*
import io.ktor.serialization.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
@ -59,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>()
/**
@ -213,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.
@ -261,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
@ -303,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()
}
@ -321,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,
@ -352,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
}
}
@ -508,7 +494,7 @@ 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)
@ -630,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 {
@ -658,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(),
@ -680,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(),
@ -698,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 {
@ -740,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)
@ -749,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(
@ -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,8 +12,6 @@ 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
@ -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
)

View File

@ -173,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

@ -15,7 +15,8 @@ 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
@ -61,11 +62,13 @@ 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)
@ -86,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 {
@ -136,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()
}
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 updatePrefContentLanguage(preferredLocale: Locale) {
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
}

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
}
val recentlyAddedJob = viewModelScope.async {
Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50).items
Crunchyroll.browse(sortBy = SortBy.NEWLY_ADDED, n = 50).data
}
val topTenJob = viewModelScope.async {
Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).items
Crunchyroll.browse(sortBy = SortBy.POPULARITY, n = 10).data
}
val recentlyAddedItems = recentlyAddedJob.await()
@ -114,7 +114,7 @@ class HomeViewModel : ViewModel() {
}
// update the watchlist after a item has been added/removed
val watchlistItems = Crunchyroll.watchlist(WATCHLIST_LENGTH).items
val watchlistItems = Crunchyroll.watchlist(WATCHLIST_LENGTH).data
currentUiState.copy(
watchlistItems = watchlistItems,
@ -133,7 +133,7 @@ class HomeViewModel : ViewModel() {
viewModelScope.launch {
uiState.update { currentUiState ->
if (currentUiState is UiState.Normal) {
val upNextItems = Crunchyroll.upNextAccount().data
val upNextItems = Crunchyroll.upNextAccount(n = 20).data
currentUiState.copy(upNextItems = upNextItems)
} else {
currentUiState

View File

@ -90,7 +90,7 @@ class LibraryFragmentViewModel : ViewModel() {
delay(250)
val results = Crunchyroll.search(query, 50)
.items.firstOrNull()?.items?.toItemMediaList()
.data.firstOrNull()?.items?.toItemMediaList()
?: listOf()
uiState.emit(UiState.Search(results))
}

View File

@ -0,0 +1,50 @@
package org.mosad.teapod.ui.activity.main.viewmodel
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.parser.crunchyroll.Item
class MyListsFragmentViewModel : ViewModel() {
private val WATCHLIST_LENGTH = 50
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
sealed class UiState {
object Loading : UiState()
data class Normal(
val watchlistItems: List<Item>
) : 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 watchlistJob = viewModelScope.async { Crunchyroll.watchlist(WATCHLIST_LENGTH).data }
uiState.emit(
UiState.Normal(watchlistJob.await())
)
} catch (e: Exception) {
uiState.emit(UiState.Error(e.message))
}
}
}
}

View File

@ -222,12 +222,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
// needs to be blocking, currentPlayback must be present when calling playCurrentMedia()
joinAll(
viewModelScope.launch(Dispatchers.IO) {
currentVersion = if (Preferences.preferSubbed) {
currentEpisode.versions?.first { it.original } ?: NoneVersion
} else {
currentEpisode.versions?.firstOrNull { it.audioLocale == currentAudioLocale.toLanguageTag() }
?: currentEpisode.versions?.first() ?: NoneVersion
}
currentVersion = currentEpisode.versions?.firstOrNull {
it.audioLocale == currentAudioLocale.toLanguageTag()
} ?: currentEpisode.versions?.first() ?: NoneVersion
// get the current streams object, if no version is set, use streamsLink
currentStreams = if (currentVersion != NoneVersion) {

View File

@ -9,12 +9,11 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.Fragment
import org.mosad.teapod.R
import org.mosad.teapod.parser.crunchyroll.Collection
import org.mosad.teapod.parser.crunchyroll.Collection2
import org.mosad.teapod.parser.crunchyroll.CollectionV2
import org.mosad.teapod.parser.crunchyroll.Item
import org.mosad.teapod.parser.crunchyroll.PlayheadObject
import org.mosad.teapod.ui.activity.player.PlayerActivity
import java.util.*
import java.util.Locale
/**
* Create a Intent for PlayerActivity with season and episode id.
@ -36,8 +35,8 @@ fun <T> concatenate(vararg lists: List<T>): List<T> {
}
// TODO move to correct location
fun Collection<Item>.toItemMediaList(): List<ItemMedia> {
return this.items.map {
fun CollectionV2<Item>.toItemMediaList(): List<ItemMedia> {
return this.data.map {
ItemMedia(it.id, it.title, it.images.poster_wide[0][0].source)
}
}
@ -59,7 +58,7 @@ fun Locale.toDisplayString(fallback: String): String {
}
}
fun Collection2<PlayheadObject>.toPlayheadsMap(): Map<String, PlayheadObject> {
fun CollectionV2<PlayheadObject>.toPlayheadsMap(): Map<String, PlayheadObject> {
return this.data.associateBy { it.contentId }
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3v9.28c-0.47,-0.17 -0.97,-0.28 -1.5,-0.28C8.01,12 6,14.01 6,16.5S8.01,21 10.5,21c2.31,0 4.2,-1.75 4.45,-4H15V6h4V3h-7z"/>
</vector>

View File

@ -141,7 +141,7 @@
android:textStyle="bold" />
<LinearLayout
android:id="@+id/linear_settings_content_language"
android:id="@+id/linear_settings_audio_language"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
@ -151,12 +151,12 @@
android:id="@+id/imageView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/settings_content_language"
android:contentDescription="@string/settings_audio_language"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="9dp"
android:scaleType="fitXY"
android:src="@drawable/ic_baseline_language_24" />
android:src="@drawable/ic_baseline_audiotrack_24" />
<LinearLayout
android:layout_width="match_parent"
@ -167,11 +167,11 @@
android:id="@+id/text_settings_content_language"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_content_language"
android:text="@string/settings_audio_language"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<TextView
android:id="@+id/text_settings_content_language_desc"
android:id="@+id/text_settings_audio_language_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_content_language_desc" />
@ -179,67 +179,41 @@
</LinearLayout>
<LinearLayout
android:id="@+id/linear_settings_secondary"
android:id="@+id/linear_settings_subtitle_language"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="horizontal"
android:padding="7dp">
<ImageView
android:id="@+id/imageView3"
android:id="@+id/imageView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/settings_prefer_subbed"
android:contentDescription="@string/settings_subtitle_language"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="9dp"
android:scaleType="fitXY"
android:src="@drawable/ic_baseline_subtitles_24" />
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="0dp"
<TextView
android:id="@+id/text_settings_subtitle_language"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/switch_secondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
android:text="@string/settings_subtitle_language"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<TextView
android:id="@+id/text_settings_secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_prefer_subbed"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<TextView
android:id="@+id/text_settings_secondary_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:maxLines="2"
android:text="@string/settings_prefer_subbed_desc" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_secondary"
android:layout_width="wrap_content"
<TextView
android:id="@+id/text_settings_subtitle_language_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="true"
android:contentDescription="@string/settings_prefer_subbed"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
android:text="@string/settings_content_language_desc" />
</LinearLayout>
</LinearLayout>
<LinearLayout

View File

@ -45,7 +45,8 @@
<string name="info">Info</string>
<string name="info_about_desc">Version %1$s (%2$s)</string>
<string name="settings">Einstellungen</string>
<string name="settings_content_language">Bevorzuge Inhaltssprache</string>
<string name="settings_audio_language">Audio Sprache</string>
<string name="settings_subtitle_language">Untertielsprache</string>
<string name="settings_content_language_desc">Englisch</string>
<string name="settings_content_language_none">Keine</string>
<string name="settings_prefer_subbed">Bevorzuge OmU</string>

View File

@ -59,7 +59,8 @@
<string name="account_tier_mega_fan" translatable="false">Mega Fan</string>
<string name="account_tier_ultimate_fan" translatable="false">Ultimate Fan</string>
<string name="settings">Settings</string>
<string name="settings_content_language">Preferred content language</string>
<string name="settings_audio_language">Audio language</string>
<string name="settings_subtitle_language">Subtitle language</string>
<string name="settings_content_language_desc">English</string>
<string name="settings_content_language_none">None</string>
<string name="settings_prefer_subbed">Prefer subbed</string>

View File

@ -1,14 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.8.10"
ext.ktor_version = "2.2.4"
ext.exo_version = "2.18.5"
ext.kotlin_version = "1.9.22"
ext.ktor_version = "2.3.6"
ext.exo_version = "2.18.7"
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.0.0'
classpath 'com.android.tools.build:gradle:8.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
@ -23,6 +23,6 @@ allprojects {
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
tasks.register('clean', Delete) {
delete rootProject.layout.buildDirectory
}

View File

@ -0,0 +1,10 @@
Dies ist der dritte beta Release von Teapod 1.1.
* Neues App Design (Material Design 3)
* Unterstützung für Crunchyroll v2 API
* Intro überspringen hinzugefügt
* Seperaten Screen für "Meine Liste"
* Dynamische Spaltenanzahl für alle Screens um große Bildschirme besser zu unterstützen
* Kleine UI/UX Verbesserungen
Alle Änderungen: https://git.mosad.xyz/Seil0/teapod/compare/1.0.0...1.1.0-beta3

View File

@ -0,0 +1,10 @@
This is the third beta release of Teapod 1.1.
* Migrate to material design 3
* Migrate crunchyroll parser to v2 (fixes crunchyroll)
* Add skip intro function
* Add a separate Watchlist fragment
* Dynamically set column count based on the display size
* Minor UI/UX improvements
Full changelog: https://git.mosad.xyz/Seil0/teapod/compare/1.0.0...1.1.0-beta3

View File

@ -16,9 +16,8 @@ org.gradle.jvmargs=-Xmx2048m
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
android.enableJetifier=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

Binary file not shown.

View File

@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

29
gradlew vendored
View File

@ -83,10 +83,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.