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 3a75e6e..116606e 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 @@ -56,9 +56,6 @@ 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 - @GET("tv/{id}/watch/providers") suspend fun getWatchProviders(@Path("id") seriesId: Int): Response @@ -73,4 +70,7 @@ interface TvApi { @GET("trending/tv/{time_window}") suspend fun trending(@Path("time_window") timeWindow: String, @Query("page") page: Int): 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/extensions/AnyExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt index b24fe7c..fd9c166 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/AnyExtensions.kt @@ -10,4 +10,6 @@ fun Any.coroutineTask(runnable: suspend () -> Unit) { fun anyOf(vararg items: T, predicate: (T) -> Boolean): Boolean = items.any(predicate) -fun T.isIn(vararg items: T): Boolean = items.any { it == this } \ No newline at end of file +fun T.isIn(vararg items: T): Boolean = items.any { it == this } + +fun pairOf(a: T, b: T) = Pair(a, b) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/NumberExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/NumberExtensions.kt index 977abf5..69c22eb 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/NumberExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/NumberExtensions.kt @@ -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 { + var a = 0 + var result = this + do { + result /= 2 + a++ + } while (result % 2 == 0) + return pairOf(a, result/2) +} diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt index 8fcc501..ddc7239 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/DetailViewCommon.kt @@ -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) + ) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt index 38e6d31..7f6c22f 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/navigation/AppNavigation.kt @@ -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") } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt index 16f557b..fffe63b 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/MediaDetailScreen.kt @@ -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, diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonDetailsView.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonDetailsView.kt new file mode 100644 index 0000000..75af873 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonDetailsView.kt @@ -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() + val applicationViewModel = viewModel() + + 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)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt index a883616..82b709d 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/SeasonListScreen.kt @@ -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 - ) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/utils/types/MediaViewType.kt b/app/src/main/java/com/owenlejeune/tvtime/utils/types/MediaViewType.kt index 9659c43..990265c 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/utils/types/MediaViewType.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/utils/types/MediaViewType.kt @@ -9,6 +9,7 @@ enum class MediaViewType { TV, @SerializedName("person") PERSON, + SEASON, EPISODE, MIXED, LIST; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4ecdfd..14820c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -260,4 +260,6 @@ Place of birth Birthday Date of death + An unexpected error occurred + Guest stars \ No newline at end of file