diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf927f4..b092c4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt b/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt index 7f9f850..66c85dc 100644 --- a/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt +++ b/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt @@ -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() } } diff --git a/app/src/main/java/hu/bbara/purefin/app/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/HomePage.kt new file mode 100644 index 0000000..e787321 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/HomePage.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt new file mode 100644 index 0000000..aa08ecf --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt @@ -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>(emptyList()) + val continueWatching = _continueWatching.asStateFlow() + + private val _libraries = MutableStateFlow>(emptyList()) + val libraries = _libraries.asStateFlow() + + private val _libraryContent = MutableStateFlow>>(emptyMap()) + val libraryContent = _libraryContent.asStateFlow() + + + fun loadContinueWatching() { + viewModelScope.launch { + val continueWatching: List = 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 = 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) + } + } + +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeAvatar.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeAvatar.kt new file mode 100644 index 0000000..822467f --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeAvatar.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeContent.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeContent.kt new file mode 100644 index 0000000..ce18030 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeContent.kt @@ -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, + 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)) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeDrawer.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeDrawer.kt new file mode 100644 index 0000000..9bbfef5 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeDrawer.kt @@ -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, + secondaryNavItems: List, + 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, + secondaryItems: List, + 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 + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeMockData.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeMockData.kt new file mode 100644 index 0000000..c25febf --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeMockData.kt @@ -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) + ) + +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt new file mode 100644 index 0000000..e35c4b4 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt @@ -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 +) + +data class LibraryItem( + val name: String, + val id: UUID +) + +data class PosterItem( + val title: String, + val isLatest: Boolean, + val colors: List +) + +data class HomeNavItem( + val label: String, + val icon: ImageVector, + val selected: Boolean = false +) + +data class HomeUser( + val name: String, + val plan: String +) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt new file mode 100644 index 0000000..fb5ebe0 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt @@ -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, + 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, + 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() } + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeTokens.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeTokens.kt new file mode 100644 index 0000000..eda28ab --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeTokens.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeTopBar.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeTopBar.kt new file mode 100644 index 0000000..8555ccc --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeTopBar.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index e6537da..0f81b43 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -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 } -} \ No newline at end of file + suspend fun updateApiClient() { + val serverUrl = userSessionRepository.serverUrl.first() + val accessToken = userSessionRepository.accessToken.first() + api.update(baseUrl = serverUrl, accessToken = accessToken) + } + + suspend fun getContinueWatching(): List { + 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 = api.itemsApi.getResumeItems(getResumeItemsRequest) + Log.d("getContinueWatching response: {}", response.content.toString()) + return response.content.items + } + + suspend fun getLibraries(): List { + val response = api.libraryApi.getMediaFolders(isHidden = false) + Log.d("getLibraries response: {}", response.content.toString()) + return response.content.items + } + + suspend fun getLibrary(libraryId: UUID): List { + 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 + } + +} diff --git a/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt b/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt index faab80e..27b17ec 100644 --- a/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt @@ -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() } } ) diff --git a/app/src/main/java/hu/bbara/purefin/login/viewmodel/LoginViewModel.kt b/app/src/main/java/hu/bbara/purefin/login/viewmodel/LoginViewModel.kt index 5485963..0cae353 100644 --- a/app/src/main/java/hu/bbara/purefin/login/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/login/viewmodel/LoginViewModel.kt @@ -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 = _username.asStateFlow() private val _password = MutableStateFlow("") val password: StateFlow = _password.asStateFlow() - val url: Flow = userSessionRepository.session.map { + val url: StateFlow = 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) } } diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSession.kt b/app/src/main/java/hu/bbara/purefin/session/UserSession.kt index 77ddb40..ffaf8a1 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSession.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSession.kt @@ -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 ) diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt b/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt index ffd34c6..6f59128 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt @@ -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 = 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 = session .map { it.accessToken } - .distinctUntilChanged() - suspend fun updateAccessToken(accessToken: String) { + suspend fun setAccessToken(accessToken: String) { userSessionDataStore.updateData { it.copy(accessToken = accessToken) } } + val userId: Flow = session + .map { it.userId } + + suspend fun setUserId(userId: UUID?) { + userSessionDataStore.updateData { + it.copy(userId = userId) + } + } + + suspend fun getUserId(): UUID? = userId.first() + val isLoggedIn: Flow = 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) - } - } } diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt b/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt index 0cf8b71..8b779a3 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt @@ -9,7 +9,7 @@ import java.io.OutputStream object UserSessionSerializer : Serializer { 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 { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f880f1c..d09f5ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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]