implement navigation, loading states, and series episode data

- Implement back navigation and home redirection in `EpisodeScreenViewModel`, `MovieScreenViewModel`, and `SeriesViewModel`.
- Refactor `EpisodeScreen`, `MovieScreen`, and `SeriesScreen` to handle data fetching via `LaunchedEffect` and display a loading state.
- Create `PurefinWaitingScreen`, a custom animated loading component with pulsing icons and dots.
- Update `SeriesViewModel` to properly map episode data, including image URLs and unique identifiers.
- Add click listeners to `EpisodeTopBar`, `MovieTopBar`, `SeriesTopBar`, and `EpisodeCard` to support navigation between screens.
- Integrate the waiting screen into the `LoginScreen` flow during connection attempts.
- Fix image URL generation in `SeriesViewModel` to use the primary image type for episodes and backdrops for series.
This commit is contained in:
2026-01-19 19:11:41 +01:00
parent f7d64478fd
commit a515819daa
15 changed files with 653 additions and 359 deletions

View File

@@ -12,35 +12,17 @@ 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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.navigation.ItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun EpisodeCard(
item: ItemDto,
episode: EpisodeUiModel,
modifier: Modifier = Modifier,
viewModel: EpisodeScreenViewModel = hiltViewModel()
) {
LaunchedEffect(item) {
when (item.type) {
BaseItemKind.EPISODE -> viewModel.selectEpisode(item.id)
else -> return@LaunchedEffect
}
}
val episodeItem = viewModel.episode.collectAsState()
if (episodeItem.value != null) {
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
@@ -53,7 +35,7 @@ fun EpisodeCard(
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
EpisodeHero(
episode = episodeItem.value!!,
episode = episode,
height = 300.dp,
isWide = true,
modifier = Modifier
@@ -61,12 +43,17 @@ fun EpisodeCard(
.weight(0.5f)
)
EpisodeDetails(
episode = episodeItem.value!!,
episode = episode,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp)
.padding(
start = contentPadding,
end = contentPadding,
top = 96.dp,
bottom = 32.dp
)
)
}
} else {
@@ -76,13 +63,13 @@ fun EpisodeCard(
.verticalScroll(rememberScrollState())
) {
EpisodeHero(
episode = episodeItem.value!!,
episode = episode,
height = 400.dp,
isWide = false,
modifier = Modifier.fillMaxWidth()
)
EpisodeDetails(
episode = episodeItem.value!!,
episode = episode,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
@@ -107,17 +94,5 @@ fun EpisodeCard(
}
}
}
} else {
Box(
modifier = modifier
.fillMaxSize()
.background(EpisodeBackgroundDark),
contentAlignment = Alignment.Center
) {
Text(
text = "Loading...",
color = Color.White
)
}
}
}

View File

