tv series season details screen

This commit is contained in:
Owen LeJeune
2023-07-25 19:27:39 -04:00
parent dcb24561ec
commit 3df94cbd70
15 changed files with 536 additions and 246 deletions

View File

@@ -10,7 +10,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailCrew
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailPerson
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ExternalIds
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.HomePagePeopleResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.PersonImage
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Image
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultPerson
import com.owenlejeune.tvtime.utils.types.TimeWindow
@@ -27,7 +27,7 @@ class PeopleService: KoinComponent {
val peopleMap = Collections.synchronizedMap(mutableStateMapOf<Int, DetailPerson>())
val castMap = Collections.synchronizedMap(mutableStateMapOf<Int, List<DetailCast>>())
val crewMap = Collections.synchronizedMap(mutableStateMapOf<Int, List<DetailCrew>>())
val imagesMap = Collections.synchronizedMap(mutableStateMapOf<Int, List<PersonImage>>())
val imagesMap = Collections.synchronizedMap(mutableStateMapOf<Int, List<Image>>())
val externalIdsMap = Collections.synchronizedMap(mutableStateMapOf<Int, ExternalIds>())
val detailsLoadingState = mutableStateOf(LoadingState.INACTIVE)

View File

@@ -73,4 +73,19 @@ interface TvApi {
@GET("tv/{id}/season/{season}")
suspend fun getSeason(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<Season>
@GET("tv/{id}/season/{season}/account_states")
suspend fun getSeasonAccountStates(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<SeasonAccountStates>
@GET("tv/{id}/season/{season}/aggregate_credits")
suspend fun getSeasonCredits(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<TvCastAndCrew>
@GET("tv/{id}/season/{season}/images")
suspend fun getSeasonImages(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<ImageCollection>
@GET("tv/{id}/season/{season}/videos")
suspend fun getSeasonVideos(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<VideoResponse>
@GET("tv/{id}/season/{season}/watch/providers")
suspend fun getSeasonWatchProviders(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<WatchProviderResponse>
}

View File

@@ -27,9 +27,13 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ReviewResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SearchResultMedia
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.SeasonAccountStates
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.StatusResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TmdbItem
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCastAndCrew
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCastMember
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.VideoResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.WatchProviderResponse
@@ -69,6 +73,30 @@ class TvService: KoinComponent, DetailService, HomePageService {
val seasons: MutableMap<Int, out Set<Season>>
get() = _seasons
private val _seasonAccountStates = Collections.synchronizedMap(mutableStateMapOf<Int, MutableMap<Int, SeasonAccountStates>>())
val seasonAccountStates: MutableMap<Int, out Map<Int, SeasonAccountStates>>
get() = _seasonAccountStates
private val _seasonCast = Collections.synchronizedMap(mutableStateMapOf<Int, MutableMap<Int, List<TvCastMember>>>())
val seasonCast: MutableMap<Int, out Map<Int, List<TvCastMember>>>
get() = _seasonCast
private val _seasonCrew = Collections.synchronizedMap(mutableStateMapOf<Int, MutableMap<Int, List<TvCrewMember>>>())
val seasonCrew: MutableMap<Int, out Map<Int, List<TvCrewMember>>>
get() = _seasonCrew
private val _seasonImages = Collections.synchronizedMap(mutableStateMapOf<Int, MutableMap<Int, ImageCollection>>())
val seasonImages: Map<Int, out Map<Int, ImageCollection>>
get() = _seasonImages
private val _seasonVideos = Collections.synchronizedMap(mutableStateMapOf<Int, MutableMap<Int, List<Video>>>())
val seasonVideos: MutableMap<Int, out Map<Int, List<Video>>>
get() = _seasonVideos
private val _seasonWatchProviders = Collections.synchronizedMap(mutableStateMapOf<Int, MutableMap<Int, WatchProviders>>())
val seasonWatchProviders: MutableMap<Int, out Map<Int, WatchProviders>>
get() = _seasonWatchProviders
val detailsLoadingState = mutableStateOf(LoadingState.INACTIVE)
val imagesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val castCrewLoadingState = mutableStateOf(LoadingState.INACTIVE)
@@ -80,6 +108,11 @@ class TvService: KoinComponent, DetailService, HomePageService {
val externalIdsLoadingState = mutableStateOf(LoadingState.INACTIVE)
val accountStatesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonsLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonAccountStatesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonCreditsLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonImagesLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonVideosLoadingState = mutableStateOf(LoadingState.INACTIVE)
val seasonWatchProvidersLoadingState = mutableStateOf(LoadingState.INACTIVE)
override suspend fun getById(id: Int, refreshing: Boolean) {
loadRemoteData(
@@ -169,6 +202,15 @@ class TvService: KoinComponent, DetailService, HomePageService {
)
}
override suspend fun getExternalIds(id: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getExternalIds(id) },
{ externalIds[id] = it },
externalIdsLoadingState,
refreshing
)
}
suspend fun getSeason(seriesId: Int, seasonId: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getSeason(seriesId, seasonId) },
@@ -186,11 +228,78 @@ class TvService: KoinComponent, DetailService, HomePageService {
)
}
override suspend fun getExternalIds(id: Int, refreshing: Boolean) {
suspend fun getSeasonAccountStates(seriesId: Int, seasonId: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getExternalIds(id) },
{ externalIds[id] = it },
externalIdsLoadingState,
{ service.getSeasonAccountStates(seriesId, seasonId) },
{ sas ->
_seasonAccountStates
.getOrPut(seriesId) {
emptyMap<Int, SeasonAccountStates>().toMutableMap()
}[seasonId] = sas
},
seasonAccountStatesLoadingState,
refreshing
)
}
suspend fun getSeasonCredits(seriesId: Int, seasonId: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getSeasonCredits(seriesId, seasonId) },
{ sc ->
_seasonCast
.getOrPut(seriesId) {
emptyMap<Int, List<TvCastMember>>().toMutableMap()
}[seasonId] = sc.cast
_seasonCrew
.getOrPut(seriesId) {
emptyMap<Int, List<TvCrewMember>>().toMutableMap()
}[seasonId] = sc.crew
},
seasonCreditsLoadingState,
refreshing
)
}
suspend fun getSeasonImages(seriesId: Int, seasonId: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getSeasonImages(seriesId, seasonId) },
{ si ->
_seasonImages
.getOrPut(seriesId) {
emptyMap<Int, ImageCollection>().toMutableMap()
}[seasonId] = si
},
seasonImagesLoadingState,
refreshing
)
}
suspend fun getSeasonVideos(seriesId: Int, seasonId: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getSeasonVideos(seriesId, seasonId) },
{ sv ->
_seasonVideos
.getOrPut(seriesId) {
emptyMap<Int, List<Video>>().toMutableMap()
}[seasonId] = sv.results
},
seasonVideosLoadingState,
refreshing
)
}
suspend fun getSeasonWatchProviders(seriesId: Int, seasonId: Int, refreshing: Boolean) {
loadRemoteData(
{ service.getSeasonWatchProviders(seriesId, seasonId) },
{ si ->
si.results[Locale.getDefault().country]?.let { wp ->
_seasonWatchProviders
.getOrPut(seriesId) {
emptyMap<Int, WatchProviders>().toMutableMap()
}[seasonId] = wp
}
},
seasonWatchProvidersLoadingState,
refreshing
)
}

