refactor UI components and improve home/library screen logic

- Rename `MediaGhostIconButton` to `GhostIconButton` and move it to the common components package.
- Implement `PurefinIconButton` as a new reusable UI component.
- Refactor `PosterItem` to include `imageUrl`, shifting image URL generation to the ViewModel.
- Update `HomePageViewModel` and `LibraryViewModel` to use `stateIn` for the server URL and handle image URL generation.
- Decouple `PosterCard` from `HomePageViewModel` by passing click lambdas as parameters.
- Add `LibraryTopBar` and navigation support (back button, item selection) to the `LibraryScreen`.
- Enhance `PurefinAsyncImage` to handle empty string models by treating them as null to trigger placeholders.
- Update `HomeTopBar` styling and replace the custom refresh button with `PurefinIconButton`.
This commit is contained in:
2026-01-25 13:17:45 +01:00
parent 3d17b7e614
commit e360517562
16 changed files with 219 additions and 116 deletions

View File

@@ -31,9 +31,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaCastMember import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaGhostIconButton
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.MediaSynopsis
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlayButton import hu.bbara.purefin.common.ui.components.MediaPlayButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
@@ -52,14 +52,14 @@ internal fun EpisodeTopBar(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
MediaGhostIconButton( GhostIconButton(
icon = Icons.Outlined.ArrowBack, icon = Icons.Outlined.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
onClick = onBack onClick = onBack
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaGhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { }) GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { }) GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
} }
} }
} }

View File

@@ -31,9 +31,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaCastMember import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaGhostIconButton
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.MediaSynopsis
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlayButton import hu.bbara.purefin.common.ui.components.MediaPlayButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
@@ -53,14 +53,14 @@ internal fun MovieTopBar(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
MediaGhostIconButton( GhostIconButton(
icon = Icons.Outlined.ArrowBack, icon = Icons.Outlined.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
onClick = onBack onClick = onBack
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaGhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { }) GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { }) GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
} }
} }
} }

View File

@@ -46,8 +46,8 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastMember import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaGhostIconButton
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
@@ -64,13 +64,13 @@ internal fun SeriesTopBar(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
MediaGhostIconButton( GhostIconButton(
onClick = onBack, onClick = onBack,
icon = Icons.Outlined.ArrowBack, icon = Icons.Outlined.ArrowBack,
contentDescription = "Back") contentDescription = "Back")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaGhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { }) GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { }) GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
} }
} }
} }

View File

@@ -74,7 +74,6 @@ fun HomePage(
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
HomeTopBar( HomeTopBar(
title = "Home",
onMenuClick = { coroutineScope.launch { drawerState.open() } } onMenuClick = { coroutineScope.launch { drawerState.open() } }
) )
} }

View File

