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 33b1f52253..73374a2ece 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 @@ -34,6 +34,7 @@ import me.him188.ani.datasources.api.EpisodeSort import me.him188.ani.datasources.api.MediaProperties import me.him188.ani.datasources.api.SubtitleKind import me.him188.ani.datasources.api.matcher.WebVideo +import me.him188.ani.datasources.api.matcher.WebVideoMatcher import me.him188.ani.datasources.api.source.MediaSourceKind import me.him188.ani.datasources.api.source.MediaSourceLocation import me.him188.ani.datasources.api.topic.EpisodeRange @@ -75,7 +76,9 @@ abstract class SelectorMediaSourceEngine { * `null` means 404 */ val document: Document?, - ) + ) { + override fun toString(): String = "SearchSubjectResult(url=$url, document=${document.toString().length}...)" + } suspend fun searchSubjects( searchUrl: String, @@ -200,23 +203,39 @@ abstract class SelectorMediaSourceEngine { } } - fun matchWebVideo(url: String, searchConfig: SelectorSearchConfig.MatchVideoConfig): WebVideo? { - val result = searchConfig.matchVideoUrlRegex?.find(url) ?: return null + fun shouldLoadPage(url: String, config: SelectorSearchConfig.MatchVideoConfig): Boolean { + if (config.enableNestedUrl) { + config.matchNestedUrlRegex?.find(url)?.let { + return true + } + } + return false + } + + fun matchWebVideo(url: String, searchConfig: SelectorSearchConfig.MatchVideoConfig): WebVideoMatcher.MatchResult { + if (shouldLoadPage(url, searchConfig)) { + return WebVideoMatcher.MatchResult.LoadPage + } + + val result = searchConfig.matchVideoUrlRegex?.find(url) ?: return WebVideoMatcher.MatchResult.Continue val videoUrl = try { result.groups["v"]?.value ?: url } catch (_: IllegalArgumentException) { // no group url } - return WebVideo( - videoUrl, - mapOf( - "User-Agent" to searchConfig.addHeadersToVideo.userAgent, - "Referer" to searchConfig.addHeadersToVideo.referer, - "Sec-Ch-Ua-Mobile" to "?0", - "Sec-Ch-Ua-Platform" to "macOS", - "Sec-Fetch-Dest" to "video", - "Sec-Fetch-Mode" to "no-cors", - "Sec-Fetch-Site" to "cross-site", + + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + videoUrl, + mapOf( + "User-Agent" to searchConfig.addHeadersToVideo.userAgent, + "Referer" to searchConfig.addHeadersToVideo.referer, + "Sec-Ch-Ua-Mobile" to "?0", + "Sec-Ch-Ua-Platform" to "macOS", + "Sec-Fetch-Dest" to "video", + "Sec-Fetch-Mode" to "no-cors", + "Sec-Fetch-Site" to "cross-site", + ), ), ) } 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 46af844eb3..bc68dcf409 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 @@ -21,6 +21,7 @@ import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormat import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA import me.him188.ani.app.data.source.media.source.web.format.parseOrNull +import org.intellij.lang.annotations.Language @Immutable @Serializable @@ -81,11 +82,18 @@ data class SelectorSearchConfig( } @Serializable + @Suppress("RegExpRedundantEscape") data class MatchVideoConfig( - @Suppress("RegExpRedundantEscape") - val matchVideoUrl: String = """^(?http(s)?:\/\/(?!.*http(s)?:\/\/).+((\.mp4)|(\.mkv)|(m3u8)).*(\?.+)?)""", + val enableNestedUrl: Boolean = true, + @Language("regexp") + val matchNestedUrl: String = """^.+(m3u8|vip|xigua\.php).+\?""", + @Language("regexp") + val matchVideoUrl: String = """(^http(s)?:\/\/(?!.*http(s)?:\/\/).+((\.mp4)|(\.mkv)|(m3u8)).*(\?.+)?)|(akamaized)|(bilivideo.com)""", val addHeadersToVideo: VideoHeaders = VideoHeaders(), ) { + val matchNestedUrlRegex by lazy { + Regex.parseOrNull(matchNestedUrl) + } val matchVideoUrlRegex by lazy { Regex.parseOrNull(matchVideoUrl) } diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt index 8779837906..e7585da7e6 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt @@ -17,6 +17,7 @@ import me.him188.ani.datasources.api.EpisodeSort import me.him188.ani.utils.xml.Element import me.him188.ani.utils.xml.QueryParser import me.him188.ani.utils.xml.parseSelectorOrNull +import org.intellij.lang.annotations.Language /** * 决定如何匹配线路和剧集 @@ -43,7 +44,8 @@ sealed class SelectorChannelFormat(override va return entries.find { it.id == id } } - const val DEFAULT_MATCH_EPISODE_SORT_FROM_NAME = "第(?.+)(话|集)" + @Language("regexp") + const val DEFAULT_MATCH_EPISODE_SORT_FROM_NAME = """第\s*(?.+)\s*[话集]""" fun isPossiblyMovie(title: String): Boolean { return ("简" in title || "繁" in title) @@ -71,13 +73,17 @@ data object SelectorChannelFormatFlattened : @Immutable @Serializable data class Config( + @Language("css") val selectChannels: String = "body > div.box-width.cor5 > div.anthology.wow.fadeInUp.animated > div.anthology-tab.nav-swiper.b-b.br div.swiper-wrapper a.swiper-slide", + @Language("css") val selectLists: String = "body > div.box-width.cor5 > div.anthology.wow.fadeInUp.animated > a", + @Language("css") val selectElements: String = "a", + @Language("regexp") val matchEpisodeSortFromName: String = DEFAULT_MATCH_EPISODE_SORT_FROM_NAME, ) : SelectorFormatConfig { override fun isValid(): Boolean { - return selectChannels.isNotBlank() && selectLists.isNotBlank() && selectElements.isNotBlank() + return selectChannels.isNotBlank() && selectLists.isNotBlank() && selectElements.isNotBlank() && matchEpisodeSortFromName.isNotBlank() } } @@ -130,15 +136,13 @@ data object SelectorChannelFormatNoChannel : @Immutable @Serializable data class Config( - val selectEpisodes: String = "", + @Language("css") + val selectEpisodes: String = "#glist-1 > div.module-blocklist.scroll-box.scroll-box-y > div > a", + @Language("regexp") val matchEpisodeSortFromName: String = DEFAULT_MATCH_EPISODE_SORT_FROM_NAME, ) : SelectorFormatConfig { val matchEpisodeSortFromNameRegex by lazy(LazyThreadSafetyMode.PUBLICATION) { - try { - matchEpisodeSortFromName.toRegex() - } catch (e: Exception) { - null - } + Regex.parseOrNull(matchEpisodeSortFromName) } override fun isValid(): Boolean { diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt index a8b460eea5..e3e93604b8 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt @@ -28,7 +28,6 @@ interface SelectorFormat { @Immutable @JvmInline value class SelectorFormatId( - // in case we want to change type val value: String, ) diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt index 5e28e65a09..ab90e9a4dc 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt @@ -15,21 +15,26 @@ import me.him188.ani.app.data.source.media.source.web.WebSearchSubjectInfo import me.him188.ani.utils.xml.Element import me.him188.ani.utils.xml.QueryParser import me.him188.ani.utils.xml.parseSelectorOrNull +import org.intellij.lang.annotations.Language /** * 决定如何匹配条目 */ sealed class SelectorSubjectFormat(override val id: SelectorFormatId) : SelectorFormat { // 方便改名 + + /** + * `null` means invalid config + */ abstract fun select( document: Element, baseUrl: String, config: Config, - ): List + ): List? companion object { val entries by lazy { // 必须 lazy, 否则可能获取到 null - listOf(SelectorSubjectFormatA) + listOf(checkNotNull(SelectorSubjectFormatA)) // checkNotNull is needed to be fail-fast } fun findById(id: SelectorFormatId): SelectorSubjectFormat<*>? { @@ -46,7 +51,8 @@ data object SelectorSubjectFormatA : SelectorSubjectFormat { - val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return emptyList() + ): List? { + val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return null return document.select(selectLists).map { a -> val name = a.attr("title").takeIf { it.isNotBlank() } ?: a.text() val href = a.attr("href") 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 a37144e940..04dfe3cb47 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 @@ -11,6 +11,7 @@ package me.him188.ani.app.ui.settings.mediasource.selector.episode import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -35,13 +36,16 @@ import kotlin.coroutines.EmptyCoroutineContext @Preview fun PreviewSelectorEpisodePaneCompact() = ProvideFoundationCompositionLocalsForPreview { Surface { + val state = rememberTestEditSelectorMediaSourceState( + SelectorSearchConfig.MatchVideoConfig(), + ) SelectorTestAndEpisodePane( - state = rememberTestEditSelectorMediaSourceState( - TestSelectorTestEpisodePresentations[0], - SelectorSearchConfig.MatchVideoConfig(), - ), + state = state, layout = SelectorEpisodePaneLayout.Compact, ) + SideEffect { + state.viewEpisode(TestSelectorTestEpisodePresentations[0]) + } } } @@ -112,7 +116,6 @@ 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") @@ -134,10 +137,6 @@ internal fun rememberTestEditSelectorMediaSourceState( 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/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt index 1da20f71eb..6f5e6f2d36 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,6 +34,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor @@ -53,6 +55,7 @@ import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigSta 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 @@ -74,20 +77,27 @@ class EditSelectorMediaSourcePageState( private val viewingItemState = mutableStateOf(null) - // lateinit var episodeNavController: NavHostController var viewingItem by viewingItemState private set + lateinit var episodeNavController: NavHostController + internal set // set from ui + fun viewEpisode( episode: SelectorTestEpisodePresentation, ) { this.viewingItem = episode -// episodeNavController.navigate("details") + if (episodeNavController.currentDestination?.hasRoute() != true) { + episodeNavController.navigate(SelectorEpisodePaneRoutes.EPISODE) + } + episodeNavController.navigate(SelectorEpisodePaneRoutes.EPISODE) } fun stopViewing() { this.viewingItem = null -// episodeNavController.navigate("list") + if (episodeNavController.currentDestination?.hasRoute() != true) { + episodeNavController.navigate(SelectorEpisodePaneRoutes.TEST) + } } diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt index bdf2ca2054..8b564891a2 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt @@ -189,6 +189,16 @@ class SelectorConfigState( matchVideoUrl.isBlank() || !isValidRegex(matchVideoUrl) } + var enableNestedUrl by prop( + { it.enableNestedUrl }, { copy(enableNestedUrl = it) }, + ) + var matchNestedUrl by prop( + { it.matchNestedUrl }, { copy(matchNestedUrl = it) }, + ) + val matchNestedUrlIsError by derivedStateOf { + matchNestedUrl.isBlank() || !isValidRegex(matchNestedUrl) + } + val videoHeaders = HeadersConfig() @Stable 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 5f56677fbf..5faa425f19 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 @@ -9,22 +9,32 @@ package me.him188.ani.app.ui.settings.mediasource.selector.edit -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color.Companion.Transparent import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding +/** + * @see me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments + */ object SelectorConfigurationDefaults { const val STEP_NAME_1 = "步骤 1:搜索条目" const val STEP_NAME_2 = "步骤 2:搜索剧集" @@ -47,8 +57,35 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, ) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + Column(modifier) { val matchVideoConfig = state.matchVideoConfig + ListItem( + headlineContent = { Text("启用嵌套链接") }, + Modifier + .padding(bottom = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) + .clickable { matchVideoConfig.enableNestedUrl = !matchVideoConfig.enableNestedUrl }, + supportingContent = { Text("当遇到匹配的链接时,跳转到该链接,并继续匹配视频链接。支持任意次数嵌套") }, + trailingContent = { + Switch(matchVideoConfig.enableNestedUrl, { matchVideoConfig.enableNestedUrl = it }) + }, + colors = ListItemDefaults.colors(containerColor = Transparent), + ) + + AnimatedVisibility(visible = matchVideoConfig.enableNestedUrl) { + OutlinedTextField( + matchVideoConfig.matchNestedUrl, { matchVideoConfig.matchNestedUrl = it }, + Modifier + .fillMaxWidth() + .moveFocusOnEnter() + .padding(bottom = verticalSpacing), + label = { Text("匹配嵌套链接") }, + supportingText = { Text("从播放页面中加载的所有资源链接中匹配出需要跳转进入的链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + isError = matchVideoConfig.matchNestedUrlIsError, + ) + } + OutlinedTextField( matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, Modifier.fillMaxWidth().moveFocusOnEnter(), 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 60958f6aaf..95da9f20a2 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 @@ -176,7 +176,10 @@ internal fun SelectorConfigurationPane( } } - Column(Modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + Column( + Modifier, + verticalArrangement = Arrangement.spacedBy((verticalSpacing - 16.dp).coerceAtLeast(0.dp)), + ) { ListItem( headlineContent = { Text("使用条目名称过滤") }, Modifier.focusable(false).clickable { state.filterBySubjectName = !state.filterBySubjectName }, @@ -280,7 +283,7 @@ private fun SubjectChannelSelectionButtonRow( Text( when (selectorChannelFormat) { // type-safe to handle all formats SelectorChannelFormatNoChannel -> "不区分线路" - SelectorChannelFormatFlattened -> "多线路扁平" + SelectorChannelFormatFlattened -> "(其他暂未支持)" }, softWrap = false, ) 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 903b3a873b..33c2b4532e 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 @@ -14,6 +14,7 @@ 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.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -30,6 +31,7 @@ import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.Card import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors @@ -43,11 +45,9 @@ 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.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.Color @@ -57,10 +57,10 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import me.him188.ani.app.ui.foundation.layout.ConnectedScrollState import me.him188.ani.app.ui.foundation.layout.paneHorizontalPadding @@ -77,11 +77,13 @@ 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 nestedNav = rememberNavController() + state.episodeNavController = nestedNav + SharedTransitionScope { transitionModifier -> NavHost(nestedNav, initialRoute, modifier.then(transitionModifier)) { composable { @@ -154,21 +156,6 @@ fun SelectorTestAndEpisodePane( } } } - - // 切换 item 时自动 nav - LaunchedEffect(state) { - snapshotFlow { state.viewingItem }.collect { value -> - if (value == null) { - nestedNav.navigate(SelectorEpisodePaneRoutes.TEST) { - launchSingleTop = true - } - } else { - nestedNav.navigate(SelectorEpisodePaneRoutes.EPISODE) { - launchSingleTop = true - } - } - } - } } } @@ -190,7 +177,7 @@ fun SelectorEpisodePaneContent( ) } - val list by state.matchResults.collectAsStateWithLifecycle(emptyList()) + val list by state.rawMatchResults.collectAsStateWithLifecycle(emptyList()) Row( Modifier.padding( @@ -201,13 +188,13 @@ fun SelectorEpisodePaneContent( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - val matchedSize by remember { + val matchedVideoSize by remember { derivedStateOf { - list.count { it.isMatch() } + list.count { it.isMatchedVideo() } } } ProvideTextStyle(MaterialTheme.typography.titleMedium) { - when (matchedSize) { + when (matchedVideoSize) { 0 -> { Icon( Icons.Rounded.PriorityHigh, @@ -223,7 +210,7 @@ fun SelectorEpisodePaneContent( contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) - Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedSize 个链接") + Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedVideoSize 个链接") } else -> { @@ -232,12 +219,44 @@ fun SelectorEpisodePaneContent( contentDescription = null, tint = Color.Yellow.compositeOver(MaterialTheme.colorScheme.error), ) - Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedSize 个链接。为了更好的稳定性,建议调整规则,匹配到正好一个链接") + Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedVideoSize 个链接。为了更好的稳定性,建议调整规则,匹配到正好一个链接") } } } } + FlowRow( + Modifier.padding(horizontal = horizontalPadding).padding(bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + FilterChip( + selected = state.hideImages, + { state.hideImages = !state.hideImages }, + label = { Text("隐藏图片") }, + leadingIcon = { if (state.hideImages) Icon(Icons.Rounded.Check, null) }, + ) + FilterChip( + selected = state.hideCss, + { state.hideCss = !state.hideCss }, + label = { Text("隐藏 CSS/字体") }, + leadingIcon = { if (state.hideCss) Icon(Icons.Rounded.Check, null) }, + ) + FilterChip( + selected = state.hideScripts, + { state.hideScripts = !state.hideScripts }, + label = { Text("隐藏 JS/WASM") }, + leadingIcon = { if (state.hideScripts) Icon(Icons.Rounded.Check, null) }, + ) + FilterChip( + selected = state.hideData, + { state.hideData = !state.hideData }, + label = { Text("隐藏 data") }, + leadingIcon = { if (state.hideData) Icon(Icons.Rounded.Check, null) }, + ) + } + + val filteredList by state.filteredResults.collectAsStateWithLifecycle(emptyList()) + LazyColumn( contentPadding = PaddingValues( bottom = itemSpacing, @@ -247,16 +266,16 @@ fun SelectorEpisodePaneContent( // 上面总是有个东西可以保证当后面加载到匹配 (置顶) 时, 看到的是那个被匹配到的 item { Spacer(Modifier.height(1.dp)) } - for (matchResult in list) { - item(key = matchResult.originalUrl) { - val isMatch = matchResult.isMatch() + for (matchResult in filteredList) { + item(key = matchResult.key) { val toaster = LocalToaster.current val clipboard = LocalClipboardManager.current ListItem( headlineContent = { Text( matchResult.originalUrl, - color = if (isMatch) MaterialTheme.colorScheme.primary else Color.Unspecified, + color = if (matchResult.highlight) + MaterialTheme.colorScheme.primary else Color.Unspecified, ) }, Modifier.animateItem() @@ -265,19 +284,28 @@ fun SelectorEpisodePaneContent( toaster.toast("已复制") }, supportingContent = { - matchResult.video?.m3u8Url?.let { - if (it != matchResult.originalUrl) { - Text("将实际播放:$it") + val m3u8 = matchResult.video?.m3u8Url + when { + m3u8 != null && m3u8 != matchResult.originalUrl -> { + Text("将实际播放:${m3u8}") + } + + matchResult.webUrl.didLoadNestedPage -> { + Text("嵌套链接") } } }, colors = itemColors, leadingContent = { Column(horizontalAlignment = Alignment.CenterHorizontally) { - if (isMatch) { - Icon(Icons.Rounded.Check, "匹配", tint = MaterialTheme.colorScheme.primary) - } else { - Icon(Icons.Rounded.Close, "未匹配") + when { + matchResult.highlight -> { + Icon(Icons.Rounded.Check, "匹配", tint = MaterialTheme.colorScheme.primary) + } + + else -> { + Icon(Icons.Rounded.Close, "未匹配") + } } } }, @@ -292,9 +320,11 @@ fun SelectorEpisodePaneContent( @Serializable sealed class SelectorEpisodePaneRoutes { @Serializable + @SerialName("TEST") data object TEST : SelectorEpisodePaneRoutes() @Serializable + @SerialName("EPISODE") // remove package data object EPISODE : SelectorEpisodePaneRoutes() } 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 index c63dc53746..765614d5e9 100644 --- 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 @@ -16,14 +16,19 @@ import me.him188.ani.app.ui.settings.mediasource.RefreshResult sealed class SelectorEpisodeResult : RefreshResult { data class InProgress( - val flow: StateFlow>, + val flow: StateFlow>, ) : SelectorEpisodeResult(), RefreshResult.InProgress data class Success( - val flow: StateFlow>, + 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 +} + +data class SelectorTestWebUrl( + val url: String, + val didLoadNestedPage: Boolean, +) 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 index 371b989516..e006531b6d 100644 --- 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 @@ -10,6 +10,7 @@ package me.him188.ani.app.ui.settings.mediasource.selector.episode import androidx.compose.runtime.* +import io.ktor.http.Url import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -25,6 +26,7 @@ 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.datasources.api.matcher.videoOrNull import me.him188.ani.utils.platform.Uuid import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException @@ -49,7 +51,7 @@ class SelectorEpisodeState( context: Context, flowDispatcher: CoroutineContext = Dispatchers.Default, ) { - private var _lastNonNullId: Uuid = Uuid.Companion.random() + private var _lastNonNullId: Uuid = Uuid.Companion.random() // 当取消选择时, 仍然需要保持 ID, 才能有 container transform 动画 val lastNonNullId by derivedStateOf { itemState.value?.id?.also { _lastNonNullId = it } ?: _lastNonNullId } @@ -63,17 +65,25 @@ class SelectorEpisodeState( val searcher = BackgroundSearcher( backgroundScope, - testDataState = derivedStateOf { itemState.value?.playUrl to webViewVideoExtractor.value }, - ) { (episodeUrl, extractor) -> - launchCollectedInBackground( + testDataState = derivedStateOf { + Triple(itemState.value?.playUrl, webViewVideoExtractor.value, matchVideoConfigState.value) + }, + ) { (episodeUrl, extractor, config) -> + launchCollectedInBackground( updateState = { SelectorEpisodeResult.InProgress(it) }, ) { flow -> try { - if (episodeUrl != null && extractor != null) { + if (episodeUrl != null && extractor != null && config != null) { withTimeoutOrNull(30.seconds) { // timeout considered as success extractor.getVideoResourceUrl(context, episodeUrl) { - collect(it) - null + val shouldLoadPage = engine.shouldLoadPage(it, config) + collect(SelectorTestWebUrl(it, didLoadNestedPage = shouldLoadPage)) + + if (shouldLoadPage) { + WebViewVideoExtractor.Instruction.LoadPage + } else { + WebViewVideoExtractor.Instruction.Continue + } } } } @@ -87,12 +97,21 @@ class SelectorEpisodeState( } @Immutable + @Stable data class MatchResult( - val originalUrl: String, + val webUrl: SelectorTestWebUrl, val video: WebVideo?, ) { + val originalUrl get() = webUrl.url + val parsedUrl = runCatching { Url(originalUrl) }.getOrNull() + + // ui + val key get() = originalUrl + @Stable - fun isMatch() = video != null + fun isMatchedVideo() = video != null + + val highlight get() = isMatchedVideo() || webUrl.didLoadNestedPage } val isSearchingInProgress get() = searcher.isSearching @@ -100,7 +119,7 @@ class SelectorEpisodeState( /** * 不断更新的匹配结果 */ - val matchResults: Flow> by derivedStateOf { + val rawMatchResults: Flow> by derivedStateOf { val matchVideoConfig = matchVideoConfigState.value ?: return@derivedStateOf emptyFlow() val searchResult = searcher.searchResult ?: return@derivedStateOf emptyFlow() val flow = when (searchResult) { @@ -116,14 +135,65 @@ class SelectorEpisodeState( flow.map { list -> list.asSequence() .map { original -> - MatchResult(original, engine.matchWebVideo(original, matchVideoConfig)) + MatchResult(original, engine.matchWebVideo(original.url, matchVideoConfig).videoOrNull) } - .distinctBy { it.originalUrl } // O(n) extra space, O(1) time + .distinctBy { it.key } // O(n) extra space, O(1) time .toMutableList() // single list instance construction .apply { // sort in-place for better performance - sortByDescending { it.isMatch() } // 优先展示匹配的 + sortByDescending { it.isMatchedVideo() } // 优先展示匹配的 } }.flowOn(flowDispatcher) // possibly significant computation } -} \ No newline at end of file + + val filteredResults: Flow> by derivedStateOf { + // read states in UI thread + val hideImages = hideImages + val hideCss = hideCss + val hideScripts = hideScripts + val hideData = hideData + + rawMatchResults.map { list -> + // In background + list.filter { shouldIncludeUrl(it, hideImages, hideCss, hideScripts, hideData) } + }.flowOn(flowDispatcher) + } + + var hideImages: Boolean by mutableStateOf(true) + var hideCss: Boolean by mutableStateOf(true) + var hideScripts: Boolean by mutableStateOf(true) + var hideData: Boolean by mutableStateOf(true) + + companion object { + private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "ico", "svg") + private val cssExtensions = setOf("css", "ttf", "woff2") + private val scriptsExtensions = setOf("js", "wasm") + + private fun shouldIncludeUrl( + result: MatchResult, + hideImages: Boolean, + hideCss: Boolean, + hideScripts: Boolean, + hideData: Boolean, + ): Boolean { + val lastSegment = result.parsedUrl?.pathSegments?.lastOrNull() ?: return true + val extension = lastSegment.substringAfterLast('.', "") + if (extension.isNotEmpty()) { + if (hideImages) { + if (imageExtensions.any { extension.equals(it, ignoreCase = true) }) return false + } + if (hideCss) { + if (cssExtensions.any { extension.equals(it, ignoreCase = true) }) return false + } + if (hideScripts) { + if (scriptsExtensions.any { extension.equals(it, ignoreCase = true) }) return false + } + } + if (hideData) { + if (result.originalUrl.startsWith("data:")) return false + } + return true + } + } +} + 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 561260dc0c..648b0914f1 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 @@ -26,6 +26,9 @@ sealed class SelectorTestEpisodeListResult : RefreshResult { @Immutable data class Success( val channels: List?, + /** + * must distinct by [SelectorTestEpisodePresentation.playUrl] + */ val episodes: List, ) : SelectorTestEpisodeListResult(), RefreshResult.Success 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 5b5edcd445..ab86279d69 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 @@ -51,7 +51,7 @@ fun SelectorTestEpisodeListGrid( verticalItemSpacing = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding, ) { for (episode in episodes) { - item(key = episode) { + item(key = episode.playUrl) { eachItem(episode) } } 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 767c3765a1..73d220f210 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 @@ -134,7 +134,10 @@ fun SharedTransitionScope.SelectorTestPane( } if (state.selectedSubject != null) { - AnimatedContent(state.episodeListSearchSelectResult) { result -> + AnimatedContent( + state.episodeListSearchSelectResult, + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { result -> if (result is SelectorTestEpisodeListResult.Success) { val staggeredGridState = rememberLazyStaggeredGridState() SelectorTestEpisodeListGrid( 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 c7a46a4cf0..4c7e418f5b 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 @@ -15,6 +15,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.util.fastDistinctBy import kotlinx.coroutines.CoroutineScope import me.him188.ani.app.data.models.ApiResponse import me.him188.ani.app.data.models.fold @@ -48,7 +49,7 @@ class SelectorTestState( } } - var selectedSubjectIndex by mutableIntStateOf(-1) + var selectedSubjectIndex by mutableIntStateOf(0) val selectedSubjectState = derivedStateOf { val success = subjectSearchSelectResult as? SelectorTestSearchSubjectResult.Success ?: return@derivedStateOf null @@ -189,9 +190,11 @@ class SelectorTestState( ?: return SelectorTestEpisodeListResult.InvalidConfig SelectorTestEpisodeListResult.Success( episodeList.channels, - episodeList.episodes.map { - SelectorTestEpisodePresentation.compute(it, query, document, config) - }, + episodeList.episodes + .fastDistinctBy { it.playUrl } + .map { + SelectorTestEpisodePresentation.compute(it, query, document, config) + }, ) } catch (e: Throwable) { SelectorTestEpisodeListResult.UnknownError(e)