diff --git a/app/shared/app-platform/src/commonMain/kotlin/navigation/AniNavigator.kt b/app/shared/app-platform/src/commonMain/kotlin/navigation/AniNavigator.kt index 6ad88ba00e..aef9a539e7 100644 --- a/app/shared/app-platform/src/commonMain/kotlin/navigation/AniNavigator.kt +++ b/app/shared/app-platform/src/commonMain/kotlin/navigation/AniNavigator.kt @@ -12,6 +12,8 @@ package me.him188.ani.app.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisallowComposableCalls +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -26,7 +28,7 @@ import me.him188.ani.datasources.api.source.FactoryId /** * Supports navigation to any page in the app. * - * 应当总是使用 [AniNavigator], 而不要访问 [navigator]. + * 应当总是使用 [AniNavigator], 而不要访问 [currentNavigator]. * * @see LocalNavigator */ @@ -39,14 +41,18 @@ interface AniNavigator { suspend fun awaitNavController(): NavHostController - val navigator: NavHostController + // Not @Stable + val currentNavigator: NavHostController + + @Composable + fun collectNavigatorAsState(): State fun popBackStack() { - navigator.popBackStack() + currentNavigator.popBackStack() } fun popBackStack(route: NavRoutes, inclusive: Boolean, saveState: Boolean = false) { - navigator.popBackStack(route, inclusive, saveState) + currentNavigator.popBackStack(route, inclusive, saveState) } // fun popBackStack( @@ -57,54 +63,54 @@ interface AniNavigator { // } fun popUntilNotWelcome() { - navigator.popBackStack(NavRoutes.Welcome, inclusive = true) + currentNavigator.popBackStack(NavRoutes.Welcome, inclusive = true) } fun popUntilNotAuth() { - navigator.popBackStack(NavRoutes.BangumiTokenAuth, inclusive = true) - navigator.popBackStack(NavRoutes.BangumiOAuth, inclusive = true) + currentNavigator.popBackStack(NavRoutes.BangumiTokenAuth, inclusive = true) + currentNavigator.popBackStack(NavRoutes.BangumiOAuth, inclusive = true) } fun navigateSubjectDetails( subjectId: Int, placeholder: SubjectDetailPlaceholder?, ) { - navigator.navigate( + currentNavigator.navigate( NavRoutes.SubjectDetail(subjectId, placeholder), ) } fun navigateSubjectCaches(subjectId: Int) { - navigator.navigate(NavRoutes.SubjectCaches(subjectId)) + currentNavigator.navigate(NavRoutes.SubjectCaches(subjectId)) } fun navigateEpisodeDetails(subjectId: Int, episodeId: Int, fullscreen: Boolean = false) { - navigator.popBackStack(NavRoutes.EpisodeDetail(subjectId, episodeId), inclusive = true) - navigator.navigate(NavRoutes.EpisodeDetail(subjectId, episodeId)) + currentNavigator.popBackStack(NavRoutes.EpisodeDetail(subjectId, episodeId), inclusive = true) + currentNavigator.navigate(NavRoutes.EpisodeDetail(subjectId, episodeId)) } fun navigateWelcome() { - navigator.navigate(NavRoutes.Welcome) + currentNavigator.navigate(NavRoutes.Welcome) } fun navigateMain( page: MainScreenPage, requestFocus: Boolean = false ) { - navigator.popBackStack(inclusive = false) + currentNavigator.popBackStack(inclusive = false) } /** * 登录页面 */ fun navigateBangumiOAuthOrTokenAuth() { - navigator.navigate(NavRoutes.BangumiOAuth) { + currentNavigator.navigate(NavRoutes.BangumiOAuth) { launchSingleTop = true } } fun navigateBangumiTokenAuth() { - navigator.navigate( + currentNavigator.navigate( NavRoutes.BangumiTokenAuth, ) { launchSingleTop = true @@ -115,32 +121,32 @@ interface AniNavigator { } fun navigateSettings(tab: SettingsTab? = null) { - navigator.navigate(NavRoutes.Settings(tab)) + currentNavigator.navigate(NavRoutes.Settings(tab)) } fun navigateEditMediaSource( factoryId: FactoryId, mediaSourceInstanceId: String, ) { - navigator.navigate( + currentNavigator.navigate( NavRoutes.EditMediaSource(factoryId.value, mediaSourceInstanceId), ) } fun navigateTorrentPeerSettings() { - navigator.navigate(NavRoutes.TorrentPeerSettings) + currentNavigator.navigate(NavRoutes.TorrentPeerSettings) } fun navigateCaches() { - navigator.navigate(NavRoutes.Caches) + currentNavigator.navigate(NavRoutes.Caches) } fun navigateCacheDetails(cacheId: String) { - navigator.navigate(NavRoutes.CacheDetail(cacheId)) + currentNavigator.navigate(NavRoutes.CacheDetail(cacheId)) } fun navigateSchedule() { - navigator.navigate(NavRoutes.Schedule) + currentNavigator.navigate(NavRoutes.Schedule) } } @@ -153,11 +159,16 @@ private class AniNavigatorImpl : AniNavigator { onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - override val navigator: NavHostController + override val currentNavigator: NavHostController get() = _navigator.replayCache.firstOrNull() ?: error("Navigator is not yet set") + @Composable + override fun collectNavigatorAsState(): State = _navigator.collectAsState(currentNavigator) + override fun setNavController(controller: NavHostController) { - this._navigator.tryEmit(controller) + check(this._navigator.tryEmit(controller)) { + "Failed to set NavController" + } } override fun isNavControllerReady(): Boolean = _navigator.replayCache.isNotEmpty() @@ -179,11 +190,11 @@ inline fun OverrideNavigation( noinline newNavigator: @DisallowComposableCalls (AniNavigator) -> AniNavigator, crossinline content: @Composable () -> Unit ) { - val current by rememberUpdatedState(LocalNavigator.current) - val newNavigatorUpdated by rememberUpdatedState(newNavigator) + val currentState = rememberUpdatedState(LocalNavigator.current) + val newNavigatorState = rememberUpdatedState(newNavigator) val new by remember { derivedStateOf { - newNavigatorUpdated(current) + newNavigatorState.value(currentState.value) } } CompositionLocalProvider(LocalNavigator provides new) { diff --git a/app/shared/src/androidMain/kotlin/ui/cache/CacheManagePage.android.kt b/app/shared/src/androidMain/kotlin/ui/cache/CacheManagePage.android.kt index 227b2451be..a7f0bb08aa 100644 --- a/app/shared/src/androidMain/kotlin/ui/cache/CacheManagePage.android.kt +++ b/app/shared/src/androidMain/kotlin/ui/cache/CacheManagePage.android.kt @@ -95,7 +95,6 @@ internal fun createTestCacheEpisode( onPause = { state.value = CacheEpisodePaused.PAUSED }, onResume = { state.value = CacheEpisodePaused.IN_PROGRESS }, onDelete = {}, - onPlay = {}, backgroundScope = GlobalScope, ) } diff --git a/app/shared/src/androidMain/kotlin/ui/cache/components/CacheGroupCard.android.kt b/app/shared/src/androidMain/kotlin/ui/cache/components/CacheGroupCard.android.kt index e25e40ec87..eadceb83a5 100644 --- a/app/shared/src/androidMain/kotlin/ui/cache/components/CacheGroupCard.android.kt +++ b/app/shared/src/androidMain/kotlin/ui/cache/components/CacheGroupCard.android.kt @@ -38,6 +38,7 @@ fun PreviewCacheGroupCardMissingTotalSize() = ProvideCompositionLocalsForPreview downloadSpeed = 233.megaBytes, totalSize = Unspecified, ), + onPlay = { _, _ -> }, ) } } @@ -54,6 +55,7 @@ fun PreviewCacheGroupCardMissingProgress() = ProvideCompositionLocalsForPreview downloadSpeed = 233.megaBytes, totalSize = 888.megaBytes, ), + onPlay = { _, _ -> }, ) } } @@ -70,6 +72,7 @@ fun PreviewCacheGroupCardMissingDownloadSpeed() = ProvideCompositionLocalsForPre downloadSpeed = Unspecified, totalSize = 888.megaBytes, ), + onPlay = { _, _ -> }, ) } } diff --git a/app/shared/src/commonMain/kotlin/ui/cache/CacheManagementScreen.kt b/app/shared/src/commonMain/kotlin/ui/cache/CacheManagementScreen.kt index d835910edf..671195874c 100644 --- a/app/shared/src/commonMain/kotlin/ui/cache/CacheManagementScreen.kt +++ b/app/shared/src/commonMain/kotlin/ui/cache/CacheManagementScreen.kt @@ -51,7 +51,6 @@ import me.him188.ani.app.domain.media.cache.MediaCacheManager import me.him188.ani.app.domain.media.cache.MediaCacheState import me.him188.ani.app.domain.media.cache.engine.MediaStats import me.him188.ani.app.domain.media.cache.engine.sum -import me.him188.ani.app.navigation.AniNavigator import me.him188.ani.app.torrent.api.files.averageRate import me.him188.ani.app.ui.adaptive.AniTopAppBar import me.him188.ani.app.ui.adaptive.AniTopAppBarDefaults @@ -85,9 +84,7 @@ import kotlin.time.Duration.Companion.seconds typealias CacheGroupGridLayoutState = LazyStaggeredGridState @Stable -class CacheManagementViewModel( - private val navigator: AniNavigator, -) : AbstractViewModel(), KoinComponent { +class CacheManagementViewModel : AbstractViewModel(), KoinComponent { private val cacheManager: MediaCacheManager by inject() private val subjectCollectionRepository: SubjectCollectionRepository by inject() @@ -211,12 +208,6 @@ class CacheManagementViewModel( onPause = { mediaCache.pause() }, onResume = { mediaCache.resume() }, onDelete = { cacheManager.deleteCache(mediaCache) }, - onPlay = { - navigator.navigateEpisodeDetails( - mediaCache.metadata.subjectIdInt, - mediaCache.metadata.episodeIdInt, - ) - }, backgroundScope = this + CoroutineName("CacheEpisode-${mediaCache.metadata.episodeIdInt}"), ) } diff --git a/app/shared/src/commonMain/kotlin/ui/cache/components/CacheEpisodeItem.kt b/app/shared/src/commonMain/kotlin/ui/cache/components/CacheEpisodeItem.kt index 96d765069a..93e81d01ce 100644 --- a/app/shared/src/commonMain/kotlin/ui/cache/components/CacheEpisodeItem.kt +++ b/app/shared/src/commonMain/kotlin/ui/cache/components/CacheEpisodeItem.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 OpenAni and contributors. + * Copyright (C) 2024-2025 OpenAni and contributors. * * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. @@ -90,7 +90,6 @@ class CacheEpisodeState( private val onPause: suspend () -> Unit, // background scope private val onResume: suspend () -> Unit, // background scope private val onDelete: suspend () -> Unit, // background scope - private val onPlay: () -> Unit, // ui scope backgroundScope: CoroutineScope, ) { @Immutable @@ -173,10 +172,6 @@ class CacheEpisodeState( } } - fun play() { - onPlay() - } - companion object { /** * @sample me.him188.ani.app.ui.cache.components.CacheEpisodeStateTest.CalculateSizeTextTest @@ -214,6 +209,7 @@ enum class CacheEpisodePaused { @Composable fun CacheEpisodeItem( state: CacheEpisodeState, + onPlay: (subjectId: Int, episodeId: Int) -> Unit, modifier: Modifier = Modifier, containerColor: Color = MaterialTheme.colorScheme.surface, ) { @@ -352,6 +348,7 @@ fun CacheEpisodeItem( Dropdown( showDropdown, { showDropdown = false }, state, + onPlay = { onPlay(state.subjectId, state.episodeId) }, ) }, colors = listItemColors, @@ -363,6 +360,7 @@ private fun Dropdown( showDropdown: Boolean, onDismissRequest: () -> Unit, state: CacheEpisodeState, + onPlay: () -> Unit, modifier: Modifier = Modifier, ) { var showConfirm by rememberSaveable { mutableStateOf(false) } @@ -422,7 +420,7 @@ private fun Dropdown( } }, onClick = { - state.play() + onPlay() onDismissRequest() }, ) diff --git a/app/shared/src/commonMain/kotlin/ui/cache/components/CacheGroupCard.kt b/app/shared/src/commonMain/kotlin/ui/cache/components/CacheGroupCard.kt index 3e4b72e2be..e27828634f 100644 --- a/app/shared/src/commonMain/kotlin/ui/cache/components/CacheGroupCard.kt +++ b/app/shared/src/commonMain/kotlin/ui/cache/components/CacheGroupCard.kt @@ -284,6 +284,7 @@ fun CacheGroupCard( } } } + val navigator = LocalNavigator.current AniAnimatedVisibility(state.expanded) { Column( @@ -296,7 +297,13 @@ fun CacheGroupCard( verticalArrangement = Arrangement.spacedBy(layoutProperties.episodeItemSpacing), // each item already has inner paddings ) { for (episode in state.episodes) { - CacheEpisodeItem(episode, containerColor = outerCardColors.containerColor) + CacheEpisodeItem( + episode, + containerColor = outerCardColors.containerColor, + onPlay = { subjectId, episodeId -> + navigator.navigateEpisodeDetails(subjectId, episodeId) + }, + ) } } } diff --git a/app/shared/src/commonMain/kotlin/ui/main/AniAppContent.kt b/app/shared/src/commonMain/kotlin/ui/main/AniAppContent.kt index d7a7b4ca5f..9878022833 100644 --- a/app/shared/src/commonMain/kotlin/ui/main/AniAppContent.kt +++ b/app/shared/src/commonMain/kotlin/ui/main/AniAppContent.kt @@ -114,7 +114,7 @@ private fun AniAppContentImpl( initialRoute: NavRoutes, modifier: Modifier = Modifier, ) { - val navController = aniNavigator.navigator + val navController by aniNavigator.collectNavigatorAsState() // 必须传给所有 Scaffold 和 TopAppBar. 注意, 如果你不传, 你的 UI 很可能会在 macOS 不工作. val windowInsetsWithoutTitleBar = ScaffoldDefaults.contentWindowInsets val windowInsets = ScaffoldDefaults.contentWindowInsets @@ -306,7 +306,7 @@ private fun AniAppContentImpl( ) { backStackEntry -> val route = backStackEntry.toRoute() CacheManagementScreen( - viewModel { CacheManagementViewModel(aniNavigator) }, + viewModel { CacheManagementViewModel() }, navigationIcon = { BackNavigationIconButton( { @@ -467,7 +467,7 @@ private fun AniAppContentImpl( } } - LaunchedEffect(true) { + LaunchedEffect(true, navController) { navController.currentBackStack.collect { list -> if (list.isEmpty()) { // workaround for 快速点击左上角返回键会白屏. navController.navigate(initialRoute) diff --git a/app/shared/src/commonMain/kotlin/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ui/main/MainScreen.kt index 45493dc4d3..8f6e4f9d6e 100644 --- a/app/shared/src/commonMain/kotlin/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ui/main/MainScreen.kt @@ -160,7 +160,8 @@ private fun MainScreenContent( layoutType = navigationLayoutType, ) { val coroutineScope = rememberCoroutineScope() - val navigator by rememberUpdatedState(LocalNavigator.current) + val navigatorState = rememberUpdatedState(LocalNavigator.current) + val navigator by navigatorState TabContent( layoutType = navigationLayoutType, Modifier.ifThen(navigationLayoutType != NavigationSuiteType.NavigationBar) { @@ -206,7 +207,7 @@ private fun MainScreenContent( } MainScreenPage.CacheManagement -> CacheManagementScreen( - viewModel { CacheManagementViewModel(navigator) }, + viewModel { CacheManagementViewModel() }, navigationIcon = { }, Modifier.fillMaxSize(), ) diff --git a/app/shared/src/commonTest/kotlin/ui/cache/components/CacheEpisodeStateTest.kt b/app/shared/src/commonTest/kotlin/ui/cache/components/CacheEpisodeStateTest.kt index 1c383c475d..3d614b9151 100644 --- a/app/shared/src/commonTest/kotlin/ui/cache/components/CacheEpisodeStateTest.kt +++ b/app/shared/src/commonTest/kotlin/ui/cache/components/CacheEpisodeStateTest.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024-2025 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * 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.ui.cache.components import androidx.compose.runtime.mutableStateOf @@ -79,7 +88,6 @@ class CacheEpisodeStateTest { onPause = { state.value = CacheEpisodePaused.PAUSED }, onResume = { state.value = CacheEpisodePaused.IN_PROGRESS }, onDelete = {}, - onPlay = {}, backgroundScope = GlobalScope, ) } diff --git a/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/PreviewFoundation.kt b/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/PreviewFoundation.kt index 6ae7eae1a8..987a106687 100644 --- a/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/PreviewFoundation.kt +++ b/app/shared/ui-foundation/src/commonMain/kotlin/ui/foundation/PreviewFoundation.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -100,9 +99,7 @@ inline fun ProvideCompositionLocalsForPreview( LocalViewModelStoreOwner provides viewModelStoreOwner, ) { val navController = rememberNavController() - SideEffect { - aniNavigator.setNavController(navController) - } + aniNavigator.setNavController(navController) ProvidePlatformCompositionLocalsForPreview { AniTheme(isDark = isDark) { ProvideAniMotionCompositionLocals {