episode details screen

This commit is contained in:
Owen LeJeune
2023-07-31 16:22:01 -04:00
parent 314e4ca964
commit a9b0a62bd8
19 changed files with 536 additions and 99 deletions

View File

@@ -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<WatchProviderResponse>
@GET("tv/{id}/season/{season}/episode/{episode}")
suspend fun getEpisodeDetails(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int, @Path("episode") episodeNumber: Int): Response<Episode>
@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<EpisodeAccountState>
@GET("tv/{id}/season/{season}/episode/{episode}/credits")
suspend fun getEpisodeCredits(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int, @Path("episode") episodeNumber: Int): Response<EpisodeCastAndCrew>
@GET("tv/{id}/season/{season}/episode/{episode}/images")
suspend fun getEpisodeImages(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int, @Path("episode") episodeNumber: Int): Response<EpisodeImageCollection>
@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<StatusResponse>
}

View File

@@ -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<Int, out Map<Int, WatchProviders>>
get() = _seasonWatchProviders
private val _episodesMap = Collections.synchronizedMap(mutableStateMapOf<String, Episode>())
val episodesMap: Map<String, Episode>
get() = _episodesMap
private val _episodeAccountStates = Collections.synchronizedMap(mutableStateMapOf<String, EpisodeAccountState>())
val episodeAccountStates: Map<String, EpisodeAccountState>
get() = _episodeAccountStates
private val _episodeCast = Collections.synchronizedMap(mutableStateMapOf<String, List<EpisodeCastMember>>())
val episodeCast: Map<String, List<EpisodeCastMember>>
get() = _episodeCast
private val _episodeCrew = Collections.synchronizedMap(mutableStateMapOf<String, List<EpisodeCrewMember>>())
val episodeCrew: Map<String, List<EpisodeCrewMember>>
get() = _episodeCrew
private val _episodeGuestStars = Collections.synchronizedMap(mutableStateMapOf<String, List<EpisodeCastMember>>())
val episodeGuestStars: Map<String, List<EpisodeCastMember>>
get() = _episodeGuestStars
private val _episodeImages = Collections.synchronizedMap(mutableStateMapOf<String, EpisodeImageCollection>())
val episodeImages: Map<String, EpisodeImageCollection>
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<Int, WatchProviders>().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)

View File

@@ -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<SeasonAccountStatesResult>() {
class SeasonAccountStatesResultDeserializer: BaseDeserializer<EpisodeAccountState>() {
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)
}
}

View File

@@ -9,4 +9,10 @@ abstract class CastAndCrew<C, R>(
class TvCastAndCrew(cast: List<TvCastMember>, crew: List<TvCrewMember>): CastAndCrew<TvCastMember, TvCrewMember>(cast, crew)
class MovieCastAndCrew(cast: List<MovieCastMember>, crew: List<MovieCrewMember>): CastAndCrew<MovieCastMember, MovieCrewMember>(cast, crew)
class MovieCastAndCrew(cast: List<MovieCastMember>, crew: List<MovieCrewMember>): CastAndCrew<MovieCastMember, MovieCrewMember>(cast, crew)
class EpisodeCastAndCrew(
cast: List<EpisodeCastMember>,
crew: List<EpisodeCrewMember>,
@SerializedName("guest_stars") val guestStars: List<EpisodeCastMember>
): CastAndCrew<EpisodeCastMember, EpisodeCrewMember>(cast, crew)

View File

@@ -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(

View File

@@ -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<EpisodeStill>
)
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
)

View File

@@ -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?
)

View File

@@ -4,12 +4,12 @@ import com.google.gson.annotations.SerializedName
class SeasonAccountStates(
@SerializedName("id") val id: Int,
@SerializedName("results") val results: List<SeasonAccountStatesResult>
@SerializedName("results") val results: List<EpisodeAccountState>
)
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
)

View File

@@ -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()
)
}

View File

@@ -12,4 +12,7 @@ fun <T> anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(p
fun <T: Any> T.isIn(vararg items: T): Boolean = items.any { it == this }
fun <T> pairOf(a: T, b: T) = Pair(a, b)
fun <T> pairOf(a: T, b: T) = Pair(a, b)
fun createEpisodeKey(seriesId: Int, seasonNumber: Int, episodeNumber: Int): String
= listOf(seriesId, seasonNumber, episodeNumber).joinToString("_")

View File

@@ -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<Person>?,
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)
)
}
}
}
}
}

View File

@@ -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()

View File

@@ -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<MainViewModel>()
val applicationViewModel = viewModel<ApplicationViewModel>()
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
)
}
}
}
}

View File

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

View File

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

View File

@@ -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
)
}
}
}

View File

@@ -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<Boolean>) {

View File

@@ -6,5 +6,6 @@ enum class Gender(val rawValue: Int) {
@SerializedName("1")
MALE(1),
@SerializedName("2")
FEMALE(2)
FEMALE(2),
UNDEFINED(-1)
}

View File

@@ -28,6 +28,7 @@
<!-- Headings -->
<string name="cast_label">Cast</string>
<string name="crew_label">Crew</string>
<string name="recommended_label">Recommended</string>
<string name="videos_label">Videos</string>
<string name="known_for_label">Known For</string>