diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt index 6d5214ec8c..4fbcad4f2f 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt @@ -170,6 +170,7 @@ class SelectorMediaSource( SelectorSearchQuery( subjectName = name, episodeSort = query.episodeSort, + allSubjectNames = query.subjectNames, ), mediaSourceId, ).getOrThrow().asFlow() 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 27c3ae8ebb..33b1f52253 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 @@ -47,11 +47,12 @@ import me.him188.ani.utils.xml.Xml data class SelectorSearchQuery( val subjectName: String, + val allSubjectNames: Set, val episodeSort: EpisodeSort, ) fun SelectorSearchQuery.toFilterContext() = MediaListFilterContext( - subjectNames = setOf(subjectName), + subjectNames = allSubjectNames, episodeSort = episodeSort, ) @@ -117,7 +118,16 @@ abstract class SelectorMediaSourceEngine { suspend fun searchEpisodes( subjectDetailsPageUrl: String, - ): ApiResponse = doHttpGet(subjectDetailsPageUrl) + ): ApiResponse = try { + doHttpGet(subjectDetailsPageUrl) + } catch (e: ClientRequestException) { + e.response.status.let { + if (it == HttpStatusCode.NotFound) { + return ApiResponse.success(null) + } + throw e + } + } /** * @return `null` if config is invalid @@ -231,7 +241,7 @@ internal fun SelectorSearchConfig.createFiltersForSubject() = buildList { } internal fun SelectorSearchConfig.createFiltersForEpisode() = buildList { - addAll(createFiltersForSubject()) + // 不使用 filterBySubjectName, 因为 web 的剧集名称通常为 "第x集", 不包含 subject if (filterByEpisodeSort) add(MediaListFilters.ContainsEpisodeSort) } 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 7209f6cfc5..46af844eb3 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 @@ -11,6 +11,7 @@ package me.him188.ani.app.data.source.media.source.web import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import io.ktor.http.URLBuilder import kotlinx.serialization.Serializable import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened @@ -59,7 +60,24 @@ data class SelectorSearchConfig( val matchVideo: MatchVideoConfig = MatchVideoConfig(), ) { // TODO: add Engine version capabilities val baseUrl by lazy(LazyThreadSafetyMode.PUBLICATION) { - searchUrl.substringBeforeLast("/") + kotlin.runCatching { + URLBuilder(searchUrl).apply { + pathSegments = emptyList() + parameters.clear() + }.toString() + }.getOrElse { + val schemaIndex = searchUrl.indexOf("//") + if (schemaIndex == -1) { + searchUrl.removeSuffix("/") + } else { + val slashIndex = searchUrl.indexOf('/', startIndex = schemaIndex + 2) + if (slashIndex == -1) { + searchUrl.removeSuffix("/") + } else { + searchUrl.substring(0, slashIndex) + } + } + } } @Serializable 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 ee2a2beb62..1da20f71eb 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 @@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor @@ -47,13 +46,13 @@ 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.theme.AniThemeDefaults 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.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.episode.SelectorTestAndEpisodePane import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation @@ -123,20 +122,21 @@ fun EditSelectorMediaSourcePage( navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), windowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, ) { - val nestedNav = rememberNavController() val episodePaneLayout = SelectorEpisodePaneLayout.calculate(navigator.scaffoldValue) val testConnectedScrollState = rememberConnectedScrollState() Scaffold( modifier, topBar = { WindowDragArea { - if (episodePaneLayout.showTopBarInScaffold) { - SelectorEpisodePaneDefaults.TopAppBar(state.episodeState) + val viewingItem = state.viewingItem + if (viewingItem != null && episodePaneLayout.showTopBarInScaffold) { + SelectorEpisodePaneDefaults.TopAppBar( + state.episodeState, + windowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ) } else { TopAppBar( title = { - nestedNav.navigate(SelectorEpisodePaneRoutes.EPISODE) - val viewingItem = state.viewingItem if (viewingItem != null) { Text(viewingItem.name) } else { @@ -152,6 +152,7 @@ fun EditSelectorMediaSourcePage( } }, windowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + colors = AniThemeDefaults.topAppBarColors(), ) } } @@ -188,12 +189,11 @@ fun EditSelectorMediaSourcePage( detailPane = { AnimatedPane1 { SelectorTestAndEpisodePane( - state, - episodePaneLayout, - Modifier.consumeWindowInsets(paddingValues), - nestedNav, - paddingValues, - testConnectedScrollState, + state = state, + layout = episodePaneLayout, + modifier = Modifier.consumeWindowInsets(paddingValues), + contentPadding = paddingValues, + testConnectedScrollState = testConnectedScrollState, ) } }, 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 4127cada0d..60958f6aaf 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 @@ -259,6 +259,7 @@ private fun SubjectChannelSelectionButtonRow( @Composable fun Btn( id: SelectorFormatId, index: Int, + enabled: Boolean = true, label: @Composable () -> Unit, ) { SegmentedButton( @@ -267,11 +268,15 @@ private fun SubjectChannelSelectionButtonRow( SegmentedButtonDefaults.itemShape(index, state.allChannelFormats.size), icon = { SegmentedButtonDefaults.Icon(state.channelFormatId == id) }, label = label, + enabled = enabled, ) } for ((index, selectorChannelFormat) in state.allChannelFormats.withIndex()) { - Btn(selectorChannelFormat.id, index) { + Btn( + selectorChannelFormat.id, index, + enabled = selectorChannelFormat == SelectorChannelFormatNoChannel, + ) { Text( when (selectorChannelFormat) { // type-safe to handle all formats SelectorChannelFormatNoChannel -> "不区分线路" 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 39fa373c61..903b3a873b 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 @@ -159,9 +159,13 @@ fun SelectorTestAndEpisodePane( LaunchedEffect(state) { snapshotFlow { state.viewingItem }.collect { value -> if (value == null) { - nestedNav.navigate(SelectorEpisodePaneRoutes.TEST) + nestedNav.navigate(SelectorEpisodePaneRoutes.TEST) { + launchSingleTop = true + } } else { - nestedNav.navigate(SelectorEpisodePaneRoutes.EPISODE) + nestedNav.navigate(SelectorEpisodePaneRoutes.EPISODE) { + launchSingleTop = true + } } } } 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 index fc0099d71d..c59fa5e6f8 100644 --- 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 @@ -9,10 +9,21 @@ package me.him188.ani.app.ui.settings.mediasource.selector.episode -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowOutward -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -20,6 +31,8 @@ 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.theme.AniThemeDefaults +import me.him188.ani.app.ui.foundation.widgets.LocalToaster 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 @@ -41,7 +54,7 @@ object SelectorEpisodePaneDefaults { title = { Row( verticalAlignment = Alignment.Companion.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text(state.episodeName, style = LocalTextStyle.current) RefreshIndicationDefaults.RefreshIconButton( @@ -55,11 +68,22 @@ object SelectorEpisodePaneDefaults { }, actions = { val uriHandler = LocalUriHandler.current - IconButton({ uriHandler.openUri(state.episodeUrl) }) { - Icon(Icons.Rounded.ArrowOutward, "打开原始链接 ${state.episodeName}") + val toaster = LocalToaster.current + if (state.episodeUrl.isNotBlank() && state.episodeUrl.startsWith("http")) { + IconButton( + { + try { + uriHandler.openUri(state.episodeUrl) + } catch (e: Throwable) { + toaster.toast("无法打开链接") + } + }, + ) { + Icon(Icons.Rounded.ArrowOutward, "打开原始链接 ${state.episodeName}") + } } }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + colors = AniThemeDefaults.topAppBarColors(), modifier = modifier, windowInsets = windowInsets, ) 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 cb3ab1f64f..c7a46a4cf0 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 @@ -40,7 +40,11 @@ class SelectorTestState( if (searchKeyword.isBlank() || sort.isBlank()) { null } else { - SelectorSearchQuery(subjectName = searchKeyword, episodeSort = EpisodeSort(sort)) + SelectorSearchQuery( + subjectName = searchKeyword, + episodeSort = EpisodeSort(sort), + allSubjectNames = setOf(searchKeyword), + ) } } @@ -126,11 +130,15 @@ class SelectorTestState( null } else { try { - engine.searchEpisodes( - selectedSubject.subjectDetailsPageUrl, + Result.success( + engine.searchEpisodes( + selectedSubject.subjectDetailsPageUrl, + ), ) } catch (e: CancellationException) { throw e + } catch (e: Throwable) { + Result.failure(e) } } } @@ -155,23 +163,28 @@ class SelectorTestState( } else -> { - convertEpisodeResult( - subjectDetailsPageDocument, - searchConfig, - queryState, + subjectDetailsPageDocument.fold( + onSuccess = { document -> + convertEpisodeResult(document, searchConfig, queryState) + }, + onFailure = { + SelectorTestEpisodeListResult.UnknownError(it) + }, ) } } } private fun convertEpisodeResult( - res: ApiResponse, + res: ApiResponse, config: SelectorSearchConfig, query: SelectorSearchQuery, ): SelectorTestEpisodeListResult { return res.fold( onSuccess = { document -> try { + document ?: return SelectorTestEpisodeListResult.Success(null, emptyList()) + val episodeList = engine.selectEpisodes(document, config) ?: return SelectorTestEpisodeListResult.InvalidConfig SelectorTestEpisodeListResult.Success(