Added HomePage Loading without Image loading

This commit is contained in:
2026-01-17 14:53:02 +01:00
parent 3fd571824f
commit a05497c7bc
19 changed files with 1044 additions and 94 deletions

View File

@@ -62,6 +62,9 @@ dependencies {
implementation(libs.hilt) implementation(libs.hilt)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.datastore) implementation(libs.datastore)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
implementation(libs.androidx.compose.foundation)
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

@@ -4,24 +4,12 @@ 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.lifecycleScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.login.ui.LoginScreen import hu.bbara.purefin.login.ui.LoginScreen
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
@@ -33,58 +21,26 @@ class PurefinActivity : ComponentActivity() {
@Inject @Inject
lateinit var userSessionRepository: UserSessionRepository lateinit var userSessionRepository: UserSessionRepository
@Inject
lateinit var jellyfinApiClient: JellyfinApiClient
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launch { init() }
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PurefinTheme { PurefinTheme {
val scope = rememberCoroutineScope()
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false) val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
if (isLoggedIn) { if (isLoggedIn) {
HomeScreen(logout = { scope.launch { userSessionRepository.setLoggedIn(false) } }) HomePage()
} else { } else {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> LoginScreen()
LoginScreen(modifier = Modifier.padding(innerPadding))
}
} }
} }
} }
} }
}
@Composable private suspend fun init() {
fun HomeScreen(modifier: Modifier = Modifier, jellyfinApiClient.updateApiClient()
logout: () -> Unit) {
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Welcome to PureFin", style = MaterialTheme.typography.headlineLarge)
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(5) { index ->
StreamingCard("Show ${index + 1}", "Description for show ${index + 1}")
}
}
Button(onClick = logout) {
Text("Logout")
}
}
}
}
@Composable
fun StreamingCard(title: String, description: String) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(description, style = MaterialTheme.typography.bodySmall)
}
} }
} }

View File

@@ -0,0 +1,75 @@
package hu.bbara.purefin.app
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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 kotlinx.coroutines.launch
@Composable
fun HomePage(
viewModel: HomePageViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val colors = rememberHomeColors()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope()
val continueWatching = viewModel.continueWatching.collectAsState()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier
.width(280.dp)
.fillMaxSize(),
drawerContainerColor = colors.drawerBackground,
drawerContentColor = colors.textPrimary
) {
HomeDrawerContent(
title = "Jellyfin",
subtitle = "Library Dashboard",
colors = colors,
primaryNavItems = HomeMockData.primaryNavItems,
secondaryNavItems = HomeMockData.secondaryNavItems,
user = HomeMockData.user
)
}
}
) {
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = colors.background,
contentColor = colors.textPrimary,
topBar = {
HomeTopBar(
title = "Home",
colors = colors,
onMenuClick = { coroutineScope.launch { drawerState.open() } }
)
}
) { innerPadding ->
HomeContent(
colors = colors,
continueWatching = continueWatching.value,
modifier = Modifier.padding(innerPadding)
)
}
}
}

View File

@@ -0,0 +1,111 @@
package hu.bbara.purefin.app
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.client.JellyfinApiClient
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import javax.inject.Inject
@HiltViewModel
class HomePageViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient
) : ViewModel() {
init {
loadHomePageData()
}
private val _continueWatching = MutableStateFlow<List<ContinueWatchingItem>>(emptyList())
val continueWatching = _continueWatching.asStateFlow()
private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList())
val libraries = _libraries.asStateFlow()
private val _libraryContent = MutableStateFlow<Map<UUID, List<PosterItem>>>(emptyMap())
val libraryContent = _libraryContent.asStateFlow()
fun loadContinueWatching() {
viewModelScope.launch {
val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching()
_continueWatching.value = continueWatching.map {
if (it.type == BaseItemKind.EPISODE) {
ContinueWatchingItem(
primaryText = it.seriesName!!,
secondaryText = it.name!!,
progress = it.userData!!.playedPercentage!!.toFloat(),
colors = listOf(Color.Red, Color.Green)
)
} else {
ContinueWatchingItem(
primaryText = it.name!!,
secondaryText = it.premiereDate!!.format(DateTimeFormatter.ofLocalizedDate(
FormatStyle.MEDIUM)),
progress = it.userData!!.playedPercentage!!.toFloat(),
colors = listOf(Color.Red, Color.Green)
)
}
}
}
}
fun loadLibraries() {
viewModelScope.launch {
val libraries: List<BaseItemDto> = jellyfinApiClient.getLibraries()
val mappedLibraries = libraries.map {
LibraryItem(
name = it.name!!,
id = it.id
)
}
_libraries.value = mappedLibraries
mappedLibraries.forEach { library ->
loadLibrary(library.id)
}
}
}
fun loadLibrary(libraryId: UUID) {
if (_libraryContent.value.containsKey(libraryId)) return
viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibrary(libraryId)
val posterItems = libraryItems.map {
PosterItem(
title = it.name ?: "Unknown",
colors = listOf(Color.Blue, Color.Cyan),
isLatest = false
)
}
_libraryContent.update { currentMap ->
currentMap + (libraryId to posterItems)
}
}
}
fun loadHomePageData() {
loadContinueWatching()
loadLibraries()
}
fun logout() {
viewModelScope.launch {
userSessionRepository.setLoggedIn(false)
}
}
}