@@ -16,7 +16,9 @@ import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@@ -34,7 +36,11 @@ class HomePageViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient private val jellyfinApiClient: JellyfinApiClient
) : ViewModel() { ) : ViewModel() {
private val _url = MutableStateFlow("") private val _url = userSessionRepository.serverUrl.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ""
)
private val _continueWatching = MutableStateFlow<List<ContinueWatchingItem>>(emptyList()) private val _continueWatching = MutableStateFlow<List<ContinueWatchingItem>>(emptyList())
val continueWatching = _continueWatching.asStateFlow() val continueWatching = _continueWatching.asStateFlow()
@@ -49,11 +55,6 @@ class HomePageViewModel @Inject constructor(
val latestLibraryContent = _latestLibraryContent.asStateFlow() val latestLibraryContent = _latestLibraryContent.asStateFlow()
init { init {
viewModelScope.launch {
userSessionRepository.serverUrl.collect {
_url.value = it
}
}
loadHomePageData() loadHomePageData()
} }
@@ -154,7 +155,8 @@ class HomePageViewModel @Inject constructor(
PosterItem( PosterItem(
id = it.id, id = it.id,
title = it.name ?: "Unknown", title = it.name ?: "Unknown",
type = it.type type = it.type,
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
) )
} }
_libraryItems.update { currentMap -> _libraryItems.update { currentMap ->
@@ -183,19 +185,20 @@ class HomePageViewModel @Inject constructor(
BaseItemKind.MOVIE -> PosterItem( BaseItemKind.MOVIE -> PosterItem(
id = it.id, id = it.id,
title = it.name ?: "Unknown", title = it.name ?: "Unknown",
type = BaseItemKind.MOVIE type = BaseItemKind.MOVIE,
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
) )
BaseItemKind.EPISODE -> PosterItem( BaseItemKind.EPISODE -> PosterItem(
id = it.id, id = it.id,
title = it.seriesName ?: "Unknown", title = it.seriesName ?: "Unknown",
type = BaseItemKind.EPISODE, type = BaseItemKind.EPISODE,
parentId = it.seriesId!! imageUrl = getImageUrl(it.parentId!!, ImageType.PRIMARY)
) )
BaseItemKind.SEASON -> PosterItem( BaseItemKind.SEASON -> PosterItem(
id = it.seriesId!!, id = it.seriesId!!,
title = it.seriesName ?: "Unknown", title = it.seriesName ?: "Unknown",
type = BaseItemKind.SERIES, type = BaseItemKind.SERIES,
parentId = it.seriesId imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
) )
else -> null else -> null
} }

View File

@@ -26,10 +26,8 @@ data class PosterItem(
val id: UUID, val id: UUID,
val title: String, val title: String,
val type: BaseItemKind, val type: BaseItemKind,
val parentId: UUID? = null val imageUrl: String
) { )
val imageItemId: UUID get() = parentId ?: id
}
data class HomeNavItem( data class HomeNavItem(
val id: UUID, val id: UUID,

View File

@@ -161,7 +161,8 @@ fun LibraryPosterSection(
title: String, title: String,
items: List<PosterItem>, items: List<PosterItem>,
action: String?, action: String?,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel()
) { ) {
SectionHeader( SectionHeader(
title = title, title = title,
@@ -176,6 +177,9 @@ fun LibraryPosterSection(
items = items, key = { it.id }) { item -> items = items, key = { it.id }) { item ->
PosterCard( PosterCard(
item = item, item = item,
onMovieSelected = { viewModel.onMovieSelected(it) },
onSeriesSelected = { viewModel.onSeriesSelected(it) },
onEpisodeSelected = { viewModel.onEpisodeSelected(it) }
) )
} }
} }

View File

@@ -4,45 +4,23 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.app.home.HomePageViewModel
@Composable @Composable
fun HomeTopBar( fun HomeTopBar(
viewModel: HomePageViewModel = hiltViewModel(),
title: String,
onMenuClick: () -> Unit, onMenuClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {
HomeAvatar(
size = 36.dp,
borderWidth = 2.dp,
borderColor = MaterialTheme.colorScheme.outline,
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
icon = Icons.Outlined.Person,
iconTint = MaterialTheme.colorScheme.onPrimary
)
}
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -55,34 +33,18 @@ fun HomeTopBar(
Row( Row(
modifier = Modifier modifier = Modifier
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onMenuClick) { PurefinIconButton(
Icon( icon = Icons.Outlined.Menu,
imageVector = Icons.Outlined.Menu, contentDescription = "Menu",
contentDescription = "Menu", onClick = onMenuClick
tint = scheme.onBackground
)
}
Text(
text = title,
color = scheme.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
) )
} }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(onClick = { viewModel.loadHomePageData() }) {
Icon(imageVector = Icons.Outlined.Refresh, contentDescription = "Refresh")
}
}
} }
} }
} }

View File

@@ -5,21 +5,50 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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.ImageType
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager private val navigationManager: NavigationManager
) : ViewModel() { ) : ViewModel() {
private val _url = userSessionRepository.serverUrl.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ""
)
private val _contents = MutableStateFlow<List<PosterItem>>(emptyList()) private val _contents = MutableStateFlow<List<PosterItem>>(emptyList())
val contents = _contents.asStateFlow() val contents = _contents.asStateFlow()
fun onMovieSelected(movieId: String) {
navigationManager.navigate(Route.Movie(ItemDto(id = UUID.fromString(movieId), type = BaseItemKind.MOVIE)))
}
fun onSeriesSelected(seriesId: String) {
viewModelScope.launch {
navigationManager.navigate(Route.Series(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
}
}
fun onBack() {
navigationManager.pop()
}
fun selectLibrary(libraryId: UUID) { fun selectLibrary(libraryId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibrary(libraryId) val libraryItems = jellyfinApiClient.getLibrary(libraryId)
@@ -27,9 +56,18 @@ class LibraryViewModel @Inject constructor(
PosterItem( PosterItem(
id = it.id, id = it.id,
title = it.name ?: "Unknown", title = it.name ?: "Unknown",
type = it.type type = it.type,
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
) )
} }
} }
} }
fun getImageUrl(itemId: UUID, type: ImageType): String {
return JellyfinImageHelper.toImageUrl(
url = _url.value,
itemId = itemId,
type = type
)
}
} }

