feat(MediaImageCard): add dropdown menu for marking media as watched/unwatched

This commit is contained in:
2026-05-03 22:09:46 +02:00
parent f2c46af410
commit 31c1b0dd92
9 changed files with 111 additions and 32 deletions

View File

@@ -1,10 +1,12 @@
package hu.bbara.purefin.ui.common.card package hu.bbara.purefin.ui.common.card
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column 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.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -12,11 +14,22 @@ import androidx.compose.foundation.layout.fillMaxWidth
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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.material3.Text
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.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -26,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaAction
@Composable @Composable
fun MediaImageCard( fun MediaImageCard(
@@ -34,6 +48,7 @@ fun MediaImageCard(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
popupActions: List<MediaAction> = emptyList(),
imageModifier: Modifier = Modifier, imageModifier: Modifier = Modifier,
shapeSize: Dp = 12.dp, shapeSize: Dp = 12.dp,
imageAspectRatio: Float = 16f / 10f, imageAspectRatio: Float = 16f / 10f,
@@ -45,6 +60,8 @@ fun MediaImageCard(
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(shapeSize) val shape = RoundedCornerShape(shapeSize)
var expanded by remember { mutableStateOf(false) }
Card( Card(
onClick = onClick, onClick = onClick,
shape = shape, shape = shape,
@@ -67,6 +84,11 @@ fun MediaImageCard(
) )
imageOverlay() imageOverlay()
} }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.padding(textPadding)) { Column(modifier = Modifier.padding(textPadding)) {
Text( Text(
text = title, text = title,
@@ -88,6 +110,30 @@ fun MediaImageCard(
) )
} }
} }
if (popupActions.isNotEmpty()) {
Box() {
IconButton(
onClick = { expanded = !expanded },
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "More actions"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
popupActions.forEach { action ->
DropdownMenuItem(
text = { Text(text = action.name) },
onClick = action.onClick
)
}
}
}
}
}
} }
} }
} }

View File

@@ -0,0 +1,6 @@
package hu.bbara.purefin.ui.model
data class MediaAction(
val name: String,
val onClick: () -> Unit
)

View File

@@ -91,6 +91,7 @@ fun AppScreen(
library.name library.name
) )
}, },
onMarkWatched = viewModel::markAsWatched,
onProfileClick = {}, onProfileClick = {},
onCheckForUpdates = { updateViewModel.checkForUpdates() }, onCheckForUpdates = { updateViewModel.checkForUpdates() },
isCheckingForUpdates = isCheckingForUpdates, isCheckingForUpdates = isCheckingForUpdates,

View File

@@ -26,6 +26,7 @@ fun HomeScreen(
onRefresh: () -> Unit, onRefresh: () -> Unit,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
onMarkWatched: (MediaUiModel, Boolean) -> Unit,
onProfileClick: () -> Unit, onProfileClick: () -> Unit,
onCheckForUpdates: () -> Unit, onCheckForUpdates: () -> Unit,
isCheckingForUpdates: Boolean, isCheckingForUpdates: Boolean,
@@ -71,6 +72,7 @@ fun HomeScreen(
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
onLibrarySelected = onLibrarySelected, onLibrarySelected = onLibrarySelected,
onBrowseLibrariesClick = { onTabSelected(1) }, onBrowseLibrariesClick = { onTabSelected(1) },
onMarkAsWatched = onMarkWatched,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) )
} }

View File