View File

@@ -0,0 +1,41 @@
package hu.bbara.purefin.app.home
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
@Composable
fun HomeAvatar(
size: Dp,
borderWidth: Dp,
borderColor: Color,
backgroundColor: Color,
icon: ImageVector,
iconTint: Color,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.border(borderWidth, borderColor, CircleShape)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint
)
}
}

View File

@@ -0,0 +1,54 @@
package hu.bbara.purefin.app.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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
@Composable
fun HomeContent(
viewModel: HomePageViewModel = hiltViewModel(),
colors: HomeColors,
continueWatching: List<ContinueWatchingItem>,
modifier: Modifier = Modifier
) {
val libraries by viewModel.libraries.collectAsState()
val libraryContent by viewModel.libraryContent.collectAsState()
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(colors.background)
) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
ContinueWatchingSection(
items = continueWatching,
colors = colors
)
}
items(libraries) { item ->
LibraryPosterSection(
title = item.name,
items = libraryContent[item.id] ?: emptyList(),
action = "See All",
colors = colors
)
}
item {
Spacer(modifier = Modifier.height(32.dp))
}
}
}

View File

@@ -0,0 +1,206 @@
package hu.bbara.purefin.app.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Person
import androidx.compose.material3.HorizontalDivider
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.graphics.Color
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 hu.bbara.purefin.app.HomePageViewModel
@Composable
fun HomeDrawerContent(
title: String,
subtitle: String,
colors: HomeColors,
primaryNavItems: List<HomeNavItem>,
secondaryNavItems: List<HomeNavItem>,
user: HomeUser,
modifier: Modifier = Modifier,
onNavItemClick: (HomeNavItem) -> Unit = {}
) {
Column(modifier = modifier.fillMaxSize()) {
HomeDrawerHeader(
title = title,
subtitle = subtitle,
colors = colors
)
HomeDrawerNav(
primaryItems = primaryNavItems,
secondaryItems = secondaryNavItems,
colors = colors,
onNavItemClick = onNavItemClick
)
Spacer(modifier = Modifier.weight(1f))
HomeDrawerFooter(user = user, colors = colors)
}
}
@Composable
fun HomeDrawerHeader(
title: String,
subtitle: String,
colors: HomeColors,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 16.dp, top = 24.dp, bottom = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.size(40.dp)
.background(colors.primary, RoundedCornerShape(12.dp)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary
)
}
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = title,
color = colors.textPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = subtitle,
color = colors.textSecondary,
fontSize = 12.sp
)
}
}
HorizontalDivider(color = colors.textSecondary.copy(alpha = 0.2f))
}
@Composable
fun HomeDrawerNav(
primaryItems: List<HomeNavItem>,
secondaryItems: List<HomeNavItem>,
colors: HomeColors,
modifier: Modifier = Modifier,
onNavItemClick: (HomeNavItem) -> Unit = {}
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
primaryItems.forEach { item ->
HomeDrawerNavItem(item = item, colors = colors) { onNavItemClick(item) }
}
if (secondaryItems.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 12.dp),
color = colors.divider
)
secondaryItems.forEach { item ->
HomeDrawerNavItem(item = item, colors = colors) { onNavItemClick(item) }
}
}
}
}
@Composable
fun HomeDrawerNavItem(
item: HomeNavItem,
colors: HomeColors,
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) {
val background = if (item.selected) colors.primary.copy(alpha = 0.12f) else Color.Transparent
val tint = if (item.selected) colors.primary else colors.textSecondary
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.background(background, RoundedCornerShape(12.dp))
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = item.icon,
contentDescription = item.label,
tint = tint
)
Text(
text = item.label,
color = if (item.selected) colors.primary else colors.textPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 12.dp)
)
}
}
@Composable
fun HomeDrawerFooter (
viewModel: HomePageViewModel = hiltViewModel(),
user: HomeUser,
colors: HomeColors,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.background(colors.drawerFooterBackground, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeAvatar(
size = 32.dp,
borderWidth = 1.dp,
borderColor = Color.White.copy(alpha = 0.1f),
backgroundColor = colors.avatarBackground,
icon = Icons.Outlined.Person,
iconTint = Color.White
)
Column(modifier = Modifier.padding(start = 12.dp)
.clickable {viewModel.logout()}) {
Text(
text = user.name,
color = colors.textPrimary,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = user.plan,
color = colors.textSecondary,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@@ -0,0 +1,24 @@
package hu.bbara.purefin.app.home
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tv
object HomeMockData {
val user = HomeUser(name = "Alex User", plan = "Premium Account")
val primaryNavItems = listOf(
HomeNavItem(label = "Home", icon = Icons.Outlined.Home, selected = true),
HomeNavItem(label = "Movies", icon = Icons.Outlined.Movie),
HomeNavItem(label = "TV Shows", icon = Icons.Outlined.Tv),
HomeNavItem(label = "Search", icon = Icons.Outlined.Search)
)
val secondaryNavItems = listOf(
HomeNavItem(label = "Settings", icon = Icons.Outlined.Settings)
)
}

View File

@@ -0,0 +1,34 @@
package hu.bbara.purefin.app.home
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID
data class ContinueWatchingItem(
val primaryText: String,
val secondaryText: String,
val progress: Float,
val colors: List<Color>
)
data class LibraryItem(
val name: String,
val id: UUID
)
data class PosterItem(
val title: String,
val isLatest: Boolean,
val colors: List<Color>
)
data class HomeNavItem(
val label: String,
val icon: ImageVector,
val selected: Boolean = false
)
data class HomeUser(
val name: String,
val plan: String
)

View File

@@ -0,0 +1,238 @@
package hu.bbara.purefin.app.home
import androidx.compose.foundation.background
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ContinueWatchingSection(
items: List<ContinueWatchingItem>,
colors: HomeColors,
modifier: Modifier = Modifier
) {
SectionHeader(
title = "Continue Watching",
action = null,
colors = colors
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(items) { item ->
ContinueWatchingCard(
item = item,
colors = colors
)
}
}
}
@Composable
fun ContinueWatchingCard(
item: ContinueWatchingItem,
colors: HomeColors,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.width(280.dp)
.wrapContentHeight()
) {
Box(
modifier = Modifier
.aspectRatio(16f / 9f)
.shadow(12.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(colors.card)
) {
Box(
modifier = Modifier
.matchParentSize()
.background(Brush.linearGradient(item.colors))
)
Box(
modifier = Modifier
.matchParentSize()
.background(Color.Black.copy(alpha = 0.2f))
)
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(4.dp)
.background(Color.White.copy(alpha = 0.2f))
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(item.progress)
.background(colors.primary)
)
}
}
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text = item.primaryText,
color = colors.textPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.secondaryText,
color = colors.textSecondary,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
fun LibraryPosterSection(
title: String,
items: List<PosterItem>,
action: String?,
colors: HomeColors,
modifier: Modifier = Modifier
) {
SectionHeader(
title = title,
action = action,
colors = colors
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(items) { item ->
PosterCard(
item = item,
colors = colors
)
}
}
}
@Composable
fun PosterCard(
item: PosterItem,
colors: HomeColors,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.width(144.dp)
.aspectRatio(2f / 3f)
.shadow(10.dp, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
.background(colors.card)
) {
Box(
modifier = Modifier
.matchParentSize()
.background(Brush.linearGradient(item.colors))
)
if (item.isLatest) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.clip(RoundedCornerShape(6.dp))
.background(colors.primary)
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = "LATEST",
color = colors.onPrimary,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
)
)
)
Text(
text = item.title,
color = colors.textPrimary,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(10.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
fun SectionHeader(
title: String,
action: String?,
colors: HomeColors,
modifier: Modifier = Modifier,
onActionClick: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
color = colors.textPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
if (action != null) {
Text(
text = action,
color = colors.primary,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable { onActionClick() }
)
}
}
}

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.app.home
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
data class HomeColors(
val primary: Color,
val onPrimary: Color,
val background: Color,
val drawerBackground: Color,
val card: Color,
val textPrimary: Color,
val textSecondary: Color,
val divider: Color,
val avatarBackground: Color,
val avatarBorder: Color,
val drawerFooterBackground: Color
)
@Composable
fun rememberHomeColors(isDark: Boolean = isSystemInDarkTheme()): HomeColors {
val primary = Color(0xFFDDA73C)
return remember(isDark) {
HomeColors(
primary = primary,
onPrimary = Color(0xFF17171C),
background = if (isDark) Color(0xFF17171C) else Color(0xFFF8F7F6),
drawerBackground = if (isDark) Color(0xFF1E1E24) else Color(0xFFF8F7F6),
card = Color(0xFF24242B),
textPrimary = if (isDark) Color.White else Color(0xFF141517),
textSecondary = if (isDark) Color(0xFF9AA0A6) else Color(0xFF6B7280),
divider = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.08f),
avatarBackground = Color(0xFF3A3A46),
avatarBorder = primary.copy(alpha = 0.3f),
drawerFooterBackground = if (isDark) Color.Black.copy(alpha = 0.2f) else Color.Black.copy(alpha = 0.05f)
)
}
}

View File

@@ -0,0 +1,87 @@
package hu.bbara.purefin.app.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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
@Composable
fun HomeTopBar(
viewModel: HomePageViewModel = hiltViewModel(),
title: String,
colors: HomeColors,
onMenuClick: () -> Unit,
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {
HomeAvatar(
size = 36.dp,
borderWidth = 2.dp,
borderColor = colors.avatarBorder,
backgroundColor = colors.avatarBackground,
icon = Icons.Outlined.Person,
iconTint = Color.White
)
}
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(colors.background.copy(alpha = 0.95f))
.zIndex(1f)
) {
Row(
modifier = Modifier
.statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 12.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onMenuClick) {
Icon(
imageVector = Icons.Outlined.Menu,
contentDescription = "Menu",
tint = colors.textPrimary
)
}
Text(
text = title,
color = colors.textPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(onClick = { viewModel.loadHomePageData() }) {
Icon(imageVector = Icons.Outlined.Refresh, contentDescription = "Refresh")
}
}
}
}
}

View File

@@ -1,38 +1,97 @@
package hu.bbara.purefin.client package hu.bbara.purefin.client
import android.content.Context import android.content.Context
import dagger.Module import android.util.Log
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import jakarta.inject.Inject import kotlinx.coroutines.flow.first
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.libraryApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Module @Singleton
@InstallIn(SingletonComponent::class)
class JellyfinApiClient @Inject constructor( class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context, @ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
) { ) {
val jellyfin = createJellyfin { private val jellyfin = createJellyfin {
context = applicationContext context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
} }
suspend fun login(username: String, password: String): Boolean { private val api = jellyfin.createApi()
val api = jellyfin.createApi(baseUrl = userSessionRepository.getUrl())
private suspend fun getUserId(): UUID? = userSessionRepository.userId.first()
suspend fun login(url: String, username: String, password: String): Boolean {
api.update(baseUrl = url)
val response = api.userApi.authenticateUserByName(username = username, password = password) val response = api.userApi.authenticateUserByName(username = username, password = password)
val authResult = response.content val authResult = response.content
//TODO set loggedIn false?
val token = authResult.accessToken ?: return false val token = authResult.accessToken ?: return false
userSessionRepository.updateAccessToken(accessToken = token) val userId = authResult.user?.id ?: return false
userSessionRepository.setAccessToken(accessToken = token)
userSessionRepository.setUserId(userId)
userSessionRepository.setLoggedIn(true) userSessionRepository.setLoggedIn(true)
api.update(accessToken = token)
return true return true
} }
} suspend fun updateApiClient() {
val serverUrl = userSessionRepository.serverUrl.first()
val accessToken = userSessionRepository.accessToken.first()
api.update(baseUrl = serverUrl, accessToken = accessToken)
}
suspend fun getContinueWatching(): List<BaseItemDto> {
val userId = getUserId()
if (userId == null) {
return emptyList()
}
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
startIndex = 0,
//TODO remove this limit if needed
limit = 10,
enableImages = false,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
Log.d("getContinueWatching response: {}", response.content.toString())
return response.content.items
}
suspend fun getLibraries(): List<BaseItemDto> {
val response = api.libraryApi.getMediaFolders(isHidden = false)
Log.d("getLibraries response: {}", response.content.toString())
return response.content.items
}
suspend fun getLibrary(libraryId: UUID): List<BaseItemDto> {
val getItemsRequest = GetItemsRequest(
userId = getUserId(),
enableImages = false,
parentId = libraryId,
enableUserData = false,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
// TODO remove this limit
limit = 10
)
val response = api.itemsApi.getItems(getItemsRequest)
Log.d("getLibrary response: {}", response.content.toString())
return response.content.items
}
}

