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.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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
|
@Composable
|
||||||
fun EpisodeCard(
|
fun EpisodeCard(
|
||||||
seriesId: String,
|
item: ItemDto,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: EpisodeScreenViewModel = hiltViewModel()
|
viewModel: EpisodeScreenViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
LaunchedEffect(seriesId) {
|
LaunchedEffect(item) {
|
||||||
viewModel.selectNextUpEpisodeForSeries(UUID.fromString(seriesId))
|
when (item.type) {
|
||||||
|
BaseItemKind.EPISODE -> viewModel.selectEpisode(item.id)
|
||||||
|
else -> return@LaunchedEffect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val episodeItem = viewModel.episode.collectAsState()
|
val episodeItem = viewModel.episode.collectAsState()
|
||||||
|
|||||||
@@ -2,21 +2,15 @@ package hu.bbara.purefin.app.content.episode
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import hu.bbara.purefin.navigation.ItemDto
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EpisodeScreen(
|
fun EpisodeScreen(
|
||||||
seriesId: String,
|
episode: ItemDto,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
EpisodeCard(
|
EpisodeCard(
|
||||||
seriesId = seriesId,
|
item = episode,
|
||||||
modifier = modifier
|
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.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import org.jellyfin.sdk.model.UUID
|
import hu.bbara.purefin.navigation.ItemDto
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieCard(
|
fun MovieCard(
|
||||||
movieId: String,
|
movie: ItemDto,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MovieScreenViewModel = hiltViewModel()
|
viewModel: MovieScreenViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
LaunchedEffect(movieId) {
|
LaunchedEffect(movie.id) {
|
||||||
viewModel.selectMovie(UUID.fromString(movieId))
|
viewModel.selectMovie(movie.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
val movieItem = viewModel.movie.collectAsState()
|
val movieItem = viewModel.movie.collectAsState()
|
||||||
|
|||||||
@@ -2,21 +2,15 @@ package hu.bbara.purefin.app.content.movie
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import hu.bbara.purefin.navigation.ItemDto
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieScreen(
|
fun MovieScreen(
|
||||||
movieId: String,
|
movie: ItemDto,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
MovieCard(
|
MovieCard(
|
||||||
movieId = movieId,
|
movie = movie,
|
||||||
modifier = modifier
|
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() }
|
val cast = people.orEmpty().map { it.toCastMember() }
|
||||||
return MovieUiModel(
|
return MovieUiModel(
|
||||||
id = id,
|
id = id,
|
||||||
|
|
||||||
title = name ?: "Unknown title",
|
title = name ?: "Unknown title",
|
||||||
year = year,
|
year = year,
|
||||||
rating = rating,
|
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.LibraryItem
|
||||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||||
import hu.bbara.purefin.client.JellyfinApiClient
|
import hu.bbara.purefin.client.JellyfinApiClient
|
||||||
|
import hu.bbara.purefin.navigation.ItemDto
|
||||||
import hu.bbara.purefin.navigation.NavigationManager
|
import hu.bbara.purefin.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.navigation.Route
|
import hu.bbara.purefin.navigation.Route
|
||||||
import hu.bbara.purefin.session.UserSessionRepository
|
import hu.bbara.purefin.session.UserSessionRepository
|
||||||
@@ -46,22 +47,30 @@ class HomePageViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onMovieSelected(movieId: String) {
|
fun onMovieSelected(movieId: String) {
|
||||||
navigationManager.navigate(Route.Movie(movieId))
|
navigationManager.navigate(Route.Movie(ItemDto(UUID.fromString(movieId), BaseItemKind.MOVIE)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSeriesSelected(seriesId: String) {
|
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() {
|
fun onBack() {
|
||||||
navigationManager.pop()
|
navigationManager.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun onGoHome() {
|
fun onGoHome() {
|
||||||
navigationManager.replaceAll(Route.Home)
|
navigationManager.replaceAll(Route.Home)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun loadContinueWatching() {
|
fun loadContinueWatching() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching()
|
val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching()
|
||||||
@@ -122,6 +131,7 @@ class HomePageViewModel @Inject constructor(
|
|||||||
private fun loadLibraryItems(libraryId: UUID) {
|
private fun loadLibraryItems(libraryId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val libraryItems: List<BaseItemDto> = jellyfinApiClient.getLibrary(libraryId)
|
val libraryItems: List<BaseItemDto> = jellyfinApiClient.getLibrary(libraryId)
|
||||||
|
// It return only Movie or Series
|
||||||
val libraryPosterItems = libraryItems.map {
|
val libraryPosterItems = libraryItems.map {
|
||||||
PosterItem(
|
PosterItem(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
@@ -158,15 +168,16 @@ class HomePageViewModel @Inject constructor(
|
|||||||
type = BaseItemKind.MOVIE
|
type = BaseItemKind.MOVIE
|
||||||
)
|
)
|
||||||
BaseItemKind.EPISODE -> PosterItem(
|
BaseItemKind.EPISODE -> PosterItem(
|
||||||
id = it.seriesId!!,
|
id = it.id,
|
||||||
title = it.seriesName ?: "Unknown",
|
title = it.seriesName ?: "Unknown",
|
||||||
type = BaseItemKind.SERIES
|
type = BaseItemKind.EPISODE,
|
||||||
|
parentId = it.seriesId!!
|
||||||
)
|
)
|
||||||
|
|
||||||
BaseItemKind.SEASON -> PosterItem(
|
BaseItemKind.SEASON -> PosterItem(
|
||||||
id = it.seriesId!!,
|
id = it.seriesId!!,
|
||||||
title = it.seriesName ?: "Unknown",
|
title = it.seriesName ?: "Unknown",
|
||||||
type = BaseItemKind.SERIES
|
type = BaseItemKind.SERIES,
|
||||||
|
parentId = it.seriesId
|
||||||
)
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ data class LibraryItem(
|
|||||||
data class PosterItem(
|
data class PosterItem(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val title: String,
|
val title: String,
|
||||||
val type: BaseItemKind
|
val type: BaseItemKind,
|
||||||
)
|
val parentId: UUID? = null
|
||||||
|
) {
|
||||||
|
val imageItemId: UUID get() = parentId ?: id
|
||||||
|
}
|
||||||
|
|
||||||
data class HomeNavItem(
|
data class HomeNavItem(
|
||||||
val label: String,
|
val label: String,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ fun ContinueWatchingCard(
|
|||||||
fun openItem(item: ContinueWatchingItem) {
|
fun openItem(item: ContinueWatchingItem) {
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.id.toString())
|
BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.id.toString())
|
||||||
BaseItemKind.EPISODE -> viewModel.onSeriesSelected(item.id.toString())
|
BaseItemKind.EPISODE -> viewModel.onSelectEpisode(item.id.toString())
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,9 +207,9 @@ fun PosterCard(
|
|||||||
when (posterItem.type) {
|
when (posterItem.type) {
|
||||||
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
|
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
|
||||||
BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString())
|
BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString())
|
||||||
|
BaseItemKind.EPISODE -> viewModel.onSelectEpisode(posterItem.id.toString())
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -222,7 +222,7 @@ fun PosterCard(
|
|||||||
.clickable(onClick = { openItem(item) })
|
.clickable(onClick = { openItem(item) })
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
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,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
|
|||||||
@@ -160,6 +160,32 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
return result.content
|
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 {
|
suspend fun getNextUpEpisode(mediaId: UUID): BaseItemDto {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
throw IllegalStateException("Not configured")
|
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
|
data object Home: Route
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Movie(val movieId: String) : Route
|
data class Movie(val item : ItemDto) : Route
|
||||||
|
|
||||||
@Serializable
|
@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 androidx.navigation3.runtime.EntryProviderScope
|
||||||
import hu.bbara.purefin.app.content.episode.EpisodeScreen
|
import hu.bbara.purefin.app.content.episode.EpisodeScreen
|
||||||
import hu.bbara.purefin.app.content.movie.MovieScreen
|
import hu.bbara.purefin.app.content.movie.MovieScreen
|
||||||
|
import hu.bbara.purefin.app.content.series.SeriesScreen
|
||||||
import hu.bbara.purefin.app.home.HomePage
|
import hu.bbara.purefin.app.home.HomePage
|
||||||
|
|
||||||
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
||||||
@@ -10,9 +11,12 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
|||||||
HomePage()
|
HomePage()
|
||||||
}
|
}
|
||||||
entry<Route.Movie> {
|
entry<Route.Movie> {
|
||||||
MovieScreen(movieId = it.movieId)
|
MovieScreen(movie = it.item)
|
||||||
|
}
|
||||||
|
entry<Route.Series> {
|
||||||
|
SeriesScreen(series = it.item)
|
||||||
}
|
}
|
||||||
entry<Route.Episode> {
|
entry<Route.Episode> {
|
||||||
EpisodeScreen(seriesId = it.seriesId)
|
EpisodeScreen(episode = it.item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user