diff --git a/app/desktop/src/main/kotlin/AniDesktop.kt b/app/desktop/src/main/kotlin/AniDesktop.kt index 3f079ad261..6ae42dd1ef 100644 --- a/app/desktop/src/main/kotlin/AniDesktop.kt +++ b/app/desktop/src/main/kotlin/AniDesktop.kt @@ -22,17 +22,22 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.LocalSystemTheme import androidx.compose.ui.Modifier import androidx.compose.ui.SystemTheme +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application @@ -118,15 +123,23 @@ import org.koin.core.context.startKoin import org.openani.mediamp.ffmpeg.FFmpegKit import org.openani.mediamp.vlc.VlcMediampPlayer import java.awt.Frame +import java.awt.KeyEventDispatcher +import java.awt.KeyboardFocusManager +import java.awt.event.KeyEvent as AwtKeyEvent +import java.awt.event.MouseWheelListener import java.io.File import java.nio.file.Paths import java.util.Locale import kotlin.io.path.absolutePathString +import kotlin.math.roundToInt import kotlin.system.exitProcess private val logger by lazy { logger("Ani") } private inline val toplevelLogger get() = logger +private const val APP_ZOOM_MIN = 0.5f +private const val APP_ZOOM_MAX = 2.0f +private const val APP_ZOOM_STEP = 0.1f object AniDesktop { // init { @@ -440,21 +453,32 @@ object AniDesktop { val uiSettings by settingsRepository.uiSettings.flow.collectAsState(UISettings.Default) val trayState = rememberAniTrayState() val appIcon = painterResource(Res.drawable.a_round) + val requestExit: () -> Unit = remember { + { + kotlin.runCatching { exitApplication() } + .onFailure { logger.error(it) { "Failed to exit application" } } + Unit + } + } AniSystemTray( state = trayState, icon = appIcon, tooltip = "Ani", - onExit = ::exitApplication, + onExit = requestExit, ) Window( visible = !trayState.isWindowHiddenToTray, onCloseRequest = { - trayState.handleCloseRequest( - closeBehavior = uiSettings.desktopCloseBehavior, - onExit = ::exitApplication, - ) + kotlin.runCatching { + trayState.handleCloseRequest( + closeBehavior = uiSettings.desktopCloseBehavior, + onExit = requestExit, + ) + }.onFailure { + logger.error(it) { "Failed to handle close request from main window" } + } }, state = windowState, title = "Ani", @@ -516,10 +540,14 @@ object AniDesktop { WindowFrame( windowState = windowState, onCloseRequest = { - trayState.handleCloseRequest( - closeBehavior = uiSettings.desktopCloseBehavior, - onExit = ::exitApplication, - ) + kotlin.runCatching { + trayState.handleCloseRequest( + closeBehavior = uiSettings.desktopCloseBehavior, + onExit = requestExit, + ) + }.onFailure { + logger.error(it) { "Failed to handle close request from custom window frame" } + } }, ) { MainWindowContent(navigator) @@ -545,6 +573,15 @@ object AniDesktop { @Composable private fun FrameWindowScope.MainWindowContent(aniNavigator: AniNavigator) { AniApp { + var zoomScale by remember { mutableStateOf(1f) } + val baseDensity = LocalDensity.current + val windowState = LocalWindowState.current + val zoomedDensity = remember(baseDensity, zoomScale) { + Density( + density = baseDensity.density * zoomScale, + fontScale = baseDensity.fontScale * zoomScale, + ) + } val themeSettings = LocalThemeSettings.current val titleBarThemeController = LocalTitleBarThemeController.current val systemTheme = LocalSystemTheme.current @@ -562,6 +599,38 @@ private fun FrameWindowScope.MainWindowContent(aniNavigator: AniNavigator) { onDispose {} } + DisposableEffect(window) { + val mouseWheelListener = MouseWheelListener { event -> + if (!event.isControlDown) return@MouseWheelListener + zoomScale = zoomScale.adjustAppZoom( + direction = if (event.wheelRotation < 0) 1 else -1, + isFullscreen = windowState.placement == WindowPlacement.Fullscreen, + ) + event.consume() + } + val keyEventDispatcher = KeyEventDispatcher { event -> + if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) { + return@KeyEventDispatcher false + } + val direction = when (event.keyCode) { + AwtKeyEvent.VK_EQUALS, AwtKeyEvent.VK_ADD -> 1 + AwtKeyEvent.VK_MINUS, AwtKeyEvent.VK_SUBTRACT -> -1 + else -> return@KeyEventDispatcher false + } + zoomScale = zoomScale.adjustAppZoom( + direction = direction, + isFullscreen = windowState.placement == WindowPlacement.Fullscreen, + ) + true + } + window.addMouseWheelListener(mouseWheelListener) + KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(keyEventDispatcher) + onDispose { + window.removeMouseWheelListener(mouseWheelListener) + KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(keyEventDispatcher) + } + } + OverrideCaptionButtonAppearance(isDark = isTitleBarDark) Box( @@ -580,6 +649,7 @@ private fun FrameWindowScope.MainWindowContent(aniNavigator: AniNavigator) { val content by vm.content.collectAsStateWithLifecycle() CompositionLocalProvider( + LocalDensity provides zoomedDensity, LocalNavigator provides aniNavigator, LocalToaster provides remember(vm) { object : Toaster { @@ -600,6 +670,13 @@ private fun FrameWindowScope.MainWindowContent(aniNavigator: AniNavigator) { } } +private fun Float.adjustAppZoom(direction: Int, isFullscreen: Boolean): Float { + val minScale = if (isFullscreen) APP_ZOOM_MIN else 1f + val scaled = (this + direction * APP_ZOOM_STEP) + .coerceIn(minScale, APP_ZOOM_MAX) + return (scaled * 10).roundToInt() / 10f +} + @Composable private fun WindowStateRecorder( windowState: WindowState, diff --git a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt index da7b10eade..8579af5930 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/MediaSelectionSeetings.kt @@ -54,6 +54,11 @@ constructor( * @since 4.1 */ val fastSelectWebKind: Boolean = true, + /** + * Hide currently failed web sources in the simple source selector. + * @since 4.2 + */ + val hideDeadWebSources: Boolean = false, /** * 给 low tier 源加载的宽容时间, 在这个时间内只接受 low tier 加载完成, * 就算 high tier 比 low tier 率先加载完成也不选择. @@ -81,4 +86,4 @@ constructor( hideSingleEpisodeForCompleted = false, ) } -} \ No newline at end of file +} diff --git a/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt b/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt index c9c90858da..406053cf56 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import me.him188.ani.app.data.models.schedule.AnimeSeasonId -import me.him188.ani.app.data.models.schedule.yearMonths import me.him188.ani.app.data.network.AniSubjectSearchService import me.him188.ani.app.data.network.BangumiSearchFilters import me.him188.ani.app.data.network.BangumiSubjectSearchService @@ -131,17 +130,23 @@ class SubjectSearchRepository( private fun SubjectSearchQuery.toBangumiSearchFilters(): BangumiSearchFilters { return BangumiSearchFilters( tags, - airDates = season?.toBangumiAirDates(), + airDates = season?.toBangumiAirDates() ?: year?.toBangumiAirDates(), ratings = rating?.toBangumiRatings(), nsfw = nsfw, ) } private fun AnimeSeasonId.toBangumiAirDates(): List { - val (begin, _, end) = this.yearMonths return listOf( - ">=${begin.first}-${begin.second}-01", - "<${end.first}-${end.second}-31", + ">=${year}-${season.searchStartMonth()}-01", + "<${season.nextSeasonStartYear(year)}-${season.nextSeasonStartMonth()}-01", + ) + } + + private fun Int.toBangumiAirDates(): List { + return listOf( + ">=$this-01-01", + "<${this + 1}-01-01", ) } @@ -153,6 +158,31 @@ class SubjectSearchRepository( ) } + private fun me.him188.ani.app.data.models.schedule.AnimeSeason.searchStartMonth(): String { + return when (this) { + me.him188.ani.app.data.models.schedule.AnimeSeason.WINTER -> "01" + me.him188.ani.app.data.models.schedule.AnimeSeason.SPRING -> "04" + me.him188.ani.app.data.models.schedule.AnimeSeason.SUMMER -> "07" + me.him188.ani.app.data.models.schedule.AnimeSeason.AUTUMN -> "10" + } + } + + private fun me.him188.ani.app.data.models.schedule.AnimeSeason.nextSeasonStartMonth(): String { + return when (this) { + me.him188.ani.app.data.models.schedule.AnimeSeason.WINTER -> "04" + me.him188.ani.app.data.models.schedule.AnimeSeason.SPRING -> "07" + me.him188.ani.app.data.models.schedule.AnimeSeason.SUMMER -> "10" + me.him188.ani.app.data.models.schedule.AnimeSeason.AUTUMN -> "01" + } + } + + private fun me.him188.ani.app.data.models.schedule.AnimeSeason.nextSeasonStartYear(year: Int): Int { + return when (this) { + me.him188.ani.app.data.models.schedule.AnimeSeason.AUTUMN -> year + 1 + else -> year + } + } + /** * 将数据过滤从View提升到分页层,不然会导致 #2380 */ diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt b/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt index ba1ee0c74a..44fd458da6 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt @@ -16,6 +16,7 @@ data class SubjectSearchQuery( val type: SubjectType = SubjectType.ANIME, // val useOldSearchApi: Boolean = true, val tags: List? = null, + val year: Int? = null, val season: AnimeSeasonId? = null, val rating: RatingRange? = null, // val rank: Pair = Pair(null, null), @@ -27,7 +28,7 @@ data class SubjectSearchQuery( } fun hasFilters(): Boolean { - return tags != null || season != null || rating != null || nsfw != null || sort != SearchSort.MATCH + return tags != null || year != null || season != null || rating != null || nsfw != null || sort != SearchSort.MATCH } fun hasSearchRequest(): Boolean { diff --git a/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt b/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt index cad5726184..d4df1f800d 100644 --- a/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt +++ b/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt @@ -250,6 +250,8 @@ class SearchViewModel( put("has_query", updatedQuery.keywords.isNotEmpty()) put("tags", updatedQuery.tags.orEmpty().joinToString(",")) put("tag_count", updatedQuery.tags.orEmpty().size) + put("year", updatedQuery.year) + put("season", updatedQuery.season?.id) } } refreshSearch(updatedQuery) @@ -318,6 +320,7 @@ class SearchViewModel( private fun SubjectSearchQuery.shouldTriggerSearch(): Boolean { return keywords.isNotEmpty() || !tags.isNullOrEmpty() || + year != null || season != null || rating != null || nsfw != null diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt index b51dfd6be4..746d386b58 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt @@ -121,8 +121,8 @@ import me.him188.ani.app.videoplayer.ui.progress.AudioSwitcher import me.him188.ani.app.videoplayer.ui.progress.MediaProgressIndicatorText import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerBar import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerDefaults -import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerDefaults.SpeedSwitcher import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerDefaults.VideoAspectRatioSelector +import me.him188.ani.app.videoplayer.ui.progress.PlaybackSpeedSwitcher import me.him188.ani.app.videoplayer.ui.progress.PlayerProgressSliderState import me.him188.ani.app.videoplayer.ui.progress.SubtitleSwitcher import me.him188.ani.app.videoplayer.ui.progress.rememberMediaProgressSliderState @@ -446,7 +446,7 @@ internal fun EpisodeVideoImpl( val playbackSpeedAlwaysOnRequester = rememberAlwaysOnRequester(playerControllerState, "speedSwitcher") playbackSpeedControllerState?.also { controller -> - SpeedSwitcher(controller) { + PlaybackSpeedSwitcher(controller) { if (it) { playbackSpeedAlwaysOnRequester.request() } else { diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt index e91e5db416..0c4c20da88 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeViewModel.kt @@ -805,6 +805,7 @@ class EpisodeViewModel( getPreferredWebMediaSource(subjectId), backgroundScope, webCaptchaCoordinator, + settingsRepository.mediaSelectorSettings, ) } else { // TODO: 2025/1/22 We should not use createTestMediaSelectorState diff --git a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt index 905fa31912..68b6e45d0a 100644 --- a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt +++ b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt @@ -13,14 +13,18 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.ChevronLeft +import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme @@ -36,9 +40,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import me.him188.ani.app.data.models.schedule.AnimeSeason import me.him188.ani.app.data.models.subject.CanonicalTagKind import me.him188.ani.app.ui.foundation.ProvideCompositionLocalsForPreview import me.him188.ani.app.ui.lang.Lang @@ -91,11 +100,21 @@ data class SearchFilterChipState( get() = selected.isNotEmpty() } +@Immutable +data class SearchTimeFilterState( + val selectedYear: Int?, + val selectedSeason: AnimeSeason?, + val availableYears: List = defaultSearchYearOptions(), +) + @Composable fun SearchFilterChipsRow( state: SearchFilterState, onClickItemText: (SearchFilterChipState, value: String) -> Unit, onCheckedChange: (SearchFilterChipState, value: String) -> Unit, + timeFilterState: SearchTimeFilterState? = null, + onYearSelected: ((Int?) -> Unit)? = null, + onSeasonSelected: ((AnimeSeason?) -> Unit)? = null, modifier: Modifier = Modifier, ) { FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -105,6 +124,20 @@ fun SearchFilterChipsRow( { onClickItemText(chipState, it) }, { onCheckedChange(chipState, it) }, ) + if (chipState.kind == CanonicalTagKind.Region && timeFilterState != null) { + SearchYearFilterChip( + state = timeFilterState, + onYearSelected = { onYearSelected?.invoke(it) }, + ) + SearchSingleSelectChip( + label = timeFilterState.selectedSeason?.displayName() ?: "\u5B63\u5EA6", + options = listOf(null) + AnimeSeason.entries, + enabled = timeFilterState.selectedYear != null, + isSelected = { it == timeFilterState.selectedSeason }, + renderOption = { it?.displayName() ?: "\u5168\u90E8\u5B63\u5EA6" }, + onSelect = { onSeasonSelected?.invoke(it) }, + ) + } } } } @@ -174,6 +207,146 @@ fun SearchFilterChip( } } +@Composable +private fun SearchYearFilterChip( + state: SearchTimeFilterState, + onYearSelected: (Int?) -> Unit, + modifier: Modifier = Modifier, +) { + val maxYear = state.availableYears.firstOrNull() + val minYear = state.availableYears.lastOrNull() + Row(modifier) { + InputChip( + selected = state.selectedYear != null, + onClick = { + onYearSelected( + if (state.selectedYear == null) { + defaultSearchYearSelection(state.availableYears) + } else { + null + }, + ) + }, + label = { + Text( + state.selectedYear?.let { "\u5E74\u4EFD\uff1a$it" } ?: "\u5E74\u4EFD\uff1a\u5168\u90E8", + Modifier.widthIn(min = 88.dp, max = 112.dp), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + softWrap = false, + maxLines = 1, + ) + }, + leadingIcon = { + IconButton( + enabled = state.selectedYear != minYear, + onClick = { + val current = state.selectedYear ?: defaultSearchYearSelection(state.availableYears) + if (current != null) { + onYearSelected((current - 1).coerceAtLeast(minYear ?: current)) + } + }, + ) { + Icon( + Icons.Rounded.ChevronLeft, + contentDescription = "\u51CF\u5C11\u5E74\u4EFD", + modifier = Modifier.size(InputChipDefaults.IconSize), + ) + } + }, + trailingIcon = { + IconButton( + enabled = state.selectedYear != maxYear, + onClick = { + val current = state.selectedYear ?: defaultSearchYearSelection(state.availableYears) + if (current != null) { + onYearSelected((current + 1).coerceAtMost(maxYear ?: current)) + } + }, + ) { + Icon( + Icons.Rounded.ChevronRight, + contentDescription = "\u589E\u52A0\u5E74\u4EFD", + modifier = Modifier.size(InputChipDefaults.IconSize), + ) + } + }, + ) + } +} + +@Composable +private fun SearchSingleSelectChip( + label: String, + options: List, + isSelected: (T) -> Boolean, + renderOption: (T) -> String, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + var showDropdown by rememberSaveable { mutableStateOf(false) } + Box(modifier) { + InputChip( + selected = enabled && options.any(isSelected), + onClick = { if (enabled) showDropdown = true }, + label = { + Text( + label, + Modifier.widthIn(max = 128.dp), + overflow = TextOverflow.Ellipsis, + softWrap = false, + maxLines = 1, + ) + }, + enabled = enabled, + trailingIcon = { + Icon( + Icons.Rounded.ArrowDropDown, + null, + Modifier.size(InputChipDefaults.IconSize), + ) + }, + ) + + DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) { + for (option in options) { + DropdownMenuItem( + text = { Text(renderOption(option)) }, + onClick = { + onSelect(option) + showDropdown = false + }, + leadingIcon = { + Checkbox( + checked = isSelected(option), + onCheckedChange = null, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 12.dp), + ) + } + } + } +} + +private fun AnimeSeason.displayName(): String = when (this) { + AnimeSeason.WINTER -> "\u4E00\u6708\u756A" + AnimeSeason.SPRING -> "\u56DB\u6708\u756A" + AnimeSeason.SUMMER -> "\u4E03\u6708\u756A" + AnimeSeason.AUTUMN -> "\u5341\u6708\u756A" +} + +private fun defaultSearchYearOptions(): List { + val currentYear = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year + return (currentYear + 1 downTo currentYear - 40).toList() +} + +private fun defaultSearchYearSelection(availableYears: List): Int? { + val currentYear = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year + return availableYears.firstOrNull { it == currentYear } ?: availableYears.firstOrNull() +} + private fun renderChipLabel( state: SearchFilterChipState, labels: SearchFilterLabels, diff --git a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt index 4df8fd1545..0deede1cde 100644 --- a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt +++ b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt @@ -79,6 +79,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import me.him188.ani.app.data.models.schedule.AnimeSeasonId import me.him188.ani.app.data.repository.RepositoryNetworkException import me.him188.ani.app.domain.search.SearchSort import me.him188.ani.app.ui.adaptive.AdaptiveSearchBar @@ -246,6 +247,39 @@ fun SearchPage( ), ) }, + timeFilterState = SearchTimeFilterState( + selectedYear = state.query.year, + selectedSeason = state.query.season?.season, + ), + onYearSelected = { year -> + val currentSeason = state.query.season?.season + onIntent( + SearchPageIntent.UpdateQuery( + state.query.copy( + year = year, + season = if (year != null && currentSeason != null) { + AnimeSeasonId(year, currentSeason) + } else { + null + }, + ), + ), + ) + }, + onSeasonSelected = { season -> + val year = state.query.year + onIntent( + SearchPageIntent.UpdateQuery( + state.query.copy( + season = if (year != null && season != null) { + AnimeSeasonId(year, season) + } else { + null + }, + ), + ), + ) + }, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPageResultColumn.kt b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPageResultColumn.kt index d641262c17..f38104167e 100644 --- a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPageResultColumn.kt +++ b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPageResultColumn.kt @@ -132,6 +132,7 @@ internal fun SearchResultColumn( .onSizeChanged { height = it.height } .keyboardDirectionToSelectItem( selectedItemIndex, + itemCount = { items.itemCount }, ) { state.animateScrollToItem(it) onSelect(it) diff --git a/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/interaction/ScrollHotKeys.kt b/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/interaction/ScrollHotKeys.kt index f484a233bf..65c9ec132c 100644 --- a/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/interaction/ScrollHotKeys.kt +++ b/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/interaction/ScrollHotKeys.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.launch fun Modifier.keyboardDirectionToSelectItem( selectedItemIndex: () -> Int, + itemCount: () -> Int, onSelect: suspend (Int) -> Unit, ) = composed { val scope = rememberCoroutineScope() @@ -28,16 +29,32 @@ fun Modifier.keyboardDirectionToSelectItem( if (it.type == KeyEventType.KeyUp) { when (it.key) { Key.DirectionUp -> { + val count = itemCount() + if (count <= 0) { + return@onPreviewKeyEvent false + } + val currentIndex = selectedItemIndex().coerceIn(0, count - 1) + val newIndex = (currentIndex - 1).coerceAtLeast(0) + if (newIndex == currentIndex) { + return@onPreviewKeyEvent true + } scope.launch { - val newIndex = (selectedItemIndex() - 1).coerceAtLeast(0) onSelect(newIndex) } true } Key.DirectionDown -> { + val count = itemCount() + if (count <= 0) { + return@onPreviewKeyEvent false + } + val currentIndex = selectedItemIndex().coerceIn(0, count - 1) + val newIndex = (currentIndex + 1).coerceAtMost(count - 1) + if (newIndex == currentIndex) { + return@onPreviewKeyEvent true + } scope.launch { - val newIndex = selectedItemIndex() + 1 onSelect(newIndex) } true diff --git a/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorState.kt b/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorState.kt index 3273f2e20b..9f3472379e 100644 --- a/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorState.kt +++ b/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorState.kt @@ -32,6 +32,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import me.him188.ani.app.data.models.preference.MediaPreference import me.him188.ani.app.data.models.preference.MediaSelectorSettings +import me.him188.ani.app.data.repository.user.Settings +import me.him188.ani.app.data.repository.user.SettingsRepository import me.him188.ani.app.domain.media.TestMediaList import me.him188.ani.app.domain.media.fetch.MediaSourceFetchResult import me.him188.ani.app.domain.media.fetch.MediaSourceFetchState @@ -65,6 +67,7 @@ fun rememberMediaSelectorState( ): MediaSelectorState { val scope = rememberBackgroundScope() val webCaptchaCoordinator = remember { GlobalKoin.get() } + val settingsRepository = remember { GlobalKoin.get() } val selector by remember { derivedStateOf(mediaSelector) } @@ -76,6 +79,7 @@ fun rememberMediaSelectorState( flowOf(null), scope.backgroundScope, webCaptchaCoordinator, + settingsRepository.mediaSelectorSettings, ) } } @@ -146,6 +150,10 @@ class MediaSelectorState( private val preferredWebMediaSource: Flow, private val backgroundScope: CoroutineScope, private val webCaptchaCoordinator: WebCaptchaCoordinator, + private val mediaSelectorSettings: Settings = object : Settings { + override val flow: Flow = flowOf(MediaSelectorSettings.Default) + override suspend fun set(value: MediaSelectorSettings) = Unit + }, ) { @Immutable data class Presentation( @@ -162,6 +170,8 @@ class MediaSelectorState( val webSources: List, val selectedWebSource: WebSource?, val selectedWebSourceChannel: WebSourceChannel?, + val hideDeadWebSourcesEnabled: Boolean, + val hiddenDeadWebSourceCount: Int, val isPlaceholder: Boolean = false, ) @@ -192,8 +202,21 @@ class MediaSelectorState( subtitleLanguageId.presentationFlow, mediaSource.presentationFlow, createWebSourcesFlow(), - ) { filteredCandidatesMedia, preferredCandidates, selected, alliance, resolution, subtitleLanguageId, mediaSource, webSources -> + mediaSelectorSettings.flow.map { it.hideDeadWebSources }, + ) { filteredCandidatesMedia, preferredCandidates, selected, alliance, resolution, subtitleLanguageId, mediaSource, webSources, hideDeadWebSourcesEnabled -> val (groupsExcluded, groupsIncluded) = MediaGrouper.buildGroups(preferredCandidates).partition { it.isExcluded } + val selectedWebSource = webSources.find { source -> source.channels.any { it.original == selected } } + val selectedWebSourceChannel = selectedWebSource?.channels?.find { it.original == selected } + val visibleWebSources = if (hideDeadWebSourcesEnabled) { + webSources.filterNot { it.shouldBeHiddenAsDeadSource(selected) } + } else { + webSources + } + val hiddenDeadWebSourceCount = if (hideDeadWebSourcesEnabled) { + webSources.count { it.shouldBeHiddenAsDeadSource(selected) } + } else { + 0 + } Presentation( filteredCandidatesMedia, preferredCandidates.mapNotNull { it.result }, @@ -201,9 +224,11 @@ class MediaSelectorState( groupsExcluded, selected, alliance, resolution, subtitleLanguageId, mediaSource, - webSources, - selectedWebSource = webSources.find { source -> source.channels.any { it.original == selected } }, - selectedWebSourceChannel = webSources.firstNotNullOfOrNull { source -> source.channels.find { it.original == selected } }, + visibleWebSources, + selectedWebSource = selectedWebSource, + selectedWebSourceChannel = selectedWebSourceChannel, + hideDeadWebSourcesEnabled = hideDeadWebSourcesEnabled, + hiddenDeadWebSourceCount = hiddenDeadWebSourceCount, ) }.stateIn( backgroundScope, @@ -217,6 +242,8 @@ class MediaSelectorState( webSources = emptyList(), selectedWebSource = null, selectedWebSourceChannel = null, + hideDeadWebSourcesEnabled = false, + hiddenDeadWebSourceCount = 0, isPlaceholder = true, ), ) @@ -363,6 +390,18 @@ class MediaSelectorState( resolvingCaptchaInstanceIds.value = resolvingCaptchaInstanceIds.value - source.instanceId } } + + fun setHideDeadWebSourcesEnabled(enabled: Boolean) { + backgroundScope.launch { + mediaSelectorSettings.update { + copy(hideDeadWebSources = enabled) + } + } + } + + private fun WebSource.shouldBeHiddenAsDeadSource(selected: Media?): Boolean { + return isError && channels.none { it.original == selected } + } } @Stable @@ -398,4 +437,8 @@ fun createTestMediaSelectorState(backgroundScope: CoroutineScope) = preferredWebMediaSource = flowOf(null), backgroundScope, NoopWebCaptchaCoordinator, + object : Settings { + override val flow: Flow = flowOf(MediaSelectorSettings.Default) + override suspend fun set(value: MediaSelectorSettings) = Unit + }, ) diff --git a/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorView.kt b/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorView.kt index a55d4634a0..3383e7a5ab 100644 --- a/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorView.kt +++ b/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediafetch/MediaSelectorView.kt @@ -175,7 +175,13 @@ fun MediaSelectorView( } } }, + onRefreshAll = onRefresh, + hideDeadSourcesEnabled = presentation.hideDeadWebSourcesEnabled, + onHideDeadSourcesEnabledChange = { + state.setHideDeadWebSourcesEnabled(it) + }, onRequestQueryEdit = { showEditRequest = true }, + hiddenDeadSourceCount = presentation.hiddenDeadWebSourceCount, Modifier.padding(bottom = bottomPadding) .weight(1f, fill = false) .fillMaxWidth() diff --git a/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediaselect/selector/MediaSelectorWebColumn.kt b/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediaselect/selector/MediaSelectorWebColumn.kt index 94da5f4392..182b8cd09a 100644 --- a/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediaselect/selector/MediaSelectorWebColumn.kt +++ b/app/shared/ui-mediaselect/src/commonMain/kotlin/ui/mediaselect/selector/MediaSelectorWebColumn.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.minimumInteractiveComponentSize @@ -45,6 +46,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.material3.IconButton as MaterialIconButton import me.him188.ani.app.domain.mediasource.instance.MediaSourceInstance import me.him188.ani.app.domain.mediasource.web.WebCaptchaRequest import me.him188.ani.app.ui.foundation.IconButton @@ -97,7 +99,11 @@ fun MediaSelectorWebSourcesColumn( onSelect: (WebSource, WebSourceChannel) -> Unit, onRefresh: (WebSource) -> Unit, onResolveCaptcha: (WebSource) -> Unit, + onRefreshAll: () -> Unit, + hideDeadSourcesEnabled: Boolean, + onHideDeadSourcesEnabledChange: (Boolean) -> Unit, onRequestQueryEdit: () -> Unit, + hiddenDeadSourceCount: Int, modifier: Modifier = Modifier, preferredSourceContainerColor: Color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.33f) ) { @@ -130,6 +136,33 @@ fun MediaSelectorWebSourcesColumn( // } Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { // not scrollable. 否则会跟 bottom sheet 的 scroll 冲突. + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u9690\u85CF\u65E0\u4FE1\u53F7\u6E90", + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = hideDeadSourcesEnabled, + onCheckedChange = onHideDeadSourcesEnabledChange, + ) + MaterialIconButton(onClick = onRefreshAll) { + Icon( + Icons.Rounded.Refresh, + contentDescription = "\u91CD\u65B0\u6D4B\u8BD5\u6E90", + ) + } + if (hideDeadSourcesEnabled && hiddenDeadSourceCount > 0) { + Text( + "\u5DF2\u9690\u85CF $hiddenDeadSourceCount \u4E2A\u5931\u6548\u6E90", + color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.bodySmall, + ) + } + } list.forEach { source -> card(source) } @@ -271,7 +304,11 @@ private fun PreviewMediaSelectorWebColumn() { onSelect = { _, _ -> }, onRefresh = {}, onResolveCaptcha = {}, + onRefreshAll = {}, + hideDeadSourcesEnabled = false, + onHideDeadSourcesEnabledChange = {}, onRequestQueryEdit = {}, + hiddenDeadSourceCount = 0, ) } } @@ -290,7 +327,11 @@ private fun PreviewMediaSelectorWebColumn3() { onSelect = { _, _ -> }, onRefresh = {}, onResolveCaptcha = {}, + onRefreshAll = {}, + hideDeadSourcesEnabled = true, + onHideDeadSourcesEnabledChange = {}, onRequestQueryEdit = {}, + hiddenDeadSourceCount = 1, ) } } diff --git a/app/shared/video-player/src/commonMain/kotlin/ui/PlaybackSpeedControllerState.kt b/app/shared/video-player/src/commonMain/kotlin/ui/PlaybackSpeedControllerState.kt index 8155ee1b41..4a765c97fe 100644 --- a/app/shared/video-player/src/commonMain/kotlin/ui/PlaybackSpeedControllerState.kt +++ b/app/shared/video-player/src/commonMain/kotlin/ui/PlaybackSpeedControllerState.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.openani.mediamp.InternalForInheritanceMediampApi import org.openani.mediamp.features.PlaybackSpeed +import kotlin.math.roundToInt /** * Side-effect: creation of this state will immediately set the playback speed to the initial speed. @@ -31,28 +32,26 @@ import org.openani.mediamp.features.PlaybackSpeed @Stable class PlaybackSpeedControllerState( private val playbackSpeed: PlaybackSpeed, - speedProvider: () -> List = { listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 3f) }, + speedProvider: () -> List = { listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f) }, scope: CoroutineScope ) { + companion object { + const val MIN_CUSTOM_SPEED = 0.1f + const val MAX_CUSTOM_SPEED = 5.0f + } + val speedList: List by derivedStateOf(speedProvider) var currentSpeed by mutableStateOf(playbackSpeed.value) private set + var isCustomSpeedDialogVisible by mutableStateOf(false) + private set + var customSpeedInput by mutableStateOf(playbackSpeed.value.formatPlaybackSpeed()) + private set /** * `-1` represents a invalid index, which means the current speed is not in the list. */ - val currentIndex: Int by derivedStateOf { - val index = speedList.indexOf(currentSpeed) - if (index == -1) { - speedList.indexOf(1f).also { - check(it != -1) { - "Playback speed list must contain 1.0f, but was $speedList" - } - } - } else { - index - } - } + val currentIndex: Int by derivedStateOf { speedList.indexOf(currentSpeed) } init { require(speedList.isNotEmpty()) { "Playback speed list must not be empty" } @@ -72,7 +71,12 @@ class PlaybackSpeedControllerState( scope.launch { playbackSpeed.valueFlow .distinctUntilChanged() - .collect { value -> currentSpeed = value } + .collect { value -> + currentSpeed = value + if (!isCustomSpeedDialogVisible) { + customSpeedInput = value.formatPlaybackSpeed() + } + } } } @@ -110,11 +114,39 @@ class PlaybackSpeedControllerState( playbackSpeed.set(value) } + fun setCustomSpeed(value: Float) { + val normalized = ((value * 10).roundToInt() / 10f) + .coerceIn(MIN_CUSTOM_SPEED, MAX_CUSTOM_SPEED) + setSpeed(normalized) + } + + fun openCustomSpeedDialog() { + customSpeedInput = currentSpeed.formatPlaybackSpeed() + isCustomSpeedDialogVisible = true + } + + fun closeCustomSpeedDialog() { + isCustomSpeedDialogVisible = false + customSpeedInput = currentSpeed.formatPlaybackSpeed() + } + + fun updateCustomSpeedInput(value: String) { + customSpeedInput = value + } + fun reset() { setSpeed(1f) } } +private fun Float.formatPlaybackSpeed(): String { + return if (this % 1f == 0f) { + roundToInt().toString() + } else { + toString().trimEnd('0').trimEnd('.') + } +} + @OptIn(InternalForInheritanceMediampApi::class) object NoOpPlaybackSpeedController : PlaybackSpeed { override val value: Float = 1f @@ -123,4 +155,4 @@ object NoOpPlaybackSpeedController : PlaybackSpeed { override fun set(speed: Float) { } -} \ No newline at end of file +} diff --git a/app/shared/video-player/src/commonMain/kotlin/ui/progress/PlaybackSpeedSwitcher.kt b/app/shared/video-player/src/commonMain/kotlin/ui/progress/PlaybackSpeedSwitcher.kt new file mode 100644 index 0000000000..85e3a06c89 --- /dev/null +++ b/app/shared/video-player/src/commonMain/kotlin/ui/progress/PlaybackSpeedSwitcher.kt @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2024-2026 OpenAni and contributors. + * + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.videoplayer.ui.progress + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.dialogs.PlatformPopupProperties +import me.him188.ani.app.videoplayer.ui.PlaybackSpeedControllerState +import kotlin.math.roundToInt + +@Composable +fun PlaybackSpeedSwitcher( + playbackSpeedControllerState: PlaybackSpeedControllerState, + modifier: Modifier = Modifier, + onExpandedChanged: (expanded: Boolean) -> Unit = {}, +) { + Box(modifier, contentAlignment = Alignment.Center) { + var expanded by rememberSaveable { mutableStateOf(false) } + var openCustomDialogAfterDismiss by rememberSaveable { mutableStateOf(false) } + val currentSpeed = playbackSpeedControllerState.currentSpeed + val customSpeedInput = playbackSpeedControllerState.customSpeedInput + val showCustomDialog = playbackSpeedControllerState.isCustomSpeedDialogVisible + val isValidCustomSpeed = remember(customSpeedInput) { + customSpeedInput.parseCustomPlaybackSpeed() != null + } + + LaunchedEffect(expanded, showCustomDialog) { + onExpandedChanged(expanded || showCustomDialog) + } + + LaunchedEffect(expanded, openCustomDialogAfterDismiss, currentSpeed) { + if (!expanded && openCustomDialogAfterDismiss) { + playbackSpeedControllerState.openCustomSpeedDialog() + openCustomDialogAfterDismiss = false + } + } + + TextButton( + onClick = { expanded = true }, + colors = ButtonDefaults.textButtonColors( + contentColor = LocalContentColor.current, + ), + modifier = Modifier.testTag(TAG_SPEED_SWITCHER_TEXT_BUTTON), + ) { + Text("${currentSpeed.formatPlaybackSpeed()}x") + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + properties = PlatformPopupProperties( + clippingEnabled = false, + ), + modifier = Modifier.testTag(TAG_SPEED_SWITCHER_DROPDOWN_MENU), + ) { + playbackSpeedControllerState.speedList.forEach { speedValue -> + DropdownMenuItem( + text = { + val color = if (currentSpeed == speedValue) { + MaterialTheme.colorScheme.primary + } else { + LocalContentColor.current + } + CompositionLocalProvider(LocalContentColor provides color) { + Text("${speedValue.formatPlaybackSpeed()}x") + } + }, + onClick = { + expanded = false + playbackSpeedControllerState.setSpeed(speedValue) + }, + ) + } + DropdownMenuItem( + text = { Text("Custom") }, + onClick = { + openCustomDialogAfterDismiss = true + expanded = false + }, + ) + } + + if (showCustomDialog) { + AlertDialog( + onDismissRequest = { playbackSpeedControllerState.closeCustomSpeedDialog() }, + title = { Text("Custom playback speed") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = customSpeedInput, + onValueChange = playbackSpeedControllerState::updateCustomSpeedInput, + singleLine = true, + label = { Text("Speed") }, + suffix = { Text("x") }, + isError = customSpeedInput.isNotBlank() && !isValidCustomSpeed, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + ) + Text( + "Enter a value from 0.1 to 5.0 with at most one decimal place.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + }, + confirmButton = { + TextButton( + enabled = isValidCustomSpeed, + onClick = { + customSpeedInput.parseCustomPlaybackSpeed()?.let { + playbackSpeedControllerState.setCustomSpeed(it) + } + playbackSpeedControllerState.closeCustomSpeedDialog() + }, + ) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { playbackSpeedControllerState.closeCustomSpeedDialog() }) { + Text("Cancel") + } + }, + ) + } + } +} + +private fun Float.formatPlaybackSpeed(): String { + return if (this % 1f == 0f) { + roundToInt().toString() + } else { + toString().trimEnd('0').trimEnd('.') + } +} + +private fun String.parseCustomPlaybackSpeed(): Float? { + val trimmed = trim() + if (!trimmed.matches(Regex("""\d+(\.\d)?"""))) return null + val value = trimmed.toFloatOrNull() ?: return null + return value.takeIf { + it in PlaybackSpeedControllerState.MIN_CUSTOM_SPEED..PlaybackSpeedControllerState.MAX_CUSTOM_SPEED + } +} diff --git a/app/shared/video-player/src/commonTest/kotlin/ui/PlaybackSpeedControllerStateTest.kt b/app/shared/video-player/src/commonTest/kotlin/ui/PlaybackSpeedControllerStateTest.kt index f117cfdd6b..c393148f9d 100644 --- a/app/shared/video-player/src/commonTest/kotlin/ui/PlaybackSpeedControllerStateTest.kt +++ b/app/shared/video-player/src/commonTest/kotlin/ui/PlaybackSpeedControllerStateTest.kt @@ -154,13 +154,13 @@ class PlaybackSpeedControllerStateTest { takeSnapshot() // 1.3f is not in list assertEquals(1.3f, state.currentSpeed) - assertEquals(1, state.currentIndex) + assertEquals(-1, state.currentIndex) state.speedUp() takeSnapshot() // nearestUp is 1.5f - assertEquals(1.25f, state.currentSpeed) - assertEquals(2, state.currentIndex) + assertEquals(1.5f, state.currentSpeed) + assertEquals(3, state.currentIndex) } @Test