View File

@@ -42,7 +42,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun LoginScreen( fun LoginScreen(
loginViewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val JellyfinOrange = Color(0xFFBD542E) val JellyfinOrange = Color(0xFFBD542E)
@@ -51,9 +51,9 @@ fun LoginScreen(
val TextSecondary = Color(0xFF9EA3A8) val TextSecondary = Color(0xFF9EA3A8)
// Observe ViewModel state // Observe ViewModel state
val serverUrl by loginViewModel.url.collectAsState(initial = "") val serverUrl by viewModel.url.collectAsState("")
val username by loginViewModel.username.collectAsState() val username by viewModel.username.collectAsState()
val password by loginViewModel.password.collectAsState() val password by viewModel.password.collectAsState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -118,7 +118,7 @@ fun LoginScreen(
PurefinComplexTextField( PurefinComplexTextField(
label = "Server URL", label = "Server URL",
value = serverUrl, value = serverUrl,
onValueChange = { coroutineScope.launch { loginViewModel.setUrl(it) } }, onValueChange = { coroutineScope.launch { viewModel.setUrl(it) } },
placeholder = "http://192.168.1.100:8096", placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage leadingIcon = Icons.Default.Storage
) )
@@ -128,7 +128,7 @@ fun LoginScreen(
PurefinComplexTextField( PurefinComplexTextField(
label = "Username", label = "Username",
value = username, value = username,
onValueChange = { loginViewModel.setUsername(it) }, onValueChange = { viewModel.setUsername(it) },
placeholder = "Enter your username", placeholder = "Enter your username",
leadingIcon = Icons.Default.Person leadingIcon = Icons.Default.Person
) )
@@ -138,7 +138,7 @@ fun LoginScreen(
PurefinPasswordField( PurefinPasswordField(
label = "Password", label = "Password",
value = password, value = password,
onValueChange = { loginViewModel.setPassword(it) }, onValueChange = { viewModel.setPassword(it) },
placeholder = "••••••••", placeholder = "••••••••",
leadingIcon = Icons.Default.Lock, leadingIcon = Icons.Default.Lock,
) )
@@ -149,7 +149,7 @@ fun LoginScreen(
content = { Text("Connect") }, content = { Text("Connect") },
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
loginViewModel.login() viewModel.login()
} }
} }
) )

View File

@@ -1,35 +1,40 @@
package hu.bbara.purefin.login.viewmodel package hu.bbara.purefin.login.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
@ApplicationContext private val currentContext: Context
) : ViewModel() { ) : ViewModel() {
private val _username = MutableStateFlow("") private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow() val username: StateFlow<String> = _username.asStateFlow()
private val _password = MutableStateFlow("") private val _password = MutableStateFlow("")
val password: StateFlow<String> = _password.asStateFlow() val password: StateFlow<String> = _password.asStateFlow()
val url: Flow<String> = userSessionRepository.session.map { val url: StateFlow<String> = userSessionRepository.session.map {
it.url it.url
} }.onStart { }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ""
)
suspend fun setUrl(url: String) { suspend fun setUrl(url: String) {
userSessionRepository.updateServerUrl(url) userSessionRepository.setServerUrl(url)
} }
fun setUsername(username: String) { fun setUsername(username: String) {
@@ -41,13 +46,13 @@ class LoginViewModel @Inject constructor(
} }
suspend fun clearFields() { suspend fun clearFields() {
userSessionRepository.updateServerUrl(""); userSessionRepository.setServerUrl("");
_username.value = "" _username.value = ""
_password.value = "" _password.value = ""
} }
suspend fun login(): Boolean { suspend fun login(): Boolean {
return jellyfinApiClient.login(username.value, password.value) return jellyfinApiClient.login(url.value, username.value, password.value)
} }
} }

