From a9b0a62bd8ec042b08d98b87f11f663df29fb30e Mon Sep 17 00:00:00 2001 From: Owen LeJeune Date: Mon, 31 Jul 2023 16:22:01 -0400 Subject: [PATCH] episode details screen --- .../tvtime/api/tmdb/api/v3/TvApi.kt | 23 ++- .../tvtime/api/tmdb/api/v3/TvService.kt | 123 +++++++++++- .../SeasonAccountStatesDeserializer.kt | 12 +- .../api/tmdb/api/v3/model/CastAndCrew.kt | 8 +- .../api/tmdb/api/v3/model/CastCrewMember.kt | 6 +- .../api/v3/model/EpisodeImageCollection.kt | 18 ++ .../tvtime/api/tmdb/api/v3/model/Person.kt | 2 +- .../tmdb/api/v3/model/SeasonAccountStates.kt | 6 +- .../owenlejeune/tvtime/di/modules/modules.kt | 7 +- .../tvtime/extensions/AnyExtensions.kt | 5 +- .../tvtime/ui/components/DetailViewCommon.kt | 97 +++++++++- .../tvtime/ui/navigation/AppNavigation.kt | 7 + .../tvtime/ui/screens/EpisodeDetailsView.kt | 180 ++++++++++++++++++ .../tvtime/ui/screens/MediaDetailScreen.kt | 79 ++------ .../tvtime/ui/screens/SeasonDetailsView.kt | 12 +- .../tvtime/ui/screens/SeasonListScreen.kt | 6 +- .../tvtime/ui/viewmodel/MainViewModel.kt | 40 ++++ .../owenlejeune/tvtime/utils/types/Gender.kt | 3 +- app/src/main/res/values/strings.xml | 1 + 19 files changed, 536 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/EpisodeImageCollection.kt create mode 100644 app/src/main/java/com/owenlejeune/tvtime/ui/screens/EpisodeDetailsView.kt diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt index ff32ead..1595742 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvApi.kt @@ -1,7 +1,6 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3 import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* -import com.owenlejeune.tvtime.utils.types.TimeWindow import retrofit2.Response import retrofit2.http.* @@ -89,4 +88,26 @@ interface TvApi { @GET("tv/{id}/season/{season}/watch/providers") suspend fun getSeasonWatchProviders(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response + + @GET("tv/{id}/season/{season}/episode/{episode}") + suspend fun getEpisodeDetails(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int, @Path("episode") episodeNumber: Int): Response + + @GET("tv/{id}/season/{season}/episode/{episode}/account_states") + suspend fun getEpisodeAccountStates(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int, @Path("episode") episodeNumber: Int): Response + + @GET("tv/{id}/season/{season}/episode/{episode}/credits") + suspend fun getEpisodeCredits(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int, @Path("episode") episodeNumber: Int): Response + + @GET("tv/{id}/season/{season}/episode/{episode}/images") + suspend fun getEpisodeImages(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int, @Path("episode") episodeNumber: Int): Response + + @FormUrlEncoded + @POST("tv/{id}/season/{season}/episode/{episode}/rating") + suspend fun postTvEpisodeRatingAsUser( + @Path("id") id: Int, + @Path("season") seasonNumber: Int, + @Path("episode") episodeNumber: Int, + @Query("session_id") sessionId: String, + @Field("value") rating: Float + ): Response } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt index e613114..58ea2fa 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/TvService.kt @@ -10,6 +10,11 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CastMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CrewMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedTv +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeAccountState +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeCastMember +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeCrewMember +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeImageCollection import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePageResponse import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection @@ -25,6 +30,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvContentRatings import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCrewMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders +import com.owenlejeune.tvtime.extensions.createEpisodeKey import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.types.TimeWindow import kotlinx.coroutines.flow.Flow @@ -84,6 +90,30 @@ class TvService: KoinComponent, DetailService, HomePageService { val seasonWatchProviders: MutableMap> get() = _seasonWatchProviders + private val _episodesMap = Collections.synchronizedMap(mutableStateMapOf()) + val episodesMap: Map + get() = _episodesMap + + private val _episodeAccountStates = Collections.synchronizedMap(mutableStateMapOf()) + val episodeAccountStates: Map + get() = _episodeAccountStates + + private val _episodeCast = Collections.synchronizedMap(mutableStateMapOf>()) + val episodeCast: Map> + get() = _episodeCast + + private val _episodeCrew = Collections.synchronizedMap(mutableStateMapOf>()) + val episodeCrew: Map> + get() = _episodeCrew + + private val _episodeGuestStars = Collections.synchronizedMap(mutableStateMapOf>()) + val episodeGuestStars: Map> + get() = _episodeGuestStars + + private val _episodeImages = Collections.synchronizedMap(mutableStateMapOf()) + val episodeImages: Map + get() = _episodeImages + val detailsLoadingState = mutableStateOf(LoadingState.INACTIVE) val imagesLoadingState = mutableStateOf(LoadingState.INACTIVE) val castCrewLoadingState = mutableStateOf(LoadingState.INACTIVE) @@ -100,6 +130,10 @@ class TvService: KoinComponent, DetailService, HomePageService { val seasonImagesLoadingState = mutableStateOf(LoadingState.INACTIVE) val seasonVideosLoadingState = mutableStateOf(LoadingState.INACTIVE) val seasonWatchProvidersLoadingState = mutableStateOf(LoadingState.INACTIVE) + val episodeLoadingState = mutableStateOf(LoadingState.INACTIVE) + val episodeAccountStateLoadingState = mutableStateOf(LoadingState.INACTIVE) + val episodeCreditsLoadingState = mutableStateOf(LoadingState.INACTIVE) + val episodeImagesLoadingState = mutableStateOf(LoadingState.INACTIVE) val isPopularTvLoading = mutableStateOf(false) val isTopRatedTvLoading = mutableStateOf(false) @@ -284,8 +318,8 @@ class TvService: KoinComponent, DetailService, HomePageService { suspend fun getSeasonWatchProviders(seriesId: Int, seasonId: Int, refreshing: Boolean) { loadRemoteData( { service.getSeasonWatchProviders(seriesId, seasonId) }, - { si -> - si.results[Locale.getDefault().country]?.let { wp -> + { swp -> + swp.results[Locale.getDefault().country]?.let { wp -> _seasonWatchProviders .getOrPut(seriesId) { emptyMap().toMutableMap() @@ -297,6 +331,91 @@ class TvService: KoinComponent, DetailService, HomePageService { ) } + suspend fun getEpisode( + seriesId: Int, + seasonId: Int, + episodeId: Int, + refreshing: Boolean + ) { + loadRemoteData( + { service.getEpisodeDetails(seriesId, seasonId, episodeId) }, + { episode -> + val key = createEpisodeKey(seriesId, seasonId, episodeId) + _episodesMap[key] = episode + }, + episodeLoadingState, + refreshing + ) + } + + suspend fun getEpisodeAccountStates( + seriesId: Int, + seasonId: Int, + episodeId: Int, + refreshing: Boolean + ) { + loadRemoteData( + { service.getEpisodeAccountStates(seriesId, seasonId, episodeId) }, + { eas -> + val key = createEpisodeKey(seriesId, seasonId, episodeId) + _episodeAccountStates[key] = eas + }, + episodeAccountStateLoadingState, + refreshing + ) + } + + suspend fun getEpisodeCredits( + seriesId: Int, + seasonId: Int, + episodeId: Int, + refreshing: Boolean + ) { + loadRemoteData( + { service.getEpisodeCredits(seriesId, seasonId, episodeId) }, + { ec -> + val key = createEpisodeKey(seriesId, seasonId, episodeId) + _episodeCast[key] = ec.cast + _episodeCrew[key] = ec.crew + _episodeGuestStars[key] = ec.guestStars + }, + episodeCreditsLoadingState, + refreshing + ) + } + + suspend fun getEpisodeImages( + seriesId: Int, + seasonId: Int, + episodeId: Int, + refreshing: Boolean + ) { + loadRemoteData( + { service.getEpisodeImages(seriesId, seasonId, episodeId) }, + { ei -> + val key = createEpisodeKey(seriesId, seasonId, episodeId) + _episodeImages[key] = ei + }, + episodeImagesLoadingState, + refreshing + ) + } + + suspend fun postEpisodeRating( + seriesId: Int, + seasonId: Int, + episodeId: Int, + rating: Float + ) { + val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") + val response = service.postTvEpisodeRatingAsUser(seriesId, seasonId, episodeId, session.sessionId, rating) + if (response.isSuccessful) { + Log.d(TAG, "Successfully posted rating") + } else { + Log.w(TAG, "Issue posting rating") + } + } + override suspend fun postRating(id: Int, rating: Float) { val session = SessionManager.currentSession.value ?: throw Exception("Session must not be null") val response = service.postTvRatingAsUser(id, session.sessionId, rating) diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/SeasonAccountStatesDeserializer.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/SeasonAccountStatesDeserializer.kt index f33d33e..021c55d 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/SeasonAccountStatesDeserializer.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/deserializer/SeasonAccountStatesDeserializer.kt @@ -2,19 +2,19 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer import com.google.gson.JsonObject import com.owenlejeune.tvtime.api.tmdb.api.BaseDeserializer -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SeasonAccountStatesResult +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeAccountState -class SeasonAccountStatesResultDeserializer: BaseDeserializer() { +class SeasonAccountStatesResultDeserializer: BaseDeserializer() { - override fun processJson(obj: JsonObject): SeasonAccountStatesResult { + override fun processJson(obj: JsonObject): EpisodeAccountState { val id = obj.get("id").asInt - val episodeNumber = obj.get("episode_number").asInt + val episodeNumber = obj.get("episode_number")?.asInt return try { val isRated = obj.get("rated").asBoolean - SeasonAccountStatesResult(id, episodeNumber, isRated, -1) + EpisodeAccountState(id, episodeNumber, isRated, -1) } catch (e: Exception) { val rating = obj.get("rated").asJsonObject.get("value").asInt - SeasonAccountStatesResult(id, episodeNumber, true, rating) + EpisodeAccountState(id, episodeNumber, true, rating) } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastAndCrew.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastAndCrew.kt index 1929d52..1935583 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastAndCrew.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastAndCrew.kt @@ -9,4 +9,10 @@ abstract class CastAndCrew( class TvCastAndCrew(cast: List, crew: List): CastAndCrew(cast, crew) -class MovieCastAndCrew(cast: List, crew: List): CastAndCrew(cast, crew) \ No newline at end of file +class MovieCastAndCrew(cast: List, crew: List): CastAndCrew(cast, crew) + +class EpisodeCastAndCrew( + cast: List, + crew: List, + @SerializedName("guest_stars") val guestStars: List +): CastAndCrew(cast, crew) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastCrewMember.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastCrewMember.kt index 197c5ad..a145bb4 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastCrewMember.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/CastCrewMember.kt @@ -36,7 +36,8 @@ class EpisodeCastMember( originalName: String, popularity: Float, order: Int, - @SerializedName("credit_id") val creditId: String + @SerializedName("credit_id") val creditId: String, + @SerializedName("character") val character: String ): CastMember(id, name, gender, profilePath, isAdult, knownForDepartment, originalName, popularity, order) class TvCastMember( @@ -90,7 +91,8 @@ class EpisodeCrewMember( originalName: String, popularity: Float, department: String, - @SerializedName("credit_id") val creditId: String + @SerializedName("credit_id") val creditId: String, + @SerializedName("job") val job: String ): CrewMember(id, name, gender, profilePath, isAdult, knownForDepartment, originalName, popularity, department) class TvCrewMember( diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/EpisodeImageCollection.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/EpisodeImageCollection.kt new file mode 100644 index 0000000..d96f4c3 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/EpisodeImageCollection.kt @@ -0,0 +1,18 @@ +package com.owenlejeune.tvtime.api.tmdb.api.v3.model + +import com.google.gson.annotations.SerializedName + +data class EpisodeImageCollection( + @SerializedName("id") val id: Int, + @SerializedName("stills") val stills: List +) + +data class EpisodeStill( + @SerializedName("aspect_ratio") val aspectRation: Float, + @SerializedName("height") val height: Int, + @SerializedName("iso_639_1") val language: String?, + @SerializedName("file_path") val stillPath: String, + @SerializedName("vote_average") val voteAverage: Float, + @SerializedName("vote_count") val voteCount: Int, + @SerializedName("width") val width: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Person.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Person.kt index dd09cce..e236a00 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Person.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Person.kt @@ -5,7 +5,7 @@ import com.owenlejeune.tvtime.utils.types.Gender open class Person( @SerializedName("id") val id: Int, - @SerializedName("name") val name: String, + @SerializedName("name", alternate = ["title"]) val name: String, @SerializedName("gender") val gender: Gender, @SerializedName("profile_path") val profilePath: String? ) diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/SeasonAccountStates.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/SeasonAccountStates.kt index 5edcf61..fcb2458 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/SeasonAccountStates.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/SeasonAccountStates.kt @@ -4,12 +4,12 @@ import com.google.gson.annotations.SerializedName class SeasonAccountStates( @SerializedName("id") val id: Int, - @SerializedName("results") val results: List + @SerializedName("results") val results: List ) -class SeasonAccountStatesResult( +class EpisodeAccountState( @SerializedName("id") val id: Int, - @SerializedName("episode_number") val episodeNumber: Int, + @SerializedName("episode_number") val episodeNumber: Int?, val isRated: Boolean, var rating: Int ) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt index d263461..32fbdb1 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt @@ -1,8 +1,6 @@ package com.owenlejeune.tvtime.di.modules import com.google.gson.GsonBuilder -import com.google.gson.JsonDeserializer -import com.google.gson.TypeAdapter import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.api.* import com.owenlejeune.tvtime.api.nextmcu.NextMCUClient @@ -28,7 +26,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CreditMedia import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCast import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCrew import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KnownFor -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SeasonAccountStatesResult +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeAccountState import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SortableSearchResult import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Service import com.owenlejeune.tvtime.api.tmdb.api.v4.AuthenticationV4Service @@ -37,7 +35,6 @@ import com.owenlejeune.tvtime.api.tmdb.api.v4.deserializer.ListItemDeserializer import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListItem import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel -import com.owenlejeune.tvtime.ui.viewmodel.SettingsViewModel import com.owenlejeune.tvtime.utils.NetworkConnectivityService import com.owenlejeune.tvtime.utils.NetworkConnectivityServiceImpl import com.owenlejeune.tvtime.utils.ResourceUtils @@ -89,7 +86,7 @@ val networkModule = module { DetailCrew::class.java to DetailCrewDeserializer(), CreditMedia::class.java to CreditMediaDeserializer(), Date::class.java to DateTypeAdapter(), - SeasonAccountStatesResult::class.java to SeasonAccountStatesResultDeserializer() + EpisodeAccountState::class.java to SeasonAccountStatesResultDeserializer() ) } diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt index fd9c166..62b8130 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt @@ -12,4 +12,7 @@ fun anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(p fun T.isIn(vararg items: T): Boolean = items.any { it == this } -fun pairOf(a: T, b: T) = Pair(a, b) \ No newline at end of file +fun pairOf(a: T, b: T) = Pair(a, b) + +fun createEpisodeKey(seriesId: Int, seasonNumber: Int, episodeNumber: Int): String + = listOf(seriesId, seasonNumber, episodeNumber).joinToString("_") \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt index fedf7c7..cd4f1a3 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt @@ -54,6 +54,8 @@ import com.google.accompanist.pager.rememberPagerState import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.api.LoadingState import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeCastMember +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.EpisodeCrewMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Image import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCastMember @@ -64,6 +66,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCrewMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderDetails import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders +import com.owenlejeune.tvtime.extensions.combineWith import com.owenlejeune.tvtime.extensions.listItems import com.owenlejeune.tvtime.extensions.shimmerBackground import com.owenlejeune.tvtime.extensions.toDp @@ -147,13 +150,15 @@ fun DetailHeader( horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.Bottom ) { - PosterItem( - url = posterUrl, - title = posterContentDescription, - elevation = elevation, - overrideShowTitle = false, - enabled = false - ) + posterUrl?.let { + PosterItem( + url = posterUrl, + title = posterContentDescription, + elevation = elevation, + overrideShowTitle = false, + enabled = false + ) + } rating?.let { if (it > 0f) { @@ -418,7 +423,9 @@ fun AdditionalDetailItem( @Composable fun EpisodeItem( + seriesId: Int, episode: Episode, + appNavController: NavController, elevation: Dp = 10.dp, maxDescriptionLines: Int = 2, rating: Int? = null @@ -430,7 +437,10 @@ fun EpisodeItem( modifier = Modifier .fillMaxWidth() .clickable { - + val codedId = seriesId.combineWith(episode.seasonNumber).combineWith(episode.episodeNumber) + appNavController.navigate( + AppNavItem.DetailView.withArgs(MediaViewType.EPISODE, codedId) + ) } ) { Box { @@ -527,6 +537,12 @@ fun CastCrewCard( val epsCount = person.totalEpisodeCount "$roles ($epsCount Eps.)" } + is EpisodeCastMember -> { + person.character + } + is EpisodeCrewMember -> { + person.job + } else -> null }, imageUrl = TmdbUtils.getFullPersonImagePath(person), @@ -584,7 +600,8 @@ fun WatchProvidersCard( Crossfade( modifier = modifier.padding(top = 4.dp, bottom = 12.dp), - targetState = selected.value + targetState = selected.value, + label = "" ) { value -> WatchProviderContainer(watchProviders = value!!, link = providers.link) } @@ -757,4 +774,66 @@ fun ImagesCard( ) } } +} + +@Composable +fun CastCard( + title: String, + isLoading: Boolean, + cast: List?, + appNavController: NavController, + onSeeMore: (() -> Unit)? = null, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.primary, + textColor: Color = MaterialTheme.colorScheme.background +) { + ContentCard( + modifier = modifier, + title = title, + backgroundColor = backgroundColor, + textColor = textColor + ) { + Column { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + item { Spacer(modifier = Modifier.width(8.dp)) } + if (isLoading) { + items(5) { + PlaceholderPosterItem() + } + } else { + items(cast?.size ?: 0) { i -> + cast?.get(i)?.let { + CastCrewCard(appNavController = appNavController, person = it) + } + } + } + item { Spacer(modifier = Modifier.width(8.dp)) } + } + + onSeeMore?.let { + if (isLoading) { + Text( + text = "", + modifier = Modifier + .padding(start = 12.dp, bottom = 12.dp) + .width(80.dp) + .shimmerBackground(RoundedCornerShape(10.dp)) + ) + } else { + Text( + text = stringResource(R.string.see_all_cast_and_crew), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.inversePrimary, + modifier = Modifier + .padding(start = 12.dp, bottom = 12.dp) + .clickable(onClick = onSeeMore) + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt index 87c2360..903bb14 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt @@ -22,6 +22,7 @@ import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.ui.screens.AboutScreen import com.owenlejeune.tvtime.ui.screens.AccountScreen import com.owenlejeune.tvtime.ui.screens.CastCrewListScreen +import com.owenlejeune.tvtime.ui.screens.EpisodeDetailsScreen import com.owenlejeune.tvtime.ui.screens.GalleryView import com.owenlejeune.tvtime.ui.screens.HomeScreen import com.owenlejeune.tvtime.ui.screens.KeywordResultsScreen @@ -129,6 +130,12 @@ fun AppNavigationHost( codedId = id ) } + MediaViewType.EPISODE -> { + EpisodeDetailsScreen( + appNavController = appNavController, + codedId = id + ) + } else -> { appNavController.popBackStack() Toast.makeText(LocalContext.current, stringResource(R.string.unexpected_error), Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/EpisodeDetailsView.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/EpisodeDetailsView.kt new file mode 100644 index 0000000..3a284c4 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/EpisodeDetailsView.kt @@ -0,0 +1,180 @@ +package com.owenlejeune.tvtime.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.google.accompanist.pager.ExperimentalPagerApi +import com.owenlejeune.tvtime.R +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode +import com.owenlejeune.tvtime.extensions.createEpisodeKey +import com.owenlejeune.tvtime.extensions.toCompositeParts +import com.owenlejeune.tvtime.ui.components.BackButton +import com.owenlejeune.tvtime.ui.components.CastCard +import com.owenlejeune.tvtime.ui.components.DetailHeader +import com.owenlejeune.tvtime.ui.components.TVTTopAppBar +import com.owenlejeune.tvtime.ui.theme.Typography +import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel +import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel +import com.owenlejeune.tvtime.utils.SessionManager +import com.owenlejeune.tvtime.utils.TmdbUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private fun fetchData( + mainViewModel: MainViewModel, + seriesId: Int, + seasonNumber: Int, + episodeNumber: Int, + force: Boolean = false +) { + val scope = CoroutineScope(Dispatchers.IO) + scope.launch { mainViewModel.getEpisode(seriesId, seasonNumber, episodeNumber, force) } + scope.launch { mainViewModel.getEpisodeCredits(seriesId, seasonNumber, episodeNumber, force) } + scope.launch { mainViewModel.getEpisodeImages(seriesId, seasonNumber, episodeNumber, force) } + if (SessionManager.isLoggedIn) { + scope.launch { mainViewModel.getEpisodeAccountStates(seriesId, seasonNumber, episodeNumber, force) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EpisodeDetailsScreen( + appNavController: NavController, + codedId: Int +) { + val mainViewModel = viewModel() + val applicationViewModel = viewModel() + + applicationViewModel.statusBarColor.value = MaterialTheme.colorScheme.background + applicationViewModel.navigationBarColor.value = MaterialTheme.colorScheme.background + + val (a, b) = codedId.toCompositeParts() + val episodeNumber = minOf(a, b) + val (c, d) = maxOf(a, b).toCompositeParts() + val seasonNumber = minOf(c, d) + val seriesId = maxOf(c, d) + LaunchedEffect(Unit) { + fetchData(mainViewModel, seriesId, seasonNumber, episodeNumber) + } + + val episodeKey = createEpisodeKey(seriesId, seasonNumber, episodeNumber) + + val episodesMap = remember { mainViewModel.tvEpisodes } + val episode = episodesMap[episodeKey] + + Scaffold( + topBar = { + TVTTopAppBar( + title = { }, + appNavController = appNavController, + navigationIcon = { BackButton(navController = appNavController) } + ) + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + episode?.let { + EpisodeContent( + seriesId = seriesId, + episodeKey = episodeKey, + episode = episode, + appNavController = appNavController, + mainViewModel = mainViewModel + ) + } + } + } +} + +@OptIn(ExperimentalPagerApi::class) +@Composable +private fun EpisodeContent( + seriesId: Int, + episodeKey: String, + episode: Episode, + appNavController: NavController, + mainViewModel: MainViewModel +) { + Column( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) + .verticalScroll(state = rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + DetailHeader( + backdropUrl = TmdbUtils.getFullEpisodeStillPath(episode.stillPath) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = episode.name, + color = MaterialTheme.colorScheme.secondary, + style = Typography.headlineLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + TmdbUtils.convertEpisodeDate(episode.airDate)?.let { + Text( + text = it, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onBackground + ) + } + + val castMap = remember { mainViewModel.tvEpisodeCast } + val cast = castMap[episodeKey] + cast?.let { + CastCard( + title = stringResource(R.string.cast_label), + isLoading = false, + cast = cast, + appNavController = appNavController + ) + } + + val guestStarsMap = remember { mainViewModel.tvEpisodeGuestStars } + val guestStars = guestStarsMap[episodeKey] + guestStars?.let { + CastCard( + title = stringResource(id = R.string.guest_stars_label), + isLoading = false, + cast = guestStars, + appNavController = appNavController + ) + } + + val crewMap = remember { mainViewModel.tvEpisodeCrew } + val crew = crewMap[episodeKey] + crew?.let { + CastCard( + title = stringResource(id = R.string.crew_label), + isLoading = false, + cast = crew, + appNavController = appNavController + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt index cb05834..c0e1fcc 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt @@ -76,7 +76,6 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedMovie import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedTv import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Genre import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection -import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.extensions.DateFormat import com.owenlejeune.tvtime.extensions.WindowSizeClass import com.owenlejeune.tvtime.extensions.combineWith @@ -85,13 +84,13 @@ import com.owenlejeune.tvtime.extensions.format import com.owenlejeune.tvtime.extensions.getCalendarYear import com.owenlejeune.tvtime.extensions.isIn import com.owenlejeune.tvtime.extensions.lazyPagingItems -import com.owenlejeune.tvtime.extensions.listItems import com.owenlejeune.tvtime.extensions.shimmerBackground import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.ui.components.ActionsView import com.owenlejeune.tvtime.ui.components.AdditionalDetailItem import com.owenlejeune.tvtime.ui.components.AvatarImage import com.owenlejeune.tvtime.ui.components.BackButton +import com.owenlejeune.tvtime.ui.components.CastCard import com.owenlejeune.tvtime.ui.components.CastCrewCard import com.owenlejeune.tvtime.ui.components.ChipDefaults import com.owenlejeune.tvtime.ui.components.ChipGroup @@ -100,14 +99,11 @@ import com.owenlejeune.tvtime.ui.components.ChipStyle import com.owenlejeune.tvtime.ui.components.CircleBackgroundColorImage import com.owenlejeune.tvtime.ui.components.ContentCard import com.owenlejeune.tvtime.ui.components.DetailHeader -import com.owenlejeune.tvtime.ui.components.ExpandableContentCard import com.owenlejeune.tvtime.ui.components.ExternalIdsArea -import com.owenlejeune.tvtime.ui.components.FullScreenThumbnailVideoPlayer import com.owenlejeune.tvtime.ui.components.HtmlText import com.owenlejeune.tvtime.ui.components.ImageGalleryOverlay import com.owenlejeune.tvtime.ui.components.ListContentCard import com.owenlejeune.tvtime.ui.components.PlaceholderDetailHeader -import com.owenlejeune.tvtime.ui.components.PlaceholderPosterItem import com.owenlejeune.tvtime.ui.components.PosterItem import com.owenlejeune.tvtime.ui.components.RoundedChip import com.owenlejeune.tvtime.ui.components.RoundedTextField @@ -192,9 +188,6 @@ fun MediaDetailScreen( if (type == MediaViewType.TV) { LaunchedEffect(mediaItem) { val lastSeason = (mediaItem as DetailedTv?)?.numberOfSeasons ?: 0 -// for (i in lastSeason downTo 0) { -// mainViewModel.getSeason(itemId, i) -// } if (lastSeason > 0) { mainViewModel.getSeason(itemId, lastSeason) } @@ -351,7 +344,7 @@ fun MediaViewContent( appNavController = appNavController ) - CastCard( + CastArea( itemId = itemId, appNavController = appNavController, type = type, @@ -795,7 +788,7 @@ private fun AdditionalTvItems( } @Composable -private fun CastCard( +private fun CastArea( itemId: Int, type: MediaViewType, mainViewModel: MainViewModel, @@ -808,65 +801,21 @@ private fun CastCard( val loadingState = remember { mainViewModel.produceDetailsLoadingStateFor(type) } val isLoading = loadingState.value.isIn(LoadingState.LOADING, LoadingState.REFRESHING) - ContentCard( + CastCard( modifier = modifier, title = stringResource(R.string.cast_label), - backgroundColor = MaterialTheme.colorScheme.primary, - textColor = MaterialTheme.colorScheme.background - ) { - Column { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - item { - Spacer(modifier = Modifier.width(8.dp)) - } - if (isLoading) { - items(5) { - PlaceholderPosterItem() - } - } else { - items(cast?.size ?: 0) { i -> - cast?.get(i)?.let { - CastCrewCard(appNavController = appNavController, person = it) - } - } - } - item { - Spacer(modifier = Modifier.width(8.dp)) - } - } - - if (isLoading) { - Text( - text = "", - modifier = Modifier - .padding(start = 12.dp, bottom = 12.dp) - .width(80.dp) - .shimmerBackground(RoundedCornerShape(10.dp)) + isLoading = isLoading, + cast = cast, + appNavController = appNavController, + onSeeMore = { + appNavController.navigate( + AppNavItem.CastCrewListView.withArgs( + type, + itemId ) - } else { - Text( - text = stringResource(R.string.see_all_cast_and_crew), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.inversePrimary, - modifier = Modifier - .padding(start = 12.dp, bottom = 12.dp) - .clickable { - appNavController.navigate( - AppNavItem.CastCrewListView.withArgs( - type, - itemId - ) - ) - } - ) - } + ) } - } + ) } @Composable diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonDetailsView.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonDetailsView.kt index 3dd3039..d6b3e8b 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonDetailsView.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonDetailsView.kt @@ -193,6 +193,7 @@ private fun SeasonContent( ) { season.episodes.forEach { episode -> DrawEpisodeCard( + seriesId = seriesId, episode = episode, accountStates = accountStates, appNavController = appNavController @@ -228,23 +229,32 @@ private fun SeasonContent( @Composable private fun DrawEpisodeCard( + seriesId: Int, episode: Episode, accountStates: SeasonAccountStates?, appNavController: NavController ) { val rating = accountStates?.results?.find { it.id == episode.id }?.takeUnless { !it.isRated }?.rating - SeasonEpisodeItem(appNavController = appNavController, episode = episode, rating = rating) + SeasonEpisodeItem( + appNavController = appNavController, + seriesId = seriesId, + episode = episode, + rating = rating + ) } @Composable private fun SeasonEpisodeItem( appNavController: NavController, + seriesId: Int, episode: Episode, rating: Int? ) { ContentCard { EpisodeItem( + seriesId = seriesId, episode = episode, + appNavController = appNavController, elevation = 0.dp, maxDescriptionLines = 5, rating = rating diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt index 9414450..05627c4 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt @@ -174,7 +174,11 @@ private fun SeasonSection( verticalArrangement = Arrangement.spacedBy(8.dp) ) { season.episodes.forEach { episode -> - EpisodeItem(episode = episode) + EpisodeItem( + seriesId = seriesId, + episode = episode, + appNavController = appNavController + ) } } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt index e7f5cb4..7d7b08a 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/MainViewModel.kt @@ -16,6 +16,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CastMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.CrewMember import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailedItem +import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Keyword @@ -24,6 +25,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMedia import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Video import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviders +import com.owenlejeune.tvtime.extensions.createEpisodeKey import com.owenlejeune.tvtime.ui.screens.tabs.MediaTabNavItem import com.owenlejeune.tvtime.utils.types.MediaViewType import com.owenlejeune.tvtime.utils.types.TimeWindow @@ -119,6 +121,12 @@ class MainViewModel: ViewModel(), KoinComponent { val tvSeasonImages = tvService.seasonImages val tvSeasonVideos = tvService.seasonVideos val tvSeasonWatchProviders = tvService.seasonWatchProviders + val tvEpisodes = tvService.episodesMap + val tvEpisodesAccountStates = tvService.episodeAccountStates + val tvEpisodeCast = tvService.episodeCast + val tvEpisodeCrew = tvService.episodeCrew + val tvEpisodeGuestStars = tvService.episodeGuestStars + val tvEpisodeImages = tvService.episodeImages val tvDetailsLoadingState = tvService.detailsLoadingState val tvImagesLoadingState = tvService.imagesLoadingState @@ -136,6 +144,10 @@ class MainViewModel: ViewModel(), KoinComponent { val tvSeasonImagesLoadingState = tvService.seasonImagesLoadingState val tvSeasonVideosLoadingState = tvService.seasonVideosLoadingState val tvSeasonWatchProvidersLoadingState = tvService.seasonWatchProvidersLoadingState + val tvEpisodeLoadingState = tvService.episodeLoadingState + val tvEpisodeAccountStateLoadingState = tvService.episodeAccountStateLoadingState + val tvEpisodeCreditsLoadingState = tvService.episodeCreditsLoadingState + val tvEpisodeImagesLoadingState = tvService.episodeImagesLoadingState val isPopularTvLoading = tvService.isPopularTvLoading val isTopRatedTvLoading = tvService.isTopRatedTvLoading @@ -559,6 +571,34 @@ class MainViewModel: ViewModel(), KoinComponent { } } + suspend fun getEpisode(seriesId: Int, seasonId: Int, episodeId: Int, force: Boolean = false) { + val key = createEpisodeKey(seriesId, seasonId, episodeId) + if (tvEpisodes[key] == null || force) { + tvService.getEpisode(seriesId, seasonId, episodeId, force) + } + } + + suspend fun getEpisodeAccountStates(seriesId: Int, seasonId: Int, episodeId: Int, force: Boolean = false) { + val key = createEpisodeKey(seriesId, seasonId, episodeId) + if (tvEpisodesAccountStates[key] == null || force) { + tvService.getEpisodeAccountStates(seriesId, seasonId, episodeId, force) + } + } + + suspend fun getEpisodeCredits(seriesId: Int, seasonId: Int, episodeInt: Int, force: Boolean = false) { + val key = createEpisodeKey(seriesId, seasonId, episodeInt) + if (tvEpisodeCast[key] == null || tvEpisodeCrew[key] == null || tvEpisodeGuestStars[key] == null || force) { + tvService.getEpisodeCredits(seriesId, seasonId, episodeInt, force) + } + } + + suspend fun getEpisodeImages(seriesId: Int, seasonId: Int, episodeId: Int, force: Boolean = false) { + val key = createEpisodeKey(seriesId, seasonId, episodeId) + if (tvEpisodeImages[key] == null || force) { + tvService.getEpisodeImages(seriesId, seasonId, episodeId, force) + } + } + @SuppressLint("ComposableNaming") @Composable fun monitorDetailsLoadingRefreshing(refreshing: MutableState) { diff --git a/app/src/main/java/com/owenlejeune/tvtime/utils/types/Gender.kt b/app/src/main/java/com/owenlejeune/tvtime/utils/types/Gender.kt index 6aa458e..910b292 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/utils/types/Gender.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/utils/types/Gender.kt @@ -6,5 +6,6 @@ enum class Gender(val rawValue: Int) { @SerializedName("1") MALE(1), @SerializedName("2") - FEMALE(2) + FEMALE(2), + UNDEFINED(-1) } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 35fcaf2..0f61f08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ Cast + Crew Recommended Videos Known For