View File

@@ -2,11 +2,19 @@ package hu.bbara.purefin.app.library.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -16,6 +24,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.app.library.LibraryViewModel import hu.bbara.purefin.app.library.LibraryViewModel
import hu.bbara.purefin.common.ui.PosterCard import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.navigation.LibraryDto import hu.bbara.purefin.navigation.LibraryDto
@Composable @Composable
@@ -30,14 +39,42 @@ fun LibraryScreen(
val libraryItems = viewModel.contents.collectAsState() val libraryItems = viewModel.contents.collectAsState()
Scaffold(
LibraryPosterGrid(libraryItems = libraryItems.value) topBar = {
LibraryTopBar(
onBack = { viewModel.onBack() }
)
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
LibraryPosterGrid(libraryItems = libraryItems.value)
}
}
} }
@Composable @Composable
fun LibraryPosterGrid( internal fun LibraryTopBar(
onBack: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp)
) {
PurefinIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
}
}
@Composable
internal fun LibraryPosterGrid(
libraryItems: List<PosterItem>, libraryItems: List<PosterItem>,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
viewModel: LibraryViewModel = hiltViewModel()
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp), columns = GridCells.Adaptive(minSize = 120.dp),
@@ -49,7 +86,10 @@ fun LibraryPosterGrid(
items(libraryItems) { item -> items(libraryItems) { item ->
PosterCard( PosterCard(
item = item, item = item,
onMovieSelected = { viewModel.onMovieSelected(item.id.toString()) },
onSeriesSelected = { viewModel.onSeriesSelected(item.id.toString()) },
onEpisodeSelected = {
}
) )
} }
} }

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.common.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -12,12 +11,10 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
@@ -29,7 +26,6 @@ import androidx.compose.ui.Alignment
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -39,31 +35,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
@Composable
fun MediaGhostIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(scheme.background.copy(alpha = 0.4f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground
)
}
}
@Composable @Composable
fun MediaMetaChip( fun MediaMetaChip(
text: String, text: String,

View File

@@ -18,26 +18,25 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.HomePageViewModel
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun PosterCard( fun PosterCard(
item: PosterItem, item: PosterItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel() onMovieSelected: (String) -> Unit,
onSeriesSelected: (String) -> Unit,
onEpisodeSelected: (String) -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
fun openItem(posterItem: PosterItem) { fun openItem(posterItem: PosterItem) {
when (posterItem.type) { when (posterItem.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString()) BaseItemKind.MOVIE -> onMovieSelected(posterItem.id.toString())
BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString()) BaseItemKind.SERIES -> onSeriesSelected(posterItem.id.toString())
BaseItemKind.EPISODE -> viewModel.onEpisodeSelected(posterItem.id.toString()) BaseItemKind.EPISODE -> onEpisodeSelected(posterItem.id.toString())
else -> {} else -> {}
} }
} }
@@ -46,7 +45,7 @@ fun PosterCard(
.width(144.dp) .width(144.dp)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = viewModel.getImageUrl(item.imageItemId, ImageType.PRIMARY), model = item.imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun GhostIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.size(52.dp)
.clip(CircleShape)
.background(scheme.background.copy(alpha = 0.65f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground
)
}
}

View File

@@ -20,8 +20,14 @@ fun PurefinAsyncImage(
) { ) {
val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant) val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant)
// Convert empty string to null to properly trigger fallback
val effectiveModel = when {
model is String && model.isEmpty() -> null
else -> model
}
AsyncImage( AsyncImage(
model = model, model = effectiveModel,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
contentScale = contentScale, contentScale = contentScale,

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun PurefinIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.size(52.dp)
.clip(CircleShape)
.background(scheme.secondary)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onSecondary
)
}
}

View File

@@ -6,6 +6,9 @@ import org.jellyfin.sdk.model.api.ImageType
class JellyfinImageHelper { class JellyfinImageHelper {
companion object { companion object {
fun toImageUrl(url: String, itemId: UUID, type: ImageType): String { fun toImageUrl(url: String, itemId: UUID, type: ImageType): String {
if (url.isEmpty()) {
return ""
}
return StringBuilder() return StringBuilder()
.append(url) .append(url)
.append("/Items/") .append("/Items/")