create basic season details screen

This commit is contained in:
Owen LeJeune
2023-07-19 18:45:33 -04:00
parent c51d7de5ba
commit dcb24561ec
10 changed files with 512 additions and 259 deletions

View File

@@ -56,9 +56,6 @@ 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>
@GET("tv/{id}/watch/providers")
suspend fun getWatchProviders(@Path("id") seriesId: Int): Response<WatchProviderResponse>
@@ -73,4 +70,7 @@ interface TvApi {
@GET("trending/tv/{time_window}")
suspend fun trending(@Path("time_window") timeWindow: String, @Query("page") page: Int): Response<SearchResult<SearchResultTv>>
@GET("tv/{id}/season/{season}")
suspend fun getSeason(@Path("id") seriesId: Int, @Path("season") seasonNumber: Int): Response<Season>
}

View File

@@ -10,4 +10,6 @@ fun Any.coroutineTask(runnable: suspend () -> Unit) {
fun <T> anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate)
fun <T: Any> T.isIn(vararg items: T): Boolean = items.any { it == this }
fun <T: Any> T.isIn(vararg items: T): Boolean = items.any { it == this }
fun <T> pairOf(a: T, b: T) = Pair(a, b)

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.pow
fun Float.dpToPx(context: Context): Float {
return this * context.resources.displayMetrics.density
@@ -12,3 +13,19 @@ fun Float.dpToPx(context: Context): Float {
@Composable
fun Int.toDp(): Dp = (this / LocalContext.current.resources.displayMetrics.density).toInt().dp
fun Int.combineWith(other: Int): Int {
val max = maxOf(this, other)
val min = minOf(this, other)
return 2f.pow(min).toInt() * ((2 * max) + 1)
}
fun Int.toCompositeParts(): Pair<Int, Int> {
var a = 0
var result = this
do {
result /= 2
a++
} while (result % 2 == 0)
return pairOf(a, result/2)
}

View File

@@ -5,9 +5,12 @@ import android.net.Uri
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -16,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -24,12 +28,14 @@ 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.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.request.CachePolicy
@@ -40,10 +46,16 @@ import com.google.accompanist.pager.PagerState
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.ExternalIds
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode
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.extensions.shimmerBackground
import com.owenlejeune.tvtime.extensions.toDp
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType
@@ -58,6 +70,7 @@ fun DetailHeader(
imageCollection: ImageCollection? = null,
backdropUrl: String? = null,
posterUrl: String? = null,
expandedPosterAsBackdrop: Boolean = false,
backdropContentDescription: String? = null,
posterContentDescription: String? = null,
rating: Float? = null,
@@ -71,20 +84,48 @@ fun DetailHeader(
.wrapContentHeight()
)
) {
if (imageCollection != null) {
BackdropGallery(
modifier = Modifier
.clickable {
showGalleryOverlay?.value = true
},
imageCollection = imageCollection,
state = pagerState
)
if (expandedPosterAsBackdrop) {
Box {
val url = TmdbUtils.getFullPosterPath(posterUrl)
val model = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCacheKey(url ?: "")
.networkCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.blur(radius = 10.dp)
.fillMaxWidth()
.aspectRatio(1.778f)
)
Box(
modifier = Modifier
.matchParentSize()
.background(Color.Black.copy(alpha = 0.6f))
.blur(radius = 5.dp)
)
}
} else {
Backdrop(
imageUrl = backdropUrl,
contentDescription = backdropContentDescription
)
if (imageCollection != null) {
BackdropGallery(
modifier = Modifier
.clickable {
showGalleryOverlay?.value = true
},
imageCollection = imageCollection,
state = pagerState
)
} else {
Backdrop(
imageUrl = backdropUrl,
contentDescription = backdropContentDescription
)
}
}
Row(
@@ -361,4 +402,119 @@ fun AdditionalDetailItem(
Divider()
}
}
}
@Composable
fun EpisodeItem(
episode: Episode,
elevation: Dp = 10.dp,
maxDescriptionLines: Int = 2
) {
Card(
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = elevation),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier
.fillMaxWidth()
.clickable {
}
) {
Box {
episode.stillPath?.let {
// Cloudy(
// modifier = Modifier.background(Color.Black.copy(alpha = 0.4f))
// ) {
val url = TmdbUtils.getFullEpisodeStillPath(it)
val model = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCacheKey(url ?: "")
.networkCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.blur(radius = 10.dp)
// .fillMaxWidth()
// .wrapContentHeight()
.matchParentSize()
)
Box(
modifier = Modifier
// .fillMaxSize()
.matchParentSize()
.background(Color.Black.copy(alpha = 0.4f))
.blur(radius = 5.dp)
)
// }
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(12.dp)
) {
val textColor = episode.stillPath?.let { Color.White } ?: if (isSystemInDarkTheme()) Color.White else Color.Black
Text(
text = "S${episode.seasonNumber}E${episode.episodeNumber}${episode.name}",
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
TmdbUtils.convertEpisodeDate(episode.airDate)?.let {
Text(
text = it,
fontStyle = FontStyle.Italic,
fontSize = 12.sp,
color = textColor
)
}
Text(
text = episode.overview,
overflow = TextOverflow.Ellipsis,
color = textColor,
maxLines = maxDescriptionLines
)
}
}
}
}
@Composable
fun CastCrewCard(
appNavController: NavController,
person: Person
) {
TwoLineImageTextCard(
title = person.name,
modifier = Modifier
.width(124.dp)
.wrapContentHeight(),
subtitle = when (person) {
is MovieCastMember -> person.character
is MovieCrewMember -> person.job
is TvCastMember -> {
val roles = person.roles.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
is TvCrewMember -> {
val roles = person.jobs.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
else -> null
},
imageUrl = TmdbUtils.getFullPersonImagePath(person),
titleTextColor = MaterialTheme.colorScheme.onPrimary,
subtitleTextColor = MaterialTheme.colorScheme.onSecondary,
onItemClicked = {
appNavController.navigate(
AppNavItem.DetailView.withArgs(MediaViewType.PERSON, person.id)
)
}
)
}

View File

@@ -1,10 +1,12 @@
package com.owenlejeune.tvtime.ui.navigation
import android.widget.Toast
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -28,6 +30,7 @@ import com.owenlejeune.tvtime.ui.screens.ListDetailScreen
import com.owenlejeune.tvtime.ui.screens.MediaDetailScreen
import com.owenlejeune.tvtime.ui.screens.PersonDetailScreen
import com.owenlejeune.tvtime.ui.screens.SearchScreen
import com.owenlejeune.tvtime.ui.screens.SeasonDetailsScreen
import com.owenlejeune.tvtime.ui.screens.SeasonListScreen
import com.owenlejeune.tvtime.ui.screens.SettingsScreen
import com.owenlejeune.tvtime.ui.screens.WebLinkScreen
@@ -54,14 +57,40 @@ fun AppNavigationHost(
exitTransition = { fadeOut(tween(500)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(500)) }
) {
composable(route = AppNavItem.MainView.route) {
applicationViewModel.currentRoute.value = AppNavItem.MainView.route
HomeScreen(
// About View
composable(route = AppNavItem.AboutView.route) {
applicationViewModel.currentRoute.value = AppNavItem.AboutView.route
AboutScreen(appNavController = appNavController)
}
// Account View
composable(
route = AppNavItem.AccountView.route,
deepLinks = listOf(
navDeepLink { uriPattern = "app://tvtime.auth.{${NavConstants.ACCOUNT_KEY}}" }
)
) {
val deepLink = it.arguments?.getString(NavConstants.ACCOUNT_KEY)
applicationViewModel.currentRoute.value = AppNavItem.AccountView.route
AccountScreen(
appNavController = appNavController,
mainNavStartRoute = mainNavStartRoute,
windowSize = windowSize
doSignInPartTwo = deepLink == NavConstants.AUTH_REDIRECT_PAGE
)
}
// Cast Crew List View
composable(
route = AppNavItem.CastCrewListView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.CastCrewListView.withArgs(type, id)
CastCrewListScreen(appNavController = appNavController, type = type, id = id)
}
// Detail View
composable(
route = AppNavItem.DetailView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
@@ -86,7 +115,7 @@ fun AppNavigationHost(
itemId = id
)
}
else -> {
MediaViewType.MOVIE, MediaViewType.TV -> {
MediaDetailScreen(
appNavController = appNavController,
itemId = id,
@@ -94,12 +123,75 @@ fun AppNavigationHost(
windowSize = windowSize
)
}
MediaViewType.SEASON -> {
SeasonDetailsScreen(
appNavController = appNavController,
codedId = id
)
}
else -> {
appNavController.popBackStack()
Toast.makeText(LocalContext.current, stringResource(R.string.unexpected_error), Toast.LENGTH_SHORT).show()
}
}
}
composable(AppNavItem.SettingsView.route) {
applicationViewModel.currentRoute.value = AppNavItem.SettingsView.route
SettingsScreen(appNavController = appNavController)
// Gallery View
composable(
route = AppNavItem.GalleryView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.GalleryView.withArgs(type, id)
GalleryView(id = id, type = type, appNavController = appNavController)
}
// Keywords View
composable(
route = AppNavItem.KeywordsView.route.plus("/{${NavConstants.KEYWORD_TYPE_KEY}}?keyword={${NavConstants.KEYWORD_NAME_KEY}}&keywordId={${NavConstants.KEYWORD_ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.KEYWORD_TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.KEYWORD_NAME_KEY) { type = NavType.StringType },
navArgument(NavConstants.KEYWORD_ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.KEYWORD_TYPE_KEY, MediaViewType::class.java)!!
val keywords = navBackStackEntry.arguments?.getString(NavConstants.KEYWORD_NAME_KEY) ?: ""
val id = navBackStackEntry.arguments?.getInt(NavConstants.KEYWORD_ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.KeywordsView.withArgs(type, keywords, id)
KeywordResultsScreen(
type = type,
keyword = keywords,
id = id,
appNavController = appNavController
)
}
// Known For View
composable(
route = AppNavItem.KnownForView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.KnownForView.withArgs(id)
KnownForScreen(appNavController = appNavController, id = id)
}
// Main View
composable(route = AppNavItem.MainView.route) {
applicationViewModel.currentRoute.value = AppNavItem.MainView.route
HomeScreen(
appNavController = appNavController,
mainNavStartRoute = mainNavStartRoute,
windowSize = windowSize
)
}
// Search View
composable(
route = AppNavItem.SearchView.route.plus("?searchType={${NavConstants.SEARCH_ID_KEY}}&pageTitle={${NavConstants.SEARCH_TITLE_KEY}}"),
arguments = listOf(
@@ -128,6 +220,24 @@ fun AppNavigationHost(
)
}
}
// Season List View
composable(
route = AppNavItem.SeasonListView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.SeasonListView.withArgs(id)
SeasonListScreen(id = id, appNavController = appNavController)
}
// Settings View
composable(AppNavItem.SettingsView.route) {
applicationViewModel.currentRoute.value = AppNavItem.SettingsView.route
SettingsScreen(appNavController = appNavController)
}
// Web Link View
composable(
route = AppNavItem.WebLinkView.route.plus("/{${NavConstants.WEB_LINK_KEY}}"),
arguments = listOf(
@@ -140,91 +250,6 @@ fun AppNavigationHost(
WebLinkScreen(url = url, appNavController = appNavController)
}
}
composable(
route = AppNavItem.AccountView.route,
deepLinks = listOf(
navDeepLink { uriPattern = "app://tvtime.auth.{${NavConstants.ACCOUNT_KEY}}" }
)
) {
val deepLink = it.arguments?.getString(NavConstants.ACCOUNT_KEY)
applicationViewModel.currentRoute.value = AppNavItem.AccountView.route
AccountScreen(
appNavController = appNavController,
doSignInPartTwo = deepLink == NavConstants.AUTH_REDIRECT_PAGE
)
}
composable(route = AppNavItem.AboutView.route) {
applicationViewModel.currentRoute.value = AppNavItem.AboutView.route
AboutScreen(appNavController = appNavController)
}
composable(
route = AppNavItem.KeywordsView.route.plus("/{${NavConstants.KEYWORD_TYPE_KEY}}?keyword={${NavConstants.KEYWORD_NAME_KEY}}&keywordId={${NavConstants.KEYWORD_ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.KEYWORD_TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.KEYWORD_NAME_KEY) { type = NavType.StringType },
navArgument(NavConstants.KEYWORD_ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.KEYWORD_TYPE_KEY, MediaViewType::class.java)!!
val keywords = navBackStackEntry.arguments?.getString(NavConstants.KEYWORD_NAME_KEY) ?: ""
val id = navBackStackEntry.arguments?.getInt(NavConstants.KEYWORD_ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.KeywordsView.withArgs(type, keywords, id)
KeywordResultsScreen(
type = type,
keyword = keywords,
id = id,
appNavController = appNavController
)
}
composable(
route = AppNavItem.KnownForView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.KnownForView.withArgs(id)
KnownForScreen(appNavController = appNavController, id = id)
}
composable(
route = AppNavItem.GalleryView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.GalleryView.withArgs(type, id)
GalleryView(id = id, type = type, appNavController = appNavController)
}
composable(
route = AppNavItem.SeasonListView.route.plus("/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.SeasonListView.withArgs(id)
SeasonListScreen(id = id, appNavController = appNavController)
}
composable(
route = AppNavItem.CastCrewListView.route.plus("/{${NavConstants.TYPE_KEY}}/{${NavConstants.ID_KEY}}"),
arguments = listOf(
navArgument(NavConstants.TYPE_KEY) { type = NavType.EnumType(MediaViewType::class.java) },
navArgument(NavConstants.ID_KEY) { type = NavType.IntType }
)
) { navBackStackEntry ->
val type = navBackStackEntry.arguments?.safeGetSerializable(NavConstants.TYPE_KEY, MediaViewType::class.java)!!
val id = navBackStackEntry.arguments?.getInt(NavConstants.ID_KEY)!!
applicationViewModel.currentRoute.value = AppNavItem.CastCrewListView.withArgs(type, id)
CastCrewListScreen(appNavController = appNavController, type = type, id = id)
}
}
}
@@ -234,33 +259,33 @@ sealed class AppNavItem(val route: String) {
val Items = listOf(MainView, DetailView, SettingsView)
}
object MainView: AppNavItem("main_route")
object AboutView: AppNavItem("about_route")
object AccountView: AppNavItem("account_route")
object CastCrewListView: AppNavItem("cast_crew_list_route") {
fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id")
}
object DetailView: AppNavItem("detail_route") {
fun withArgs(type: MediaViewType, id: Int) = route.plus("/${type}/${id}")
}
object SettingsView: AppNavItem("settings_route")
object SearchView: AppNavItem("search_route") {
fun withArgs(searchType: MediaViewType, pageTitle: String) = route.plus("?searchType=$searchType&pageTitle=$pageTitle")
object GalleryView: AppNavItem("gallery_view_route") {
fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id")
}
object WebLinkView: AppNavItem("web_link_route") {
fun withArgs(url: String) = route.plus("/$url")
}
object AccountView: AppNavItem("account_route")
object AboutView: AppNavItem("about_route")
object KeywordsView: AppNavItem("keywords_route") {
fun withArgs(type: MediaViewType, keyword: String, id: Int) = route.plus("/$type?keyword=$keyword&keywordId=$id")
}
object KnownForView: AppNavItem("known_for_route") {
fun withArgs(id: Int) = route.plus("/$id")
}
object GalleryView: AppNavItem("gallery_view_route") {
fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id")
object MainView: AppNavItem("main_route")
object SearchView: AppNavItem("search_route") {
fun withArgs(searchType: MediaViewType, pageTitle: String) = route.plus("?searchType=$searchType&pageTitle=$pageTitle")
}
object SeasonListView: AppNavItem("season_list_route") {
fun withArgs(id: Int) = route.plus("/$id")
}
object CastCrewListView: AppNavItem("cast_crew_list_route") {
fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id")
object SettingsView: AppNavItem("settings_route")
object WebLinkView: AppNavItem("web_link_route") {
fun withArgs(url: String) = route.plus("/$url")
}
}

View File

@@ -102,6 +102,7 @@ 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.CastCrewCard
import com.owenlejeune.tvtime.ui.components.ChipDefaults
import com.owenlejeune.tvtime.ui.components.ChipGroup
import com.owenlejeune.tvtime.ui.components.ChipInfo
@@ -872,42 +873,6 @@ private fun CastCard(
}
}
@Composable
private fun CastCrewCard(
appNavController: NavController,
person: Person
) {
TwoLineImageTextCard(
title = person.name,
modifier = Modifier
.width(124.dp)
.wrapContentHeight(),
subtitle = when (person) {
is MovieCastMember -> person.character
is MovieCrewMember -> person.job
is TvCastMember -> {
val roles = person.roles.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
is TvCrewMember -> {
val roles = person.jobs.joinToString(separator = "/") { it.role }
val epsCount = person.totalEpisodeCount
"$roles ($epsCount Eps.)"
}
else -> null
},
imageUrl = TmdbUtils.getFullPersonImagePath(person),
titleTextColor = MaterialTheme.colorScheme.onPrimary,
subtitleTextColor = MaterialTheme.colorScheme.onSecondary,
onItemClicked = {
appNavController.navigate(
AppNavItem.DetailView.withArgs(MediaViewType.PERSON, person.id)
)
}
)
}
@Composable
private fun SeasonCard(
itemId: Int,

View File

@@ -0,0 +1,163 @@
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.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.api.tmdb.api.v3.model.Season
import com.owenlejeune.tvtime.extensions.toCompositeParts
import com.owenlejeune.tvtime.ui.components.BackButton
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.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.TmdbUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun fetchData(
mainViewModel: MainViewModel,
seasonNumber: Int,
seriesId: Int,
force: Boolean = false
) {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch { mainViewModel.getSeason(seriesId, seasonNumber, force) }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SeasonDetailsScreen(
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 seasonNumber = minOf(a, b)
val seriesId = maxOf(a, b)
LaunchedEffect(Unit) {
fetchData(mainViewModel, seasonNumber, seriesId)
}
val seasonsMap = remember { mainViewModel.tvSeasons }
val season = seasonsMap[seriesId]?.firstOrNull { it.seasonNumber == seasonNumber }
Scaffold(
topBar = {
TVTTopAppBar(
title = { },
appNavController = appNavController,
navigationIcon = { BackButton(navController = appNavController) }
)
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
SeasonContent(appNavController = appNavController, season = season)
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun SeasonContent(
appNavController: NavController,
season: Season?
) {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.verticalScroll(state = rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
DetailHeader(
posterUrl = TmdbUtils.getFullPosterPath(season?.posterPath),
elevation = 0.dp,
expandedPosterAsBackdrop = true
)
Column(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = season?.name ?: "",
color = MaterialTheme.colorScheme.secondary,
style = Typography.headlineLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
season?.episodes?.forEach { episode ->
SeasonEpisodeItem(appNavController = appNavController, episode = episode)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun SeasonEpisodeItem(
appNavController: NavController,
episode: Episode
) {
ContentCard {
EpisodeItem(episode = episode, elevation = 0.dp, maxDescriptionLines = 5)
episode.guestStars?.let { guestStars ->
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.guest_stars_label),
style = Typography.headlineSmall
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(8.dp))
guestStars.forEach {
CastCrewCard(appNavController = appNavController, person = it)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}

View File

@@ -6,27 +6,20 @@ import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -43,29 +36,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
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 coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Episode
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.Season
import com.owenlejeune.tvtime.extensions.combineWith
import com.owenlejeune.tvtime.ui.components.BackButton
import com.owenlejeune.tvtime.ui.components.EpisodeItem
import com.owenlejeune.tvtime.ui.components.TVTTopAppBar
import com.owenlejeune.tvtime.ui.navigation.AppNavItem
import com.owenlejeune.tvtime.ui.viewmodel.ApplicationViewModel
import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import com.owenlejeune.tvtime.utils.types.MediaViewType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -80,7 +65,7 @@ fun SeasonListScreen(
applicationViewModel.navigationBarColor.value = MaterialTheme.colorScheme.background
LaunchedEffect(Unit) {
val numSeasons = mainViewModel.detailedTv[id]!!.numberOfSeasons
val numSeasons = mainViewModel.detailedTv[id]?.numberOfSeasons ?: 0
for (i in 0..numSeasons) {
mainViewModel.getSeason(id, i, true)
}
@@ -113,7 +98,12 @@ fun SeasonListScreen(
val seasons = seasonsMap[id] ?: emptySet()
seasons.sortedBy { it.seasonNumber }.forEachIndexed { index, season ->
SeasonSection(season = season, expandedByDefault = index == 0)
SeasonSection(
appNavController = appNavController,
seriesId = id,
season = season,
expandedByDefault = index == 0
)
}
Spacer(modifier = Modifier.height(6.dp))
@@ -123,7 +113,12 @@ fun SeasonListScreen(
}
@Composable
private fun SeasonSection(season: Season, expandedByDefault: Boolean) {
private fun SeasonSection(
appNavController: NavController,
seriesId: Int,
season: Season,
expandedByDefault: Boolean
) {
var isExpanded by remember { mutableStateOf(expandedByDefault) }
Row(
@@ -135,7 +130,8 @@ private fun SeasonSection(season: Season, expandedByDefault: Boolean) {
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.clickable {
val combinedId = seriesId.combineWith(season.seasonNumber)
appNavController.navigate(AppNavItem.DetailView.withArgs(type = MediaViewType.SEASON, id = combinedId))
}
) {
Text(
@@ -182,78 +178,4 @@ private fun SeasonSection(season: Season, expandedByDefault: Boolean) {
}
}
}
}
@Composable
private fun EpisodeItem(episode: Episode) {
Card(
shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier
.fillMaxWidth()
.clickable {
}
) {
Box(
modifier = Modifier.height(112.dp)
) {
episode.stillPath?.let {
// Cloudy(
// modifier = Modifier.background(Color.Black.copy(alpha = 0.4f))
// ) {
val url = TmdbUtils.getFullEpisodeStillPath(it)
val model = ImageRequest.Builder(LocalContext.current)
.data(url)
.diskCacheKey(url ?: "")
.networkCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.blur(radius = 10.dp)
.fillMaxWidth()
.wrapContentHeight()
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.blur(radius = 5.dp)
)
// }
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(12.dp)
) {
val textColor = episode.stillPath?.let { Color.White } ?: if (isSystemInDarkTheme()) Color.White else Color.Black
Text(
text = "S${episode.seasonNumber}E${episode.episodeNumber}${episode.name}",
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
TmdbUtils.convertEpisodeDate(episode.airDate)?.let {
Text(
text = it,
fontStyle = FontStyle.Italic,
fontSize = 12.sp,
color = textColor
)
}
Text(
text = episode.overview,
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
}
}
}

View File

@@ -9,6 +9,7 @@ enum class MediaViewType {
TV,
@SerializedName("person")
PERSON,
SEASON,
EPISODE,
MIXED,
LIST;

View File

@@ -260,4 +260,6 @@
<string name="place_of_birth">Place of birth</string>
<string name="birthday">Birthday</string>
<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>
</resources>