fix crunchyroll parser to work with the latest api changes

This commit is contained in:
Jannik 2023-02-19 14:21:46 +01:00
parent 097383a082
commit 8b7fb3ac5f
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
19 changed files with 375 additions and 198 deletions

View File

@ -31,6 +31,7 @@ import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.serialization.SerializationException
@ -245,7 +246,7 @@ object Crunchyroll {
val account: Account = try {
requestGet(indexEndpoint)
} catch (ex: SerializationException) {
} catch (ex: Exception) {
Log.e(TAG, "SerializationException in account(). This is bad!", ex)
NoneAccount
}
@ -275,7 +276,7 @@ object Crunchyroll {
): BrowseResult {
val browseEndpoint = "/content/v1/browse"
val parameters = mutableListOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"sort_by" to sortBy.str,
"start" to start,
"n" to n
@ -298,7 +299,7 @@ object Crunchyroll {
Log.d(TAG, "browse result not cached, fetching: $parameters")
val browseResult: BrowseResult = try {
requestGet(browseEndpoint, parameters)
}catch (ex: SerializationException) {
}catch (ex: Exception) {
Log.e(TAG, "SerializationException in browse().", ex)
NoneBrowseResult
}
@ -328,7 +329,7 @@ object Crunchyroll {
suspend fun search(query: String, n: Int = 10): SearchResult {
val searchEndpoint = "/content/v1/search"
val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"q" to query,
"n" to n,
"type" to "series"
@ -339,8 +340,8 @@ object Crunchyroll {
return try {
requestGet(searchEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in search(), with query = \"$query\".", ex)
NoneSearchResult
}
}
@ -355,7 +356,7 @@ object Crunchyroll {
suspend fun objects(objects: List<String>): Collection<Item> {
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"Signature" to signature,
"Policy" to policy,
"Key-Pair-Id" to keyPairID
@ -363,8 +364,8 @@ object Crunchyroll {
return try {
requestGet(episodesEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in objects().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in objects().", ex)
NoneCollection
}
}
@ -375,12 +376,12 @@ object Crunchyroll {
@Suppress("unused")
suspend fun seasonList(): DiscSeasonList {
val seasonListEndpoint = "/content/v1/season_list"
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())
return try {
requestGet(seasonListEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in seasonList().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in seasonList().", ex)
NoneDiscSeasonList
}
}
@ -395,7 +396,7 @@ object Crunchyroll {
suspend fun series(seriesId: String): Series {
val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId"
val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"Signature" to signature,
"Policy" to policy,
"Key-Pair-Id" to keyPairID
@ -403,8 +404,8 @@ object Crunchyroll {
return try {
requestGet(seriesEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in series().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in series().", ex)
NoneSeries
}
}
@ -415,18 +416,21 @@ object Crunchyroll {
* @param seriesId The series id for which to call up next
* @return A **[UpNextSeriesItem]** with a Panel representing the up next episode
*/
suspend fun upNextSeries(seriesId: String): UpNextSeriesItem {
val upNextSeriesEndpoint = "/content/v1/up_next_series"
suspend fun upNextSeries(seriesId: String): UpNextSeriesList {
val upNextSeriesEndpoint = "/content/v2/discover/up_next/$seriesId"
val parameters = listOf(
"series_id" to seriesId,
"locale" to Preferences.preferredLocale.toLanguageTag()
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
)
return try {
requestGet(upNextSeriesEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in upNextSeries().", ex)
NoneUpNextSeriesItem
} catch (ex: JsonConvertException) {
Log.e(TAG, "JsonConvertException in upNextSeries() with seriesId=$seriesId", ex)
NoneUpNextSeriesList
} catch (ex: Exception) {
Log.e(TAG, "Exception in upNextSeries().", ex)
NoneUpNextSeriesList
}
}
@ -439,14 +443,14 @@ object Crunchyroll {
suspend fun seasons(seriesId: String): Seasons {
val seasonsEndpoint = "/content/v2/cms/series/$seriesId/seasons"
val parameters = listOf(
"preferred_audio_language" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredLocale.toLanguageTag()
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
)
return try {
requestGet(seasonsEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in seasons().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in seasons().", ex)
NoneSeasons
}
}
@ -460,14 +464,14 @@ object Crunchyroll {
suspend fun episodes(seasonId: String): Episodes {
val episodesEndpoint = "/content/v2/cms/seasons/$seasonId/episodes"
val parameters = listOf(
"preferred_audio_language" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredLocale.toLanguageTag()
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
)
return try {
requestGet(episodesEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in episodes().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in episodes().", ex)
NoneEpisodes
}
}
@ -480,18 +484,23 @@ object Crunchyroll {
*/
suspend fun streams(url: String): Streams {
val parameters = listOf(
"preferred_audio_language" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredLocale.toLanguageTag()
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
)
return try {
requestGet(url, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in streams().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in streams().", ex)
NoneStreams
}
}
suspend fun streamsFromMediaGUID(mediaGUID: String): Streams {
val streamsEndpoint = "/content/v2/cms/videos/$mediaGUID/streams"
return streams(streamsEndpoint)
}
/**
* Additional media functions: watchlist (series), playhead, similar to
*/
@ -504,13 +513,13 @@ object Crunchyroll {
*/
suspend fun isWatchlist(seriesId: String): Boolean {
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())
return try {
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
.containsKey(seriesId)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in isWatchlist() with seriesId = $seriesId", ex)
false
}
}
@ -522,13 +531,18 @@ object Crunchyroll {
*/
suspend fun postWatchlist(seriesId: String) {
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())
val json = buildJsonObject {
put("content_id", seriesId)
}
requestPost(watchlistPostEndpoint, parameters, json)
try {
requestPost(watchlistPostEndpoint, parameters, json)
} catch (ex: Exception) {
Log.e(TAG, "Exception in postWatchlist() with seriesId = $seriesId", ex)
}
}
/**
@ -538,9 +552,14 @@ object Crunchyroll {
*/
suspend fun deleteWatchlist(seriesId: String) {
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())
try {
requestDelete(watchlistDeleteEndpoint, parameters)
} catch (ex: Exception) {
Log.e(TAG, "Exception in deleteWatchlist() with seriesId = $seriesId", ex)
}
requestDelete(watchlistDeleteEndpoint, parameters)
}
/**
@ -553,7 +572,7 @@ object Crunchyroll {
*/
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())
return try {
requestGet(playheadsEndpoint, parameters)
@ -574,7 +593,7 @@ object Crunchyroll {
*/
suspend fun postPlayheads(episodeId: String, playhead: Int) {
val playheadsEndpoint = "/content/v1/playheads/$accountID"
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())
val json = buildJsonObject {
put("content_id", episodeId)
@ -583,7 +602,7 @@ object Crunchyroll {
try {
requestPost(playheadsEndpoint, parameters, json)
} catch (ex: Throwable) {
} catch (ex: Exception) {
Log.e(TAG, "Exception in postPlayheads()", ex.cause)
}
}
@ -603,8 +622,8 @@ object Crunchyroll {
return try {
val response: HttpResponse = requestGet(datalabIntroEndpoint, url = staticUrl)
Json.decodeFromString(response.bodyAsText())
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in datalabIntro(). EpisodeId=$episodeId", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in datalabIntro(). EpisodeId=$episodeId", ex)
NoneDatalabIntro
}
}
@ -620,14 +639,14 @@ object Crunchyroll {
val similarToEndpoint = "/content/v1/$accountID/similar_to"
val parameters = listOf(
"guid" to seriesId,
"locale" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"n" to n
)
return try {
requestGet(similarToEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in similarTo().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in similarTo().", ex)
NoneSimilarToResult
}
}
@ -640,23 +659,24 @@ object Crunchyroll {
* List items present in the watchlist.
*
* @param n Number of items to return, defaults to 20.
* @return A **[Watchlist]** containing up to n **[Item]**.
* @return A **[Collection]** containing up to n **[Item]**.
*/
suspend fun watchlist(n: Int = 20): Watchlist {
val watchlistEndpoint = "/content/v1/$accountID/watchlist"
suspend fun watchlist(n: Int = 20): Collection<Item> {
val watchlistEndpoint = "/content/v2/discover/$accountID/watchlist"
val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"n" to n
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"n" to n,
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag()
)
val list: ContinueWatchingList = try {
val list: Watchlist = try {
requestGet(watchlistEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in watchlist().", ex)
NoneContinueWatchingList
} catch (ex: Exception) {
Log.e(TAG, "Exception in watchlist().", ex)
NoneWatchlist
}
val objects = list.items.map{ it.panel.episodeMetadata.seriesId }
val objects = list.data.map{ it.panel.episodeMetadata.seriesId }
return objects(objects)
}
@ -664,27 +684,27 @@ object Crunchyroll {
* List the next up episodes for the logged in account.
*
* @param n Number of items to return, defaults to 20.
* @return A **[ContinueWatchingList]** containing up to n **[ContinueWatchingItem]**.
* @return A **[HistoryList]** containing up to n **[UpNextAccountItem]**.
*/
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
suspend fun upNextAccount(n: Int = 20): HistoryList {
val watchlistEndpoint = "/content/v2/discover/$accountID/history"
val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"n" to n
)
return try {
requestGet(watchlistEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in upNextAccount().", ex)
NoneContinueWatchingList
} catch (ex: Exception) {
Log.e(TAG, "Exception in upNextAccount().", ex)
NoneHistoryList
}
}
suspend fun recommendations(n: Int = 20, start: Int = 0): RecommendationsList {
val recommendationsEndpoint = "/content/v1/$accountID/recommendations"
val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
"n" to n,
"start" to start,
"variant_id" to 0
@ -692,8 +712,8 @@ object Crunchyroll {
return try {
requestGet(recommendationsEndpoint, parameters)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in recommendations().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in recommendations().", ex)
NoneRecommendationsList
}
}
@ -712,8 +732,8 @@ object Crunchyroll {
return try {
requestGet(profileEndpoint)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in profile().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in profile().", ex)
NoneProfile
}
}
@ -742,8 +762,8 @@ object Crunchyroll {
return try {
requestGet(profileEndpoint)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in benefits().", ex)
} catch (ex: Exception) {
Log.e(TAG, "Exception in benefits().", ex)
NoneBenefits
}
}

View File

@ -117,24 +117,23 @@ data class Collection<T>(
@SerialName("items") val items: List<T>
)
@Serializable
data class Collection2<T>(
@SerialName("total") val total: Int,
@SerialName("data") val data: List<T>
)
typealias SearchResult = Collection<SearchCollection>
typealias SearchCollection = Collection<Item>
typealias BrowseResult = Collection<Item>
typealias SimilarToResult = Collection<Item>
typealias DiscSeasonList = Collection<SeasonListItem>
typealias Watchlist = Collection<Item>
typealias ContinueWatchingList = Collection<ContinueWatchingItem>
typealias Watchlist = Collection2<WatchlistItem>
typealias HistoryList = Collection2<UpNextAccountItem>
typealias UpNextSeriesList = Collection2<UpNextSeriesItem>
typealias RecommendationsList = Collection<Item>
typealias Benefits = Collection<Benefit>
@Serializable
data class UpNextSeriesItem(
@SerialName("playhead") val playhead: Int,
@SerialName("fully_watched") val fullyWatched: Boolean,
@SerialName("never_watched") val neverWatched: Boolean,
@SerialName("panel") val panel: EpisodePanel,
)
/**
* panel data classes
*/
@ -178,18 +177,32 @@ data class SeasonListLocalization(
/**
* continue_watching_item data classes
*/
@Serializable
data class ContinueWatchingItem(
data class WatchlistItem(
@SerialName("panel") val panel: EpisodePanel,
@SerialName("new") val new: Boolean,
@SerialName("new_content") val newContent: Boolean,
// not present in up_next_account -> continue_watching_item
// @SerialName("is_favorite") val isFavorite: Boolean,
// @SerialName("never_watched") val neverWatched: Boolean,
// @SerialName("completion_status") val completionStatus: Boolean,
@SerialName("playhead") val playhead: Int,
// not present in watchlist -> continue_watching_item
@SerialName("fully_watched") val fullyWatched: Boolean = false,
@SerialName("never_watched") val neverWatched: Boolean = false,
@SerialName("is_favorite") val isFavorite: Boolean,
)
@Serializable
data class UpNextAccountItem(
@SerialName("panel") val panel: EpisodePanel,
@SerialName("new") val new: Boolean,
@SerialName("playhead") val playhead: Int,
@SerialName("fully_watched") val fullyWatched: Boolean = false,
)
@Serializable
data class UpNextSeriesItem(
@SerialName("panel") val panel: EpisodePanel,
@SerialName("playhead") val playhead: Int,
@SerialName("fully_watched") val fullyWatched: Boolean,
@SerialName("never_watched") val neverWatched: Boolean,
)
// EpisodePanel is used in ContinueWatchingItem and UpNextSeriesItem
@ -202,7 +215,7 @@ data class EpisodePanel(
@SerialName("description") val description: String,
@SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata,
@SerialName("images") val images: Thumbnail,
@SerialName("playback") val playback: String,
// @SerialName("streams_link") val streamsLink: String,
)
@Serializable
@ -218,24 +231,19 @@ data class EpisodeMetadata(
val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList()))
val NoneEpisodeMetadata = EpisodeMetadata(0, 0, "", 0, "", "", "")
val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "")
val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf())) //, "")
val NoneCollection = Collection<Item>(0, emptyList())
val NoneSearchResult = SearchResult(0, emptyList())
val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneSimilarToResult = SimilarToResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneWatchlist = Watchlist(0, emptyList())
val NoneHistoryList = HistoryList(0, emptyList())
val NoneUpNextSeriesList = UpNextSeriesList(0, emptyList())
val NoneRecommendationsList = RecommendationsList(0, emptyList())
val NoneBenefits = Benefits(0, emptyList())
val NoneUpNextSeriesItem = UpNextSeriesItem(
playhead = 0,
fullyWatched = false,
neverWatched = false,
panel = NoneEpisodePanel
)
/**
* series data class
*/
@ -309,7 +317,7 @@ data class Episode(
@SerialName("is_dubbed") val isDubbed: Boolean,
@SerialName("images") val images: Thumbnail,
@SerialName("duration_ms") val durationMs: Int,
@SerialName("playback") val playback: String,
@SerialName("versions") val versions: List<Version>,
@SerialName("streams_link") val streamsLink: String,
)
@ -318,6 +326,17 @@ data class Thumbnail(
@SerialName("thumbnail") val thumbnail: List<List<Poster>>
)
@Serializable
data class Version(
@SerialName("audio_locale") val audioLocale: String,
@SerialName("guid") val guid: String,
@SerialName("is_premium_only") val isPremiumOnly: Boolean,
@SerialName("media_guid") val mediaGUID: String,
@SerialName("original") val original: Boolean,
@SerialName("season_guid") val seasonGUID: String,
@SerialName("variant") val variant: String,
)
val NoneEpisodes = Episodes(0, listOf())
val NoneEpisode = Episode(
id = "",
@ -335,10 +354,20 @@ val NoneEpisode = Episode(
isDubbed = false,
images = Thumbnail(listOf()),
durationMs = 0,
playback = "",
versions = emptyList(),
streamsLink = ""
)
val NoneVersion = Version(
audioLocale = "",
guid = "",
isPremiumOnly = false,
mediaGUID = "",
original = true,
seasonGUID = "",
variant = ""
)
typealias PlayheadsMap = Map<String, PlayheadObject>
@Serializable
@ -412,6 +441,7 @@ data class Profile(
@SerialName("avatar") val avatar: String,
@SerialName("email") val email: String,
@SerialName("maturity_rating") val maturityRating: String,
@SerialName("preferred_content_audio_language") val preferredContentAudioLanguage: String,
@SerialName("preferred_content_subtitle_language") val preferredContentSubtitleLanguage: String,
@SerialName("username") val username: String,
)
@ -419,6 +449,7 @@ val NoneProfile = Profile(
avatar = "",
email = "",
maturityRating = "",
preferredContentAudioLanguage = "",
preferredContentSubtitleLanguage = "",
username = ""
)

View File

@ -8,7 +8,9 @@ import java.util.*
object Preferences {
var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start
var preferredAudioLocale: Locale = Locale.forLanguageTag("en-US")
internal set
var preferredSubtitleLocale: Locale = Locale.forLanguageTag("en-US")
internal set
var preferSubbed = false
internal set
@ -30,13 +32,22 @@ object Preferences {
)
}
fun savePreferredLocal(context: Context, preferredLocale: Locale) {
fun savePreferredAudioLocal(context: Context, preferredLocale: Locale) {
with(getSharedPref(context).edit()) {
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
apply()
}
this.preferredLocale = preferredLocale
this.preferredAudioLocale = preferredLocale
}
fun savePreferredSubtitleLocal(context: Context, preferredLocale: Locale) {
with(getSharedPref(context).edit()) {
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
apply()
}
this.preferredSubtitleLocale = preferredLocale
}
fun savePreferSecondary(context: Context, preferSubbed: Boolean) {
@ -90,7 +101,12 @@ object Preferences {
fun load(context: Context) {
val sharedPref = getSharedPref(context)
preferredLocale = Locale.forLanguageTag(
preferredAudioLocale = Locale.forLanguageTag(
sharedPref.getString(
context.getString(R.string.save_key_preferred_audio_local), "en-US"
) ?: "en-US"
)
preferredSubtitleLocale = Locale.forLanguageTag(
sharedPref.getString(
context.getString(R.string.save_key_preferred_local), "en-US"
) ?: "en-US"

View File

@ -169,9 +169,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
scope.launch { Crunchyroll.account() },
scope.launch {
// update the local preferred content language, since it may have changed
val locale = Locale.forLanguageTag(Crunchyroll.profile().preferredContentSubtitleLanguage)
Preferences.savePreferredLocal(this@MainActivity, locale)
val profile = Crunchyroll.profile()
val audioLocale = Locale.forLanguageTag(profile.preferredContentAudioLanguage)
val subtitleLocale = Locale.forLanguageTag(profile.preferredContentSubtitleLanguage)
Preferences.savePreferredAudioLocal(this@MainActivity, audioLocale)
Preferences.savePreferredSubtitleLocal(this@MainActivity, subtitleLocale)
}
)
}

View File

@ -168,7 +168,7 @@ class AccountFragment : Fragment() {
}.invokeOnCompletion {
// update the local preferred content language
Preferences.savePreferredLocal(requireContext(), preferredLocale)
Preferences.savePreferredSubtitleLocal(requireContext(), preferredLocale)
// update profile since the language selection might have changed
profile = lifecycleScope.async { Crunchyroll.profile() }

View File

@ -20,7 +20,7 @@ import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.coroutines.launch
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentMediaBinding
import org.mosad.teapod.parser.crunchyroll.NoneUpNextSeriesItem
import org.mosad.teapod.parser.crunchyroll.NoneUpNextSeriesList
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
import org.mosad.teapod.util.playerIntent
import org.mosad.teapod.util.tmdb.TMDBApiController
@ -104,8 +104,8 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
}
binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull()
binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesItem) {
upNextSeries.panel.title
binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesList) {
upNextSeries.data.first().panel.title
} else seriesCrunchy.title
binding.textOverview.text = seriesCrunchy.description
@ -174,8 +174,9 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private fun initActions() = with(model) {
binding.buttonPlay.setOnClickListener {
if (upNextSeries != NoneUpNextSeriesItem) {
playEpisode(upNextSeries.panel.episodeMetadata.seasonId, upNextSeries.panel.id)
if (upNextSeries != NoneUpNextSeriesList) {
val panel = upNextSeries.data.first().panel
playEpisode(panel.episodeMetadata.seasonId, panel.id)
}
}
@ -199,8 +200,8 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private fun playerFinishedCallback() = lifecycleScope.launch {
model.updateOnResume()
if (model.upNextSeries != NoneUpNextSeriesItem) {
binding.textTitle.text = model.upNextSeries.panel.title
if (model.upNextSeries != NoneUpNextSeriesList) {
binding.textTitle.text = model.upNextSeries.data.first().panel.title
}
// needs to be called after model.updateOnResume()

View File

@ -44,7 +44,7 @@ class MyListsFragment : Fragment() {
}.attach()
lifecycleScope.launch {
val items = Crunchyroll.watchlist(50).items
val items = Crunchyroll.watchlist(50)
MediaFragmentSimilar(items.toItemMediaList()).also {
fragments.add(it)

View File

@ -26,7 +26,8 @@ import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.mosad.teapod.parser.crunchyroll.*
import kotlin.random.Random
@ -40,7 +41,7 @@ class HomeViewModel : ViewModel() {
sealed class UiState {
object Loading : UiState()
data class Normal(
val upNextItems: List<ContinueWatchingItem>,
val upNextItems: List<UpNextAccountItem>,
val watchlistItems: List<Item>,
val recommendationsItems: List<Item>,
val recentlyAddedItems: List<Item>,
@ -65,7 +66,7 @@ class HomeViewModel : ViewModel() {
uiState.emit(UiState.Loading)
try {
// run the loading in parallel to speed up the process
val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().items }
val upNextJob = viewModelScope.async { Crunchyroll.upNextAccount().data }
val watchlistJob = viewModelScope.async { Crunchyroll.watchlist(WATCHLIST_LENGTH).items }
val recommendationsJob = viewModelScope.async {
Crunchyroll.recommendations(20).items
@ -81,7 +82,7 @@ class HomeViewModel : ViewModel() {
// FIXME crashes on newTitles.items.size == 0
val highlightItem = recentlyAddedItems[Random.nextInt(recentlyAddedItems.size)]
val highlightItemUpNextJob = viewModelScope.async {
Crunchyroll.upNextSeries(highlightItem.id)
Crunchyroll.upNextSeries(highlightItem.id).data.first()
}
val highlightItemIsWatchlistJob = viewModelScope.async {
Crunchyroll.isWatchlist(highlightItem.id)
@ -132,7 +133,7 @@ class HomeViewModel : ViewModel() {
viewModelScope.launch {
uiState.update { currentUiState ->
if (currentUiState is UiState.Normal) {
val upNextItems = Crunchyroll.upNextAccount().items
val upNextItems = Crunchyroll.upNextAccount().data
currentUiState.copy(upNextItems = upNextItems)
} else {
currentUiState

View File

@ -30,7 +30,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
val currentPlayheads: MutableMap<String, PlayheadObject> = mutableMapOf()
var isWatchlist = false
internal set
var upNextSeries = NoneUpNextSeriesItem
var upNextSeries = NoneUpNextSeriesList
internal set
var similarTo = NoneSimilarToResult
internal set
@ -60,8 +60,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// load the preferred season:
// next episode > preferred language (language per season, not per stream)
currentSeasonCrunchy = seasonsCrunchy.data.firstOrNull{ season ->
season.id == upNextSeries.panel.episodeMetadata.seasonId
} ?: seasonsCrunchy.getPreferredSeasonByLocal(Preferences.preferredLocale)
season.id == upNextSeries.data.first().panel.episodeMetadata.seasonId
} ?: seasonsCrunchy.getPreferredSeasonByLocal(Preferences.preferredSubtitleLocale)
// Note: if we need to query metaDB, do it now
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)

View File

@ -75,10 +75,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
internal set
var currentEpisode = NoneEpisode
internal set
var currentVersion = NoneVersion
internal set
var currentStreams = NoneStreams
internal set
// current playback settings
var currentLanguage: Locale = Preferences.preferredLocale
var currentAudioLocale: Locale = Preferences.preferredAudioLocale
internal set
var currentSubtitleLocale: Locale = Preferences.preferredSubtitleLocale
internal set
init {
@ -146,9 +151,31 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
playCurrentMedia(currentPlayhead)
}
fun setLanguage(language: Locale) {
currentLanguage = language
playCurrentMedia(player.currentPosition)
fun setLanguage(newAudioLocale: Locale, newSubtitleLocale: Locale) {
// TODO if the audio locale has changes update the streams, if only the subtitle locale has changed load the new stream
if (newAudioLocale != currentAudioLocale) {
currentAudioLocale = newAudioLocale
currentVersion = currentEpisode.versions.firstOrNull {
it.audioLocale == currentAudioLocale.toLanguageTag()
} ?: currentEpisode.versions.first()
viewModelScope.launch {
currentStreams = Crunchyroll.streamsFromMediaGUID(currentVersion.mediaGUID)
Log.d(classTag, currentVersion.toString())
playCurrentMedia(player.currentPosition)
}
} else if (newSubtitleLocale != currentSubtitleLocale) {
currentSubtitleLocale = newSubtitleLocale
playCurrentMedia(player.currentPosition)
}
println(newSubtitleLocale != currentSubtitleLocale)
println("currentSubtitleLocale: $currentSubtitleLocale")
println("newSubtitleLocale: $newSubtitleLocale")
// else nothing has changed so no need do do anything
}
// player actions
@ -198,8 +225,17 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
// needs to be blocking, currentPlayback must be present when calling playCurrentMedia()
joinAll(
viewModelScope.launch(Dispatchers.IO) {
currentStreams = Crunchyroll.streams(currentEpisode.streamsLink)
println("stream: $Streams")
currentVersion = if (Preferences.preferSubbed) {
currentEpisode.versions.first { it.original }
} else {
currentEpisode.versions
.firstOrNull { it.audioLocale == currentAudioLocale.toLanguageTag() }
?: currentEpisode.versions.first()
}
currentStreams = Crunchyroll.streamsFromMediaGUID(currentVersion.mediaGUID)
Log.d(classTag, currentVersion.toString())
println("stream: $currentStreams")
},
viewModelScope.launch(Dispatchers.IO) {
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
@ -215,7 +251,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
currentIntroMetadata = NoneDatalabIntro //Crunchyroll.datalabIntro(currentEpisode.id)
}
)
Log.d(classTag, "playback: ${currentEpisode.streamsLink}")
Log.d(classTag, "streams: ${currentEpisode.streamsLink}")
if (startPlayback) {
playCurrentMedia()
@ -223,25 +259,25 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
}
/**
* Play the current media from currentPlaybackCr.
* Play the current media from currentStreams.
*
* @param seekPosition The seek position for the episode (default = 0).
* @param seekPosition The seek position for the media (default = 0).
*/
fun playCurrentMedia(seekPosition: Long = 0) {
// get preferred stream url, set current language if it differs from the preferred one
val preferredLocale = currentLanguage
val preferredLocale = currentSubtitleLocale
val fallbackLocal = Locale.US
val url = when {
currentStreams.data[0].adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> {
currentStreams.data[0].adaptive_hls[preferredLocale.toLanguageTag()]?.url
}
currentStreams.data[0].adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> {
currentLanguage = fallbackLocal
currentSubtitleLocale = fallbackLocal
currentStreams.data[0].adaptive_hls[fallbackLocal.toLanguageTag()]?.url
}
else -> {
// if no language tag is present use the first entry
currentLanguage = Locale.ROOT
currentSubtitleLocale = Locale.ROOT
currentStreams.data[0].adaptive_hls.entries.first().value.url
}
}

View File

@ -9,6 +9,7 @@ import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.children
import androidx.fragment.app.DialogFragment
@ -24,8 +25,8 @@ class LanguageSettingsDialogFragment : DialogFragment() {
private lateinit var model: PlayerViewModel
private lateinit var binding: PlayerLanguageSettingsBinding
private var selectedLocale = Locale.ROOT
private var selectedView: View? = null
private var selectedSubtitleLocale = Locale.ROOT
private var selectedAudioLocale = Locale.ROOT
companion object {
const val TAG = "LanguageSettingsDialogFragment"
@ -35,7 +36,7 @@ class LanguageSettingsDialogFragment : DialogFragment() {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle)
model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java]
selectedLocale = model.currentLanguage
selectedSubtitleLocale = model.currentSubtitleLocale
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -46,18 +47,41 @@ class LanguageSettingsDialogFragment : DialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var selectedSubtitleView: TextView? = null
model.currentStreams.data[0].adaptive_hls.keys.forEach { languageTag ->
val locale = Locale.forLanguageTag(languageTag)
addLanguage(locale, locale == model.currentLanguage) { v ->
selectedLocale = locale
updateSelectedLanguage(v as TextView)
val subtitleView = addLanguage(binding.linearSubtitleLanguages, locale) { v ->
selectedSubtitleLocale = locale
updateSelectedLanguage(binding.linearSubtitleLanguages, v as TextView)
}
// if the view is the currently selected one, highlight it
if (locale == model.currentSubtitleLocale) {
selectedSubtitleView = subtitleView
updateSelectedLanguage(binding.linearSubtitleLanguages, subtitleView)
}
}
val currentAudioLocal = Locale.forLanguageTag(model.currentVersion.audioLocale)
var selectedAudioView: TextView? = null
model.currentEpisode.versions.forEach { version ->
val locale = Locale.forLanguageTag(version.audioLocale)
val audioView = addLanguage(binding.linearAudioLanguages, locale) { v ->
selectedAudioLocale = locale
updateSelectedLanguage(binding.linearAudioLanguages, v as TextView)
}
// if the view is the currently selected one, highlight it
if (locale == currentAudioLocal) {
selectedAudioView = audioView
updateSelectedLanguage(binding.linearAudioLanguages, audioView)
}
}
binding.buttonCloseLanguageSettings.setOnClickListener { dismiss() }
binding.buttonCancel.setOnClickListener { dismiss() }
binding.buttonSelect.setOnClickListener {
model.setLanguage(selectedLocale)
model.setLanguage(selectedAudioLocale, selectedSubtitleLocale)
dismiss()
}
@ -65,8 +89,12 @@ class LanguageSettingsDialogFragment : DialogFragment() {
hideBars(requireDialog().window, binding.root)
// scroll to the position of the view, if it's the selected language
binding.scrollLanguages.post {
binding.scrollLanguages.scrollTo(0, selectedView?.top ?: 0)
binding.scrollSubtitleLanguages.post {
binding.scrollSubtitleLanguages.scrollTo(0, selectedSubtitleView?.top ?: 0)
}
binding.scrollAudioLanguages.post {
binding.scrollSubtitleLanguages.scrollTo(0, selectedAudioView?.top ?: 0)
}
}
@ -75,35 +103,32 @@ class LanguageSettingsDialogFragment : DialogFragment() {
model.player.play()
}
private fun addLanguage(locale: Locale, isSelected: Boolean, onClick: View.OnClickListener) {
private fun addLanguage(linear: LinearLayout, locale: Locale, onClick: View.OnClickListener): TextView {
val text = TextView(context).apply {
height = 96
gravity = Gravity.CENTER_VERTICAL
text = if (locale == Locale.ROOT) context.getString(R.string.no_subtitles) else locale.displayLanguage
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
if (isSelected) {
setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme))
setTypeface(null, Typeface.BOLD)
setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_baseline_check_24, 0, 0, 0)
compoundDrawablesRelative.getOrNull(0)?.setTint(Color.WHITE)
compoundDrawablePadding = 12
selectedView = this
} else {
setTextColor(context.resources.getColor(R.color.textSecondaryDark, context.theme))
setPadding(75, 0, 0, 0)
}
setTextColor(context.resources.getColor(R.color.textSecondaryDark, context.theme))
setPadding(75, 0, 0, 0)
setOnClickListener(onClick)
}
binding.linearLanguages.addView(text)
linear.addView(text)
return text
}
private fun updateSelectedLanguage(selected: TextView) {
/**
* Highlights the selected audio/subtitle language
*
* @param languageLayout The audio/subtitle Layout to update
* @param selected The newly selected language TextView
*/
private fun updateSelectedLanguage(languageLayout: LinearLayout, selected: TextView) {
// rest all tf to not selected style
binding.linearLanguages.children.forEach { child ->
languageLayout.children.forEach { child ->
if (child is TextView) {
child.apply {
setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme))

View File

@ -10,7 +10,6 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.Fragment
import org.mosad.teapod.R
import org.mosad.teapod.parser.crunchyroll.Collection
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem
import org.mosad.teapod.parser.crunchyroll.Item
import org.mosad.teapod.ui.activity.player.PlayerActivity
import java.util.*
@ -48,19 +47,6 @@ fun List<Item>.toItemMediaList(): List<ItemMedia> {
}
}
@JvmName("toItemMediaListContinueWatchingItem")
fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
return items.map {
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
}
}
fun List<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
return this.map {
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
}
}
fun Locale.toDisplayString(fallback: String): String {
return if (this.displayLanguage.isNotEmpty() && this.displayCountry.isNotEmpty()) {
"${this.displayLanguage} (${this.displayCountry})"

View File

@ -9,9 +9,9 @@ import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import org.mosad.teapod.R
import org.mosad.teapod.databinding.ItemMediaBinding
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem
import org.mosad.teapod.parser.crunchyroll.UpNextAccountItem
class MediaEpisodeListAdapter(private val onClickListener: OnClickListener, private val itemOffset: Int = 0) : ListAdapter<ContinueWatchingItem, MediaEpisodeListAdapter.MediaViewHolder>(DiffCallback) {
class MediaEpisodeListAdapter(private val onClickListener: OnClickListener, private val itemOffset: Int = 0) : ListAdapter<UpNextAccountItem, MediaEpisodeListAdapter.MediaViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder {
val binding = ItemMediaBinding.inflate(
@ -37,7 +37,7 @@ class MediaEpisodeListAdapter(private val onClickListener: OnClickListener, priv
inner class MediaViewHolder(val binding: ItemMediaBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: ContinueWatchingItem) {
fun bind(item: UpNextAccountItem) {
val metadata = item.panel.episodeMetadata
binding.textTitle.text = binding.root.context.getString(R.string.season_episode_title,
@ -57,17 +57,17 @@ class MediaEpisodeListAdapter(private val onClickListener: OnClickListener, priv
}
}
companion object DiffCallback : DiffUtil.ItemCallback<ContinueWatchingItem>() {
override fun areItemsTheSame(oldItem: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean {
companion object DiffCallback : DiffUtil.ItemCallback<UpNextAccountItem>() {
override fun areItemsTheSame(oldItem: UpNextAccountItem, newItem: UpNextAccountItem): Boolean {
return oldItem.panel.id == newItem.panel.id
}
override fun areContentsTheSame(oldItem: ContinueWatchingItem, newItem: ContinueWatchingItem): Boolean {
override fun areContentsTheSame(oldItem: UpNextAccountItem, newItem: UpNextAccountItem): Boolean {
return oldItem == newItem
}
}
class OnClickListener(val clickListener: (item: ContinueWatchingItem) -> Unit) {
fun onClick(item: ContinueWatchingItem) = clickListener(item)
class OnClickListener(val clickListener: (item: UpNextAccountItem) -> Unit) {
fun onClick(item: UpNextAccountItem) = clickListener(item)
}
}

View File

@ -67,7 +67,7 @@ class TMDBApiController {
): T = coroutineScope {
val path = "$apiUrl$endpoint"
val params = concatenate(
listOf("api_key" to apiKey, "language" to Preferences.preferredLocale.language),
listOf("api_key" to apiKey, "language" to Preferences.preferredSubtitleLocale.language),
parameters
)

View File

@ -131,7 +131,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="7dp"
android:text="@string/subtitles"
android:text="@string/language"
android:textAllCaps="false"
app:icon="@drawable/ic_baseline_subtitles_24"
app:layout_constraintBottom_toBottomOf="parent"

View File

@ -36,7 +36,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="44dp"
android:text="@string/subtitles"
android:textAlignment="center"
android:textColor="@color/player_white"
android:textSize="18sp"
@ -44,24 +43,80 @@
</LinearLayout>
<ScrollView
android:id="@+id/scroll_languages"
<LinearLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@string/language"
android:orientation="horizontal"
app:layout_constraintBottom_toTopOf="@+id/linear_bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/linear_top">
<LinearLayout
android:id="@+id/linear_languages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="56dp"
android:layout_marginEnd="56dp"
android:orientation="vertical" />
</ScrollView>
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/audio"
android:textAlignment="center"
android:textColor="@color/player_white"
android:textSize="18sp"
android:textStyle="bold" />
<ScrollView
android:id="@+id/scroll_audio_languages"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/audio">
<LinearLayout
android:id="@+id/linear_audio_languages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="56dp"
android:layout_marginEnd="56dp"
android:orientation="vertical" />
</ScrollView>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_subtitles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/subtitles"
android:textAlignment="center"
android:textColor="@color/player_white"
android:textSize="18sp"
android:textStyle="bold" />
<ScrollView
android:id="@+id/scroll_subtitle_languages"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/subtitles">
<LinearLayout
android:id="@+id/linear_subtitle_languages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="56dp"
android:layout_marginEnd="56dp"
android:orientation="vertical" />
</ScrollView>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/linear_bottom"

View File

@ -86,6 +86,7 @@
<string name="next_episode">Nächste Folge</string>
<string name="skip_opening">Intro überspringen</string>
<string name="language">Sprache</string>
<string name="audio">Audio</string>
<string name="subtitles">Untertitel</string>
<string name="episodes">Folgen</string>
<string name="episode">Folge</string>

View File

@ -112,6 +112,7 @@
<string name="time_min_sec" translatable="false">%1$02d:%2$02d</string>
<string name="time_hour_min_sec" translatable="false">%1$d:%2$02d:%3$02d</string>
<string name="language">Language</string>
<string name="audio">Audio</string>
<string name="subtitles">Subtitles</string>
<string name="episodes">Episodes</string>
<string name="episode">Episode</string>
@ -150,6 +151,7 @@
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
<!-- for legacy reasons the prefer subbed key is called prefer_secondary-->
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
<string name="save_key_preferred_audio_local" translatable="false">org.mosad.teapod.preferred_audio_local</string>
<string name="save_key_preferred_local" translatable="false">org.mosad.teapod.preferred_local</string>
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>

View File

@ -8,7 +8,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.0'
classpath 'com.android.tools.build:gradle:7.4.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong