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

View File

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

View File

@ -8,7 +8,9 @@ import java.util.*
object Preferences { 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 internal set
var preferSubbed = false var preferSubbed = false
internal set internal set
@ -30,13 +32,22 @@ object Preferences {
) )
} }
fun savePreferredLocal(context: Context, preferredLocale: Locale) { fun savePreferredAudioLocal(context: Context, preferredLocale: Locale) {
with(getSharedPref(context).edit()) { with(getSharedPref(context).edit()) {
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag()) putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
apply() 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) { fun savePreferSecondary(context: Context, preferSubbed: Boolean) {
@ -90,7 +101,12 @@ object Preferences {
fun load(context: Context) { fun load(context: Context) {
val sharedPref = getSharedPref(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( sharedPref.getString(
context.getString(R.string.save_key_preferred_local), "en-US" context.getString(R.string.save_key_preferred_local), "en-US"
) ?: "en-US" ) ?: "en-US"

View File

@ -169,9 +169,12 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
scope.launch { Crunchyroll.account() }, scope.launch { Crunchyroll.account() },
scope.launch { scope.launch {
// update the local preferred content language, since it may have changed // update the local preferred content language, since it may have changed
val locale = Locale.forLanguageTag(Crunchyroll.profile().preferredContentSubtitleLanguage) val profile = Crunchyroll.profile()
Preferences.savePreferredLocal(this@MainActivity, locale)
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 { }.invokeOnCompletion {
// update the local preferred content language // update the local preferred content language
Preferences.savePreferredLocal(requireContext(), preferredLocale) Preferences.savePreferredSubtitleLocal(requireContext(), preferredLocale)
// update profile since the language selection might have changed // update profile since the language selection might have changed
profile = lifecycleScope.async { Crunchyroll.profile() } profile = lifecycleScope.async { Crunchyroll.profile() }

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
val currentPlayheads: MutableMap<String, PlayheadObject> = mutableMapOf() val currentPlayheads: MutableMap<String, PlayheadObject> = mutableMapOf()
var isWatchlist = false var isWatchlist = false
internal set internal set
var upNextSeries = NoneUpNextSeriesItem var upNextSeries = NoneUpNextSeriesList
internal set internal set
var similarTo = NoneSimilarToResult var similarTo = NoneSimilarToResult
internal set internal set
@ -60,8 +60,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// load the preferred season: // load the preferred season:
// next episode > preferred language (language per season, not per stream) // next episode > preferred language (language per season, not per stream)
currentSeasonCrunchy = seasonsCrunchy.data.firstOrNull{ season -> currentSeasonCrunchy = seasonsCrunchy.data.firstOrNull{ season ->
season.id == upNextSeries.panel.episodeMetadata.seasonId season.id == upNextSeries.data.first().panel.episodeMetadata.seasonId
} ?: seasonsCrunchy.getPreferredSeasonByLocal(Preferences.preferredLocale) } ?: seasonsCrunchy.getPreferredSeasonByLocal(Preferences.preferredSubtitleLocale)
// Note: if we need to query metaDB, do it now // Note: if we need to query metaDB, do it now
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes) // 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 internal set
var currentEpisode = NoneEpisode var currentEpisode = NoneEpisode
internal set internal set
var currentVersion = NoneVersion
internal set
var currentStreams = NoneStreams var currentStreams = NoneStreams
internal set
// current playback settings // current playback settings
var currentLanguage: Locale = Preferences.preferredLocale var currentAudioLocale: Locale = Preferences.preferredAudioLocale
internal set
var currentSubtitleLocale: Locale = Preferences.preferredSubtitleLocale
internal set internal set
init { init {
@ -146,9 +151,31 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
playCurrentMedia(currentPlayhead) playCurrentMedia(currentPlayhead)
} }
fun setLanguage(language: Locale) { fun setLanguage(newAudioLocale: Locale, newSubtitleLocale: Locale) {
currentLanguage = language // TODO if the audio locale has changes update the streams, if only the subtitle locale has changed load the new stream
playCurrentMedia(player.currentPosition) 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 // player actions
@ -198,8 +225,17 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
// needs to be blocking, currentPlayback must be present when calling playCurrentMedia() // needs to be blocking, currentPlayback must be present when calling playCurrentMedia()
joinAll( joinAll(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
currentStreams = Crunchyroll.streams(currentEpisode.streamsLink) currentVersion = if (Preferences.preferSubbed) {
println("stream: $Streams") 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) { viewModelScope.launch(Dispatchers.IO) {
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let { Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
@ -215,7 +251,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
currentIntroMetadata = NoneDatalabIntro //Crunchyroll.datalabIntro(currentEpisode.id) currentIntroMetadata = NoneDatalabIntro //Crunchyroll.datalabIntro(currentEpisode.id)
} }
) )
Log.d(classTag, "playback: ${currentEpisode.streamsLink}") Log.d(classTag, "streams: ${currentEpisode.streamsLink}")
if (startPlayback) { if (startPlayback) {
playCurrentMedia() 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) { fun playCurrentMedia(seekPosition: Long = 0) {
// get preferred stream url, set current language if it differs from the preferred one // 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 fallbackLocal = Locale.US
val url = when { val url = when {
currentStreams.data[0].adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> { currentStreams.data[0].adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> {
currentStreams.data[0].adaptive_hls[preferredLocale.toLanguageTag()]?.url currentStreams.data[0].adaptive_hls[preferredLocale.toLanguageTag()]?.url
} }
currentStreams.data[0].adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> { currentStreams.data[0].adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> {
currentLanguage = fallbackLocal currentSubtitleLocale = fallbackLocal
currentStreams.data[0].adaptive_hls[fallbackLocal.toLanguageTag()]?.url currentStreams.data[0].adaptive_hls[fallbackLocal.toLanguageTag()]?.url
} }
else -> { else -> {
// if no language tag is present use the first entry // 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 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.children import androidx.core.view.children
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
@ -24,8 +25,8 @@ class LanguageSettingsDialogFragment : DialogFragment() {
private lateinit var model: PlayerViewModel private lateinit var model: PlayerViewModel
private lateinit var binding: PlayerLanguageSettingsBinding private lateinit var binding: PlayerLanguageSettingsBinding
private var selectedLocale = Locale.ROOT private var selectedSubtitleLocale = Locale.ROOT
private var selectedView: View? = null private var selectedAudioLocale = Locale.ROOT
companion object { companion object {
const val TAG = "LanguageSettingsDialogFragment" const val TAG = "LanguageSettingsDialogFragment"
@ -35,7 +36,7 @@ class LanguageSettingsDialogFragment : DialogFragment() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle) setStyle(STYLE_NO_TITLE, R.style.FullScreenDialogStyle)
model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java] model = ViewModelProvider(requireActivity())[PlayerViewModel::class.java]
selectedLocale = model.currentLanguage selectedSubtitleLocale = model.currentSubtitleLocale
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -46,18 +47,41 @@ class LanguageSettingsDialogFragment : DialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
var selectedSubtitleView: TextView? = null
model.currentStreams.data[0].adaptive_hls.keys.forEach { languageTag -> model.currentStreams.data[0].adaptive_hls.keys.forEach { languageTag ->
val locale = Locale.forLanguageTag(languageTag) val locale = Locale.forLanguageTag(languageTag)
addLanguage(locale, locale == model.currentLanguage) { v -> val subtitleView = addLanguage(binding.linearSubtitleLanguages, locale) { v ->
selectedLocale = locale selectedSubtitleLocale = locale
updateSelectedLanguage(v as TextView) 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.buttonCloseLanguageSettings.setOnClickListener { dismiss() }
binding.buttonCancel.setOnClickListener { dismiss() } binding.buttonCancel.setOnClickListener { dismiss() }
binding.buttonSelect.setOnClickListener { binding.buttonSelect.setOnClickListener {
model.setLanguage(selectedLocale) model.setLanguage(selectedAudioLocale, selectedSubtitleLocale)
dismiss() dismiss()
} }
@ -65,8 +89,12 @@ class LanguageSettingsDialogFragment : DialogFragment() {
hideBars(requireDialog().window, binding.root) hideBars(requireDialog().window, binding.root)
// scroll to the position of the view, if it's the selected language // scroll to the position of the view, if it's the selected language
binding.scrollLanguages.post { binding.scrollSubtitleLanguages.post {
binding.scrollLanguages.scrollTo(0, selectedView?.top ?: 0) 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() 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 { val text = TextView(context).apply {
height = 96 height = 96
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
text = if (locale == Locale.ROOT) context.getString(R.string.no_subtitles) else locale.displayLanguage text = if (locale == Locale.ROOT) context.getString(R.string.no_subtitles) else locale.displayLanguage
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
setTextColor(context.resources.getColor(R.color.textSecondaryDark, context.theme))
if (isSelected) { setPadding(75, 0, 0, 0)
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)
}
setOnClickListener(onClick) 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 // rest all tf to not selected style
binding.linearLanguages.children.forEach { child -> languageLayout.children.forEach { child ->
if (child is TextView) { if (child is TextView) {
child.apply { child.apply {
setTextColor(context.resources.getColor(R.color.textPrimaryDark, context.theme)) 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 androidx.fragment.app.Fragment
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.parser.crunchyroll.Collection 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.parser.crunchyroll.Item
import org.mosad.teapod.ui.activity.player.PlayerActivity import org.mosad.teapod.ui.activity.player.PlayerActivity
import java.util.* 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 { fun Locale.toDisplayString(fallback: String): String {
return if (this.displayLanguage.isNotEmpty() && this.displayCountry.isNotEmpty()) { return if (this.displayLanguage.isNotEmpty() && this.displayCountry.isNotEmpty()) {
"${this.displayLanguage} (${this.displayCountry})" "${this.displayLanguage} (${this.displayCountry})"

View File

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

View File

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

View File

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

View File

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

View File

@ -86,6 +86,7 @@
<string name="next_episode">Nächste Folge</string> <string name="next_episode">Nächste Folge</string>
<string name="skip_opening">Intro überspringen</string> <string name="skip_opening">Intro überspringen</string>
<string name="language">Sprache</string> <string name="language">Sprache</string>
<string name="audio">Audio</string>
<string name="subtitles">Untertitel</string> <string name="subtitles">Untertitel</string>
<string name="episodes">Folgen</string> <string name="episodes">Folgen</string>
<string name="episode">Folge</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_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="time_hour_min_sec" translatable="false">%1$d:%2$02d:%3$02d</string>
<string name="language">Language</string> <string name="language">Language</string>
<string name="audio">Audio</string>
<string name="subtitles">Subtitles</string> <string name="subtitles">Subtitles</string>
<string name="episodes">Episodes</string> <string name="episodes">Episodes</string>
<string name="episode">Episode</string> <string name="episode">Episode</string>
@ -150,6 +151,7 @@
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string> <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--> <!-- 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_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_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_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string> <string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>

View File

@ -8,7 +8,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong