diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt index d1d08b3a9..8e7160c68 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/AccessibilityJourneyTest.kt @@ -164,6 +164,10 @@ private object EmptyCatalogFixture : app.muxtv.catalog.ChannelBrowseRepository { override fun pages(query: app.muxtv.catalog.ChannelBrowseQuery): kotlinx.coroutines.flow.Flow> = kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + kotlinx.coroutines.flow.Flow> = + kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty()) } private fun SemanticsNodeInteraction.press( diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt new file mode 100644 index 000000000..1137b7f04 --- /dev/null +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ChannelQuickActionsJourneyTest.kt @@ -0,0 +1,260 @@ +package app.muxtv + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.catalog.ChannelQuery +import app.muxtv.catalog.PlayableChannel +import app.muxtv.catalog.PlayableChannelSummary +import app.muxtv.catalog.PlaybackAccessMutationResult +import app.muxtv.catalog.PlaybackCatalog +import app.muxtv.catalog.PlaybackVariantResolution +import app.muxtv.catalog.RecentChannel +import app.muxtv.catalog.RecentChannelWriteResult +import app.muxtv.catalog.RecentChannelsQuery +import app.muxtv.catalog.RecentChannelsRepository +import app.muxtv.designsystem.MuxTvTheme +import app.muxtv.feature.channels.ChannelsRoute +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.junit.Rule +import org.junit.Test + +class ChannelQuickActionsJourneyTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun normalOkStillOpensPlaybackWhenQuickActionsAreEnabled() { + val catalog = QuickActionPlaybackCatalog() + var openedChannelId: String? = null + + composeRule.setContent { + MuxTvTheme { + channelsRoute( + catalog = catalog, + preferences = HidingPreferencesRepository(catalog), + onOpenChannel = { openedChannelId = it }, + ) + } + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("channel-row-0") + .assertIsFocused() + .pressEnter() + composeRule.waitForIdle() + + assertThat(openedChannelId).isEqualTo("channel-a") + composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist() + } + + @Test + fun longClickOpensQuickActionsWithoutStartingPlayback() { + val catalog = QuickActionPlaybackCatalog() + var openedChannelId: String? = null + + composeRule.setContent { + MuxTvTheme { + channelsRoute( + catalog = catalog, + preferences = HidingPreferencesRepository(catalog), + onOpenChannel = { openedChannelId = it }, + ) + } + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("channel-row-0") + .performSemanticsAction(SemanticsActions.OnLongClick) + composeRule.waitForIdle() + + assertThat(openedChannelId).isNull() + composeRule.onNodeWithTag("channel-quick-actions").assertExists() + composeRule.onNodeWithText("В избранное").assertIsFocused() + } + + @Test + fun hidingFocusedChannelReturnsFocusToNearestPreviousRow() { + val catalog = QuickActionPlaybackCatalog() + val preferences = HidingPreferencesRepository(catalog) + + composeRule.setContent { + MuxTvTheme { + channelsRoute( + catalog = catalog, + preferences = preferences, + ) + } + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("channel-row-0").performKeyInput { + keyDown(Key.DirectionDown) + keyUp(Key.DirectionDown) + } + composeRule.onNodeWithTag("channel-row-1").assertIsFocused() + composeRule.onNodeWithTag("channel-row-1") + .performSemanticsAction(SemanticsActions.OnLongClick) + composeRule.waitForIdle() + composeRule.onNodeWithText("Скрыть").performClick() + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText( + "Долгое OK · быстрые действия", + substring = false, + ).fetchSemanticsNodes().isEmpty() + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText("Второй", substring = false).fetchSemanticsNodes().isEmpty() + } + composeRule.waitForIdle() + + composeRule.onNodeWithText("Первый", substring = false).assertIsFocused() + } + + @androidx.compose.runtime.Composable + private fun channelsRoute( + catalog: QuickActionPlaybackCatalog, + preferences: ChannelPreferencesRepository, + onOpenChannel: (String) -> Unit = {}, + ) { + ChannelsRoute( + channelBrowseRepository = TestChannelBrowseRepository( + playbackCatalog = catalog, + recentChannelsRepository = EmptyRecentChannelsRepository, + epgGuideRepository = NoGuideEpgGuideRepository, + ), + epgGuideRepository = NoGuideEpgGuideRepository, + playbackSessionStateSource = NoPlaybackSessionStateSource, + profileId = PROFILE_ID, + onOpenChannel = onOpenChannel, + channelPreferencesRepository = preferences, + ) + } + + private companion object { + const val PROFILE_ID = "profile-main" + } +} + +private class HidingPreferencesRepository( + private val catalog: QuickActionPlaybackCatalog, +) : ChannelPreferencesRepository { + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.Applied + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult { + if (isHidden) catalog.remove(channelId) + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied +} + +private class QuickActionPlaybackCatalog : PlaybackCatalog { + private val channels = MutableStateFlow( + listOf( + quickActionChannel("channel-a", "Первый"), + quickActionChannel("channel-b", "Второй"), + quickActionChannel("channel-c", "Третий"), + ), + ) + + fun remove(channelId: String) { + channels.value = channels.value.filterNot { it.channelId == channelId } + } + + override fun observeChannels(query: ChannelQuery): Flow> = + channels.map { rows -> + rows.filter { row -> !query.favoritesOnly || row.isFavorite } + .take(query.limit) + } + + override suspend fun getChannel( + profileId: String, + channelId: String, + ): PlayableChannel? = null + + override suspend fun resolveVariant( + profileId: String, + channelId: String, + preferredVariantId: String?, + ): PlaybackVariantResolution? = null + + override suspend fun approveInsecurePlayback( + profileId: String, + channelId: String, + variantId: String, + ): PlaybackAccessMutationResult = PlaybackAccessMutationResult.NotFound + + override suspend fun revokeInsecurePlayback( + profileId: String, + channelId: String, + variantId: String, + ): PlaybackAccessMutationResult = PlaybackAccessMutationResult.NotFound +} + +private object EmptyRecentChannelsRepository : RecentChannelsRepository { + override fun observeRecent(query: RecentChannelsQuery): Flow> = flowOf(emptyList()) + + override suspend fun recordSuccessfulPlayback( + profileId: String, + channelId: String, + successfulAtEpochMillis: Long, + ): RecentChannelWriteResult = RecentChannelWriteResult.Applied +} + +private fun quickActionChannel( + id: String, + name: String, +): PlayableChannelSummary = PlayableChannelSummary( + channelId = id, + displayName = name, + logoUrl = null, + groupTitle = "Тест", + channelNumber = null, + isFavorite = false, + variantCount = 1, +) + +private fun androidx.compose.ui.test.SemanticsNodeInteraction.pressEnter() = performKeyInput { + keyDown(Key.Enter) + keyUp(Key.Enter) +} diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt index 9e2bd4167..310b1738a 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt @@ -233,6 +233,10 @@ class HomeJourneyTest { val EmptyBrowseRepository = object : ChannelBrowseRepository { override fun pages(query: ChannelBrowseQuery): Flow> = flowOf(androidx.paging.PagingData.empty()) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + Flow> = + flowOf(androidx.paging.PagingData.empty()) } val FavoritesBrowseRepository = object : ChannelBrowseRepository { @@ -257,6 +261,10 @@ class HomeJourneyTest { ), ), ) + + override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery): + Flow> = + flowOf(androidx.paging.PagingData.empty()) } } } diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt b/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt new file mode 100644 index 000000000..a5589ef8d --- /dev/null +++ b/app/tv/src/androidTest/kotlin/app/muxtv/ManageChannelsAcceptanceJourneyTest.kt @@ -0,0 +1,293 @@ +package app.muxtv + +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.paging.PagingData +import app.muxtv.catalog.ChannelBrowseItem +import app.muxtv.catalog.ChannelBrowseQuery +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.designsystem.MuxTvTheme +import app.muxtv.feature.channels.ManageChannelsRoute +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.junit.Rule +import org.junit.Test + +class ManageChannelsAcceptanceJourneyTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun hiddenChannelCanBeRecoveredAndFocusFallsBackToNearestPreviousHiddenRow() { + val fixture = ManageChannelsFixture( + initialRows = listOf( + managementChannel( + id = "channel-a", + canonicalName = "Первый", + number = "1", + isHidden = true, + ), + managementChannel( + id = "channel-b", + canonicalName = "Второй", + number = "2", + isHidden = true, + ), + managementChannel( + id = "channel-c", + canonicalName = "Третий", + number = "3", + isHidden = false, + ), + ), + ) + + composeRule.setContent { + MuxTvTheme { + ManageChannelsRoute( + channelBrowseRepository = fixture, + channelPreferencesRepository = fixture, + profileId = PROFILE_ID, + ) + } + } + + composeRule.onNodeWithTag("manage-channels-filter-hidden").performClick() + composeRule.waitUntilTag("manage-channel-row-channel-b") + composeRule.onNodeWithTag("manage-channel-row-channel-b").performClick() + composeRule.onNodeWithText("Показать").assertIsFocused().performClick() + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("manage-channels-actions").fetchSemanticsNodes().isEmpty() + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("manage-channel-row-channel-b").fetchSemanticsNodes().isEmpty() + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("manage-channel-row-channel-a").assertIsFocused() + composeRule.onNodeWithTag("manage-channel-row-channel-c").assertDoesNotExist() + } + + @Test + fun renameNumberAndResetRoundTripKeepsFavoriteState() { + val fixture = ManageChannelsFixture( + initialRows = listOf( + managementChannel( + id = "channel-news", + canonicalName = "Новости", + number = "10", + isFavorite = true, + ), + ), + ) + + composeRule.setContent { + MuxTvTheme { + ManageChannelsRoute( + channelBrowseRepository = fixture, + channelPreferencesRepository = fixture, + profileId = PROFILE_ID, + ) + } + } + composeRule.waitUntilTag("manage-channel-row-channel-news") + + composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() + composeRule.onNodeWithText("Переименовать").performClick() + composeRule.onNodeWithTag("manage-channels-editor") + .assertIsFocused() + .performTextClearance() + .performTextInput("Мои новости") + composeRule.onNodeWithText("Сохранить").performClick() + composeRule.waitForIdle() + composeRule.waitUntilText("Мои новости") + + composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() + composeRule.onNodeWithText("Номер").performClick() + composeRule.onNodeWithTag("manage-channels-editor") + .assertIsFocused() + .performTextInput("77") + composeRule.onNodeWithText("Сохранить").performClick() + composeRule.waitForIdle() + composeRule.waitUntilText("77") + composeRule.onNodeWithText("Видим · Избранное · Изменён").assertExists() + + composeRule.onNodeWithTag("manage-channel-row-channel-news").performClick() + composeRule.onNodeWithText("Сбросить").performClick() + composeRule.waitForIdle() + composeRule.waitUntilText("Новости") + + composeRule.onNodeWithText("10").assertExists() + composeRule.onNodeWithText("Видим · Избранное").assertExists() + composeRule.onNodeWithText("Изменён", substring = true).assertDoesNotExist() + composeRule.onNodeWithTag("manage-channel-row-channel-news").assertIsFocused() + } + + private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.waitUntilTag(tag: String) { + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithTag(tag).fetchSemanticsNodes().size == 1 + } + } + + private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.waitUntilText(text: String) { + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText(text, substring = false).fetchSemanticsNodes().isNotEmpty() + } + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithTag("manage-channels-actions").fetchSemanticsNodes().isEmpty() + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + } +} + +private class ManageChannelsFixture( + initialRows: List, +) : ChannelBrowseRepository, ChannelPreferencesRepository { + private val rows = MutableStateFlow(initialRows) + + override fun pages(query: ChannelBrowseQuery): Flow> = + flowOf(PagingData.empty()) + + override fun managementPages(query: ChannelManagementQuery): Flow> = + rows.map { current -> + val filtered = when (query.visibility) { + ChannelManagementVisibility.ALL -> current + ChannelManagementVisibility.VISIBLE -> current.filterNot { it.isHidden } + ChannelManagementVisibility.HIDDEN -> current.filter { it.isHidden } + } + PagingData.from(filtered) + } + + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = + updateVisible(channelId) { row -> row.copy(isFavorite = isFavorite) } + .toFavoriteResult() + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = + update(channelId) { row -> row.copy(isHidden = isHidden) } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult { + val normalized = customName?.trim() + if (normalized != null && normalized.isBlank()) return ChannelPreferenceMutationResult.InvalidInput + return update(channelId) { row -> + row.copy(effectiveDisplayName = normalized ?: row.canonicalDisplayName) + } + } + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult { + if (channelNumber != null && channelNumber !in 1..9999) { + return ChannelPreferenceMutationResult.InvalidInput + } + return update(channelId) { row -> + row.copy( + customChannelNumber = channelNumber, + effectiveChannelNumber = channelNumber?.toString() ?: row.defaultChannelNumber, + ) + } + } + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = + update(channelId) { row -> + row.copy( + effectiveDisplayName = row.canonicalDisplayName, + customChannelNumber = null, + effectiveChannelNumber = row.defaultChannelNumber, + isHidden = false, + ) + } + + private fun updateVisible( + channelId: String, + transform: (ChannelManagementItem) -> ChannelManagementItem, + ): ChannelPreferenceMutationResult { + val current = rows.value + val index = current.indexOfFirst { row -> row.channelId == channelId && !row.isHidden } + if (index < 0) return ChannelPreferenceMutationResult.NotFound + return replaceAt(index, transform) + } + + private fun update( + channelId: String, + transform: (ChannelManagementItem) -> ChannelManagementItem, + ): ChannelPreferenceMutationResult { + val current = rows.value + val index = current.indexOfFirst { row -> row.channelId == channelId } + if (index < 0) return ChannelPreferenceMutationResult.NotFound + return replaceAt(index, transform) + } + + private fun replaceAt( + index: Int, + transform: (ChannelManagementItem) -> ChannelManagementItem, + ): ChannelPreferenceMutationResult { + val current = rows.value + val before = current[index] + val after = transform(before) + if (before == after) return ChannelPreferenceMutationResult.Unchanged + rows.value = current.toMutableList().also { it[index] = after } + return ChannelPreferenceMutationResult.Applied + } + + private fun ChannelPreferenceMutationResult.toFavoriteResult(): ChannelFavoriteMutationResult = when (this) { + ChannelPreferenceMutationResult.Applied -> ChannelFavoriteMutationResult.Applied + ChannelPreferenceMutationResult.Unchanged -> ChannelFavoriteMutationResult.Unchanged + ChannelPreferenceMutationResult.NotFound -> ChannelFavoriteMutationResult.NotFound + ChannelPreferenceMutationResult.InvalidInput -> ChannelFavoriteMutationResult.NotFound + } +} + +private fun managementChannel( + id: String, + canonicalName: String, + number: String, + isFavorite: Boolean = false, + isHidden: Boolean = false, +): ChannelManagementItem = ChannelManagementItem( + channelId = id, + canonicalDisplayName = canonicalName, + effectiveDisplayName = canonicalName, + defaultChannelNumber = number, + customChannelNumber = null, + effectiveChannelNumber = number, + isFavorite = isFavorite, + isHidden = isHidden, + variantCount = 1, +) diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt index 15651e0a8..b2a60a03a 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelBrowseRepository.kt @@ -7,6 +7,8 @@ import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseItem import app.muxtv.catalog.ChannelBrowseQuery import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.ChannelQuery import app.muxtv.catalog.EpgGuideRepository @@ -18,6 +20,7 @@ import app.muxtv.catalog.RecentChannelsQuery import app.muxtv.catalog.RecentChannelsRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest @OptIn(ExperimentalCoroutinesApi::class) @@ -59,6 +62,9 @@ internal class TestChannelBrowseRepository( } } + override fun managementPages(query: ChannelManagementQuery): Flow> = + flowOf(PagingData.empty()) + private companion object { val COMPLETED_LOAD_STATES = LoadStates( refresh = LoadState.NotLoading(endOfPaginationReached = true), diff --git a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt index ea0c1e556..adaf4dbce 100644 --- a/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt +++ b/app/tv/src/androidTest/kotlin/app/muxtv/TestChannelPreferencesRepository.kt @@ -1,6 +1,7 @@ package app.muxtv import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult import app.muxtv.catalog.ChannelPreferencesRepository internal object NoChannelPreferencesRepository : ChannelPreferencesRepository { @@ -9,4 +10,27 @@ internal object NoChannelPreferencesRepository : ChannelPreferencesRepository { channelId: String, isFavorite: Boolean, ): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.NotFound + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.NotFound } diff --git a/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt b/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt index b2cb0106f..34277f607 100644 --- a/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt +++ b/app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt @@ -11,6 +11,9 @@ sealed interface AppDestination : NavKey { @Serializable data object Channels : AppDestination + @Serializable + data object ManageChannels : AppDestination + @Serializable data object Guide : AppDestination diff --git a/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt b/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt index d6cf05e4d..d650980c1 100644 --- a/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt +++ b/app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt @@ -36,6 +36,7 @@ import app.muxtv.designsystem.component.MuxTvNavigationRail import app.muxtv.designsystem.component.MuxTvNavigationRailItem import app.muxtv.designsystem.icon.MuxTvIcons import app.muxtv.feature.channels.ChannelsRoute +import app.muxtv.feature.channels.ManageChannelsRoute import app.muxtv.feature.doctor.DoctorExportStatus import app.muxtv.feature.doctor.DoctorRoute import app.muxtv.feature.guide.GuideRoute @@ -138,12 +139,21 @@ fun AppNavigation( AppDestination.Channels -> ChannelsRoute( channelBrowseRepository = channelBrowseRepository, + channelPreferencesRepository = channelPreferencesRepository, epgGuideRepository = epgGuideRepository, playbackSessionStateSource = controllerConnector, profileId = DatabaseDefaults.PRIMARY_PROFILE_ID, onOpenChannel = { channelId -> open(AppDestination.Player(channelId)) }, + onManageChannels = { open(AppDestination.ManageChannels) }, + railFocusRequester = railFocusRequester, + ) + + AppDestination.ManageChannels -> ManageChannelsRoute( + channelBrowseRepository = channelBrowseRepository, + channelPreferencesRepository = channelPreferencesRepository, + profileId = DatabaseDefaults.PRIMARY_PROFILE_ID, railFocusRequester = railFocusRequester, ) @@ -248,6 +258,7 @@ private fun AppDestination.navigationKey(): String = when (this) { AppDestination.Guide -> "guide" AppDestination.Search -> "search" AppDestination.Settings -> "settings" + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -269,6 +280,7 @@ private fun AppDestination.navigationLabel(): String = when (this) { AppDestination.Guide -> "Программа" AppDestination.Search -> "Поиск" AppDestination.Settings -> "Настройки" + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -281,6 +293,7 @@ private fun AppDestination.navigationIcon() = when (this) { AppDestination.Guide -> MuxTvIcons.Guide AppDestination.Search -> MuxTvIcons.Search AppDestination.Settings -> MuxTvIcons.Settings + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -293,6 +306,7 @@ private fun AppDestination.navigationTestTag(): String = when (this) { AppDestination.Guide -> "nav-guide" AppDestination.Search -> "nav-search" AppDestination.Settings -> "nav-settings" + AppDestination.ManageChannels -> error("ManageChannels is not a top-level destination.") AppDestination.Sources -> error("Sources is not a top-level destination.") AppDestination.Doctor -> error("Doctor is not a top-level destination.") AppDestination.AddSource -> error("AddSource is not a top-level destination.") @@ -300,6 +314,7 @@ private fun AppDestination.navigationTestTag(): String = when (this) { } private fun AppDestination.topLevelDestination(): AppDestination = when (this) { + AppDestination.ManageChannels -> AppDestination.Channels AppDestination.Sources, AppDestination.Doctor, AppDestination.AddSource -> AppDestination.Settings is AppDestination.Player -> AppDestination.Channels else -> this diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt index 6ab9df107..26d72294e 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelBrowseRepository.kt @@ -58,6 +58,51 @@ data class ChannelBrowseItem( "guideState=$guideState)" } +enum class ChannelManagementVisibility { + ALL, + VISIBLE, + HIDDEN, +} + +data class ChannelManagementQuery( + val profileId: String, + val visibility: ChannelManagementVisibility, +) { + init { + require(profileId.isNotBlank()) + } + + override fun toString(): String = + "ChannelManagementQuery(profileId=, visibility=$visibility)" +} + +data class ChannelManagementItem( + val channelId: String, + val canonicalDisplayName: String, + val effectiveDisplayName: String, + val defaultChannelNumber: String?, + val customChannelNumber: Int?, + val effectiveChannelNumber: String?, + val isFavorite: Boolean, + val isHidden: Boolean, + val variantCount: Int, +) { + init { + require(channelId.isNotBlank()) + require(canonicalDisplayName.isNotBlank()) + require(effectiveDisplayName.isNotBlank()) + require(variantCount > 0) + } + + override fun toString(): String = + "ChannelManagementItem(channelId=, canonicalDisplayName=, " + + "effectiveDisplayName=, defaultChannelNumberPresent=${defaultChannelNumber != null}, " + + "customChannelNumber=$customChannelNumber, effectiveChannelNumberPresent=${effectiveChannelNumber != null}, " + + "isFavorite=$isFavorite, isHidden=$isHidden, variantCount=$variantCount)" +} + interface ChannelBrowseRepository { fun pages(query: ChannelBrowseQuery): Flow> + + fun managementPages(query: ChannelManagementQuery): Flow> } diff --git a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt index 1f5ecd14b..288f75cde 100644 --- a/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt +++ b/catalog/api/src/main/kotlin/app/muxtv/catalog/ChannelPreferencesRepository.kt @@ -6,10 +6,40 @@ enum class ChannelFavoriteMutationResult { NotFound, } +enum class ChannelPreferenceMutationResult { + Applied, + Unchanged, + NotFound, + InvalidInput, +} + interface ChannelPreferencesRepository { suspend fun setFavorite( profileId: String, channelId: String, isFavorite: Boolean, ): ChannelFavoriteMutationResult + + suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult + + suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult + + suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult + + suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult } diff --git a/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt new file mode 100644 index 000000000..219e551d1 --- /dev/null +++ b/catalog/api/src/test/kotlin/app/muxtv/catalog/ChannelManagementContractTest.kt @@ -0,0 +1,76 @@ +package app.muxtv.catalog + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test + +class ChannelManagementContractTest { + @Test + fun queryRequiresProfileAndRedactsItFromDiagnostics() { + assertThrows(IllegalArgumentException::class.java) { + ChannelManagementQuery( + profileId = " ", + visibility = ChannelManagementVisibility.ALL, + ) + } + + val query = ChannelManagementQuery( + profileId = "private-profile-id", + visibility = ChannelManagementVisibility.HIDDEN, + ) + + assertThat(query.toString()).doesNotContain("private-profile-id") + assertThat(query.toString()).contains("HIDDEN") + } + + @Test + fun visibilityModesAreExplicitAndStable() { + assertThat(ChannelManagementVisibility.entries) + .containsExactly( + ChannelManagementVisibility.ALL, + ChannelManagementVisibility.VISIBLE, + ChannelManagementVisibility.HIDDEN, + ) + .inOrder() + } + + @Test + fun managementItemKeepsCanonicalAndEffectiveValuesDistinct() { + val item = ChannelManagementItem( + channelId = "channel-1", + canonicalDisplayName = "Discovery Channel HD", + effectiveDisplayName = "Discovery", + defaultChannelNumber = "501", + customChannelNumber = 7, + effectiveChannelNumber = "7", + isFavorite = true, + isHidden = true, + variantCount = 2, + ) + + assertThat(item.canonicalDisplayName).isEqualTo("Discovery Channel HD") + assertThat(item.effectiveDisplayName).isEqualTo("Discovery") + assertThat(item.defaultChannelNumber).isEqualTo("501") + assertThat(item.customChannelNumber).isEqualTo(7) + assertThat(item.effectiveChannelNumber).isEqualTo("7") + assertThat(item.toString()).doesNotContain("Discovery Channel HD") + assertThat(item.toString()).doesNotContain("Discovery") + } + + @Test + fun managementItemRequiresAtLeastOneActiveVariant() { + assertThrows(IllegalArgumentException::class.java) { + ChannelManagementItem( + channelId = "channel-1", + canonicalDisplayName = "Canonical", + effectiveDisplayName = "Effective", + defaultChannelNumber = null, + customChannelNumber = null, + effectiveChannelNumber = null, + isFavorite = false, + isHidden = false, + variantCount = 0, + ) + } + } +} diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt index 21a3ef8c8..f7c6cc082 100644 --- a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelBrowseRepositoryTest.kt @@ -7,6 +7,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseQuery +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.NowNextQuery @@ -106,6 +108,69 @@ class ChannelBrowseRepositoryTest { .inOrder() } + @Test + fun managementVisibilityCanRecoverHiddenChannelsWithoutWeakeningBrowse() = runTest { + activateRevision(revisionNumber = 1L, channelCount = 2) + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = "channel-00002", + isHidden = true, + ), + ) + + val browse = repository.pages(query(ChannelBrowseFilter.ALL)).asSnapshot() + val all = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)).asSnapshot() + val visible = repository.managementPages(managementQuery(ChannelManagementVisibility.VISIBLE)).asSnapshot() + val hidden = repository.managementPages(managementQuery(ChannelManagementVisibility.HIDDEN)).asSnapshot() + + assertThat(browse.map { it.channelId }).containsExactly("channel-00001") + assertThat(all.map { it.channelId }) + .containsExactly("channel-00001", "channel-00002") + .inOrder() + assertThat(visible.map { it.channelId }).containsExactly("channel-00001") + assertThat(hidden.map { it.channelId }).containsExactly("channel-00002") + assertThat(hidden.single().isHidden).isTrue() + } + + @Test + fun managementProjectionKeepsProviderDefaultsSeparateFromUserOverrides() = runTest { + activateRevision(revisionNumber = 1L, channelCount = 1) + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = "channel-00001", + isFavorite = true, + customName = "Renamed channel", + channelNumber = 7, + ), + ) + + val item = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)) + .asSnapshot() + .single() + + assertThat(item.canonicalDisplayName).isEqualTo("Channel 00001") + assertThat(item.effectiveDisplayName).isEqualTo("Renamed channel") + assertThat(item.defaultChannelNumber).isEqualTo("1") + assertThat(item.customChannelNumber).isEqualTo(7) + assertThat(item.effectiveChannelNumber).isEqualTo("7") + assertThat(item.isFavorite).isTrue() + assertThat(item.isHidden).isFalse() + assertThat(item.variantCount).isEqualTo(1) + } + + @Test + fun managementProjectionReadsOnlyActiveSourceRevision() = runTest { + activateRevision(revisionNumber = 1L, channelCount = 2) + activateRevision(revisionNumber = 2L, channelCount = 1) + + val rows = repository.managementPages(managementQuery(ChannelManagementVisibility.ALL)).asSnapshot() + + assertThat(rows.map { it.channelId }).containsExactly("channel-00001") + assertThat(rows.single().variantCount).isEqualTo(1) + } + @Test fun activeRevisionInvalidatesExistingPagingSource() = runTest { activateRevision(revisionNumber = 1L, channelCount = 1) @@ -131,6 +196,9 @@ class ChannelBrowseRepositoryTest { private fun query(filter: ChannelBrowseFilter) = ChannelBrowseQuery(PROFILE_ID, filter) + private fun managementQuery(visibility: ChannelManagementVisibility) = + ChannelManagementQuery(PROFILE_ID, visibility) + private fun refresh( key: Int? = null, loadSize: Int, diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelControlBrowseAcceptanceTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelControlBrowseAcceptanceTest.kt new file mode 100644 index 000000000..7cf860f91 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelControlBrowseAcceptanceTest.kt @@ -0,0 +1,161 @@ +package app.muxtv.database + +import androidx.paging.testing.asSnapshot +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.muxtv.catalog.ChannelBrowseFilter +import app.muxtv.catalog.ChannelBrowseQuery +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelControlBrowseAcceptanceTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + private lateinit var browse: RoomChannelBrowseRepository + private lateinit var recent: RoomRecentChannelsRepository + + @Before + fun setUp() = runTest { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + browse = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = RoomEpgGuideRepository(database.epgGuideDao()), + nowEpochMillis = { 5_000L }, + ) + recent = RoomRecentChannelsRepository(database.recentChannelsDao()) + database.profileDao().insert(ProfileEntity(PROFILE_ID, "Primary", isPrimary = true)) + activateCatalog() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun oneOverlayDrivesAllFavoritesAndRecentThenHideSuppressesEveryBrowseProjection() = runTest { + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_A, + isFavorite = true, + customName = "Мои новости", + channelNumber = 77, + ), + ) + recent.recordSuccessfulPlayback(PROFILE_ID, CHANNEL_B, 1_000L) + recent.recordSuccessfulPlayback(PROFILE_ID, CHANNEL_A, 2_000L) + + val all = browse.pages(query(ChannelBrowseFilter.ALL)).asSnapshot() + val favorites = browse.pages(query(ChannelBrowseFilter.FAVORITES)).asSnapshot() + val recentRows = browse.pages(query(ChannelBrowseFilter.RECENT)).asSnapshot() + + assertThat(all.map { it.channelId }).containsExactly(CHANNEL_B, CHANNEL_A).inOrder() + assertEffectiveOverlay(all.single { it.channelId == CHANNEL_A }) + assertThat(favorites.map { it.channelId }).containsExactly(CHANNEL_A) + assertEffectiveOverlay(favorites.single()) + assertThat(recentRows.map { it.channelId }).containsExactly(CHANNEL_A, CHANNEL_B).inOrder() + assertEffectiveOverlay(recentRows.first()) + + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_A, + isFavorite = true, + customName = "Мои новости", + channelNumber = 77, + isHidden = true, + ), + ) + + assertThat(browse.pages(query(ChannelBrowseFilter.ALL)).asSnapshot().map { it.channelId }) + .containsExactly(CHANNEL_B) + assertThat(browse.pages(query(ChannelBrowseFilter.FAVORITES)).asSnapshot()).isEmpty() + assertThat(browse.pages(query(ChannelBrowseFilter.RECENT)).asSnapshot().map { it.channelId }) + .containsExactly(CHANNEL_B) + } + + private fun assertEffectiveOverlay(item: app.muxtv.catalog.ChannelBrowseItem) { + assertThat(item.displayName).isEqualTo("Мои новости") + assertThat(item.channelNumber).isEqualTo("77") + assertThat(item.isFavorite).isTrue() + } + + private fun query(filter: ChannelBrowseFilter) = ChannelBrowseQuery(PROFILE_ID, filter) + + private suspend fun activateCatalog() { + revisionStore.upsertSource(SourceDefinition(SOURCE_ID, "Provider")) + revisionStore.beginRevision( + sourceId = SOURCE_ID, + revisionNumber = 1L, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = SOURCE_ID, + revisionNumber = 1L, + entries = listOf( + stagedEntry( + providerChannelId = "provider-a", + canonicalChannelId = CHANNEL_A, + displayName = "Новости", + channelNumber = "1", + variantId = "variant-a", + ), + stagedEntry( + providerChannelId = "provider-b", + canonicalChannelId = CHANNEL_B, + displayName = "Спорт", + channelNumber = "2", + variantId = "variant-b", + ), + ), + ) + val result = revisionStore.activate( + sourceId = SOURCE_ID, + revisionNumber = 1L, + activatedAtEpochMillis = 1_500L, + statistics = SourceRevisionStatistics( + parsedEntries = 2, + skippedEntries = 0, + warningCount = 0, + ), + ) + assertThat(result).isInstanceOf(SourceRevisionActivationResult.Activated::class.java) + } + + private fun stagedEntry( + providerChannelId: String, + canonicalChannelId: String, + displayName: String, + channelNumber: String, + variantId: String, + ) = StagedCatalogEntry( + providerChannelId = providerChannelId, + providerKey = "tvg:$canonicalChannelId", + rawName = displayName, + canonicalChannelId = canonicalChannelId, + canonicalDisplayName = displayName, + streamVariantId = variantId, + locator = "https://example.invalid/$variantId.m3u8", + tvgId = canonicalChannelId, + groupTitle = "Тест", + channelNumber = channelNumber, + ) + + private companion object { + const val PROFILE_ID = "profile-main" + const val SOURCE_ID = "source-main" + const val CHANNEL_A = "channel-a" + const val CHANNEL_B = "channel-b" + } +} diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt new file mode 100644 index 000000000..965cc4bc3 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementInvalidationTest.kt @@ -0,0 +1,119 @@ +package app.muxtv.database + +import androidx.paging.PagingSource +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelManagementInvalidationTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + + @Before + fun setUp() = runTest { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + database.profileDao().insert(ProfileEntity(PROFILE_ID, "Primary", isPrimary = true)) + activateCatalog() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun overlayChangeInvalidatesManagementPagingSource() = runTest { + val source = database.channelBrowseDao().pageManagedChannels( + profileId = PROFILE_ID, + hiddenState = null, + ) + val first = source.load(refresh()) as PagingSource.LoadResult.Page + assertThat(first.data.single().isHidden).isFalse() + + val invalidated = CompletableDeferred() + source.registerInvalidatedCallback { invalidated.complete(Unit) } + + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_ID, + isHidden = true, + ), + ) + database.invalidationTracker.refresh("user_channel_overlays") + + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(10_000L) { invalidated.await() } + } + assertThat(source.invalid).isTrue() + + val replacement = database.channelBrowseDao().pageManagedChannels( + profileId = PROFILE_ID, + hiddenState = null, + ) + val second = replacement.load(refresh()) as PagingSource.LoadResult.Page + assertThat(second.data.single().isHidden).isTrue() + } + + private suspend fun activateCatalog() { + revisionStore.upsertSource(SourceDefinition(SOURCE_ID, "Provider")) + revisionStore.beginRevision( + sourceId = SOURCE_ID, + revisionNumber = 1L, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = SOURCE_ID, + revisionNumber = 1L, + entries = listOf( + StagedCatalogEntry( + providerChannelId = "provider-channel-1", + providerKey = "tvg:1", + rawName = "Channel 1", + canonicalChannelId = CHANNEL_ID, + canonicalDisplayName = "Channel 1", + streamVariantId = "variant-1", + locator = "https://example.invalid/1.m3u8", + channelNumber = "1", + ), + ), + ) + revisionStore.activate( + sourceId = SOURCE_ID, + revisionNumber = 1L, + activatedAtEpochMillis = 1_500L, + statistics = SourceRevisionStatistics( + parsedEntries = 1, + skippedEntries = 0, + warningCount = 0, + ), + ) + } + + private fun refresh() = PagingSource.LoadParams.Refresh( + key = null, + loadSize = 64, + placeholdersEnabled = false, + ) + + private companion object { + const val PROFILE_ID = "profile-main" + const val SOURCE_ID = "source-main" + const val CHANNEL_ID = "channel-00001" + } +} diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt new file mode 100644 index 000000000..f14bee164 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelManagementRepositoryTest.kt @@ -0,0 +1,144 @@ +package app.muxtv.database + +import androidx.paging.testing.asSnapshot +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelNowNext +import app.muxtv.catalog.EpgGuideRepository +import app.muxtv.catalog.NowNextQuery +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelManagementRepositoryTest { + private lateinit var database: MuxTvDatabase + private lateinit var revisionStore: SourceRevisionStore + + @Before + fun setUp() = runTest { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MuxTvDatabase::class.java, + ).build() + revisionStore = RoomSourceRevisionStore(database.sourceRevisionDao()) + database.profileDao().insert(ProfileEntity(PROFILE_ID, "Primary", isPrimary = true)) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun managementPagingDoesNotObserveOrQueryGuide() = runTest { + activateSource( + sourceId = "source-a", + providerChannelId = "provider-a", + streamVariantId = "variant-a", + channelNumber = "10", + ) + val repository = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = ExplodingGuideRepository, + nowEpochMillis = { 1_000L }, + ) + + val rows = repository.managementPages(query()).asSnapshot() + + assertThat(rows.map { it.channelId }).containsExactly(CANONICAL_CHANNEL_ID) + } + + @Test + fun managementPagingAggregatesActiveVariantsAcrossProvidersIntoOneCanonicalRow() = runTest { + activateSource( + sourceId = "source-a", + providerChannelId = "provider-a", + streamVariantId = "variant-a", + channelNumber = "10", + ) + activateSource( + sourceId = "source-b", + providerChannelId = "provider-b", + streamVariantId = "variant-b", + channelNumber = "20", + ) + val repository = RoomChannelBrowseRepository( + dao = database.channelBrowseDao(), + guideRepository = ExplodingGuideRepository, + nowEpochMillis = { 1_000L }, + ) + + val rows = repository.managementPages(query()).asSnapshot() + + assertThat(rows).hasSize(1) + assertThat(rows.single().channelId).isEqualTo(CANONICAL_CHANNEL_ID) + assertThat(rows.single().variantCount).isEqualTo(2) + assertThat(rows.single().defaultChannelNumber).isEqualTo("10") + } + + private fun query() = ChannelManagementQuery( + profileId = PROFILE_ID, + visibility = ChannelManagementVisibility.ALL, + ) + + private suspend fun activateSource( + sourceId: String, + providerChannelId: String, + streamVariantId: String, + channelNumber: String, + ) { + revisionStore.upsertSource(SourceDefinition(sourceId, sourceId)) + revisionStore.beginRevision( + sourceId = sourceId, + revisionNumber = 1L, + startedAtEpochMillis = 1_000L, + ) + revisionStore.stageBatch( + sourceId = sourceId, + revisionNumber = 1L, + entries = listOf( + StagedCatalogEntry( + providerChannelId = providerChannelId, + providerKey = "provider:$providerChannelId", + rawName = "Canonical Channel", + canonicalChannelId = CANONICAL_CHANNEL_ID, + canonicalDisplayName = "Canonical Channel", + streamVariantId = streamVariantId, + locator = "https://example.invalid/$streamVariantId.m3u8", + channelNumber = channelNumber, + ), + ), + ) + revisionStore.activate( + sourceId = sourceId, + revisionNumber = 1L, + activatedAtEpochMillis = 1_500L, + statistics = SourceRevisionStatistics( + parsedEntries = 1, + skippedEntries = 0, + warningCount = 0, + ), + ) + } + + private object ExplodingGuideRepository : EpgGuideRepository { + override suspend fun getNowNext(query: NowNextQuery): List = + error("Management paging must not query guide data") + + override fun observeDataChanges(): Flow = + error("Management paging must not observe guide data") + } + + private companion object { + const val PROFILE_ID = "profile-main" + const val CANONICAL_CHANNEL_ID = "channel-shared" + } +} diff --git a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt index 6fc30f5cb..9565493c2 100644 --- a/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt +++ b/core/database/src/androidTest/kotlin/app/muxtv/database/ChannelPreferencesRepositoryTest.kt @@ -4,6 +4,7 @@ import androidx.room3.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult import app.muxtv.catalog.ChannelQuery import app.muxtv.catalog.RejectAllPlaybackAccessPolicyResolver import app.muxtv.catalog.UnhandledPlaybackReferenceResolver @@ -98,11 +99,130 @@ class ChannelPreferencesRepositoryTest { assertThat(channel.summary.channelNumber).isEqualTo("7") } + @Test + fun hiddenMutationCreatesOverlayAndSupportsUnhide() = runTest { + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, true), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).isNull() + + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, true), + ).isEqualTo(ChannelPreferenceMutationResult.Unchanged) + + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, false), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).isNotNull() + } + + @Test + fun customNameMutationTrimsAndPreservesOtherOverlayFields() = runTest { + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_ID, + isFavorite = true, + channelNumber = 7, + ), + ) + + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, " My News "), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + + val channel = requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)) + assertThat(channel.summary.displayName).isEqualTo("My News") + assertThat(channel.summary.channelNumber).isEqualTo("7") + assertThat(channel.summary.isFavorite).isTrue() + + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "My News"), + ).isEqualTo(ChannelPreferenceMutationResult.Unchanged) + } + + @Test + fun customNameMutationRejectsInvalidInputWithoutCreatingOverlay() = runTest { + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, " "), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "News\u0000HD"), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "N".repeat(129)), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat(database.catalogDao().countOverlays(PROFILE_ID)).isEqualTo(0) + } + + @Test + fun channelNumberMutationPersistsValidNumberAndSupportsReset() = runTest { + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 7), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat( + requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).summary.channelNumber, + ).isEqualTo("7") + + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 0), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 10_000), + ).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, null), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + assertThat( + requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)).summary.channelNumber, + ).isEqualTo("10") + } + + @Test + fun resetCustomizationRestoresProviderPresentationAndPreservesFavorite() = runTest { + database.catalogDao().insertOverlay( + UserChannelOverlayEntity( + profileId = PROFILE_ID, + canonicalChannelId = CHANNEL_ID, + isFavorite = true, + isHidden = true, + customName = "Hidden News", + channelNumber = 77, + ), + ) + + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, CHANNEL_ID), + ).isEqualTo(ChannelPreferenceMutationResult.Applied) + + val channel = requireNotNull(playbackCatalog.getChannel(PROFILE_ID, CHANNEL_ID)) + assertThat(channel.summary.displayName).isEqualTo("News") + assertThat(channel.summary.channelNumber).isEqualTo("10") + assertThat(channel.summary.isFavorite).isTrue() + + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, CHANNEL_ID), + ).isEqualTo(ChannelPreferenceMutationResult.Unchanged) + } + @Test fun missingChannelReturnsNotFoundWithoutCreatingOverlay() = runTest { assertThat( channelPreferences.setFavorite(PROFILE_ID, "missing-channel", true), ).isEqualTo(ChannelFavoriteMutationResult.NotFound) + assertThat( + channelPreferences.setHidden(PROFILE_ID, "missing-channel", true), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, "missing-channel", "Missing"), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, "missing-channel", 1), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, "missing-channel"), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) assertThat(database.catalogDao().countOverlays(PROFILE_ID)).isEqualTo(0) } @@ -134,6 +254,18 @@ class ChannelPreferencesRepositoryTest { assertThat( channelPreferences.setFavorite(PROFILE_ID, CHANNEL_ID, true), ).isEqualTo(ChannelFavoriteMutationResult.NotFound) + assertThat( + channelPreferences.setHidden(PROFILE_ID, CHANNEL_ID, true), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setCustomName(PROFILE_ID, CHANNEL_ID, "Old News"), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.setChannelNumber(PROFILE_ID, CHANNEL_ID, 7), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) + assertThat( + channelPreferences.resetCustomization(PROFILE_ID, CHANNEL_ID), + ).isEqualTo(ChannelPreferenceMutationResult.NotFound) assertThat(database.catalogDao().countOverlays(PROFILE_ID)).isEqualTo(0) } diff --git a/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt b/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt index c65f3a075..4ea8e22b2 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/ChannelBrowseDao.kt @@ -15,6 +15,18 @@ internal data class ActiveChannelBrowseRow( val variantCount: Int, ) +internal data class ActiveChannelManagementRow( + val channelId: String, + val canonicalDisplayName: String, + val effectiveDisplayName: String, + val defaultChannelNumber: String?, + val customChannelNumber: Int?, + val effectiveChannelNumber: String?, + val isFavorite: Boolean, + val isHidden: Boolean, + val variantCount: Int, +) + @Dao @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) internal interface ChannelBrowseDao { @@ -59,6 +71,50 @@ internal interface ChannelBrowseDao { favoritesOnly: Boolean, ): PagingSource + @Query( + """ + SELECT canonical_channels.id AS channelId, + canonical_channels.displayName AS canonicalDisplayName, + COALESCE(user_channel_overlays.customName, canonical_channels.displayName) AS effectiveDisplayName, + MIN(provider_channels.channelNumber) AS defaultChannelNumber, + user_channel_overlays.channelNumber AS customChannelNumber, + COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) AS effectiveChannelNumber, + COALESCE(user_channel_overlays.isFavorite, 0) AS isFavorite, + COALESCE(user_channel_overlays.isHidden, 0) AS isHidden, + COUNT(DISTINCT stream_variants.id) AS variantCount + FROM canonical_channels + INNER JOIN stream_variants + ON stream_variants.canonicalChannelId = canonical_channels.id + INNER JOIN provider_channels + ON provider_channels.id = stream_variants.providerChannelId + INNER JOIN sources + ON sources.id = provider_channels.sourceId + LEFT JOIN user_channel_overlays + ON user_channel_overlays.profileId = :profileId + AND user_channel_overlays.canonicalChannelId = canonical_channels.id + WHERE provider_channels.revisionNumber = sources.activeRevision + AND (:hiddenState IS NULL OR COALESCE(user_channel_overlays.isHidden, 0) = :hiddenState) + GROUP BY canonical_channels.id, + canonical_channels.displayName, + user_channel_overlays.customName, + user_channel_overlays.channelNumber, + user_channel_overlays.isFavorite, + user_channel_overlays.isHidden + ORDER BY CASE + WHEN COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) <> '' + AND COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) NOT GLOB '*[^0-9]*' + THEN CAST(COALESCE(CAST(user_channel_overlays.channelNumber AS TEXT), MIN(provider_channels.channelNumber)) AS INTEGER) + ELSE 2147483647 + END, + effectiveDisplayName COLLATE NOCASE, + canonical_channels.id COLLATE BINARY + """, + ) + fun pageManagedChannels( + profileId: String, + hiddenState: Int?, + ): PagingSource + @Query( """ SELECT recent_channels.canonicalChannelId AS channelId, diff --git a/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt b/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt index 90f4d0a14..de5d37228 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/ChannelPreferencesDao.kt @@ -6,8 +6,12 @@ import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction -internal data class ActiveChannelFavoriteRow( +internal data class ActiveChannelPreferenceRow( + val hasOverlay: Boolean, val isFavorite: Boolean, + val customName: String?, + val channelNumber: Int?, + val isHidden: Boolean, ) internal enum class FavoriteWriteResult { @@ -16,11 +20,23 @@ internal enum class FavoriteWriteResult { NotFound, } +internal enum class PreferenceWriteResult { + Applied, + Unchanged, + NotFound, + InvalidInput, +} + @Dao internal abstract class ChannelPreferencesDao { @Query( """ - SELECT COALESCE(user_channel_overlays.isFavorite, 0) AS isFavorite + SELECT + CASE WHEN user_channel_overlays.canonicalChannelId IS NULL THEN 0 ELSE 1 END AS hasOverlay, + COALESCE(user_channel_overlays.isFavorite, 0) AS isFavorite, + user_channel_overlays.customName AS customName, + user_channel_overlays.channelNumber AS channelNumber, + COALESCE(user_channel_overlays.isHidden, 0) AS isHidden FROM canonical_channels INNER JOIN stream_variants ON stream_variants.canonicalChannelId = canonical_channels.id @@ -33,14 +49,13 @@ internal abstract class ChannelPreferencesDao { AND user_channel_overlays.canonicalChannelId = canonical_channels.id WHERE canonical_channels.id = :channelId AND provider_channels.revisionNumber = sources.activeRevision - AND COALESCE(user_channel_overlays.isHidden, 0) = 0 LIMIT 1 """, ) - protected abstract suspend fun findActiveFavorite( + protected abstract suspend fun findActivePreferences( profileId: String, channelId: String, - ): ActiveChannelFavoriteRow? + ): ActiveChannelPreferenceRow? @Query( """ @@ -56,6 +71,79 @@ internal abstract class ChannelPreferencesDao { isFavorite: Boolean, ): Int + @Query( + """ + UPDATE user_channel_overlays + SET isHidden = :isHidden + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun updateHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): Int + + @Query( + """ + UPDATE user_channel_overlays + SET customName = :customName + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun updateCustomName( + profileId: String, + channelId: String, + customName: String?, + ): Int + + @Query( + """ + UPDATE user_channel_overlays + SET channelNumber = :channelNumber + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun updateChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): Int + + @Query( + """ + UPDATE user_channel_overlays + SET customName = NULL, + channelNumber = NULL, + isHidden = 0 + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + """, + ) + protected abstract suspend fun resetPresentation( + profileId: String, + channelId: String, + ): Int + + @Query( + """ + DELETE FROM user_channel_overlays + WHERE profileId = :profileId + AND canonicalChannelId = :channelId + AND isFavorite = 0 + AND customName IS NULL + AND channelNumber IS NULL + AND isHidden = 0 + """, + ) + protected abstract suspend fun deleteDefaultOverlay( + profileId: String, + channelId: String, + ): Int + @Insert(onConflict = OnConflictStrategy.ABORT) protected abstract suspend fun insertOverlay(overlay: UserChannelOverlayEntity) @@ -68,8 +156,11 @@ internal abstract class ChannelPreferencesDao { require(profileId.isNotBlank()) require(channelId.isNotBlank()) - val current = findActiveFavorite(profileId, channelId) + val current = findActivePreferences(profileId, channelId) ?: return FavoriteWriteResult.NotFound + if (current.isHidden) { + return FavoriteWriteResult.NotFound + } if (current.isFavorite == isFavorite) { return FavoriteWriteResult.Unchanged } @@ -85,4 +176,130 @@ internal abstract class ChannelPreferencesDao { } return FavoriteWriteResult.Applied } + + @Transaction + open suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.isHidden == isHidden) { + return PreferenceWriteResult.Unchanged + } + + if (updateHidden(profileId, channelId, isHidden) == 0) { + insertOverlay( + UserChannelOverlayEntity( + profileId = profileId, + canonicalChannelId = channelId, + isHidden = isHidden, + ), + ) + } else if (!isHidden) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } + + @Transaction + open suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + val normalizedName = normalizeCustomChannelName(customName) + ?: if (customName == null) null else return PreferenceWriteResult.InvalidInput + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.customName == normalizedName) { + return PreferenceWriteResult.Unchanged + } + + if (updateCustomName(profileId, channelId, normalizedName) == 0) { + insertOverlay( + UserChannelOverlayEntity( + profileId = profileId, + canonicalChannelId = channelId, + customName = normalizedName, + ), + ) + } else if (normalizedName == null) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } + + @Transaction + open suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + if (channelNumber != null && channelNumber !in MIN_CUSTOM_CHANNEL_NUMBER..MAX_CUSTOM_CHANNEL_NUMBER) { + return PreferenceWriteResult.InvalidInput + } + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.channelNumber == channelNumber) { + return PreferenceWriteResult.Unchanged + } + + if (updateChannelNumber(profileId, channelId, channelNumber) == 0) { + insertOverlay( + UserChannelOverlayEntity( + profileId = profileId, + canonicalChannelId = channelId, + channelNumber = channelNumber, + ), + ) + } else if (channelNumber == null) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } + + @Transaction + open suspend fun resetCustomization( + profileId: String, + channelId: String, + ): PreferenceWriteResult { + require(profileId.isNotBlank()) + require(channelId.isNotBlank()) + + val current = findActivePreferences(profileId, channelId) + ?: return PreferenceWriteResult.NotFound + if (current.customName == null && current.channelNumber == null && !current.isHidden) { + return PreferenceWriteResult.Unchanged + } + + resetPresentation(profileId, channelId) + if (!current.isFavorite) { + deleteDefaultOverlay(profileId, channelId) + } + return PreferenceWriteResult.Applied + } } + +private fun normalizeCustomChannelName(value: String?): String? { + if (value == null) return null + val normalized = value.trim() + if (normalized.isEmpty()) return null + if (normalized.codePointCount(0, normalized.length) > MAX_CUSTOM_CHANNEL_NAME_CODE_POINTS) return null + if (normalized.any(Char::isISOControl)) return null + return normalized +} + +private const val MAX_CUSTOM_CHANNEL_NAME_CODE_POINTS = 128 +private const val MIN_CUSTOM_CHANNEL_NUMBER = 1 +private const val MAX_CUSTOM_CHANNEL_NUMBER = 9_999 diff --git a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt index 242d00f58..81cee32b6 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelBrowseRepository.kt @@ -5,10 +5,14 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import androidx.paging.PagingState +import androidx.paging.map import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseItem import app.muxtv.catalog.ChannelBrowseQuery import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.GuideProjectionState @@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.CancellationException @@ -64,6 +69,23 @@ internal class RoomChannelBrowseRepository( }, ).flow } + + override fun managementPages(query: ChannelManagementQuery): Flow> = + Pager( + config = CHANNEL_BROWSE_PAGING_CONFIG, + pagingSourceFactory = { + dao.pageManagedChannels( + profileId = query.profileId, + hiddenState = when (query.visibility) { + ChannelManagementVisibility.ALL -> null + ChannelManagementVisibility.VISIBLE -> 0 + ChannelManagementVisibility.HIDDEN -> 1 + }, + ) + }, + ).flow.map { pagingData -> + pagingData.map(ActiveChannelManagementRow::toModel) + } } internal val CHANNEL_BROWSE_PAGING_CONFIG = PagingConfig( @@ -168,3 +190,16 @@ private fun ActiveChannelBrowseRow.toModel(guide: ChannelNowNext?): ChannelBrows variantCount = variantCount, guideState = guide?.state ?: GuideProjectionState.NO_GUIDE, ) + +private fun ActiveChannelManagementRow.toModel(): ChannelManagementItem = + ChannelManagementItem( + channelId = channelId, + canonicalDisplayName = canonicalDisplayName, + effectiveDisplayName = effectiveDisplayName, + defaultChannelNumber = defaultChannelNumber, + customChannelNumber = customChannelNumber, + effectiveChannelNumber = effectiveChannelNumber, + isFavorite = isFavorite, + isHidden = isHidden, + variantCount = variantCount, + ) diff --git a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt index 95af20c2f..2fdb93da7 100644 --- a/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt +++ b/core/database/src/main/kotlin/app/muxtv/database/RoomChannelPreferencesRepository.kt @@ -1,6 +1,7 @@ package app.muxtv.database import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult import app.muxtv.catalog.ChannelPreferencesRepository internal class RoomChannelPreferencesRepository( @@ -15,4 +16,34 @@ internal class RoomChannelPreferencesRepository( FavoriteWriteResult.Unchanged -> ChannelFavoriteMutationResult.Unchanged FavoriteWriteResult.NotFound -> ChannelFavoriteMutationResult.NotFound } + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = dao.setHidden(profileId, channelId, isHidden).toApiResult() + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = dao.setCustomName(profileId, channelId, customName).toApiResult() + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = dao.setChannelNumber(profileId, channelId, channelNumber).toApiResult() + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult = dao.resetCustomization(profileId, channelId).toApiResult() +} + +private fun PreferenceWriteResult.toApiResult(): ChannelPreferenceMutationResult = when (this) { + PreferenceWriteResult.Applied -> ChannelPreferenceMutationResult.Applied + PreferenceWriteResult.Unchanged -> ChannelPreferenceMutationResult.Unchanged + PreferenceWriteResult.NotFound -> ChannelPreferenceMutationResult.NotFound + PreferenceWriteResult.InvalidInput -> ChannelPreferenceMutationResult.InvalidInput } diff --git a/core/database/src/test/kotlin/app/muxtv/database/ChannelPreferencesApiContractTest.kt b/core/database/src/test/kotlin/app/muxtv/database/ChannelPreferencesApiContractTest.kt new file mode 100644 index 000000000..bc1a897bb --- /dev/null +++ b/core/database/src/test/kotlin/app/muxtv/database/ChannelPreferencesApiContractTest.kt @@ -0,0 +1,22 @@ +package app.muxtv.database + +import app.muxtv.catalog.ChannelPreferencesRepository +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ChannelPreferencesApiContractTest { + @Test + fun channelControlMutationsAreExposedByCatalogPort() { + val methodNames = ChannelPreferencesRepository::class.java.methods + .map { method -> method.name } + .toSet() + + assertThat(methodNames).containsAtLeast( + "setFavorite", + "setHidden", + "setCustomName", + "setChannelNumber", + "resetCustomization", + ) + } +} diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelQuickActions.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelQuickActions.kt new file mode 100644 index 000000000..5d2a0451f --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelQuickActions.kt @@ -0,0 +1,78 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository + +internal enum class ChannelQuickActionKind { + FAVORITE, + HIDE, + RENAME, + CHANNEL_NUMBER, + RESET, +} + +internal data class ChannelQuickAction( + val kind: ChannelQuickActionKind, + val label: String, +) + +internal fun quickActionsFor(isFavorite: Boolean): List = listOf( + ChannelQuickAction( + kind = ChannelQuickActionKind.FAVORITE, + label = if (isFavorite) "Убрать из избранного" else "В избранное", + ), + ChannelQuickAction(ChannelQuickActionKind.HIDE, "Скрыть"), + ChannelQuickAction(ChannelQuickActionKind.RENAME, "Переименовать"), + ChannelQuickAction(ChannelQuickActionKind.CHANNEL_NUMBER, "Номер"), + ChannelQuickAction(ChannelQuickActionKind.RESET, "Сбросить"), +) + +internal class ChannelQuickActionsController( + private val channelPreferencesRepository: ChannelPreferencesRepository, + private val profileId: String, +) { + init { + require(profileId.isNotBlank()) + } + + suspend fun setFavorite( + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = channelPreferencesRepository.setFavorite( + profileId = profileId, + channelId = channelId, + isFavorite = isFavorite, + ) + + suspend fun hide(channelId: String): ChannelPreferenceMutationResult = + channelPreferencesRepository.setHidden( + profileId = profileId, + channelId = channelId, + isHidden = true, + ) + + suspend fun setCustomName( + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = channelPreferencesRepository.setCustomName( + profileId = profileId, + channelId = channelId, + customName = customName, + ) + + suspend fun setChannelNumber( + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = channelPreferencesRepository.setChannelNumber( + profileId = profileId, + channelId = channelId, + channelNumber = channelNumber, + ) + + suspend fun resetCustomization(channelId: String): ChannelPreferenceMutationResult = + channelPreferencesRepository.resetCustomization( + profileId = profileId, + channelId = channelId, + ) +} diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt new file mode 100644 index 000000000..f47234c26 --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsManageEntryRoute.kt @@ -0,0 +1,57 @@ +package app.muxtv.feature.channels + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.catalog.EpgGuideRepository +import app.muxtv.designsystem.TvTokens +import app.muxtv.designsystem.component.MuxTvActionButton +import app.muxtv.player.PlaybackSessionStateSource + +/** + * Adds the dedicated channel-management entry without changing the normal + * Channels browse/filter surface or its focus-restoration implementation. + */ +@Composable +fun ChannelsRoute( + channelBrowseRepository: ChannelBrowseRepository, + channelPreferencesRepository: ChannelPreferencesRepository, + epgGuideRepository: EpgGuideRepository, + playbackSessionStateSource: PlaybackSessionStateSource, + profileId: String, + onOpenChannel: (String) -> Unit, + onManageChannels: () -> Unit, + modifier: Modifier = Modifier, + railFocusRequester: FocusRequester? = null, +) { + Box(modifier = modifier.fillMaxSize()) { + ChannelsRoute( + channelBrowseRepository = channelBrowseRepository, + epgGuideRepository = epgGuideRepository, + playbackSessionStateSource = playbackSessionStateSource, + profileId = profileId, + onOpenChannel = onOpenChannel, + modifier = Modifier.fillMaxSize(), + railFocusRequester = railFocusRequester, + channelPreferencesRepository = channelPreferencesRepository, + ) + MuxTvActionButton( + text = "Управление", + onClick = onManageChannels, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 88.dp, end = TvTokens.Spacing.screenInset) + .testTag(CHANNELS_MANAGE_TEST_TAG), + ) + } +} + +internal const val CHANNELS_MANAGE_TEST_TAG = "channels-manage" diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt index 83d97e49a..1575f6c39 100644 --- a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ChannelsRoute.kt @@ -3,6 +3,7 @@ package app.muxtv.feature.channels import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +20,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Star @@ -30,6 +33,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -44,6 +48,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -59,6 +64,9 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository import app.muxtv.catalog.EpgGuideRepository import app.muxtv.designsystem.TvTokens import app.muxtv.designsystem.component.MuxTvActionButton @@ -68,6 +76,7 @@ import app.muxtv.designsystem.component.MuxTvScreenScaffold import app.muxtv.player.PlaybackSessionStateSource import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch @Composable fun ChannelsRoute( @@ -78,6 +87,7 @@ fun ChannelsRoute( onOpenChannel: (String) -> Unit, modifier: Modifier = Modifier, railFocusRequester: FocusRequester? = null, + channelPreferencesRepository: ChannelPreferencesRepository? = null, ) { val factory = remember( channelBrowseRepository, @@ -96,6 +106,14 @@ fun ChannelsRoute( } } } + val quickActionsController = remember(channelPreferencesRepository, profileId) { + channelPreferencesRepository?.let { repository -> + ChannelQuickActionsController( + channelPreferencesRepository = repository, + profileId = profileId, + ) + } + } val screenViewModel: ChannelsViewModel = viewModel(factory = factory) val filter by screenViewModel.filter.collectAsStateWithLifecycle() val rowsFlow = remember(screenViewModel, filter) { @@ -146,6 +164,7 @@ fun ChannelsRoute( filter = filter, listState = listState, focusAnchor = focusAnchor, + quickActionsController = quickActionsController, onFilterChanged = screenViewModel::setFilter, onFocusAnchorChanged = { anchor -> focusedChannelId = anchor.itemKey @@ -201,16 +220,20 @@ private fun ChannelsContent( filter: ChannelsFilter, listState: LazyListState, focusAnchor: FocusAnchor?, + quickActionsController: ChannelQuickActionsController?, onFilterChanged: (ChannelsFilter) -> Unit, onFocusAnchorChanged: (FocusAnchor) -> Unit, onOpenChannel: (String) -> Unit, modifier: Modifier, railFocusRequester: FocusRequester? = null, ) { + val scope = rememberCoroutineScope() val focusRequesters = remember { mutableStateMapOf() } val allFilterFocusRequester = remember { FocusRequester() } val favoritesFilterFocusRequester = remember { FocusRequester() } val recentFilterFocusRequester = remember { FocusRequester() } + val firstQuickActionFocusRequester = remember { FocusRequester() } + val quickEditorFocusRequester = remember { FocusRequester() } val selectedFilterFocusRequester = when (filter) { ChannelsFilter.ALL -> allFilterFocusRequester ChannelsFilter.FAVORITES -> favoritesFilterFocusRequester @@ -218,9 +241,47 @@ private fun ChannelsContent( } var restorationCompleted by remember(filter) { mutableStateOf(false) } var observedFocusedChannelId by remember(filter) { mutableStateOf(null) } + var quickActionChannel by remember(filter) { mutableStateOf(null) } + var quickActionAnchor by remember(filter) { mutableStateOf(null) } + var quickEditor by remember(filter) { mutableStateOf(null) } + var quickMutationMessage by remember(filter) { mutableStateOf(null) } + var quickFocusReturnRequest by remember(filter) { mutableStateOf(null) } val refreshState = rows.loadState.refresh val appendState = rows.loadState.append + fun dismissQuickActions(waitForAnchorRemoval: Boolean) { + quickActionAnchor?.let { anchor -> + quickFocusReturnRequest = ChannelQuickFocusReturnRequest( + anchor = anchor, + waitForAnchorRemoval = waitForAnchorRemoval, + ) + } + quickActionChannel = null + quickActionAnchor = null + quickEditor = null + quickMutationMessage = null + } + + fun consumePreferenceResult( + result: ChannelPreferenceMutationResult, + invalidMessage: String, + waitForAnchorRemoval: Boolean = false, + ) { + when (result) { + ChannelPreferenceMutationResult.Applied, + ChannelPreferenceMutationResult.Unchanged, + ChannelPreferenceMutationResult.NotFound, + -> dismissQuickActions( + waitForAnchorRemoval = waitForAnchorRemoval && + result != ChannelPreferenceMutationResult.NotFound, + ) + + ChannelPreferenceMutationResult.InvalidInput -> { + quickMutationMessage = invalidMessage + } + } + } + LaunchedEffect( rows, refreshState, @@ -294,111 +355,283 @@ private fun ChannelsContent( restorationCompleted = true } - MuxTvScreenScaffold( - title = filter.title(), - modifier = modifier, + LaunchedEffect(quickActionChannel?.channelId, quickEditor) { + if (quickActionChannel == null) return@LaunchedEffect + withFrameNanos { } + when (quickEditor) { + null -> firstQuickActionFocusRequester.requestFocus() + else -> quickEditorFocusRequester.requestFocus() + } + } + + LaunchedEffect( + quickFocusReturnRequest, + rows, + refreshState, + appendState, + rows.itemCount, ) { - Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { - MuxTvActionButton( - text = "Все каналы", - onClick = { onFilterChanged(ChannelsFilter.ALL) }, - selected = filter == ChannelsFilter.ALL, - modifier = Modifier.testTag(CHANNELS_ALL_FILTER_TEST_TAG) - .focusProperties { - left = railFocusRequester ?: FocusRequester.Default - right = favoritesFilterFocusRequester - } - .focusRequester(allFilterFocusRequester), - ) - MuxTvActionButton( - text = "Избранное", - onClick = { onFilterChanged(ChannelsFilter.FAVORITES) }, - selected = filter == ChannelsFilter.FAVORITES, - modifier = Modifier.testTag(CHANNELS_FAVORITES_FILTER_TEST_TAG) - .focusProperties { - left = allFilterFocusRequester - right = recentFilterFocusRequester - } - .focusRequester(favoritesFilterFocusRequester), - ) - MuxTvActionButton( - text = "Недавние", - onClick = { onFilterChanged(ChannelsFilter.RECENT) }, - selected = filter == ChannelsFilter.RECENT, - modifier = Modifier.testTag(CHANNELS_RECENT_FILTER_TEST_TAG) - .focusProperties { - left = favoritesFilterFocusRequester + val request = quickFocusReturnRequest ?: return@LaunchedEffect + if (refreshState !is LoadState.NotLoading || rows.itemCount == 0) return@LaunchedEffect + + val requestedIndex = request.anchor.previousIndex.coerceIn(0, rows.itemCount - 1) + listState.scrollToItem(requestedIndex, request.anchor.scrollOffset) + + val target = snapshotFlow { + val anchoredIndex = findLoadedIndex(rows, request.anchor.itemKey) + when { + request.waitForAnchorRemoval && anchoredIndex != null -> null + !request.waitForAnchorRemoval && anchoredIndex != null -> { + request.anchor.itemKey.let { anchoredIndex to it } + } + + appendState is LoadState.NotLoading && appendState.endOfPaginationReached -> { + val loadedIds = (0 until rows.itemCount) + .mapNotNull { index -> rows.peek(index)?.channelId } + if (loadedIds.size == rows.itemCount) { + request.anchor.resolveAgainst(loadedIds)?.let { resolved -> + resolved.index to resolved.itemKey + } + } else { + null } - .focusRequester(recentFilterFocusRequester), - ) + } + + else -> null + } } - Text( - text = filter.countLabel(rows.itemCount), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - LazyColumn( + .filterNotNull() + .first() + + val (targetIndex, targetId) = target + if (targetIndex != requestedIndex) { + listState.scrollToItem(targetIndex, request.anchor.scrollOffset) + } + + val requester = snapshotFlow { + val placed = listState.layoutInfo.visibleItemsInfo.any { item -> item.index == targetIndex } + if (placed) focusRequesters[targetId] else null + } + .filterNotNull() + .first() + + withFrameNanos { } + if (requester.requestFocus()) { + quickFocusReturnRequest = null + } + } + + Box(modifier = modifier.fillMaxSize()) { + MuxTvScreenScaffold( + title = filter.title(), modifier = Modifier.fillMaxSize(), - state = listState, - verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), ) { - items( - count = rows.itemCount, - key = rows.itemKey(ChannelRowUiModel::channelId), - contentType = rows.itemContentType { "channel-row" }, - ) { index -> - val row = rows[index] - if (row == null) { - Text("Загрузка…", modifier = Modifier.testTag("channel-loading-$index")) - } else { - val focusRequester = remember(row.channelId) { FocusRequester() } - DisposableEffect(row.channelId, focusRequester) { - focusRequesters[row.channelId] = focusRequester - onDispose { - if (focusRequesters[row.channelId] === focusRequester) { - focusRequesters.remove(row.channelId) - } + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = "Все каналы", + onClick = { onFilterChanged(ChannelsFilter.ALL) }, + selected = filter == ChannelsFilter.ALL, + modifier = Modifier.testTag(CHANNELS_ALL_FILTER_TEST_TAG) + .focusProperties { + left = railFocusRequester ?: FocusRequester.Default + right = favoritesFilterFocusRequester } - } - fun captureFocusAnchor() = onFocusAnchorChanged( - FocusAnchor( - itemKey = row.channelId, - previousIndex = index, - scrollOffset = listState.firstVisibleItemScrollOffset, - ), - ) - ChannelRow( - row = row, - onClick = { - captureFocusAnchor() - onOpenChannel(row.channelId) - }, - modifier = Modifier.fillMaxWidth() - .testTag("$CHANNEL_ROW_TEST_TAG_PREFIX$index") - .focusProperties { - if (index == 0) { - up = selectedFilterFocusRequester - left = railFocusRequester ?: FocusRequester.Default + .focusRequester(allFilterFocusRequester), + ) + MuxTvActionButton( + text = "Избранное", + onClick = { onFilterChanged(ChannelsFilter.FAVORITES) }, + selected = filter == ChannelsFilter.FAVORITES, + modifier = Modifier.testTag(CHANNELS_FAVORITES_FILTER_TEST_TAG) + .focusProperties { + left = allFilterFocusRequester + right = recentFilterFocusRequester + } + .focusRequester(favoritesFilterFocusRequester), + ) + MuxTvActionButton( + text = "Недавние", + onClick = { onFilterChanged(ChannelsFilter.RECENT) }, + selected = filter == ChannelsFilter.RECENT, + modifier = Modifier.testTag(CHANNELS_RECENT_FILTER_TEST_TAG) + .focusProperties { + left = favoritesFilterFocusRequester + } + .focusRequester(recentFilterFocusRequester), + ) + } + Text( + text = filter.countLabel(rows.itemCount), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + items( + count = rows.itemCount, + key = rows.itemKey(ChannelRowUiModel::channelId), + contentType = rows.itemContentType { "channel-row" }, + ) { index -> + val row = rows[index] + if (row == null) { + Text("Загрузка…", modifier = Modifier.testTag("channel-loading-$index")) + } else { + val focusRequester = remember(row.channelId) { FocusRequester() } + DisposableEffect(row.channelId, focusRequester) { + focusRequesters[row.channelId] = focusRequester + onDispose { + if (focusRequesters[row.channelId] === focusRequester) { + focusRequesters.remove(row.channelId) } } - .focusRequester(focusRequester) - .onFocusChanged { state -> - if (state.isFocused) { - observedFocusedChannelId = row.channelId - captureFocusAnchor() - } else if (observedFocusedChannelId == row.channelId) { - observedFocusedChannelId = null + } + fun captureFocusAnchor(): FocusAnchor { + val anchor = FocusAnchor( + itemKey = row.channelId, + previousIndex = index, + scrollOffset = listState.firstVisibleItemScrollOffset, + ) + onFocusAnchorChanged(anchor) + return anchor + } + ChannelRow( + row = row, + onClick = { + captureFocusAnchor() + onOpenChannel(row.channelId) + }, + onLongClick = quickActionsController?.let { + { + quickActionAnchor = captureFocusAnchor() + quickActionChannel = row + quickEditor = null + quickMutationMessage = null + quickFocusReturnRequest = null } }, - ) + modifier = Modifier.fillMaxWidth() + .testTag("$CHANNEL_ROW_TEST_TAG_PREFIX$index") + .focusProperties { + if (index == 0) { + up = selectedFilterFocusRequester + left = railFocusRequester ?: FocusRequester.Default + } + } + .focusRequester(focusRequester) + .onFocusChanged { state -> + if (state.isFocused) { + observedFocusedChannelId = row.channelId + captureFocusAnchor() + } else if (observedFocusedChannelId == row.channelId) { + observedFocusedChannelId = null + } + }, + ) + } } - } - if (rows.loadState.append is LoadState.Error) { - item(key = "append-error", contentType = "append-error") { - MuxTvActionButton(text = "Повторить загрузку", onClick = rows::retry) + if (rows.loadState.append is LoadState.Error) { + item(key = "append-error", contentType = "append-error") { + MuxTvActionButton(text = "Повторить загрузку", onClick = rows::retry) + } } } } + + val selected = quickActionChannel + val controller = quickActionsController + if (selected != null && controller != null) { + ChannelQuickActionsPanel( + channel = selected, + editor = quickEditor, + mutationMessage = quickMutationMessage, + firstActionFocusRequester = firstQuickActionFocusRequester, + editorFocusRequester = quickEditorFocusRequester, + onFavorite = { + scope.launch { + when (controller.setFavorite(selected.channelId, !selected.isFavorite)) { + ChannelFavoriteMutationResult.Applied, + ChannelFavoriteMutationResult.Unchanged, + -> dismissQuickActions( + waitForAnchorRemoval = filter == ChannelsFilter.FAVORITES && selected.isFavorite, + ) + + ChannelFavoriteMutationResult.NotFound -> dismissQuickActions(waitForAnchorRemoval = false) + } + } + }, + onHide = { + scope.launch { + consumePreferenceResult( + result = controller.hide(selected.channelId), + invalidMessage = "Не удалось скрыть канал.", + waitForAnchorRemoval = true, + ) + } + }, + onRename = { + quickEditor = ChannelQuickEditor.Name(selected.displayName) + quickMutationMessage = null + }, + onEditNumber = { + quickEditor = ChannelQuickEditor.Number("") + quickMutationMessage = null + }, + onEditorValueChanged = { value -> + quickEditor = when (val current = quickEditor) { + is ChannelQuickEditor.Name -> current.copy(value = value) + is ChannelQuickEditor.Number -> current.copy(value = value) + null -> null + } + quickMutationMessage = null + }, + onSaveEditor = save@{ + when (val current = quickEditor) { + is ChannelQuickEditor.Name -> scope.launch { + consumePreferenceResult( + result = controller.setCustomName(selected.channelId, current.value), + invalidMessage = "Введите имя от 1 до 128 символов без управляющих знаков.", + ) + } + + is ChannelQuickEditor.Number -> { + val raw = current.value.trim() + val parsed = raw.takeIf(String::isNotEmpty)?.toIntOrNull() + if (raw.isNotEmpty() && parsed == null) { + quickMutationMessage = "Введите номер от 1 до 9999." + return@save + } + scope.launch { + consumePreferenceResult( + result = controller.setChannelNumber(selected.channelId, parsed), + invalidMessage = "Введите номер от 1 до 9999.", + ) + } + } + + null -> Unit + } + }, + onCancelEditor = { + quickEditor = null + quickMutationMessage = null + }, + onReset = { + scope.launch { + consumePreferenceResult( + result = controller.resetCustomization(selected.channelId), + invalidMessage = "Не удалось сбросить настройки канала.", + ) + } + }, + onClose = { dismissQuickActions(waitForAnchorRemoval = false) }, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = TvTokens.Spacing.screenInset), + ) + } } } @@ -409,15 +642,158 @@ private fun findLoadedIndex(rows: LazyPagingItems, channelId: return null } +@Composable +private fun ChannelQuickActionsPanel( + channel: ChannelRowUiModel, + editor: ChannelQuickEditor?, + mutationMessage: String?, + firstActionFocusRequester: FocusRequester, + editorFocusRequester: FocusRequester, + onFavorite: () -> Unit, + onHide: () -> Unit, + onRename: () -> Unit, + onEditNumber: () -> Unit, + onEditorValueChanged: (String) -> Unit, + onSaveEditor: () -> Unit, + onCancelEditor: () -> Unit, + onReset: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + Column( + modifier = modifier + .width(420.dp) + .clip(shape) + .background(TvTokens.Color.surfaceRaised) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, shape) + .padding(TvTokens.Spacing.medium) + .testTag(CHANNEL_QUICK_ACTIONS_TEST_TAG), + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + Text( + text = channel.displayName, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Долгое OK · быстрые действия", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + when (editor) { + null -> Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.xSmall)) { + quickActionsFor(channel.isFavorite).forEachIndexed { index, action -> + MuxTvActionButton( + text = action.label, + onClick = when (action.kind) { + ChannelQuickActionKind.FAVORITE -> onFavorite + ChannelQuickActionKind.HIDE -> onHide + ChannelQuickActionKind.RENAME -> onRename + ChannelQuickActionKind.CHANNEL_NUMBER -> onEditNumber + ChannelQuickActionKind.RESET -> onReset + }, + modifier = if (index == 0) { + Modifier.fillMaxWidth().focusRequester(firstActionFocusRequester) + } else { + Modifier.fillMaxWidth() + }, + ) + } + MuxTvActionButton( + text = "Закрыть", + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) + } + + is ChannelQuickEditor.Name -> ChannelQuickEditorRow( + label = "Название", + value = editor.value, + keyboardType = KeyboardType.Text, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + + is ChannelQuickEditor.Number -> ChannelQuickEditorRow( + label = "Номер · пустое поле вернёт номер источника", + value = editor.value, + keyboardType = KeyboardType.Number, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + } + + if (mutationMessage != null) { + Text( + text = mutationMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(CHANNEL_QUICK_ACTIONS_ERROR_TEST_TAG), + ) + } + } +} + +@Composable +private fun ChannelQuickEditorRow( + label: String, + value: String, + keyboardType: KeyboardType, + focusRequester: FocusRequester, + onValueChanged: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.xSmall)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + BasicTextField( + value = value, + onValueChange = onValueChanged, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .padding(horizontal = TvTokens.Spacing.small) + .focusRequester(focusRequester) + .testTag(CHANNEL_QUICK_ACTIONS_EDITOR_TEST_TAG), + textStyle = MaterialTheme.typography.titleMedium.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + ) + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton(text = "Сохранить", onClick = onSave) + MuxTvActionButton(text = "Отмена", onClick = onCancel) + } + } +} + /** Lounge channel row: fixed geometry, no focus scale. */ @Composable private fun ChannelRow( row: ChannelRowUiModel, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { var focused by remember { mutableStateOf(false) } val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + val interactionModifier = if (onLongClick == null) { + Modifier.clickable(role = Role.Button, onClick = onClick) + } else { + Modifier.combinedClickable( + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick, + ) + } Row( modifier = modifier .height(TvTokens.Size.channelRowHeight) @@ -429,7 +805,7 @@ private fun ChannelRow( shape = shape, ) .onFocusChanged { focused = it.isFocused } - .clickable(role = Role.Button, onClick = onClick) + .then(interactionModifier) .focusable() .padding(horizontal = TvTokens.Spacing.medium), verticalAlignment = Alignment.CenterVertically, @@ -548,6 +924,18 @@ private fun MessageRoute( } } +private sealed interface ChannelQuickEditor { + val value: String + + data class Name(override val value: String) : ChannelQuickEditor + data class Number(override val value: String) : ChannelQuickEditor +} + +private data class ChannelQuickFocusReturnRequest( + val anchor: FocusAnchor, + val waitForAnchorRemoval: Boolean, +) + private fun ChannelsFilter.title() = when (this) { ChannelsFilter.ALL -> "Эфир" ChannelsFilter.FAVORITES -> "Избранное" @@ -574,5 +962,8 @@ private const val CHANNEL_ROW_TEST_TAG_PREFIX = "channel-row-" private const val CHANNELS_ALL_FILTER_TEST_TAG = "channels-filter-all" private const val CHANNELS_FAVORITES_FILTER_TEST_TAG = "channels-filter-favorites" private const val CHANNELS_RECENT_FILTER_TEST_TAG = "channels-filter-recent" +internal const val CHANNEL_QUICK_ACTIONS_TEST_TAG = "channel-quick-actions" +internal const val CHANNEL_QUICK_ACTIONS_EDITOR_TEST_TAG = "channel-quick-actions-editor" +internal const val CHANNEL_QUICK_ACTIONS_ERROR_TEST_TAG = "channel-quick-actions-error" private val ROW_PROGRAMME_WIDTH = 340.dp private val ROW_PROGRESS_WIDTH = 120.dp \ No newline at end of file diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt new file mode 100644 index 000000000..a0dc7863e --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt @@ -0,0 +1,740 @@ +package app.muxtv.feature.channels + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.designsystem.TvTokens +import app.muxtv.designsystem.component.MuxTvActionButton +import app.muxtv.designsystem.component.MuxTvChannelLogo +import app.muxtv.designsystem.component.MuxTvScreenScaffold +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@Composable +fun ManageChannelsRoute( + channelBrowseRepository: ChannelBrowseRepository, + channelPreferencesRepository: ChannelPreferencesRepository, + profileId: String, + modifier: Modifier = Modifier, + railFocusRequester: FocusRequester? = null, +) { + val factory = remember(channelBrowseRepository, channelPreferencesRepository, profileId) { + viewModelFactory { + initializer { + ManageChannelsViewModel( + channelBrowseRepository = channelBrowseRepository, + channelPreferencesRepository = channelPreferencesRepository, + profileId = profileId, + ) + } + } + } + val screenViewModel: ManageChannelsViewModel = viewModel(factory = factory) + val filter by screenViewModel.filter.collectAsStateWithLifecycle() + val rowsFlow = remember(screenViewModel, filter) { screenViewModel.rowsFor(filter) } + val rows = rowsFlow.collectAsLazyPagingItems() + val scope = rememberCoroutineScope() + var selectedChannel by remember { mutableStateOf(null) } + var editor by remember { mutableStateOf(null) } + var mutationMessage by remember { mutableStateOf(null) } + var selectionFocusAnchor by remember { mutableStateOf(null) } + var focusReturnRequest by remember { mutableStateOf(null) } + + fun dismissActions(waitForAnchorRemoval: Boolean) { + selectionFocusAnchor?.let { anchor -> + focusReturnRequest = ManageChannelsFocusReturnRequest( + anchor = anchor, + waitForAnchorRemoval = waitForAnchorRemoval, + ) + } + selectionFocusAnchor = null + selectedChannel = null + editor = null + } + + fun consumeMutationResult( + result: ChannelPreferenceMutationResult, + invalidMessage: String, + waitForAnchorRemoval: Boolean = false, + ) { + mutationMessage = when (result) { + ChannelPreferenceMutationResult.Applied, + ChannelPreferenceMutationResult.Unchanged, + -> null + + ChannelPreferenceMutationResult.NotFound -> "Канал больше недоступен в активном каталоге." + ChannelPreferenceMutationResult.InvalidInput -> invalidMessage + } + if (result.shouldDismissManageChannelActions()) { + dismissActions(waitForAnchorRemoval = waitForAnchorRemoval) + } + } + + ManageChannelsContent( + rows = rows, + filter = filter, + selectedChannel = selectedChannel, + editor = editor, + mutationMessage = mutationMessage, + focusReturnRequest = focusReturnRequest, + onFilterChanged = { next -> + selectionFocusAnchor = null + focusReturnRequest = null + selectedChannel = null + editor = null + mutationMessage = null + screenViewModel.setFilter(next) + }, + onSelectChannel = { channel, anchor -> + selectedChannel = channel + selectionFocusAnchor = anchor + focusReturnRequest = null + editor = null + mutationMessage = null + }, + onCloseActions = { + dismissActions(waitForAnchorRemoval = false) + mutationMessage = null + }, + onFocusReturnConsumed = { + focusReturnRequest = null + }, + onToggleHidden = { channel -> + scope.launch { + consumeMutationResult( + result = screenViewModel.setHidden(channel.channelId, !channel.isHidden), + invalidMessage = "Не удалось изменить видимость канала.", + waitForAnchorRemoval = filter != ManageChannelsFilter.ALL, + ) + } + }, + onRename = { channel -> + editor = ManageChannelEditor.Name( + value = channel.effectiveDisplayName, + ) + mutationMessage = null + }, + onEditNumber = { channel -> + editor = ManageChannelEditor.Number( + value = channel.customChannelNumber?.toString().orEmpty(), + ) + mutationMessage = null + }, + onEditorValueChanged = { value -> + editor = when (val current = editor) { + is ManageChannelEditor.Name -> current.copy(value = value) + is ManageChannelEditor.Number -> current.copy(value = value) + null -> null + } + mutationMessage = null + }, + onSaveEditor = save@{ + val channel = selectedChannel ?: return@save + when (val current = editor) { + is ManageChannelEditor.Name -> scope.launch { + consumeMutationResult( + result = screenViewModel.setCustomName(channel.channelId, current.value), + invalidMessage = "Введите имя от 1 до 128 символов без управляющих знаков.", + ) + } + + is ManageChannelEditor.Number -> { + val raw = current.value.trim() + val parsed = raw.takeIf(String::isNotEmpty)?.toIntOrNull() + if (raw.isNotEmpty() && parsed == null) { + mutationMessage = "Введите номер от 1 до 9999." + return@save + } + scope.launch { + consumeMutationResult( + result = screenViewModel.setChannelNumber(channel.channelId, parsed), + invalidMessage = "Введите номер от 1 до 9999.", + ) + } + } + + null -> Unit + } + }, + onCancelEditor = { + editor = null + mutationMessage = null + }, + onReset = { channel -> + scope.launch { + consumeMutationResult( + result = screenViewModel.resetCustomization(channel.channelId), + invalidMessage = "Не удалось сбросить настройки канала.", + waitForAnchorRemoval = filter == ManageChannelsFilter.HIDDEN && channel.isHidden, + ) + } + }, + railFocusRequester = railFocusRequester, + modifier = modifier, + ) +} + +@Composable +private fun ManageChannelsContent( + rows: LazyPagingItems, + filter: ManageChannelsFilter, + selectedChannel: ChannelManagementItem?, + editor: ManageChannelEditor?, + mutationMessage: String?, + focusReturnRequest: ManageChannelsFocusReturnRequest?, + onFilterChanged: (ManageChannelsFilter) -> Unit, + onSelectChannel: (ChannelManagementItem, FocusAnchor) -> Unit, + onCloseActions: () -> Unit, + onFocusReturnConsumed: () -> Unit, + onToggleHidden: (ChannelManagementItem) -> Unit, + onRename: (ChannelManagementItem) -> Unit, + onEditNumber: (ChannelManagementItem) -> Unit, + onEditorValueChanged: (String) -> Unit, + onSaveEditor: () -> Unit, + onCancelEditor: () -> Unit, + onReset: (ChannelManagementItem) -> Unit, + railFocusRequester: FocusRequester?, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val rowFocusRequesters = remember { mutableStateMapOf() } + val allFocusRequester = remember { FocusRequester() } + val visibleFocusRequester = remember { FocusRequester() } + val hiddenFocusRequester = remember { FocusRequester() } + val firstActionFocusRequester = remember { FocusRequester() } + val editorFocusRequester = remember { FocusRequester() } + val selectedFilterFocusRequester = when (filter) { + ManageChannelsFilter.ALL -> allFocusRequester + ManageChannelsFilter.VISIBLE -> visibleFocusRequester + ManageChannelsFilter.HIDDEN -> hiddenFocusRequester + } + val refreshState = rows.loadState.refresh + val appendState = rows.loadState.append + + LaunchedEffect(selectedChannel?.channelId, editor) { + when { + editor != null -> editorFocusRequester.requestFocus() + selectedChannel != null -> firstActionFocusRequester.requestFocus() + } + } + + LaunchedEffect( + focusReturnRequest, + rows, + refreshState, + appendState, + rows.itemCount, + ) { + val request = focusReturnRequest ?: return@LaunchedEffect + if (refreshState !is LoadState.NotLoading) return@LaunchedEffect + + if (rows.itemCount == 0) { + withFrameNanos { } + if (selectedFilterFocusRequester.requestFocus()) { + onFocusReturnConsumed() + } + return@LaunchedEffect + } + + val requestedIndex = request.anchor.previousIndex.coerceIn(0, rows.itemCount - 1) + listState.scrollToItem(requestedIndex, request.anchor.scrollOffset) + + val target = snapshotFlow { + val anchoredIndex = findLoadedManageChannelIndex(rows, request.anchor.itemKey) + when { + request.waitForAnchorRemoval && anchoredIndex != null -> null + !request.waitForAnchorRemoval && anchoredIndex != null -> { + request.anchor.itemKey.let { anchoredIndex to it } + } + + appendState is LoadState.NotLoading && appendState.endOfPaginationReached -> { + val loadedIds = (0 until rows.itemCount) + .mapNotNull { index -> rows.peek(index)?.channelId } + if (loadedIds.size == rows.itemCount) { + request.anchor.resolveAgainst(loadedIds)?.let { resolved -> + resolved.index to resolved.itemKey + } + } else { + null + } + } + + else -> null + } + } + .filterNotNull() + .first() + + val (targetIndex, targetId) = target + if (targetIndex != requestedIndex) { + listState.scrollToItem(targetIndex, request.anchor.scrollOffset) + } + + val requester = snapshotFlow { + val placed = listState.layoutInfo.visibleItemsInfo.any { item -> item.index == targetIndex } + if (placed) rowFocusRequesters[targetId] else null + } + .filterNotNull() + .first() + + withFrameNanos { } + if (requester.requestFocus()) { + onFocusReturnConsumed() + } + } + + MuxTvScreenScaffold( + title = "Управление каналами", + modifier = modifier, + titleTestTag = MANAGE_CHANNELS_TITLE_TEST_TAG, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = "Все", + onClick = { onFilterChanged(ManageChannelsFilter.ALL) }, + selected = filter == ManageChannelsFilter.ALL, + modifier = Modifier + .testTag(MANAGE_CHANNELS_FILTER_ALL_TEST_TAG) + .focusProperties { + left = railFocusRequester ?: FocusRequester.Default + right = visibleFocusRequester + } + .focusRequester(allFocusRequester), + ) + MuxTvActionButton( + text = "Видимые", + onClick = { onFilterChanged(ManageChannelsFilter.VISIBLE) }, + selected = filter == ManageChannelsFilter.VISIBLE, + modifier = Modifier + .testTag(MANAGE_CHANNELS_FILTER_VISIBLE_TEST_TAG) + .focusProperties { + left = allFocusRequester + right = hiddenFocusRequester + } + .focusRequester(visibleFocusRequester), + ) + MuxTvActionButton( + text = "Скрытые", + onClick = { onFilterChanged(ManageChannelsFilter.HIDDEN) }, + selected = filter == ManageChannelsFilter.HIDDEN, + modifier = Modifier + .testTag(MANAGE_CHANNELS_FILTER_HIDDEN_TEST_TAG) + .focusProperties { left = visibleFocusRequester } + .focusRequester(hiddenFocusRequester), + ) + } + + Spacer(Modifier.height(TvTokens.Spacing.small)) + Text( + text = filter.summary(rows.itemCount), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (selectedChannel != null) { + Spacer(Modifier.height(TvTokens.Spacing.small)) + ManageChannelActions( + channel = selectedChannel, + editor = editor, + mutationMessage = mutationMessage, + firstActionFocusRequester = firstActionFocusRequester, + editorFocusRequester = editorFocusRequester, + onToggleHidden = { onToggleHidden(selectedChannel) }, + onRename = { onRename(selectedChannel) }, + onEditNumber = { onEditNumber(selectedChannel) }, + onEditorValueChanged = onEditorValueChanged, + onSaveEditor = onSaveEditor, + onCancelEditor = onCancelEditor, + onReset = { onReset(selectedChannel) }, + onClose = onCloseActions, + ) + } else if (mutationMessage != null) { + Spacer(Modifier.height(TvTokens.Spacing.small)) + Text( + text = mutationMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(Modifier.height(TvTokens.Spacing.small)) + when { + rows.loadState.refresh is LoadState.Loading && rows.itemCount == 0 -> { + Text("Загрузка каналов…", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + rows.loadState.refresh is LoadState.Error && rows.itemCount == 0 -> { + Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + Text("Не удалось прочитать список каналов.", color = MaterialTheme.colorScheme.error) + MuxTvActionButton(text = "Повторить", onClick = rows::retry) + } + } + + rows.loadState.refresh is LoadState.NotLoading && rows.itemCount == 0 -> { + Text( + text = filter.emptyMessage(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MANAGE_CHANNELS_EMPTY_TEST_TAG), + ) + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + items( + count = rows.itemCount, + key = rows.itemKey(ChannelManagementItem::channelId), + contentType = rows.itemContentType { "manage-channel-row" }, + ) { index -> + val channel = rows[index] + if (channel == null) { + Text("Загрузка…") + } else { + val focusRequester = remember(channel.channelId) { FocusRequester() } + DisposableEffect(channel.channelId, focusRequester) { + rowFocusRequesters[channel.channelId] = focusRequester + onDispose { + if (rowFocusRequesters[channel.channelId] === focusRequester) { + rowFocusRequesters.remove(channel.channelId) + } + } + } + ManageChannelRow( + channel = channel, + selected = selectedChannel?.channelId == channel.channelId, + onClick = { + onSelectChannel( + channel, + FocusAnchor( + itemKey = channel.channelId, + previousIndex = index, + scrollOffset = listState.firstVisibleItemScrollOffset, + ), + ) + }, + modifier = Modifier + .fillMaxWidth() + .testTag("$MANAGE_CHANNEL_ROW_TEST_TAG_PREFIX${channel.channelId}") + .focusProperties { + if (index == 0) { + up = selectedFilterFocusRequester + left = railFocusRequester ?: FocusRequester.Default + } + } + .focusRequester(focusRequester), + ) + } + } + if (rows.loadState.append is LoadState.Error) { + item(key = "manage-append-error") { + MuxTvActionButton(text = "Повторить загрузку", onClick = rows::retry) + } + } + } + } + } +} + +private fun findLoadedManageChannelIndex( + rows: LazyPagingItems, + channelId: String, +): Int? { + for (index in 0 until rows.itemCount) { + if (rows.peek(index)?.channelId == channelId) return index + } + return null +} + +@Composable +private fun ManageChannelActions( + channel: ChannelManagementItem, + editor: ManageChannelEditor?, + mutationMessage: String?, + firstActionFocusRequester: FocusRequester, + editorFocusRequester: FocusRequester, + onToggleHidden: () -> Unit, + onRename: () -> Unit, + onEditNumber: () -> Unit, + onEditorValueChanged: (String) -> Unit, + onSaveEditor: () -> Unit, + onCancelEditor: () -> Unit, + onReset: () -> Unit, + onClose: () -> Unit, +) { + val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(TvTokens.Color.surfaceRaised) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, shape) + .padding(TvTokens.Spacing.medium) + .testTag(MANAGE_CHANNELS_ACTIONS_TEST_TAG), + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + Text( + text = channel.effectiveDisplayName, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + when (editor) { + null -> Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = if (channel.isHidden) "Показать" else "Скрыть", + onClick = onToggleHidden, + modifier = Modifier.focusRequester(firstActionFocusRequester), + ) + MuxTvActionButton(text = "Переименовать", onClick = onRename) + MuxTvActionButton(text = "Номер", onClick = onEditNumber) + MuxTvActionButton(text = "Сбросить", onClick = onReset) + MuxTvActionButton(text = "Закрыть", onClick = onClose) + } + + is ManageChannelEditor.Name -> EditorRow( + label = "Название", + value = editor.value, + keyboardType = KeyboardType.Text, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + + is ManageChannelEditor.Number -> EditorRow( + label = "Номер · пустое поле вернёт номер источника", + value = editor.value, + keyboardType = KeyboardType.Number, + focusRequester = editorFocusRequester, + onValueChanged = onEditorValueChanged, + onSave = onSaveEditor, + onCancel = onCancelEditor, + ) + } + + if (mutationMessage != null) { + Text( + text = mutationMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(MANAGE_CHANNELS_ERROR_TEST_TAG), + ) + } + } +} + +@Composable +private fun EditorRow( + label: String, + value: String, + keyboardType: KeyboardType, + focusRequester: FocusRequester, + onValueChanged: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.xSmall)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row( + horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = value, + onValueChange = onValueChanged, + modifier = Modifier + .width(420.dp) + .height(48.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .border(1.dp, MaterialTheme.colorScheme.borderVariant, RoundedCornerShape(TvTokens.Shape.rowCorner)) + .padding(horizontal = TvTokens.Spacing.small) + .focusRequester(focusRequester) + .testTag(MANAGE_CHANNELS_EDITOR_TEST_TAG), + textStyle = MaterialTheme.typography.titleMedium.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + ) + MuxTvActionButton(text = "Сохранить", onClick = onSave) + MuxTvActionButton(text = "Отмена", onClick = onCancel) + } + } +} + +@Composable +private fun ManageChannelRow( + channel: ChannelManagementItem, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember(channel.channelId) { mutableStateOf(false) } + val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + val customizedName = channel.effectiveDisplayName != channel.canonicalDisplayName + val customizedNumber = channel.customChannelNumber != null + val originalParts = buildList { + if (customizedName) add("исходное имя: ${channel.canonicalDisplayName}") + if (customizedNumber) { + channel.defaultChannelNumber?.takeIf(String::isNotBlank)?.let { add("номер источника: $it") } + } + } + val stateParts = buildList { + add(if (channel.isHidden) "Скрыт" else "Видим") + if (channel.isFavorite) add("Избранное") + if (customizedName || customizedNumber) add("Изменён") + if (channel.variantCount > 1) add("Источников: ${channel.variantCount}") + } + + Row( + modifier = modifier + .height(MANAGE_CHANNEL_ROW_HEIGHT) + .clip(shape) + .background( + when { + focused -> TvTokens.Color.surfaceRaised + selected -> MaterialTheme.colorScheme.surfaceVariant + else -> MaterialTheme.colorScheme.surface + }, + ) + .border( + width = if (focused) TvTokens.Focus.outlineWidth else 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.borderVariant, + shape = shape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable(role = Role.Button, onClick = onClick) + .focusable() + .padding(horizontal = TvTokens.Spacing.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.Center) { + Text( + text = channel.effectiveChannelNumber?.takeIf(String::isNotBlank) ?: "—", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Spacer(Modifier.width(TvTokens.Spacing.small)) + MuxTvChannelLogo(name = channel.effectiveDisplayName) + Spacer(Modifier.width(TvTokens.Spacing.small)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = channel.effectiveDisplayName, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = originalParts.joinToString(" · ").ifBlank { "Без пользовательских изменений" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(TvTokens.Spacing.medium)) + Text( + text = stateParts.joinToString(" · "), + style = MaterialTheme.typography.bodyMedium, + color = if (channel.isHidden) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +private data class ManageChannelsFocusReturnRequest( + val anchor: FocusAnchor, + val waitForAnchorRemoval: Boolean, +) + +private sealed interface ManageChannelEditor { + val value: String + + data class Name(override val value: String) : ManageChannelEditor + data class Number(override val value: String) : ManageChannelEditor +} + +internal fun ChannelPreferenceMutationResult.shouldDismissManageChannelActions(): Boolean = + this != ChannelPreferenceMutationResult.InvalidInput + +private fun ManageChannelsFilter.summary(count: Int): String = when (this) { + ManageChannelsFilter.ALL -> "Каналов в управлении: $count" + ManageChannelsFilter.VISIBLE -> "Видимых каналов: $count" + ManageChannelsFilter.HIDDEN -> "Скрытых каналов: $count" +} + +private fun ManageChannelsFilter.emptyMessage(): String = when (this) { + ManageChannelsFilter.ALL -> "Активных каналов пока нет." + ManageChannelsFilter.VISIBLE -> "Нет видимых каналов. Скрытые можно восстановить во вкладке «Скрытые»." + ManageChannelsFilter.HIDDEN -> "Скрытых каналов нет." +} + +private const val MANAGE_CHANNELS_TITLE_TEST_TAG = "manage-channels-title" +private const val MANAGE_CHANNELS_FILTER_ALL_TEST_TAG = "manage-channels-filter-all" +private const val MANAGE_CHANNELS_FILTER_VISIBLE_TEST_TAG = "manage-channels-filter-visible" +private const val MANAGE_CHANNELS_FILTER_HIDDEN_TEST_TAG = "manage-channels-filter-hidden" +private const val MANAGE_CHANNELS_EMPTY_TEST_TAG = "manage-channels-empty" +private const val MANAGE_CHANNELS_ACTIONS_TEST_TAG = "manage-channels-actions" +private const val MANAGE_CHANNELS_EDITOR_TEST_TAG = "manage-channels-editor" +private const val MANAGE_CHANNELS_ERROR_TEST_TAG = "manage-channels-error" +private const val MANAGE_CHANNEL_ROW_TEST_TAG_PREFIX = "manage-channel-row-" +private val MANAGE_CHANNEL_ROW_HEIGHT = 88.dp diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt new file mode 100644 index 000000000..927c4b1ff --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt @@ -0,0 +1,99 @@ +package app.muxtv.feature.channels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class ManageChannelsFilter { + ALL, + VISIBLE, + HIDDEN, +} + +class ManageChannelsViewModel( + private val channelBrowseRepository: ChannelBrowseRepository, + private val channelPreferencesRepository: ChannelPreferencesRepository, + private val profileId: String, +) : ViewModel() { + init { + require(profileId.isNotBlank()) { "profileId must not be blank." } + } + + private val mutableFilter = MutableStateFlow(ManageChannelsFilter.ALL) + val filter: StateFlow = mutableFilter.asStateFlow() + + private val rowsByFilter = + mutableMapOf>>() + + fun setFilter(filter: ManageChannelsFilter) { + mutableFilter.value = filter + } + + fun rowsFor(filter: ManageChannelsFilter): Flow> = + rowsByFilter.getOrPut(filter) { + channelBrowseRepository + .managementPages( + ChannelManagementQuery( + profileId = profileId, + visibility = filter.toManagementVisibility(), + ), + ) + .cachedIn(viewModelScope) + } + + suspend fun setHidden( + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.setHidden( + profileId = profileId, + channelId = channelId, + isHidden = isHidden, + ) + + suspend fun setCustomName( + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.setCustomName( + profileId = profileId, + channelId = channelId, + customName = customName, + ) + + suspend fun setChannelNumber( + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.setChannelNumber( + profileId = profileId, + channelId = channelId, + channelNumber = channelNumber, + ) + + suspend fun resetCustomization( + channelId: String, + ): ChannelPreferenceMutationResult = + channelPreferencesRepository.resetCustomization( + profileId = profileId, + channelId = channelId, + ) + + private fun ManageChannelsFilter.toManagementVisibility(): ChannelManagementVisibility = + when (this) { + ManageChannelsFilter.ALL -> ChannelManagementVisibility.ALL + ManageChannelsFilter.VISIBLE -> ChannelManagementVisibility.VISIBLE + ManageChannelsFilter.HIDDEN -> ChannelManagementVisibility.HIDDEN + } +} diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelQuickActionsContractTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelQuickActionsContractTest.kt new file mode 100644 index 000000000..ae62b9d58 --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelQuickActionsContractTest.kt @@ -0,0 +1,103 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class ChannelQuickActionsContractTest { + @Test + fun `visible channel exposes bounded quick actions in stable order`() { + assertThat(quickActionsFor(isFavorite = false).map(ChannelQuickAction::kind)) + .containsExactly( + ChannelQuickActionKind.FAVORITE, + ChannelQuickActionKind.HIDE, + ChannelQuickActionKind.RENAME, + ChannelQuickActionKind.CHANNEL_NUMBER, + ChannelQuickActionKind.RESET, + ) + .inOrder() + assertThat(quickActionsFor(isFavorite = false).first().label).isEqualTo("В избранное") + assertThat(quickActionsFor(isFavorite = true).first().label).isEqualTo("Убрать из избранного") + } + + @Test + fun `quick action mutations remain profile scoped`() = runBlocking { + val preferences = RecordingPreferencesRepository() + val controller = ChannelQuickActionsController( + channelPreferencesRepository = preferences, + profileId = PROFILE_ID, + ) + + controller.setFavorite(CHANNEL_ID, true) + controller.hide(CHANNEL_ID) + controller.setCustomName(CHANNEL_ID, " News ") + controller.setChannelNumber(CHANNEL_ID, 77) + controller.resetCustomization(CHANNEL_ID) + + assertThat(preferences.calls) + .containsExactly( + "favorite:$PROFILE_ID:$CHANNEL_ID:true", + "hidden:$PROFILE_ID:$CHANNEL_ID:true", + "name:$PROFILE_ID:$CHANNEL_ID: News ", + "number:$PROFILE_ID:$CHANNEL_ID:77", + "reset:$PROFILE_ID:$CHANNEL_ID", + ) + .inOrder() + } + + private class RecordingPreferencesRepository : ChannelPreferencesRepository { + val calls = mutableListOf() + + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult { + calls += "favorite:$profileId:$channelId:$isFavorite" + return ChannelFavoriteMutationResult.Applied + } + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult { + calls += "hidden:$profileId:$channelId:$isHidden" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult { + calls += "name:$profileId:$channelId:$customName" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult { + calls += "number:$profileId:$channelId:$channelNumber" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult { + calls += "reset:$profileId:$channelId" + return ChannelPreferenceMutationResult.Applied + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + const val CHANNEL_ID = "channel-a" + } +} diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt index 6800767b5..cd79d202e 100644 --- a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ChannelsViewModelTest.kt @@ -9,6 +9,8 @@ import app.muxtv.catalog.ChannelBrowseFilter import app.muxtv.catalog.ChannelBrowseItem import app.muxtv.catalog.ChannelBrowseQuery import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery import app.muxtv.catalog.ChannelNowNext import app.muxtv.catalog.EpgGuideRepository import app.muxtv.catalog.GuideProjectionState @@ -212,6 +214,9 @@ class ChannelsViewModelTest { } return flowOf(PagingData.from(listOf(item(id)))) } + + override fun managementPages(query: ChannelManagementQuery): Flow> = + flowOf(PagingData.empty()) } private class FakePlaybackStateSource : PlaybackSessionStateSource { diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsFilterContractTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsFilterContractTest.kt new file mode 100644 index 000000000..1b1abbfe8 --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsFilterContractTest.kt @@ -0,0 +1,16 @@ +package app.muxtv.feature.channels + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ManageChannelsFilterContractTest { + @Test + fun managementSurfaceExposesOnlyExplicitVisibilityFilters() { + assertThat(ManageChannelsFilter.entries) + .containsExactly( + ManageChannelsFilter.ALL, + ManageChannelsFilter.VISIBLE, + ManageChannelsFilter.HIDDEN, + ).inOrder() + } +} diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsMutationUiContractTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsMutationUiContractTest.kt new file mode 100644 index 000000000..512d2bfa3 --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsMutationUiContractTest.kt @@ -0,0 +1,15 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelPreferenceMutationResult +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ManageChannelsMutationUiContractTest { + @Test + fun onlyInvalidInputKeepsTheCurrentActionSnapshotOpen() { + assertThat(ChannelPreferenceMutationResult.Applied.shouldDismissManageChannelActions()).isTrue() + assertThat(ChannelPreferenceMutationResult.Unchanged.shouldDismissManageChannelActions()).isTrue() + assertThat(ChannelPreferenceMutationResult.NotFound.shouldDismissManageChannelActions()).isTrue() + assertThat(ChannelPreferenceMutationResult.InvalidInput.shouldDismissManageChannelActions()).isFalse() + } +} diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt new file mode 100644 index 000000000..a1fb58ced --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt @@ -0,0 +1,183 @@ +package app.muxtv.feature.channels + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.paging.PagingData +import app.muxtv.catalog.ChannelBrowseItem +import app.muxtv.catalog.ChannelBrowseQuery +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ManageChannelsViewModelTest { + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun managementFiltersCreateExplicitHiddenAwareQueriesLazily() = runBlocking { + val browse = RecordingBrowseRepository() + val preferences = RecordingPreferencesRepository() + + withViewModel(browse, preferences) { viewModel -> + assertThat(browse.managementQueries).isEmpty() + + val allRows = viewModel.rowsFor(ManageChannelsFilter.ALL) + awaitManagementQueryCount(browse, 1) + assertThat(viewModel.rowsFor(ManageChannelsFilter.ALL)).isSameInstanceAs(allRows) + + viewModel.setFilter(ManageChannelsFilter.VISIBLE) + viewModel.rowsFor(viewModel.filter.value) + awaitManagementQueryCount(browse, 2) + + viewModel.setFilter(ManageChannelsFilter.HIDDEN) + viewModel.rowsFor(viewModel.filter.value) + awaitManagementQueryCount(browse, 3) + } + + assertThat(browse.managementQueries.map { it.visibility }) + .containsExactly( + ChannelManagementVisibility.ALL, + ChannelManagementVisibility.VISIBLE, + ChannelManagementVisibility.HIDDEN, + ).inOrder() + assertThat(browse.managementQueries.all { it.profileId == PROFILE_ID }).isTrue() + } + + @Test + fun mutationsDelegateToProfileScopedPreferencesRepository() = runBlocking { + val browse = RecordingBrowseRepository() + val preferences = RecordingPreferencesRepository() + + withViewModel(browse, preferences) { viewModel -> + viewModel.setHidden("channel-a", true) + viewModel.setCustomName("channel-a", " News HD ") + viewModel.setChannelNumber("channel-a", 42) + viewModel.resetCustomization("channel-a") + repeat(20) { + if (preferences.calls.size >= 4) return@repeat + yield() + } + } + + assertThat(preferences.calls).containsExactly( + "hidden:$PROFILE_ID:channel-a:true", + "name:$PROFILE_ID:channel-a: News HD ", + "number:$PROFILE_ID:channel-a:42", + "reset:$PROFILE_ID:channel-a", + ).inOrder() + } + + private suspend fun awaitManagementQueryCount(repository: RecordingBrowseRepository, count: Int) { + repeat(100) { + if (repository.managementQueries.size >= count) return + yield() + } + error("Expected $count management queries, got ${repository.managementQueries.size}") + } + + private suspend fun withViewModel( + browse: ChannelBrowseRepository, + preferences: ChannelPreferencesRepository, + block: suspend (ManageChannelsViewModel) -> Unit, + ) { + val store = ViewModelStore() + val factory = viewModelFactory { + initializer { + ManageChannelsViewModel( + channelBrowseRepository = browse, + channelPreferencesRepository = preferences, + profileId = PROFILE_ID, + ) + } + } + val viewModel = ViewModelProvider.create(store, factory)[ManageChannelsViewModel::class] + try { + block(viewModel) + } finally { + store.clear() + } + } + + private class RecordingBrowseRepository : ChannelBrowseRepository { + val managementQueries = mutableListOf() + + override fun pages(query: ChannelBrowseQuery): Flow> = + flowOf(PagingData.empty()) + + override fun managementPages(query: ChannelManagementQuery): Flow> { + managementQueries += query + return flowOf(PagingData.empty()) + } + } + + private class RecordingPreferencesRepository : ChannelPreferencesRepository { + val calls = mutableListOf() + + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.Unchanged + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult { + calls += "hidden:$profileId:$channelId:$isHidden" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult { + calls += "name:$profileId:$channelId:$customName" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult { + calls += "number:$profileId:$channelId:$channelNumber" + return ChannelPreferenceMutationResult.Applied + } + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult { + calls += "reset:$profileId:$channelId" + return ChannelPreferenceMutationResult.Applied + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + } +}