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.androidx.hilt.navigation.compose)
implementation(libs.datastore)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
implementation(libs.androidx.compose.foundation)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -4,24 +4,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
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.session.UserSessionRepository
import hu.bbara.purefin.ui.theme.PurefinTheme
@@ -33,58 +21,26 @@ class PurefinActivity : ComponentActivity() {
@Inject
lateinit var userSessionRepository: UserSessionRepository
@Inject
lateinit var jellyfinApiClient: JellyfinApiClient
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch { init() }
enableEdgeToEdge()
setContent {
PurefinTheme {
val scope = rememberCoroutineScope()
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
if (isLoggedIn) {
HomeScreen(logout = { scope.launch { userSessionRepository.setLoggedIn(false) } })
HomePage()
} else {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
LoginScreen(modifier = Modifier.padding(innerPadding))
}
LoginScreen()
}
}
}
}
}
@Composable
fun HomeScreen(modifier: Modifier = Modifier,
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)
}
private suspend fun init() {
jellyfinApiClient.updateApiClient()
}
}

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
import android.content.Context
import dagger.Module
import dagger.hilt.InstallIn
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
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.itemsApi
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.createJellyfin
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
@InstallIn(SingletonComponent::class)
@Singleton
class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository,
) {
val jellyfin = createJellyfin {
private val jellyfin = createJellyfin {
context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
}
suspend fun login(username: String, password: String): Boolean {
val api = jellyfin.createApi(baseUrl = userSessionRepository.getUrl())
private val api = jellyfin.createApi()
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 authResult = response.content
//TODO set loggedIn 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)
api.update(accessToken = token)
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
fun LoginScreen(
loginViewModel: LoginViewModel = hiltViewModel(),
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val JellyfinOrange = Color(0xFFBD542E)
@@ -51,9 +51,9 @@ fun LoginScreen(
val TextSecondary = Color(0xFF9EA3A8)
// Observe ViewModel state
val serverUrl by loginViewModel.url.collectAsState(initial = "")
val username by loginViewModel.username.collectAsState()
val password by loginViewModel.password.collectAsState()
val serverUrl by viewModel.url.collectAsState("")
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val coroutineScope = rememberCoroutineScope()
@@ -118,7 +118,7 @@ fun LoginScreen(
PurefinComplexTextField(
label = "Server URL",
value = serverUrl,
onValueChange = { coroutineScope.launch { loginViewModel.setUrl(it) } },
onValueChange = { coroutineScope.launch { viewModel.setUrl(it) } },
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
@@ -128,7 +128,7 @@ fun LoginScreen(
PurefinComplexTextField(
label = "Username",
value = username,
onValueChange = { loginViewModel.setUsername(it) },
onValueChange = { viewModel.setUsername(it) },
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
@@ -138,7 +138,7 @@ fun LoginScreen(
PurefinPasswordField(
label = "Password",
value = password,
onValueChange = { loginViewModel.setPassword(it) },
onValueChange = { viewModel.setPassword(it) },
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
@@ -149,7 +149,7 @@ fun LoginScreen(
content = { Text("Connect") },
onClick = {
coroutineScope.launch {
loginViewModel.login()
viewModel.login()
}
}
)

View File

@@ -1,35 +1,40 @@
package hu.bbara.purefin.login.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
@ApplicationContext private val currentContext: Context
) : ViewModel() {
private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow()
private val _password = MutableStateFlow("")
val password: StateFlow<String> = _password.asStateFlow()
val url: Flow<String> = userSessionRepository.session.map {
val url: StateFlow<String> = userSessionRepository.session.map {
it.url
}
}.onStart { }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ""
)
suspend fun setUrl(url: String) {
userSessionRepository.updateServerUrl(url)
userSessionRepository.setServerUrl(url)
}
fun setUsername(username: String) {
@@ -41,13 +46,13 @@ class LoginViewModel @Inject constructor(
}
suspend fun clearFields() {
userSessionRepository.updateServerUrl("");
userSessionRepository.setServerUrl("");
_username.value = ""
_password.value = ""
}
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
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
data class UserSession(
val accessToken: String,
val url: String,
@Serializable(with = UUIDSerializer::class)
val userId: UUID?,
val loggedIn: Boolean
)

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.UUID
import javax.inject.Inject
class UserSessionRepository @Inject constructor(
@@ -14,20 +15,33 @@ class UserSessionRepository @Inject constructor(
val serverUrl: Flow<String> = session
.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
.map { it.accessToken }
.distinctUntilChanged()
suspend fun updateAccessToken(accessToken: String) {
suspend fun setAccessToken(accessToken: String) {
userSessionDataStore.updateData {
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()
suspend fun setLoggedIn(isLoggedIn: Boolean) {
@@ -35,10 +49,4 @@ class UserSessionRepository @Inject constructor(
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> {
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 {
try {

View File

@@ -14,6 +14,8 @@ hiltNavigationCompose = "1.2.0"
ksp = "2.1.0-1.0.29"
datastore = "1.1.1"
kotlinxSerializationJson = "1.7.3"
okhttp = "4.12.0"
foundation = "1.10.1"
[libraries]
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" }
datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
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]