feat(ui): add watched toggle to detail screens and home/library cards

Add mark-as-watched/unwatched bottom sheet actions to home screen
NextUp cards and library browse cards, and to library screen grid items.
Add a single-button watched toggle (with dual icon/color states) to
Movie, Episode, and Series detail screens. Inject
JellyfinMediaMetadataUpdater into all four affected ViewModels.
This commit is contained in:
2026-06-12 17:47:14 +00:00
parent c92a8831a5
commit def92ad111
17 changed files with 155 additions and 8 deletions

View File

@@ -21,7 +21,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -95,6 +97,12 @@ sealed interface MediaDetailSecondaryActionUiModel {
val onClick: () -> Unit = {}, val onClick: () -> Unit = {},
override val testTag: String? = null, override val testTag: String? = null,
) : MediaDetailSecondaryActionUiModel ) : MediaDetailSecondaryActionUiModel
data class MarkAsWatched(
val watched: Boolean,
val onClick: () -> Unit,
override val testTag: String? = null,
) : MediaDetailSecondaryActionUiModel
} }
data class MediaDetailSynopsisUiModel( data class MediaDetailSynopsisUiModel(
@@ -356,6 +364,25 @@ private fun MediaDetailActions(
modifier = Modifier.optionalTestTag(action.testTag) modifier = Modifier.optionalTestTag(action.testTag)
) )
} }
is MediaDetailSecondaryActionUiModel.MarkAsWatched -> {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = if (action.watched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
icon = if (action.watched) {
Icons.Outlined.CheckCircle
} else {
Icons.Outlined.RadioButtonUnchecked
},
height = 48.dp,
onClick = action.onClick,
modifier = Modifier.optionalTestTag(action.testTag)
)
}
} }
} }
} }

View File

@@ -34,6 +34,7 @@ import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDownloadButtonTag import hu.bbara.purefin.ui.screen.episode.components.EpisodeDownloadButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodeWatchedButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -83,6 +84,7 @@ fun EpisodeScreen(
downloadState = downloadState.value, downloadState = downloadState.value,
onBack = viewModel::onBack, onBack = viewModel::onBack,
onDownloadClick = onDownloadClick, onDownloadClick = onDownloadClick,
onMarkAsWatched = viewModel::markAsWatched,
modifier = modifier modifier = modifier
) )
} }
@@ -95,6 +97,7 @@ private fun EpisodeScreenInternal(
downloadState: DownloadState, downloadState: DownloadState,
onBack: () -> Unit, onBack: () -> Unit,
onDownloadClick: () -> Unit, onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -110,7 +113,8 @@ private fun EpisodeScreenInternal(
uiModel = episode.toMediaDetailScaffoldUiModel( uiModel = episode.toMediaDetailScaffoldUiModel(
onPlayClick = playAction, onPlayClick = playAction,
downloadState = downloadState, downloadState = downloadState,
onDownloadClick = onDownloadClick onDownloadClick = onDownloadClick,
onMarkAsWatched = onMarkAsWatched,
), ),
modifier = modifier, modifier = modifier,
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
@@ -127,6 +131,7 @@ private fun Episode.toMediaDetailScaffoldUiModel(
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
downloadState: DownloadState, downloadState: DownloadState,
onDownloadClick: () -> Unit, onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel( ): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY), imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = title, title = title,
@@ -141,6 +146,11 @@ private fun Episode.toMediaDetailScaffoldUiModel(
testTag = EpisodePlayButtonTag testTag = EpisodePlayButtonTag
), ),
secondaryActions = listOf( secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.MarkAsWatched(
watched = watched,
onClick = { onMarkAsWatched(!watched) },
testTag = EpisodeWatchedButtonTag
),
MediaDetailSecondaryActionUiModel.Download( MediaDetailSecondaryActionUiModel.Download(
downloadState = downloadState, downloadState = downloadState,
onClick = onDownloadClick, onClick = onDownloadClick,

View File

@@ -88,3 +88,4 @@ internal fun EpisodeTopBar(
internal const val EpisodeSeriesButtonTag = "episode-series-button" internal const val EpisodeSeriesButtonTag = "episode-series-button"
internal const val EpisodePlayButtonTag = "episode-play-button" internal const val EpisodePlayButtonTag = "episode-play-button"
internal const val EpisodeDownloadButtonTag = "episode-download-button" internal const val EpisodeDownloadButtonTag = "episode-download-button"
internal const val EpisodeWatchedButtonTag = "episode-watched-button"

View File

@@ -124,6 +124,7 @@ fun HomeContent(
NextUpSection( NextUpSection(
items = nextUp, items = nextUp,
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag(HomeNextUpSectionTag) modifier = Modifier.testTag(HomeNextUpSectionTag)
) )
} }
@@ -138,6 +139,7 @@ fun HomeContent(
items = libraryContent[library.id].orEmpty(), items = libraryContent[library.id].orEmpty(),
onLibrarySelected = onLibrarySelected, onLibrarySelected = onLibrarySelected,
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}") modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}")
) )
} }

