mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: add images to libraries on LibrariesContent screen
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
// 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,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
placeholder = placeholderPainter,
|
||||
error = placeholderPainter,
|
||||
fallback = placeholderPainter
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -46,6 +46,7 @@ data class LibraryItem(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val type: CollectionType,
|
||||
val posterUrl: String,
|
||||
val isEmpty: Boolean
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user