diff --git a/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt b/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt
index d07e01e..4ea9d20 100644
--- a/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt
+++ b/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt
@@ -24,6 +24,7 @@ internal data class ResolvedMedia(
val videoNumber: Int?,
val episodeId: Int?,
val episodeTitle: String?,
+ val isCurrentMediaWatched: Boolean,
val isSeries: Boolean,
val hasNext: Boolean,
val hasPrevious: Boolean,
@@ -70,6 +71,7 @@ internal class PlayerInteractor(
videoNumber = episode?.number,
episodeId = episode?.id,
episodeTitle = episode?.title,
+ isCurrentMediaWatched = isWatched(episode?.watched),
isSeries = true,
hasNext = resolvedSeason != null && resolvedEpisode != null &&
findNextEpisode(item, resolvedSeason, resolvedEpisode) != null,
@@ -89,6 +91,7 @@ internal class PlayerInteractor(
videoNumber = video?.number,
episodeId = null,
episodeTitle = null,
+ isCurrentMediaWatched = isWatched(item.watched),
isSeries = false,
hasNext = false,
hasPrevious = false,
@@ -103,6 +106,8 @@ internal class PlayerInteractor(
else -> false
}
+ private fun isWatched(watched: Int?): Boolean = watched == WATCHED_STATUS
+
fun findEpisode(item: Item, seasonNumber: Int, episodeNumber: Int) =
item.seasons
?.find { it.number == seasonNumber }
@@ -223,6 +228,16 @@ internal class PlayerInteractor(
api.toggleWatchingStatus(id, status = WATCHED_STATUS, season = season, video = videoNumber)
}
+ suspend fun markCurrentAsWatched(id: Int, season: Int? = null, episode: Int? = null): Item {
+ api.toggleWatchingStatus(
+ id = id,
+ status = WATCHED_STATUS,
+ season = season,
+ video = episode,
+ ).getOrThrow()
+ return itemDetailsRepository.refresh(id)
+ }
+
suspend fun setEpisodeWatched(id: Int, season: Int, episode: Int, watched: Boolean): Item {
api.toggleWatchingStatus(
id = id,
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt
index 1ad2782..4b676c9 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerButtonRow.kt
@@ -30,6 +30,11 @@ import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
+import com.adamglin.PhosphorIcons
+import com.adamglin.phosphoricons.Duotone
+import com.adamglin.phosphoricons.Fill
+import com.adamglin.phosphoricons.duotone.Eye
+import com.adamglin.phosphoricons.fill.Eye
import com.kino.puber.R
@Composable
@@ -58,6 +63,8 @@ internal data class PlayerButtonRowState(
val isPlaying: Boolean,
val hasNextEpisode: Boolean,
val hasPreviousEpisode: Boolean,
+ val canMarkCurrentWatched: Boolean,
+ val isCurrentMediaWatched: Boolean,
)
@Composable
@@ -72,6 +79,19 @@ private fun PrimaryControls(
onClick = actions.onTogglePlayPause,
focusRequester = focusRequesters.firstButton,
)
+ if (state.canMarkCurrentWatched) {
+ PlayerButton(
+ text = stringResource(R.string.player_button_mark_watched),
+ icon = if (state.isCurrentMediaWatched) {
+ PhosphorIcons.Fill.Eye
+ } else {
+ PhosphorIcons.Duotone.Eye
+ },
+ onClick = actions.onMarkCurrentWatchedClick,
+ selected = state.isCurrentMediaWatched,
+ contentDescription = stringResource(R.string.player_button_mark_watched_content_description),
+ )
+ }
if (!state.isMovie) {
PlayerButton(
text = stringResource(R.string.player_button_episodes),
@@ -154,15 +174,17 @@ private fun PlayerButton(
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
+ selected: Boolean = false,
+ contentDescription: String? = null,
) {
Button(
onClick = onClick,
modifier = modifier,
- colors = transparentButtonColors(),
+ colors = if (selected) selectedButtonColors() else transparentButtonColors(),
) {
Icon(
imageVector = icon,
- contentDescription = null,
+ contentDescription = contentDescription,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
@@ -180,3 +202,11 @@ private fun transparentButtonColors() = ButtonDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.primary,
focusedContentColor = MaterialTheme.colorScheme.onPrimary,
)
+
+@Composable
+private fun selectedButtonColors() = ButtonDefaults.colors(
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.primary,
+ focusedContainerColor = MaterialTheme.colorScheme.primary,
+ focusedContentColor = MaterialTheme.colorScheme.onPrimary,
+)
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt
index 595dd42..187b6b2 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerControlsOverlay.kt
@@ -30,7 +30,10 @@ internal fun PlayerControlsOverlay(
isPlaying: Boolean,
hasNextEpisode: Boolean,
hasPreviousEpisode: Boolean,
+ canMarkCurrentWatched: Boolean,
+ isCurrentMediaWatched: Boolean,
onEpisodesClick: () -> Unit,
+ onMarkCurrentWatchedClick: () -> Unit,
onAudioSubtitlesClick: () -> Unit,
onVideoSettingsClick: () -> Unit,
onNextEpisodeClick: () -> Unit,
@@ -67,9 +70,12 @@ internal fun PlayerControlsOverlay(
isPlaying = isPlaying,
hasNextEpisode = hasNextEpisode,
hasPreviousEpisode = hasPreviousEpisode,
+ canMarkCurrentWatched = canMarkCurrentWatched,
+ isCurrentMediaWatched = isCurrentMediaWatched,
),
actions = PlayerControlActions(
onEpisodesClick = onEpisodesClick,
+ onMarkCurrentWatchedClick = onMarkCurrentWatchedClick,
onAudioSubtitlesClick = onAudioSubtitlesClick,
onVideoSettingsClick = onVideoSettingsClick,
onNextEpisodeClick = onNextEpisodeClick,
@@ -99,6 +105,7 @@ private data class ProgressBarState(
internal data class PlayerControlActions(
val onEpisodesClick: () -> Unit,
+ val onMarkCurrentWatchedClick: () -> Unit,
val onAudioSubtitlesClick: () -> Unit,
val onVideoSettingsClick: () -> Unit,
val onNextEpisodeClick: () -> Unit,
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerOverlayLayers.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerOverlayLayers.kt
index c0e7d1e..30e8a57 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerOverlayLayers.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerOverlayLayers.kt
@@ -75,7 +75,10 @@ internal fun PlayerControlsLayer(
isPlaying = content.isPlaying,
hasNextEpisode = content.hasNextEpisode,
hasPreviousEpisode = content.hasPreviousEpisode,
+ canMarkCurrentWatched = content.canMarkCurrentWatched,
+ isCurrentMediaWatched = content.isCurrentMediaWatched,
onEpisodesClick = rememberAction(onAction, PlayerAction.OpenEpisodesPanel),
+ onMarkCurrentWatchedClick = rememberAction(onAction, PlayerAction.MarkCurrentWatched),
onAudioSubtitlesClick = rememberAction(onAction, PlayerAction.OpenAudioSubtitlesPanel),
onVideoSettingsClick = rememberAction(onAction, PlayerAction.OpenVideoSettingsPanel),
onNextEpisodeClick = rememberAction(onAction, PlayerAction.NextEpisode),
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerPreview.kt b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerPreview.kt
index 13ce0f6..24ee571 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerPreview.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/component/PlayerPreview.kt
@@ -92,6 +92,8 @@ private fun previewSeriesContent(
seekIndicator: SeekIndicatorState? = null,
nextEpisodeCountdown: Int? = null,
resumeDialog: ResumeDialogState? = null,
+ canMarkCurrentWatched: Boolean = true,
+ isCurrentMediaWatched: Boolean = false,
) = PlayerContentState(
title = "Кибердеревня",
subtitle = "2 сезон, 6 серия, Что там, за железным занавесом?",
@@ -122,6 +124,8 @@ private fun previewSeriesContent(
isMovie = false,
hasNextEpisode = true,
hasPreviousEpisode = true,
+ canMarkCurrentWatched = canMarkCurrentWatched,
+ isCurrentMediaWatched = isCurrentMediaWatched,
nextEpisodeCountdown = nextEpisodeCountdown,
resumeDialog = resumeDialog,
episodes = previewEpisodes,
@@ -132,6 +136,8 @@ private fun previewMovieContent(
controlsVisible: Boolean = true,
activePanel: ActivePanel = ActivePanel.None,
resumeDialog: ResumeDialogState? = null,
+ canMarkCurrentWatched: Boolean = true,
+ isCurrentMediaWatched: Boolean = false,
) = PlayerContentState(
title = "Граф Монте-Кристо",
subtitle = null,
@@ -162,6 +168,8 @@ private fun previewMovieContent(
isMovie = true,
hasNextEpisode = false,
hasPreviousEpisode = false,
+ canMarkCurrentWatched = canMarkCurrentWatched,
+ isCurrentMediaWatched = isCurrentMediaWatched,
nextEpisodeCountdown = null,
resumeDialog = resumeDialog,
episodes = null,
@@ -212,6 +220,16 @@ private fun MovieControlsPreview() = PuberTheme {
)
}
+@Preview(name = "Movie — watched control active", device = TV_1080p)
+@Composable
+private fun MovieWatchedControlsPreview() = PuberTheme {
+ PlayerScreenContent(
+ state = PlayerViewState.Content(previewMovieContent(isCurrentMediaWatched = true)),
+ onAction = {},
+ exoPlayer = { null },
+ )
+}
+
@Preview(name = "Series — controls hidden + seek forward", device = TV_1080p)
@Composable
private fun SeekForwardPreview() = PuberTheme {
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerAction.kt b/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerAction.kt
index 3f97207..5b36eb5 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerAction.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerAction.kt
@@ -39,6 +39,7 @@ internal sealed class PlayerAction : UIAction {
data class SelectEpisodeById(val episodeId: Int) : PlayerAction()
data class EpisodeWatchedChanged(val item: VideoItemUIState, val watched: Boolean) : PlayerAction()
data class SeasonWatchedChanged(val item: VideoItemUIState, val watched: Boolean) : PlayerAction()
+ data object MarkCurrentWatched : PlayerAction()
data object NextEpisode : PlayerAction()
data object PreviousEpisode : PlayerAction()
data object CancelNextEpisodeCountdown : PlayerAction()
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerViewState.kt b/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerViewState.kt
index 24568b1..3ad1993 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerViewState.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/model/PlayerViewState.kt
@@ -58,6 +58,8 @@ internal data class PlayerContentState(
val isMovie: Boolean,
val hasNextEpisode: Boolean,
val hasPreviousEpisode: Boolean,
+ val canMarkCurrentWatched: Boolean,
+ val isCurrentMediaWatched: Boolean,
// Next episode countdown
val nextEpisodeCountdown: Int?,
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/ContentStateFactory.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/ContentStateFactory.kt
index 595bd22..06440c9 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/vm/ContentStateFactory.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/ContentStateFactory.kt
@@ -51,6 +51,9 @@ internal class ContentStateFactory(private val mapper: PlayerUIMapper) {
isMovie = !resolved.isSeries,
hasNextEpisode = resolved.hasNext,
hasPreviousEpisode = resolved.hasPrevious,
+ canMarkCurrentWatched = !resolved.isSeries ||
+ (resolved.seasonNumber != null && resolved.episodeNumber != null),
+ isCurrentMediaWatched = resolved.isCurrentMediaWatched,
nextEpisodeCountdown = null,
resumeDialog = resumeDialog,
episodes = if (resolved.isSeries) mapper.mapEpisodes(item) else null,
diff --git a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt
index 0ce61fc..bc8a3e8 100644
--- a/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt
+++ b/app/src/main/java/com/kino/puber/ui/feature/player/vm/PlayerVM.kt
@@ -52,7 +52,10 @@ internal class PlayerVM(
override val initialViewState: PlayerViewState = PlayerViewState.Loading
override fun dispatchError(error: ErrorEntity) {
- updateViewState(PlayerViewState.Error(error.message))
+ when (stateValue) {
+ is PlayerViewState.Content -> showMessage(error.message)
+ else -> updateViewState(PlayerViewState.Error(error.message))
+ }
}
private inline fun updateContent(crossinline update: PlayerContentState.() -> PlayerContentState) {
@@ -278,6 +281,7 @@ internal class PlayerVM(
is PlayerAction.SelectEpisodeById -> selectEpisodeById(action.episodeId)
is PlayerAction.EpisodeWatchedChanged -> onEpisodeWatchedChanged(action.item, action.watched)
is PlayerAction.SeasonWatchedChanged -> onSeasonWatchedChanged(action.item, action.watched)
+ is PlayerAction.MarkCurrentWatched -> markCurrentMediaWatched()
is PlayerAction.NextEpisode -> playNextEpisode()
is PlayerAction.PreviousEpisode -> playPreviousEpisode()
is PlayerAction.CancelNextEpisodeCountdown -> cancelCountdown()
@@ -562,7 +566,7 @@ internal class PlayerVM(
runCatching {
interactor.setEpisodeWatched(params.itemId, season, episode, watched)
}.onSuccess { updated ->
- updateEpisodes(updated)
+ syncContentWithItem(updated)
showMessage(
resources.getString(
if (watched) {
@@ -584,7 +588,7 @@ internal class PlayerVM(
runCatching {
interactor.setSeasonWatched(params.itemId, season, watched)
}.onSuccess { updated ->
- updateEpisodes(updated)
+ syncContentWithItem(updated)
showMessage(
resources.getString(
if (watched) {
@@ -600,9 +604,125 @@ internal class PlayerVM(
}
}
- private fun updateEpisodes(item: Item) {
- currentMedia = currentMedia?.copy(item = item)
- updateContent { copy(episodes = mapper.mapEpisodes(item)) }
+ private fun markCurrentMediaWatched() {
+ val media = currentMedia ?: return
+ val content = (stateValue as? PlayerViewState.Content)?.content ?: return
+ if (content.isCurrentMediaWatched || progressTracker.isMarkedWatched || !content.canMarkCurrentWatched) return
+
+ val isMovie = content.isMovie
+ val season = if (isMovie) null else media.seasonNumber ?: return
+ val episode = if (isMovie) null else media.episodeNumber ?: return
+
+ launch {
+ runCatching {
+ interactor.markCurrentAsWatched(
+ id = params.itemId,
+ season = season,
+ episode = episode,
+ )
+ }.onSuccess { updated ->
+ progressTracker.markAsWatched()
+ syncContentWithItem(updated, fallbackCurrentWatched = true)
+ showMessage(
+ resources.getString(
+ if (isMovie) {
+ R.string.video_details_watched_added
+ } else {
+ R.string.context_menu_episode_watched
+ }
+ )
+ )
+ }.onFailure { throwable ->
+ showMessage(errorHandler.map(throwable).message)
+ }
+ }
+ }
+
+ private fun syncContentWithItem(item: Item, fallbackCurrentWatched: Boolean? = null) {
+ val media = currentMedia ?: return
+ val content = (stateValue as? PlayerViewState.Content)?.content ?: return
+ val watched = item.currentMediaWatched(
+ isMovie = content.isMovie,
+ seasonNumber = media.seasonNumber,
+ episodeNumber = media.episodeNumber,
+ ) ?: fallbackCurrentWatched ?: content.isCurrentMediaWatched
+ val episodes = if (content.isMovie) {
+ content.episodes
+ } else {
+ mapper.mapEpisodes(item) ?: content.episodes
+ }
+
+ currentMedia = media.copy(item = item)
+ syncProgressTracker(watched)
+ updateContent {
+ copy(
+ isCurrentMediaWatched = watched,
+ episodes = episodes,
+ )
+ }
+ }
+
+ private fun syncCurrentMediaWatchedLocally(watched: Boolean) {
+ val media = currentMedia ?: return
+ val content = (stateValue as? PlayerViewState.Content)?.content ?: return
+ val updated = media.item.withCurrentMediaWatched(
+ watched = watched,
+ isMovie = content.isMovie,
+ seasonNumber = media.seasonNumber,
+ episodeNumber = media.episodeNumber,
+ )
+ syncContentWithItem(updated, fallbackCurrentWatched = watched)
+ }
+
+ private fun Item.currentMediaWatched(isMovie: Boolean, seasonNumber: Int?, episodeNumber: Int?): Boolean? {
+ return if (isMovie) {
+ watched?.let(::isWatchedStatus)
+ } else {
+ seasons
+ ?.find { it.number == seasonNumber }
+ ?.episodes
+ ?.find { it.number == episodeNumber }
+ ?.watched
+ ?.let(::isWatchedStatus)
+ }
+ }
+
+ private fun Item.withCurrentMediaWatched(
+ watched: Boolean,
+ isMovie: Boolean,
+ seasonNumber: Int?,
+ episodeNumber: Int?,
+ ): Item {
+ val status = if (watched) WATCHED_STATUS else UNWATCHED_STATUS
+ return if (isMovie) {
+ copy(watched = status)
+ } else {
+ copy(
+ seasons = seasons?.map { season ->
+ if (season.number != seasonNumber) {
+ season
+ } else {
+ season.copy(
+ episodes = season.episodes?.map { episode ->
+ if (episode.number == episodeNumber) episode.copy(watched = status) else episode
+ }
+ )
+ }
+ }
+ )
+ }
+ }
+
+ private fun syncProgressTracker(watched: Boolean) {
+ if (watched) {
+ progressTracker.markAsWatched()
+ } else {
+ progressTracker.reset()
+ }
+ }
+
+ private fun isWatchedStatus(status: Int): Boolean {
+ return status == WATCHED_STATUS
}
private fun currentEpisode(): CurrentEpisode? {
@@ -625,7 +745,7 @@ internal class PlayerVM(
}
private fun onPlaybackEnded() {
- markCurrentAsWatched()
+ autoMarkCurrentAsWatched()
val state = stateValue as? PlayerViewState.Content ?: return
val content = state.content
when {
@@ -908,7 +1028,7 @@ internal class PlayerVM(
val skipState = state.activeSkipSegment ?: return
if (skipState.type == SkipSegmentType.CREDITS && !state.isMovie && state.hasNextEpisode) {
- markCurrentAsWatched()
+ autoMarkCurrentAsWatched()
playNextEpisode()
} else {
playbackController.seekTo(skipState.targetPositionMs)
@@ -939,13 +1059,13 @@ internal class PlayerVM(
if (duration <= 0) return
val remaining = duration - playbackController.currentPosition
if (remaining < duration * AUTO_MARK_WATCHED_THRESHOLD) {
- markCurrentAsWatched()
+ autoMarkCurrentAsWatched()
}
}
- private fun markCurrentAsWatched() {
+ private fun autoMarkCurrentAsWatched() {
if (progressTracker.isMarkedWatched) return
- progressTracker.markAsWatched()
+ syncCurrentMediaWatchedLocally(watched = true)
launch {
interactor.markAsWatched(
id = params.itemId,
@@ -984,6 +1104,8 @@ internal class PlayerVM(
}
private companion object {
+ const val WATCHED_STATUS = 1
+ const val UNWATCHED_STATUS = 0
const val CONTROLS_HIDE_DELAY_MS = 3000L
const val SEEK_INDICATOR_HIDE_DELAY_MS = 1500L
const val PROGRESS_SYNC_INTERVAL_MS = 30_000L
diff --git a/app/src/main/res/values/player.xml b/app/src/main/res/values/player.xml
index a18282b..10b3427 100644
--- a/app/src/main/res/values/player.xml
+++ b/app/src/main/res/values/player.xml
@@ -3,6 +3,8 @@
Буферизация…
Серии
+ Просмотрено
+ Отметить просмотренным
Аудио и субтитры
Видео
diff --git a/app/src/test/kotlin/com/kino/puber/domain/interactor/player/PlayerInteractorTest.kt b/app/src/test/kotlin/com/kino/puber/domain/interactor/player/PlayerInteractorTest.kt
index 96cfa38..2f6a827 100644
--- a/app/src/test/kotlin/com/kino/puber/domain/interactor/player/PlayerInteractorTest.kt
+++ b/app/src/test/kotlin/com/kino/puber/domain/interactor/player/PlayerInteractorTest.kt
@@ -7,10 +7,14 @@ import com.kino.puber.data.api.models.ItemType
import com.kino.puber.data.api.models.Season
import com.kino.puber.data.api.models.VideoFile
import com.kino.puber.data.api.models.VideoUrl
+import com.kino.puber.data.api.models.WatchingToggleResponse
import com.kino.puber.data.repository.ItemDetailsRepository
import com.kino.puber.data.repository.PlayerPreferencesRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -262,6 +266,7 @@ class PlayerInteractorTest {
fun resolveMedia_movie_returnsCorrectFlags() {
val result = interactor.resolveMedia(movieItem, seasonNumber = null, episodeNumber = null)
assertFalse(result.isSeries)
+ assertFalse(result.isCurrentMediaWatched)
assertFalse(result.hasNext)
assertFalse(result.hasPrevious)
assertNull(result.seasonNumber)
@@ -270,6 +275,12 @@ class PlayerInteractorTest {
assertNull(result.episodeTitle)
}
+ @Test
+ fun resolveMedia_movie_setsWatchedStateFromItem() {
+ val result = interactor.resolveMedia(movieItem.copy(watched = 1), seasonNumber = null, episodeNumber = null)
+ assertTrue(result.isCurrentMediaWatched)
+ }
+
@Test
fun resolveMedia_series_computesHasNextAndHasPrevious() {
// Middle episode: s1e2 — has both next (s1e3) and previous (s1e1)
@@ -305,5 +316,51 @@ class PlayerInteractorTest {
assertEquals(episode1.number, result.videoNumber)
}
+ @Test
+ fun resolveMedia_series_setsWatchedStateFromEpisode() {
+ val watchedEpisode = episode2.copy(watched = 1)
+ val item = serialItem.copy(
+ seasons = listOf(season1.copy(episodes = listOf(episode1, watchedEpisode, episode3)), season2)
+ )
+
+ val result = interactor.resolveMedia(item, seasonNumber = 1, episodeNumber = 2)
+
+ assertTrue(result.isCurrentMediaWatched)
+ }
+
+ @Test
+ fun markCurrentAsWatched_movieMarksAndRefreshesItem() = runTest {
+ val refreshed = movieItem.copy(watched = 1)
+ coEvery {
+ api.toggleWatchingStatus(id = 10, status = 1, season = null, video = null)
+ } returns Result.success(WatchingToggleResponse(status = 1, watched = 1))
+ coEvery { itemDetailsRepository.refresh(10) } returns refreshed
+
+ val result = interactor.markCurrentAsWatched(id = 10)
+
+ assertEquals(refreshed, result)
+ coVerify(exactly = 1) {
+ api.toggleWatchingStatus(id = 10, status = 1, season = null, video = null)
+ }
+ coVerify(exactly = 1) { itemDetailsRepository.refresh(10) }
+ }
+
+ @Test
+ fun markCurrentAsWatched_episodeUsesSeasonAndEpisodeThenRefreshesItem() = runTest {
+ val refreshed = serialItem.copy(seasons = listOf(season1.copy(episodes = listOf(episode1.copy(watched = 1)))))
+ coEvery {
+ api.toggleWatchingStatus(id = 42, status = 1, season = 1, video = 1)
+ } returns Result.success(WatchingToggleResponse(status = 1, watched = 1))
+ coEvery { itemDetailsRepository.refresh(42) } returns refreshed
+
+ val result = interactor.markCurrentAsWatched(id = 42, season = 1, episode = 1)
+
+ assertEquals(refreshed, result)
+ coVerify(exactly = 1) {
+ api.toggleWatchingStatus(id = 42, status = 1, season = 1, video = 1)
+ }
+ coVerify(exactly = 1) { itemDetailsRepository.refresh(42) }
+ }
+
// endregion
}
diff --git a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt
index 45fd071..164af5b 100644
--- a/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt
+++ b/app/src/test/kotlin/com/kino/puber/ui/feature/player/vm/PlayerVMTest.kt
@@ -1,9 +1,15 @@
package com.kino.puber.ui.feature.player.vm
import com.kino.puber.core.error.ErrorHandler
+import com.kino.puber.core.error.ErrorEntity
import com.kino.puber.core.ui.navigation.AppRouter
+import com.kino.puber.core.ui.uikit.component.moviesList.VideoGridUIState
+import com.kino.puber.core.ui.uikit.component.moviesList.VideoItemUIState
+import com.kino.puber.data.api.models.Episode
import com.kino.puber.data.api.models.Item
import com.kino.puber.data.api.models.ItemType
+import com.kino.puber.data.api.models.Season
+import com.kino.puber.data.api.models.SkipSegmentType
import com.kino.puber.domain.interactor.player.PlayerInteractor
import com.kino.puber.domain.interactor.player.ResolvedMedia
import com.kino.puber.domain.interactor.player.SkipSegmentInteractor
@@ -20,18 +26,18 @@ import com.kino.puber.ui.feature.player.model.PlayerViewState
import com.kino.puber.ui.feature.player.model.ResumeDialogState
import com.kino.puber.ui.feature.player.model.SkipSegmentUIState
import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState
-import com.kino.puber.data.api.models.SkipSegmentType
import com.kino.puber.util.FakeResourceProvider
import com.kino.puber.util.MainDispatcherExtension
-import org.junit.jupiter.api.Assertions.assertFalse
import io.mockk.clearAllMocks
import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
@@ -103,6 +109,7 @@ class PlayerVMTest {
every { mapper.formatSeekOffset(any(), any()) } returns "+10s"
every { mapper.buildTitle(any(), any(), any()) } returns "Title"
every { mapper.buildSubtitle(any(), any(), any(), any()) } returns "Sub"
+ every { mapper.mapEpisodes(any()) } returns null
every { mapper.mapSkipSegmentLabel(any()) } returns "Skip"
every { mapper.defaultSoundModeLabel() } returns "Stereo"
every { playbackController.setCallback(capture(callbackSlot)) } returns Unit
@@ -668,6 +675,125 @@ class PlayerVMTest {
// endregion
+ // region Manual mark watched
+
+ @Test
+ fun markCurrentWatched_movieCallsInteractorWithoutEpisodeAndUpdatesState() {
+ val updated = testItem.copy(type = ItemType.MOVIE, watched = 1)
+ coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy(
+ isMovie = true,
+ hasNextEpisode = false,
+ hasPreviousEpisode = false,
+ )
+ coEvery {
+ interactor.markCurrentAsWatched(id = 42, season = null, episode = null)
+ } returns updated
+ val vm = startedVM()
+
+ vm.onAction(PlayerAction.MarkCurrentWatched)
+
+ assertTrue(contentState(vm).isCurrentMediaWatched)
+ coVerify(exactly = 1) {
+ interactor.markCurrentAsWatched(id = 42, season = null, episode = null)
+ }
+ }
+
+ @Test
+ fun markCurrentWatched_episodePassesSeasonAndEpisodeAndUpdatesEpisodes() {
+ val updated = testItem.copy(watched = 1)
+ val updatedEpisodes = VideoGridUIState(emptyList())
+ coEvery { interactor.markCurrentAsWatched(id = 42, season = 1, episode = 1) } returns updated
+ every { mapper.mapEpisodes(updated) } returns updatedEpisodes
+ val vm = startedVM()
+
+ vm.onAction(PlayerAction.MarkCurrentWatched)
+
+ assertTrue(contentState(vm).isCurrentMediaWatched)
+ assertEquals(updatedEpisodes, contentState(vm).episodes)
+ coVerify(exactly = 1) { interactor.markCurrentAsWatched(id = 42, season = 1, episode = 1) }
+ }
+
+ @Test
+ fun markCurrentWatched_alreadyWatchedDoesNotCallInteractor() {
+ coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy(
+ isCurrentMediaWatched = true,
+ )
+ val vm = startedVM()
+
+ vm.onAction(PlayerAction.MarkCurrentWatched)
+
+ coVerify(exactly = 0) { interactor.markCurrentAsWatched(any(), any(), any()) }
+ }
+
+ @Test
+ fun markCurrentWatched_failurePreservesStateAndMapsError() {
+ val failure = IllegalStateException("Failed")
+ coEvery { interactor.markCurrentAsWatched(id = 42, season = 1, episode = 1) } throws failure
+ every { errorHandler.map(failure) } returns ErrorEntity(message = "Mapped", code = "test")
+ val vm = startedVM()
+
+ vm.onAction(PlayerAction.MarkCurrentWatched)
+
+ assertFalse(contentState(vm).isCurrentMediaWatched)
+ verify(exactly = 1) { errorHandler.map(failure) }
+ }
+
+ @Test
+ fun markCurrentWatched_afterAutoMarkDoesNotDuplicateManualApiCall() {
+ val vm = startedVM()
+
+ callbackSlot.captured.onPlaybackEnded()
+ vm.onAction(PlayerAction.MarkCurrentWatched)
+
+ assertTrue(contentState(vm).isCurrentMediaWatched)
+ coVerify(exactly = 1) { interactor.markAsWatched(id = 42, season = 1, videoNumber = 1) }
+ coVerify(exactly = 0) { interactor.markCurrentAsWatched(any(), any(), any()) }
+ }
+
+ @Test
+ fun episodeWatchedChanged_currentEpisodeUpdatesWatchedButtonState() {
+ val updated = testItem.withCurrentEpisodeWatched(true)
+ val updatedEpisodes = VideoGridUIState(emptyList())
+ coEvery { interactor.setEpisodeWatched(42, season = 1, episode = 1, watched = true) } returns updated
+ every { mapper.mapEpisodes(updated) } returns updatedEpisodes
+ val vm = startedVM()
+
+ vm.onAction(PlayerAction.EpisodeWatchedChanged(currentEpisodeItem, watched = true))
+
+ assertTrue(contentState(vm).isCurrentMediaWatched)
+ assertEquals(updatedEpisodes, contentState(vm).episodes)
+ }
+
+ @Test
+ fun episodeWatchedChanged_currentEpisodeUnwatchedUpdatesWatchedButtonState() {
+ val updated = testItem.withCurrentEpisodeWatched(false)
+ coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy(
+ isCurrentMediaWatched = true,
+ )
+ coEvery { interactor.setEpisodeWatched(42, season = 1, episode = 1, watched = false) } returns updated
+ val vm = startedVM()
+
+ vm.onAction(PlayerAction.EpisodeWatchedChanged(currentEpisodeItem, watched = false))
+
+ assertFalse(contentState(vm).isCurrentMediaWatched)
+ }
+
+ @Test
+ fun seasonWatchedChanged_currentSeasonUnwatchedUpdatesWatchedButtonState() {
+ val updated = testItem.withCurrentEpisodeWatched(false)
+ coEvery { contentStateFactory.build(any(), any(), any(), any(), any(), any()) } returns testContentState.copy(
+ isCurrentMediaWatched = true,
+ )
+ coEvery { interactor.setSeasonWatched(42, season = 1, watched = false) } returns updated
+ val vm = startedVM()
+
+ vm.onAction(PlayerAction.SeasonWatchedChanged(currentEpisodeItem, watched = false))
+
+ assertFalse(contentState(vm).isCurrentMediaWatched)
+ }
+
+ // endregion
+
// region Back navigation (additional branches)
@Test
@@ -703,11 +829,33 @@ class PlayerVMTest {
private val testItem = Item(id = 42, title = "Breaking Bad", type = ItemType.SERIAL, watched = 5, new = 3)
+ private val currentEpisodeItem = VideoItemUIState(
+ id = 101,
+ title = "1. Pilot",
+ imageUrl = "",
+ bigImageUrl = "",
+ seasonNumber = 1,
+ episodeNumber = 1,
+ )
+
+ private fun Item.withCurrentEpisodeWatched(watched: Boolean): Item {
+ val status = if (watched) 1 else 0
+ return copy(
+ seasons = listOf(
+ Season(
+ id = 1,
+ number = 1,
+ episodes = listOf(Episode(id = 101, number = 1, title = "Pilot", watched = status)),
+ )
+ )
+ )
+ }
+
private val testResolvedMedia = ResolvedMedia(
files = emptyList(), audios = emptyList(), subtitles = emptyList(),
watchingTime = null, duration = 2400, videoNumber = 1, episodeId = 101,
episodeTitle = "Pilot", isSeries = true, hasNext = true,
- hasPrevious = true, seasonNumber = 1, episodeNumber = 1,
+ isCurrentMediaWatched = false, hasPrevious = true, seasonNumber = 1, episodeNumber = 1,
)
private val testSubtitleTracks = listOf(
@@ -735,6 +883,7 @@ class PlayerVMTest {
bufferPresets = listOf(BufferPresetUIState(0, "Auto", BufferPreset.AUTO)),
selectedBufferPresetIndex = 0,
isMovie = false, hasNextEpisode = true, hasPreviousEpisode = true, nextEpisodeCountdown = null,
+ canMarkCurrentWatched = true, isCurrentMediaWatched = false,
resumeDialog = null, episodes = null, currentEpisodeId = 101,
)
}