View File

@@ -1,10 +1,14 @@
package hu.bbara.purefin.session package hu.bbara.purefin.session
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable @Serializable
data class UserSession( data class UserSession(
val accessToken: String, val accessToken: String,
val url: String, val url: String,
@Serializable(with = UUIDSerializer::class)
val userId: UUID?,
val loggedIn: Boolean val loggedIn: Boolean
) )

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
class UserSessionRepository @Inject constructor( class UserSessionRepository @Inject constructor(
@@ -14,20 +15,33 @@ class UserSessionRepository @Inject constructor(
val serverUrl: Flow<String> = session val serverUrl: Flow<String> = session
.map { it.url } .map { it.url }
.distinctUntilChanged()
suspend fun getUrl(): String = serverUrl.first() suspend fun setServerUrl(serverUrl: String) {
userSessionDataStore.updateData {
it.copy(url = serverUrl)
}
}
val accessToken: Flow<String> = session val accessToken: Flow<String> = session
.map { it.accessToken } .map { it.accessToken }
.distinctUntilChanged()
suspend fun updateAccessToken(accessToken: String) { suspend fun setAccessToken(accessToken: String) {
userSessionDataStore.updateData { userSessionDataStore.updateData {
it.copy(accessToken = accessToken) it.copy(accessToken = accessToken)
} }
} }
val userId: Flow<UUID?> = session
.map { it.userId }
suspend fun setUserId(userId: UUID?) {
userSessionDataStore.updateData {
it.copy(userId = userId)
}
}
suspend fun getUserId(): UUID? = userId.first()
val isLoggedIn: Flow<Boolean> = session.map { it.loggedIn }.distinctUntilChanged() val isLoggedIn: Flow<Boolean> = session.map { it.loggedIn }.distinctUntilChanged()
suspend fun setLoggedIn(isLoggedIn: Boolean) { suspend fun setLoggedIn(isLoggedIn: Boolean) {
@@ -35,10 +49,4 @@ class UserSessionRepository @Inject constructor(
it.copy(loggedIn = isLoggedIn) it.copy(loggedIn = isLoggedIn)
} }
} }
suspend fun updateServerUrl(serverUrl: String) {
userSessionDataStore.updateData {
it.copy(url = serverUrl)
}
}
} }

View File

@@ -9,7 +9,7 @@ import java.io.OutputStream
object UserSessionSerializer : Serializer<UserSession> { object UserSessionSerializer : Serializer<UserSession> {
override val defaultValue: UserSession override val defaultValue: UserSession
get() = UserSession(accessToken = "", url = "", loggedIn = false) get() = UserSession(accessToken = "", url = "", loggedIn = false, userId = null)
override suspend fun readFrom(input: InputStream): UserSession { override suspend fun readFrom(input: InputStream): UserSession {
try { try {

View File

@@ -14,6 +14,8 @@ hiltNavigationCompose = "1.2.0"
ksp = "2.1.0-1.0.29" ksp = "2.1.0-1.0.29"
datastore = "1.1.1" datastore = "1.1.1"
kotlinxSerializationJson = "1.7.3" kotlinxSerializationJson = "1.7.3"
okhttp = "4.12.0"
foundation = "1.10.1"
[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" }
@@ -38,6 +40,9 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", v
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
[plugins] [plugins]