mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,4 @@ import dagger.hilt.android.HiltAndroidApp
|
||||
@HiltAndroidApp
|
||||
class PurefinApplication : Application() {
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(
|
||||
@@ -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(
|
||||
@@ -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
|
||||
@@ -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(
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
@@ -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
|
||||
@@ -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(
|
||||
@@ -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(
|
||||
|
||||
@@ -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") }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
12
app/src/main/java/hu/bbara/purefin/navigation/Route.kt
Normal file
12
app/src/main/java/hu/bbara/purefin/navigation/Route.kt
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.7.3"
|
||||
agp = "8.9.1"
|
||||
coreKtx = "1.15.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
@@ -18,6 +18,7 @@ okhttp = "4.12.0"
|
||||
foundation = "1.10.1"
|
||||
coil = "3.3.0"
|
||||
media3 = "1.9.0"
|
||||
nav3Core = "1.0.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -26,6 +27,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
@@ -50,6 +52,8 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp"
|
||||
medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
|
||||
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
|
||||
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
|
||||
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
|
||||
|
||||
|
||||
[plugins]
|
||||
|
||||
Reference in New Issue
Block a user