From 71309305643f846757579553bc851d620949fbbc Mon Sep 17 00:00:00 2001 From: Owen LeJeune Date: Sun, 11 Jun 2023 11:14:33 -0400 Subject: [PATCH] display list of seasons/episodes on tv series details page --- .../tvtime/api/tmdb/api/v3/TvApi.kt | 3 + .../tvtime/api/tmdb/api/v3/TvService.kt | 5 + .../tvtime/api/tmdb/api/v3/model/Episode.kt | 18 ++ .../tvtime/api/tmdb/api/v3/model/Season.kt | 10 + .../tvtime/ui/screens/main/MediaDetailView.kt | 272 ++++++++++++++++-- .../ui/screens/onboarding/OnboardingPage.kt | 4 +- .../com/owenlejeune/tvtime/utils/TmdbUtils.kt | 22 ++ app/src/main/res/drawable/tmdb_logo_long.xml | 22 ++ 8 files changed, 332 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Episode.kt create mode 100644 app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Season.kt create mode 100644 app/src/main/res/drawable/tmdb_logo_long.xml 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 c3a19e4..f028478 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 @@ -55,4 +55,7 @@ interface TvApi { @Query("session_id") sessionId: String ): Response + @GET("tv/{id}/season/{season}") + suspend fun getSeason(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): 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 c2ffb85..a057ff0 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 @@ -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 { + return service.getSeason(seriesId, seasonId) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Episode.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Episode.kt new file mode 100644 index 0000000..515fb8c --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Episode.kt @@ -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? +) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Season.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Season.kt new file mode 100644 index 0000000..8447bf3 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/api/v3/model/Season.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/MediaDetailView.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/MediaDetailView.kt index a753163..5941cc1 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/MediaDetailView.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/MediaDetailView.kt @@ -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 +) { + Tabs( + modifier = Modifier.offset(y = (-16).dp), + pagerState = pagerState, + tabs = tabs + ) +} + +@Composable +private fun SeasonsTab( + itemId: Int?, + mediaItem: MutableState, + service: TvService +) { + val scope = rememberCoroutineScope() + + mediaItem.value?.let { tv -> + val series = tv as DetailedTv + + val seasons = remember { mutableStateMapOf() } + 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, + 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, service: TvService) { mediaItem.value?.let { tv -> @@ -249,7 +461,8 @@ private fun MiscTvDetails(mediaItem: MutableState, 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, 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(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" } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/onboarding/OnboardingPage.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/onboarding/OnboardingPage.kt index 4267744..ade96f1 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/onboarding/OnboardingPage.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/onboarding/OnboardingPage.kt @@ -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( diff --git a/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt b/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt index a2fa5c9..20e3d07 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/utils/TmdbUtils.kt @@ -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) } + } + } \ No newline at end of file diff --git a/app/src/main/res/drawable/tmdb_logo_long.xml b/app/src/main/res/drawable/tmdb_logo_long.xml new file mode 100644 index 0000000..96ee3dd --- /dev/null +++ b/app/src/main/res/drawable/tmdb_logo_long.xml @@ -0,0 +1,22 @@ + + + + + + + + + + +