feat: add images to libraries on LibrariesContent screen

This commit is contained in:
2026-02-22 12:50:56 +01:00
parent 843bd749b1
commit ce126988d9
10 changed files with 103 additions and 81 deletions

View File

@@ -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()

View File

@@ -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)
)
}

View File

@@ -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
)

View File

@@ -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
)
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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()
)

View File

@@ -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<BaseItemDto> = 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,

View File

@@ -7,6 +7,7 @@ data class Library(
val id: UUID,
val name: String,
val type: CollectionType,
val posterUrl: String,
val series: List<Series>? = null,
val movies: List<Movie>? = null,
) {

View File

@@ -46,6 +46,7 @@ data class LibraryItem(
val id: UUID,
val name: String,
val type: CollectionType,
val posterUrl: String,
val isEmpty: Boolean
)

View File

@@ -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()