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.Collections
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Home 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar 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.HomeTopBar
import hu.bbara.purefin.app.home.ui.LibrariesContent import hu.bbara.purefin.app.home.ui.LibrariesContent
import hu.bbara.purefin.feature.shared.home.HomePageViewModel import hu.bbara.purefin.feature.shared.home.HomePageViewModel
import org.jellyfin.sdk.model.api.CollectionType
@Composable @Composable
fun HomePage( fun HomePage(
@@ -44,11 +41,7 @@ fun HomePage(
HomeNavItem( HomeNavItem(
id = it.id, id = it.id,
label = it.name, label = it.name,
icon = when (it.type) { posterUrl = it.posterUrl
CollectionType.MOVIES -> Icons.Outlined.Movie
CollectionType.TVSHOWS -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
},
) )
} }
val continueWatching = viewModel.continueWatching.collectAsState() 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 package hu.bbara.purefin.app.home.ui
import androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
data class HomeNavItem( data class HomeNavItem(
val id: UUID, val id: UUID,
val label: String, val label: String,
val icon: ImageVector, val posterUrl: String,
val selected: Boolean = false 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.background
import androidx.compose.foundation.clickable 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
@Composable @Composable
fun LibrariesContent( fun LibrariesContent(
@@ -26,30 +28,38 @@ fun LibrariesContent(
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background),
.padding(horizontal = 16.dp, vertical = 8.dp) contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(32.dp),
) { ) {
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
ListItem( LibraryListItem(
headlineContent = { item = item,
Text( modifier = Modifier.clickable {
text = item.label, onLibrarySelected(item)
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) }
) )
} }
} }
} }
@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 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.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
/** /**
* Async image that falls back to theme-synced color blocks so loading/error states * Async image that falls back to theme-synced color blocks so loading/error states
* stay aligned with PurefinTheme's colorScheme. * 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 @Composable
fun PurefinAsyncImage( fun PurefinAsyncImage(
model: Any?, model: Any?,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, 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 // Convert empty string to null to properly trigger fallback
val effectiveModel = when { val effectiveModel = if (model is String && model.isEmpty()) null else model
model is String && model.isEmpty() -> null
else -> model
}
AsyncImage( // Show icon immediately for null model (no request will be made); callbacks update it otherwise.
model = effectiveModel, var showFallbackIcon by remember(effectiveModel) { mutableStateOf(effectiveModel == null) }
contentDescription = contentDescription,
modifier = modifier, Box(modifier = modifier, contentAlignment = Alignment.Center) {
contentScale = contentScale, AsyncImage(
placeholder = placeholderPainter, modifier = Modifier.fillMaxSize(),
error = placeholderPainter, model = effectiveModel,
fallback = placeholderPainter 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 //TODO add support for playlists
val filteredLibraries = val filteredLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS } 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 _libraries.value = emptyLibraries
val filledLibraries = emptyLibraries.map { library -> val filledLibraries = emptyLibraries.map { library ->
@@ -369,17 +369,27 @@ class InMemoryMediaRepository @Inject constructor(
return userSessionRepository.serverUrl.first() return userSessionRepository.serverUrl.first()
} }
private fun BaseItemDto.toLibrary(): Library { private fun BaseItemDto.toLibrary(serverUrl: String): Library {
return when (this.collectionType) { return when (this.collectionType) {
CollectionType.MOVIES -> Library( CollectionType.MOVIES -> Library(
id = this.id, id = this.id,
name = this.name!!, name = this.name!!,
posterUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = this.id,
type = ImageType.PRIMARY
),
type = CollectionType.MOVIES, type = CollectionType.MOVIES,
movies = emptyList() movies = emptyList()
) )
CollectionType.TVSHOWS -> Library( CollectionType.TVSHOWS -> Library(
id = this.id, id = this.id,
name = this.name!!, name = this.name!!,
posterUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = this.id,
type = ImageType.PRIMARY
),
type = CollectionType.TVSHOWS, type = CollectionType.TVSHOWS,
series = emptyList() series = emptyList()
) )

View File

@@ -110,7 +110,7 @@ class JellyfinApiClient @Inject constructor(
ItemFields.PARENT_ID, ItemFields.PARENT_ID,
ItemFields.DATE_LAST_REFRESHED, ItemFields.DATE_LAST_REFRESHED,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.SEASON_USER_DATA ItemFields.SEASON_USER_DATA,
) )
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
@@ -119,7 +119,7 @@ class JellyfinApiClient @Inject constructor(
} }
val getItemsRequest = GetItemsRequest( val getItemsRequest = GetItemsRequest(
userId = getUserId(), userId = getUserId(),
enableImages = false, enableImages = true,
parentId = libraryId, parentId = libraryId,
fields = itemFields, fields = itemFields,
enableUserData = true, enableUserData = true,

View File

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

View File

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

View File

@@ -4,9 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository 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.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.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LibraryDto import hu.bbara.purefin.core.data.navigation.LibraryDto
import hu.bbara.purefin.core.data.navigation.MovieDto 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.Route
import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Media
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -22,7 +21,6 @@ import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -45,6 +43,7 @@ class HomePageViewModel @Inject constructor(
id = it.id, id = it.id,
name = it.name, name = it.name,
type = it.type, type = it.type,
posterUrl = it.posterUrl,
isEmpty = when(it.type) { isEmpty = when(it.type) {
CollectionType.MOVIES -> mediaRepository.movies.value.isEmpty() CollectionType.MOVIES -> mediaRepository.movies.value.isEmpty()
CollectionType.TVSHOWS -> mediaRepository.series.value.isEmpty() CollectionType.TVSHOWS -> mediaRepository.series.value.isEmpty()