display list of seasons/episodes on tv series details page

This commit is contained in:
Owen LeJeune
2023-06-11 11:14:33 -04:00
parent f90a2a91bd
commit 7130930564
8 changed files with 332 additions and 24 deletions

View File

@@ -55,4 +55,7 @@ interface TvApi {
@Query("session_id") sessionId: String
): Response<StatusResponse>
@GET("tv/{id}/season/{season}")
suspend fun getSeason(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<Season>
}

View File

@@ -8,6 +8,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.KeywordsResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.RatingBody
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ReviewResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.StatusResponse
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.TvContentRatings
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.VideoResponse
@@ -77,4 +78,8 @@ class TvService: KoinComponent, DetailService, HomePageService {
return service.getKeywords(id)
}
suspend fun getSeason(seriesId: Int, seasonId: Int): Response<Season> {
return service.getSeason(seriesId, seasonId)
}
}

View File

@@ -0,0 +1,18 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName
data class Episode(
@SerializedName("name")
val name: String,
@SerializedName("overview")
val overview: String,
@SerializedName("episode_number")
val episodeNumber: Int,
@SerializedName("season_number")
val seasonNumber: Int,
@SerializedName("still_path")
val stillPath: String?,
@SerializedName("air_date")
val airDate: String?
)

View File

@@ -0,0 +1,10 @@
package com.owenlejeune.tvtime.api.tmdb.api.v3.model
import com.google.gson.annotations.SerializedName
data class Season(
@SerializedName("name")
val name: String,
@SerializedName("episodes")
val episodes: List<Episode>
)

View File