@@ -42,6 +42,7 @@ fun HomeContent(
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
onBrowseLibrariesClick: () -> Unit, onBrowseLibrariesClick: () -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -105,6 +106,7 @@ fun HomeContent(
item(key = "continue-watching") { item(key = "continue-watching") {
ContinueWatchingSection( ContinueWatchingSection(
items = continueWatching, items = continueWatching,
onMarkAsWatched = onMarkAsWatched,
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected
) )
} }

View File

@@ -15,6 +15,7 @@ import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.MediaImageCard import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.ui.model.MediaUiModel
@Composable @Composable
@@ -22,6 +23,7 @@ internal fun ContinueWatchingCard(
item: MediaUiModel, item: MediaUiModel,
sharedBoundsKey: String, sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -34,6 +36,16 @@ internal fun ContinueWatchingCard(
title = item.primaryText, title = item.primaryText,
subtitle = item.secondaryText, subtitle = item.secondaryText,
onClick = onClick, onClick = onClick,
popupActions = listOf(
MediaAction(
name = "Mark as watched",
onClick = { onMarkAsWatched(item, true) }
),
MediaAction(
name = "Mark as unwatched",
onClick = { onMarkAsWatched(item, false) }
)
),
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey), imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
shapeSize = 26.dp, shapeSize = 26.dp,
imageAspectRatio = 16f / 9f, imageAspectRatio = 16f / 9f,

View File

@@ -21,6 +21,7 @@ import hu.bbara.purefin.ui.model.MediaUiModel
fun ContinueWatchingSection( fun ContinueWatchingSection(
items: List<MediaUiModel>, items: List<MediaUiModel>,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -47,7 +48,8 @@ fun ContinueWatchingSection(
ContinueWatchingCard( ContinueWatchingCard(
item = item, item = item,
sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id), sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id),
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched
) )
} }
} }

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.LocalMediaRepository import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.download.MediaDownloadController import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media import hu.bbara.purefin.model.Media
import hu.bbara.purefin.navigation.EpisodeDto import hu.bbara.purefin.navigation.EpisodeDto
@@ -34,8 +35,9 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AppViewModel @Inject constructor( class AppViewModel @Inject constructor(
private val homeRepository: HomeRepository, private val homeRepository: HomeRepository,
private val mediaCatalogReader: LocalMediaRepository, private val localMediaRepository: LocalMediaRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() { ) : ViewModel() {
@@ -59,8 +61,8 @@ class AppViewModel @Inject constructor(
posterUrl = it.posterUrl, posterUrl = it.posterUrl,
size = it.size, size = it.size,
isEmpty = when (it.type) { isEmpty = when (it.type) {
LibraryKind.MOVIES -> mediaCatalogReader.movies.value.isEmpty() LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
LibraryKind.SERIES -> mediaCatalogReader.series.value.isEmpty() LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
} }
) )
} }
@@ -68,9 +70,9 @@ class AppViewModel @Inject constructor(
val suggestions = combine( val suggestions = combine(
homeRepository.suggestions, homeRepository.suggestions,
mediaCatalogReader.movies, localMediaRepository.movies,
mediaCatalogReader.series, localMediaRepository.series,
mediaCatalogReader.episodes localMediaRepository.episodes
) { list, moviesMap, seriesMap, episodesMap -> ) { list, moviesMap, seriesMap, episodesMap ->
list.mapNotNull { media -> list.mapNotNull { media ->
when (media) { when (media) {
@@ -94,8 +96,8 @@ class AppViewModel @Inject constructor(
val continueWatching = combine( val continueWatching = combine(
homeRepository.continueWatching, homeRepository.continueWatching,
mediaCatalogReader.movies, localMediaRepository.movies,
mediaCatalogReader.episodes localMediaRepository.episodes
) { list, moviesMap, episodesMap -> ) { list, moviesMap, episodesMap ->
list.mapNotNull { media -> list.mapNotNull { media ->
when (media) { when (media) {
@@ -116,7 +118,7 @@ class AppViewModel @Inject constructor(
val nextUp = combine( val nextUp = combine(
homeRepository.nextUp, homeRepository.nextUp,
mediaCatalogReader.episodes localMediaRepository.episodes
) { list, episodesMap -> ) { list, episodesMap ->
list.mapNotNull { media -> list.mapNotNull { media ->
when (media) { when (media) {
@@ -134,9 +136,9 @@ class AppViewModel @Inject constructor(
val latestLibraryContent = combine( val latestLibraryContent = combine(
homeRepository.latestLibraryContent, homeRepository.latestLibraryContent,
mediaCatalogReader.movies, localMediaRepository.movies,
mediaCatalogReader.series, localMediaRepository.series,
mediaCatalogReader.episodes localMediaRepository.episodes
) { libraryMap, moviesMap, seriesMap, episodesMap -> ) { libraryMap, moviesMap, seriesMap, episodesMap ->
libraryMap.mapValues { (_, items) -> libraryMap.mapValues { (_, items) ->
items.mapNotNull { media -> items.mapNotNull { media ->
@@ -162,6 +164,12 @@ class AppViewModel @Inject constructor(
initialValue = emptyMap() initialValue = emptyMap()
) )
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
}
}
fun onLibrarySelected(id: UUID, name: String) { fun onLibrarySelected(id: UUID, name: String) {
viewModelScope.launch { viewModelScope.launch {
navigationManager.navigate(Route.LibraryRoute(library = LibraryDto(id = id, name = name))) navigationManager.navigate(Route.LibraryRoute(library = LibraryDto(id = id, name = name)))