Compare commits

...

5 Commits

Author SHA1 Message Date
8d194f2baf chore(build): increment app versionCode to 1000033 2026-06-26 18:40:24 +02:00
c836c809fc feat(series): add confirmation dialog before marking series as watched 2026-06-26 16:39:37 +00:00
b6fc10df57 chore(build): increment app-tv versionCode to 2000017 2026-06-26 18:34:43 +02:00
18e68c6051 feat(series): add long-click context menu to episode cards
Add a ModalBottomSheet with Mark as watched/unwatched actions on long-click of episode cards.

- Replace clickable with combinedClickable to support long-click gestures
- Add markEpisodeAsWatched function to SeriesViewModel
- Add MediaAction model usage for bottom sheet actions
- Add test tag for the dialog component
2026-06-26 16:32:53 +00:00
e8224849cc fix(player): prevent hidden seek timeline from showing when controls are visible 2026-06-26 18:29:05 +02:00
5 changed files with 104 additions and 12 deletions

View File

@@ -73,12 +73,12 @@ import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
@@ -379,7 +379,7 @@ fun TvPlayerScreen(
}
}
if (!showSkipIntroButton) {
if (!showSkipIntroButton && !controlsVisible) {
ValueChangeTimedVisibility(
value = hiddenSeekCounter,
hideAfterMillis = 2500L,

View File

@@ -3,9 +3,11 @@ package hu.bbara.purefin.ui.screen.series
import android.content.Intent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -144,6 +146,7 @@ private fun SeriesScreenInternal(
val context = LocalContext.current
val navigationManager = LocalNavigationManager.current
var showDownloadDialog by remember { mutableStateOf(false) }
var showMarkAsWatchedDialog by remember { mutableStateOf(false) }
fun getDefaultSeason(): Season? {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
@@ -188,7 +191,13 @@ private fun SeriesScreenInternal(
nextUpEpisode = nextUpEpisode,
onPlayClick = playAction ?: {},
onDownloadClick = { showDownloadDialog = true },
onMarkAsWatched = onMarkAsWatched,
onMarkAsWatched = { watched ->
if (watched) {
showMarkAsWatchedDialog = true
} else {
onMarkAsWatched(false)
}
},
bodyColor = scheme.onSurface
),
modifier = modifier,
@@ -233,6 +242,16 @@ private fun SeriesScreenInternal(
onDismiss = { showDownloadDialog = false }
)
}
if (showMarkAsWatchedDialog) {
MarkSeriesAsWatchedConfirmationDialog(
onConfirm = {
showMarkAsWatchedDialog = false
onMarkAsWatched(true)
},
onDismiss = { showMarkAsWatchedDialog = false }
)
}
}
private fun Series.toMediaDetailScaffoldUiModel(
@@ -312,3 +331,25 @@ private fun SeriesDownloadOption.requiresNotificationPermission(): Boolean = whe
SeriesDownloadOption.SMART -> true
SeriesDownloadOption.DELETE_SMART -> false
}
@Composable
private fun MarkSeriesAsWatchedConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Mark series as watched?") },
text = { Text("This will mark every episode of this series as watched.") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Mark as watched")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -1,8 +1,10 @@
package hu.bbara.purefin.ui.screen.series.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -60,6 +62,7 @@ import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class)
@@ -315,6 +318,7 @@ internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modif
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun EpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(),
@@ -322,15 +326,31 @@ private fun EpisodeCard(
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
var showBottomSheet by remember { mutableStateOf(false) }
val popupActions = remember(episode.id) {
listOf(
MediaAction(name = "Mark as watched") {
viewModel.markEpisodeAsWatched(episode.id, true)
},
MediaAction(name = "Mark as unwatched") {
viewModel.markEpisodeAsWatched(episode.id, false)
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.testTag("$SeriesEpisodeCardTagPrefix${episode.id}")
.clickable { viewModel.onSelectEpisode(
.combinedClickable(
onClick = {
viewModel.onSelectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
) },
)
},
onLongClick = { showBottomSheet = true }
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -389,6 +409,30 @@ private fun EpisodeCard(
)
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
modifier = Modifier.testTag(SeriesEpisodeActionsDialogTag),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
popupActions.forEach { action ->
ListItem(
headlineContent = { Text(text = action.name) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable {
action.onClick()
showBottomSheet = false
}
)
}
}
}
}
}
internal const val SeriesPlayButtonTag = "series-play-button"
@@ -402,6 +446,7 @@ internal const val SeriesSmartDownloadButtonTag = "series-smart-download-button"
internal const val SeriesSeasonSelectorTag = "series-season-selector"
internal const val SeriesEpisodeCarouselTag = "series-episode-carousel"
internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-"
internal const val SeriesEpisodeActionsDialogTag = "series-episode-actions-dialog"
@Composable
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {

View File

@@ -186,6 +186,12 @@ class SeriesViewModel @Inject constructor(
}
}
fun markEpisodeAsWatched(episodeId: UUID, watched: Boolean) {
viewModelScope.launch {
mediaMetadataUpdater.markAsWatched(episodeId, watched)
}
}
fun selectSeries(series: SeriesDto) {
_series.value = series
viewModelScope.launch {

View File

@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.builtInKotlin=false
android.newDsl=false
purefinReleaseVersionCode=1000032
purefinReleaseVersionCode=1000033
purefinDebugVersionCode=1000003
purefinTvVersionCode=2000016
purefinTvVersionCode=2000017