@@ -1,40 +1,47 @@
package com.owenlejeune.tvtime.ui.screens.main
import android.content.Context
import android.media.MediaActionSound
import android.widget.Toast
import androidx.compose.animation.*
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import coil.compose.AsyncImage
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@@ -49,6 +56,8 @@ import com.owenlejeune.tvtime.extensions.listItems
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.components.*
import com.owenlejeune.tvtime.ui.navigation.MainNavItem
import com.owenlejeune.tvtime.ui.navigation.TabNavItem
import com.owenlejeune.tvtime.ui.screens.main.tabs.top.Tabs
import com.owenlejeune.tvtime.ui.theme.FavoriteSelected
import com.owenlejeune.tvtime.ui.theme.RatingSelected
import com.owenlejeune.tvtime.ui.theme.WatchlistSelected
@@ -57,7 +66,6 @@ import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils
import kotlinx.coroutines.*
import org.json.JSONObject
import org.koin.java.KoinJavaComponent
import org.koin.java.KoinJavaComponent.get
import java.text.DecimalFormat
@@ -194,7 +202,7 @@ private fun MediaViewContent(
)
Column(
modifier = Modifier.padding(horizontal = 16.dp),
// modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (type == MediaViewType.MOVIE) {
@@ -205,18 +213,55 @@ private fun MediaViewContent(
ActionsView(itemId = itemId, type = type, service = service)
OverviewCard(itemId = itemId, mediaItem = mediaItem, service = service)
if (type == MediaViewType.MOVIE) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(horizontal = 16.dp)
) {
MainContent(
itemId = itemId,
mediaItem = mediaItem,
type = type,
service = service,
appNavController = appNavController,
windowSize = windowSize
)
}
} else {
val tabState = rememberPagerState()
val tabs = listOf(DetailsTab, SeasonsTab)
TvSeriesTabs(pagerState = tabState, tabs = tabs)
HorizontalPager(
count = tabs.size,
state = tabState,
userScrollEnabled = false
) { page ->
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(horizontal = 16.dp)
) {
when (tabs[page]) {
is DetailsTab -> {
MainContent(
itemId = itemId,
mediaItem = mediaItem,
type = type,
service = service,
appNavController = appNavController,
windowSize = windowSize
)
}
CastCard(itemId = itemId, service = service, appNavController = appNavController)
SimilarContentCard(itemId = itemId, service = service, mediaType = type, appNavController = appNavController)
VideosCard(itemId = itemId, service = service)
AdditionalDetailsCard(itemId = itemId, mediaItem = mediaItem, service = service, type = type)
if (windowSize != WindowSizeClass.Expanded) {
ReviewsCard(itemId = itemId, service = service)
is SeasonsTab -> {
SeasonsTab(
itemId = itemId,
mediaItem = mediaItem,
service = service as TvService
)
}
}
}
}
}
}
@@ -238,6 +283,173 @@ private fun MediaViewContent(
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun TvSeriesTabs(
pagerState: PagerState,
tabs: List<TabNavItem>
) {
Tabs(
modifier = Modifier.offset(y = (-16).dp),
pagerState = pagerState,
tabs = tabs
)
}
@Composable
private fun SeasonsTab(
itemId: Int?,
mediaItem: MutableState<DetailedItem?>,
service: TvService
) {
val scope = rememberCoroutineScope()
mediaItem.value?.let { tv ->
val series = tv as DetailedTv
val seasons = remember { mutableStateMapOf<Int, Season>() }
LaunchedEffect(Unit) {
itemId?.let {
for (i in 0..series.numberOfSeasons) {
scope.launch {
val season = service.getSeason(it, i)
if (season.isSuccessful) {
seasons[i] = season.body()!!
}
}
}
}
}
seasons.toSortedMap().values.forEach { season ->
SeasonSection(season = season)
}
}
}
@Composable
private fun SeasonSection(season: Season) {
var isExpanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(color = MaterialTheme.colorScheme.surfaceVariant)
.clickable {
isExpanded = !isExpanded
}
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = season.name,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
var currentRotation by remember { mutableStateOf(0f) }
val rotation = remember { androidx.compose.animation.core.Animatable(currentRotation) }
LaunchedEffect(isExpanded) {
rotation.animateTo(
targetValue = if (isExpanded) 180f else 0f,
animationSpec = tween(200, easing = LinearEasing)
) {
currentRotation = value
}
}
Icon(
imageVector = Icons.Outlined.ExpandMore,
contentDescription = null,
modifier = Modifier
.size(32.dp)
.rotate(currentRotation),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
season.episodes.forEachIndexed { index, episode ->
EpisodeItem(episode = episode)
if (index != season.episodes.size - 1) {
Divider()
}
}
}
}
}
@Composable
private fun EpisodeItem(episode: Episode) {
val height = 170.dp
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.height(height)
) {
AsyncImage(
model = TmdbUtils.getFullEpisodeStillPath(episode),
contentDescription = null,
modifier = Modifier
.size(width = 100.dp, height = height)
.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "S${episode.seasonNumber}E${episode.episodeNumber}${episode.name}",
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
TmdbUtils.convertEpisodeDate(episode.airDate)?.let {
Text(
text = it,
fontStyle = FontStyle.Italic,
fontSize = 12.sp
)
}
Text(
text = episode.overview,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
@Composable
private fun MainContent(
itemId: Int?,
mediaItem: MutableState<DetailedItem?>,
type: MediaViewType,
service: DetailService,
appNavController: NavController,
windowSize: WindowSizeClass
) {
OverviewCard(itemId = itemId, mediaItem = mediaItem, service = service)
CastCard(itemId = itemId, service = service, appNavController = appNavController)
SimilarContentCard(itemId = itemId, service = service, mediaType = type, appNavController = appNavController)
VideosCard(itemId = itemId, service = service, modifier = Modifier.fillMaxWidth())
AdditionalDetailsCard(itemId = itemId, mediaItem = mediaItem, service = service, type = type)
if (windowSize != WindowSizeClass.Expanded) {
ReviewsCard(itemId = itemId, service = service)
}
}
@Composable
private fun MiscTvDetails(mediaItem: MutableState<DetailedItem?>, service: TvService) {
mediaItem.value?.let { tv ->
@@ -249,7 +461,8 @@ private fun MiscTvDetails(mediaItem: MutableState<DetailedItem?>, service: TvSer
MiscDetails(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
.wrapContentHeight()
.padding(horizontal = 16.dp),
year = TmdbUtils.getSeriesRun(series),
runtime = TmdbUtils.convertRuntimeToHoursMinutes(series),
genres = series.genres,
@@ -269,7 +482,8 @@ private fun MiscMovieDetails(mediaItem: MutableState<DetailedItem?>, service: Mo
MiscDetails(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
.wrapContentHeight()
.padding(horizontal = 16.dp),
year = TmdbUtils.getMovieReleaseYear(movie),
runtime = TmdbUtils.convertRuntimeToHoursMinutes(movie),
genres = movie.genres,
@@ -328,7 +542,8 @@ private fun ActionsView(
val session = SessionManager.currentSession.value
Row(
modifier = modifier
.wrapContentSize(),
.wrapContentSize()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RateButton(
@@ -985,7 +1200,11 @@ fun SimilarContentCard(
}
@Composable
fun VideosCard(itemId: Int?, service: DetailService, modifier: Modifier = Modifier) {
fun VideosCard(
itemId: Int?,
service: DetailService,
modifier: Modifier = Modifier
) {
val videoResponse = remember { mutableStateOf<VideoResponse?>(null) }
itemId?.let {
if (videoResponse.value == null) {
@@ -1373,4 +1592,13 @@ private fun addToFavorite(
}
}
}
}
object DetailsTab: TabNavItem("details_route") {
override val name: String
get() = "Details"
}
object SeasonsTab: TabNavItem("seasons_route") {
override val name: String
get() = "Seasons"
}

View File

@@ -75,9 +75,9 @@ sealed class OnboardingPage(
.padding(all = 24.dp)
) {
Icon(
painter = painterResource(id = R.drawable.tmdb_logo),
painter = painterResource(id = R.drawable.tmdb_logo_long),
contentDescription = null,
modifier = Modifier.size(32.dp),
modifier = Modifier.height(height = 16.dp),
tint = Color.Unspecified
)
Text(

View File

@@ -11,6 +11,7 @@ object TmdbUtils {
private const val PERSON_BASE = "https://www.themoviedb.org/t/p/w600_and_h900_bestv2"
private const val GRAVATAR_BASE = "https://www.gravatar.com/avatar/"
private const val AVATAR_BASE = "https://www.themoviedb.org/t/p/w150_and_h150_face"
private const val STILL_BASE = "https://www.themoviedb.org/t/p/w454_and_h254_bestv2/"
private const val DEF_REGION = "US"
@@ -63,6 +64,16 @@ object TmdbUtils {
return getFullAvatarPath(author.avatarPath)
}
fun getFullEpisodeStillPath(path: String?): String? {
return path?.let {
"${STILL_BASE}${path}"
}
}
fun getFullEpisodeStillPath(episode: Episode): String? {
return getFullEpisodeStillPath(episode.stillPath)
}
fun getMovieReleaseYear(movie: DetailedMovie): String {
return movie.releaseDate.split("-")[0]
}
@@ -208,4 +219,15 @@ object TmdbUtils {
return "$${thousands}"
}
fun convertEpisodeDate(inDate: String?): String? {
if (inDate == null) {
return null
}
val origFormat = SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault())
val outFormat = SimpleDateFormat("MMMM dd, yyyy", java.util.Locale.getDefault())
return origFormat.parse(inDate)?.let { outFormat.format(it) }
}
}

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="489.04dp"
android:height="35.4dp"
android:viewportWidth="489.04"
android:viewportHeight="35.4">
<path
android:pathData="M293.5,0h8.9l8.75,23.2h0.1L320.15,0h8.35L313.9,35.4h-6.25ZM340.1,0h7.8L347.9,35.4h-7.8ZM362.3,0h24.05L386.35,7.2L370.1,7.2v6.6h15.35L385.45,21L370.1,21v7.2h17.15v7.2L362.3,35.4ZM417.3,0L429,0a33.54,33.54 0,0 1,8.07 1A18.55,18.55 0,0 1,443.75 4a15.1,15.1 0,0 1,4.52 5.53A18.5,18.5 0,0 1,450 17.8a16.91,16.91 0,0 1,-1.63 7.58,16.37 16.37,0 0,1 -4.37,5.5 19.52,19.52 0,0 1,-6.35 3.37A24.59,24.59 0,0 1,430 35.4L417.29,35.4ZM425.11,28.2h4a21.57,21.57 0,0 0,5 -0.55,10.87 10.87,0 0,0 4,-1.83 8.69,8.69 0,0 0,2.67 -3.34,11.92 11.92,0 0,0 1,-5.08 9.87,9.87 0,0 0,-1 -4.52,9 9,0 0,0 -2.62,-3.18 11.68,11.68 0,0 0,-3.88 -1.88,17.43 17.43,0 0,0 -4.67,-0.62h-4.6ZM461.24,0h13.2a34.42,34.42 0,0 1,4.63 0.32,12.9 12.9,0 0,1 4.17,1.3 7.88,7.88 0,0 1,3 2.73A8.34,8.34 0,0 1,487.39 9a7.42,7.42 0,0 1,-1.67 5,9.28 9.28,0 0,1 -4.43,2.82v0.1a10,10 0,0 1,3.18 1,8.38 8.38,0 0,1 2.45,1.85 7.79,7.79 0,0 1,1.57 2.62,9.16 9.16,0 0,1 0.55,3.2 8.52,8.52 0,0 1,-1.2 4.68,9.42 9.42,0 0,1 -3.1,3 13.38,13.38 0,0 1,-4.27 1.65,23.11 23.11,0 0,1 -4.73,0.5h-14.5ZM469,14.15h5.65a8.16,8.16 0,0 0,1.78 -0.2A4.78,4.78 0,0 0,478 13.3a3.34,3.34 0,0 0,1.13 -1.2,3.63 3.63,0 0,0 0.42,-1.8 3.22,3.22 0,0 0,-0.47 -1.82,3.33 3.33,0 0,0 -1.23,-1.13 5.77,5.77 0,0 0,-1.7 -0.58,10.79 10.79,0 0,0 -1.85,-0.17L469,6.6ZM469,28.8h7a8.91,8.91 0,0 0,1.83 -0.2,4.78 4.78,0 0,0 1.67,-0.7 4,4 0,0 0,1.23 -1.3,3.71 3.71,0 0,0 0.47,-2 3.13,3.13 0,0 0,-0.62 -2A4,4 0,0 0,479 21.45,7.83 7.83,0 0,0 477,20.9a15.12,15.12 0,0 0,-2.05 -0.15L469,20.75ZM204,35.33L271,35.33a17.66,17.66 0,0 0,17.66 -17.66h0A17.67,17.67 0,0 0,271 0L204.06,0A17.67,17.67 0,0 0,186.4 17.67h0A17.66,17.66 0,0 0,204.06 35.33ZM10.1,6.9L0,6.9L0,0L28,0L28,6.9L17.9,6.9L17.9,35.4L10.1,35.4ZM39,0h7.8L46.8,13.2L61.9,13.2L61.9,0h7.8L69.7,35.4L61.9,35.4L61.9,20.1L46.75,20.1L46.75,35.4L39,35.4ZM80.2,0h24L104.2,7.2L88,7.2v6.6h15.35L103.35,21L88,21v7.2h17.15v7.2h-25ZM135.2,0L147,0l8.15,23.1h0.1L163.45,0L175.2,0L175.2,35.4h-7.8L167.4,8.25h-0.1L158,35.4h-5.95l-9,-27.15L143,8.25L143,35.4h-7.8Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="0"
android:startY="17.7"
android:endX="489.04"
android:endY="17.7"
android:type="linear">
<item android:offset="0" android:color="#FF90CEA1"/>
<item android:offset="0.56" android:color="#FF3CBEC9"/>
<item android:offset="1" android:color="#FF00B3E5"/>
</gradient>
</aapt:attr>
</path>
</vector>