implement series screen and refactor navigation to use ItemDto

- Implement `SeriesScreen`, `SeriesViewModel`, and associated UI components (`SeriesCard`, `SeriesHero`, etc.) for TV show details.
- Add `getSeasons` and `getEpisodesInSeason` methods to `JellyfinApiClient`.
- Refactor `Route` and navigation logic to use `ItemDto` (containing ID and `BaseItemKind`) instead of raw strings.
- Update `MovieScreen` and `EpisodeScreen` to accept `ItemDto` as a parameter.
- Enhance `PosterItem` and `ContinueWatchingItem` logic to handle parent IDs and correct image fetching for episodes and seasons.
- Add mock data for the series detail view.
- Remove redundant `MovieScreenNavigation.kt`.
This commit is contained in:
2026-01-18 19:13:29 +01:00
parent aa00748923
commit f7d64478fd
19 changed files with 887 additions and 55 deletions

View File

@@ -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()

View File

@@ -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")
}

View File

@@ -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()

View File

@@ -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")
}

View File

@@ -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<Route>.homeSection() {
entry<Route.Movie> {
MovieScreen(movieId = it.movieId)
}
}

View File

@@ -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,

View File

@@ -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
)
}
}
}

View File

@@ -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)

View File

@@ -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<SeriesSeasonUiModel>, 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<SeriesEpisodeUiModel>, 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<SeriesCastMemberUiModel>, 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)
)
}
}

View File

@@ -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<SeriesEpisodeUiModel>
)
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<SeriesSeasonUiModel>,
val cast: List<SeriesCastMemberUiModel>
)
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
)
)
)
}
}

View File

@@ -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
)
}

View File

@@ -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<SeriesUiModel?>(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<BaseItemDto>,
episodesItemResult: Map<UUID, List<BaseItemDto>>
): 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
)
}
}

View File

@@ -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<BaseItemDto> = jellyfinApiClient.getContinueWatching()
@@ -122,6 +131,7 @@ class HomePageViewModel @Inject constructor(
private fun loadLibraryItems(libraryId: UUID) {
viewModelScope.launch {
val libraryItems: List<BaseItemDto> = 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
}

View File

@@ -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,

View File

@@ -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

View File

@@ -160,6 +160,32 @@ class JellyfinApiClient @Inject constructor(
return result.content
}
suspend fun getSeasons(seriesId: UUID): List<BaseItemDto> {
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<BaseItemDto> {
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")

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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<Route>.appRouteEntryBuilder() {
@@ -10,9 +11,12 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
HomePage()
}
entry<Route.Movie> {
MovieScreen(movieId = it.movieId)
MovieScreen(movie = it.item)
}
entry<Route.Series> {
SeriesScreen(series = it.item)
}
entry<Route.Episode> {
EpisodeScreen(seriesId = it.seriesId)
EpisodeScreen(episode = it.item)
}
}