@@ -59,13 +59,20 @@ import coil3.compose.AsyncImage
import hu.bbara.purefin.player.PlayerActivity
@Composable
internal fun EpisodeTopBar(modifier: Modifier = Modifier) {
internal fun EpisodeTopBar(
viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back")
GhostIconButton(
onClick = { viewModel.onBack() },
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")
@@ -75,6 +82,7 @@ internal fun EpisodeTopBar(modifier: Modifier = Modifier) {
@Composable
private fun GhostIconButton(
onClick: () -> Unit = {},
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier
@@ -84,7 +92,7 @@ private fun GhostIconButton(
.size(40.dp)
.clip(CircleShape)
.background(EpisodeBackgroundDark.copy(alpha = 0.4f))
.clickable { },
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(

View File

@@ -1,16 +1,32 @@
package hu.bbara.purefin.app.content.episode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.navigation.ItemDto
@Composable
fun EpisodeScreen(
episode: ItemDto,
viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(episode) {
viewModel.selectEpisode(episode.id)
}
val episode = viewModel.episode.collectAsState()
if (episode.value != null) {
EpisodeCard(
item = episode,
episode = episode.value!!,
modifier = modifier
)
} else {
PurefinWaitingScreen()
}
}

View File

@@ -5,6 +5,9 @@ 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.navigation.ItemDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -12,6 +15,7 @@ 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.BaseItemKind
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import java.time.LocalDateTime
@@ -23,12 +27,28 @@ import javax.inject.Inject
@HiltViewModel
class EpisodeScreenViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository
): ViewModel() {
private val _episode = MutableStateFlow<EpisodeUiModel?>(null)
val episode = _episode.asStateFlow()
fun onSeriesSelected(seriesId: String) {
viewModelScope.launch {
navigationManager.navigate(Route.Series(ItemDto(UUID.fromString(seriesId), BaseItemKind.SERIES)))
}
}
fun onBack() {
navigationManager.pop()
}
fun onGoHome() {
navigationManager.replaceAll(Route.Home)
}
fun selectNextUpEpisodeForSeries(seriesId: UUID) {
viewModelScope.launch {
val episode = jellyfinApiClient.getNextUpEpisode(seriesId)

View File

@@ -12,31 +12,17 @@ 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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.navigation.ItemDto
@Composable
fun MovieCard(
movie: ItemDto,
movie: MovieUiModel,
modifier: Modifier = Modifier,
viewModel: MovieScreenViewModel = hiltViewModel()
) {
LaunchedEffect(movie.id) {
viewModel.selectMovie(movie.id)
}
val movieItem = viewModel.movie.collectAsState()
if (movieItem.value != null) {
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
@@ -49,7 +35,7 @@ fun MovieCard(
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
MovieHero(
movie = movieItem.value!!,
movie = movie,
height = 300.dp,
isWide = true,
modifier = Modifier
@@ -57,12 +43,17 @@ fun MovieCard(
.weight(0.5f)
)
MovieDetails(
movie = movieItem.value!!,
movie = movie,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp)
.padding(
start = contentPadding,
end = contentPadding,
top = 96.dp,
bottom = 32.dp
)
)
}
} else {
@@ -72,13 +63,13 @@ fun MovieCard(
.verticalScroll(rememberScrollState())
) {
MovieHero(
movie = movieItem.value!!,
movie = movie,
height = 400.dp,
isWide = false,
modifier = Modifier.fillMaxWidth()
)
MovieDetails(
movie = movieItem.value!!,
movie = movie,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
@@ -103,17 +94,5 @@ fun MovieCard(
}
}
}
} else {
Box(
modifier = modifier
.fillMaxSize()
.background(MovieBackgroundDark),
contentAlignment = Alignment.Center
) {
Text(
text = "Loading...",
color = Color.White
)
}
}
}

View File

@@ -59,13 +59,20 @@ import coil3.compose.AsyncImage
import hu.bbara.purefin.player.PlayerActivity
@Composable
internal fun MovieTopBar(modifier: Modifier = Modifier) {
internal fun MovieTopBar(
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back")
GhostIconButton(
onClick = { viewModel.onBack() },
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")
@@ -77,6 +84,7 @@ internal fun MovieTopBar(modifier: Modifier = Modifier) {
private fun GhostIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Box(
@@ -84,7 +92,7 @@ private fun GhostIconButton(
.size(40.dp)
.clip(CircleShape)
.background(MovieBackgroundDark.copy(alpha = 0.4f))
.clickable { },
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
@@ -133,8 +141,7 @@ internal fun MovieHero(
.background(
Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
MovieBackgroundDark.copy(alpha = 0.8f)
Color.Transparent, MovieBackgroundDark.copy(alpha = 0.8f)
)
)
)

View File

@@ -1,16 +1,31 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.navigation.ItemDto
@Composable
fun MovieScreen(
movie: ItemDto,
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(movie.id) {
viewModel.selectMovie(movie.id)
}
val movieItem = viewModel.movie.collectAsState()
if (movieItem.value != null) {
MovieCard(
movie = movie,
movie = movieItem.value!!,
modifier = modifier
)
} else {
PurefinWaitingScreen()
}
}

View File

@@ -5,6 +5,8 @@ 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.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -20,12 +22,22 @@ import javax.inject.Inject
@HiltViewModel
class MovieScreenViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository
): ViewModel() {
private val _movie = MutableStateFlow<MovieUiModel?>(null)
val movie = _movie.asStateFlow()
fun onBack() {
navigationManager.pop()
}
fun onGoHome() {
navigationManager.replaceAll(Route.Home)
}
fun selectMovie(movieId: UUID) {
viewModelScope.launch {
val movieInfo = jellyfinApiClient.getItemInfo(movieId)

View File

@@ -1,7 +1,6 @@
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
@@ -14,30 +13,20 @@ 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.tooling.preview.Preview
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,
series: SeriesUiModel,
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()
@@ -50,25 +39,28 @@ fun SeriesCard(
.verticalScroll(rememberScrollState())
) {
SeriesHero(
imageUrl = series.value!!.heroImageUrl,
imageUrl = series.heroImageUrl,
height = heroHeight
)
Column(
modifier = Modifier
.fillMaxWidth()
.offset(y = (-96).dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp)
) {
Text(
text = series.value!!.title,
text = series.title,
color = Color.White,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
lineHeight = 36.sp
)
Spacer(modifier = Modifier.height(16.dp))
SeriesMetaChips(series = series.value!!)
SeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(24.dp))
SeriesActionButtons()
Spacer(modifier = Modifier.height(24.dp))
@@ -80,20 +72,32 @@ fun SeriesCard(
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = series.value!!.synopsis,
text = series.synopsis,
color = SeriesMutedStrong,
fontSize = 13.sp,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(28.dp))
SeasonTabs(seasons = series.value!!.seasonTabs)
Text(
text = "Episodes",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(28.dp))
SeasonTabs(seasons = series.seasonTabs)
Spacer(modifier = Modifier.height(16.dp))
}
EpisodeCarousel(episodes = series.value!!.seasonTabs.firstOrNull { it.isSelected }?.episodes.orEmpty())
EpisodeCarousel(
episodes = series.seasonTabs.firstOrNull { it.isSelected }?.episodes
?: series.seasonTabs.firstOrNull()?.episodes
?: emptyList()
)
Spacer(modifier = Modifier.height(32.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 32.dp)
.padding(top = 0.dp, bottom = 0.dp)
) {
Text(
text = "Cast",
@@ -103,7 +107,8 @@ fun SeriesCard(
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = series.value!!.cast)
CastRow(cast = series.cast)
}
}
}
@@ -114,17 +119,10 @@ fun SeriesCard(
.align(Alignment.TopCenter)
)
}
} else {
Box(
modifier = modifier
.fillMaxSize()
.background(SeriesBackgroundDark),
contentAlignment = Alignment.Center
) {
Text(
text = "Loading...",
color = Color.White
)
}
}
@Preview
@Composable
fun SeriesCardPreview() {
SeriesCard(series = SeriesMockData.series())
}

View File

@@ -50,16 +50,23 @@ 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 androidx.hilt.navigation.compose.hiltViewModel
import coil3.compose.AsyncImage
@Composable
internal fun SeriesTopBar(modifier: Modifier = Modifier) {
internal fun SeriesTopBar(
viewModel: SeriesViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back")
GhostIconButton(
onClick = { viewModel.onBack() },
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")
@@ -69,6 +76,7 @@ internal fun SeriesTopBar(modifier: Modifier = Modifier) {
@Composable
private fun GhostIconButton(
onClick: () -> Unit = {},
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier
@@ -78,7 +86,7 @@ private fun GhostIconButton(
.size(40.dp)
.clip(CircleShape)
.background(SeriesBackgroundDark.copy(alpha = 0.4f))
.clickable { },
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
@@ -267,7 +275,10 @@ internal fun EpisodeCarousel(episodes: List<SeriesEpisodeUiModel>, modifier: Mod
}
@Composable
private fun EpisodeCard(episode: SeriesEpisodeUiModel) {
private fun EpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(),
episode: SeriesEpisodeUiModel
) {
Column(
modifier = Modifier
.width(260.dp)
@@ -275,7 +286,7 @@ private fun EpisodeCard(episode: SeriesEpisodeUiModel) {
.background(SeriesSurfaceDark.copy(alpha = 0.3f))
.border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(16.dp))
.padding(12.dp)
.clickable { },
.clickable { viewModel.onSelectEpisode(episode.id) },
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.app.content.series
data class SeriesEpisodeUiModel(
val id: String,
val title: String,
val description: String,
val duration: String,
@@ -35,18 +36,21 @@ internal object SeriesMockData {
fun series(): SeriesUiModel {
val heroUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuD3hBjDpw00tDCQsK5xNcnJra301k1T4LksWVZzHieH9KHQItEQkVzhwevJvf8RkaQKdVKvObzRlfDDqa3_PNwLUlUQc1LpDih8p94VTGobEV62qi7QrmNyQm_o55KRMNWiTG3zLLpblGqo3uUNQcYmPFqfNML95dClXQ4lQNl85-zgerPPAbGPr23dswbIYCigyTAaXgrmdV_nbNQ5LdDB0Wh5cMHtP0uxz6k3ARjNom6clhphGIUF9e6YSvKuwuiZ-1lMYFg8C_4"
val episode1 = SeriesEpisodeUiModel(
id = "1",
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(
id = "2",
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(
id = "3",
title = "E3: Singularity",
description = "Tension rises as the ship approaches the event horizon, and the AI begins to behave erratically.",
duration = "1h 02m",

View File

@@ -1,16 +1,31 @@
package hu.bbara.purefin.app.content.series
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.navigation.ItemDto
@Composable
fun SeriesScreen(
series: ItemDto,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) {
LaunchedEffect(series.id) {
viewModel.selectSeries(series.id)
}
val series = viewModel.series.collectAsState()
if (series.value != null) {
SeriesCard(
series = series,
series = series.value!!,
modifier = modifier
)
} else {
PurefinWaitingScreen()
}
}

View File

@@ -5,6 +5,9 @@ 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.navigation.ItemDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -12,6 +15,7 @@ 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.BaseItemKind
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import javax.inject.Inject
@@ -19,12 +23,28 @@ import javax.inject.Inject
@HiltViewModel
class SeriesViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository
) : ViewModel() {
private val _series = MutableStateFlow<SeriesUiModel?>(null)
val series = _series.asStateFlow()
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 selectSeries(seriesId: UUID) {
viewModelScope.launch {
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
@@ -35,7 +55,13 @@ class SeriesViewModel @Inject constructor(
val episodesItemResult = seasonsItemResult.associate { season ->
season.id to jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
}
_series.value = mapToSeriesUiModel(serverUrl, seriesItemResult, seasonsItemResult, episodesItemResult)
val seriesUiModel = mapToSeriesUiModel(
serverUrl,
seriesItemResult,
seasonsItemResult,
episodesItemResult
)
_series.value = seriesUiModel
}
}
@@ -49,25 +75,20 @@ class SeriesViewModel @Inject constructor(
val episodeItemResult = episodesItemResult[season.id] ?: emptyList()
val episodeItemUiModels = episodeItemResult.map { episode ->
SeriesEpisodeUiModel(
id = episode.id.toString(),
title = episode.name ?: "Unknown",
description = episode.overview ?: "",
duration = "58m",
imageUrl = ""
imageUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = episode.id, type = ImageType.PRIMARY)
)
}
SeriesSeasonUiModel(
name = season.name ?: "Unknown",
episodes = episodeItemUiModels,
// TODO add actual logic or remove
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",
@@ -75,7 +96,11 @@ class SeriesViewModel @Inject constructor(
year = seriesItemResult!!.productionYear?.toString() ?: seriesItemResult!!.premiereDate?.year?.toString().orEmpty(),
seasons = "3 Seasons",
synopsis = seriesItemResult.overview ?: "No synopsis available.",
heroImageUrl = "",
heroImageUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = seriesItemResult.id,
type = ImageType.BACKDROP
),
seasonTabs = seasonUiModels,
cast = seriesItemResult.people.orEmpty().map { it.toCastMember() }
)

View File

@@ -0,0 +1,197 @@
package hu.bbara.purefin.common.ui
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Movie
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinWaitingScreen(
modifier: Modifier = Modifier
) {
val accentColor = Color(0xFFBD542E)
val backgroundColor = Color(0xFF141517)
val surfaceColor = Color(0xFF1E2124)
val textPrimary = Color.White
val textSecondary = Color(0xFF9EA3A8)
val transition = rememberInfiniteTransition(label = "waiting-pulse")
val pulseScale = transition.animateFloat(
initialValue = 0.9f,
targetValue = 1.15f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulse-scale"
)
val pulseAlpha = transition.animateFloat(
initialValue = 0.2f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulse-alpha"
)
val gradient = Brush.radialGradient(
colors = listOf(
accentColor.copy(alpha = 0.28f),
backgroundColor
)
)
Box(
modifier = modifier
.fillMaxSize()
.background(gradient)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.background(surfaceColor.copy(alpha = 0.92f))
.padding(horizontal = 28.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(86.dp)
.graphicsLayer {
scaleX = pulseScale.value
scaleY = pulseScale.value
}
.alpha(pulseAlpha.value)
.border(
width = 2.dp,
color = accentColor.copy(alpha = 0.6f),
shape = RoundedCornerShape(26.dp)
)
)
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(22.dp))
.background(accentColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Movie,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(40.dp)
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Connecting",
color = textPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "Summoning the media gnomes...",
color = textSecondary,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(24.dp))
WaitingDots(accentColor = accentColor)
}
}
}
@Composable
private fun WaitingDots(accentColor: Color, modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "waiting-dots")
val firstAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 0, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-1"
)
val secondAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 140, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-2"
)
val thirdAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 280, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-3"
)
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
WaitingDot(alpha = firstAlpha.value, color = accentColor)
WaitingDot(alpha = secondAlpha.value, color = accentColor)
WaitingDot(alpha = thirdAlpha.value, color = accentColor)
}
}
@Composable
private fun WaitingDot(alpha: Float, color: Color) {
Box(
modifier = Modifier
.size(10.dp)
.graphicsLayer {
val scale = 0.7f + (alpha * 0.3f)
scaleX = scale
scaleY = scale
}
.alpha(alpha)
.background(color, CircleShape)
)
}

View File

@@ -26,7 +26,10 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -37,6 +40,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinComplexTextField
import hu.bbara.purefin.common.ui.PurefinPasswordField
import hu.bbara.purefin.common.ui.PurefinTextButton
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.login.viewmodel.LoginViewModel
import kotlinx.coroutines.launch
@@ -47,16 +51,19 @@ fun LoginScreen(
) {
val JellyfinOrange = Color(0xFFBD542E)
val JellyfinBg = Color(0xFF141517)
val JellyfinSurface = Color(0xFF1E2124)
val TextSecondary = Color(0xFF9EA3A8)
// Observe ViewModel state
val serverUrl by viewModel.url.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
} else {
Column(
modifier = modifier
.fillMaxSize()
@@ -149,7 +156,12 @@ fun LoginScreen(
content = { Text("Connect") },
onClick = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
@@ -173,6 +185,6 @@ fun LoginScreen(
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}