View File

@@ -10,28 +10,41 @@ import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.SeriesUiModel import hu.bbara.purefin.core.model.SeriesUiModel
import androidx.compose.runtime.remember
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
import hu.bbara.purefin.ui.common.badge.WatchStateBadge import hu.bbara.purefin.ui.common.badge.WatchStateBadge
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
@Composable @Composable
internal fun HomeBrowseCard( internal fun HomeBrowseCard(
uiModel: MediaUiModel, uiModel: MediaUiModel,
sharedBoundsKey: String, sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) { val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel) onMediaSelected(uiModel)
} }
val popupActions = remember(uiModel, onMarkAsWatched) {
buildList {
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
}
}
}
MediaImageCard( MediaImageCard(
imageUrl = uiModel.primaryImageUrl, imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText, title = uiModel.primaryText,
subtitle = uiModel.secondaryText, subtitle = uiModel.secondaryText,
onClick = onClick, onClick = onClick,
popupActions = popupActions,
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey), imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
imageAspectRatio = 15f / 16f, imageAspectRatio = 15f / 16f,
modifier = modifier.width(188.dp) modifier = modifier.width(188.dp)

View File

@@ -25,6 +25,7 @@ fun LibraryPosterSection(
items: List<MediaUiModel>, items: List<MediaUiModel>,
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -61,6 +62,7 @@ fun LibraryPosterSection(
mediaId = item.id mediaId = item.id
), ),
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeLibraryItemTagPrefix${library.id}-$index") modifier = Modifier.testTag("$HomeLibraryItemTagPrefix${library.id}-$index")
) )
} }

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -13,24 +14,39 @@ import androidx.compose.ui.unit.dp
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.core.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.core.model.SeriesUiModel
@Composable @Composable
internal fun NextUpCard( internal fun NextUpCard(
uiModel: MediaUiModel, uiModel: MediaUiModel,
sharedBoundsKey: String, sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) { val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel) onMediaSelected(uiModel)
} }
val popupActions = remember(uiModel, onMarkAsWatched) {
buildList {
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
}
}
}
MediaImageCard( MediaImageCard(
imageUrl = uiModel.primaryImageUrl, imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText, title = uiModel.primaryText,
subtitle = uiModel.secondaryText, subtitle = uiModel.secondaryText,
onClick = onClick, onClick = onClick,
popupActions = popupActions,
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey), imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
shapeSize = 24.dp, shapeSize = 24.dp,
titleStyle = MaterialTheme.typography.titleSmall, titleStyle = MaterialTheme.typography.titleSmall,

View File

@@ -22,6 +22,7 @@ import hu.bbara.purefin.core.model.MediaUiModel
fun NextUpSection( fun NextUpSection(
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
@@ -51,6 +52,7 @@ fun NextUpSection(
uiModel = item, uiModel = item,
sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id), sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id),
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeNextUpItemTagPrefix$index") modifier = Modifier.testTag("$HomeNextUpItemTagPrefix$index")
) )
} }

