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.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()
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user