mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(MediaImageCard): add dropdown menu for marking media as watched/unwatched
This commit is contained in:
@@ -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,26 +84,55 @@ fun MediaImageCard(
|
|||||||
)
|
)
|
||||||
imageOverlay()
|
imageOverlay()
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.padding(textPadding)) {
|
Row(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = title,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = titleStyle,
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
fontWeight = FontWeight.SemiBold,
|
) {
|
||||||
maxLines = 1,
|
Column(modifier = Modifier.padding(textPadding)) {
|
||||||
overflow = TextOverflow.Ellipsis
|
Text(
|
||||||
)
|
text = title,
|
||||||
subtitle
|
style = titleStyle,
|
||||||
?.takeIf { it.isNotBlank() }
|
fontWeight = FontWeight.SemiBold,
|
||||||
?.let { text ->
|
maxLines = 1,
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
overflow = TextOverflow.Ellipsis
|
||||||
Text(
|
)
|
||||||
text = text,
|
subtitle
|
||||||
style = subtitleStyle,
|
?.takeIf { it.isNotBlank() }
|
||||||
color = scheme.onSurfaceVariant,
|
?.let { text ->
|
||||||
maxLines = 1,
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
overflow = TextOverflow.Ellipsis
|
Text(
|
||||||
)
|
text = text,
|
||||||
|
style = subtitleStyle,
|
||||||
|
color = scheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package hu.bbara.purefin.ui.model
|
||||||
|
|
||||||
|
data class MediaAction(
|
||||||
|
val name: String,
|
||||||
|
val onClick: () -> Unit
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
Reference in New Issue
Block a user