From a40a297cd667d8d27908dcdc54121173dc250aa4 Mon Sep 17 00:00:00 2001 From: Him188 Date: Fri, 20 Sep 2024 00:48:58 +0100 Subject: [PATCH] Implement episode pane --- .../source/web/SelectorMediaSourceEngine.kt | 6 +- .../media/source/web/SelectorSearchConfig.kt | 2 +- .../kotlin/ui/main/AniAppContentPortrait.kt | 6 +- ...ectorChannelConfigurationColumn.android.kt | 4 +- .../selector/edit/SelectorEditPane.android.kt | 2 +- .../episode/SelectorEpisodePane.android.kt | 68 ++- .../selector/test/SelectorTestPane.android.kt | 36 +- .../RefreshIndicatedHeadlineRow.kt | 4 +- .../selector/EditSelectorMediaSourcePage.kt | 129 ++++-- .../SelectorMediaSourceConfigurationPage.kt | 6 +- .../SelectorChannelConfigurationColumn.kt | 2 +- ...urationState.kt => SelectorConfigState.kt} | 2 +- .../edit/SelectorConfigurationDefaults.kt | 22 +- .../edit/SelectorConfigurationPane.kt | 37 +- .../selector/episode/SelectorEpisodePane.kt | 411 ++++++++++-------- .../episode/SelectorEpisodePaneDefaults.kt | 92 ++++ .../selector/episode/SelectorEpisodeResult.kt | 29 ++ .../selector/episode/SelectorEpisodeState.kt | 129 ++++++ .../selector/test/SelectTestEpisodeResult.kt | 3 + .../selector/test/SelectorTestEpisodeList.kt | 33 +- .../selector/test/SelectorTestPane.kt | 41 +- .../test/SelectorTestSearchSubjectResult.kt | 2 +- .../selector/test/SelectorTestState.kt | 17 - 23 files changed, 744 insertions(+), 339 deletions(-) rename app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/{SelectorConfigurationState.kt => SelectorConfigState.kt} (99%) create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePaneDefaults.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeResult.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeState.kt diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt index 36b45d988f..27c3ae8ebb 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt @@ -192,7 +192,11 @@ abstract class SelectorMediaSourceEngine { fun matchWebVideo(url: String, searchConfig: SelectorSearchConfig.MatchVideoConfig): WebVideo? { val result = searchConfig.matchVideoUrlRegex?.find(url) ?: return null - val videoUrl = result.groups["v"]?.value ?: result.value + val videoUrl = try { + result.groups["v"]?.value ?: url + } catch (_: IllegalArgumentException) { // no group + url + } return WebVideo( videoUrl, mapOf( diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt index 764a6442e3..7209f6cfc5 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt @@ -65,7 +65,7 @@ data class SelectorSearchConfig( @Serializable data class MatchVideoConfig( @Suppress("RegExpRedundantEscape") - val matchVideoUrl: String = """^(?http(s)?:\/\/(?!.*http(s)?:\/\/).+((\.mp4)|(\.mkv)|(\.m3u8)))""", + val matchVideoUrl: String = """^(?http(s)?:\/\/(?!.*http(s)?:\/\/).+((\.mp4)|(\.mkv)|(m3u8)).*(\?.+)?)""", val addHeadersToVideo: VideoHeaders = VideoHeaders(), ) { val matchVideoUrlRegex by lazy { diff --git a/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt b/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt index 8f2b3ce890..a1dbb8326e 100644 --- a/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt +++ b/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt @@ -58,7 +58,7 @@ import me.him188.ani.app.ui.settings.SettingsViewModel import me.him188.ani.app.ui.settings.mediasource.rss.EditRssMediaSourcePage import me.him188.ani.app.ui.settings.mediasource.rss.EditRssMediaSourceViewModel import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourcePage -import me.him188.ani.app.ui.settings.mediasource.selector.SelectorMediaSourceConfigurationViewModel +import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourceViewModel import me.him188.ani.app.ui.settings.tabs.media.torrent.peer.PeerFilterSettingsPage import me.him188.ani.app.ui.settings.tabs.media.torrent.peer.PeerFilterSettingsViewModel import me.him188.ani.app.ui.subject.cache.SubjectCacheScene @@ -311,8 +311,8 @@ fun AniAppContentPortrait( SelectorMediaSource.FactoryId -> { val context = LocalContext.current EditSelectorMediaSourcePage( - viewModel(key = mediaSourceInstanceId) { - SelectorMediaSourceConfigurationViewModel(mediaSourceInstanceId, context) + viewModel(key = mediaSourceInstanceId) { + EditSelectorMediaSourceViewModel(mediaSourceInstanceId, context) }, Modifier, windowInsets = windowInsets, diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt index 27bd35a0fd..273b789b25 100644 --- a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt @@ -27,9 +27,9 @@ import me.him188.ani.utils.platform.annotations.TestOnly @TestOnly fun rememberTestSelectorConfigurationState( arguments: SelectorMediaSourceArguments = SelectorMediaSourceArguments.Default -): SelectorConfigurationState { +): SelectorConfigState { return remember { - SelectorConfigurationState( + SelectorConfigState( createTestSaveableStorage( arguments, ), diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt index cd4c2c4123..66f0d2da8c 100644 --- a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt @@ -26,7 +26,7 @@ fun PreviewSelectorConfigurationPane() = ProvideFoundationCompositionLocalsForPr Surface { SelectorConfigurationPane( remember { - SelectorConfigurationState( + SelectorConfigState( createTestSaveableStorage( SelectorMediaSourceArguments.Default, ), diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt index e2b38b582e..a37144e940 100644 --- a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt @@ -9,8 +9,6 @@ package me.him188.ani.app.ui.settings.mediasource.selector.episode -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -18,52 +16,45 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.tooling.preview.Preview import me.him188.ani.app.data.source.media.resolver.TestWebViewVideoExtractor +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig import me.him188.ani.app.platform.LocalContext import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview import me.him188.ani.app.ui.foundation.stateOf +import me.him188.ani.app.ui.settings.mediasource.rss.createTestSaveableStorage import me.him188.ani.app.ui.settings.mediasource.rss.test.buildMatchTags -import me.him188.ani.app.ui.settings.mediasource.selector.edit.rememberTestSelectorConfigurationState +import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourcePageState import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation import me.him188.ani.app.ui.settings.mediasource.selector.test.TestSelectorMediaSourceEngine import me.him188.ani.datasources.api.EpisodeSort import me.him188.ani.utils.platform.annotations.TestOnly import kotlin.coroutines.EmptyCoroutineContext -@TestOnly -private val configurationContent: @Composable ColumnScope.(contentPadding: PaddingValues) -> Unit = { contentPadding -> - SelectorEpisodePaneDefaults.ConfigurationContent( - rememberTestSelectorConfigurationState(), - contentPadding = contentPadding, - ) -} - @OptIn(TestOnly::class) @Composable @Preview -fun PreviewSelectorEpisodePaneWithBottomSheet() = ProvideFoundationCompositionLocalsForPreview { +fun PreviewSelectorEpisodePaneCompact() = ProvideFoundationCompositionLocalsForPreview { Surface { - SelectorEpisodePane( - state = rememberTestSelectorEpisodeState( + SelectorTestAndEpisodePane( + state = rememberTestEditSelectorMediaSourceState( TestSelectorTestEpisodePresentations[0], SelectorSearchConfig.MatchVideoConfig(), ), - layout = SelectorEpisodePaneLayout.WithBottomSheet, - configurationContent = configurationContent, + layout = SelectorEpisodePaneLayout.Compact, ) } } @OptIn(TestOnly::class) @Composable -@Preview -fun PreviewSelectorEpisodePaneListOnly() { +@Preview(device = "spec:width=1280dp,height=800dp,dpi=240") +fun PreviewSelectorEpisodePaneExpanded() { ProvideFoundationCompositionLocalsForPreview { Surface { - SelectorEpisodePane( - state = rememberTestSelectorEpisodeState(), - layout = SelectorEpisodePaneLayout.ListOnly, - configurationContent = configurationContent, + SelectorTestAndEpisodePane( + state = rememberTestEditSelectorMediaSourceState(), + layout = SelectorEpisodePaneLayout.Expanded, + initialRoute = SelectorEpisodePaneRoutes.EPISODE, ) } } @@ -117,3 +108,36 @@ internal fun rememberTestSelectorEpisodeState( ) } } + +@TestOnly +@Composable +internal fun rememberTestEditSelectorMediaSourceState( + viewing: SelectorTestEpisodePresentation? = TestSelectorTestEpisodePresentations[0], + matchVideoConfig: SelectorSearchConfig.MatchVideoConfig = SelectorSearchConfig.MatchVideoConfig(), + urls: (pageUrl: String) -> List = { + listOf("https://example.com/a.mkv") + }, +): EditSelectorMediaSourcePageState { + val context = LocalContext.current + val scope = rememberCoroutineScope() + return remember { + EditSelectorMediaSourcePageState( + createTestSaveableStorage( + SelectorMediaSourceArguments.Default.run { + copy( + searchConfig = searchConfig.copy(matchVideo = matchVideoConfig), + ) + }, + ), + engine = TestSelectorMediaSourceEngine(), + webViewVideoExtractor = stateOf(TestWebViewVideoExtractor(urls)), + backgroundScope = scope, + context, + flowDispatcher = EmptyCoroutineContext, + ).apply { + viewing?.let { presentation -> + this.viewEpisode(presentation) + } + } + } +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt index 898cb19c75..aca4af2553 100644 --- a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt @@ -11,6 +11,9 @@ package me.him188.ani.app.ui.settings.mediasource.selector.test +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SharedTransitionScope import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf @@ -29,21 +32,30 @@ import me.him188.ani.utils.xml.Document import me.him188.ani.utils.xml.Element @Composable +@SuppressLint("UnusedContentLambdaTargetStateParameter") @Preview fun PreviewSelectorTestPane() = ProvideFoundationCompositionLocalsForPreview { val scope = rememberCoroutineScope() - Surface { - SelectorTestPane( - remember { - SelectorTestState( - searchConfigState = mutableStateOf(SelectorSearchConfig.Empty), - engine = TestSelectorMediaSourceEngine(), - scope, - ).apply { - subjectSearcher.restartCurrentSearch() - } - }, - ) + SharedTransitionScope { modifier -> + @Suppress("AnimatedContentLabel") + AnimatedContent(1) { _ -> + Surface { + SelectorTestPane( + remember { + SelectorTestState( + searchConfigState = mutableStateOf(SelectorSearchConfig.Empty), + engine = TestSelectorMediaSourceEngine(), + scope, + ).apply { + subjectSearcher.restartCurrentSearch() + } + }, + {}, + this, + modifier = modifier, + ) + } + } } } diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt index 5a11c9a826..0d882d1aab 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt @@ -92,7 +92,7 @@ fun RefreshIndicatedHeadlineRow( result: RefreshResult?, modifier: Modifier = Modifier, refreshIcon: @Composable () -> Unit = { RefreshIndicationDefaults.RefreshIconButton(onRefresh) }, - style: TextStyle = MaterialTheme.typography.headlineSmall, + style: TextStyle = MaterialTheme.typography.titleLarge, ) { Row(modifier, verticalAlignment = Alignment.CenterVertically) { ProvideTextStyle(style) { @@ -114,7 +114,7 @@ object RefreshIndicationDefaults { onClick: () -> Unit, modifier: Modifier = Modifier, ) { - TextButton( + IconButton( onClick = onClick, modifier = modifier, ) { diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt index bd65b697f5..ee2a2beb62 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt @@ -25,50 +25,75 @@ import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.CoroutineDispatcher +import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine import me.him188.ani.app.platform.Context +import me.him188.ani.app.ui.foundation.interaction.WindowDragArea import me.him188.ani.app.ui.foundation.layout.AnimatedPane1 import me.him188.ani.app.ui.foundation.layout.isWidthCompact import me.him188.ani.app.ui.foundation.layout.materialWindowMarginPadding +import me.him188.ani.app.ui.foundation.layout.rememberConnectedScrollState import me.him188.ani.app.ui.foundation.navigation.BackHandler import me.him188.ani.app.ui.foundation.widgets.TopAppBarGoBackButton import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigState import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationPane -import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationState -import me.him188.ani.app.ui.settings.mediasource.selector.episode.ConfigurationContent -import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodePane import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodePaneDefaults import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodePaneLayout +import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodePaneRoutes import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodeState -import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestPane +import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorTestAndEpisodePane +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestState +import kotlin.coroutines.CoroutineContext -class EditSelectorMediaSourceState( +class EditSelectorMediaSourcePageState( argumentsStorage: SaveableStorage, engine: SelectorMediaSourceEngine, webViewVideoExtractor: State, backgroundScope: CoroutineScope, context: Context, - flowDispatcher: CoroutineDispatcher = Dispatchers.Default, + flowDispatcher: CoroutineContext = Dispatchers.Default, ) { - internal val configurationState: SelectorConfigurationState = SelectorConfigurationState(argumentsStorage) + internal val configurationState: SelectorConfigState = SelectorConfigState(argumentsStorage) internal val testState: SelectorTestState = SelectorTestState(configurationState.searchConfigState, engine, backgroundScope) + private val viewingItemState = mutableStateOf(null) + + // lateinit var episodeNavController: NavHostController + var viewingItem by viewingItemState + private set + + fun viewEpisode( + episode: SelectorTestEpisodePresentation, + ) { + this.viewingItem = episode +// episodeNavController.navigate("details") + } + + fun stopViewing() { + this.viewingItem = null +// episodeNavController.navigate("list") + } + + internal val episodeState: SelectorEpisodeState = SelectorEpisodeState( - itemState = derivedStateOf { testState.viewingItem }, + itemState = viewingItemState, matchVideoConfigState = derivedStateOf { configurationState.searchConfigState.value?.matchVideo }, webViewVideoExtractor = webViewVideoExtractor, engine = engine, @@ -80,7 +105,7 @@ class EditSelectorMediaSourceState( @Composable fun EditSelectorMediaSourcePage( - vm: SelectorMediaSourceConfigurationViewModel, + vm: EditSelectorMediaSourceViewModel, modifier: Modifier = Modifier, navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), windowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, @@ -93,32 +118,61 @@ fun EditSelectorMediaSourcePage( @Composable fun EditSelectorMediaSourcePage( - state: EditSelectorMediaSourceState, + state: EditSelectorMediaSourcePageState, modifier: Modifier = Modifier, navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), windowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, ) { + val nestedNav = rememberNavController() + val episodePaneLayout = SelectorEpisodePaneLayout.calculate(navigator.scaffoldValue) + val testConnectedScrollState = rememberConnectedScrollState() Scaffold( modifier, topBar = { - TopAppBar( - title = { Text(state.configurationState.displayName) }, - navigationIcon = { TopAppBarGoBackButton() }, - actions = { - if (currentWindowAdaptiveInfo().isWidthCompact) { - TextButton({ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) }) { - Text("测试") - } - } - }, - windowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), - ) + WindowDragArea { + if (episodePaneLayout.showTopBarInScaffold) { + SelectorEpisodePaneDefaults.TopAppBar(state.episodeState) + } else { + TopAppBar( + title = { + nestedNav.navigate(SelectorEpisodePaneRoutes.EPISODE) + val viewingItem = state.viewingItem + if (viewingItem != null) { + Text(viewingItem.name) + } else { + Text(state.configurationState.displayName) + } + }, + navigationIcon = { TopAppBarGoBackButton() }, + actions = { + if (currentWindowAdaptiveInfo().isWidthCompact && navigator.currentDestination?.pane != ListDetailPaneScaffoldRole.Detail) { + TextButton({ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) }) { + Text("测试") + } + } + }, + windowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ) + } + } }, contentWindowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom), ) { paddingValues -> BackHandler(navigator.canNavigateBack()) { navigator.navigateBack() } + + // 在外面启动, 避免在切换页面后重新启动导致刷新 + LaunchedEffect(state) { + state.testState.subjectSearcher.observeChangeLoop() + } + LaunchedEffect(state) { + state.testState.episodeListSearcher.observeChangeLoop() + } + LaunchedEffect(state) { + state.episodeState.searcher.observeChangeLoop() + } + ListDetailPaneScaffold( navigator.scaffoldDirective, navigator.scaffoldValue, @@ -133,34 +187,17 @@ fun EditSelectorMediaSourcePage( }, detailPane = { AnimatedPane1 { - SelectorTestPane( - state.testState, - onViewEpisode = { - state.testState.viewEpisode(it) - navigator.navigateTo(ListDetailPaneScaffoldRole.Extra) - }, - Modifier.fillMaxSize().consumeWindowInsets(paddingValues), + SelectorTestAndEpisodePane( + state, + episodePaneLayout, + Modifier.consumeWindowInsets(paddingValues), + nestedNav, paddingValues, + testConnectedScrollState, ) } }, Modifier.materialWindowMarginPadding(), - extraPane = { - AnimatedPane1 { - SelectorEpisodePane( - state.episodeState, - SelectorEpisodePaneLayout.calculate(navigator.scaffoldValue), - configurationContent = { - SelectorEpisodePaneDefaults.ConfigurationContent( - state.configurationState, - contentPadding = it, - ) - }, - Modifier.fillMaxSize().consumeWindowInsets(paddingValues), - paddingValues, - ) - } - }, ) } } diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt index ad3bb9cc21..e2831d9dc8 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt @@ -51,7 +51,7 @@ import org.koin.core.component.inject private typealias ArgumentsType = SelectorMediaSourceArguments @Stable -class SelectorMediaSourceConfigurationViewModel( +class EditSelectorMediaSourceViewModel( initialInstanceId: String, context: Context, ) : AbstractViewModel(), KoinComponent { @@ -68,7 +68,7 @@ class SelectorMediaSourceConfigurationViewModel( } } - val state: Flow = this.instanceId.transformLatest { instanceId -> + val state: Flow = this.instanceId.transformLatest { instanceId -> coroutineScope { val saveTasker = MonoTasker(this) val arguments = mutableStateOf(null) @@ -81,7 +81,7 @@ class SelectorMediaSourceConfigurationViewModel( } } emit( - EditSelectorMediaSourceState( + EditSelectorMediaSourcePageState( argumentsStorage = SaveableStorage( arguments, onSave = { diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt index 74390405d8..468a9b606c 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt @@ -41,7 +41,7 @@ import me.him188.ani.app.ui.settings.mediasource.MediaSourceConfigurationDefault @Composable internal fun SelectorChannelConfigurationColumn( formatId: SelectorFormatId, - state: SelectorConfigurationState, + state: SelectorConfigState, modifier: Modifier = Modifier, textFieldShape: Shape = MediaSourceConfigurationDefaults.outlinedTextFieldShape, ) { diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt similarity index 99% rename from app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationState.kt rename to app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt index c25923f61e..bdf2ca2054 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationState.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt @@ -27,7 +27,7 @@ import me.him188.ani.utils.xml.parseSelectorOrNull * 编辑配置 */ @Stable -class SelectorConfigurationState( +class SelectorConfigState( private val argumentsStorage: SaveableStorage, ) { private val arguments by argumentsStorage.containerState diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt index f0097b165f..5f56677fbf 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt @@ -42,7 +42,7 @@ object SelectorConfigurationDefaults { @Suppress("UnusedReceiverParameter") @Composable internal fun SelectorConfigurationDefaults.MatchVideoSection( - state: SelectorConfigurationState, + state: SelectorConfigState, modifier: Modifier = Modifier, textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, @@ -53,28 +53,10 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, Modifier.fillMaxWidth().moveFocusOnEnter(), label = { Text("匹配视频链接") }, - supportingText = { Text("从播放页面中加载的所有资源链接中匹配出视频链接的正则表达式。将会使用匹配结果的分组 v") }, + supportingText = { Text("从播放页面中加载的所有资源链接中匹配出视频链接的正则表达式。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), shape = textFieldShape, isError = matchVideoConfig.matchVideoUrlIsError, ) - - val conf = matchVideoConfig.videoHeaders - OutlinedTextField( - conf.referer, { conf.referer = it }, - Modifier.fillMaxWidth().moveFocusOnEnter(), - label = { Text("Referer") }, - supportingText = { Text("HTTP 请求的 Referer") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - shape = textFieldShape, - ) - OutlinedTextField( - conf.userAgent, { conf.userAgent = it }, - Modifier.fillMaxWidth().moveFocusOnEnter(), - label = { Text("User-Agent") }, - supportingText = { Text("HTTP 请求的 User-Agent") }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - shape = textFieldShape, - ) } } diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt index 7975017948..4127cada0d 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt @@ -53,7 +53,7 @@ import me.him188.ani.app.ui.settings.mediasource.rss.edit.MediaSourceHeadline @Composable internal fun SelectorConfigurationPane( - state: SelectorConfigurationState, + state: SelectorConfigState, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, @@ -153,13 +153,13 @@ internal fun SelectorConfigurationPane( SubjectChannelSelectionButtonRow( state, - Modifier.fillMaxWidth(), + Modifier.fillMaxWidth().padding(bottom = 4.dp), ) AnimatedContent( state.channelFormatId, Modifier - .padding(vertical = 12.dp) + .padding(vertical = 16.dp) .fillMaxWidth() .animateContentSize(tween(EasingDurations.standard, easing = StandardEasing)), transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, @@ -208,6 +208,35 @@ internal fun SelectorConfigurationPane( verticalSpacing = verticalSpacing, ) + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text("播放视频时") + } + } + + Column(Modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + val conf = state.matchVideoConfig.videoHeaders + OutlinedTextField( + conf.referer, { conf.referer = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("Referer") }, + supportingText = { Text("播放视频时执行的 HTTP 请求的 Referer") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + OutlinedTextField( + conf.userAgent, { conf.userAgent = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("User-Agent") }, + supportingText = { Text("播放视频时执行的 HTTP 请求的 User-Agent") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + } + Row(Modifier.align(Alignment.End).padding(top = verticalSpacing, bottom = 12.dp)) { ProvideTextStyleContentColor( MaterialTheme.typography.labelMedium, @@ -223,7 +252,7 @@ internal fun SelectorConfigurationPane( @Composable private fun SubjectChannelSelectionButtonRow( - state: SelectorConfigurationState, + state: SelectorConfigState, modifier: Modifier = Modifier, ) { SingleChoiceSegmentedButtonRow(modifier) { diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt index 9b5e95a80e..39fa373c61 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt @@ -9,21 +9,27 @@ package me.him188.ani.app.ui.settings.mediasource.selector.episode +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PriorityHigh +import androidx.compose.material.icons.rounded.Verified import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.Card import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors @@ -31,160 +37,244 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor -import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine -import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig -import me.him188.ani.app.platform.Context -import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults -import me.him188.ani.app.ui.settings.mediasource.BackgroundSearcher -import me.him188.ani.app.ui.settings.mediasource.launchCollectedInBackground -import me.him188.ani.app.ui.settings.mediasource.selector.edit.MatchVideoSection +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.Serializable +import me.him188.ani.app.ui.foundation.layout.ConnectedScrollState +import me.him188.ani.app.ui.foundation.layout.paneHorizontalPadding +import me.him188.ani.app.ui.foundation.layout.rememberConnectedScrollState +import me.him188.ani.app.ui.foundation.navigation.BackHandler +import me.him188.ani.app.ui.foundation.widgets.FastLinearProgressIndicator +import me.him188.ani.app.ui.foundation.widgets.LocalToaster +import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourcePageState import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationDefaults -import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationState -import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation -import me.him188.ani.datasources.api.matcher.WebVideo -import me.him188.ani.datasources.api.matcher.WebVideoMatcher -import kotlin.coroutines.CoroutineContext +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestPane -/** - * 测试 [WebVideoMatcher] - */ -@Stable -class SelectorEpisodeState( - private val itemState: State, - /** - * null means loading. Should finally have one. - */ - matchVideoConfigState: State, - /** - * null means loading. Should finally have one. - */ - private val webViewVideoExtractor: State, - private val engine: SelectorMediaSourceEngine, - backgroundScope: CoroutineScope, - context: Context, - flowDispatcher: CoroutineContext = Dispatchers.Default, +@Composable +fun SelectorTestAndEpisodePane( + state: EditSelectorMediaSourcePageState, + layout: SelectorEpisodePaneLayout, + modifier: Modifier = Modifier, + nestedNav: NavHostController = rememberNavController(), + contentPadding: PaddingValues = PaddingValues(0.dp), + testConnectedScrollState: ConnectedScrollState = rememberConnectedScrollState(), + initialRoute: SelectorEpisodePaneRoutes = SelectorEpisodePaneRoutes.TEST, ) { - val episodeName: String by derivedStateOf { itemState.value?.name ?: "" } - val episodeUrl: String by derivedStateOf { itemState.value?.playUrl ?: "" } + SharedTransitionScope { transitionModifier -> + NavHost(nestedNav, initialRoute, modifier.then(transitionModifier)) { + composable { + SelectorTestPane( + state.testState, + onViewEpisode = { + state.viewEpisode(it) + }, + this, + Modifier.fillMaxSize(), + contentPadding = contentPadding, + connectedScrollState = testConnectedScrollState, + ) + } + composable { + BackHandler { + state.stopViewing() + nestedNav.popBackStack(SelectorEpisodePaneRoutes.EPISODE, inclusive = true) + } + val cardColors: CardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) - /** - * 该页面的所有链接 - */ - val searcher = - BackgroundSearcher( - backgroundScope, - testDataState = derivedStateOf { itemState.value?.playUrl to webViewVideoExtractor.value }, - ) { (episodeUrl, extractor) -> - launchCollectedInBackground { - if (episodeUrl != null && extractor != null) { - extractor.getVideoResourceUrl(context, episodeUrl) { - collect(it) + // decorate + val content: @Composable () -> Unit = { + SelectorEpisodePaneContent( + state.episodeState, + Modifier.fillMaxSize(), + itemColors = ListItemDefaults.colors(containerColor = cardColors.containerColor), + ) + } + val topAppBarDecorated = if (layout.showTopBarInPane) { + { + // list 展开, 能编辑配置 + Card( + Modifier + .sharedBounds(rememberSharedContentState(state.episodeState.lastNonNullId), this) + .fillMaxSize(), + colors = cardColors, + shape = MaterialTheme.shapes.large, + ) { + SelectorEpisodePaneDefaults.TopAppBar(state.episodeState) + content() + } } + } else content + + val bottomSheetDecorated = if (layout.showBottomSheet) { + { + BottomSheetScaffold( + sheetContent = { + SelectorEpisodePaneDefaults.ConfigurationContent( + state.configurationState, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + }, + Modifier + .fillMaxSize(), + sheetPeekHeight = 78.dp, + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + topAppBarDecorated() + } + } + } + } else topAppBarDecorated + + Box(Modifier.padding(contentPadding)) { + bottomSheetDecorated() } } } - @Immutable - data class MatchResult( - val originalUrl: String, - val video: WebVideo?, - ) { - @Stable - fun isMatch() = video != null - } - - /** - * 不断更新的匹配结果 - */ - val matchResults: Flow> by derivedStateOf { - val matchVideoConfig = matchVideoConfigState.value ?: return@derivedStateOf emptyFlow() - val searchResult = searcher.searchResult ?: return@derivedStateOf emptyFlow() - searchResult.map { list -> - list.asSequence() - .map { original -> - MatchResult(original, engine.matchWebVideo(original, matchVideoConfig)) + // 切换 item 时自动 nav + LaunchedEffect(state) { + snapshotFlow { state.viewingItem }.collect { value -> + if (value == null) { + nestedNav.navigate(SelectorEpisodePaneRoutes.TEST) + } else { + nestedNav.navigate(SelectorEpisodePaneRoutes.EPISODE) } - .distinctBy { it.originalUrl } // O(n) extra space, O(1) time - .toMutableList() // single list instance construction - .apply { - // sort in-place for better performance - sortByDescending { it.isMatch() } // 优先展示匹配的 - } - }.flowOn(flowDispatcher) // possibly significant computation + } + } } } + @Composable -fun SelectorVideoMatcherPaneContent( +fun SelectorEpisodePaneContent( state: SelectorEpisodeState, modifier: Modifier = Modifier, itemSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, - cardColors: CardColors = AniThemeDefaults.backgroundCardColors(), + horizontalPadding: Dp = currentWindowAdaptiveInfo().windowSizeClass.paneHorizontalPadding, itemColors: ListItemColors = ListItemDefaults.colors(), ) { Column(modifier) { - Card( - colors = cardColors, - shape = MaterialTheme.shapes.large, + Box(Modifier.height(4.dp), contentAlignment = Alignment.Center) { + FastLinearProgressIndicator( + state.isSearchingInProgress, + delayMillis = 0, + minimumDurationMillis = 300, + ) + } + + val list by state.matchResults.collectAsStateWithLifecycle(emptyList()) + + Row( + Modifier.padding( + start = horizontalPadding, end = horizontalPadding, + top = 20.dp, + bottom = 20.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row(Modifier.padding(horizontal = 16.dp).padding(top = 16.dp)) { - ProvideTextStyle( - MaterialTheme.typography.titleLarge, - ) { - Text("匹配视频") + val matchedSize by remember { + derivedStateOf { + list.count { it.isMatch() } } } + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + when (matchedSize) { + 0 -> { + Icon( + Icons.Rounded.PriorityHigh, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text("根据步骤 3 的配置,从 ${list.size} 个链接中未匹配到播放链接,请检查配置") + } - ListItem( - headlineContent = { Text(state.episodeName) }, - supportingContent = { Text(state.episodeUrl) }, - colors = ListItemDefaults.colors(containerColor = cardColors.containerColor), - ) - } + 1 -> { + Icon( + Icons.Rounded.Verified, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedSize 个链接") + } - val list by state.matchResults.collectAsStateWithLifecycle(emptyList()) + else -> { + Icon( + Icons.Rounded.PriorityHigh, + contentDescription = null, + tint = Color.Yellow.compositeOver(MaterialTheme.colorScheme.error), + ) + Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedSize 个链接。为了更好的稳定性,建议调整规则,匹配到正好一个链接") + } + } + } + } - LazyVerticalGrid( - columns = GridCells.Adaptive(300.dp), - horizontalArrangement = Arrangement.spacedBy(itemSpacing), - verticalArrangement = Arrangement.spacedBy(itemSpacing), + LazyColumn( + contentPadding = PaddingValues( + bottom = itemSpacing, + start = horizontalPadding - 8.dp, end = horizontalPadding, + ), ) { + // 上面总是有个东西可以保证当后面加载到匹配 (置顶) 时, 看到的是那个被匹配到的 + item { Spacer(Modifier.height(1.dp)) } + for (matchResult in list) { item(key = matchResult.originalUrl) { val isMatch = matchResult.isMatch() + val toaster = LocalToaster.current + val clipboard = LocalClipboardManager.current ListItem( - headlineContent = { Text(matchResult.originalUrl) }, - Modifier.animateItem(), + headlineContent = { + Text( + matchResult.originalUrl, + color = if (isMatch) MaterialTheme.colorScheme.primary else Color.Unspecified, + ) + }, + Modifier.animateItem() + .clickable { + clipboard.setText(AnnotatedString(matchResult.originalUrl)) + toaster.toast("已复制") + }, supportingContent = { - Text(matchResult.video?.m3u8Url ?: "未匹配") + matchResult.video?.m3u8Url?.let { + if (it != matchResult.originalUrl) { + Text("将实际播放:$it") + } + } }, colors = itemColors, - trailingContent = { - if (isMatch) { - Icon(Icons.Rounded.Check, "匹配", tint = MaterialTheme.colorScheme.primary) - } else { - Icon(Icons.Rounded.Close, "未匹配") + leadingContent = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (isMatch) { + Icon(Icons.Rounded.Check, "匹配", tint = MaterialTheme.colorScheme.primary) + } else { + Icon(Icons.Rounded.Close, "未匹配") + } } }, ) @@ -194,84 +284,47 @@ fun SelectorVideoMatcherPaneContent( } } -enum class SelectorEpisodePaneLayout { - WithBottomSheet, - ListOnly, ; +@Serializable +sealed class SelectorEpisodePaneRoutes { + @Serializable + data object TEST : SelectorEpisodePaneRoutes() + + @Serializable + data object EPISODE : SelectorEpisodePaneRoutes() +} + +@Immutable +data class SelectorEpisodePaneLayout( + val showTopBarInPane: Boolean, + val showTopBarInScaffold: Boolean, + val showBottomSheet: Boolean, +) { companion object { + val Expanded = SelectorEpisodePaneLayout( + showTopBarInPane = true, + showTopBarInScaffold = false, + showBottomSheet = false, + ) + + val Compact = SelectorEpisodePaneLayout( + showTopBarInPane = false, + showTopBarInScaffold = true, + showBottomSheet = true, + ) + fun calculate( scaffoldValue: ThreePaneScaffoldValue, ): SelectorEpisodePaneLayout { return when { scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded -> { // list 和 extra 同时展开, 也就是大屏环境. list 内包含了配置, 所以我们无需使用 bottom sheet 显示配置 - ListOnly + Expanded } - else -> WithBottomSheet + else -> Compact } } } } -/** - * 测试 [WebVideoMatcher] - * @param configurationContent [SelectorEpisodePaneDefaults.ConfigurationContent] - */ -@Composable -fun SelectorEpisodePane( - state: SelectorEpisodeState, - layout: SelectorEpisodePaneLayout, - configurationContent: @Composable ColumnScope.(contentPadding: PaddingValues) -> Unit, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp), -) { - if (layout == SelectorEpisodePaneLayout.ListOnly) { - SelectorVideoMatcherPaneContent( - state, - modifier, - ) - } else { - BottomSheetScaffold( - sheetContent = { - configurationContent(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp)) - }, - modifier.padding(contentPadding), - sheetPeekHeight = 78.dp, - ) { paddingValues -> - SelectorVideoMatcherPaneContent( - state, - Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues), - ) - } - } -} - -object SelectorEpisodePaneDefaults - -@Suppress("UnusedReceiverParameter") -@Composable -fun SelectorEpisodePaneDefaults.ConfigurationContent( - state: SelectorConfigurationState, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp), - textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, - verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, -) { - Column(modifier.padding(contentPadding)) { - Row(Modifier.padding(bottom = 16.dp)) { - ProvideTextStyle( - MaterialTheme.typography.titleLarge, - ) { - Text("编辑配置") - } - } - SelectorConfigurationDefaults.MatchVideoSection( - state, - textFieldShape = textFieldShape, - verticalSpacing = verticalSpacing, - ) - } -} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePaneDefaults.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePaneDefaults.kt new file mode 100644 index 0000000000..fc0099d71d --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePaneDefaults.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 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.settings.mediasource.selector.episode + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowOutward +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.widgets.TopAppBarGoBackButton +import me.him188.ani.app.ui.settings.mediasource.RefreshIndicationDefaults +import me.him188.ani.app.ui.settings.mediasource.selector.edit.MatchVideoSection +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigState +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationDefaults + +object SelectorEpisodePaneDefaults { + @Composable + fun TopAppBar( + state: SelectorEpisodeState, + modifier: Modifier = Modifier.Companion, + windowInsets: WindowInsets = WindowInsets(0.dp), + ) { + val onRefresh = { state.searcher.restartCurrentSearch() } + TopAppBar( + navigationIcon = { + TopAppBarGoBackButton() + }, + title = { + Row( + verticalAlignment = Alignment.Companion.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(state.episodeName, style = LocalTextStyle.current) + RefreshIndicationDefaults.RefreshIconButton( + onClick = onRefresh, + ) + RefreshIndicationDefaults.RefreshResultTextButton( + result = state.searcher.searchResult, + onRefresh = onRefresh, + ) + } + }, + actions = { + val uriHandler = LocalUriHandler.current + IconButton({ uriHandler.openUri(state.episodeUrl) }) { + Icon(Icons.Rounded.ArrowOutward, "打开原始链接 ${state.episodeName}") + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + modifier = modifier, + windowInsets = windowInsets, + ) + } + + @Composable + fun ConfigurationContent( + state: SelectorConfigState, + modifier: Modifier = Modifier.Companion, + contentPadding: PaddingValues = PaddingValues(0.dp), + textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, + verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, + ) { + Column(modifier.padding(contentPadding)) { + Row(Modifier.Companion.padding(bottom = 16.dp)) { + ProvideTextStyle( + MaterialTheme.typography.titleLarge, + ) { + Text("编辑配置") + } + } + SelectorConfigurationDefaults.MatchVideoSection( + state, + textFieldShape = textFieldShape, + verticalSpacing = verticalSpacing, + ) + } + } + +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeResult.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeResult.kt new file mode 100644 index 0000000000..c63dc53746 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeResult.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 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.settings.mediasource.selector.episode + +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.StateFlow +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.ui.settings.mediasource.RefreshResult + +sealed class SelectorEpisodeResult : RefreshResult { + data class InProgress( + val flow: StateFlow>, + ) : SelectorEpisodeResult(), RefreshResult.InProgress + + data class Success( + val flow: StateFlow>, + ) : SelectorEpisodeResult(), RefreshResult.Success + + object InvalidConfig : SelectorEpisodeResult(), RefreshResult.InvalidConfig + data class ApiError(override val reason: ApiFailure) : SelectorEpisodeResult(), RefreshResult.ApiError + data class UnknownError(override val exception: Throwable) : SelectorEpisodeResult(), RefreshResult.UnknownError +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeState.kt new file mode 100644 index 0000000000..371b989516 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeState.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 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.settings.mediasource.selector.episode + +import androidx.compose.runtime.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeoutOrNull +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.platform.Context +import me.him188.ani.app.ui.settings.mediasource.BackgroundSearcher +import me.him188.ani.app.ui.settings.mediasource.launchCollectedInBackground +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation +import me.him188.ani.datasources.api.matcher.WebVideo +import me.him188.ani.utils.platform.Uuid +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds + +/** + * 测试 [me.him188.ani.datasources.api.matcher.WebVideoMatcher] + */ +@Stable +class SelectorEpisodeState( + private val itemState: State, + /** + * null means loading. Should finally have one. + */ + matchVideoConfigState: State, + /** + * null means loading. Should finally have one. + */ + private val webViewVideoExtractor: State, + private val engine: SelectorMediaSourceEngine, + backgroundScope: CoroutineScope, + context: Context, + flowDispatcher: CoroutineContext = Dispatchers.Default, +) { + private var _lastNonNullId: Uuid = Uuid.Companion.random() + val lastNonNullId by derivedStateOf { + itemState.value?.id?.also { _lastNonNullId = it } ?: _lastNonNullId + } + + val episodeName: String by derivedStateOf { itemState.value?.name ?: "" } + val episodeUrl: String by derivedStateOf { itemState.value?.playUrl ?: "" } + + /** + * 该页面的所有链接 + */ + val searcher = + BackgroundSearcher( + backgroundScope, + testDataState = derivedStateOf { itemState.value?.playUrl to webViewVideoExtractor.value }, + ) { (episodeUrl, extractor) -> + launchCollectedInBackground( + updateState = { SelectorEpisodeResult.InProgress(it) }, + ) { flow -> + try { + if (episodeUrl != null && extractor != null) { + withTimeoutOrNull(30.seconds) { // timeout considered as success + extractor.getVideoResourceUrl(context, episodeUrl) { + collect(it) + null + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SelectorEpisodeResult.UnknownError(e) + } + SelectorEpisodeResult.Success(flow) + } + } + + @Immutable + data class MatchResult( + val originalUrl: String, + val video: WebVideo?, + ) { + @Stable + fun isMatch() = video != null + } + + val isSearchingInProgress get() = searcher.isSearching + + /** + * 不断更新的匹配结果 + */ + val matchResults: Flow> by derivedStateOf { + val matchVideoConfig = matchVideoConfigState.value ?: return@derivedStateOf emptyFlow() + val searchResult = searcher.searchResult ?: return@derivedStateOf emptyFlow() + val flow = when (searchResult) { + is SelectorEpisodeResult.ApiError, + is SelectorEpisodeResult.UnknownError, + is SelectorEpisodeResult.InvalidConfig, + -> return@derivedStateOf emptyFlow() + + is SelectorEpisodeResult.InProgress -> searchResult.flow + is SelectorEpisodeResult.Success -> searchResult.flow + } + + flow.map { list -> + list.asSequence() + .map { original -> + MatchResult(original, engine.matchWebVideo(original, matchVideoConfig)) + } + .distinctBy { it.originalUrl } // O(n) extra space, O(1) time + .toMutableList() // single list instance construction + .apply { + // sort in-place for better performance + sortByDescending { it.isMatch() } // 优先展示匹配的 + } + }.flowOn(flowDispatcher) // possibly significant computation + } +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt index e3429197ca..561260dc0c 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt @@ -18,6 +18,7 @@ import me.him188.ani.app.ui.settings.mediasource.RefreshResult import me.him188.ani.app.ui.settings.mediasource.rss.test.MatchTag import me.him188.ani.app.ui.settings.mediasource.rss.test.buildMatchTags import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.platform.Uuid import me.him188.ani.utils.xml.Element @Immutable @@ -50,6 +51,8 @@ class SelectorTestEpisodePresentation( val tags: List, val origin: Element?, ) { + val id: Uuid = Uuid.random() + companion object { fun compute( info: WebSearchEpisodeInfo, diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt index a323780366..5b5edcd445 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import me.him188.ani.app.ui.foundation.layout.cardHorizontalPadding @@ -36,10 +37,10 @@ import me.him188.ani.app.ui.settings.mediasource.rss.test.OutlinedMatchTag @Composable fun SelectorTestEpisodeListGrid( episodes: List, - onClick: (SelectorTestEpisodePresentation) -> Unit, modifier: Modifier = Modifier, state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), contentPadding: PaddingValues = PaddingValues(0.dp), + eachItem: @Composable (SelectorTestEpisodePresentation) -> Unit, ) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Adaptive(300.dp), @@ -51,19 +52,33 @@ fun SelectorTestEpisodeListGrid( ) { for (episode in episodes) { item(key = episode) { - EpisodeCard( - title = { Text(episode.name) }, - { onClick(episode) }, - ) { - episode.tags.forEach { - OutlinedMatchTag(it) - } - } + eachItem(episode) } } } } +@Stable +object SelectorTestEpisodeListGridDefaults { + @Composable + fun EpisodeCard( + episode: SelectorTestEpisodePresentation, + onClick: () -> Unit, + modifier: Modifier = Modifier, + ) { + EpisodeCard( + title = { Text(episode.name) }, + { onClick() }, + modifier, + ) { + episode.tags.forEach { + OutlinedMatchTag(it) + } + } + } + +} + @Composable private fun EpisodeCard( title: @Composable () -> Unit, diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt index 44d5799f97..767c3765a1 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt @@ -10,6 +10,8 @@ package me.him188.ani.app.ui.settings.mediasource.selector.test import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -22,14 +24,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import me.him188.ani.app.ui.foundation.interaction.nestedScrollWorkaround +import me.him188.ani.app.ui.foundation.layout.ConnectedScrollState +import me.him188.ani.app.ui.foundation.layout.PaddingValuesSides import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding import me.him188.ani.app.ui.foundation.layout.connectedScroll +import me.him188.ani.app.ui.foundation.layout.only import me.him188.ani.app.ui.foundation.layout.rememberConnectedScrollState import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults import me.him188.ani.app.ui.foundation.widgets.FastLinearProgressIndicator @@ -41,23 +46,24 @@ import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigura * 测试数据源. 编辑 */ @Composable -fun SelectorTestPane( +fun SharedTransitionScope.SelectorTestPane( state: SelectorTestState, onViewEpisode: (SelectorTestEpisodePresentation) -> Unit, + animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + connectedScrollState: ConnectedScrollState = rememberConnectedScrollState(), ) { - LaunchedEffect(state) { - state.subjectSearcher.observeChangeLoop() - } - LaunchedEffect(state) { - state.episodeListSearcher.observeChangeLoop() - } - val verticalSpacing = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding - Column(modifier.padding(contentPadding)) { - val connectedScrollState = rememberConnectedScrollState() - Column(Modifier.connectedScroll(connectedScrollState)) { + Column( + modifier + .padding(contentPadding.only(PaddingValuesSides.Top)) + .clipToBounds(), + ) { + Column( + Modifier.connectedScroll(connectedScrollState) + .padding(contentPadding.only(PaddingValuesSides.Horizontal)), + ) { Text( "测试数据源", style = MaterialTheme.typography.headlineSmall, @@ -104,6 +110,7 @@ fun SelectorTestPane( AnimatedContent( state.selectedSubject, + Modifier.padding(contentPadding.only(PaddingValuesSides.Horizontal)), transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, ) { selectedSubjectIndex -> if (selectedSubjectIndex != null) { @@ -132,12 +139,18 @@ fun SelectorTestPane( val staggeredGridState = rememberLazyStaggeredGridState() SelectorTestEpisodeListGrid( result.episodes, - onClick = onViewEpisode, modifier = Modifier.padding(top = verticalSpacing - 8.dp) .nestedScroll(connectedScrollState.nestedScrollConnection) .nestedScrollWorkaround(staggeredGridState, connectedScrollState), state = staggeredGridState, - ) + contentPadding = contentPadding.only(PaddingValuesSides.Horizontal + PaddingValuesSides.Bottom), + ) { episode -> + SelectorTestEpisodeListGridDefaults.EpisodeCard( + episode, + { onViewEpisode(episode) }, + Modifier.sharedBounds(rememberSharedContentState(episode.id), animatedVisibilityScope), + ) + } } } } diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt index 5b341478b9..6f54bfc5ae 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt @@ -46,7 +46,7 @@ sealed class SelectorTestSearchSubjectResult : RefreshResult { } @Immutable -class SelectorTestSubjectPresentation( +data class SelectorTestSubjectPresentation( val name: String, val subjectDetailsPageUrl: String, val origin: Element?, diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt index 379281e33f..cb3ab1f64f 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlinx.coroutines.CoroutineScope import me.him188.ani.app.data.models.ApiResponse @@ -165,22 +164,6 @@ class SelectorTestState( } } - // lateinit var episodeNavController: NavHostController - var viewingItem by mutableStateOf(null) - private set - - fun viewEpisode( - episode: SelectorTestEpisodePresentation, - ) { - this.viewingItem = episode -// episodeNavController.navigate("details") - } - - fun stopViewing() { - this.viewingItem = null -// episodeNavController.navigate("list") - } - private fun convertEpisodeResult( res: ApiResponse, config: SelectorSearchConfig,