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

View File

@@ -5,9 +5,21 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope 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.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.disk.DiskCache import coil3.disk.DiskCache
@@ -16,10 +28,13 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.app.HomePage
import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.client.JellyfinAuthInterceptor import hu.bbara.purefin.client.JellyfinAuthInterceptor
import hu.bbara.purefin.login.ui.LoginScreen 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.session.UserSessionRepository
import hu.bbara.purefin.ui.theme.PurefinTheme import hu.bbara.purefin.ui.theme.PurefinTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -30,9 +45,14 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class PurefinActivity : ComponentActivity() { class PurefinActivity : ComponentActivity() {
@Inject
lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>
@Inject @Inject
lateinit var userSessionRepository: UserSessionRepository lateinit var userSessionRepository: UserSessionRepository
@Inject
lateinit var navigationManager: NavigationManager
@Inject @Inject
lateinit var jellyfinApiClient: JellyfinApiClient lateinit var jellyfinApiClient: JellyfinApiClient
@@ -47,18 +67,16 @@ class PurefinActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launch { init() } lifecycleScope.launch { init() }
configureImageLoader() configureImageLoader()
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PurefinTheme { PurefinTheme() {
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false) MainApp(
if (isLoggedIn) { userSessionRepository = userSessionRepository,
HomePage() entryBuilders = entryBuilders,
} else { navigationManager = navigationManager
LoginScreen() )
}
} }
} }
} }
@@ -101,4 +119,50 @@ class PurefinActivity : ComponentActivity() {
private fun isDebuggable(): Boolean { private fun isDebuggable(): Boolean {
return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 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 @HiltAndroidApp
class PurefinApplication : Application() { 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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -14,11 +14,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.HomeContent import hu.bbara.purefin.app.home.ui.HomeContent
import hu.bbara.purefin.app.home.HomeDrawerContent import hu.bbara.purefin.app.home.ui.HomeDrawerContent
import hu.bbara.purefin.app.home.HomeMockData import hu.bbara.purefin.app.home.ui.HomeMockData
import hu.bbara.purefin.app.home.HomeTopBar import hu.bbara.purefin.app.home.ui.HomeTopBar
import hu.bbara.purefin.app.home.rememberHomeColors import hu.bbara.purefin.app.home.ui.rememberHomeColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @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.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ContinueWatchingItem import hu.bbara.purefin.app.home.ui.ContinueWatchingItem
import hu.bbara.purefin.app.home.LibraryItem import hu.bbara.purefin.app.home.ui.LibraryItem
import hu.bbara.purefin.app.home.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient 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 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
@@ -23,6 +25,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomePageViewModel @Inject constructor( class HomePageViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager,
private val jellyfinApiClient: JellyfinApiClient private val jellyfinApiClient: JellyfinApiClient
) : ViewModel() { ) : ViewModel() {
@@ -42,6 +45,19 @@ class HomePageViewModel @Inject constructor(
loadHomePageData() loadHomePageData()
} }
fun onMovieSelected(movieId: String) {
navigationManager.navigate(Route.Movie(movieId))
}
fun onBack() {
navigationManager.pop()
}
fun onGoHome() {
navigationManager.replaceAll(Route.Home)
}
fun loadContinueWatching() { fun loadContinueWatching() {
viewModelScope.launch { viewModelScope.launch {
val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching() val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching()
@@ -103,7 +119,8 @@ class HomePageViewModel @Inject constructor(
val libraryPosterItems = libraryItems.map { val libraryPosterItems = libraryItems.map {
PosterItem( PosterItem(
id = it.id, id = it.id,
title = it.name ?: "Unknown" title = it.name ?: "Unknown",
type = it.type
) )
} }
_libraryItems.update { currentMap -> _libraryItems.update { currentMap ->
@@ -132,16 +149,19 @@ class HomePageViewModel @Inject constructor(
when (it.type) { when (it.type) {
BaseItemKind.MOVIE -> PosterItem( BaseItemKind.MOVIE -> PosterItem(
id = it.id, id = it.id,
title = it.name ?: "Unknown" title = it.name ?: "Unknown",
type = it.type
) )
BaseItemKind.EPISODE -> PosterItem( BaseItemKind.EPISODE -> PosterItem(
id = it.seriesId!!, id = it.seriesId!!,
title = it.seriesName ?: "Unknown" title = it.seriesName ?: "Unknown",
type = it.type
) )
BaseItemKind.SEASON -> PosterItem( BaseItemKind.SEASON -> PosterItem(
id = it.seriesId!!, id = it.seriesId!!,
title = it.seriesName ?: "Unknown" title = it.seriesName ?: "Unknown",
type = it.type
) )
else -> null 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.background
import androidx.compose.foundation.border 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.background
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -12,7 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.HomePageViewModel import hu.bbara.purefin.app.home.HomePageViewModel
@Composable @Composable
fun HomeContent( 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.background
import androidx.compose.foundation.clickable 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.HomePageViewModel import hu.bbara.purefin.app.home.HomePageViewModel
@Composable @Composable
fun HomeDrawerContent( 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.Icons
import androidx.compose.material.icons.outlined.Home 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
data class ContinueWatchingItem( data class ContinueWatchingItem(
val id: UUID, val id: UUID,
@@ -20,7 +21,8 @@ data class LibraryItem(
data class PosterItem( data class PosterItem(
val id: UUID, val id: UUID,
val title: String val title: String,
val type: BaseItemKind
) )
data class HomeNavItem( 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 android.content.Intent
import androidx.compose.foundation.background 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.text.style.TextOverflow
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
import hu.bbara.purefin.app.home.HomePageViewModel
import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import kotlin.math.nextUp import kotlin.math.nextUp
@@ -122,9 +125,9 @@ fun ContinueWatchingCard(
Button( Button(
modifier = Modifier.align(Alignment.BottomEnd), modifier = Modifier.align(Alignment.BottomEnd),
onClick = { onClick = {
val intent = Intent(context, PlayerActivity::class.java) val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", item.id.toString()) intent.putExtra("MEDIA_ID", item.id.toString())
context.startActivity(intent) context.startActivity(intent)
}) { }) {
Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = "Play") Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = "Play")
} }
@@ -173,7 +176,7 @@ fun LibraryPosterSection(
) { item -> ) { item ->
PosterCard( PosterCard(
item = item, item = item,
colors = colors colors = colors,
) )
} }
} }
@@ -183,8 +186,18 @@ fun LibraryPosterSection(
fun PosterCard( fun PosterCard(
item: PosterItem, item: PosterItem,
colors: HomeColors, 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( Box(
modifier = modifier modifier = modifier
.width(144.dp) .width(144.dp)
@@ -192,6 +205,7 @@ fun PosterCard(
.shadow(10.dp, RoundedCornerShape(14.dp)) .shadow(10.dp, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.background(colors.card) .background(colors.card)
.clickable(onClick = { openItem(item) })
) { ) {
AsyncImage( AsyncImage(
model = JellyfinImageHelper.toImageUrl(url = "https://jellyfin.bbara.hu", itemId = item.id, type = ImageType.PRIMARY), 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.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable 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.background
import androidx.compose.foundation.layout.Arrangement 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.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.HomePageViewModel import hu.bbara.purefin.app.home.HomePageViewModel
@Composable @Composable
fun HomeTopBar( 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.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi 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.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi 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.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.request.GetItemsRequest 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 org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -146,6 +148,31 @@ class JellyfinApiClient @Inject constructor(
return response.content 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> { suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> {
val result = api.mediaInfoApi val result = api.mediaInfoApi
.getPostedPlaybackInfo( .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)
}
}

View File

@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.7.3" agp = "8.9.1"
coreKtx = "1.15.0" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" junitVersion = "1.2.1"
@@ -18,6 +18,7 @@ okhttp = "4.12.0"
foundation = "1.10.1" foundation = "1.10.1"
coil = "3.3.0" coil = "3.3.0"
media3 = "1.9.0" media3 = "1.9.0"
nav3Core = "1.0.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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-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-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-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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } 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-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
medi3-ui = { group = "androidx.media3", name = "media3-ui", 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"} 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] [plugins]