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,112 +12,87 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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 hu.bbara.purefin.navigation.ItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable @Composable
fun EpisodeCard( fun EpisodeCard(
item: ItemDto, episode: EpisodeUiModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: EpisodeScreenViewModel = hiltViewModel()
) { ) {
LaunchedEffect(item) { BoxWithConstraints(
when (item.type) { modifier = modifier
BaseItemKind.EPISODE -> viewModel.selectEpisode(item.id) .fillMaxSize()
else -> return@LaunchedEffect .background(EpisodeBackgroundDark)
} ) {
} val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
val episodeItem = viewModel.episode.collectAsState() Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
if (episodeItem.value != null) { Row(modifier = Modifier.fillMaxSize()) {
BoxWithConstraints( EpisodeHero(
modifier = modifier episode = episode,
.fillMaxSize() height = 300.dp,
.background(EpisodeBackgroundDark) isWide = true,
) {
val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
EpisodeHero(
episode = episodeItem.value!!,
height = 300.dp,
isWide = true,
modifier = Modifier
.fillMaxHeight()
.weight(0.5f)
)
EpisodeDetails(
episode = episodeItem.value!!,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp)
)
}
} else {
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxHeight()
.weight(0.5f)
)
EpisodeDetails(
episode = episode,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { .padding(
EpisodeHero( start = contentPadding,
episode = episodeItem.value!!, end = contentPadding,
height = 400.dp, top = 96.dp,
isWide = false, bottom = 32.dp
modifier = Modifier.fillMaxWidth() )
) )
EpisodeDetails(
episode = episodeItem.value!!,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
)
}
} }
} else {
EpisodeTopBar( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(horizontal = 16.dp, vertical = 16.dp) .verticalScroll(rememberScrollState())
) ) {
EpisodeHero(
if (!isWide) { episode = episode,
FloatingPlayButton( height = 400.dp,
isWide = false,
modifier = Modifier.fillMaxWidth()
)
EpisodeDetails(
episode = episode,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .fillMaxWidth()
.padding(20.dp) .padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
) )
} }
} }
}
} else { EpisodeTopBar(
Box( modifier = Modifier
modifier = modifier .fillMaxWidth()
.fillMaxSize() .padding(horizontal = 16.dp, vertical = 16.dp)
.background(EpisodeBackgroundDark),
contentAlignment = Alignment.Center
) {
Text(
text = "Loading...",
color = Color.White
) )
if (!isWide) {
FloatingPlayButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
} }
} }
} }

View File

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

View File

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

View File

@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.image.JellyfinImageHelper 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 hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -12,6 +15,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto 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.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -23,12 +27,28 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class EpisodeScreenViewModel @Inject constructor( class EpisodeScreenViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository private val userSessionRepository: UserSessionRepository
): ViewModel() { ): ViewModel() {
private val _episode = MutableStateFlow<EpisodeUiModel?>(null) private val _episode = MutableStateFlow<EpisodeUiModel?>(null)
val episode = _episode.asStateFlow() 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) { fun selectNextUpEpisodeForSeries(seriesId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val episode = jellyfinApiClient.getNextUpEpisode(seriesId) val episode = jellyfinApiClient.getNextUpEpisode(seriesId)

View File

@@ -12,108 +12,87 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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 hu.bbara.purefin.navigation.ItemDto
@Composable @Composable
fun MovieCard( fun MovieCard(
movie: ItemDto, movie: MovieUiModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MovieScreenViewModel = hiltViewModel()
) { ) {
LaunchedEffect(movie.id) { BoxWithConstraints(
viewModel.selectMovie(movie.id) modifier = modifier
} .fillMaxSize()
.background(MovieBackgroundDark)
) {
val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
val movieItem = viewModel.movie.collectAsState() Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
if (movieItem.value != null) { Row(modifier = Modifier.fillMaxSize()) {
BoxWithConstraints( MovieHero(
modifier = modifier movie = movie,
.fillMaxSize() height = 300.dp,
.background(MovieBackgroundDark) isWide = true,
) {
val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
MovieHero(
movie = movieItem.value!!,
height = 300.dp,
isWide = true,
modifier = Modifier
.fillMaxHeight()
.weight(0.5f)
)
MovieDetails(
movie = movieItem.value!!,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp)
)
}
} else {
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxHeight()
.weight(0.5f)
)
MovieDetails(
movie = movie,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { .padding(
MovieHero( start = contentPadding,
movie = movieItem.value!!, end = contentPadding,
height = 400.dp, top = 96.dp,
isWide = false, bottom = 32.dp
modifier = Modifier.fillMaxWidth() )
) )
MovieDetails(
movie = movieItem.value!!,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
)
}
} }
} else {
MovieTopBar( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(horizontal = 16.dp, vertical = 16.dp) .verticalScroll(rememberScrollState())
) ) {
MovieHero(
if (!isWide) { movie = movie,
FloatingPlayButton( height = 400.dp,
isWide = false,
modifier = Modifier.fillMaxWidth()
)
MovieDetails(
movie = movie,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .fillMaxWidth()
.padding(20.dp) .padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
) )
} }
} }
}
} else { MovieTopBar(
Box( modifier = Modifier
modifier = modifier .fillMaxWidth()
.fillMaxSize() .padding(horizontal = 16.dp, vertical = 16.dp)
.background(MovieBackgroundDark),
contentAlignment = Alignment.Center
) {
Text(
text = "Loading...",
color = Color.White
) )
if (!isWide) {
FloatingPlayButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
} }
} }
} }

