diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt index 7dd0b16..4703e43 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt @@ -21,17 +21,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import org.jellyfin.sdk.model.UUID +import hu.bbara.purefin.navigation.ItemDto +import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun EpisodeCard( - seriesId: String, + item: ItemDto, modifier: Modifier = Modifier, viewModel: EpisodeScreenViewModel = hiltViewModel() ) { - LaunchedEffect(seriesId) { - viewModel.selectNextUpEpisodeForSeries(UUID.fromString(seriesId)) + LaunchedEffect(item) { + when (item.type) { + BaseItemKind.EPISODE -> viewModel.selectEpisode(item.id) + else -> return@LaunchedEffect + } } val episodeItem = viewModel.episode.collectAsState() diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index ab94732..41b624c 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -2,21 +2,15 @@ package hu.bbara.purefin.app.content.episode import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import hu.bbara.purefin.navigation.ItemDto @Composable fun EpisodeScreen( - seriesId: String, + episode: ItemDto, modifier: Modifier = Modifier ) { EpisodeCard( - seriesId = seriesId, + item = episode, modifier = modifier ) } - -@Preview(showBackground = true) -@Composable -private fun EpisodeScreenPreview() { - EpisodeScreen(seriesId = "test") -} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt index 5534466..098713b 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt @@ -21,17 +21,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import org.jellyfin.sdk.model.UUID +import hu.bbara.purefin.navigation.ItemDto @Composable fun MovieCard( - movieId: String, + movie: ItemDto, modifier: Modifier = Modifier, viewModel: MovieScreenViewModel = hiltViewModel() ) { - LaunchedEffect(movieId) { - viewModel.selectMovie(UUID.fromString(movieId)) + LaunchedEffect(movie.id) { + viewModel.selectMovie(movie.id) } val movieItem = viewModel.movie.collectAsState() diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index 4f7cabf..394cc6f 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -2,21 +2,15 @@ package hu.bbara.purefin.app.content.movie import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import hu.bbara.purefin.navigation.ItemDto @Composable fun MovieScreen( - movieId: String, + movie: ItemDto, modifier: Modifier = Modifier ) { MovieCard( - movieId = movieId, + movie = movie, modifier = modifier ) } - -@Preview(showBackground = true) -@Composable -private fun MovieScreenPreview() { - MovieScreen(movieId = "test") -} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenNavigation.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenNavigation.kt deleted file mode 100644 index c62de29..0000000 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenNavigation.kt +++ /dev/null @@ -1,13 +0,0 @@ -package hu.bbara.purefin.app.content.movie - -import androidx.navigation3.runtime.EntryProviderScope -import hu.bbara.purefin.navigation.Route - -/** - * Navigation 3 entry definition for the Home section. - */ -fun EntryProviderScope.homeSection() { - entry { - MovieScreen(movieId = it.movieId) - } -} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt index b87c96c..4c73261 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt @@ -56,6 +56,7 @@ class MovieScreenViewModel @Inject constructor( val cast = people.orEmpty().map { it.toCastMember() } return MovieUiModel( id = id, + title = name ?: "Unknown title", year = year, rating = rating, diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesCard.kt new file mode 100644 index 0000000..6b157b9 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesCard.kt @@ -0,0 +1,130 @@ +package hu.bbara.purefin.app.content.series + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.navigation.ItemDto + +@Composable +fun SeriesCard( + series: ItemDto, + modifier: Modifier = Modifier, + viewModel: SeriesViewModel = hiltViewModel() +) { + LaunchedEffect(series.id) { + viewModel.selectSeries(series.id) + } + + val series = viewModel.series.collectAsState() + + if (series.value != null) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(SeriesBackgroundDark) + ) { + val heroHeight = maxHeight * 0.6f + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + SeriesHero( + imageUrl = series.value!!.heroImageUrl, + height = heroHeight + ) + Column( + modifier = Modifier + .fillMaxWidth() + .offset(y = (-96).dp) + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = series.value!!.title, + color = Color.White, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + lineHeight = 36.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + SeriesMetaChips(series = series.value!!) + Spacer(modifier = Modifier.height(24.dp)) + SeriesActionButtons() + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Synopsis", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = series.value!!.synopsis, + color = SeriesMutedStrong, + fontSize = 13.sp, + lineHeight = 20.sp + ) + Spacer(modifier = Modifier.height(28.dp)) + SeasonTabs(seasons = series.value!!.seasonTabs) + Spacer(modifier = Modifier.height(16.dp)) + } + EpisodeCarousel(episodes = series.value!!.seasonTabs.firstOrNull { it.isSelected }?.episodes.orEmpty()) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp, bottom = 32.dp) + ) { + Text( + text = "Cast", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 20.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + CastRow(cast = series.value!!.cast) + } + } + + SeriesTopBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + .align(Alignment.TopCenter) + ) + } + } else { + Box( + modifier = modifier + .fillMaxSize() + .background(SeriesBackgroundDark), + contentAlignment = Alignment.Center + ) { + Text( + text = "Loading...", + color = Color.White + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesColors.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesColors.kt new file mode 100644 index 0000000..77f209f --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesColors.kt @@ -0,0 +1,10 @@ +package hu.bbara.purefin.app.content.series + +import androidx.compose.ui.graphics.Color + +internal val SeriesPrimary = Color(0xFFDDA73C) +internal val SeriesBackgroundDark = Color(0xFF141414) +internal val SeriesSurfaceDark = Color(0xFF1F1F1F) +internal val SeriesSurfaceBorder = Color(0x1AFFFFFF) +internal val SeriesMuted = Color(0xB3FFFFFF) +internal val SeriesMutedStrong = Color(0x99FFFFFF) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt new file mode 100644 index 0000000..b2ef5ef --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -0,0 +1,430 @@ +package hu.bbara.purefin.app.content.series + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.PlayCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage + +@Composable +internal fun SeriesTopBar(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") + GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") + } + } +} + +@Composable +private fun GhostIconButton( + icon: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(SeriesBackgroundDark.copy(alpha = 0.4f)) + .clickable { }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White + ) + } +} + +@Composable +internal fun SeriesHero( + imageUrl: String, + height: Dp, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(height) + .background(SeriesBackgroundDark) + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + SeriesBackgroundDark.copy(alpha = 0.4f), + SeriesBackgroundDark + ) + ) + ) + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + PlayButton(size = 80.dp) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun SeriesMetaChips(series: SeriesUiModel) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MetaChip(text = series.year) + MetaChip(text = series.rating) + MetaChip(text = series.seasons) + MetaChip( + text = series.format, + background = SeriesPrimary.copy(alpha = 0.2f), + border = SeriesPrimary.copy(alpha = 0.3f), + textColor = SeriesPrimary + ) + } +} + +@Composable +private fun MetaChip( + text: String, + background: Color = Color.White.copy(alpha = 0.1f), + border: Color = Color.White.copy(alpha = 0.05f), + textColor: Color = Color.White +) { + Box( + modifier = Modifier + .height(28.dp) + .wrapContentHeight(Alignment.CenterVertically) + .clip(RoundedCornerShape(6.dp)) + .background(background) + .border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp)) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = textColor, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +internal fun SeriesActionButtons(modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ActionButton( + text = "Watchlist", + icon = Icons.Outlined.Add, + modifier = Modifier.weight(1f) + ) + ActionButton( + text = "Download", + icon = Icons.Outlined.Download, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ActionButton( + text: String, + icon: ImageVector, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .height(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.1f)) + .border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(12.dp)) + .clickable { }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(imageVector = icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +internal fun SeasonTabs(seasons: List, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + seasons.forEach { season -> + SeasonTab(name = season.name, isSelected = season.isSelected) + } + } +} + +@Composable +private fun SeasonTab(name: String, isSelected: Boolean) { + val color = if (isSelected) SeriesPrimary else SeriesMutedStrong + val borderColor = if (isSelected) SeriesPrimary else Color.Transparent + Column( + modifier = Modifier + .padding(bottom = 8.dp) + .clickable { } + ) { + Text( + text = name, + color = color, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .height(2.dp) + .width(52.dp) + .background(borderColor) + ) + } +} + +@Composable +internal fun EpisodeCarousel(episodes: List, modifier: Modifier = Modifier) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(episodes) { episode -> + EpisodeCard(episode = episode) + } + } +} + +@Composable +private fun EpisodeCard(episode: SeriesEpisodeUiModel) { + Column( + modifier = Modifier + .width(260.dp) + .clip(RoundedCornerShape(16.dp)) + .background(SeriesSurfaceDark.copy(alpha = 0.3f)) + .border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(16.dp)) + .padding(12.dp) + .clickable { }, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)) + .background(SeriesSurfaceDark) + .border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(12.dp)) + ) { + AsyncImage( + model = episode.imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.2f)) + ) + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = null, + tint = Color.White, + modifier = Modifier + .align(Alignment.Center) + .size(32.dp) + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp) + .background(Color.Black.copy(alpha = 0.8f), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = episode.duration, + color = Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = episode.title, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = episode.description, + color = SeriesMutedStrong, + fontSize = 11.sp, + lineHeight = 16.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +internal fun CastRow(cast: List, modifier: Modifier = Modifier) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(cast) { member -> + CastCard(member = member) + } + } +} + +@Composable +private fun CastCard(member: SeriesCastMemberUiModel) { + Column( + modifier = Modifier.width(84.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .aspectRatio(4f / 5f) + .clip(RoundedCornerShape(12.dp)) + .background(SeriesSurfaceDark) + .border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(12.dp)) + ) { + if (member.imageUrl == null) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White.copy(alpha = 0.05f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = null, + tint = SeriesMutedStrong + ) + } + } else { + AsyncImage( + model = member.imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + Text( + text = member.name, + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = member.role, + color = SeriesMutedStrong, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun PlayButton(size: Dp, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(size) + .shadow(24.dp, CircleShape) + .clip(CircleShape) + .background(SeriesPrimary) + .clickable { }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play", + tint = SeriesBackgroundDark, + modifier = Modifier.size(36.dp) + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesModels.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesModels.kt new file mode 100644 index 0000000..ba59196 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesModels.kt @@ -0,0 +1,114 @@ +package hu.bbara.purefin.app.content.series + +data class SeriesEpisodeUiModel( + val title: String, + val description: String, + val duration: String, + val imageUrl: String +) + +data class SeriesSeasonUiModel( + val name: String, + val isSelected: Boolean, + val episodes: List +) + +data class SeriesCastMemberUiModel( + val name: String, + val role: String, + val imageUrl: String? +) + +data class SeriesUiModel( + val title: String, + val year: String, + val rating: String, + val seasons: String, + val format: String, + val synopsis: String, + val heroImageUrl: String, + val seasonTabs: List, + val cast: List +) + +internal object SeriesMockData { + fun series(): SeriesUiModel { + val heroUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuD3hBjDpw00tDCQsK5xNcnJra301k1T4LksWVZzHieH9KHQItEQkVzhwevJvf8RkaQKdVKvObzRlfDDqa3_PNwLUlUQc1LpDih8p94VTGobEV62qi7QrmNyQm_o55KRMNWiTG3zLLpblGqo3uUNQcYmPFqfNML95dClXQ4lQNl85-zgerPPAbGPr23dswbIYCigyTAaXgrmdV_nbNQ5LdDB0Wh5cMHtP0uxz6k3ARjNom6clhphGIUF9e6YSvKuwuiZ-1lMYFg8C_4" + val episode1 = SeriesEpisodeUiModel( + title = "E1: The Beginning", + description = "The crew assembles for the first time as the anomaly begins to expand rapidly near Saturn's rings.", + duration = "58m", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuC6OPszCXCIP_FMO3BJJUrjpCtDNw9aeHYOGyOAXdqF078hDFNrH7KXbaQ7qtipz6aIPLivd8VBBffNMbeAiYIjjWjn5GMb6Xn9iiJz0D2rzhCKi0TBeFrN6tC1IXJkzQyQKJNhTnyokWy9dd-YtN65V7er7RT6hP5jdVBXhtK1xZMjlgrm1bk_FTTmKd8Afu3zPtJCaaC98Z608vav5zhYlkrdA1wKNSTWTpzwMSyDIY3pNQNPFauWf0n-iEu7QsYTAwhCG_zfxz0" + ) + val episode2 = SeriesEpisodeUiModel( + title = "E2: Event Horizon", + description = "Dr. Cole discovers a frequency embedded in the rift's radiation that suggests intelligent design.", + duration = "54m", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuBExsf-wEzAVjMxasU2ImGhlreqQo9biBSN1yHyAbW8MyuhuppRw9ho7OD3vsbySSJ3kNluEgH1Qun45PmLnZWixZsFU4Qc7UGGJNKMS5Nkm4GZAsKdFvb3z_i1tkCvaXXvGpqmwI0qjFuo1QyjjhYPA5Yp3I8ZhrnDYdQv_GxbhR6Vl3mY1rbxd2BIUEE5oMTwTF-QmJztUEaViZkSGSG2VgVXZ5VAREn4xWE902OH2sysllvXQJQIaj439JIC2_Vg61m0-F-F1Vc" + ) + val episode3 = SeriesEpisodeUiModel( + title = "E3: Singularity", + description = "Tension rises as the ship approaches the event horizon, and the AI begins to behave erratically.", + duration = "1h 02m", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuA5CFDWsWYO4YxdRoLd2QfH5Su2KLhtj5xSDb8qmzWHvPE888ac_HAAj1wu1uqdFNSncdmmJ-bWsc--h6NYKxVXkhd4vHaFWi0XTJXgsR0F3cBu_l2SynSX4TMNSy5C3XWDurgeSH789byOe1HvoxHCHTJYaSf3OyEbil-NOp9g_9mZ24CIZOI79nx57CRzmooxoswycqssPpfTNkrnoYrrAczt5qbncwLM9NVU442YxyBFisr2Ds9H-CNBOakiCtaKnoJ6npznM7U" + ) + return SeriesUiModel( + title = "Interstellar Horizon: The Series", + year = "2024", + rating = "TV-MA", + seasons = "3 Seasons", + format = "4K HDR", + synopsis = "When a mysterious cosmic rift appears near Saturn, a team of seasoned astronauts and theoretical physicists must embark on a high-stakes voyage across dimensions. They seek to unlock the secrets of time-dilated anomalies that threaten the very fabric of human existence on Earth.", + heroImageUrl = heroUrl, + seasonTabs = listOf( + SeriesSeasonUiModel( + name = "Season 1", + isSelected = true, + episodes = listOf(episode1, episode2, episode3) + ), + SeriesSeasonUiModel( + name = "Season 2", + isSelected = false, + episodes = listOf(episode1, episode2, episode3) + ), + SeriesSeasonUiModel( + name = "Season 3", + isSelected = false, + episodes = listOf(episode1, episode2, episode3) + ) + ), + cast = listOf( + SeriesCastMemberUiModel( + name = "Marcus Thorne", + role = "Cmdr. Vance", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuC6OPszCXCIP_FMO3BJJUrjpCtDNw9aeHYOGyOAXdqF078hDFNrH7KXbaQ7qtipz6aIPLivd8VBBffNMbeAiYIjjWjn5GMb6Xn9iiJz0D2rzhCKi0TBeFrN6tC1IXJkzQyQKJNhTnyokWy9dd-YtN65V7er7RT6hP5jdVBXhtK1xZMjlgrm1bk_FTTmKd8Afu3zPtJCaaC98Z608vav5zhYlkrdA1wKNSTWTpzwMSyDIY3pNQNPFauWf0n-iEu7QsYTAwhCG_zfxz0" + ), + SeriesCastMemberUiModel( + name = "Elena Rossi", + role = "Dr. Sarah Cole", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuBExsf-wEzAVjMxasU2ImGhlreqQo9biBSN1yHyAbW8MyuhuppRw9ho7OD3vsbySSJ3kNluEgH1Qun45PmLnZWixZsFU4Qc7UGGJNKMS5Nkm4GZAsKdFvb3z_i1tkCvaXXvGpqmwI0qjFuo1QyjjhYPA5Yp3I8ZhrnDYdQv_GxbhR6Vl3mY1rbxd2BIUEE5oMTwTF-QmJztUEaViZkSGSG2VgVXZ5VAREn4xWE902OH2sysllvXQJQIaj439JIC2_Vg61m0-F-F1Vc" + ), + SeriesCastMemberUiModel( + name = "Julian Chen", + role = "Tech Officer Lin", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuA5CFDWsWYO4YxdRoLd2QfH5Su2KLhtj5xSDb8qmzWHvPE888ac_HAAj1wu1uqdFNSncdmmJ-bWsc--h6NYKxVXkhd4vHaFWi0XTJXgsR0F3cBu_l2SynSX4TMNSy5C3XWDurgeSH789byOe1HvoxHCHTJYaSf3OyEbil-NOp9g_9mZ24CIZOI79nx57CRzmooxoswycqssPpfTNkrnoYrrAczt5qbncwLM9NVU442YxyBFisr2Ds9H-CNBOakiCtaKnoJ6npznM7U" + ), + SeriesCastMemberUiModel( + name = "Sarah Jenkins", + role = "Mission Pilot", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuBN6_72VggBdNx7ITLvEvIA6OSre5iJI6kQiUVMpKAlYgd8TpT-Jx6DzZwGsGACLnAXOUuzT2R7mx9A9DNZcqi5BF_jSaEdeYpfcBvJttmVPAwiCiq1_PI2BwoZZH_Ccmq2AHV5lQqcYaA2rPkf4e7YLLLgpmVbGjKhncTotQtxiZvmLNzCbLUdlEb7XLgHKfjS6FU6djV9ocOo9bxZ_YtrQj-mMFvYGzCxeFYC8OF0kIV2NN3kQYH8x1X-rYMqu2-d7klJfQdhKHw" + ), + SeriesCastMemberUiModel( + name = "David Wu", + role = "The AI (Voice)", + imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuCnNkjaBc2hU2zJ5hAF8iZZ_ZZvMlU79o4JtPNCP2MEfttpF0fe_BHWsMMl6h3S37FJ1dTLk8AQuvRQ_ggy1u-71xlQWULB76rT8pdZiRE7TkInQ8gwpigs84KNWbTRxVUI7Nia9RPyJeFE7egZqnT46TQWUeN8llWF9EDQ6mpfVLH0vHhKUlko39iDgMnBIequYntugSFgWJQc1jH-AxZ4OpJr_-uZGkwtQ_CVYNV69u9y107gk5BwaUFwPeipe8Bn9I655kyHIuQ" + ), + SeriesCastMemberUiModel( + name = "Alex Reed", + role = "Engineer", + imageUrl = null + ) + ) + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt new file mode 100644 index 0000000..dc9b2db --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -0,0 +1,16 @@ +package hu.bbara.purefin.app.content.series + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import hu.bbara.purefin.navigation.ItemDto + +@Composable +fun SeriesScreen( + series: ItemDto, + modifier: Modifier = Modifier +) { + SeriesCard( + series = series, + modifier = modifier + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt new file mode 100644 index 0000000..4458fb9 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt @@ -0,0 +1,92 @@ +package hu.bbara.purefin.app.content.series + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.image.JellyfinImageHelper +import hu.bbara.purefin.session.UserSessionRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemPerson +import org.jellyfin.sdk.model.api.ImageType +import javax.inject.Inject + +@HiltViewModel +class SeriesViewModel @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient, + private val userSessionRepository: UserSessionRepository +) : ViewModel() { + + private val _series = MutableStateFlow(null) + val series = _series.asStateFlow() + + fun selectSeries(seriesId: UUID) { + viewModelScope.launch { + val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank { + "https://jellyfin.bbara.hu" + } + val seriesItemResult = jellyfinApiClient.getItemInfo(mediaId = seriesId) + val seasonsItemResult = jellyfinApiClient.getSeasons(seriesId) + val episodesItemResult = seasonsItemResult.associate { season -> + season.id to jellyfinApiClient.getEpisodesInSeason(seriesId, season.id) + } + _series.value = mapToSeriesUiModel(serverUrl, seriesItemResult, seasonsItemResult, episodesItemResult) + } + } + + private fun mapToSeriesUiModel( + serverUrl: String, + seriesItemResult: BaseItemDto?, + seasonsItemResult: List, + episodesItemResult: Map> + ): SeriesUiModel { + val seasonUiModels = seasonsItemResult.map { season -> + val episodeItemResult = episodesItemResult[season.id] ?: emptyList() + val episodeItemUiModels = episodeItemResult.map { episode -> + SeriesEpisodeUiModel( + title = episode.name ?: "Unknown", + description = episode.overview ?: "", + duration = "58m", + imageUrl = "" + ) + } + SeriesSeasonUiModel( + name = season.name ?: "Unknown", + episodes = episodeItemUiModels, + isSelected = false, + ) + } + val heroImageUrl = seriesItemResult?.let { series -> + JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = series.id, + type = ImageType.BACKDROP + ) + } ?: "" + return SeriesUiModel( + title = seriesItemResult?.name ?: "Unknown", + format = seriesItemResult?.container ?: "VIDEO", + rating = seriesItemResult?.officialRating ?: "NR", + year = seriesItemResult!!.productionYear?.toString() ?: seriesItemResult!!.premiereDate?.year?.toString().orEmpty(), + seasons = "3 Seasons", + synopsis = seriesItemResult.overview ?: "No synopsis available.", + heroImageUrl = "", + seasonTabs = seasonUiModels, + cast = seriesItemResult.people.orEmpty().map { it.toCastMember() } + ) + } + + private fun BaseItemPerson.toCastMember(): SeriesCastMemberUiModel { + return SeriesCastMemberUiModel( + name = name ?: "Unknown", + role = role ?: "", + imageUrl = null + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index a42f3a3..2f4627b 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -8,6 +8,7 @@ import hu.bbara.purefin.app.home.ui.ContinueWatchingItem import hu.bbara.purefin.app.home.ui.LibraryItem import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.navigation.ItemDto import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.session.UserSessionRepository @@ -46,22 +47,30 @@ class HomePageViewModel @Inject constructor( } fun onMovieSelected(movieId: String) { - navigationManager.navigate(Route.Movie(movieId)) + navigationManager.navigate(Route.Movie(ItemDto(UUID.fromString(movieId), BaseItemKind.MOVIE))) } fun onSeriesSelected(seriesId: String) { - navigationManager.navigate(Route.Episode(seriesId)) + viewModelScope.launch { + navigationManager.navigate(Route.Series(ItemDto(UUID.fromString(seriesId), BaseItemKind.SERIES))) + } + } + + fun onSelectEpisode(episodeId: String) { + viewModelScope.launch { + navigationManager.navigate(Route.Episode(ItemDto(UUID.fromString(episodeId), BaseItemKind.EPISODE))) + } } fun onBack() { navigationManager.pop() } + fun onGoHome() { navigationManager.replaceAll(Route.Home) } - fun loadContinueWatching() { viewModelScope.launch { val continueWatching: List = jellyfinApiClient.getContinueWatching() @@ -122,6 +131,7 @@ class HomePageViewModel @Inject constructor( private fun loadLibraryItems(libraryId: UUID) { viewModelScope.launch { val libraryItems: List = jellyfinApiClient.getLibrary(libraryId) + // It return only Movie or Series val libraryPosterItems = libraryItems.map { PosterItem( id = it.id, @@ -158,15 +168,16 @@ class HomePageViewModel @Inject constructor( type = BaseItemKind.MOVIE ) BaseItemKind.EPISODE -> PosterItem( - id = it.seriesId!!, + id = it.id, title = it.seriesName ?: "Unknown", - type = BaseItemKind.SERIES + type = BaseItemKind.EPISODE, + parentId = it.seriesId!! ) - BaseItemKind.SEASON -> PosterItem( id = it.seriesId!!, title = it.seriesName ?: "Unknown", - type = BaseItemKind.SERIES + type = BaseItemKind.SERIES, + parentId = it.seriesId ) else -> null } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt index bd06d86..b6bbd0c 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt @@ -23,8 +23,11 @@ data class LibraryItem( data class PosterItem( val id: UUID, val title: String, - val type: BaseItemKind -) + val type: BaseItemKind, + val parentId: UUID? = null +) { + val imageItemId: UUID get() = parentId ?: id +} data class HomeNavItem( val label: String, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt index 98d8344..41f48a4 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt @@ -85,7 +85,7 @@ fun ContinueWatchingCard( fun openItem(item: ContinueWatchingItem) { when (item.type) { BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.id.toString()) - BaseItemKind.EPISODE -> viewModel.onSeriesSelected(item.id.toString()) + BaseItemKind.EPISODE -> viewModel.onSelectEpisode(item.id.toString()) else -> {} } } @@ -207,9 +207,9 @@ fun PosterCard( when (posterItem.type) { BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString()) BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString()) + BaseItemKind.EPISODE -> viewModel.onSelectEpisode(posterItem.id.toString()) else -> {} } - } Box( @@ -222,7 +222,7 @@ fun PosterCard( .clickable(onClick = { openItem(item) }) ) { AsyncImage( - model = JellyfinImageHelper.toImageUrl(url = "https://jellyfin.bbara.hu", itemId = item.id, type = ImageType.PRIMARY), + model = JellyfinImageHelper.toImageUrl(url = "https://jellyfin.bbara.hu", itemId = item.imageItemId, type = ImageType.PRIMARY), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index 8c96652..7ed710a 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -160,6 +160,32 @@ class JellyfinApiClient @Inject constructor( return result.content } + suspend fun getSeasons(seriesId: UUID): List { + if (!ensureConfigured()) { + return emptyList() + } + val result = api.tvShowsApi.getSeasons( + userId = getUserId(), + seriesId = seriesId, + ) + Log.d("getSeasons response: {}", result.content.toString()) + return result.content.items + } + + suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List { + if (!ensureConfigured()) { + return emptyList() + } + val result = api.tvShowsApi.getEpisodes( + userId = getUserId(), + seriesId = seriesId, + seasonId = seasonId, + enableUserData = true + ) + Log.d("getEpisodesInSeason response: {}", result.content.toString()) + return result.content.items + } + suspend fun getNextUpEpisode(mediaId: UUID): BaseItemDto { if (!ensureConfigured()) { throw IllegalStateException("Not configured") diff --git a/app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt b/app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt new file mode 100644 index 0000000..289c80f --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt @@ -0,0 +1,13 @@ +package hu.bbara.purefin.navigation + +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.UUIDSerializer + +@Serializable +data class ItemDto ( + @Serializable(with = UUIDSerializer::class) + val id: UUID, + val type : BaseItemKind +) \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/navigation/Route.kt b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt index fa30cf4..876cc5b 100644 --- a/app/src/main/java/hu/bbara/purefin/navigation/Route.kt +++ b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt @@ -8,8 +8,11 @@ sealed interface Route : NavKey { data object Home: Route @Serializable - data class Movie(val movieId: String) : Route + data class Movie(val item : ItemDto) : Route @Serializable - data class Episode(val seriesId: String) : Route + data class Series(val item : ItemDto) : Route + + @Serializable + data class Episode(val item : ItemDto) : Route } diff --git a/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt b/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt index 235cc0c..0578323 100644 --- a/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt +++ b/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt @@ -3,6 +3,7 @@ package hu.bbara.purefin.navigation import androidx.navigation3.runtime.EntryProviderScope import hu.bbara.purefin.app.content.episode.EpisodeScreen import hu.bbara.purefin.app.content.movie.MovieScreen +import hu.bbara.purefin.app.content.series.SeriesScreen import hu.bbara.purefin.app.home.HomePage fun EntryProviderScope.appRouteEntryBuilder() { @@ -10,9 +11,12 @@ fun EntryProviderScope.appRouteEntryBuilder() { HomePage() } entry { - MovieScreen(movieId = it.movieId) + MovieScreen(movie = it.item) + } + entry { + SeriesScreen(series = it.item) } entry { - EpisodeScreen(seriesId = it.seriesId) + EpisodeScreen(episode = it.item) } }