View File

@@ -0,0 +1,21 @@
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
class SeasonAccountStatesResultDeserializer: BaseDeserializer<SeasonAccountStatesResult>() {
override fun processJson(obj: JsonObject): SeasonAccountStatesResult {
val id = obj.get("id").asInt
val episodeNumber = obj.get("episode_number").asInt
return try {
val isRated = obj.get("rated").asBoolean
SeasonAccountStatesResult(id, episodeNumber, isRated, -1)
} catch (e: Exception) {
val rating = obj.get("rated").asJsonObject.get("value").asInt
SeasonAccountStatesResult(id, episodeNumber, true, rating)
}
}
}

View File

@@ -5,5 +5,7 @@ import com.google.gson.annotations.SerializedName
data class Image(
@SerializedName("file_path") val filePath: String,
@SerializedName("height") val height: Int,
@SerializedName("width") val width: Int
@SerializedName("width") val width: Int,
@SerializedName("aspect_ratio") val aspectRatio: Float? = null,
@SerializedName("iso_639_1") val language: String? = null
)

View File

@@ -1,10 +0,0 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName
class PersonImage(
@SerializedName("aspect_ratio") val aspectRatio: Float,
@SerializedName("file_path") val filePath: String,
@SerializedName("width") val width: Int,
@SerializedName("height") val height: Int
)

