From ce126988d9e2ac19996437370e82d1d930ffc907 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 22 Feb 2026 12:50:56 +0100 Subject: [PATCH] feat: add images to libraries on LibrariesContent screen --- .../hu/bbara/purefin/app/home/HomePage.kt | 9 +-- .../bbara/purefin/app/home/ui/HomeMockData.kt | 25 -------- .../bbara/purefin/app/home/ui/HomeModels.kt | 3 +- .../purefin/app/home/ui/LibrariesContent.kt | 58 ++++++++++------- .../common/ui/components/PurefinAsyncImage.kt | 64 ++++++++++++++----- .../core/data/InMemoryMediaRepository.kt | 14 +++- .../core/data/client/JellyfinApiClient.kt | 4 +- .../hu/bbara/purefin/core/model/Library.kt | 1 + .../purefin/feature/shared/home/HomeModels.kt | 1 + .../feature/shared/home/HomePageViewModel.kt | 5 +- 10 files changed, 103 insertions(+), 81 deletions(-) delete mode 100644 app/src/main/java/hu/bbara/purefin/app/home/ui/HomeMockData.kt diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt index ca2f48c..bd2950e 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt @@ -6,8 +6,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Collections import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Movie -import androidx.compose.material.icons.outlined.Tv import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -29,7 +27,6 @@ import hu.bbara.purefin.app.home.ui.HomeNavItem import hu.bbara.purefin.app.home.ui.HomeTopBar import hu.bbara.purefin.app.home.ui.LibrariesContent import hu.bbara.purefin.feature.shared.home.HomePageViewModel -import org.jellyfin.sdk.model.api.CollectionType @Composable fun HomePage( @@ -44,11 +41,7 @@ fun HomePage( HomeNavItem( id = it.id, label = it.name, - icon = when (it.type) { - CollectionType.MOVIES -> Icons.Outlined.Movie - CollectionType.TVSHOWS -> Icons.Outlined.Tv - else -> Icons.Outlined.Collections - }, + posterUrl = it.posterUrl ) } val continueWatching = viewModel.continueWatching.collectAsState() diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeMockData.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeMockData.kt deleted file mode 100644 index 8833267..0000000 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeMockData.kt +++ /dev/null @@ -1,25 +0,0 @@ -package hu.bbara.purefin.app.home.ui - -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 -import org.jellyfin.sdk.model.UUID - -object HomeMockData { - val user = HomeUser(name = "Alex User", plan = "Premium Account") - - val primaryNavItems = listOf( - HomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true), - HomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie), - HomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv), - HomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search) - ) - - val secondaryNavItems = listOf( - HomeNavItem(id = UUID.randomUUID(), label = "Settings", icon = Icons.Outlined.Settings) - ) - -} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt index ac6dd1e..acfa185 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt @@ -1,12 +1,11 @@ package hu.bbara.purefin.app.home.ui -import androidx.compose.ui.graphics.vector.ImageVector import org.jellyfin.sdk.model.UUID data class HomeNavItem( val id: UUID, val label: String, - val icon: ImageVector, + val posterUrl: String, val selected: Boolean = false ) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/LibrariesContent.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/LibrariesContent.kt index 111538a..6c1f791 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/LibrariesContent.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/LibrariesContent.kt @@ -2,20 +2,22 @@ package hu.bbara.purefin.app.home.ui 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.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import hu.bbara.purefin.common.ui.components.PurefinAsyncImage @Composable fun LibrariesContent( @@ -26,30 +28,38 @@ fun LibrariesContent( LazyColumn( modifier = modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .padding(horizontal = 16.dp, vertical = 8.dp) + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), ) { items(items, key = { it.id }) { item -> - ListItem( - headlineContent = { - Text( - text = item.label, - style = MaterialTheme.typography.titleMedium - ) - }, - leadingContent = { - Icon( - imageVector = item.icon, - contentDescription = item.label, - tint = MaterialTheme.colorScheme.primary - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable { onLibrarySelected(item) } + LibraryListItem( + item = item, + modifier = Modifier.clickable { + onLibrarySelected(item) + } ) } } } + +@Composable +fun LibraryListItem( + item: HomeNavItem, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + PurefinAsyncImage( + model = item.posterUrl, + contentDescription = item.label, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(24.dp)) + ) + Text( + text = item.label, + style = MaterialTheme.typography.displaySmall + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/common/ui/components/PurefinAsyncImage.kt b/app/src/main/java/hu/bbara/purefin/common/ui/components/PurefinAsyncImage.kt index 9d510a9..7f72044 100644 --- a/app/src/main/java/hu/bbara/purefin/common/ui/components/PurefinAsyncImage.kt +++ b/app/src/main/java/hu/bbara/purefin/common/ui/components/PurefinAsyncImage.kt @@ -1,38 +1,72 @@ package hu.bbara.purefin.common.ui.components +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BrokenImage +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage /** * Async image that falls back to theme-synced color blocks so loading/error states * stay aligned with PurefinTheme's colorScheme. + * + * Uses plain [AsyncImage] (no SubcomposeLayout) so it is safe inside lazy lists and + * composables that query intrinsic measurements (e.g. [ListItem]). + * + * - Loading: solid surfaceVariant block + * - Error / missing URL: surfaceVariant block with a centered [fallbackIcon] */ @Composable fun PurefinAsyncImage( model: Any?, contentDescription: String?, modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Crop + contentScale: ContentScale = ContentScale.Crop, + fallbackIcon: ImageVector = Icons.Outlined.BrokenImage ) { - val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant) + val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant + val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant // Convert empty string to null to properly trigger fallback - val effectiveModel = when { - model is String && model.isEmpty() -> null - else -> model - } + val effectiveModel = if (model is String && model.isEmpty()) null else model - AsyncImage( - model = effectiveModel, - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale, - placeholder = placeholderPainter, - error = placeholderPainter, - fallback = placeholderPainter - ) + // Show icon immediately for null model (no request will be made); callbacks update it otherwise. + var showFallbackIcon by remember(effectiveModel) { mutableStateOf(effectiveModel == null) } + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = effectiveModel, + contentDescription = contentDescription, + contentScale = contentScale, + placeholder = ColorPainter(surfaceVariant), + error = ColorPainter(surfaceVariant), + fallback = ColorPainter(surfaceVariant), + onLoading = { showFallbackIcon = false }, + onSuccess = { showFallbackIcon = false }, + onError = { showFallbackIcon = true }, + ) + if (showFallbackIcon) { + Icon( + imageVector = fallbackIcon, + contentDescription = null, + tint = onSurfaceVariant, + modifier = Modifier.size(32.dp) + ) + } + } } diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt index de291ba..68cc656 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt @@ -177,7 +177,7 @@ class InMemoryMediaRepository @Inject constructor( //TODO add support for playlists val filteredLibraries = librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS } - val emptyLibraries = filteredLibraries.map { it.toLibrary() } + val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) } _libraries.value = emptyLibraries val filledLibraries = emptyLibraries.map { library -> @@ -369,17 +369,27 @@ class InMemoryMediaRepository @Inject constructor( return userSessionRepository.serverUrl.first() } - private fun BaseItemDto.toLibrary(): Library { + private fun BaseItemDto.toLibrary(serverUrl: String): Library { return when (this.collectionType) { CollectionType.MOVIES -> Library( id = this.id, name = this.name!!, + posterUrl = JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = this.id, + type = ImageType.PRIMARY + ), type = CollectionType.MOVIES, movies = emptyList() ) CollectionType.TVSHOWS -> Library( id = this.id, name = this.name!!, + posterUrl = JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = this.id, + type = ImageType.PRIMARY + ), type = CollectionType.TVSHOWS, series = emptyList() ) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt index 63ccb80..4a36c0e 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt @@ -110,7 +110,7 @@ class JellyfinApiClient @Inject constructor( ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED, ItemFields.OVERVIEW, - ItemFields.SEASON_USER_DATA + ItemFields.SEASON_USER_DATA, ) suspend fun getLibraryContent(libraryId: UUID): List = withContext(Dispatchers.IO) { @@ -119,7 +119,7 @@ class JellyfinApiClient @Inject constructor( } val getItemsRequest = GetItemsRequest( userId = getUserId(), - enableImages = false, + enableImages = true, parentId = libraryId, fields = itemFields, enableUserData = true, diff --git a/core/model/src/main/java/hu/bbara/purefin/core/model/Library.kt b/core/model/src/main/java/hu/bbara/purefin/core/model/Library.kt index 1c36060..28f3647 100644 --- a/core/model/src/main/java/hu/bbara/purefin/core/model/Library.kt +++ b/core/model/src/main/java/hu/bbara/purefin/core/model/Library.kt @@ -7,6 +7,7 @@ data class Library( val id: UUID, val name: String, val type: CollectionType, + val posterUrl: String, val series: List? = null, val movies: List? = null, ) { diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt index 5c3f136..16c5a2f 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt @@ -46,6 +46,7 @@ data class LibraryItem( val id: UUID, val name: String, val type: CollectionType, + val posterUrl: String, val isEmpty: Boolean ) diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt index 4a1e50b..2f9750a 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt @@ -4,9 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.data.MediaRepository -import hu.bbara.purefin.core.model.Media import hu.bbara.purefin.core.data.domain.usecase.RefreshHomeDataUseCase -import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.LibraryDto import hu.bbara.purefin.core.data.navigation.MovieDto @@ -14,6 +12,7 @@ import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.core.model.Media import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -22,7 +21,6 @@ import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.ImageType import javax.inject.Inject @HiltViewModel @@ -45,6 +43,7 @@ class HomePageViewModel @Inject constructor( id = it.id, name = it.name, type = it.type, + posterUrl = it.posterUrl, isEmpty = when(it.type) { CollectionType.MOVIES -> mediaRepository.movies.value.isEmpty() CollectionType.TVSHOWS -> mediaRepository.series.value.isEmpty()