mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
13
app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt
Normal file
13
app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user