diff --git a/app/build.gradle b/app/build.gradle index 525d370..7a4b317 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,6 +143,9 @@ dependencies { def compose_markdown = "0.3.3" implementation "com.github.jeziellago:compose-markdown:$compose_markdown" + def cloudy = "0.1.2" + implementation "com.github.skydoves:cloudy:$cloudy" + // testing def junit = "4.13.2" def androidx_junit = "1.1.3" 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 index 515fb8c..f974ff2 100644 --- 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 @@ -1,18 +1,21 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3.model import com.google.gson.annotations.SerializedName +import java.util.Date 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? + @SerializedName("air_date") val airDate: Date?, + @SerializedName("episode_number") val episodeNumber: Int, + @SerializedName("id") val id: Int, + @SerializedName("name") val name: String, + @SerializedName("overview") val overview: String, + @SerializedName("production_code") val productionCode: String, + @SerializedName("runtime") val runtime: Int, + @SerializedName("season_number") val seasonNumber: Int, + @SerializedName("show_id") val showId: Int, + @SerializedName("still_path") val stillPath: String?, + @SerializedName("vote_average") val voteAverage: Float, + @SerializedName("vote_count") val voteCount: Int, + @SerializedName("crew") val crew: List?, + @SerializedName("guest_starts") val guestStars: List? ) \ 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 index 8447bf3..ebc7477 100644 --- 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 @@ -1,10 +1,15 @@ package com.owenlejeune.tvtime.api.tmdb.api.v3.model import com.google.gson.annotations.SerializedName +import java.util.Date data class Season( - @SerializedName("name") - val name: String, - @SerializedName("episodes") - val episodes: List + @SerializedName("_id") val _id: String, + @SerializedName("air_date") val airDate: Date?, + @SerializedName("episodes") val episodes: List, + @SerializedName("name") val name: String, + @SerializedName("overview") val overview: String, + @SerializedName("id") val id: Int, + @SerializedName("poster_path") val posterPath: String?, + @SerializedName("season_number") val seasonNumber: Int ) \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt index ae87372..8709326 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/ListExtensions.kt @@ -18,4 +18,8 @@ fun List.bringToFront(predicate: (T) -> Boolean): List { } } return frontItems.plus(orig) +} + +fun List.subListLimit(limit: Int, fromIndex: Int = 0): List { + return subList(fromIndex = fromIndex, toIndex = minOf(size, fromIndex+limit)) } \ 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 44ff381..ed1e0ee 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 @@ -28,6 +28,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.SeasonListScreen import com.owenlejeune.tvtime.ui.screens.SettingsScreen import com.owenlejeune.tvtime.ui.screens.WebLinkScreen import com.owenlejeune.tvtime.utils.NavConstants @@ -200,6 +201,16 @@ fun AppNavigationHost( 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)!! + + SeasonListScreen(id = id, appNavController = appNavController) + } } } @@ -233,5 +244,8 @@ sealed class AppNavItem(val route: String) { object GalleryView: AppNavItem("gallery_view_route") { fun withArgs(type: MediaViewType, id: Int) = route.plus("/$type/$id") } + object SeasonListView: AppNavItem("season_list_route") { + fun withArgs(id: Int) = route.plus("/$id") + } } 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 2b42a42..f3d56c5 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 @@ -4,8 +4,6 @@ import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.compose.animation.* -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow @@ -14,16 +12,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Send -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.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.stringResource import androidx.compose.ui.text.font.FontStyle @@ -38,13 +33,13 @@ import androidx.paging.compose.collectAsLazyPagingItems import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow 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 import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.api.tmdb.api.v3.model.* import com.owenlejeune.tvtime.extensions.WindowSizeClass +import com.owenlejeune.tvtime.extensions.getCalendarYear import com.owenlejeune.tvtime.extensions.lazyPagingItems import com.owenlejeune.tvtime.extensions.listItems import com.owenlejeune.tvtime.ui.components.* @@ -53,7 +48,6 @@ import com.owenlejeune.tvtime.ui.viewmodel.MainViewModel import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.types.MediaViewType -import com.owenlejeune.tvtime.utils.types.TabNavItem import kotlinx.coroutines.* @OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class) @@ -204,24 +198,55 @@ private fun MediaViewContent( ActionsView(itemId = itemId, type = type, modifier = Modifier.padding(start = 20.dp)) } - if (type == MediaViewType.MOVIE) { - MainContentMovie( + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + OverviewCard( itemId = itemId, mediaItem = mediaItem, type = type, - appNavController = appNavController, - windowSize = windowSize, - mainViewModel = mainViewModel + mainViewModel = mainViewModel, + appNavController = appNavController ) - } else { - MainContentTv( + + CastCard( itemId = itemId, - mediaItem = mediaItem, - type = type, appNavController = appNavController, - windowSize = windowSize, + type = type, mainViewModel = mainViewModel ) + + if (type == MediaViewType.TV) { + SeasonCard( + itemId = itemId, + mediaItem = mediaItem, + mainViewModel = mainViewModel, + appNavController = appNavController + ) + } + + SimilarContentCard( + itemId = itemId, + mediaType = type, + appNavController = appNavController, + mainViewModel = mainViewModel + ) + + VideosCard( + itemId = itemId, + modifier = Modifier.fillMaxWidth(), + mainViewModel = mainViewModel, + type = type + ) + + AdditionalDetailsCard(mediaItem = mediaItem, type = type) + + WatchProvidersCard(itemId = itemId, type = type, mainViewModel = mainViewModel) + + if (windowSize != WindowSizeClass.Expanded) { + ReviewsCard(itemId = itemId, type = type, mainViewModel = mainViewModel) + } } } @@ -243,165 +268,6 @@ 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: DetailedItem?, - mainViewModel: MainViewModel -) { - LaunchedEffect(mediaItem) { - val numSeasons = (mediaItem as DetailedTv?)?.numberOfSeasons ?: 0 - for (i in 0..numSeasons) { - mainViewModel.getSeason(itemId, i) - } - } - - val seasonsMap = remember { mainViewModel.tvSeasons } - val seasons = seasonsMap[itemId] - - seasons?.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: DetailedItem?, - type: MediaViewType, - appNavController: NavController, - windowSize: WindowSizeClass, - mainViewModel: MainViewModel -) { - OverviewCard(itemId = itemId, mediaItem = mediaItem, type = type, mainViewModel = mainViewModel, appNavController = appNavController) - - CastCard(itemId = itemId, appNavController = appNavController, type = type, mainViewModel = mainViewModel) - - SimilarContentCard(itemId = itemId, mediaType = type, appNavController = appNavController, mainViewModel = mainViewModel) - - VideosCard(itemId = itemId, modifier = Modifier.fillMaxWidth(), mainViewModel = mainViewModel, type = type) - - AdditionalDetailsCard(mediaItem = mediaItem, type = type) - - WatchProvidersCard(itemId = itemId, type = type, mainViewModel = mainViewModel) - - if (windowSize != WindowSizeClass.Expanded) { - ReviewsCard(itemId = itemId, type = type, mainViewModel = mainViewModel) - } -} - @Composable private fun MiscTvDetails( itemId: Int, @@ -778,6 +644,77 @@ private fun CastCrewCard(appNavController: NavController, person: Person) { ) } +@Composable +private fun SeasonCard( + itemId: Int, + mediaItem: DetailedItem?, + mainViewModel: MainViewModel, + appNavController: NavController +) { + LaunchedEffect(mediaItem) { + val lastSeason = (mediaItem as DetailedTv?)?.numberOfSeasons ?: 0 + if (lastSeason > 0) { + mainViewModel.getSeason(itemId, lastSeason) + } + } + + val seasonsMap = remember { mainViewModel.tvSeasons } + val lastSeason = seasonsMap[itemId]?.lastOrNull() + + lastSeason?.let { + ContentCard( + title = "Latest Season" + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(all = 12.dp) + ) { + PosterItem( + url = TmdbUtils.getFullPosterPath(it.posterPath), + title = it.name, + overrideShowTitle = false, + enabled = false + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = it.name, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "${it.airDate?.getCalendarYear()} | ${it.episodes.size} Episodes" + ) + Text( + text = it.overview, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis + ) + } + } + + Box( + modifier = Modifier + .padding(start = 12.dp, bottom = 12.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable { + appNavController.navigate(AppNavItem.SeasonListView.withArgs(id = itemId)) + } + ) { + Text( + text = "See all seasons", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + modifier = Modifier.padding(4.dp) + ) + } + } + } +} + @Composable fun SimilarContentCard( itemId: Int, @@ -914,7 +851,6 @@ private fun VideoGroup(results: List