View File

@@ -3,5 +3,5 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName
class PersonImageCollection(
@SerializedName("profiles") val images: List<PersonImage>
@SerializedName("profiles") val images: List<Image>
)

View File

@@ -0,0 +1,15 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName
class SeasonAccountStates(
@SerializedName("id") val id: Int,
@SerializedName("results") val results: List<SeasonAccountStatesResult>
)
class SeasonAccountStatesResult(
@SerializedName("id") val id: Int,
@SerializedName("episode_number") val episodeNumber: Int,
val isRated: Boolean,
var rating: Int
)

View File

@@ -21,12 +21,14 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.CreditMediaDeserializ
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.DetailCastDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.DetailCrewDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.KnownForDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.SeasonAccountStatesResultDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.deserializer.SortableSearchResultDeserializer
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.AccountStates
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.SortableSearchResult
import com.owenlejeune.tvtime.api.tmdb.api.v4.AccountV4Service
import com.owenlejeune.tvtime.api.tmdb.api.v4.AuthenticationV4Service
@@ -86,7 +88,8 @@ val networkModule = module {
DetailCast::class.java to DetailCastDeserializer(),
DetailCrew::class.java to DetailCrewDeserializer(),
CreditMedia::class.java to CreditMediaDeserializer(),
Date::class.java to DateTypeAdapter()
Date::class.java to DateTypeAdapter(),
SeasonAccountStatesResult::class.java to SeasonAccountStatesResultDeserializer()
)
}

View File