View File

@@ -59,13 +59,20 @@ import coil3.compose.AsyncImage
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
@Composable @Composable
internal fun MovieTopBar(modifier: Modifier = Modifier) { internal fun MovieTopBar(
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast")
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More")
@@ -77,6 +84,7 @@ internal fun MovieTopBar(modifier: Modifier = Modifier) {
private fun GhostIconButton( private fun GhostIconButton(
icon: ImageVector, icon: ImageVector,
contentDescription: String, contentDescription: String,
onClick: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
@@ -84,7 +92,7 @@ private fun GhostIconButton(
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MovieBackgroundDark.copy(alpha = 0.4f)) .background(MovieBackgroundDark.copy(alpha = 0.4f))
.clickable { }, .clickable { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -133,8 +141,7 @@ internal fun MovieHero(
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colors = listOf( colors = listOf(
Color.Transparent, Color.Transparent, MovieBackgroundDark.copy(alpha = 0.8f)
MovieBackgroundDark.copy(alpha = 0.8f)
) )
) )
) )
@@ -431,7 +438,7 @@ private fun PlayButton(
.shadow(24.dp, CircleShape) .shadow(24.dp, CircleShape)
.clip(CircleShape) .clip(CircleShape)
.background(MoviePrimary) .background(MoviePrimary)
.clickable{ .clickable {
val intent = Intent(context, PlayerActivity::class.java) val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", movieId.value!!.id.toString()) intent.putExtra("MEDIA_ID", movieId.value!!.id.toString())
context.startActivity(intent) context.startActivity(intent)

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package hu.bbara.purefin.app.content.series package hu.bbara.purefin.app.content.series
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -14,61 +13,54 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.navigation.ItemDto
@Composable @Composable
fun SeriesCard( fun SeriesCard(
series: ItemDto, series: SeriesUiModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) { ) {
LaunchedEffect(series.id) {
viewModel.selectSeries(series.id)
}
val series = viewModel.series.collectAsState() BoxWithConstraints(
modifier = modifier
if (series.value != null) { .fillMaxSize()
BoxWithConstraints( .background(SeriesBackgroundDark)
modifier = modifier ) {
val heroHeight = maxHeight * 0.6f
Column(
modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(SeriesBackgroundDark) .verticalScroll(rememberScrollState())
) { ) {
val heroHeight = maxHeight * 0.6f SeriesHero(
imageUrl = series.heroImageUrl,
height = heroHeight
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .offset(y = (-96).dp)
) { ) {
SeriesHero(
imageUrl = series.value!!.heroImageUrl,
height = heroHeight
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.offset(y = (-96).dp)
.padding(horizontal = 20.dp) .padding(horizontal = 20.dp)
.padding(bottom = 32.dp)
) { ) {
Text( Text(
text = series.value!!.title, text = series.title,
color = Color.White, color = Color.White,
fontSize = 30.sp, fontSize = 30.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = 36.sp lineHeight = 36.sp
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
SeriesMetaChips(series = series.value!!) SeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
SeriesActionButtons() SeriesActionButtons()
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -80,20 +72,32 @@ fun SeriesCard(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = series.value!!.synopsis, text = series.synopsis,
color = SeriesMutedStrong, color = SeriesMutedStrong,
fontSize = 13.sp, fontSize = 13.sp,
lineHeight = 20.sp
) )
Spacer(modifier = Modifier.height(28.dp)) 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)) 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 24.dp, bottom = 32.dp) .padding(top = 0.dp, bottom = 0.dp)
) { ) {
Text( Text(
text = "Cast", text = "Cast",
@@ -103,28 +107,22 @@ fun SeriesCard(
modifier = Modifier.padding(horizontal = 20.dp) modifier = Modifier.padding(horizontal = 20.dp)
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = series.value!!.cast) CastRow(cast = series.cast)
} }
} }
}
SeriesTopBar( SeriesTopBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp) .padding(horizontal = 16.dp, vertical = 16.dp)
.align(Alignment.TopCenter) .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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@Composable @Composable
internal fun SeriesTopBar(modifier: Modifier = Modifier) { internal fun SeriesTopBar(
viewModel: SeriesViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast")
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More")
@@ -69,6 +76,7 @@ internal fun SeriesTopBar(modifier: Modifier = Modifier) {
@Composable @Composable
private fun GhostIconButton( private fun GhostIconButton(
onClick: () -> Unit = {},
icon: ImageVector, icon: ImageVector,
contentDescription: String, contentDescription: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -78,7 +86,7 @@ private fun GhostIconButton(
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(SeriesBackgroundDark.copy(alpha = 0.4f)) .background(SeriesBackgroundDark.copy(alpha = 0.4f))
.clickable { }, .clickable { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -267,7 +275,10 @@ internal fun EpisodeCarousel(episodes: List<SeriesEpisodeUiModel>, modifier: Mod
} }
@Composable @Composable
private fun EpisodeCard(episode: SeriesEpisodeUiModel) { private fun EpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(),
episode: SeriesEpisodeUiModel
) {
Column( Column(
modifier = Modifier modifier = Modifier
.width(260.dp) .width(260.dp)
@@ -275,7 +286,7 @@ private fun EpisodeCard(episode: SeriesEpisodeUiModel) {
.background(SeriesSurfaceDark.copy(alpha = 0.3f)) .background(SeriesSurfaceDark.copy(alpha = 0.3f))
.border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(16.dp)) .border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(16.dp))
.padding(12.dp) .padding(12.dp)
.clickable { }, .clickable { viewModel.onSelectEpisode(episode.id) },
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Box( Box(

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.app.content.series package hu.bbara.purefin.app.content.series
data class SeriesEpisodeUiModel( data class SeriesEpisodeUiModel(
val id: String,
val title: String, val title: String,
val description: String, val description: String,
val duration: String, val duration: String,
@@ -35,18 +36,21 @@ internal object SeriesMockData {
fun series(): SeriesUiModel { fun series(): SeriesUiModel {
val heroUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuD3hBjDpw00tDCQsK5xNcnJra301k1T4LksWVZzHieH9KHQItEQkVzhwevJvf8RkaQKdVKvObzRlfDDqa3_PNwLUlUQc1LpDih8p94VTGobEV62qi7QrmNyQm_o55KRMNWiTG3zLLpblGqo3uUNQcYmPFqfNML95dClXQ4lQNl85-zgerPPAbGPr23dswbIYCigyTAaXgrmdV_nbNQ5LdDB0Wh5cMHtP0uxz6k3ARjNom6clhphGIUF9e6YSvKuwuiZ-1lMYFg8C_4" val heroUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuD3hBjDpw00tDCQsK5xNcnJra301k1T4LksWVZzHieH9KHQItEQkVzhwevJvf8RkaQKdVKvObzRlfDDqa3_PNwLUlUQc1LpDih8p94VTGobEV62qi7QrmNyQm_o55KRMNWiTG3zLLpblGqo3uUNQcYmPFqfNML95dClXQ4lQNl85-zgerPPAbGPr23dswbIYCigyTAaXgrmdV_nbNQ5LdDB0Wh5cMHtP0uxz6k3ARjNom6clhphGIUF9e6YSvKuwuiZ-1lMYFg8C_4"
val episode1 = SeriesEpisodeUiModel( val episode1 = SeriesEpisodeUiModel(
id = "1",
title = "E1: The Beginning", title = "E1: The Beginning",
description = "The crew assembles for the first time as the anomaly begins to expand rapidly near Saturn's rings.", description = "The crew assembles for the first time as the anomaly begins to expand rapidly near Saturn's rings.",
duration = "58m", duration = "58m",
imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuC6OPszCXCIP_FMO3BJJUrjpCtDNw9aeHYOGyOAXdqF078hDFNrH7KXbaQ7qtipz6aIPLivd8VBBffNMbeAiYIjjWjn5GMb6Xn9iiJz0D2rzhCKi0TBeFrN6tC1IXJkzQyQKJNhTnyokWy9dd-YtN65V7er7RT6hP5jdVBXhtK1xZMjlgrm1bk_FTTmKd8Afu3zPtJCaaC98Z608vav5zhYlkrdA1wKNSTWTpzwMSyDIY3pNQNPFauWf0n-iEu7QsYTAwhCG_zfxz0" imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuC6OPszCXCIP_FMO3BJJUrjpCtDNw9aeHYOGyOAXdqF078hDFNrH7KXbaQ7qtipz6aIPLivd8VBBffNMbeAiYIjjWjn5GMb6Xn9iiJz0D2rzhCKi0TBeFrN6tC1IXJkzQyQKJNhTnyokWy9dd-YtN65V7er7RT6hP5jdVBXhtK1xZMjlgrm1bk_FTTmKd8Afu3zPtJCaaC98Z608vav5zhYlkrdA1wKNSTWTpzwMSyDIY3pNQNPFauWf0n-iEu7QsYTAwhCG_zfxz0"
) )
val episode2 = SeriesEpisodeUiModel( val episode2 = SeriesEpisodeUiModel(
id = "2",
title = "E2: Event Horizon", title = "E2: Event Horizon",
description = "Dr. Cole discovers a frequency embedded in the rift's radiation that suggests intelligent design.", description = "Dr. Cole discovers a frequency embedded in the rift's radiation that suggests intelligent design.",
duration = "54m", duration = "54m",
imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuBExsf-wEzAVjMxasU2ImGhlreqQo9biBSN1yHyAbW8MyuhuppRw9ho7OD3vsbySSJ3kNluEgH1Qun45PmLnZWixZsFU4Qc7UGGJNKMS5Nkm4GZAsKdFvb3z_i1tkCvaXXvGpqmwI0qjFuo1QyjjhYPA5Yp3I8ZhrnDYdQv_GxbhR6Vl3mY1rbxd2BIUEE5oMTwTF-QmJztUEaViZkSGSG2VgVXZ5VAREn4xWE902OH2sysllvXQJQIaj439JIC2_Vg61m0-F-F1Vc" imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuBExsf-wEzAVjMxasU2ImGhlreqQo9biBSN1yHyAbW8MyuhuppRw9ho7OD3vsbySSJ3kNluEgH1Qun45PmLnZWixZsFU4Qc7UGGJNKMS5Nkm4GZAsKdFvb3z_i1tkCvaXXvGpqmwI0qjFuo1QyjjhYPA5Yp3I8ZhrnDYdQv_GxbhR6Vl3mY1rbxd2BIUEE5oMTwTF-QmJztUEaViZkSGSG2VgVXZ5VAREn4xWE902OH2sysllvXQJQIaj439JIC2_Vg61m0-F-F1Vc"
) )
val episode3 = SeriesEpisodeUiModel( val episode3 = SeriesEpisodeUiModel(
id = "3",
title = "E3: Singularity", title = "E3: Singularity",
description = "Tension rises as the ship approaches the event horizon, and the AI begins to behave erratically.", description = "Tension rises as the ship approaches the event horizon, and the AI begins to behave erratically.",
duration = "1h 02m", duration = "1h 02m",

View File

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

View File

@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.image.JellyfinImageHelper 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 hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -12,6 +15,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto 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.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import javax.inject.Inject import javax.inject.Inject
@@ -19,12 +23,28 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SeriesViewModel @Inject constructor( class SeriesViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository private val userSessionRepository: UserSessionRepository
) : ViewModel() { ) : ViewModel() {
private val _series = MutableStateFlow<SeriesUiModel?>(null) private val _series = MutableStateFlow<SeriesUiModel?>(null)
val series = _series.asStateFlow() 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) { fun selectSeries(seriesId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank { val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
@@ -35,7 +55,13 @@ class SeriesViewModel @Inject constructor(
val episodesItemResult = seasonsItemResult.associate { season -> val episodesItemResult = seasonsItemResult.associate { season ->
season.id to jellyfinApiClient.getEpisodesInSeason(seriesId, season.id) 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 episodeItemResult = episodesItemResult[season.id] ?: emptyList()
val episodeItemUiModels = episodeItemResult.map { episode -> val episodeItemUiModels = episodeItemResult.map { episode ->
SeriesEpisodeUiModel( SeriesEpisodeUiModel(
id = episode.id.toString(),
title = episode.name ?: "Unknown", title = episode.name ?: "Unknown",
description = episode.overview ?: "", description = episode.overview ?: "",
duration = "58m", duration = "58m",
imageUrl = "" imageUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = episode.id, type = ImageType.PRIMARY)
) )
} }
SeriesSeasonUiModel( SeriesSeasonUiModel(
name = season.name ?: "Unknown", name = season.name ?: "Unknown",
episodes = episodeItemUiModels, episodes = episodeItemUiModels,
// TODO add actual logic or remove
isSelected = false, isSelected = false,
) )
} }
val heroImageUrl = seriesItemResult?.let { series ->
JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = series.id,
type = ImageType.BACKDROP
)
} ?: ""
return SeriesUiModel( return SeriesUiModel(
title = seriesItemResult?.name ?: "Unknown", title = seriesItemResult?.name ?: "Unknown",
format = seriesItemResult?.container ?: "VIDEO", format = seriesItemResult?.container ?: "VIDEO",
@@ -75,7 +96,11 @@ class SeriesViewModel @Inject constructor(
year = seriesItemResult!!.productionYear?.toString() ?: seriesItemResult!!.premiereDate?.year?.toString().orEmpty(), year = seriesItemResult!!.productionYear?.toString() ?: seriesItemResult!!.premiereDate?.year?.toString().orEmpty(),
seasons = "3 Seasons", seasons = "3 Seasons",
synopsis = seriesItemResult.overview ?: "No synopsis available.", synopsis = seriesItemResult.overview ?: "No synopsis available.",
heroImageUrl = "", heroImageUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = seriesItemResult.id,
type = ImageType.BACKDROP
),
seasonTabs = seasonUiModels, seasonTabs = seasonUiModels,
cast = seriesItemResult.people.orEmpty().map { it.toCastMember() } 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.PurefinComplexTextField
import hu.bbara.purefin.common.ui.PurefinPasswordField import hu.bbara.purefin.common.ui.PurefinPasswordField
import hu.bbara.purefin.common.ui.PurefinTextButton import hu.bbara.purefin.common.ui.PurefinTextButton
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.login.viewmodel.LoginViewModel import hu.bbara.purefin.login.viewmodel.LoginViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -47,132 +51,140 @@ fun LoginScreen(
) { ) {
val JellyfinOrange = Color(0xFFBD542E) val JellyfinOrange = Color(0xFFBD542E)
val JellyfinBg = Color(0xFF141517) val JellyfinBg = Color(0xFF141517)
val JellyfinSurface = Color(0xFF1E2124)
val TextSecondary = Color(0xFF9EA3A8) val TextSecondary = Color(0xFF9EA3A8)
// Observe ViewModel state // Observe ViewModel state
val serverUrl by viewModel.url.collectAsState() val serverUrl by viewModel.url.collectAsState()
val username by viewModel.username.collectAsState() val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState() val password by viewModel.password.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Column( if (isLoggingIn) {
modifier = modifier PurefinWaitingScreen(modifier = modifier)
.fillMaxSize() } else {
.background(JellyfinBg) Column(
.padding(24.dp) modifier = modifier
.verticalScroll(rememberScrollState()), .fillMaxSize()
horizontalAlignment = Alignment.CenterHorizontally .background(JellyfinBg)
) { .padding(24.dp)
Spacer(modifier = Modifier.weight(0.5f)) .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
// Logo Section
Box(
modifier = Modifier
.size(100.dp)
.background(JellyfinOrange, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) { ) {
Icon( Spacer(modifier = Modifier.weight(0.5f))
imageVector = Icons.Default.Movie, // Replace with actual logo resource
contentDescription = "Logo", // Logo Section
tint = Color.White, Box(
modifier = Modifier.size(60.dp) modifier = Modifier
.size(100.dp)
.background(JellyfinOrange, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Movie, // Replace with actual logo resource
contentDescription = "Logo",
tint = Color.White,
modifier = Modifier.size(60.dp)
)
}
Text(
text = "Jellyfin",
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "PERSONAL MEDIA SYSTEM",
color = TextSecondary,
fontSize = 12.sp,
letterSpacing = 2.sp
) )
}
Text( Spacer(modifier = Modifier.height(48.dp))
text = "Jellyfin",
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "PERSONAL MEDIA SYSTEM",
color = TextSecondary,
fontSize = 12.sp,
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.height(48.dp)) // Form Section
Text(
text = "Connect to Server",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)
Text(
text = "Enter your details to access your library",
color = TextSecondary,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 24.dp)
)
// Form Section PurefinComplexTextField(
Text( label = "Server URL",
text = "Connect to Server", value = serverUrl,
color = Color.White, onValueChange = { viewModel.setUrl(it) },
fontSize = 22.sp, placeholder = "http://192.168.1.100:8096",
fontWeight = FontWeight.Bold, leadingIcon = Icons.Default.Storage
modifier = Modifier.align(Alignment.Start) )
)
Text(
text = "Enter your details to access your library",
color = TextSecondary,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 24.dp)
)
PurefinComplexTextField( Spacer(modifier = Modifier.height(16.dp))
label = "Server URL",
value = serverUrl,
onValueChange = { viewModel.setUrl(it) },
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp)) PurefinComplexTextField(
label = "Username",
value = username,
onValueChange = { viewModel.setUsername(it) },
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
PurefinComplexTextField( Spacer(modifier = Modifier.height(16.dp))
label = "Username",
value = username,
onValueChange = { viewModel.setUsername(it) },
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(16.dp)) PurefinPasswordField(
label = "Password",
value = password,
onValueChange = { viewModel.setPassword(it) },
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
PurefinPasswordField( Spacer(modifier = Modifier.height(32.dp))
label = "Password",
value = password,
onValueChange = { viewModel.setPassword(it) },
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(32.dp)) PurefinTextButton(
content = { Text("Connect") },
onClick = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
PurefinTextButton( Spacer(modifier = Modifier.weight(0.5f))
content = { Text("Connect") },
onClick = { // Footer Links
coroutineScope.launch { Row(
viewModel.login() modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Search, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(18.dp))
Text(" Discover Servers", color = TextSecondary)
}
}
TextButton(onClick = {}) {
Text("Need Help?", color = TextSecondary)
} }
} }
)
Spacer(modifier = Modifier.weight(0.5f)) Spacer(modifier = Modifier.height(16.dp))
// Footer Links
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Search, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(18.dp))
Text(" Discover Servers", color = TextSecondary)
}
}
TextButton(onClick = {}) {
Text("Need Help?", color = TextSecondary)
}
} }
Spacer(modifier = Modifier.height(16.dp))
} }
} }