View File

@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -30,6 +31,7 @@ import hu.bbara.purefin.core.model.SeriesUiModel
import hu.bbara.purefin.core.navigation.LibraryDto import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.ui.common.badge.WatchStateBadge import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.card.MediaImageCard import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar
@@ -66,7 +68,10 @@ fun LibraryScreen(
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.padding(innerPadding) .padding(innerPadding)
) { ) {
LibraryPosterGrid(libraryItems = libraryItems.value) LibraryPosterGrid(
libraryItems = libraryItems.value,
onMarkAsWatched = viewModel::markAsWatched,
)
} }
} }
} }
@@ -75,6 +80,7 @@ fun LibraryScreen(
internal fun LibraryPosterGrid( internal fun LibraryPosterGrid(
libraryItems: List<MediaUiModel>, libraryItems: List<MediaUiModel>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
viewModel: LibraryViewModel = hiltViewModel() viewModel: LibraryViewModel = hiltViewModel()
) { ) {
BoxWithConstraints( BoxWithConstraints(
@@ -91,6 +97,15 @@ internal fun LibraryPosterGrid(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(libraryItems, key = { item -> item.id }) { item -> items(libraryItems, key = { item -> item.id }) { item ->
val popupActions = remember {
buildList {
if (item is MovieUiModel || item is EpisodeUiModel || item is SeriesUiModel) {
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(item, true) })
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(item, false) })
}
}
}
MediaImageCard( MediaImageCard(
imageUrl = item.primaryImageUrl, imageUrl = item.primaryImageUrl,
title = item.primaryText, title = item.primaryText,
@@ -102,6 +117,7 @@ internal fun LibraryPosterGrid(
is EpisodeUiModel -> Unit is EpisodeUiModel -> Unit
} }
}, },
popupActions = popupActions,
modifier = Modifier.testTag("$LibraryPosterItemTagPrefix${item.id}") modifier = Modifier.testTag("$LibraryPosterItemTagPrefix${item.id}")
) { ) {
when (item) { when (item) {

View File

@@ -28,6 +28,7 @@ import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.screen.movie.components.MovieWatchedButtonTag
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.movie.components.MovieDownloadButtonTag import hu.bbara.purefin.ui.screen.movie.components.MovieDownloadButtonTag
@@ -67,6 +68,7 @@ fun MovieScreen(
downloadState = downloadState.value, downloadState = downloadState.value,
onDownloadClick = onDownloadClick, onDownloadClick = onDownloadClick,
onBack = viewModel::onBack, onBack = viewModel::onBack,
onMarkAsWatched = viewModel::markAsWatched,
modifier = modifier modifier = modifier
) )
} else { } else {
@@ -81,6 +83,7 @@ private fun MovieScreenInternal(
downloadState: DownloadState = DownloadState.NotDownloaded, downloadState: DownloadState = DownloadState.NotDownloaded,
onDownloadClick: () -> Unit = {}, onDownloadClick: () -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -96,7 +99,8 @@ private fun MovieScreenInternal(
uiModel = movie.toMediaDetailScaffoldUiModel( uiModel = movie.toMediaDetailScaffoldUiModel(
onPlayClick = playAction, onPlayClick = playAction,
downloadState = downloadState, downloadState = downloadState,
onDownloadClick = onDownloadClick onDownloadClick = onDownloadClick,
onMarkAsWatched = onMarkAsWatched,
), ),
modifier = modifier, modifier = modifier,
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
@@ -112,6 +116,7 @@ private fun Movie.toMediaDetailScaffoldUiModel(
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
downloadState: DownloadState, downloadState: DownloadState,
onDownloadClick: () -> Unit, onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel( ): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY), imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = title, title = title,
@@ -125,6 +130,11 @@ private fun Movie.toMediaDetailScaffoldUiModel(
testTag = MoviePlayButtonTag testTag = MoviePlayButtonTag
), ),
secondaryActions = listOf( secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.MarkAsWatched(
watched = watched,
onClick = { onMarkAsWatched(!watched) },
testTag = MovieWatchedButtonTag
),
MediaDetailSecondaryActionUiModel.Download( MediaDetailSecondaryActionUiModel.Download(
downloadState = downloadState, downloadState = downloadState,
onClick = onDownloadClick, onClick = onDownloadClick,

View File

@@ -38,3 +38,4 @@ internal fun MovieTopBar(
internal const val MoviePlayButtonTag = "movie-play-button" internal const val MoviePlayButtonTag = "movie-play-button"
internal const val MovieDownloadButtonTag = "movie-download-button" internal const val MovieDownloadButtonTag = "movie-download-button"
internal const val MovieWatchedButtonTag = "movie-watched-button"

View File

@@ -48,6 +48,7 @@ import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
import hu.bbara.purefin.ui.screen.series.components.SeriesAddButtonTag import hu.bbara.purefin.ui.screen.series.components.SeriesAddButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadButtonTag import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadOption import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadOption
import hu.bbara.purefin.ui.screen.series.components.SeriesWatchedButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesTopBar import hu.bbara.purefin.ui.screen.series.components.SeriesTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -117,6 +118,7 @@ fun SeriesScreen(
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes, onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState, onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
onBack = viewModel::onGoHome, onBack = viewModel::onGoHome,
onMarkAsWatched = viewModel::markAsWatched,
offline = series.offline, offline = series.offline,
modifier = modifier modifier = modifier
) )
@@ -136,6 +138,7 @@ private fun SeriesScreenInternal(
onLoadSeasonEpisodes: (UUID, UUID) -> Unit, onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
onObserveSeasonDownloadState: (List<Episode>) -> Unit, onObserveSeasonDownloadState: (List<Episode>) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
offline: Boolean, offline: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -187,6 +190,7 @@ private fun SeriesScreenInternal(
nextUpEpisode = nextUpEpisode, nextUpEpisode = nextUpEpisode,
onPlayClick = playAction ?: {}, onPlayClick = playAction ?: {},
onDownloadClick = { showDownloadDialog = true }, onDownloadClick = { showDownloadDialog = true },
onMarkAsWatched = onMarkAsWatched,
bodyColor = scheme.onSurface bodyColor = scheme.onSurface
), ),
modifier = modifier, modifier = modifier,
@@ -238,6 +242,7 @@ private fun Series.toMediaDetailScaffoldUiModel(
nextUpEpisode: Episode?, nextUpEpisode: Episode?,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onDownloadClick: () -> Unit, onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
bodyColor: Color, bodyColor: Color,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel( ): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY), imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
@@ -246,6 +251,7 @@ private fun Series.toMediaDetailScaffoldUiModel(
titleLineHeight = 36.sp, titleLineHeight = 36.sp,
metadataItems = listOf(year, "$seasonCount Seasons"), metadataItems = listOf(year, "$seasonCount Seasons"),
actions = selectedSeason?.let { actions = selectedSeason?.let {
val seriesWatched = unwatchedEpisodeCount == 0
MediaDetailActionsUiModel( MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel( primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched), text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
@@ -254,6 +260,11 @@ private fun Series.toMediaDetailScaffoldUiModel(
testTag = SeriesPlayButtonTag testTag = SeriesPlayButtonTag
), ),
secondaryActions = listOf( secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.MarkAsWatched(
watched = seriesWatched,
onClick = { onMarkAsWatched(!seriesWatched) },
testTag = SeriesWatchedButtonTag
),
MediaDetailSecondaryActionUiModel.Icon( MediaDetailSecondaryActionUiModel.Icon(
icon = Icons.Outlined.Add, icon = Icons.Outlined.Add,
testTag = SeriesAddButtonTag testTag = SeriesAddButtonTag

View File

@@ -394,6 +394,7 @@ private fun EpisodeCard(
internal const val SeriesPlayButtonTag = "series-play-button" internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesAddButtonTag = "series-add-button" internal const val SeriesAddButtonTag = "series-add-button"
internal const val SeriesDownloadButtonTag = "series-download-button" internal const val SeriesDownloadButtonTag = "series-download-button"
internal const val SeriesWatchedButtonTag = "series-watched-button"
internal const val SeriesDownloadDialogTag = "series-download-dialog" internal const val SeriesDownloadDialogTag = "series-download-dialog"
internal const val SeriesDownloadSeasonButtonTag = "series-download-season-button" internal const val SeriesDownloadSeasonButtonTag = "series-download-season-button"
internal const val SeriesDownloadAllButtonTag = "series-download-all-button" internal const val SeriesDownloadAllButtonTag = "series-download-all-button"

View File

@@ -4,6 +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.HomeRepository import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.SeriesUiModel import hu.bbara.purefin.core.model.SeriesUiModel
@@ -25,6 +26,7 @@ import javax.inject.Inject
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val homeRepository: HomeRepository, private val homeRepository: HomeRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
) : ViewModel() { ) : ViewModel() {
private val selectedLibrary = MutableStateFlow<UUID?>(null) private val selectedLibrary = MutableStateFlow<UUID?>(null)
@@ -69,6 +71,12 @@ class LibraryViewModel @Inject constructor(
} }
} }
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
}
}
fun onBack() { fun onBack() {
navigationManager.pop() navigationManager.pop()
} }

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -30,6 +31,7 @@ class EpisodeScreenViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
): ViewModel() { ): ViewModel() {
private val _episode = MutableStateFlow<EpisodeDto?>(null) private val _episode = MutableStateFlow<EpisodeDto?>(null)
@@ -81,6 +83,13 @@ class EpisodeScreenViewModel @Inject constructor(
} }
} }
fun markAsWatched(watched: Boolean) {
val episodeId = _episode.value?.id ?: return
viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(episodeId, watched)
}
}
fun onDownloadClick() { fun onDownloadClick() {
val episodeId = _episode.value?.id ?: return val episodeId = _episode.value?.id ?: return
viewModelScope.launch { viewModelScope.launch {

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.core.navigation.MovieDto import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -28,6 +29,7 @@ class MovieScreenViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
): ViewModel() { ): ViewModel() {
private val _movie = MutableStateFlow<MovieDto?>(null) private val _movie = MutableStateFlow<MovieDto?>(null)
@@ -68,6 +70,13 @@ class MovieScreenViewModel @Inject constructor(
} }
} }
fun markAsWatched(watched: Boolean) {
val movieId = movie.value?.id ?: return
viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(movieId, watched)
}
}
fun onDownloadClick() { fun onDownloadClick() {
val movieId = movie.value?.id ?: return val movieId = movie.value?.id ?: return
viewModelScope.launch { viewModelScope.launch {

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -33,6 +34,7 @@ class SeriesViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
) : ViewModel() { ) : ViewModel() {
private val _series = MutableStateFlow<SeriesDto?>(null) private val _series = MutableStateFlow<SeriesDto?>(null)
@@ -183,6 +185,13 @@ class SeriesViewModel @Inject constructor(
navigationManager.replaceAll(Route.Home) navigationManager.replaceAll(Route.Home)
} }
fun markAsWatched(watched: Boolean) {
val seriesId = _series.value?.id ?: return
viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(seriesId, watched)
}
}
fun selectSeries(series: SeriesDto) { fun selectSeries(series: SeriesDto) {
_series.value = series _series.value = series
viewModelScope.launch { viewModelScope.launch {