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 hu.bbara.purefin.common.ui.MediaCastMember
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.MediaSynopsis
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlayButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
@@ -52,14 +52,14 @@ internal fun EpisodeTopBar(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
MediaGhostIconButton(
GhostIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaGhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", 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 hu.bbara.purefin.common.ui.MediaCastMember
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.MediaSynopsis
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlayButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
@@ -53,14 +53,14 @@ internal fun MovieTopBar(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
MediaGhostIconButton(
GhostIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaGhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", 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 hu.bbara.purefin.common.ui.MediaCastMember
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.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
@@ -64,13 +64,13 @@ internal fun SeriesTopBar(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
MediaGhostIconButton(
GhostIconButton(
onClick = onBack,
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaGhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
}
}
}

View File

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

View File

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

View File

@@ -161,7 +161,8 @@ fun LibraryPosterSection(
title: String,
items: List<PosterItem>,
action: String?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel()
) {
SectionHeader(
title = title,
@@ -176,6 +177,9 @@ fun LibraryPosterSection(
items = items, key = { it.id }) { item ->
PosterCard(
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.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.HomePageViewModel
import hu.bbara.purefin.common.ui.components.PurefinIconButton
@Composable
fun HomeTopBar(
viewModel: HomePageViewModel = hiltViewModel(),
title: String,
onMenuClick: () -> Unit,
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
@@ -55,34 +33,18 @@ fun HomeTopBar(
Row(
modifier = Modifier
.statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 12.dp)
.padding(horizontal = 16.dp, vertical = 16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onMenuClick) {
Icon(
imageVector = Icons.Outlined.Menu,
PurefinIconButton(
icon = Icons.Outlined.Menu,
contentDescription = "Menu",
tint = scheme.onBackground
onClick = onMenuClick
)
}
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 hu.bbara.purefin.app.home.ui.PosterItem
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.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import javax.inject.Inject
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager
) : ViewModel() {
private val _url = userSessionRepository.serverUrl.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ""
)
private val _contents = MutableStateFlow<List<PosterItem>>(emptyList())
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) {
viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibrary(libraryId)
@@ -27,9 +56,18 @@ class LibraryViewModel @Inject constructor(
PosterItem(
id = it.id,
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.LazyVerticalGrid
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.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.library.LibraryViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.navigation.LibraryDto
@Composable
@@ -30,14 +39,42 @@ fun LibraryScreen(
val libraryItems = viewModel.contents.collectAsState()
Scaffold(
topBar = {
LibraryTopBar(
onBack = { viewModel.onBack() }
)
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
LibraryPosterGrid(libraryItems = libraryItems.value)
}
}
}
@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>,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
viewModel: LibraryViewModel = hiltViewModel()
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
@@ -49,7 +86,10 @@ fun LibraryPosterGrid(
items(libraryItems) { item ->
PosterCard(
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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -39,31 +35,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
fun MediaMetaChip(
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.unit.dp
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.common.ui.components.PurefinAsyncImage
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun PosterCard(
item: PosterItem,
modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel()
onMovieSelected: (String) -> Unit,
onSeriesSelected: (String) -> Unit,
onEpisodeSelected: (String) -> Unit,
) {
val scheme = MaterialTheme.colorScheme
fun openItem(posterItem: PosterItem) {
when (posterItem.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString())
BaseItemKind.EPISODE -> viewModel.onEpisodeSelected(posterItem.id.toString())
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id.toString())
BaseItemKind.SERIES -> onSeriesSelected(posterItem.id.toString())
BaseItemKind.EPISODE -> onEpisodeSelected(posterItem.id.toString())
else -> {}
}
}
@@ -46,7 +45,7 @@ fun PosterCard(
.width(144.dp)
) {
PurefinAsyncImage(
model = viewModel.getImageUrl(item.imageItemId, ImageType.PRIMARY),
model = item.imageUrl,
contentDescription = null,
modifier = Modifier
.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)
// Convert empty string to null to properly trigger fallback
val effectiveModel = when {
model is String && model.isEmpty() -> null
else -> model
}
AsyncImage(
model = model,
model = effectiveModel,
contentDescription = contentDescription,
modifier = modifier,
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 {
companion object {
fun toImageUrl(url: String, itemId: UUID, type: ImageType): String {
if (url.isEmpty()) {
return ""
}
return StringBuilder()
.append(url)
.append("/Items/")