implement Navigation 3 and Movie Details screen

- Update Android Gradle Plugin to 8.9.1 and target SDK to 36.
- Integrate `androidx.navigation3` for Compose-based navigation management.
- Implement a centralized `NavigationManager` and `Route` system (supporting `Home` and `Movie` routes).
- Add a comprehensive Movie Details screen featuring hero images, synopsis, metadata chips, and cast lists.
- Refactor `HomePage` and related components into a dedicated `hu.bbara.purefin.app.home` package.
- Enhance `JellyfinApiClient` with methods to fetch item details and "next up" episodes.
- Update `PurefinActivity` to use `NavDisplay` for handling app navigation and backstack.
- Setup Hilt modules for providing navigation-related dependencies.
This commit is contained in:
2026-01-18 13:34:15 +01:00
parent fd100816cc
commit c5c2c105ee
28 changed files with 1022 additions and 45 deletions

View File

@@ -11,12 +11,12 @@ plugins {
android {
namespace = "hu.bbara.purefin"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "hu.bbara.purefin"
minSdk = 29
targetSdk = 35
targetSdk = 36
versionCode = 1
versionName = "1.0"
@@ -50,6 +50,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
@@ -70,6 +71,8 @@ dependencies {
implementation(libs.medi3.ui)
implementation(libs.medi3.exoplayer)
implementation(libs.medi3.ui.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -5,9 +5,21 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
@@ -16,10 +28,13 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.app.HomePage
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.client.JellyfinAuthInterceptor
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.NavigationCommand
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import hu.bbara.purefin.ui.theme.PurefinTheme
import kotlinx.coroutines.launch
@@ -30,9 +45,14 @@ import javax.inject.Inject
@AndroidEntryPoint
class PurefinActivity : ComponentActivity() {
@Inject
lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>
@Inject
lateinit var userSessionRepository: UserSessionRepository
@Inject
lateinit var navigationManager: NavigationManager
@Inject
lateinit var jellyfinApiClient: JellyfinApiClient
@@ -47,18 +67,16 @@ class PurefinActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch { init() }
configureImageLoader()
enableEdgeToEdge()
setContent {
PurefinTheme {
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
if (isLoggedIn) {
HomePage()
} else {
LoginScreen()
}
PurefinTheme() {
MainApp(
userSessionRepository = userSessionRepository,
entryBuilders = entryBuilders,
navigationManager = navigationManager
)
}
}
}
@@ -101,4 +119,50 @@ class PurefinActivity : ComponentActivity() {
private fun isDebuggable(): Boolean {
return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
}
@Composable
fun MainApp(
userSessionRepository: UserSessionRepository,
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
navigationManager: NavigationManager
) {
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
if (isLoggedIn) {
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
val appEntryProvider =
entryProvider {
entryBuilders.forEach { builder -> builder() }
}
LaunchedEffect(navigationManager, backStack) {
navigationManager.commands.collect { command ->
when (command) {
NavigationCommand.Pop -> backStack.removeLastOrNull()
is NavigationCommand.Navigate -> backStack.add(command.route)
is NavigationCommand.ReplaceAll -> {
backStack.clear()
backStack.add(command.route)
}
}
}
}
CompositionLocalProvider(LocalNavigationManager provides navigationManager) {
NavDisplay(
backStack = backStack,
modifier = Modifier.fillMaxSize(),
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = appEntryProvider
)
}
} else {
LoginScreen()
}
}
}

View File

@@ -6,6 +6,4 @@ import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class PurefinApplication : Application() {
}

View File

@@ -0,0 +1,119 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import org.jellyfin.sdk.model.UUID
@Composable
fun MovieCard(
movieId: String,
modifier: Modifier = Modifier,
viewModel: MovieScreenViewModel = hiltViewModel()
) {
LaunchedEffect(movieId) {
viewModel.selectMovie(UUID.fromString(movieId))
}
val movieItem = viewModel.movie.collectAsState()
if (movieItem.value != null) {
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(MovieBackgroundDark)
) {
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
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
MovieHero(
movie = movieItem.value!!,
height = 400.dp,
isWide = false,
modifier = Modifier.fillMaxWidth()
)
MovieDetails(
movie = movieItem.value!!,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
)
}
}
MovieTopBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
if (!isWide) {
FloatingPlayButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
}
}
} else {
Box(
modifier = modifier
.fillMaxSize()
.background(MovieBackgroundDark),
contentAlignment = Alignment.Center
) {
Text(
text = "Loading...",
color = Color.White
)
}
}
}

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.ui.graphics.Color
internal val MoviePrimary = Color(0xFFDDA73C)
internal val MovieBackgroundDark = Color(0xFF141414)
internal val MovieSurfaceDark = Color(0xFF1F1F1F)
internal val MovieSurfaceBorder = Color(0x1AFFFFFF)
internal val MovieMuted = Color(0x99FFFFFF)
internal val MovieMutedStrong = Color(0x66FFFFFF)

View File

@@ -0,0 +1,452 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@Composable
internal fun MovieTopBar(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast")
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More")
}
}
}
@Composable
private fun GhostIconButton(
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(MovieBackgroundDark.copy(alpha = 0.4f))
.clickable { },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = Color.White
)
}
}
@Composable
internal fun MovieHero(
movie: MovieUiModel,
height: Dp,
isWide: Boolean,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.height(height)
.background(MovieBackgroundDark)
) {
AsyncImage(
model = movie.heroImageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
MovieBackgroundDark.copy(alpha = 0.4f),
MovieBackgroundDark
)
)
)
)
if (isWide) {
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
MovieBackgroundDark.copy(alpha = 0.8f)
)
)
)
)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
PlayButton(size = if (isWide) 96.dp else 80.dp)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun MovieDetails(
movie: MovieUiModel,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(
text = movie.title,
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp
)
Spacer(modifier = Modifier.height(16.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MetaChip(text = movie.year)
MetaChip(text = movie.rating)
MetaChip(text = movie.runtime)
MetaChip(
text = movie.format,
background = MoviePrimary.copy(alpha = 0.2f),
border = MoviePrimary.copy(alpha = 0.3f),
textColor = MoviePrimary
)
}
Spacer(modifier = Modifier.height(24.dp))
PlaybackSettings(movie = movie)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Synopsis",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = movie.synopsis,
color = MovieMuted,
fontSize = 15.sp,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(24.dp))
ActionButtons()
Spacer(modifier = Modifier.height(28.dp))
Text(
text = "Cast",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = movie.cast)
}
}
@Composable
private fun MetaChip(
text: String,
background: Color = Color.White.copy(alpha = 0.1f),
border: Color = Color.Transparent,
textColor: Color = Color.White
) {
Box(
modifier = Modifier
.height(28.dp)
.wrapContentHeight(Alignment.CenterVertically)
.clip(RoundedCornerShape(6.dp))
.background(background)
.border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = textColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun PlaybackSettings(movie: MovieUiModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MovieSurfaceDark)
.border(1.dp, MovieSurfaceBorder, RoundedCornerShape(16.dp))
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = null,
tint = MoviePrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Playback Settings",
color = MovieMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SettingDropdown(
label = "Audio Track",
icon = Icons.Outlined.VolumeUp,
value = movie.audioTrack
)
SettingDropdown(
label = "Subtitles",
icon = Icons.Outlined.ClosedCaption,
value = movie.subtitles
)
}
}
}
@Composable
private fun SettingDropdown(
label: String,
icon: ImageVector,
value: String
) {
Column {
Text(
text = label,
color = MovieMutedStrong,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(MovieBackgroundDark)
.border(1.dp, MovieSurfaceBorder, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = null, tint = MovieMutedStrong)
Spacer(modifier = Modifier.width(10.dp))
Text(text = value, color = Color.White, fontSize = 14.sp)
}
Icon(imageVector = Icons.Outlined.ExpandMore, contentDescription = null, tint = MovieMutedStrong)
}
}
}
@Composable
private fun ActionButtons() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ActionButton(
text = "Watchlist",
icon = Icons.Outlined.Add,
modifier = Modifier.weight(1f)
)
ActionButton(
text = "Download",
icon = Icons.Outlined.Download,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun ActionButton(
text: String,
icon: ImageVector,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.05f))
.border(1.dp, MovieSurfaceBorder, RoundedCornerShape(12.dp))
.clickable { },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun CastRow(cast: List<CastMember>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast) { member ->
Column(modifier = Modifier.width(96.dp)) {
Box(
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(MovieSurfaceDark)
) {
if (member.imageUrl == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White.copy(alpha = 0.05f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = MovieMutedStrong
)
}
} else {
AsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = MovieMutedStrong,
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun PlayButton(
size: Dp,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.shadow(24.dp, CircleShape)
.clip(CircleShape)
.background(MoviePrimary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = MovieBackgroundDark,
modifier = Modifier.size(42.dp)
)
}
}
@Composable
internal fun FloatingPlayButton(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(56.dp)
.shadow(20.dp, CircleShape)
.clip(CircleShape)
.background(MoviePrimary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = MovieBackgroundDark
)
}
}

View File

@@ -0,0 +1,20 @@
package hu.bbara.purefin.app.content.movie
data class CastMember(
val name: String,
val role: String,
val imageUrl: String?
)
data class MovieUiModel(
val title: String,
val year: String,
val rating: String,
val runtime: String,
val format: String,
val synopsis: String,
val heroImageUrl: String,
val audioTrack: String,
val subtitles: String,
val cast: List<CastMember>
)

View File

@@ -0,0 +1,22 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun MovieScreen(
movieId: String,
modifier: Modifier = Modifier
) {
MovieCard(
movieId = movieId,
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
private fun MovieScreenPreview() {
MovieScreen(movieId = "test")
}

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.app.content.movie
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.navigation.Route
/**
* Navigation 3 entry definition for the Home section.
*/
fun EntryProviderScope<Route>.homeSection() {
entry<Route.Movie> {
MovieScreen(movieId = it.movieId)
}
}

View File

@@ -0,0 +1,91 @@
package hu.bbara.purefin.app.content.movie
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class MovieScreenViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository
): ViewModel() {
private val _movie = MutableStateFlow<MovieUiModel?>(null)
val movie = _movie.asStateFlow()
fun selectMovie(movieId: UUID) {
viewModelScope.launch {
val movieInfo = jellyfinApiClient.getItemInfo(movieId)
if (movieInfo == null) {
_movie.value = null
return@launch
}
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
"https://jellyfin.bbara.hu"
}
_movie.value = movieInfo.toUiModel(serverUrl)
}
}
private fun BaseItemDto.toUiModel(serverUrl: String): MovieUiModel {
val year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty()
val rating = officialRating ?: "NR"
val runtime = formatRuntime(runTimeTicks)
val format = container?.uppercase() ?: "VIDEO"
val synopsis = overview ?: "No synopsis available."
val heroImageUrl = id?.let { itemId ->
JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = itemId,
type = ImageType.BACKDROP
)
} ?: ""
val cast = people.orEmpty().map { it.toCastMember() }
return MovieUiModel(
title = name ?: "Unknown title",
year = year,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
heroImageUrl = heroImageUrl,
audioTrack = "Default",
subtitles = "Unknown",
cast = cast
)
}
private fun BaseItemPerson.toCastMember(): CastMember {
return CastMember(
name = name ?: "Unknown",
role = role ?: "",
imageUrl = null
)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) {
"${hours}h ${minutes}m"
} else {
"${minutes}m"
}
}
}

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.app.home
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.navigation.Route
/**
* Navigation 3 entry definition for the Home section.
*/
fun EntryProviderScope<Route>.homeSection() {
entry<Route.Home> {
HomePage()
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app
package hu.bbara.purefin.app.home
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -14,11 +14,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.HomeContent
import hu.bbara.purefin.app.home.HomeDrawerContent
import hu.bbara.purefin.app.home.HomeMockData
import hu.bbara.purefin.app.home.HomeTopBar
import hu.bbara.purefin.app.home.rememberHomeColors
import hu.bbara.purefin.app.home.ui.HomeContent
import hu.bbara.purefin.app.home.ui.HomeDrawerContent
import hu.bbara.purefin.app.home.ui.HomeMockData
import hu.bbara.purefin.app.home.ui.HomeTopBar
import hu.bbara.purefin.app.home.ui.rememberHomeColors
import kotlinx.coroutines.launch
@Composable

View File

@@ -1,13 +1,15 @@
package hu.bbara.purefin.app
package hu.bbara.purefin.app.home
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ContinueWatchingItem
import hu.bbara.purefin.app.home.LibraryItem
import hu.bbara.purefin.app.home.PosterItem
import hu.bbara.purefin.app.home.ui.ContinueWatchingItem
import hu.bbara.purefin.app.home.ui.LibraryItem
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import javax.inject.Inject
@HiltViewModel
class HomePageViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager,
private val jellyfinApiClient: JellyfinApiClient
) : ViewModel() {
@@ -42,6 +45,19 @@ class HomePageViewModel @Inject constructor(
loadHomePageData()
}
fun onMovieSelected(movieId: String) {
navigationManager.navigate(Route.Movie(movieId))
}
fun onBack() {
navigationManager.pop()
}
fun onGoHome() {
navigationManager.replaceAll(Route.Home)
}
fun loadContinueWatching() {
viewModelScope.launch {
val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching()
@@ -103,7 +119,8 @@ class HomePageViewModel @Inject constructor(
val libraryPosterItems = libraryItems.map {
PosterItem(
id = it.id,
title = it.name ?: "Unknown"
title = it.name ?: "Unknown",
type = it.type
)
}
_libraryItems.update { currentMap ->
@@ -132,16 +149,19 @@ class HomePageViewModel @Inject constructor(
when (it.type) {
BaseItemKind.MOVIE -> PosterItem(
id = it.id,
title = it.name ?: "Unknown"
title = it.name ?: "Unknown",
type = it.type
)
BaseItemKind.EPISODE -> PosterItem(
id = it.seriesId!!,
title = it.seriesName ?: "Unknown"
title = it.seriesName ?: "Unknown",
type = it.type
)
BaseItemKind.SEASON -> PosterItem(
id = it.seriesId!!,
title = it.seriesName ?: "Unknown"
title = it.seriesName ?: "Unknown",
type = it.type
)
else -> null
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
@@ -12,7 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.HomePageViewModel
import hu.bbara.purefin.app.home.HomePageViewModel
@Composable
fun HomeContent(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.HomePageViewModel
import hu.bbara.purefin.app.home.HomePageViewModel
@Composable
fun HomeDrawerContent(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Home

View File

@@ -1,8 +1,9 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
data class ContinueWatchingItem(
val id: UUID,
@@ -20,7 +21,8 @@ data class LibraryItem(
data class PosterItem(
val id: UUID,
val title: String
val title: String,
val type: BaseItemKind
)
data class HomeNavItem(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import android.content.Intent
import androidx.compose.foundation.background
@@ -36,9 +36,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil3.compose.AsyncImage
import hu.bbara.purefin.app.home.HomePageViewModel
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.player.PlayerActivity
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import kotlin.math.nextUp
@@ -122,9 +125,9 @@ fun ContinueWatchingCard(
Button(
modifier = Modifier.align(Alignment.BottomEnd),
onClick = {
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", item.id.toString())
context.startActivity(intent)
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", item.id.toString())
context.startActivity(intent)
}) {
Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = "Play")
}
@@ -173,7 +176,7 @@ fun LibraryPosterSection(
) { item ->
PosterCard(
item = item,
colors = colors
colors = colors,
)
}
}
@@ -183,8 +186,18 @@ fun LibraryPosterSection(
fun PosterCard(
item: PosterItem,
colors: HomeColors,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel()
) {
fun openItem(posterItem: PosterItem)
{
when (posterItem.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
else -> {}
}
}
Box(
modifier = modifier
.width(144.dp)
@@ -192,6 +205,7 @@ fun PosterCard(
.shadow(10.dp, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
.background(colors.card)
.clickable(onClick = { openItem(item) })
) {
AsyncImage(
model = JellyfinImageHelper.toImageUrl(url = "https://jellyfin.bbara.hu", itemId = item.id, type = ImageType.PRIMARY),

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home
package hu.bbara.purefin.app.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.HomePageViewModel
import hu.bbara.purefin.app.home.HomePageViewModel
@Composable
fun HomeTopBar(

View File

@@ -9,6 +9,7 @@ import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
@@ -24,6 +25,7 @@ import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import java.util.UUID
import javax.inject.Inject
@@ -146,6 +148,31 @@ class JellyfinApiClient @Inject constructor(
return response.content
}
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? {
if (!ensureConfigured()) {
return null
}
val result = api.userLibraryApi.getItem(
itemId = mediaId,
userId = getUserId()
)
Log.d("getItemInfo response: {}", result.content.toString())
return result.content
}
suspend fun getNextUpEpisode(mediaId: UUID): BaseItemDto {
if (!ensureConfigured()) {
throw IllegalStateException("Not configured")
}
val getNextUpRequest = GetNextUpRequest(
userId = getUserId(),
seriesId = mediaId,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
Log.d("getNextUpEpisode response: {}", result.content.toString())
return result.content.items.first()
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> {
val result = api.mediaInfoApi
.getPostedPlaybackInfo(

View File

@@ -0,0 +1,45 @@
package hu.bbara.purefin.navigation
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
sealed interface NavigationCommand {
data class Navigate(val route: Route) : NavigationCommand
data class ReplaceAll(val route: Route) : NavigationCommand
data object Pop : NavigationCommand
}
interface NavigationManager {
val commands: SharedFlow<NavigationCommand>
fun navigate(route: Route)
fun replaceAll(route: Route)
fun pop()
}
class DefaultNavigationManager : NavigationManager {
private val _commands =
MutableSharedFlow<NavigationCommand>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val commands: SharedFlow<NavigationCommand> = _commands.asSharedFlow()
override fun navigate(route: Route) {
_commands.tryEmit(NavigationCommand.Navigate(route))
}
override fun replaceAll(route: Route) {
_commands.tryEmit(NavigationCommand.ReplaceAll(route))
}
override fun pop() {
_commands.tryEmit(NavigationCommand.Pop)
}
}
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.navigation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NavigationManagerModule {
@Provides
@Singleton
fun provideNavigationManager(): NavigationManager = DefaultNavigationManager()
}

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.navigation
import androidx.navigation3.runtime.EntryProviderScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(ActivityRetainedComponent::class)
object NavigationModule {
@IntoSet
@Provides
fun provideAppEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
appRouteEntryBuilder()
}
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
sealed interface Route : NavKey {
@Serializable
data object Home: Route
@Serializable
data class Movie(val movieId: String) : Route
}

View File

@@ -0,0 +1,14 @@
package hu.bbara.purefin.navigation
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.app.content.movie.MovieScreen
import hu.bbara.purefin.app.home.HomePage
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.Home> {
HomePage()
}
entry<Route.Movie> {
MovieScreen(movieId = it.movieId)
}
}