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