@@ -2,13 +2,18 @@ package com.owenlejeune.tvtime.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
@@ -28,7 +33,9 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
@@ -47,12 +54,17 @@ 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.Image
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCastMember
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCrewMember
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Person
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCastMember
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.listItems
import com.owenlejeune.tvtime.extensions.shimmerBackground
import com.owenlejeune.tvtime.extensions.toDp
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
@@ -408,7 +420,8 @@ fun AdditionalDetailItem(
fun EpisodeItem(
episode: Episode,
elevation: Dp = 10.dp,
maxDescriptionLines: Int = 2
maxDescriptionLines: Int = 2,
rating: Int? = null
) {
Card(
shape = RoundedCornerShape(10.dp),
@@ -478,6 +491,14 @@ fun EpisodeItem(
color = textColor,
maxLines = maxDescriptionLines
)
rating?.let {
Text(
text = stringResource(id = R.string.your_rating, rating),
color = textColor.copy(alpha = 0.8f),
fontSize = 12.sp,
fontStyle = FontStyle.Italic
)
}
}
}
}
@@ -517,4 +538,223 @@ fun CastCrewCard(
)
}
)
}
@Composable
fun WatchProvidersCard(
modifier: Modifier = Modifier,
providers: WatchProviders
) {
Card(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = stringResource(R.string.watch_providers_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp, top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val itemsMap = mutableMapOf<Int, List<WatchProviderDetails>>().apply {
providers.flaterate?.let { put(0, it) }
providers.rent?.let { put(1, it) }
providers.buy?.let { put(2, it) }
}
val selected = remember { mutableStateOf(if (itemsMap.isEmpty()) null else itemsMap.values.first()) }
val context = LocalContext.current
PillSegmentedControl(
items = itemsMap.values.toList(),
itemLabel = { i, _ ->
when (i) {
0 -> context.getString(R.string.streaming_label)
1 -> context.getString(R.string.rent_label)
2 -> context.getString(R.string.buy_label)
else -> ""
}
},
onItemSelected = { i, _ -> selected.value = itemsMap.values.toList()[i] },
modifier = Modifier.padding(all = 8.dp)
)
Crossfade(
modifier = modifier.padding(top = 4.dp, bottom = 12.dp),
targetState = selected.value
) { value ->
WatchProviderContainer(watchProviders = value!!, link = providers.link)
}
}
}
@Composable
fun WatchProviderContainer(
watchProviders: List<WatchProviderDetails>,
link: String
) {
val context = LocalContext.current
com.google.accompanist.flowlayout.FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
mainAxisSpacing = 8.dp,
crossAxisSpacing = 4.dp
) {
watchProviders
.sortedBy { it.displayPriority }
.forEach { item ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
context.startActivity(intent)
}
) {
val url = TmdbUtils.fullLogoPath(item.logoPath)
val model = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCacheKey(url)
.networkCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(10.dp))
)
Text(
text = item.providerName,
fontSize = 10.sp,
modifier = Modifier.width(48.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
fun VideosCard(
modifier: Modifier = Modifier,
videos: List<Video>
) {
ExpandableContentCard(
modifier = modifier,
title = {
Text(
text = stringResource(id = R.string.videos_label),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
toggleTextColor = MaterialTheme.colorScheme.primary
) { isExpanded ->
VideoGroup(
results = videos,
type = Video.Type.TRAILER,
title = stringResource(id = Video.Type.TRAILER.stringRes)
)
if (isExpanded) {
Video.Type.values().filter { it != Video.Type.TRAILER }.forEach { type ->
VideoGroup(
results = videos,
type = type,
title = stringResource(id = type.stringRes)
)
}
}
}
}
@Composable
fun VideoGroup(
results: List<Video>,
type: Video.Type,
title: String
) {
val videos = results.filter { it.isOfficial && it.type == type }
if (videos.isNotEmpty()) {
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 12.dp, top = 8.dp)
)
val posterWidth = 120.dp
LazyRow(modifier = Modifier
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
item {
Spacer(modifier = Modifier.width(8.dp))
}
listItems(videos) { video ->
FullScreenThumbnailVideoPlayer(
key = video.key,
title = video.name,
modifier = Modifier
.width(posterWidth)
.wrapContentHeight()
)
}
item {
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
@Composable
fun ImagesCard(
images: List<Image>,
onSeeAll: (() -> Unit)? = null
) {
ContentCard(
title = stringResource(R.string.images_title)
) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Spacer(modifier = Modifier.width(8.dp))
}
items(images) { image ->
PosterItem(
width = 120.dp,
url = TmdbUtils.getFullPersonImagePath(image.filePath),
placeholder = Icons.Filled.Person,
title = ""
)
}
item {
Spacer(modifier = Modifier.width(8.dp))
}
}
onSeeAll?.let {
Text(
text = stringResource(id = R.string.expand_see_all),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier
.padding(start = 16.dp, bottom = 16.dp)
.clickable(onClick = onSeeAll)
)
}
}
}

View File

@@ -1,11 +1,8 @@
package com.owenlejeune.tvtime.ui.screens
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
@@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
@@ -70,7 +66,6 @@ import androidx.paging.compose.collectAsLazyPagingItems
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
@@ -81,15 +76,10 @@ 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.MovieCastMember
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.MovieCrewMember
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Person
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvCastMember
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.extensions.DateFormat
import com.owenlejeune.tvtime.extensions.WindowSizeClass
import com.owenlejeune.tvtime.extensions.combineWith
import com.owenlejeune.tvtime.extensions.combinedOnVisibilityChange
import com.owenlejeune.tvtime.extensions.format
import com.owenlejeune.tvtime.extensions.getCalendarYear
@@ -116,7 +106,6 @@ 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.PillSegmentedControl
import com.owenlejeune.tvtime.ui.components.PlaceholderDetailHeader
import com.owenlejeune.tvtime.ui.components.PlaceholderPosterItem
import com.owenlejeune.tvtime.ui.components.PosterItem
@@ -124,6 +113,8 @@ import com.owenlejeune.tvtime.ui.components.RoundedChip
import com.owenlejeune.tvtime.ui.components.RoundedTextField
import com.owenlejeune.tvtime.ui.components.TVTTopAppBar
import com.owenlejeune.tvtime.ui.components.TwoLineImageTextCard
import com.owenlejeune.tvtime.ui.components.VideosCard
import com.owenlejeune.tvtime.ui.components.WatchProvidersCard
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.theme.Typography
import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel
@@ -377,7 +368,7 @@ fun MediaViewContent(
mainViewModel = mainViewModel
)
VideosCard(
VideosArea(
itemId = itemId,
modifier = Modifier.fillMaxWidth(),
mainViewModel = mainViewModel,
@@ -386,7 +377,7 @@ fun MediaViewContent(
AdditionalDetailsCard(mediaItem = mediaItem, type = type)
WatchProvidersCard(itemId = itemId, type = type, mainViewModel = mainViewModel)
WatchProvidersArea(itemId = itemId, type = type, mainViewModel = mainViewModel)
if (
mediaItem?.productionCompanies?.firstOrNull { it.name == "Marvel Studios" } != null
@@ -891,6 +882,9 @@ private fun SeasonCard(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(all = 12.dp)
.clickable {
appNavController.navigate(AppNavItem.DetailView.withArgs(MediaViewType.SEASON, itemId.combineWith(it.seasonNumber)))
}
) {
PosterItem(
url = TmdbUtils.getFullPosterPath(it.posterPath),
@@ -992,7 +986,7 @@ fun SimilarContentCard(
}
@Composable
fun VideosCard(
fun VideosArea(
itemId: Int,
type: MediaViewType,
mainViewModel: MainViewModel,
@@ -1002,74 +996,13 @@ fun VideosCard(
val videos = videosMap[itemId]
if (videos?.any { it.isOfficial } == true) {
ExpandableContentCard(
modifier = modifier,
title = {
Text(
text = stringResource(id = R.string.videos_label),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
toggleTextColor = MaterialTheme.colorScheme.primary
) { isExpanded ->
VideoGroup(
results = videos,
type = Video.Type.TRAILER,
title = stringResource(id = Video.Type.TRAILER.stringRes)
)
if (isExpanded) {
Video.Type.values().filter { it != Video.Type.TRAILER }.forEach { type ->
VideoGroup(
results = videos,
type = type,
title = stringResource(id = type.stringRes)
)
}
}
}
}
}
@Composable
private fun VideoGroup(results: List<Video>, type: Video.Type, title: String) {
val videos = results.filter { it.isOfficial && it.type == type }
if (videos.isNotEmpty()) {
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 12.dp, top = 8.dp)
)
val posterWidth = 120.dp
LazyRow(modifier = Modifier
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
item {
Spacer(modifier = Modifier.width(8.dp))
}
listItems(videos) { video ->
FullScreenThumbnailVideoPlayer(
key = video.key,
title = video.name,
modifier = Modifier
.width(posterWidth)
.wrapContentHeight()
)
}
item {
Spacer(modifier = Modifier.width(8.dp))
}
}
VideosCard(videos = videos, modifier = modifier)
}
}
@SuppressLint("AutoboxingStateValueProperty")
@Composable
private fun WatchProvidersCard(
private fun WatchProvidersArea(
itemId: Int,
type: MediaViewType,
mainViewModel: MainViewModel,
@@ -1079,105 +1012,11 @@ private fun WatchProvidersCard(
val watchProviders = watchProvidersMap[itemId]
watchProviders?.let { providers ->
if (providers.buy?.isNotEmpty() == true || providers.rent?.isNotEmpty() == true || providers.flaterate?.isNotEmpty() == true) {
Card(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = stringResource(R.string.watch_providers_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp, top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val itemsMap = mutableMapOf<Int, List<WatchProviderDetails>>().apply {
providers.flaterate?.let { put(0, it) }
providers.rent?.let { put(1, it) }
providers.buy?.let { put(2, it) }
}
val selected = remember { mutableStateOf(if (itemsMap.isEmpty()) null else itemsMap.values.first()) }
val context = LocalContext.current
PillSegmentedControl(
items = itemsMap.values.toList(),
itemLabel = { i, _ ->
when (i) {
0 -> context.getString(R.string.streaming_label)
1 -> context.getString(R.string.rent_label)
2 -> context.getString(R.string.buy_label)
else -> ""
}
},
onItemSelected = { i, _ -> selected.value = itemsMap.values.toList()[i] },
modifier = Modifier.padding(all = 8.dp)
)
Crossfade(
modifier = modifier.padding(top = 4.dp, bottom = 12.dp),
targetState = selected.value
) { value ->
WatchProviderContainer(watchProviders = value!!, link = providers.link)
}
}
WatchProvidersCard(providers = providers, modifier = modifier)
}
}
}
@Composable
private fun WatchProviderContainer(
watchProviders: List<WatchProviderDetails>,
link: String
) {
val context = LocalContext.current
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
mainAxisSpacing = 8.dp,
crossAxisSpacing = 4.dp
) {
watchProviders
.sortedBy { it.displayPriority }
.forEach { item ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
context.startActivity(intent)
}
) {
val url = TmdbUtils.fullLogoPath(item.logoPath)
val model = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCacheKey(url)
.networkCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(10.dp))
)
Text(
text = item.providerName,
fontSize = 10.sp,
modifier = Modifier.width(48.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
private fun NextMcuProjectCard(
itemId: Int,

View File

@@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -46,8 +45,6 @@ import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailPerson
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.Image
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.extensions.DateFormat
@@ -60,6 +57,7 @@ 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.ImagesCard
import com.owenlejeune.tvtime.ui.components.PosterItem
import com.owenlejeune.tvtime.ui.components.TVTTopAppBar
import com.owenlejeune.tvtime.ui.components.TwoLineImageTextCard
@@ -193,7 +191,7 @@ fun PersonDetailScreen(
AdditionalDetailsCard(id = personId, mainViewModel = mainViewModel)
ImagesCard(id = personId, appNavController = appNavController)
ImagesArea(id = personId, appNavController = appNavController)
}
}
@@ -331,7 +329,7 @@ private fun CreditsCard(
}
@Composable
private fun ImagesCard(
private fun ImagesArea(
id: Int,
appNavController: NavController
) {
@@ -339,46 +337,12 @@ private fun ImagesCard(
val imagesMap = remember { mainViewModel.peopleImagesMap }
val images = imagesMap[id] ?: emptyList()
ContentCard(
title = stringResource(R.string.images_title)
) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Spacer(modifier = Modifier.width(8.dp))
}
items(images) { image ->
PosterItem(
width = 120.dp,
url = TmdbUtils.getFullPersonImagePath(image.filePath),
placeholder = Icons.Filled.Person,
title = ""
)
}
item {
Spacer(modifier = Modifier.width(8.dp))
}
}
Text(
text = stringResource(id = R.string.expand_see_all),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier
.padding(start = 16.dp, bottom = 16.dp)
.clickable {
appNavController.navigate(
AppNavItem.GalleryView.withArgs(
MediaViewType.PERSON,
id
)
)
}
ImagesCard(images = images) {
appNavController.navigate(
AppNavItem.GalleryView.withArgs(
MediaViewType.PERSON,
id
)
)
}
}

View File

@@ -35,7 +35,10 @@ import com.owenlejeune.tvtime.ui.components.CastCrewCard
import com.owenlejeune.tvtime.ui.components.ContentCard
import com.owenlejeune.tvtime.ui.components.DetailHeader
import com.owenlejeune.tvtime.ui.components.EpisodeItem
import com.owenlejeune.tvtime.ui.components.ImagesCard
import com.owenlejeune.tvtime.ui.components.TVTTopAppBar
import com.owenlejeune.tvtime.ui.components.VideosCard
import com.owenlejeune.tvtime.ui.components.WatchProvidersCard
import com.owenlejeune.tvtime.ui.theme.Typography
import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
@@ -52,6 +55,11 @@ private fun fetchData(
) {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch { mainViewModel.getSeason(seriesId, seasonNumber, force) }
scope.launch { mainViewModel.getSeasonAccountStates(seriesId, seasonNumber, force) }
scope.launch { mainViewModel.getSeasonImages(seriesId, seasonNumber, force) }
scope.launch { mainViewModel.getSeasonVideos(seriesId, seasonNumber, force) }
scope.launch { mainViewModel.getSeasonCredits(seriesId, seasonNumber, force) }
scope.launch { mainViewModel.getSeasonWatchProviders(seriesId, seasonNumber, force) }
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -86,7 +94,14 @@ fun SeasonDetailsScreen(
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
SeasonContent(appNavController = appNavController, season = season)
season?.let {
SeasonContent(
seriesId = seriesId,
appNavController = appNavController,
mainViewModel = mainViewModel,
season = season
)
}
}
}
}
@@ -94,8 +109,10 @@ fun SeasonDetailsScreen(
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun SeasonContent(
seriesId: Int,
appNavController: NavController,
season: Season?
mainViewModel: MainViewModel,
season: Season
) {
Column(
modifier = Modifier
@@ -104,7 +121,7 @@ private fun SeasonContent(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
DetailHeader(
posterUrl = TmdbUtils.getFullPosterPath(season?.posterPath),
posterUrl = TmdbUtils.getFullPosterPath(season.posterPath),
elevation = 0.dp,
expandedPosterAsBackdrop = true
)
@@ -115,7 +132,7 @@ private fun SeasonContent(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = season?.name ?: "",
text = season.name,
color = MaterialTheme.colorScheme.secondary,
style = Typography.headlineLarge,
maxLines = 2,
@@ -123,8 +140,32 @@ private fun SeasonContent(
modifier = Modifier.fillMaxWidth()
)
season?.episodes?.forEach { episode ->
SeasonEpisodeItem(appNavController = appNavController, episode = episode)
val accountStatesMap = remember { mainViewModel.tvSeasonAccountStates }
val accountStates = accountStatesMap[seriesId]?.get(season.seasonNumber)
season.episodes.forEach { episode ->
val rating = accountStates?.results?.find { it.id == episode.id }?.takeUnless { !it.isRated }?.rating
SeasonEpisodeItem(appNavController = appNavController, episode = episode, rating = rating)
}
val imagesMap = remember { mainViewModel.tvSeasonImages }
val images = imagesMap[seriesId]?.get(season.seasonNumber)
images?.let {
ImagesCard(images = images.posters)
}
val videosMap = remember { mainViewModel.tvSeasonVideos }
val videos = videosMap[seriesId]?.get(season.seasonNumber)
if (videos?.any { it.isOfficial } == true) {
VideosCard(videos = videos, modifier = Modifier.fillMaxWidth())
}
val watchProvidersMap = remember { mainViewModel.tvSeasonWatchProviders }
val watchProviders = watchProvidersMap[seriesId]?.get(season.seasonNumber)
watchProviders?.let { providers ->
if (providers.buy?.isNotEmpty() == true || providers.rent?.isNotEmpty() == true || providers.flaterate?.isNotEmpty() == true) {
WatchProvidersCard(providers = providers)
}
}
}
@@ -135,10 +176,16 @@ private fun SeasonContent(
@Composable
private fun SeasonEpisodeItem(
appNavController: NavController,
episode: Episode
episode: Episode,
rating: Int?
) {
ContentCard {
EpisodeItem(episode = episode, elevation = 0.dp, maxDescriptionLines = 5)
EpisodeItem(
episode = episode,
elevation = 0.dp,
maxDescriptionLines = 5,
rating = rating
)
episode.guestStars?.let { guestStars ->
Spacer(modifier = Modifier.height(12.dp))

View File

@@ -103,6 +103,12 @@ class MainViewModel: ViewModel(), KoinComponent {
val similarTv = tvService.similar
val tvAccountStates = tvService.accountStates
val tvKeywordResults = tvService.keywordResults
val tvSeasonAccountStates = tvService.seasonAccountStates
val tvSeasonCast = tvService.seasonCast
val tvSeasonCrew = tvService.seasonCrew
val tvSeasonImages = tvService.seasonImages
val tvSeasonVideos = tvService.seasonVideos
val tvSeasonWatchProviders = tvService.seasonWatchProviders
val tvDetailsLoadingState = tvService.detailsLoadingState
val tvImagesLoadingState = tvService.imagesLoadingState
@@ -115,6 +121,11 @@ class MainViewModel: ViewModel(), KoinComponent {
val tvSeasonsLoadingState = tvService.seasonsLoadingState
val tvContentRatingsLoadingState = tvService.contentRatingsLoadingState
val tvAccountStatesLoadingState = tvService.accountStatesLoadingState
val tvSeasonAccountStatesLoadingState = tvService.seasonAccountStatesLoadingState
val tvSeasonCreditsLoadingState = tvService.seasonCreditsLoadingState
val tvSeasonImagesLoadingState = tvService.seasonImagesLoadingState
val tvSeasonVideosLoadingState = tvService.seasonVideosLoadingState
val tvSeasonWatchProvidersLoadingState = tvService.seasonWatchProvidersLoadingState
val popularTv by lazy {
createPagingFlow(
@@ -455,6 +466,39 @@ class MainViewModel: ViewModel(), KoinComponent {
}
}
suspend fun getSeasonAccountStates(seriesId: Int, seasonId: Int, force: Boolean = false) {
if (tvSeasonAccountStates[seriesId]?.get(seasonId) == null || force) {
tvService.getSeasonAccountStates(seriesId, seasonId, force)
}
}
suspend fun getSeasonImages(seriesId: Int, seasonId: Int, force: Boolean = false) {
if (tvSeasonImages[seriesId]?.get(seasonId) == null || force) {
tvService.getSeasonImages(seriesId, seasonId, force)
}
}
suspend fun getSeasonVideos(seriesId: Int, seasonId: Int, force: Boolean = false) {
if (tvSeasonVideos[seriesId]?.get(seasonId) == null || force) {
tvService.getSeasonVideos(seriesId, seasonId, force)
}
}
suspend fun getSeasonCredits(seriesId: Int, seasonId: Int, force: Boolean = false) {
if (tvSeasonCast[seriesId]?.get(seasonId) == null ||
tvSeasonCrew[seriesId]?.get(seasonId) == null ||
force
) {
tvService.getSeasonCredits(seriesId, seasonId, force)
}
}
suspend fun getSeasonWatchProviders(seriesId: Int, seasonId: Int, force: Boolean = false) {
if (tvSeasonWatchProviders[seriesId]?.get(seasonId) == null || force) {
tvService.getSeasonWatchProviders(seriesId, seasonId, force)
}
}
@SuppressLint("ComposableNaming")
@Composable
fun monitorDetailsLoadingRefreshing(refreshing: MutableState<Boolean>) {

View File

@@ -262,4 +262,5 @@
<string name="date_of_death">Date of death</string>
<string name="unexpected_error">An unexpected error occurred</string>
<string name="guest_stars_label">Guest stars</string>
<string name="your_rating">Your rating: %1$d/10</string>
</resources>