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, ) }